mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-09 10:16:50 +00:00
Merge branch 'feat/plugin-feature' into feature/speed-dial
Adapt the speed dial's ActionRegistry to the collapsed get_plugin_capability(PluginCapabilityId) overload, and restore the script success/skipped status message the dialog lost when its PluginScriptRunner refactor was superseded by ActionRegistry.
This commit is contained in:
@@ -3,10 +3,13 @@ add_executable(${_TEST_NAME}_tests
|
||||
${_TEST_NAME}_tests_main.cpp
|
||||
test_action_source.cpp
|
||||
test_plugin_host_api.cpp
|
||||
test_plugin_capability_config.cpp
|
||||
test_plugin_config.cpp
|
||||
test_plugin_capabilities_in_use.cpp
|
||||
test_plugin_install.cpp
|
||||
test_plugin_lifecycle.cpp
|
||||
test_slicing_pipeline_bindings.cpp
|
||||
test_slicing_pipeline_params.cpp
|
||||
test_slicing_pipeline_config.cpp
|
||||
test_plugin_sort.cpp
|
||||
test_plugin_cloud_metadata.cpp
|
||||
../fff_print/test_helpers.cpp
|
||||
|
||||
39
tests/slic3rutils/plugin_test_utils.hpp
Normal file
39
tests/slic3rutils/plugin_test_utils.hpp
Normal file
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
// Point data_dir() at a throwaway directory for the lifetime of a test and
|
||||
// restore the previous value afterwards, so code under test writes into a
|
||||
// disposable tree and tests don't leak state into each other.
|
||||
struct ScopedDataDir
|
||||
{
|
||||
std::string previous;
|
||||
boost::filesystem::path dir;
|
||||
|
||||
explicit ScopedDataDir(const std::string& tag)
|
||||
{
|
||||
namespace fs = boost::filesystem;
|
||||
previous = data_dir();
|
||||
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
|
||||
fs::create_directories(dir);
|
||||
set_data_dir(dir.string());
|
||||
}
|
||||
|
||||
~ScopedDataDir()
|
||||
{
|
||||
set_data_dir(previous);
|
||||
boost::system::error_code ec;
|
||||
boost::filesystem::remove_all(dir, ec);
|
||||
}
|
||||
|
||||
ScopedDataDir(const ScopedDataDir&) = delete;
|
||||
ScopedDataDir& operator=(const ScopedDataDir&) = delete;
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
74
tests/slic3rutils/test_plugin_capabilities_in_use.cpp
Normal file
74
tests/slic3rutils/test_plugin_capabilities_in_use.cpp
Normal file
@@ -0,0 +1,74 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Preset.hpp>
|
||||
#include <libslic3r/PrintConfig.hpp>
|
||||
#include <slic3r/plugin/PluginResolver.hpp>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
// A print preset carrying a "plugins" manifest and the one plugin-backed print option.
|
||||
Preset make_print_preset(const std::vector<std::string>& manifest, const std::vector<std::string>& pipeline)
|
||||
{
|
||||
Preset preset(Preset::TYPE_PRINT, "test-print");
|
||||
const std::unique_ptr<DynamicPrintConfig> defaults(
|
||||
DynamicPrintConfig::new_from_defaults_keys({"plugins", "slicing_pipeline_plugin"}));
|
||||
preset.config = *defaults;
|
||||
preset.config.option<ConfigOptionStrings>("plugins")->values = manifest;
|
||||
preset.config.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values = pipeline;
|
||||
return preset;
|
||||
}
|
||||
|
||||
std::vector<std::string> capability_names(const std::vector<PluginCapabilityRef>& refs)
|
||||
{
|
||||
std::vector<std::string> names;
|
||||
for (const PluginCapabilityRef& ref : refs)
|
||||
names.push_back(ref.capability_name);
|
||||
return names;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("referenced_capabilities keeps only manifest entries an option points at", "[PluginResolver]")
|
||||
{
|
||||
// CapB is declared in the manifest but no option references it, so it is not in use.
|
||||
const Preset preset = make_print_preset({"acme;;CapA", "acme;;CapB"}, {"CapA"});
|
||||
|
||||
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) == std::vector<std::string>{"CapA"});
|
||||
}
|
||||
|
||||
TEST_CASE("referenced_capabilities matches every value of a vector option", "[PluginResolver]")
|
||||
{
|
||||
const Preset preset = make_print_preset({"acme;;CapA", "acme;;CapB", "acme;;CapC"}, {"CapA", "CapC"});
|
||||
|
||||
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) ==
|
||||
std::vector<std::string>{"CapA", "CapC"});
|
||||
}
|
||||
|
||||
TEST_CASE("referenced_capabilities is empty when the manifest is empty", "[PluginResolver]")
|
||||
{
|
||||
const Preset preset = make_print_preset({}, {"CapA"});
|
||||
|
||||
CHECK(referenced_capabilities(Preset::TYPE_PRINT, preset).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("referenced_capabilities ignores untracked preset types", "[PluginResolver]")
|
||||
{
|
||||
Preset preset = make_print_preset({"acme;;CapA"}, {"CapA"});
|
||||
preset.type = Preset::TYPE_SLA_PRINT;
|
||||
|
||||
CHECK(referenced_capabilities(Preset::TYPE_SLA_PRINT, preset).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("referenced_capabilities skips malformed manifest entries", "[PluginResolver]")
|
||||
{
|
||||
// parse_capability_ref rejects entries that are not "name;uuid;capability".
|
||||
const Preset preset = make_print_preset({"garbage", "acme;;CapA"}, {"CapA"});
|
||||
|
||||
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) == std::vector<std::string>{"CapA"});
|
||||
}
|
||||
374
tests/slic3rutils/test_plugin_capability_config.cpp
Normal file
374
tests/slic3rutils/test_plugin_capability_config.cpp
Normal file
@@ -0,0 +1,374 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginConfig.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonPluginBridge.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <pybind11/embed.h>
|
||||
#include <pybind11/pybind11.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace py = pybind11;
|
||||
using namespace Slic3r;
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
void ensure_python_initialized()
|
||||
{
|
||||
// As in test_plugin_host_api.cpp: `orca` is compiled into this binary, so a bare interpreter is
|
||||
// enough and does not need the bundled Python home.
|
||||
if (!Py_IsInitialized()) {
|
||||
static py::scoped_interpreter interpreter;
|
||||
(void) interpreter;
|
||||
}
|
||||
}
|
||||
|
||||
py::module_ import_orca_module()
|
||||
{
|
||||
ensure_python_initialized();
|
||||
(void) PythonPluginBridge::instance(); // force the embedded module registration into the binary
|
||||
return py::module_::import("orca");
|
||||
}
|
||||
|
||||
// Builds a Python capability the way PluginLoader does: the audit identity is stamped on by the
|
||||
// host, never supplied by the plugin, and it scopes every config call to this one capability.
|
||||
py::object make_capability(const std::string& class_name,
|
||||
const std::string& body,
|
||||
const std::string& plugin_key,
|
||||
const std::string& capability_name,
|
||||
PluginCapabilityType type = PluginCapabilityType::Script)
|
||||
{
|
||||
// Import first: it brings the interpreter up, and any py:: object built before it would touch a
|
||||
// Python that does not exist yet.
|
||||
py::module_ orca = import_orca_module();
|
||||
|
||||
py::dict globals;
|
||||
globals["orca"] = orca;
|
||||
|
||||
py::exec("class " + class_name + "(orca.PythonPluginBase):\n" + body, globals);
|
||||
py::object instance = globals[class_name.c_str()]();
|
||||
|
||||
if (!plugin_key.empty()) {
|
||||
auto iface = instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
|
||||
iface->set_audit_plugin_key(plugin_key);
|
||||
iface->set_resolved_identity(capability_name, type);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
std::shared_ptr<PluginCapabilityInterface> as_interface(const py::object& instance)
|
||||
{
|
||||
return instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
|
||||
}
|
||||
|
||||
// The Python API writes through the PluginManager singleton, so that is where assertions read from.
|
||||
PluginConfig& host_config() { return PluginManager::instance().get_config(); }
|
||||
|
||||
// The Python config API speaks JSON text, not dicts; these helpers keep the tests in terms of values.
|
||||
json py_get_config(const py::object& cap) { return json::parse(cap.attr("get_config")().cast<std::string>()); }
|
||||
|
||||
bool py_save_config(const py::object& cap, const json& value) { return cap.attr("save_config")(value.dump()).cast<bool>(); }
|
||||
|
||||
PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, const char* plugin_key)
|
||||
{
|
||||
return {type, name, plugin_key};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]")
|
||||
{
|
||||
py::module_ orca = import_orca_module();
|
||||
REQUIRE(py::hasattr(orca, "PythonPluginBase"));
|
||||
|
||||
py::object base = orca.attr("PythonPluginBase");
|
||||
// Host-provided: every capability has a config, so there is no hook to opt out of being
|
||||
// configurable.
|
||||
CHECK(py::hasattr(base, "get_config"));
|
||||
CHECK(py::hasattr(base, "save_config"));
|
||||
CHECK(py::hasattr(base, "get_config_version"));
|
||||
// Plugin-provided (the host calls these). All optional.
|
||||
CHECK(py::hasattr(base, "has_config_ui"));
|
||||
CHECK(py::hasattr(base, "get_config_ui"));
|
||||
CHECK(py::hasattr(base, "get_default_config"));
|
||||
|
||||
// Config is reached only through the capability, never as a free orca.config.* function, so a
|
||||
// capability cannot name — and cannot touch — a config that is not its own.
|
||||
CHECK_FALSE(py::hasattr(orca, "config"));
|
||||
}
|
||||
|
||||
TEST_CASE("get_config returns only cap_config and save_config persists it", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-roundtrip");
|
||||
host_config().load(); // reset the singleton's in-memory store against the empty temp dir
|
||||
|
||||
py::object cap = make_capability("RoundTripCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
||||
|
||||
// Nothing stored yet: the JSON text of an empty object, not None, so a plugin can json.loads() it
|
||||
// unconditionally.
|
||||
py::object initial = cap.attr("get_config")();
|
||||
REQUIRE(py::isinstance<py::str>(initial));
|
||||
CHECK(json::parse(initial.cast<std::string>()) == json::object());
|
||||
CHECK(cap.attr("get_config_version")().cast<std::string>().empty());
|
||||
|
||||
REQUIRE(py_save_config(cap, json{{"speed", 5}, {"name", "fast"}}));
|
||||
|
||||
const auto stored = host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"));
|
||||
REQUIRE(stored);
|
||||
CHECK(stored->id == capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"));
|
||||
CHECK(stored->config == json{{"speed", 5}, {"name", "fast"}});
|
||||
|
||||
// Python reads back exactly cap_config — no host metadata.
|
||||
const json reloaded = py_get_config(cap);
|
||||
CHECK(reloaded.size() == 2);
|
||||
CHECK(reloaded.contains("speed"));
|
||||
CHECK_FALSE(reloaded.contains("plugin_key"));
|
||||
CHECK_FALSE(reloaded.contains("capability"));
|
||||
CHECK_FALSE(reloaded.contains("cap_config"));
|
||||
CHECK_FALSE(reloaded.contains("plugin_version"));
|
||||
}
|
||||
|
||||
TEST_CASE("save_config rejects a string that is not valid JSON", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-badjson");
|
||||
host_config().load();
|
||||
|
||||
py::object cap = make_capability("BadJsonCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
||||
|
||||
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
|
||||
|
||||
// Refusing unparseable text must leave the previously stored config alone.
|
||||
CHECK_FALSE(cap.attr("save_config")("{not json").cast<bool>());
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"keep", "me"}});
|
||||
}
|
||||
|
||||
TEST_CASE("Saving one capability's config does not touch another's", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-isolation");
|
||||
host_config().load();
|
||||
|
||||
const std::string body = " def get_name(self): return 'cap'\n";
|
||||
// Same capability name under two plugins, plus a second capability of plugin_a: each addresses
|
||||
// only the entry matching its own stamped identity.
|
||||
py::object a_cap1 = make_capability("IsoCapA1", body, "plugin_a", "cap_a");
|
||||
py::object a_cap2 = make_capability("IsoCapA2", body, "plugin_a", "cap_b");
|
||||
py::object b_cap1 = make_capability("IsoCapB1", body, "plugin_b", "cap_a");
|
||||
|
||||
REQUIRE(py_save_config(a_cap1, json{{"value", 1}}));
|
||||
REQUIRE(py_save_config(a_cap2, json{{"value", 2}}));
|
||||
REQUIRE(py_save_config(b_cap1, json{{"value", 3}}));
|
||||
|
||||
REQUIRE(py_save_config(a_cap1, json{{"value", 99}}));
|
||||
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"value", 99}});
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_b", "plugin_a"))->config == json{{"value", 2}});
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->config == json{{"value", 3}});
|
||||
|
||||
// A shared name under one plugin must remain isolated when the capability type differs.
|
||||
py::object importer = make_capability("IsoCapImporter", body, "plugin_a", "cap_a", PluginCapabilityType::Importer);
|
||||
REQUIRE(py_save_config(importer, json{{"value", 4}}));
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"value", 99}});
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Importer, "cap_a", "plugin_a"))->config == json{{"value", 4}});
|
||||
|
||||
host_config().load();
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"value", 99}});
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Importer, "cap_a", "plugin_a"))->config == json{{"value", 4}});
|
||||
|
||||
CHECK(py_get_config(a_cap2).at("value") == 2);
|
||||
CHECK(py_get_config(b_cap1).at("value") == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("Config API refuses a capability the host never materialized", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-unowned");
|
||||
host_config().load();
|
||||
|
||||
// No audit identity: never loaded by the host, so it has no config to address. Refused rather
|
||||
// than served from, or written to, some arbitrary entry.
|
||||
py::object orphan = make_capability("OrphanCap", " def get_name(self): return 'cap'\n", "", "");
|
||||
|
||||
CHECK_THROWS(orphan.attr("get_config")());
|
||||
CHECK_THROWS(orphan.attr("get_config_version")());
|
||||
CHECK_THROWS(orphan.attr("save_config")(json::object().dump()));
|
||||
}
|
||||
|
||||
TEST_CASE("Custom config UI hooks dispatch to the Python override", "[PluginConfig][Python]")
|
||||
{
|
||||
py::object cap = make_capability("CustomUiCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def has_config_ui(self): return True\n"
|
||||
" def get_config_ui(self): return '<p>hello</p>'\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
CHECK(iface->has_config_ui());
|
||||
CHECK(iface->get_config_ui() == "<p>hello</p>");
|
||||
}
|
||||
|
||||
TEST_CASE("A capability that omits the config UI hooks gets the default editor", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-bare");
|
||||
host_config().load();
|
||||
|
||||
// Both hooks are optional and only choose the editor: a capability that overrides neither is
|
||||
// still configurable, it just gets the host's JSON editor. There is no way to opt out.
|
||||
py::object bare = make_capability("BareCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(bare);
|
||||
REQUIRE(iface);
|
||||
CHECK_FALSE(iface->has_config_ui()); // -> default JSON editor
|
||||
CHECK(iface->get_config_ui().empty());
|
||||
|
||||
REQUIRE(py_save_config(bare, json{{"speed", 5}}));
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"speed", 5}});
|
||||
}
|
||||
|
||||
TEST_CASE("get_default_config supplies the value Restore defaults writes back", "[PluginConfig][Python]")
|
||||
{
|
||||
SECTION("not overridden -> an empty config")
|
||||
{
|
||||
// Already "restore defaults" for a capability that keeps its stored config sparse and applies
|
||||
// its own defaults on read.
|
||||
py::object bare = make_capability("NoDefaultsCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(bare);
|
||||
REQUIRE(iface);
|
||||
CHECK(iface->get_default_config() == json::object());
|
||||
}
|
||||
|
||||
SECTION("overridden -> exactly what the plugin returns")
|
||||
{
|
||||
py::object cap = make_capability("DefaultsCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def get_default_config(self):\n"
|
||||
" return {'speed': 5, 'nested': {'on': True}, 'items': [1, 2]}\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
// Round-trips through py_to_json untouched: the host does not reshape or validate it.
|
||||
CHECK(iface->get_default_config() == json{{"speed", 5}, {"nested", {{"on", true}}}, {"items", {1, 2}}});
|
||||
}
|
||||
|
||||
SECTION("overridden but returns None -> an empty config, never a null")
|
||||
{
|
||||
// `def get_default_config(self): pass` is the easy mistake, and it must not store
|
||||
// "cap_config": null.
|
||||
py::object cap = make_capability("NoneDefaultsCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def get_default_config(self): pass\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
|
||||
const json restored = iface->get_default_config();
|
||||
CHECK(restored == json::object());
|
||||
CHECK_FALSE(restored.is_null());
|
||||
}
|
||||
|
||||
SECTION("overridden but returns a non-object -> an empty config")
|
||||
{
|
||||
py::object cap = make_capability("ScalarDefaultsCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def get_default_config(self): return [1, 2, 3]\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
CHECK(iface->get_default_config() == json::object());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-restore");
|
||||
host_config().load();
|
||||
|
||||
const std::string defaults_body = " def get_name(self): return 'cap'\n"
|
||||
" def get_default_config(self): return {'speed': 1}\n";
|
||||
py::object target = make_capability("RestoreTargetCap", defaults_body, "plugin_a", "cap_a");
|
||||
py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a");
|
||||
|
||||
const json edited = json{{"speed", 99}};
|
||||
REQUIRE(py_save_config(target, edited));
|
||||
REQUIRE(py_save_config(bystander, edited));
|
||||
|
||||
// What PluginsDialog::restore_capability_config does: ask the capability, store the answer.
|
||||
auto iface = as_interface(target);
|
||||
REQUIRE(host_config().store_capability_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), iface->get_default_config()));
|
||||
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"speed", 1}});
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->config == json{{"speed", 99}});
|
||||
}
|
||||
|
||||
TEST_CASE("A raising get_default_config leaves the stored config untouched", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-restore-raise");
|
||||
host_config().load();
|
||||
|
||||
py::object cap = make_capability("RaisingDefaultsCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def get_default_config(self): raise RuntimeError('boom')\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
CHECK_THROWS_AS(iface->get_default_config(), py::error_already_set);
|
||||
|
||||
// The dialog stores nothing when the hook throws: a broken plugin must not wipe user settings.
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"keep", "me"}});
|
||||
}
|
||||
|
||||
TEST_CASE("A raising config UI hook surfaces as an exception the host can catch", "[PluginConfig][Python]")
|
||||
{
|
||||
py::object cap = make_capability("RaisingCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def has_config_ui(self): return True\n"
|
||||
" def get_config_ui(self): raise RuntimeError('boom')\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
|
||||
// The trampoline rethrows; callers catch it and fall back to the default JSON editor.
|
||||
CHECK_THROWS_AS(iface->get_config_ui(), py::error_already_set);
|
||||
|
||||
// Catching it leaves the interpreter usable.
|
||||
CHECK(iface->get_name() == "cap_a");
|
||||
}
|
||||
|
||||
TEST_CASE("A config UI hook returning the wrong type does not crash the host", "[PluginConfig][Python]")
|
||||
{
|
||||
// has_config_ui() is plugin-authored, so it can return anything; the host must survive the call.
|
||||
py::object cap = make_capability("BadTypeCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def has_config_ui(self): return 'not a bool'\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
|
||||
// Deliberately not REQUIRE_THROWS: pybind may coerce or reject the value, and both are fine.
|
||||
// What must hold is that the call is survivable — PluginLoader's guard turns a throw into
|
||||
// "no custom UI".
|
||||
try {
|
||||
(void) iface->has_config_ui();
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
|
||||
// The capability is still usable afterwards.
|
||||
CHECK(iface->get_name() == "cap_a");
|
||||
CHECK(iface->get_config_ui().empty());
|
||||
}
|
||||
@@ -1,46 +1,27 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginConfig.hpp>
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
// Point data_dir() at a throwaway directory for the lifetime of a test and restore the previous
|
||||
// value afterwards (same pattern as test_plugin_lifecycle.cpp).
|
||||
struct ScopedDataDir
|
||||
{
|
||||
std::string previous;
|
||||
fs::path dir;
|
||||
|
||||
explicit ScopedDataDir(const std::string& tag)
|
||||
{
|
||||
previous = data_dir();
|
||||
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
|
||||
fs::create_directories(dir);
|
||||
set_data_dir(dir.string());
|
||||
}
|
||||
|
||||
~ScopedDataDir()
|
||||
{
|
||||
set_data_dir(previous);
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(dir, ec);
|
||||
}
|
||||
|
||||
fs::path plugins_dir() const { return dir / "orca_plugins"; }
|
||||
};
|
||||
|
||||
// Shut both singletons down while boost::log is still alive; left to their static
|
||||
// destructors, shutdown()'s logging runs after boost::log tears down its thread-local
|
||||
// storage and crashes the process on exit (same reason ScopedPluginManager exists in
|
||||
@@ -55,46 +36,46 @@ struct ScopedManagerShutdown
|
||||
}
|
||||
};
|
||||
|
||||
// A plugin whose per-plugin settings live in the PEP-723 header — the only place they exist.
|
||||
const char* const SETTINGS_PLUGIN_SOURCE = R"PY(# /// script
|
||||
const char* const CLOUD_PLUGIN_SOURCE = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Settings Cloud Plugin"
|
||||
# name = "Config Cloud Plugin"
|
||||
# type = "slicing-pipeline"
|
||||
# version = "1.0"
|
||||
#
|
||||
# [tool.orcaslicer.plugin.settings]
|
||||
# twist_deg_per_mm = "1.0"
|
||||
# taper_per_mm = "0.0"
|
||||
# ///
|
||||
print('ok')
|
||||
)PY";
|
||||
|
||||
} // namespace
|
||||
|
||||
// Regression: update_cloud_metadata() replaces a matched entry's descriptor with the cloud
|
||||
// catalog record. Cloud records never carry [tool.orcaslicer.plugin.settings] (it exists only
|
||||
// in the local package's PEP-723 header, parsed at discovery), so the merge must preserve the
|
||||
// locally-parsed settings. When it does not, get_plugin_settings() serves an empty map and
|
||||
// plugins silently fall back to their built-in defaults (found via Twistify running with its
|
||||
// demo defaults instead of the header values, 2026-07-17).
|
||||
TEST_CASE("cloud metadata refresh preserves locally-parsed plugin settings", "[PluginCloudMetadata]")
|
||||
// Regression: update_cloud_metadata() replaces a matched entry's descriptor wholesale with the
|
||||
// cloud catalog record (`entry = cloud_entry`). Configuration used to ride on the descriptor, so
|
||||
// that overwrite silently wiped it and plugins fell back to their built-in defaults (found via
|
||||
// Twistify running with its demo defaults instead of the configured values, 2026-07-17).
|
||||
// Configuration now lives in PluginConfig, keyed by the capability identity and kept off the
|
||||
// descriptor entirely, so the merge cannot reach it. This asserts that end to end: a stored
|
||||
// config survives the same refresh path, while the descriptor fields the refresh owns do update.
|
||||
//
|
||||
// The capability need not exist for this to be meaningful: what is pinned is the architectural
|
||||
// invariant that config never rides on the descriptor again. Anyone reintroducing it there, or
|
||||
// adding a cloud-refresh path that prunes config, fails here.
|
||||
TEST_CASE("cloud metadata refresh preserves a plugin's stored config", "[PluginCloudMetadata]")
|
||||
{
|
||||
ScopedManagerShutdown manager_shutdown_guard; // declared first: destroyed last
|
||||
ScopedDataDir data_dir_guard("cloud-meta-settings");
|
||||
ScopedDataDir data_dir_guard("cloud-meta-config");
|
||||
|
||||
// A locally-installed cloud plugin: package .py with a settings header, plus an
|
||||
// install-state sidecar carrying the cloud identity.
|
||||
// A locally-installed cloud plugin: package .py plus an install-state sidecar carrying the
|
||||
// cloud identity. Discovery derives plugin_key from the cloud UUID.
|
||||
const std::string uuid = "11111111-2222-3333-4444-555555555555";
|
||||
const fs::path plugin_dir = data_dir_guard.plugins_dir() / uuid;
|
||||
const fs::path plugin_dir = fs::path(get_orca_plugins_dir()) / uuid;
|
||||
fs::create_directories(plugin_dir);
|
||||
{
|
||||
std::ofstream out((plugin_dir / "cloud_plugin-test.py").string(), std::ios::binary);
|
||||
out << SETTINGS_PLUGIN_SOURCE;
|
||||
out << CLOUD_PLUGIN_SOURCE;
|
||||
}
|
||||
PluginDescriptor sidecar;
|
||||
sidecar.name = "Settings Cloud Plugin";
|
||||
sidecar.name = "Config Cloud Plugin";
|
||||
sidecar.installed_version = "1.0";
|
||||
sidecar.cloud = CloudPluginState{uuid, true, false, false, false};
|
||||
REQUIRE(write_install_state(plugin_dir, sidecar));
|
||||
@@ -109,25 +90,38 @@ TEST_CASE("cloud metadata refresh preserves locally-parsed plugin settings", "[P
|
||||
return {};
|
||||
};
|
||||
|
||||
// Discovery parsed the header settings (premise).
|
||||
PluginDescriptor discovered = find_by_uuid();
|
||||
REQUIRE(discovered.settings.count("twist_deg_per_mm") == 1);
|
||||
CHECK(discovered.settings.at("twist_deg_per_mm") == "1.0");
|
||||
const PluginDescriptor discovered = find_by_uuid();
|
||||
REQUIRE(discovered.plugin_key == uuid);
|
||||
REQUIRE(discovered.version == "1.0");
|
||||
|
||||
// A cloud catalog refresh for the same plugin: the record knows name/version/uuid but has
|
||||
// no settings, no local paths.
|
||||
// The user has configured the plugin's capability (premise).
|
||||
const PluginCapabilityId id{PluginCapabilityType::SlicingPipeline, "Twist", uuid};
|
||||
const json configured{{"twist_deg_per_mm", 1.0}, {"taper_per_mm", 0.0}};
|
||||
REQUIRE(manager.get_config().store_capability_config(id, configured));
|
||||
|
||||
// A cloud catalog refresh for the same plugin: the record knows name/version/uuid and knows
|
||||
// nothing about local config or local paths.
|
||||
PluginDescriptor cloud_record;
|
||||
cloud_record.name = "Settings Cloud Plugin";
|
||||
cloud_record.name = "Config Cloud Plugin";
|
||||
cloud_record.plugin_key = uuid;
|
||||
cloud_record.version = "1.1";
|
||||
cloud_record.cloud = CloudPluginState{uuid, false, false, false, false};
|
||||
manager.update_cloud_metadata({cloud_record});
|
||||
|
||||
// Cloud metadata landed on the descriptor...
|
||||
const PluginDescriptor refreshed = find_by_uuid();
|
||||
// Cloud metadata landed...
|
||||
CHECK(refreshed.version == "1.1");
|
||||
// ...and the locally-parsed settings survived the merge.
|
||||
REQUIRE(refreshed.settings.count("twist_deg_per_mm") == 1);
|
||||
CHECK(refreshed.settings.at("twist_deg_per_mm") == "1.0");
|
||||
CHECK(refreshed.settings.count("taper_per_mm") == 1);
|
||||
CHECK(refreshed.plugin_key == uuid);
|
||||
CHECK(refreshed.installed_version == "1.0");
|
||||
|
||||
// ...and the stored config is untouched, both in the live store...
|
||||
const auto stored = manager.get_config().get_config(id);
|
||||
REQUIRE(stored);
|
||||
CHECK(stored->config == configured);
|
||||
|
||||
// ...and on disk, which is what the next run reads back.
|
||||
PluginConfig reloaded;
|
||||
reloaded.load();
|
||||
REQUIRE(reloaded.has_config(id));
|
||||
CHECK(reloaded.get_config(id)->config == configured);
|
||||
}
|
||||
|
||||
264
tests/slic3rutils/test_plugin_config.cpp
Normal file
264
tests/slic3rutils/test_plugin_config.cpp
Normal file
@@ -0,0 +1,264 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginConfig.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/nowide/fstream.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, const char* plugin_key)
|
||||
{
|
||||
return {type, name, plugin_key};
|
||||
}
|
||||
|
||||
json read_config_file()
|
||||
{
|
||||
boost::nowide::ifstream ifs(PluginConfig::plugin_config_file().c_str());
|
||||
json root;
|
||||
ifs >> root;
|
||||
return root;
|
||||
}
|
||||
|
||||
void write_config_file(const std::string& contents)
|
||||
{
|
||||
const fs::path path(PluginConfig::plugin_config_file());
|
||||
fs::create_directories(path.parent_path());
|
||||
boost::nowide::ofstream ofs(path.string().c_str(), std::ios::out | std::ios::trunc);
|
||||
ofs << contents;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("PluginConfig creates, reads back and persists a capability config", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-roundtrip");
|
||||
|
||||
PluginConfig config;
|
||||
const PluginCapabilityId id = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
|
||||
|
||||
// A capability nobody has configured yet reads as an empty record rather than throwing.
|
||||
CHECK_FALSE(config.has_config(id));
|
||||
CHECK_FALSE(config.get_config(id));
|
||||
|
||||
REQUIRE(config.store_capability_config(id, json{{"speed", 5}}));
|
||||
|
||||
const auto stored = config.get_config(id);
|
||||
REQUIRE(stored);
|
||||
CHECK(stored->id == id);
|
||||
CHECK(stored->config == json{{"speed", 5}});
|
||||
CHECK(config.has_config(id));
|
||||
|
||||
// store_capability_config writes through, so a fresh instance (a restart, in effect) sees it.
|
||||
PluginConfig reloaded;
|
||||
reloaded.load();
|
||||
CHECK(reloaded.get_config(id)->config == json{{"speed", 5}});
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig updates only the target capability's cap_config", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-isolation");
|
||||
|
||||
PluginConfig config;
|
||||
const PluginCapabilityId a_a = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
|
||||
const PluginCapabilityId a_b = capability_id(PluginCapabilityType::Script, "cap_b", "plugin_a");
|
||||
const PluginCapabilityId b_a = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b");
|
||||
// The identity is the full (type, capability, plugin_key) tuple, so all three below are separate records.
|
||||
REQUIRE(config.store_capability_config(a_a, json{{"value", 1}}));
|
||||
REQUIRE(config.store_capability_config(a_b, json{{"value", 2}}));
|
||||
REQUIRE(config.store_capability_config(b_a, json{{"value", 3}}));
|
||||
|
||||
REQUIRE(config.store_capability_config(a_a, json{{"value", 99}}));
|
||||
|
||||
CHECK(config.get_config(a_a)->config == json{{"value", 99}});
|
||||
CHECK(config.get_config(a_b)->config == json{{"value", 2}});
|
||||
CHECK(config.get_config(b_a)->config == json{{"value", 3}});
|
||||
|
||||
// The same holds on disk, not just in memory.
|
||||
PluginConfig reloaded;
|
||||
reloaded.load();
|
||||
CHECK(reloaded.get_config(a_a)->config == json{{"value", 99}});
|
||||
CHECK(reloaded.get_config(a_b)->config == json{{"value", 2}});
|
||||
CHECK(reloaded.get_config(b_a)->config == json{{"value", 3}});
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig isolates same-name capabilities by type", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-type-isolation");
|
||||
const PluginCapabilityId script = capability_id(PluginCapabilityType::Script, "shared", "plugin_a");
|
||||
const PluginCapabilityId importer = capability_id(PluginCapabilityType::Importer, "shared", "plugin_a");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE(config.store_capability_config(script, json{{"value", "script"}}));
|
||||
REQUIRE(config.store_capability_config(importer, json{{"value", "importer"}}));
|
||||
CHECK(config.get_config(script)->config == json{{"value", "script"}});
|
||||
CHECK(config.get_config(importer)->config == json{{"value", "importer"}});
|
||||
|
||||
PluginConfig reloaded;
|
||||
reloaded.load();
|
||||
CHECK(reloaded.get_config(script)->config == json{{"value", "script"}});
|
||||
CHECK(reloaded.get_config(importer)->config == json{{"value", "importer"}});
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig serializes the documented on-disk schema", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-schema");
|
||||
|
||||
PluginConfig config;
|
||||
const PluginCapabilityId id = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
|
||||
REQUIRE(config.store_capability_config(id, json{{"speed", 5}}));
|
||||
|
||||
// Locks the field names: an existing config.json must keep loading after any future change.
|
||||
const json root = read_config_file();
|
||||
REQUIRE(root.contains("config"));
|
||||
REQUIRE(root.at("config").is_array());
|
||||
REQUIRE(root.at("config").size() == 1);
|
||||
|
||||
const json& entry = root.at("config").front();
|
||||
CHECK(entry.at("plugin_key") == "plugin_a");
|
||||
CHECK(entry.at("capability") == "cap_a");
|
||||
CHECK(entry.at("capability_type") == "script");
|
||||
CHECK(entry.at("cap_config") == json{{"speed", 5}});
|
||||
CHECK(entry.contains("plugin_version"));
|
||||
// Only cap_config is user data; the rest of the record is host-managed.
|
||||
CHECK(entry.size() == 5);
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig keeps a capability's config after its plugin goes away", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-retention");
|
||||
|
||||
{
|
||||
PluginConfig config;
|
||||
REQUIRE(config.store_capability_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), json{{"token", "keep me"}}));
|
||||
}
|
||||
|
||||
// config.json is deliberately not keyed to installed plugins: a record outlives its plugin and is
|
||||
// still there on reinstall. Asserts no cleanup path silently drops it.
|
||||
PluginConfig after_removal;
|
||||
after_removal.load();
|
||||
CHECK(after_removal.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"token", "keep me"}});
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig treats a missing config file as an empty store", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-missing");
|
||||
|
||||
REQUIRE_FALSE(fs::exists(PluginConfig::plugin_config_file()));
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load());
|
||||
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a")));
|
||||
CHECK_FALSE(config.dirty());
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig survives a malformed config file", "[PluginConfig]")
|
||||
{
|
||||
SECTION("not JSON at all")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-garbage");
|
||||
write_config_file("this is not json {{{");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load()); // a bad config must not block startup
|
||||
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a")));
|
||||
}
|
||||
|
||||
SECTION("valid JSON without the entries array")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-noarray");
|
||||
write_config_file(R"({"config": {"not": "an array"}})");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load());
|
||||
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a")));
|
||||
}
|
||||
|
||||
SECTION("entries without an identity are skipped, the rest still load")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-partial");
|
||||
write_config_file(R"({"config": [
|
||||
{"cap_config": {"orphan": true}},
|
||||
{"plugin_key": "plugin_a", "capability": "cap_a", "capability_type": "script", "plugin_version": "1.0.0", "cap_config": {"kept": true}}
|
||||
]})");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load());
|
||||
CHECK(config.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"kept", true}});
|
||||
CHECK(config.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->plugin_version == "1.0.0");
|
||||
}
|
||||
|
||||
SECTION("an entry with no cap_config reads as an empty object")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-nocap");
|
||||
write_config_file(R"({"config": [
|
||||
{"plugin_key": "plugin_a", "capability": "cap_a", "capability_type": "script", "plugin_version": "1.0.0"}
|
||||
]})");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load());
|
||||
REQUIRE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a")));
|
||||
CHECK(config.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json::object());
|
||||
}
|
||||
|
||||
SECTION("legacy entries without capability_type remain addressable")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-notype");
|
||||
write_config_file(R"({"config": [
|
||||
{"plugin_key": "plugin_a", "capability": "cap_a", "plugin_version": "1.0.0", "cap_config": {"old": true}}
|
||||
]})");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load());
|
||||
const auto id = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
|
||||
REQUIRE(config.has_config(id));
|
||||
CHECK(config.get_config(id)->config == json{{"old", true}});
|
||||
|
||||
REQUIRE(config.store_capability_config(id, json{{"migrated", true}}));
|
||||
const json root = read_config_file();
|
||||
REQUIRE(root.at("config").size() == 1);
|
||||
CHECK(root.at("config").front().at("capability_type") == "script");
|
||||
CHECK(root.at("config").front().at("cap_config") == json{{"migrated", true}});
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig refuses to store a record without an identity", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-identity");
|
||||
|
||||
PluginConfig config;
|
||||
config.save_config(CapabilityConfigEntry{capability_id(PluginCapabilityType::Script, "cap_a", ""), "1.0.0", json::object()});
|
||||
config.save_config(CapabilityConfigEntry{capability_id(PluginCapabilityType::Script, "", "plugin_a"), "1.0.0", json::object()});
|
||||
|
||||
// Neither could ever be looked up again, so neither is kept.
|
||||
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "")));
|
||||
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "", "plugin_a")));
|
||||
CHECK_FALSE(config.dirty());
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig preserves unknown keys inside cap_config", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-unknown");
|
||||
|
||||
// The host never interprets cap_config, so a nested/odd shape must round-trip untouched.
|
||||
const json nested = json{{"nested", {{"deep", json::array({1, 2, 3})}}}, {"flag", false}, {"name", "x"}};
|
||||
|
||||
PluginConfig config;
|
||||
const PluginCapabilityId id = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
|
||||
REQUIRE(config.store_capability_config(id, nested));
|
||||
|
||||
PluginConfig reloaded;
|
||||
reloaded.load();
|
||||
CHECK(reloaded.get_config(id)->config == nested);
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include <fstream>
|
||||
@@ -15,30 +17,6 @@ namespace fs = boost::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// Point data_dir() at a throwaway directory for the lifetime of a test and
|
||||
// restore the previous value afterwards, so install_plugin() writes into a
|
||||
// disposable tree and tests don't leak state into each other.
|
||||
struct ScopedDataDir
|
||||
{
|
||||
std::string previous;
|
||||
fs::path dir;
|
||||
|
||||
explicit ScopedDataDir(const std::string& tag)
|
||||
{
|
||||
previous = data_dir();
|
||||
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
|
||||
fs::create_directories(dir);
|
||||
set_data_dir(dir.string());
|
||||
}
|
||||
|
||||
~ScopedDataDir()
|
||||
{
|
||||
set_data_dir(previous);
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(dir, ec);
|
||||
}
|
||||
};
|
||||
|
||||
fs::path write_py_file(const fs::path& dir, const std::string& filename, const std::string& contents)
|
||||
{
|
||||
fs::create_directories(dir);
|
||||
@@ -142,38 +120,4 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins
|
||||
scanned.version = "1.0.0"; // as parsed from the unchanged PEP723 header
|
||||
read_install_state(plugin_dir, scanned);
|
||||
CHECK(scanned.installed_version == "1.2.0");
|
||||
}
|
||||
|
||||
TEST_CASE("install_plugin parses [tool.orcaslicer.plugin.settings] into descriptor.settings", "[PluginInstall]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-settings");
|
||||
|
||||
// A PEP-723 header with a per-plugin settings sub-table. Values stay strings; the plugin
|
||||
// parses what it needs (ctx.params). This is the source Twistify reads its knobs from.
|
||||
const std::string contents =
|
||||
"# /// script\n"
|
||||
"# requires-python = \">=3.12\"\n"
|
||||
"#\n"
|
||||
"# [tool.orcaslicer.plugin]\n"
|
||||
"# name = \"Settings Plugin\"\n"
|
||||
"# type = \"slicing-pipeline\"\n"
|
||||
"#\n"
|
||||
"# [tool.orcaslicer.plugin.settings]\n"
|
||||
"# twist_deg_per_mm = \"1.5\"\n"
|
||||
"# taper_per_mm = \"-0.004\"\n"
|
||||
"# ///\n"
|
||||
"print('ok')\n";
|
||||
const fs::path py = write_py_file(data_dir_guard.dir / "src", "settings.py", contents);
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
std::string error;
|
||||
const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"", descriptor, error);
|
||||
|
||||
REQUIRE(installed);
|
||||
CHECK(error.empty());
|
||||
REQUIRE(descriptor.settings.count("twist_deg_per_mm") == 1);
|
||||
CHECK(descriptor.settings.at("twist_deg_per_mm") == "1.5");
|
||||
CHECK(descriptor.settings.at("taper_per_mm") == "-0.004");
|
||||
// Identity keys are NOT captured as settings (they belong to [tool.orcaslicer.plugin]).
|
||||
CHECK(descriptor.settings.count("name") == 0);
|
||||
}
|
||||
}
|
||||
@@ -122,9 +122,7 @@ bool load_and_wait(PluginManager& manager,
|
||||
|
||||
std::shared_ptr<PluginCapabilityInterface> find_capability(PluginManager& manager, const std::string& plugin_key,
|
||||
const std::string& name)
|
||||
{
|
||||
return manager.get_plugin_capability(plugin_key, name, PluginCapabilityType::Unknown, /*only_enabled=*/false);
|
||||
}
|
||||
{ return manager.get_plugin_capability({PluginCapabilityType::Unknown, name, plugin_key}, /*only_enabled=*/false); }
|
||||
|
||||
std::vector<std::shared_ptr<PluginCapabilityInterface>> capabilities_of(PluginManager& manager, const std::string& plugin_key)
|
||||
{
|
||||
@@ -173,7 +171,7 @@ TEST_CASE("A discovered script plugin loads and materializes its capability", "[
|
||||
CHECK(echo->is_enabled());
|
||||
CHECK(echo->audit_plugin_key() == "Echo_Plugin");
|
||||
|
||||
CHECK(manager.get_plugin_capability("Echo_Plugin", "Echo", PluginCapabilityType::Script) == echo);
|
||||
CHECK(manager.get_plugin_capability({PluginCapabilityType::Script, "Echo", "Echo_Plugin"}) == echo);
|
||||
|
||||
manager.unload_plugin("Echo_Plugin");
|
||||
}
|
||||
@@ -244,7 +242,7 @@ TEST_CASE("Unloading a plugin drops the package and its capabilities", "[PluginL
|
||||
|
||||
CHECK_FALSE(manager.is_plugin_loaded("Echo_Plugin"));
|
||||
CHECK(manager.get_plugin_capabilities("Echo_Plugin").empty());
|
||||
CHECK(manager.get_plugin_capability("Echo_Plugin", "Echo", PluginCapabilityType::Script) == nullptr);
|
||||
CHECK(manager.get_plugin_capability({PluginCapabilityType::Script, "Echo", "Echo_Plugin"}) == nullptr);
|
||||
|
||||
// The package stays discovered, but nothing capability-shaped survives the unload.
|
||||
const PluginDescriptor descriptor = descriptor_of(manager, "Echo_Plugin");
|
||||
@@ -403,7 +401,7 @@ TEST_CASE("Disabling a capability round-trips through the sidecar and survives a
|
||||
REQUIRE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
|
||||
|
||||
// Disabling writes the choice through to .install_state.json.
|
||||
manager.set_capability_enabled("Echo_Plugin", "Echo", false);
|
||||
manager.set_capability_enabled({PluginCapabilityType::Unknown, "Echo", "Echo_Plugin"}, false);
|
||||
CHECK_FALSE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
|
||||
|
||||
PluginInstallState persisted;
|
||||
@@ -440,7 +438,7 @@ TEST_CASE("A capability disabled after load stays disabled when rediscovered and
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
|
||||
manager.set_capability_enabled("Echo_Plugin", "Echo", false);
|
||||
manager.set_capability_enabled({PluginCapabilityType::Unknown, "Echo", "Echo_Plugin"}, false);
|
||||
REQUIRE(manager.unload_plugin("Echo_Plugin"));
|
||||
|
||||
// Rediscover, as the app does when a plugin is toggled off and back on. The enable flags the
|
||||
|
||||
@@ -106,7 +106,7 @@ TEST_CASE("orca.slicing is workflow-only: context exposes raw print/object; view
|
||||
py::object slicing = orca.attr("slicing");
|
||||
|
||||
// Context surface: raw graph entry points + workflow accessors.
|
||||
for (const char* name : { "print", "object", "params", "config_value", "cancelled",
|
||||
for (const char* name : { "print", "object", "config_value", "cancelled",
|
||||
"orca_version", "step" })
|
||||
CHECK(py::hasattr(slicing.attr("SlicingPipelineContext"), name));
|
||||
|
||||
|
||||
153
tests/slic3rutils/test_slicing_pipeline_config.cpp
Normal file
153
tests/slic3rutils/test_slicing_pipeline_config.cpp
Normal file
@@ -0,0 +1,153 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginConfig.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
|
||||
#include "fff_print/test_helpers.hpp"
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <pybind11/embed.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::Test;
|
||||
namespace fs = boost::filesystem;
|
||||
using json = nlohmann::json;
|
||||
|
||||
// End-to-end coverage of a slicing-pipeline capability reading its own config: the loader seeds the
|
||||
// store from the capability's get_default_config() hook, and the real dispatch
|
||||
// (execute_capabilities_from_refs -> hook -> GIL -> trampoline) lets the plugin read back whatever
|
||||
// the host has stored, through self.get_config(). A break anywhere in that chain makes plugins
|
||||
// silently run on their built-in defaults, which is invisible to the plugin author (Twistify
|
||||
// incident, 2026-07-17).
|
||||
//
|
||||
// Note this is deliberately NOT ctx.config_value(): that reads the slicer's print config, not the
|
||||
// plugin's own config.
|
||||
|
||||
namespace {
|
||||
|
||||
struct ScopedPluginManager
|
||||
{
|
||||
bool initialized = false;
|
||||
|
||||
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
|
||||
~ScopedPluginManager()
|
||||
{
|
||||
PluginManager::instance().shutdown();
|
||||
PythonInterpreter::instance().shutdown();
|
||||
}
|
||||
};
|
||||
|
||||
const char* const CONFIG_PROBE_SOURCE = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Config Probe"
|
||||
# description = "Echoes its own config back to the test"
|
||||
# author = "OrcaSlicer"
|
||||
# version = "1.0"
|
||||
# type = "slicing-pipeline"
|
||||
# ///
|
||||
import json
|
||||
|
||||
import orca
|
||||
|
||||
class ConfigEcho(orca.slicing.SlicingPipelineCapabilityBase):
|
||||
def get_name(self):
|
||||
return "ConfigEcho"
|
||||
|
||||
def get_default_config(self):
|
||||
return {"alpha": "1.25", "beta": "hello"}
|
||||
|
||||
def execute(self, ctx):
|
||||
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
|
||||
return orca.ExecutionResult.success()
|
||||
try:
|
||||
text = repr(sorted(json.loads(self.get_config()).items()))
|
||||
except Exception as e: # what plugins' defaults-fallback code swallows silently
|
||||
text = "config-error: " + repr(e)
|
||||
orca._probe_config = text # read back by the test through pybind
|
||||
return orca.ExecutionResult.success("config probed")
|
||||
|
||||
@orca.plugin
|
||||
class ConfigProbePackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(ConfigEcho)
|
||||
)PY";
|
||||
|
||||
fs::path write_plugin(const std::string& stem, const std::string& source)
|
||||
{
|
||||
const fs::path plugin_dir = fs::path(get_orca_plugins_dir()) / stem;
|
||||
fs::create_directories(plugin_dir);
|
||||
|
||||
std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary);
|
||||
out << source;
|
||||
out.close();
|
||||
|
||||
return plugin_dir;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("slicing-pipeline dispatch delivers the stored config to self.get_config()", "[slicing_pipeline][PluginConfig][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("pipeline-config");
|
||||
write_plugin("ConfigProbe", CONFIG_PROBE_SOURCE);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.get_config().load(); // reset the singleton's store against the empty temp data dir
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
manager.load_plugin("ConfigProbe", /*skip_deps=*/true, {});
|
||||
REQUIRE(manager.wait_for_plugin_load("ConfigProbe", std::chrono::seconds(120), error));
|
||||
INFO("load error: " << error);
|
||||
REQUIRE(manager.is_plugin_loaded("ConfigProbe"));
|
||||
|
||||
const PluginCapabilityId id{PluginCapabilityType::SlicingPipeline, "ConfigEcho", "ConfigProbe"};
|
||||
|
||||
// Loading seeds the store from the capability's get_default_config() hook, so a plugin has a
|
||||
// config before anyone has opened the Config tab.
|
||||
const auto seeded = manager.get_config().get_config(id);
|
||||
REQUIRE(seeded);
|
||||
CHECK(seeded->config == json({{"alpha", "1.25"}, {"beta", "hello"}}));
|
||||
|
||||
// What editing the config in the Config tab does: the value the plugin must actually run on.
|
||||
REQUIRE(manager.get_config().store_capability_config(id, json({{"alpha", "9.5"}, {"beta", "hello"}})));
|
||||
|
||||
// Slice with the capability selected, exactly as a preset would reference it.
|
||||
Print print;
|
||||
Model model;
|
||||
auto config = DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new ConfigOptionStrings({"ConfigEcho"}));
|
||||
config.set_key_value("plugins", new ConfigOptionStrings({"ConfigProbe;;ConfigEcho"}));
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
|
||||
std::string observed = "<capability never executed>";
|
||||
{
|
||||
PythonGILState gil;
|
||||
REQUIRE(static_cast<bool>(gil));
|
||||
pybind11::module_ orca = pybind11::module_::import("orca");
|
||||
if (pybind11::hasattr(orca, "_probe_config"))
|
||||
observed = orca.attr("_probe_config").cast<std::string>();
|
||||
}
|
||||
INFO("config observed by Python: " << observed);
|
||||
// The edited value arrived, not the seeded default: the host's store is what reaches the plugin.
|
||||
CHECK(observed.find("'alpha', '9.5'") != std::string::npos);
|
||||
CHECK(observed.find("'beta', 'hello'") != std::string::npos);
|
||||
CHECK(observed.find("1.25") == std::string::npos);
|
||||
|
||||
manager.unload_plugin("ConfigProbe");
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
|
||||
#include "fff_print/test_helpers.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <pybind11/embed.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::Test;
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
// End-to-end coverage of ctx.params for slicing-pipeline capabilities: discovery parses
|
||||
// [tool.orcaslicer.plugin.settings] from the PEP-723 header, and the real dispatch
|
||||
// (execute_capabilities_from_refs -> hook -> GIL -> trampoline) hands it to the plugin.
|
||||
// A break anywhere in that chain makes plugins silently run on their built-in defaults,
|
||||
// which is invisible to the plugin author (Twistify incident, 2026-07-17).
|
||||
|
||||
namespace {
|
||||
|
||||
struct ScopedDataDir
|
||||
{
|
||||
std::string previous;
|
||||
fs::path dir;
|
||||
|
||||
explicit ScopedDataDir(const std::string& tag)
|
||||
{
|
||||
previous = data_dir();
|
||||
// canonical(): the plugin audit canonicalizes its allowed roots, so a path through
|
||||
// the macOS /var -> /private/var symlink would be rejected as "outside allowed root".
|
||||
dir = fs::canonical(fs::temp_directory_path()) / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
|
||||
fs::create_directories(dir);
|
||||
set_data_dir(dir.string());
|
||||
}
|
||||
|
||||
~ScopedDataDir()
|
||||
{
|
||||
set_data_dir(previous);
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(dir, ec);
|
||||
}
|
||||
|
||||
fs::path plugins_dir() const { return dir / "orca_plugins"; }
|
||||
};
|
||||
|
||||
struct ScopedPluginManager
|
||||
{
|
||||
bool initialized = false;
|
||||
|
||||
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
|
||||
~ScopedPluginManager()
|
||||
{
|
||||
PluginManager::instance().shutdown();
|
||||
PythonInterpreter::instance().shutdown();
|
||||
}
|
||||
};
|
||||
|
||||
const char* const PARAM_PROBE_SOURCE = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Param Probe"
|
||||
# description = "Echoes ctx.params back to the test"
|
||||
# author = "OrcaSlicer"
|
||||
# version = "1.0"
|
||||
# type = "slicing-pipeline"
|
||||
#
|
||||
# [tool.orcaslicer.plugin.settings]
|
||||
# alpha = "1.25"
|
||||
# beta = "hello"
|
||||
# ///
|
||||
import orca
|
||||
|
||||
class ParamEcho(orca.slicing.SlicingPipelineCapabilityBase):
|
||||
def get_name(self):
|
||||
return "ParamEcho"
|
||||
|
||||
def execute(self, ctx):
|
||||
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
|
||||
return orca.ExecutionResult.success()
|
||||
try:
|
||||
text = repr(sorted(dict(ctx.params).items()))
|
||||
except Exception as e: # what plugins' defaults-fallback code swallows silently
|
||||
text = "params-error: " + repr(e)
|
||||
orca._probe_params = text # read back by the test through pybind
|
||||
return orca.ExecutionResult.success("params probed")
|
||||
|
||||
@orca.plugin
|
||||
class ParamProbePackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(ParamEcho)
|
||||
)PY";
|
||||
|
||||
fs::path write_plugin(const ScopedDataDir& data_dir_guard, const std::string& stem, const std::string& source)
|
||||
{
|
||||
const fs::path plugin_dir = data_dir_guard.plugins_dir() / stem;
|
||||
fs::create_directories(plugin_dir);
|
||||
|
||||
std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary);
|
||||
out << source;
|
||||
out.close();
|
||||
|
||||
return plugin_dir;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("slicing-pipeline dispatch delivers PEP-723 settings as ctx.params", "[slicing_pipeline][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("pipeline-params");
|
||||
write_plugin(data_dir_guard, "ParamProbe", PARAM_PROBE_SOURCE);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
manager.load_plugin("ParamProbe", /*skip_deps=*/true, {});
|
||||
REQUIRE(manager.wait_for_plugin_load("ParamProbe", std::chrono::seconds(120), error));
|
||||
INFO("load error: " << error);
|
||||
REQUIRE(manager.is_plugin_loaded("ParamProbe"));
|
||||
|
||||
// The manager serves the header settings for the key the dispatch resolves by.
|
||||
const auto settings = manager.get_plugin_settings("ParamProbe");
|
||||
REQUIRE(settings.count("alpha") == 1);
|
||||
CHECK(settings.at("alpha") == "1.25");
|
||||
|
||||
// Slice with the capability selected, exactly as a preset would reference it.
|
||||
Print print;
|
||||
Model model;
|
||||
auto config = DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new ConfigOptionStrings({"ParamEcho"}));
|
||||
config.set_key_value("plugins", new ConfigOptionStrings({"ParamProbe;;ParamEcho"}));
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
|
||||
std::string observed = "<capability never executed>";
|
||||
{
|
||||
PythonGILState gil;
|
||||
REQUIRE(static_cast<bool>(gil));
|
||||
pybind11::module_ orca = pybind11::module_::import("orca");
|
||||
if (pybind11::hasattr(orca, "_probe_params"))
|
||||
observed = orca.attr("_probe_params").cast<std::string>();
|
||||
}
|
||||
INFO("ctx.params observed by Python: " << observed);
|
||||
CHECK(observed.find("'alpha', '1.25'") != std::string::npos);
|
||||
CHECK(observed.find("'beta', 'hello'") != std::string::npos);
|
||||
|
||||
manager.unload_plugin("ParamProbe");
|
||||
}
|
||||
Reference in New Issue
Block a user