mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-15 23:12:08 +00:00
tests
This commit is contained in:
@@ -2,7 +2,9 @@ get_filename_component(_TEST_NAME ${CMAKE_CURRENT_LIST_DIR} NAME)
|
||||
add_executable(${_TEST_NAME}_tests
|
||||
${_TEST_NAME}_tests_main.cpp
|
||||
test_plugin_host_api.cpp
|
||||
test_plugin_capability_config.cpp
|
||||
test_plugin_capability_identifier.cpp
|
||||
test_plugin_config.cpp
|
||||
test_plugin_install.cpp
|
||||
test_plugin_sort.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
|
||||
361
tests/slic3rutils/test_plugin_capability_config.cpp
Normal file
361
tests/slic3rutils/test_plugin_capability_config.cpp
Normal file
@@ -0,0 +1,361 @@
|
||||
#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()
|
||||
{
|
||||
// Same rationale as test_plugin_host_api.cpp: `orca` is an embedded module 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 from `body` and materializes it the way PluginLoader does: the audit
|
||||
// identity is stamped on by the host, never supplied by the plugin, and it is what 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)
|
||||
{
|
||||
// Import first: it is what brings the interpreter up, and constructing any py:: object
|
||||
// beforehand 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_audit_capability_name(capability_name);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
std::shared_ptr<PluginCapabilityInterface> as_interface(const py::object& instance)
|
||||
{
|
||||
return instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
|
||||
}
|
||||
|
||||
// The config the Python API actually writes to: capability_save_config persists through the
|
||||
// PluginManager singleton, so that is where the assertions read from.
|
||||
PluginConfig& host_config() { return PluginManager::instance().get_config(); }
|
||||
|
||||
} // 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 (the capability calls these). Every capability has a config, so these are
|
||||
// always available — there is no hook to opt in or 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 through the capability, never as a free orca.config.* function, so a
|
||||
// capability cannot name — and therefore 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: an empty dict, not None, so a plugin can index it unconditionally.
|
||||
py::object initial = cap.attr("get_config")();
|
||||
REQUIRE(py::isinstance<py::dict>(initial));
|
||||
CHECK(py::len(initial) == 0);
|
||||
CHECK(cap.attr("get_config_version")().cast<std::string>().empty());
|
||||
|
||||
py::dict value;
|
||||
value["speed"] = 5;
|
||||
value["name"] = "fast";
|
||||
REQUIRE(cap.attr("save_config")(value).cast<bool>());
|
||||
|
||||
// Persisted through PluginConfig, under this capability's identity only.
|
||||
const BaseConfig stored = host_config().get_config("plugin_a", "cap_a");
|
||||
REQUIRE_FALSE(stored.empty());
|
||||
CHECK(stored.config == json{{"speed", 5}, {"name", "fast"}});
|
||||
|
||||
// And read back through Python as a dict of exactly cap_config — no host metadata.
|
||||
py::object reloaded = cap.attr("get_config")();
|
||||
REQUIRE(py::isinstance<py::dict>(reloaded));
|
||||
CHECK(py::len(reloaded) == 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("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 different 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");
|
||||
|
||||
py::dict one, two, three;
|
||||
one["value"] = 1;
|
||||
two["value"] = 2;
|
||||
three["value"] = 3;
|
||||
REQUIRE(a_cap1.attr("save_config")(one).cast<bool>());
|
||||
REQUIRE(a_cap2.attr("save_config")(two).cast<bool>());
|
||||
REQUIRE(b_cap1.attr("save_config")(three).cast<bool>());
|
||||
|
||||
py::dict updated;
|
||||
updated["value"] = 99;
|
||||
REQUIRE(a_cap1.attr("save_config")(updated).cast<bool>());
|
||||
|
||||
CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"value", 99}});
|
||||
CHECK(host_config().get_config("plugin_a", "cap_b").config == json{{"value", 2}});
|
||||
CHECK(host_config().get_config("plugin_b", "cap_a").config == json{{"value", 3}});
|
||||
|
||||
// Each capability still reads back its own value.
|
||||
CHECK(a_cap2.attr("get_config")()["value"].cast<int>() == 2);
|
||||
CHECK(b_cap1.attr("get_config")()["value"].cast<int>() == 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: the instance was 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")(py::dict()));
|
||||
}
|
||||
|
||||
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 — so it stays in the Config sidebar
|
||||
// and its config API keeps working. There is no way for a capability to opt out of having one.
|
||||
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());
|
||||
|
||||
py::dict value;
|
||||
value["speed"] = 5;
|
||||
REQUIRE(bare.attr("save_config")(value).cast<bool>());
|
||||
CHECK(host_config().get_config("plugin_a", "cap_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")
|
||||
{
|
||||
// Which is already "restore defaults" for a capability that keeps its stored config sparse
|
||||
// and applies its own defaults on read: clearing the overrides restores them.
|
||||
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. It must not be able to store
|
||||
// "cap_config": null — an unimplemented hook means an empty config, however it is spelled.
|
||||
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");
|
||||
|
||||
py::dict edited;
|
||||
edited["speed"] = 99;
|
||||
REQUIRE(target.attr("save_config")(edited).cast<bool>());
|
||||
REQUIRE(bystander.attr("save_config")(edited).cast<bool>());
|
||||
|
||||
// What PluginsDialog::restore_capability_config does: ask the capability, store the answer.
|
||||
auto iface = as_interface(target);
|
||||
REQUIRE(host_config().store_capability_config("plugin_a", "cap_a", iface->get_default_config()));
|
||||
|
||||
CHECK(host_config().get_config("plugin_a", "cap_a").config == json{{"speed", 1}});
|
||||
// The same capability name under another plugin keeps its edited value.
|
||||
CHECK(host_config().get_config("plugin_b", "cap_a").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");
|
||||
|
||||
py::dict value;
|
||||
value["keep"] = "me";
|
||||
REQUIRE(cap.attr("save_config")(value).cast<bool>());
|
||||
|
||||
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("plugin_a", "cap_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 logs the traceback and rethrows; callers (PluginLoader when caching the flag,
|
||||
// PluginsDialog when opening the Config tab) catch it and fall back to the default JSON editor
|
||||
// instead of crashing.
|
||||
CHECK_THROWS_AS(iface->get_config_ui(), py::error_already_set);
|
||||
|
||||
// Catching it leaves the interpreter usable — the host is still able to talk to the capability.
|
||||
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. Whatever pybind makes of a
|
||||
// non-bool, the host must survive the call: it either converts or throws, never crashes.
|
||||
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 the value or reject it, and both are
|
||||
// acceptable. What must hold is that the call is survivable — a throw is what PluginLoader's
|
||||
// guard turns into "no custom UI".
|
||||
try {
|
||||
(void) iface->has_config_ui();
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
|
||||
// The capability is still usable afterwards: the bad hook cost it nothing but its own answer.
|
||||
CHECK(iface->get_name() == "cap_a");
|
||||
CHECK(iface->get_config_ui().empty());
|
||||
}
|
||||
216
tests/slic3rutils/test_plugin_config.cpp
Normal file
216
tests/slic3rutils/test_plugin_config.cpp
Normal file
@@ -0,0 +1,216 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginConfig.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 {
|
||||
|
||||
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;
|
||||
|
||||
// A capability nobody has configured yet reads as an empty record rather than throwing.
|
||||
CHECK_FALSE(config.has_config("plugin_a", "cap_a"));
|
||||
CHECK(config.get_config("plugin_a", "cap_a").empty());
|
||||
|
||||
REQUIRE(config.store_capability_config("plugin_a", "cap_a", json{{"speed", 5}}));
|
||||
|
||||
const BaseConfig stored = config.get_config("plugin_a", "cap_a");
|
||||
REQUIRE_FALSE(stored.empty());
|
||||
CHECK(stored.plugin_key == "plugin_a");
|
||||
CHECK(stored.capability_name == "cap_a");
|
||||
CHECK(stored.config == json{{"speed", 5}});
|
||||
CHECK(config.has_config("plugin_a", "cap_a"));
|
||||
|
||||
// store_capability_config writes through, so a fresh instance (a restart, in effect) sees it.
|
||||
PluginConfig reloaded;
|
||||
reloaded.load();
|
||||
CHECK(reloaded.get_config("plugin_a", "cap_a").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;
|
||||
// Two capabilities in one plugin, plus a same-named capability in a different plugin: the
|
||||
// identity is the (plugin_key, capability) pair, so all three are separate records.
|
||||
REQUIRE(config.store_capability_config("plugin_a", "cap_a", json{{"value", 1}}));
|
||||
REQUIRE(config.store_capability_config("plugin_a", "cap_b", json{{"value", 2}}));
|
||||
REQUIRE(config.store_capability_config("plugin_b", "cap_a", json{{"value", 3}}));
|
||||
|
||||
REQUIRE(config.store_capability_config("plugin_a", "cap_a", json{{"value", 99}}));
|
||||
|
||||
CHECK(config.get_config("plugin_a", "cap_a").config == json{{"value", 99}});
|
||||
CHECK(config.get_config("plugin_a", "cap_b").config == json{{"value", 2}});
|
||||
CHECK(config.get_config("plugin_b", "cap_a").config == json{{"value", 3}});
|
||||
|
||||
// The same holds on disk, not just in memory.
|
||||
PluginConfig reloaded;
|
||||
reloaded.load();
|
||||
CHECK(reloaded.get_config("plugin_a", "cap_a").config == json{{"value", 99}});
|
||||
CHECK(reloaded.get_config("plugin_a", "cap_b").config == json{{"value", 2}});
|
||||
CHECK(reloaded.get_config("plugin_b", "cap_a").config == json{{"value", 3}});
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig serializes the documented on-disk schema", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-schema");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE(config.store_capability_config("plugin_a", "cap_a", 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("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() == 4);
|
||||
}
|
||||
|
||||
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("plugin_a", "cap_a", json{{"token", "keep me"}}));
|
||||
}
|
||||
|
||||
// Nothing here installs, uninstalls or unsubscribes a plugin: config.json is deliberately not
|
||||
// keyed to installed plugins, so a record outlives the plugin and is still there when the user
|
||||
// reinstalls or resubscribes. This asserts no cleanup path silently drops it.
|
||||
PluginConfig after_removal;
|
||||
after_removal.load();
|
||||
CHECK(after_removal.get_config("plugin_a", "cap_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("plugin_a", "cap_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("plugin_a", "cap_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("plugin_a", "cap_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", "plugin_version": "1.0.0", "cap_config": {"kept": true}}
|
||||
]})");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load());
|
||||
CHECK(config.get_config("plugin_a", "cap_a").config == json{{"kept", true}});
|
||||
CHECK(config.get_config("plugin_a", "cap_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", "plugin_version": "1.0.0"}
|
||||
]})");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load());
|
||||
REQUIRE(config.has_config("plugin_a", "cap_a"));
|
||||
CHECK(config.get_config("plugin_a", "cap_a").config == json::object());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig refuses to store a record without an identity", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-identity");
|
||||
|
||||
PluginConfig config;
|
||||
config.save_config(BaseConfig{"", "cap_a", "1.0.0", json::object()});
|
||||
config.save_config(BaseConfig{"plugin_a", "", "1.0.0", json::object()});
|
||||
|
||||
// Neither could ever be looked up again, so neither is kept.
|
||||
CHECK_FALSE(config.has_config("", "cap_a"));
|
||||
CHECK_FALSE(config.has_config("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;
|
||||
REQUIRE(config.store_capability_config("plugin_a", "cap_a", nested));
|
||||
|
||||
PluginConfig reloaded;
|
||||
reloaded.load();
|
||||
CHECK(reloaded.get_config("plugin_a", "cap_a").config == nested);
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PythonFileUtils.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);
|
||||
|
||||
Reference in New Issue
Block a user