diff --git a/tests/slic3rutils/CMakeLists.txt b/tests/slic3rutils/CMakeLists.txt index 1e62c1a6ba..f0c4570d98 100644 --- a/tests/slic3rutils/CMakeLists.txt +++ b/tests/slic3rutils/CMakeLists.txt @@ -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 ) diff --git a/tests/slic3rutils/plugin_test_utils.hpp b/tests/slic3rutils/plugin_test_utils.hpp new file mode 100644 index 0000000000..52e503f6f4 --- /dev/null +++ b/tests/slic3rutils/plugin_test_utils.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include + +#include + +#include + +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 diff --git a/tests/slic3rutils/test_plugin_capability_config.cpp b/tests/slic3rutils/test_plugin_capability_config.cpp new file mode 100644 index 0000000000..06540257d8 --- /dev/null +++ b/tests/slic3rutils/test_plugin_capability_config.cpp @@ -0,0 +1,361 @@ +#include + +#include +#include +#include +#include +#include + +#include "plugin_test_utils.hpp" + +#include +#include +#include + +#include +#include + +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>(); + iface->set_audit_plugin_key(plugin_key); + iface->set_audit_capability_name(capability_name); + } + return instance; +} + +std::shared_ptr as_interface(const py::object& instance) +{ + return instance.cast>(); +} + +// 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(initial)); + CHECK(py::len(initial) == 0); + CHECK(cap.attr("get_config_version")().cast().empty()); + + py::dict value; + value["speed"] = 5; + value["name"] = "fast"; + REQUIRE(cap.attr("save_config")(value).cast()); + + // 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(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()); + REQUIRE(a_cap2.attr("save_config")(two).cast()); + REQUIRE(b_cap1.attr("save_config")(three).cast()); + + py::dict updated; + updated["value"] = 99; + REQUIRE(a_cap1.attr("save_config")(updated).cast()); + + 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() == 2); + CHECK(b_cap1.attr("get_config")()["value"].cast() == 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 '

hello

'\n", + "plugin_a", "cap_a"); + + auto iface = as_interface(cap); + REQUIRE(iface); + CHECK(iface->has_config_ui()); + CHECK(iface->get_config_ui() == "

hello

"); +} + +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()); + 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()); + REQUIRE(bystander.attr("save_config")(edited).cast()); + + // 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()); + + 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()); +} diff --git a/tests/slic3rutils/test_plugin_config.cpp b/tests/slic3rutils/test_plugin_config.cpp new file mode 100644 index 0000000000..3ed3303882 --- /dev/null +++ b/tests/slic3rutils/test_plugin_config.cpp @@ -0,0 +1,216 @@ +#include + +#include +#include + +#include "plugin_test_utils.hpp" + +#include +#include +#include + +#include + +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); +} diff --git a/tests/slic3rutils/test_plugin_install.cpp b/tests/slic3rutils/test_plugin_install.cpp index 5f48b07179..04b6ff0d19 100644 --- a/tests/slic3rutils/test_plugin_install.cpp +++ b/tests/slic3rutils/test_plugin_install.cpp @@ -5,6 +5,8 @@ #include #include +#include "plugin_test_utils.hpp" + #include #include @@ -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);