From 6e42a9b0ae572b6b57454cd9f18c648d0ac55571 Mon Sep 17 00:00:00 2001 From: Lizard Date: Wed, 14 Oct 2026 09:18:56 +0300 Subject: [PATCH] Hot reload local preset bundles (#15424) --- sandboxes/orca_reload_local_bundle_demo.py | 410 ++++++++++ src/libslic3r/Preset.cpp | 17 +- src/libslic3r/Preset.hpp | 3 +- src/libslic3r/PresetBundle.cpp | 382 +++++++++- src/libslic3r/PresetBundle.hpp | 3 + src/slic3r/plugin/host/PluginHostPresets.cpp | 69 +- src/slic3r/plugin/host/PluginHostUi.cpp | 5 + src/slic3r/plugin/host/PluginHostUi.hpp | 3 + .../libslic3r/test_preset_bundle_loading.cpp | 720 ++++++++++++++++++ tests/slic3rutils/test_plugin_host_api.cpp | 25 + 10 files changed, 1606 insertions(+), 31 deletions(-) create mode 100644 sandboxes/orca_reload_local_bundle_demo.py diff --git a/sandboxes/orca_reload_local_bundle_demo.py b/sandboxes/orca_reload_local_bundle_demo.py new file mode 100644 index 0000000000..5af520049e --- /dev/null +++ b/sandboxes/orca_reload_local_bundle_demo.py @@ -0,0 +1,410 @@ +# /// script +# requires-python = ">=3.12" +# +# [tool.orcaslicer.plugin] +# id = "reload-local-bundle-demo" +# name = "Reload Local Bundle Demo" +# description = "Manages local filament, process, and printer profiles with live bundle reload." +# author = "OrcaSlicer contributors" +# version = "0.1.0" +# /// +"""A small interactive consumer of ``orca.host.preset.bundles.reload_local``.""" + +import json +import os +import tempfile + +import orca + + +BUNDLE_ID = "reload-local-bundle-demo" +STATE_FILE = ".reload-local-bundle-demo-state.json" +DEMO_PRESETS = ( + { + "id": "filament", + "name": "Local Bundle Demo Filament", + "kind": "Filament", + "type": "filament", + "directory": "filament", + "collection": "filaments", + "setting_key": "filament_settings_id", + }, + { + "id": "process", + "name": "Local Bundle Demo Process", + "kind": "Process", + "type": "process", + "directory": "process", + "collection": "prints", + "setting_key": "print_settings_id", + }, + { + "id": "printer", + "name": "Local Bundle Demo Printer", + "kind": "Printer", + "type": "machine", + "directory": "machine", + "collection": "printers", + "setting_key": "printer_settings_id", + }, +) + + +def _data_dir(): + parts = os.path.abspath(__file__).replace("\\", "/").split("/") + if "orca_plugins" not in parts: + raise RuntimeError("Install this file as a local OrcaSlicer plugin") + return "/".join(parts[: parts.index("orca_plugins")]) + + +def _folder_from_preset_file(path): + parts = os.path.normpath(path or "").replace("\\", "/").split("/") + try: + return parts[parts.index("user") + 1] + except (ValueError, IndexError): + return None + + +def _bundle_dir(bundle): + for collection in (bundle.filaments, bundle.prints, bundle.printers): + for index in range(collection.size()): + folder = _folder_from_preset_file(collection.preset(index).file) + if folder: + return os.path.join(_data_dir(), "user", folder, "_local", BUNDLE_ID) + return os.path.join(_data_dir(), "user", "default", "_local", BUNDLE_ID) + + +def _source_path(bundle, item): + return os.path.join(_bundle_dir(bundle), item["directory"], item["name"] + ".json") + + +def _state_path(bundle): + return os.path.join(_bundle_dir(bundle), STATE_FILE) + + +def _write_json_atomic(path, value): + os.makedirs(os.path.dirname(path), exist_ok=True) + descriptor, temporary = tempfile.mkstemp( + prefix="." + os.path.basename(path) + ".", suffix=".tmp", dir=os.path.dirname(path) + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + json.dump(value, stream, ensure_ascii=False, indent=2) + stream.write("\n") + os.replace(temporary, path) + except Exception: + try: + os.unlink(temporary) + except OSError: + pass + raise + + +def _remove_file(path): + try: + os.unlink(path) + except FileNotFoundError: + pass + + +def _read_state(bundle): + try: + with open(_state_path(bundle), "r", encoding="utf-8") as stream: + state = json.load(stream) + except FileNotFoundError: + return {"profiles": {}} + except (OSError, ValueError) as error: + raise RuntimeError("The demo state file cannot be read: %s" % error) + if not isinstance(state, dict) or not isinstance(state.get("profiles"), dict): + raise RuntimeError("The demo state file has an invalid shape") + return state + + +def _profile_state(state, item): + profiles = state["profiles"] + profile = profiles.get(item["id"]) + if profile is None: + profile = {} + profiles[item["id"]] = profile + if not isinstance(profile, dict): + raise RuntimeError("The demo state for %s has an invalid shape" % item["name"]) + profile["enabled"] = profile.get("enabled") is True + profile["deleted"] = profile.get("deleted") is True + if profile["deleted"]: + profile["enabled"] = False + profile["parent_name"] = str(profile.get("parent_name") or "") + return profile + + +def _collection(bundle, item): + return getattr(bundle, item["collection"]) + + +def _target_name(item): + return "_local/%s/%s" % (BUNDLE_ID, item["name"]) + + +def _parent_name(bundle, item): + collection = _collection(bundle, item) + own_names = { + _target_name(candidate) + for candidate in DEMO_PRESETS + if candidate["collection"] == item["collection"] + } + candidates = [collection.selected_preset()] + candidates.extend(collection.preset(index) for index in range(collection.size())) + for preset in candidates: + if preset.name not in own_names: + return preset.name + raise RuntimeError("A parent %s preset is required for this demo" % item["kind"].lower()) + + +def _parent_is_live(bundle, item, name): + return bool(name) and name not in {_target_name(item)} and _collection(bundle, item).find_preset(name) is not None + + +def _profile(item, parent_name): + return { + "type": item["type"], + "name": item["name"], + "from": "User", + "instantiation": "true", + "version": "1.0.0", + "inherits": parent_name, + item["setting_key"]: item["name"], + } + + +def _reconcile(bundle, state): + for item in DEMO_PRESETS: + profile = _profile_state(state, item) + if profile["enabled"] and not profile["deleted"] and not _parent_is_live(bundle, item, profile["parent_name"]): + profile["parent_name"] = _parent_name(bundle, item) + + for item in DEMO_PRESETS: + profile = _profile_state(state, item) + path = _source_path(bundle, item) + if profile["enabled"] and not profile["deleted"]: + _write_json_atomic(path, _profile(item, profile["parent_name"])) + else: + _remove_file(path) + _write_json_atomic( + os.path.join(_bundle_dir(bundle), "bundle_metadata.json"), + {"id": BUNDLE_ID, "name": "Reload Local Bundle Demo", "version": "0.1.0"}, + ) + _write_json_atomic(_state_path(bundle), state) + orca.host.preset.bundles.reload_local(BUNDLE_ID) + + +def _state_payload(bundle, state, message="", error=""): + items = [] + for item in DEMO_PRESETS: + profile = _profile_state(state, item) + items.append( + { + "id": item["id"], + "name": item["name"], + "kind": item["kind"], + "enabled": profile["enabled"], + "deleted": profile["deleted"], + "loaded": _collection(bundle, item).find_preset(_target_name(item)) is not None, + } + ) + return { + "command": "state", + "items": items, + "bundles": orca.host.preset.bundles.list_ids(), + "message": message, + "error": error, + } + + +PAGE = r""" + +
How it works

Each row controls one profile in this local bundle. A switch adds or removes its source file; Delete removes the demo record and Restore returns it disabled. Every action reloads this bundle across filament, process, and machine. Local bundles lists every bundle id that orca.host.preset.bundles.list_ids() reports; Reload calls orca.host.preset.bundles.reload_local() for that id.

+
+

Local bundles

+
+
+
+""" + + +class ReloadLocalBundleDemo(orca.script.ScriptPluginCapabilityBase): + window = None + + def get_name(self): + return "Reload local bundle demo" + + def execute(self): + try: + if self.window is not None and self.window.is_open(): + self._post_state() + return orca.ExecutionResult.success("Reload local bundle demo refreshed.") + self.window = orca.host.ui.create_window( + title="Reload local bundle demo", html=PAGE, width=500, height=420, + on_message=self.on_message, on_close=self.on_close, + ) + return orca.ExecutionResult.success("Reload local bundle demo opened.") + except Exception as error: + message = "Reload local bundle demo failed: %s" % error + orca.host.ui.message(message, title="Reload local bundle demo", buttons="ok", icon="error") + return orca.ExecutionResult.failure(orca.PluginResult.RecoverableError, message) + + def on_close(self): + self.window = None + + def _post_state(self, message="", error=""): + try: + bundle = orca.host.preset_bundle() + payload = _state_payload(bundle, _read_state(bundle), message, error) + except Exception as state_error: + payload = {"command": "state", "items": [], "message": message, "error": str(state_error)} + if self.window is not None and self.window.is_open(): + self.window.post(payload) + + def on_message(self, message): + try: + message = message or {} + bundle = orca.host.preset_bundle() + state = _read_state(bundle) + if message.get("command") == "state": + self._post_state() + return + if message.get("command") == "reload_bundle": + bundle_id = message.get("bundle_id") + if not isinstance(bundle_id, str) or not bundle_id: + raise RuntimeError("A bundle id is required") + orca.host.preset.bundles.reload_local(bundle_id) + self._post_state(message="Reloaded local bundle %s." % bundle_id) + return + item = next((item for item in DEMO_PRESETS if item["id"] == message.get("id")), None) + if item is None: + raise RuntimeError("Unknown demo preset") + profile = _profile_state(state, item) + command = message.get("command") + if command == "set_enabled": + enabled = message.get("enabled") + if not isinstance(enabled, bool): + raise RuntimeError("Preset availability must be a boolean") + if profile["deleted"]: + raise RuntimeError("Restore the deleted demo preset before enabling it") + profile["enabled"] = enabled + elif command == "delete": + profile.update({"enabled": False, "deleted": True, "parent_name": ""}) + elif command == "restore": + profile.update({"enabled": False, "deleted": False, "parent_name": ""}) + else: + raise RuntimeError("Unknown demo action") + _reconcile(bundle, state) + self._post_state(message="The change was applied. Check the corresponding preset selector in Orca.") + except Exception as error: + self._post_state(error=str(error)) + + +@orca.plugin +class ReloadLocalBundleDemoPackage(orca.base): + def register_capabilities(self): + orca.register_capability(ReloadLocalBundleDemo) diff --git a/src/libslic3r/Preset.cpp b/src/libslic3r/Preset.cpp index 4108ee210d..f557348998 100644 --- a/src/libslic3r/Preset.cpp +++ b/src/libslic3r/Preset.cpp @@ -1822,7 +1822,7 @@ PresetCollection::UserPresetLoad PresetCollection::resolve_user_preset( const boost::filesystem::path &file, const std::string &canonical_name, const PresetOrigin &load_origin, ForwardCompatibilitySubstitutionRule substitution_rule, const std::string &extruder_id_name, const std::string &extruder_variant_name, - std::set *key_set1, std::set *key_set2) const + std::set *key_set1, std::set *key_set2, bool read_only) const { UserPresetLoad out; out.preset = Preset(m_type, canonical_name, false); @@ -1856,7 +1856,10 @@ PresetCollection::UserPresetLoad PresetCollection::resolve_user_preset( std::string version_str = key_values[BBL_JSON_KEY_VERSION]; boost::optional version = Semver::parse(version_str); - if (!version) return out; + if (!version) { + out.errors.push_back((boost::format("invalid version %1% in config %2%") % version_str % preset.file).str()); + return out; + } preset.version = *version; if (key_values.find(BBL_JSON_KEY_FILAMENT_ID) != key_values.end()) @@ -1878,6 +1881,12 @@ PresetCollection::UserPresetLoad PresetCollection::resolve_user_preset( inherit_preset = this->find_preset2(inherits_value); Preset::normalize_inherits(config, inherit_preset); } + // Local hot reload only supports FFF; no SLA default printer is installed. + if (read_only && load_origin.kind == PresetOrigin::Kind::LocalBundle && m_type == Preset::TYPE_PRINTER && + Preset::printer_technology(config) != ptFFF) { + out.errors.push_back((boost::format("local bundle printer %1% is not FFF") % preset.file).str()); + return out; + } const Preset& default_preset = this->default_preset_for(config); if (inherit_preset) { preset.config = inherit_preset->config; @@ -2042,7 +2051,7 @@ void PresetCollection::load_presets( resolve_then_commit(files.size(), [&](size_t i) { return this->resolve_user_preset(files[i].path, files[i].canonical_name, resolved_origin, substitution_rule, - extruder_id_name, extruder_variant_name, key_set1, key_set2); + extruder_id_name, extruder_variant_name, key_set1, key_set2, read_only); }, [&](size_t i, UserPresetLoad &&loaded) { // Resolve read the files without the lock, so another instance may have saved @@ -2062,7 +2071,7 @@ void PresetCollection::load_presets( if (! (on_disk == loaded.on_disk)) { CNumericLocalesSetter locales_setter; loaded = this->resolve_user_preset(files[i].path, files[i].canonical_name, resolved_origin, substitution_rule, - extruder_id_name, extruder_variant_name, key_set1, key_set2); + extruder_id_name, extruder_variant_name, key_set1, key_set2, read_only); } } const bool leave_files = read_only || (! lock_path.empty() && ! instance_lock.locked()); diff --git a/src/libslic3r/Preset.hpp b/src/libslic3r/Preset.hpp index 98e7e62e90..b6dc3b37bb 100644 --- a/src/libslic3r/Preset.hpp +++ b/src/libslic3r/Preset.hpp @@ -991,7 +991,8 @@ private: UserPresetLoad resolve_user_preset(const boost::filesystem::path &file, const std::string &canonical_name, const PresetOrigin &load_origin, ForwardCompatibilitySubstitutionRule substitution_rule, const std::string &extruder_id_name, const std::string &extruder_variant_name, - std::set *key_set1, std::set *key_set2) const; + std::set *key_set1, std::set *key_set2, + bool read_only) const; // Install one resolved preset. The collection, its alias maps, the error count // and the preset files on disk are touched here and only here. diff --git a/src/libslic3r/PresetBundle.cpp b/src/libslic3r/PresetBundle.cpp index 711501aecf..7d7855e624 100644 --- a/src/libslic3r/PresetBundle.cpp +++ b/src/libslic3r/PresetBundle.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -56,8 +57,10 @@ #include #include +#include #include #include +#include #include #include #include @@ -1383,6 +1386,27 @@ std::string PresetBundle::get_hotend_model_for_printer_model(std::string model_n return out; } +static void load_bundle_presets(PresetBundle& bundle, const std::string& bundle_dir, BundleMetadata& metadata, + const PresetOrigin& origin, PresetsConfigSubstitutions& substitutions, + ForwardCompatibilitySubstitutionRule substitution_rule, bool read_only = false) +{ + struct Collection { + PresetCollection& presets; + const char* directory; + std::vector& names; + }; + + metadata.print_presets.clear(); + metadata.filament_presets.clear(); + metadata.printer_presets.clear(); + for (Collection collection : { + Collection{ bundle.prints, PRESET_PRINT_NAME, metadata.print_presets }, + Collection{ bundle.filaments, PRESET_FILAMENT_NAME, metadata.filament_presets }, + Collection{ bundle.printers, PRESET_PRINTER_NAME, metadata.printer_presets } }) + collection.presets.load_presets(bundle_dir, collection.directory, substitutions, substitution_rule, + [&](Preset& preset) { collection.names.push_back(preset.name); }, origin, read_only); +} + PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, ForwardCompatibilitySubstitutionRule substitution_rule, bool read_only) { BOOST_LOG_TRIVIAL(info) << __FUNCTION__ << __LINE__ << " entry and user is: " << user; @@ -1431,19 +1455,8 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For BundleMetadata metadata; if (!load_bundle_metadata(metadata_file, metadata)) continue; - metadata.print_presets.clear(); - metadata.filament_presets.clear(); - metadata.printer_presets.clear(); - - this->prints.load_presets(bundle_dir, PRESET_PRINT_NAME, substitutions, substitution_rule, [&](Preset& preset) { - metadata.print_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id), read_only); - this->filaments.load_presets(bundle_dir, PRESET_FILAMENT_NAME, substitutions, substitution_rule, [&](Preset& preset) { - metadata.filament_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id), read_only); - this->printers.load_presets(bundle_dir, PRESET_PRINTER_NAME, substitutions, substitution_rule, [&](Preset& preset) { - metadata.printer_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id), read_only); + load_bundle_presets(*this, bundle_dir, metadata, PresetOrigin(PresetOrigin::Kind::LocalBundle, metadata.id), + substitutions, substitution_rule, read_only); metadata.bundle_type = BundleType::Local; metadata.path = metadata_file.string(); @@ -1466,20 +1479,10 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For BundleMetadata metadata; if (!load_bundle_metadata(metadata_file, metadata)) continue; - metadata.print_presets.clear(); - metadata.filament_presets.clear(); - metadata.printer_presets.clear(); metadata.is_subscribed = true; - this->prints.load_presets(bundle_dir, PRESET_PRINT_NAME, substitutions, substitution_rule, [&](Preset& preset) { - metadata.print_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id), read_only); - this->filaments.load_presets(bundle_dir, PRESET_FILAMENT_NAME, substitutions, substitution_rule, [&](Preset& preset) { - metadata.filament_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id), read_only); - this->printers.load_presets(bundle_dir, PRESET_PRINTER_NAME, substitutions, substitution_rule, [&](Preset& preset) { - metadata.printer_presets.push_back(preset.name); - }, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id), read_only); + load_bundle_presets(*this, bundle_dir, metadata, PresetOrigin(PresetOrigin::Kind::SubscribedBundle, metadata.id), + substitutions, substitution_rule, read_only); metadata.bundle_type = BundleType::Subscribed; metadata.path = metadata_file.string(); @@ -1534,6 +1537,335 @@ PresetsConfigSubstitutions PresetBundle::load_user_presets(std::string user, For return PresetsConfigSubstitutions(); } +static bool is_single_path_component(const std::string& value) +{ + const fs::path path(value); + return !value.empty() && value != "." && value != ".." && value.find_first_of("/\\") == std::string::npos && + !path.has_root_name() && !path.has_root_directory(); +} + +static bool check_local_bundle(PresetBundleMetadata& bundles, const fs::path& user_dir, const std::string& bundle_id, + BundleMetadata& metadata, std::string& error) +{ + if (!is_single_path_component(bundle_id)) { + error = "Local bundle id must be a single path component"; + return false; + } + const fs::path bundle_dir = user_dir / PRESET_LOCAL_DIR / bundle_id; + if (!fs::is_directory(bundle_dir)) { + error = "Local bundle directory does not exist"; + return false; + } + if (fs::exists(user_dir / PRESET_SUBSCRIBED_DIR / bundle_id)) { + error = "A subscribed bundle already uses this id"; + return false; + } + { + std::shared_lock lock(bundles.RWMtx); + const auto it = bundles.m_bundles.find(bundle_id); + if (it != bundles.m_bundles.end() && (it->second.bundle_type == BundleType::Subscribed || it->second.is_subscribed)) { + error = "A subscribed bundle already uses this id"; + return false; + } + } + if (!metadata.load_from_json((bundle_dir / PRESET_BUNDLE_METADATA).string()) || metadata.id != bundle_id) { + error = "Local bundle metadata is missing, malformed, or has a different id"; + return false; + } + return true; +} + +std::vector PresetBundle::list_local_bundle_ids(const std::string& preset_folder) +{ + std::vector ids; + const std::string user = preset_folder.empty() ? DEFAULT_USER_FOLDER_NAME : preset_folder; + if (!is_single_path_component(user)) + return ids; + + const fs::path user_dir = fs::path(data_dir()) / PRESET_USER_DIR / user; + const fs::path local_dir = user_dir / PRESET_LOCAL_DIR; + try { + if (!fs::is_directory(local_dir)) + return ids; + for (const auto& entry : fs::directory_iterator(local_dir)) { + const std::string bundle_id = entry.path().filename().string(); + try { + BundleMetadata metadata; + std::string error; + if (check_local_bundle(bundles, user_dir, bundle_id, metadata, error)) + ids.push_back(bundle_id); + } catch (const std::exception& exception) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": skipping " << bundle_id << ": " << exception.what(); + } + } + } catch (const std::exception& exception) { + BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": " << exception.what(); + } + std::sort(ids.begin(), ids.end()); + return ids; +} + +bool PresetBundle::reload_local_bundle(const std::string& preset_folder, const std::string& bundle_id, std::string* error) +{ + auto fail = [error](const std::string& message) { + if (error != nullptr) + *error = message; + return false; + }; + if (!is_single_path_component(bundle_id)) + return fail("Local bundle id must be a single path component"); + + const std::string user = preset_folder.empty() ? DEFAULT_USER_FOLDER_NAME : preset_folder; + if (!is_single_path_component(user)) + return fail("Preset folder must be a single path component"); + + try { + const fs::path user_dir = fs::path(data_dir()) / PRESET_USER_DIR / user; + const fs::path bundle_dir = user_dir / PRESET_LOCAL_DIR / bundle_id; + const fs::path metadata_file = bundle_dir / PRESET_BUNDLE_METADATA; + + BundleMetadata metadata; + std::string check_error; + if (!check_local_bundle(bundles, user_dir, bundle_id, metadata, check_error)) + return fail(check_error); + + // Keep background preset sync from changing the collections between copying and committing them. + std::scoped_lock collection_lock(prints.m_mutex, filaments.m_mutex, printers.m_mutex); + const std::string target_prefix = std::string(PRESET_LOCAL_DIR) + "/" + bundle_id + "/"; + const auto is_target = [&](const Preset& preset) { + return preset.bundle_id == bundle_id && boost::starts_with(preset.name, target_prefix); + }; + struct Selection { + std::string name; + Preset base{Preset::TYPE_INVALID, std::string()}; + Preset edited{Preset::TYPE_INVALID, std::string()}; + std::vector dirty_options; + }; + auto snapshot = [&](const PresetCollection& collection, Selection& selection) { + selection.name = collection.get_selected_preset_name(); + if (selection.name.empty()) + return false; + for (const Preset& preset : collection.get_presets()) { + if (preset.name == selection.name) { + selection.base = preset; + selection.edited = collection.get_edited_preset(); + selection.dirty_options = PresetCollection::dirty_options(&selection.edited, &selection.base); + return true; + } + } + return false; + }; + + Selection print_selection, filament_selection, printer_selection; + if (!snapshot(prints, print_selection) || !snapshot(filaments, filament_selection) || !snapshot(printers, printer_selection)) + return fail("Preset selections are not initialized"); + + PresetBundle staged; + // Collection copies keep vendor pointers into the live vendor map, which is not replaced by this reload. + staged.prints = prints; + staged.filaments = filaments; + staged.printers = printers; + staged.filament_presets = filament_presets; + staged.project_config.apply_only(project_config, {"flush_volumes_matrix", "flush_volumes_vector", "flush_multiplier"}); + auto remove_target = [&](PresetCollection& collection) { + auto begin = collection.m_presets.begin() + collection.m_num_default_presets; + collection.m_presets.erase(std::remove_if(begin, collection.m_presets.end(), is_target), collection.m_presets.end()); + collection.sort_presets(); + }; + remove_target(staged.prints); + remove_target(staged.filaments); + remove_target(staged.printers); + + const std::array errors_before = { staged.prints.error_count(), staged.filaments.error_count(), staged.printers.error_count() }; + PresetsConfigSubstitutions substitutions; + load_bundle_presets(staged, bundle_dir.string(), metadata, PresetOrigin(PresetOrigin::Kind::LocalBundle, bundle_id), + substitutions, ForwardCompatibilitySubstitutionRule::Enable, true); + if (staged.prints.error_count() != errors_before[0] || staged.filaments.error_count() != errors_before[1] || staged.printers.error_count() != errors_before[2]) + return fail("Local bundle contains an invalid preset"); + + auto dirty_target_was_removed = [&](const Selection& selection, const PresetCollection& collection) { + if (selection.dirty_options.empty() || !is_target(selection.base)) + return false; + for (const Preset& preset : collection.get_presets()) + if (preset.name == selection.name && is_target(preset)) + return false; + return true; + }; + if (dirty_target_was_removed(print_selection, staged.prints) || + dirty_target_was_removed(filament_selection, staged.filaments) || + dirty_target_was_removed(printer_selection, staged.printers)) + return fail("A modified selected preset was removed or renamed"); + + auto external_child_name = [&](const PresetCollection& collection) { + auto depends_on_target = [&](const Preset& preset) { + if (is_target(preset)) + return false; + const Preset* parent = &preset; + std::unordered_set visited; + while ((parent = collection.get_preset_parent(*parent)) != nullptr && visited.insert(parent).second) + if (is_target(*parent)) + return true; + for (const char* key : {"compatible_prints", "compatible_printers"}) { + const auto* names = preset.config.option(key); + if (names == nullptr) + continue; + const PresetCollection& referenced = std::string(key) == "compatible_printers" ? printers : prints; + for (const std::string& name : names->values) { + const Preset* dependency = referenced.find_preset(name, false); + if (dependency != nullptr && is_target(*dependency)) + return true; + } + } + return false; + }; + for (const Preset& preset : collection.get_presets()) + if (depends_on_target(preset)) + return preset.name; + if (depends_on_target(collection.get_edited_preset())) + return collection.get_edited_preset().name; + return std::string(); + }; + for (const PresetCollection* collection : std::array{&prints, &filaments, &printers}) { + const std::string child_name = external_child_name(*collection); + if (!child_name.empty()) + return fail("Preset outside this bundle depends on it: " + child_name); + } + + auto restore_selection = [](PresetCollection& collection, const Selection& selection) { + if (collection.find_preset(selection.name, false, true) == nullptr) { + collection.select_preset(collection.first_visible_idx()); + return false; + } + collection.select_preset_by_name(selection.name, true); + if (collection.get_selected_preset_name() != selection.name) + return false; + Preset& edited = collection.get_edited_preset(); + for (const std::string& key : selection.dirty_options) { + if (const ConfigOption* option = selection.edited.config.option(key)) + edited.config.set_key_value(key, option->clone()); + else + edited.config.erase(key); + } + collection.update_dirty(); + return true; + }; + + staged.prints.update_map_alias_to_profile_name(); + staged.filaments.update_map_alias_to_profile_name(); + staged.printers.update_map_alias_to_profile_name(); + const bool printer_preserved = restore_selection(staged.printers, printer_selection); + const bool print_preserved = restore_selection(staged.prints, print_selection); + const bool filament_preserved = restore_selection(staged.filaments, filament_selection); + const auto selection_mode = [printer_preserved](bool selection_preserved) { + if (!selection_preserved) + return PresetSelectCompatibleType::Always; + return printer_preserved ? PresetSelectCompatibleType::Never : PresetSelectCompatibleType::OnlyIfWasCompatible; + }; + if (staged.printers.get_edited_preset().printer_technology() != ptFFF) + return fail("Local bundle reload requires an FFF printer selection"); + staged.update_compatible(selection_mode(print_preserved), selection_mode(filament_preserved)); + for (std::string& name : staged.filament_presets) + if (boost::starts_with(name, target_prefix) && staged.filaments.find_preset(name, false) == nullptr) + name = staged.filaments.first_compatible().name; + staged.update_multi_material_filament_presets(); + + struct Collection + { + PresetCollection& live; + PresetCollection& candidate; + size_t original_size; + }; + std::array collections{{{prints, staged.prints, prints.size()}, + {filaments, staged.filaments, filaments.size()}, + {printers, staged.printers, printers.size()}}}; + for (size_t i = 0; i < collections.size(); ++i) { + const auto& collection = collections[i]; + const auto dirty_options = collection.live.current_dirty_options(); + if (dirty_options.empty()) + continue; + if (collection.live.get_selected_preset_name() != collection.candidate.get_selected_preset_name()) + return fail("Reload would discard a modified selected preset"); + for (const std::string& key : dirty_options) { + const ConfigOption* before = collection.live.get_edited_preset().config.option(key); + const ConfigOption* after = collection.candidate.get_edited_preset().config.option(key); + if ((before == nullptr) != (after == nullptr) || (before != nullptr && *before != *after)) + return fail("Reload would discard a modified preset option: " + key); + } + } + + if (calibrate_printer != nullptr && staged.printers.find_preset(calibrate_printer->name, false, true) != nullptr) + staged.set_calibrate_printer(calibrate_printer->name); + std::array, 3> flush_options; + size_t i = 0; + for (const char* key : {"flush_volumes_matrix", "flush_volumes_vector", "flush_multiplier"}) + flush_options[i++] = {project_config.option(key), staged.project_config.option(key)}; + + metadata.bundle_type = BundleType::Local; + metadata.is_subscribed = false; + metadata.update_available = false; + metadata.path = metadata_file.string(); + fs::path local_dir = user_dir / PRESET_LOCAL_DIR; + std::unique_lock lock(bundles.RWMtx); + const auto existing = bundles.m_bundles.find(bundle_id); + if (existing != bundles.m_bundles.end() && + (existing->second.bundle_type == BundleType::Subscribed || existing->second.is_subscribed)) + return fail("A subscribed bundle already uses this id"); + + std::set calibration_filaments; + const Preset* calibration_printer = nullptr; + try { + // Growing a deque preserves existing objects. Roll back appended copies if preparation fails. + for (size_t i = 0; i < collections.size(); ++i) { + auto& collection = collections[i]; + while (collection.live.size() < collection.candidate.size()) + collection.live.m_presets.emplace_back(collection.candidate.m_presets[collection.live.size()]); + } + auto calibration_preset = [](const Preset* preset, PresetCollection& live, PresetCollection& candidate) -> const Preset* { + if (preset == nullptr) + return nullptr; + if (preset == &candidate.get_edited_preset()) + return &live.get_edited_preset(); + const auto it = candidate.find_preset_internal(preset->name); + return &live.m_presets[it - candidate.m_presets.begin()]; + }; + calibration_printer = calibration_preset(staged.calibrate_printer, printers, staged.printers); + for (const Preset* preset : staged.calibrate_filaments) + calibration_filaments.insert(calibration_preset(preset, filaments, staged.filaments)); + // Insertion is the last allocating operation. Replacing metadata only moves its members. + bundles.m_bundles.insert_or_assign(bundle_id, std::move(metadata)); + } catch (...) { + for (size_t i = 0; i < collections.size(); ++i) + while (collections[i].live.size() > collections[i].original_size) + collections[i].live.m_presets.pop_back(); + throw; + } + auto install_target = [](PresetCollection& live, PresetCollection& candidate) noexcept { + // Keep stored Preset references alive when the collection's layout is unchanged. + // Adding/removing presets retains the collection's usual sorted-position semantics. + for (size_t i = 0; i < candidate.size(); ++i) + live.m_presets[i] = std::move(candidate.m_presets[i]); + while (live.size() > candidate.size()) + live.m_presets.pop_back(); + live.m_map_alias_to_profile_name.swap(candidate.m_map_alias_to_profile_name); + // Move assignment does not allocate (including Semver); swapping Presets would construct temporary containers. + live.m_edited_preset = std::move(candidate.m_edited_preset); + live.m_saved_preset = std::move(candidate.m_saved_preset); + live.m_idx_selected = candidate.m_idx_selected; + }; + for (size_t i = 0; i < collections.size(); ++i) + install_target(collections[i].live, collections[i].candidate); + filament_presets.swap(staged.filament_presets); + for (const auto& options : flush_options) + options.first->values.swap(options.second->values); + calibrate_printer = calibration_printer; + calibrate_filaments.swap(calibration_filaments); + dir_user_presets_local.swap(local_dir); + return true; + } catch (const std::exception& exception) { + return fail(std::string("Failed to reload local bundle: ") + exception.what()); + } +} + PresetsConfigSubstitutions PresetBundle::load_user_presets(AppConfig & config, std::map> &my_presets, ForwardCompatibilitySubstitutionRule substitution_rule) diff --git a/src/libslic3r/PresetBundle.hpp b/src/libslic3r/PresetBundle.hpp index 9d41746aee..30e2c66d67 100644 --- a/src/libslic3r/PresetBundle.hpp +++ b/src/libslic3r/PresetBundle.hpp @@ -302,6 +302,9 @@ public: // BBS Load user presets PresetsConfigSubstitutions load_user_presets(std::string user, ForwardCompatibilitySubstitutionRule rule, bool read_only = false); PresetsConfigSubstitutions load_user_presets(AppConfig &config, std::map>& my_presets, ForwardCompatibilitySubstitutionRule rule); + + bool reload_local_bundle(const std::string& preset_folder, const std::string& bundle_id, std::string* error = nullptr); + std::vector list_local_bundle_ids(const std::string& preset_folder); // Orca: Import subscribed bundle presets (load and save to disk in one operation), handles one bundle at a time PresetsConfigSubstitutions update_subscribed_presets(AppConfig& config, const std::map>& bundle_presets, diff --git a/src/slic3r/plugin/host/PluginHostPresets.cpp b/src/slic3r/plugin/host/PluginHostPresets.cpp index 6e10e3f282..4f032e0808 100644 --- a/src/slic3r/plugin/host/PluginHostPresets.cpp +++ b/src/slic3r/plugin/host/PluginHostPresets.cpp @@ -1,16 +1,27 @@ #include "PluginHostBindings.hpp" +#include "PluginHostUi.hpp" +#include "slic3r/plugin/PluginBindingUtils.hpp" + #include #include #include -#include "slic3r/plugin/PluginBindingUtils.hpp" #include +#include #include #include #include + +#include +#include +#include +#include +#include + #include +#include #include #include @@ -37,10 +48,66 @@ PresetCollection& printer_presets(PresetBundle& bundle) return static_cast(bundle.printers); } +void reload_local_bundle(const std::string& bundle_id) +{ + bool reloaded = false; + std::string error; + PluginHostUi::run_on_ui_thread([&] { + GUI::GUI_App& app = GUI::wxGetApp(); + if (app.is_closing()) + throw std::runtime_error("OrcaSlicer is closing"); + if (app.preset_bundle == nullptr) + throw std::runtime_error("Preset bundle is not available"); + if (app.app_config == nullptr) + throw std::runtime_error("Application configuration is not available"); + if (app.mainframe == nullptr) + throw std::runtime_error("OrcaSlicer main window is not available"); + + reloaded = app.preset_bundle->reload_local_bundle(app.app_config->get("preset_folder"), bundle_id, &error); + if (!reloaded) + return; + + app.mainframe->update_side_preset_ui(); + for (GUI::Tab* tab : app.tabs_list) { + tab->reload_config(); + tab->update_changed_ui(); + } + if (GUI::Plater* plater = app.plater()) + plater->sidebar().update_all_preset_comboboxes(); + }); + if (!reloaded) + throw std::runtime_error(error.empty() ? "Local bundle was not reloaded" : error); +} + +std::vector list_bundle_ids() +{ + std::vector ids; + PluginHostUi::run_on_ui_thread([&] { + GUI::GUI_App& app = GUI::wxGetApp(); + if (app.is_closing()) + throw std::runtime_error("OrcaSlicer is closing"); + if (app.preset_bundle == nullptr) + throw std::runtime_error("Preset bundle is not available"); + if (app.app_config == nullptr) + throw std::runtime_error("Application configuration is not available"); + + ids = app.preset_bundle->list_local_bundle_ids(app.app_config->get("preset_folder")); + }); + return ids; +} + } // namespace void host_bindings::register_presets(py::module_& host) { + auto preset = host.def_submodule("preset", "Preset operations"); + auto bundles = preset.def_submodule("bundles", "Local preset bundle operations"); + bundles.def("reload_local", &reload_local_bundle, py::arg("bundle_id"), + "Reload one local preset bundle from the active user's _local directory."); + bundles.def("list_ids", &list_bundle_ids, + "IDs of local preset bundles in the active user's _local directory that reload_local() accepts, " + "including bundles added after startup."); + py::enum_(host, "PresetType") .value("Invalid", Preset::TYPE_INVALID) .value("Print", Preset::TYPE_PRINT) diff --git a/src/slic3r/plugin/host/PluginHostUi.cpp b/src/slic3r/plugin/host/PluginHostUi.cpp index 15fe24b2c5..4a87d5b26d 100644 --- a/src/slic3r/plugin/host/PluginHostUi.cpp +++ b/src/slic3r/plugin/host/PluginHostUi.cpp @@ -646,6 +646,11 @@ void plater_notification(GUI::NotificationManager::NotificationLevel notificatio } // namespace +void PluginHostUi::run_on_ui_thread(std::function fn) +{ + run_on_ui_blocking(std::move(fn)); +} + void PluginHostUi::RegisterBindings(pybind11::module_& host) { auto ui = host.def_submodule( diff --git a/src/slic3r/plugin/host/PluginHostUi.hpp b/src/slic3r/plugin/host/PluginHostUi.hpp index a8a5e64606..cb47416b71 100644 --- a/src/slic3r/plugin/host/PluginHostUi.hpp +++ b/src/slic3r/plugin/host/PluginHostUi.hpp @@ -2,6 +2,7 @@ #include +#include #include namespace Slic3r { @@ -19,6 +20,8 @@ class PluginHostUi public: static void RegisterBindings(pybind11::module_& host); + static void run_on_ui_thread(std::function fn); + // Lifecycle hook: close and tear down every UI window owned by a plugin. PluginManager invokes // this after plugin teardown and also for bulk unload during application shutdown. static void close_windows_for_plugin(const std::string& plugin_key); diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index a8ff462d37..47f5c80cca 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -10,10 +10,17 @@ #include #include #include +#include +#include +#include +#include +#include +#include #include #include #include +#include #include #include #include "libslic3r/PresetBundle.hpp" @@ -148,6 +155,124 @@ struct RenameTestCollection : public PresetCollection using PresetCollection::update_map_system_profile_renamed; }; +struct ScopedPresetDataDir +{ + ScopedTemporaryDir tmp{"orca-local-bundle-reload"}; + std::string previous{data_dir()}; + + ScopedPresetDataDir() { set_data_dir(tmp.path().string()); } + ~ScopedPresetDataDir() { set_data_dir(previous); } + + fs::path bundle_dir(const std::string& id) const + { + return tmp.path() / PRESET_USER_DIR / DEFAULT_USER_FOLDER_NAME / PRESET_LOCAL_DIR / id; + } +}; + +void write_bundle_metadata(const fs::path& bundle_dir, const std::string& id) +{ + fs::create_directories(bundle_dir); + std::ofstream((bundle_dir / PRESET_BUNDLE_METADATA).string()) << "{\"id\":\"" << id << "\"}"; +} + +void write_print_preset_with_layer_height(const DynamicPrintConfig& default_config, const fs::path& file, + const std::string& name, double layer_height) +{ + DynamicPrintConfig config(default_config); + config.option("print_settings_id", true)->value = name; + config.option(BBL_JSON_KEY_INHERITS, true)->value.clear(); + config.option("layer_height", true)->value = layer_height; + fs::create_directories(file.parent_path()); + config.save_to_json(file.string(), name, "User", "1.0.0"); +} + +double layer_height(const Preset& preset) +{ + return preset.config.option("layer_height")->value; +} + +// The first copy snapshots the dirty edit; cloning that snapshot replays the edit after source loading. +// This exercises late failures and concurrent metadata updates without a production test hook. +struct ReloadEdit : ConfigOptionString +{ + std::function on_restore; + bool copied = false; + + explicit ReloadEdit(std::function on_restore) : ConfigOptionString("dirty start gcode"), on_restore(std::move(on_restore)) {} + + ConfigOption* clone() const override + { + if (copied) + on_restore(); + auto* copy = new ReloadEdit(*this); + copy->copied = true; + return copy; + } +}; + +void check_reload_state(const PresetBundle& bundle, const PresetBundle& before) +{ + const PresetCollection* actual[] = {&bundle.prints, &bundle.filaments, &bundle.printers}; + const PresetCollection* expected[] = {&before.prints, &before.filaments, &before.printers}; + for (size_t i = 0; i < 3; ++i) { + CAPTURE(i); + CHECK(actual[i]->get_selected_preset_name() == expected[i]->get_selected_preset_name()); + CHECK(actual[i]->get_edited_preset().config == expected[i]->get_edited_preset().config); + CHECK(actual[i]->get_edited_preset().is_dirty == expected[i]->get_edited_preset().is_dirty); + REQUIRE(actual[i]->size() == expected[i]->size()); + for (size_t j = 0; j < actual[i]->size(); ++j) { + const Preset& preset = actual[i]->get_presets()[j]; + const Preset& old = expected[i]->get_presets()[j]; + CHECK(preset.name == old.name); + CHECK(preset.config == old.config); + CHECK(preset.bundle_id == old.bundle_id); + CHECK(preset.is_compatible == old.is_compatible); + CHECK(preset.is_visible == old.is_visible); + CHECK(preset.is_dirty == old.is_dirty); + CHECK(actual[i]->get_preset_name_by_alias(preset.alias) == expected[i]->get_preset_name_by_alias(old.alias)); + } + } + CHECK(bundle.filament_presets == before.filament_presets); + CHECK(bundle.project_config == before.project_config); +} + +struct LocalReloadFixture +{ + ScopedPresetDataDir data_dir; + PresetBundle bundle; + const std::string id = "reload-target"; + const fs::path directory = data_dir.bundle_dir(id); + const std::string prefix = std::string(PRESET_LOCAL_DIR) + "/" + id + "/"; + std::string error; + + LocalReloadFixture() + { + write_bundle_metadata(directory, id); + write_print_preset_with_layer_height(bundle.prints.default_preset().config, directory / PRESET_PRINT_NAME / "Print.json", "Print", + 0.20); + write_preset_with_inherits(bundle.filaments.default_preset().config, directory / PRESET_FILAMENT_NAME / "Filament.json", "Filament", + ""); + write_preset_with_inherits(bundle.printers.default_preset().config, directory / PRESET_PRINTER_NAME / "Printer.json", "Printer", ""); + REQUIRE(bundle.reload_local_bundle("", id, &error)); + bundle.prints.select_preset_by_name(prefix + "Print", true); + bundle.filaments.select_preset_by_name(prefix + "Filament", true); + bundle.printers.select_preset_by_name(prefix + "Printer", true); + bundle.filament_presets = {prefix + "Filament"}; + bundle.update_compatible(PresetSelectCompatibleType::Never); + } + + void change_sources() + { + write_print_preset_with_layer_height(bundle.prints.default_preset().config, directory / PRESET_PRINT_NAME / "Print.json", "Print", + 0.30); + write_preset_with_inherits(bundle.filaments.default_preset().config, directory / PRESET_FILAMENT_NAME / "Added Filament.json", + "Added Filament", ""); + write_preset_with_inherits(bundle.printers.default_preset().config, directory / PRESET_PRINTER_NAME / "Added Printer.json", + "Added Printer", ""); + std::ofstream((directory / PRESET_BUNDLE_METADATA).string()) << "{\"id\":\"" << id << "\",\"version\":\"2.0.0\"}"; + } +}; + } // namespace TEST_CASE("Preset identity is canonicalized from load path", "[Preset][Identity]") @@ -267,6 +392,431 @@ TEST_CASE("Printer extruder count tolerates missing nozzle diameter", "[Preset][ CHECK(bundle.get_printer_extruder_count() == 2); } +TEST_CASE("Reloading one local bundle preserves unrelated and modified presets", "[Preset][Bundle]") +{ + using Catch::Matchers::WithinAbs; + + ScopedPresetDataDir data_dir; + PresetBundle bundle; + const std::string id = "reload-target"; + const fs::path bundle_dir = data_dir.bundle_dir(id); + const fs::path process_file = bundle_dir / PRESET_PRINT_NAME / "Managed Print.json"; + const std::string target_name = std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Print"; + std::string error; + + write_bundle_metadata(bundle_dir, id); + write_print_preset_with_layer_height(bundle.prints.default_preset().config, process_file, "Managed Print", 0.20); + write_preset_with_inherits(bundle.filaments.default_preset().config, + bundle_dir / PRESET_FILAMENT_NAME / "Managed Filament.json", "Managed Filament", ""); + write_preset_with_inherits(bundle.printers.default_preset().config, + bundle_dir / PRESET_PRINTER_NAME / "Managed Printer.json", "Managed Printer", ""); + + REQUIRE(bundle.reload_local_bundle("", id, &error)); + const Preset* target = bundle.prints.find_preset(target_name, false, true); + REQUIRE(target != nullptr); + CHECK(target->bundle_id == id); + CHECK_FALSE(target->is_user()); + REQUIRE(bundle.filaments.find_preset(std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Filament") != nullptr); + REQUIRE(bundle.printers.find_preset(std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Printer") != nullptr); + CHECK(bundle.prints.get_preset_name_by_alias("Managed Print") == target_name); + CHECK(bundle.filaments.get_preset_name_by_alias("Managed Filament") == + std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Filament"); + CHECK(bundle.printers.get_preset_name_by_alias("Managed Printer") == + std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Printer"); + + const std::string unrelated_name = add_inmemory_preset(bundle.prints, "Unrelated Process").name; + bundle.prints.select_preset_by_name(unrelated_name, true); + write_print_preset_with_layer_height(bundle.prints.default_preset().config, process_file, "Managed Print", 0.25); + REQUIRE(bundle.reload_local_bundle("", id, &error)); + CHECK(bundle.prints.get_selected_preset_name() == unrelated_name); + target = bundle.prints.find_preset(target_name, false, true); + REQUIRE(target != nullptr); + CHECK_THAT(layer_height(*target), WithinAbs(0.25, 1e-6)); + + bundle.prints.select_preset_by_name(target_name, true); + bundle.prints.get_edited_preset().config.option("layer_height", true)->value = 0.33; + REQUIRE(bundle.prints.update_dirty()); + write_print_preset_with_layer_height(bundle.prints.default_preset().config, process_file, "Managed Print", 0.28); + REQUIRE(bundle.reload_local_bundle("", id, &error)); + CHECK(bundle.prints.get_selected_preset_name() == target_name); + CHECK_THAT(layer_height(bundle.prints.get_selected_preset()), WithinAbs(0.28, 1e-6)); + CHECK_THAT(layer_height(bundle.prints.get_edited_preset()), WithinAbs(0.33, 1e-6)); + CHECK(bundle.prints.current_is_dirty()); + + std::ofstream(process_file.string()) << "not json"; + CHECK_FALSE(bundle.reload_local_bundle("", id, &error)); + CHECK(fs::exists(process_file)); + CHECK_THAT(layer_height(bundle.prints.get_selected_preset()), WithinAbs(0.28, 1e-6)); + CHECK_THAT(layer_height(bundle.prints.get_edited_preset()), WithinAbs(0.33, 1e-6)); + + bundle.prints.discard_current_changes(); + REQUIRE(fs::remove_all(process_file.parent_path()) > 0); + REQUIRE(bundle.reload_local_bundle("", id, &error)); + CHECK(bundle.prints.find_preset(target_name, false, true) == nullptr); + CHECK(bundle.prints.get_selected_preset_name() != target_name); + CHECK(bundle.filaments.find_preset(std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Filament") != nullptr); + CHECK(bundle.printers.find_preset(std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Printer") != nullptr); + + const std::string filament_name = std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Filament"; + const std::string printer_name = std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Printer"; + bundle.filaments.select_preset_by_name(filament_name, true); + bundle.printers.select_preset_by_name(printer_name, true); + bundle.filament_presets = { filament_name }; + REQUIRE(fs::remove_all(bundle_dir / PRESET_FILAMENT_NAME) > 0); + REQUIRE(fs::remove_all(bundle_dir / PRESET_PRINTER_NAME) > 0); + REQUIRE(bundle.reload_local_bundle("", id, &error)); + CHECK(bundle.filaments.get_selected_preset_name() != filament_name); + CHECK(bundle.printers.get_selected_preset_name() != printer_name); + REQUIRE(bundle.filament_presets.size() == 1); + CHECK(bundle.filament_presets.front() != filament_name); + CHECK(bundle.filaments.find_preset(bundle.filament_presets.front(), false) != nullptr); +} + +TEST_CASE("Reloading a local bundle resolves a loaded parent preset", "[Preset][Bundle]") +{ + ScopedPresetDataDir data_dir; + PresetBundle bundle; + const std::string id = "inherited-target"; + const fs::path bundle_dir = data_dir.bundle_dir(id); + const std::string parent_name = "Generic ABS @System"; + const std::string target_name = std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Filament"; + std::string error; + + add_inmemory_preset(bundle.filaments, parent_name); + write_bundle_metadata(bundle_dir, id); + write_preset_with_inherits(bundle.filaments.default_preset().config, + bundle_dir / PRESET_FILAMENT_NAME / "Managed Filament.json", "Managed Filament", parent_name); + + REQUIRE(bundle.reload_local_bundle("", id, &error)); + const Preset* target = bundle.filaments.find_preset(target_name, false, true); + REQUIRE(target != nullptr); + CHECK(target->inherits() == parent_name); +} + +TEST_CASE("Reloading a local bundle rejects unsafe source and inheritance changes", "[Preset][Bundle]") +{ + ScopedPresetDataDir data_dir; + PresetBundle bundle; + const std::string id = "guarded-target"; + const fs::path bundle_dir = data_dir.bundle_dir(id); + const std::string target_name = std::string(PRESET_LOCAL_DIR) + "/" + id + "/Managed Print"; + std::string error; + + write_bundle_metadata(bundle_dir, id); + write_print_preset_with_layer_height(bundle.prints.default_preset().config, + bundle_dir / PRESET_PRINT_NAME / "Managed Print.json", "Managed Print", 0.20); + REQUIRE(bundle.reload_local_bundle("", id, &error)); + + add_inmemory_preset(bundle.prints, "External Child", target_name); + CHECK_FALSE(bundle.reload_local_bundle("", id, &error)); + CHECK(error.find("External Child") != std::string::npos); + CHECK(bundle.prints.find_preset(target_name, false, true) != nullptr); + + for (auto it = bundle.prints.begin(); it != bundle.prints.end(); ++it) { + if (it->name == "External Child") { + bundle.prints.erase(it); + break; + } + } + fs::remove_all(bundle_dir); + CHECK_FALSE(bundle.reload_local_bundle("", id, &error)); + CHECK(bundle.prints.find_preset(target_name, false, true) != nullptr); + CHECK_FALSE(bundle.reload_local_bundle("", "../outside", &error)); + CHECK(error == "Local bundle id must be a single path component"); + CHECK_FALSE(bundle.reload_local_bundle("../outside", id, &error)); + CHECK(error == "Preset folder must be a single path component"); +#ifdef _WIN32 + CHECK_FALSE(bundle.reload_local_bundle("", "C:", &error)); + CHECK(error == "Local bundle id must be a single path component"); +#endif +} + +TEST_CASE("A failure while restoring dirty edits leaves the whole local bundle unchanged", "[Preset][Bundle][Regression]") +{ + LocalReloadFixture f; + bool restored = false; + f.bundle.printers.get_edited_preset().config.set_key_value("machine_start_gcode", new ReloadEdit([&] { + restored = true; + throw std::bad_alloc(); + })); + f.bundle.printers.update_dirty(); + const PresetBundle before(f.bundle); + const BundleMetadata metadata = f.bundle.bundles.m_bundles.at(f.id); + const fs::path local_dir = f.bundle.dir_user_presets_local; + f.change_sources(); + + CHECK_FALSE(f.bundle.reload_local_bundle("", f.id, &f.error)); + REQUIRE(restored); + CHECK_THAT(f.error, Catch::Matchers::ContainsSubstring("Failed to reload")); + check_reload_state(f.bundle, before); + CHECK(f.bundle.dir_user_presets_local == local_dir); + const auto& after = f.bundle.bundles.m_bundles.at(f.id); + CHECK(after.version == metadata.version); + CHECK(after.path == metadata.path); + CHECK(after.bundle_type == metadata.bundle_type); + CHECK(after.print_presets == metadata.print_presets); + CHECK(after.filament_presets == metadata.filament_presets); + CHECK(after.printer_presets == metadata.printer_presets); + CHECK(fs::exists(f.directory / PRESET_FILAMENT_NAME / "Added Filament.json")); + + // The rejected staging state does not poison the next reload or leave locks held. + f.bundle.printers.discard_current_changes(); + REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error)); + CHECK_THAT(layer_height(f.bundle.prints.get_selected_preset()), Catch::Matchers::WithinAbs(0.30, 1e-6)); +} + +TEST_CASE("A subscribed bundle registered during staging wins over the local reload", "[Preset][Bundle][Regression]") +{ + const bool replace_local_entry = GENERATE(false, true); + LocalReloadFixture f; + if (!replace_local_entry) + f.bundle.bundles.m_bundles.erase(f.id); + bool registered = false; + f.bundle.printers.get_edited_preset().config.set_key_value("machine_start_gcode", new ReloadEdit([&] { + // Finish a background metadata write after the initial local-source + // check, before live commit. + std::async(std::launch::async, [&] { + BundleMetadata subscribed; + subscribed.id = f.id; + subscribed.bundle_type = BundleType::Subscribed; + subscribed.is_subscribed = true; + subscribed.version = "9.0.0"; + std::unique_lock lock(f.bundle.bundles.RWMtx); + f.bundle.bundles.m_bundles.insert_or_assign(f.id, + std::move(subscribed)); + }).get(); + registered = true; + })); + f.bundle.printers.update_dirty(); + const PresetBundle before(f.bundle); + f.change_sources(); + + CHECK_FALSE(f.bundle.reload_local_bundle("", f.id, &f.error)); + REQUIRE(registered); + CHECK_THAT(f.error, Catch::Matchers::ContainsSubstring("subscribed")); + check_reload_state(f.bundle, before); + const auto& metadata = f.bundle.bundles.m_bundles.at(f.id); + CHECK(metadata.bundle_type == BundleType::Subscribed); + CHECK(metadata.is_subscribed); + CHECK(metadata.version == "9.0.0"); +} + +TEST_CASE("External compatible process references prevent local bundle replacement and removal", "[Preset][Bundle][Regression]") +{ + const bool edited_only = GENERATE(false, true); + const bool remove = GENERATE(false, true); + LocalReloadFixture f; + const std::string external_name = "External Filament"; + add_inmemory_preset(f.bundle.filaments, external_name); + f.bundle.filaments.select_preset_by_name(external_name, true); + Preset& dependent = edited_only ? f.bundle.filaments.get_edited_preset() : *f.bundle.filaments.find_preset(external_name, false, true); + dependent.config.set_key_value("compatible_prints", new ConfigOptionStrings({f.prefix + "Print"})); + if (!edited_only) + f.bundle.filaments.select_preset_by_name(external_name, true); + f.bundle.filaments.update_dirty(); + f.bundle.filament_presets = {external_name}; + f.bundle.update_compatible(PresetSelectCompatibleType::Never); + REQUIRE(f.bundle.filaments.get_edited_preset().is_compatible); + const PresetBundle before(f.bundle); + if (remove) + REQUIRE(fs::remove(f.directory / PRESET_PRINT_NAME / "Print.json")); + else + f.change_sources(); + + CHECK_FALSE(f.bundle.reload_local_bundle("", f.id, &f.error)); + CHECK_THAT(f.error, Catch::Matchers::ContainsSubstring(external_name)); + check_reload_state(f.bundle, before); + CHECK(f.bundle.bundles.m_bundles.at(f.id).print_presets == std::vector{f.prefix + "Print"}); +} + +TEST_CASE("External compatible printer references prevent local bundle replacement and removal", "[Preset][Bundle][Regression]") +{ + const bool filament = GENERATE(false, true); + const bool edited_only = GENERATE(false, true); + const bool remove = GENERATE(false, true); + LocalReloadFixture f; + PresetCollection& collection = filament ? f.bundle.filaments : f.bundle.prints; + const std::string external_name = filament ? "External Filament" : "External Print"; + add_inmemory_preset(collection, external_name); + collection.select_preset_by_name(external_name, true); + Preset& dependent = edited_only ? collection.get_edited_preset() : *collection.find_preset(external_name, false, true); + dependent.config.set_key_value("compatible_printers", new ConfigOptionStrings({f.prefix + "Printer"})); + if (!edited_only) + collection.select_preset_by_name(external_name, true); + collection.update_dirty(); + if (filament) + f.bundle.filament_presets = {external_name}; + f.bundle.update_compatible(PresetSelectCompatibleType::Never); + REQUIRE(collection.get_edited_preset().is_compatible); + const PresetBundle before(f.bundle); + if (remove) + REQUIRE(fs::remove(f.directory / PRESET_PRINTER_NAME / "Printer.json")); + else + f.change_sources(); + + CHECK_FALSE(f.bundle.reload_local_bundle("", f.id, &f.error)); + CHECK_THAT(f.error, Catch::Matchers::ContainsSubstring(external_name)); + check_reload_state(f.bundle, before); +} + +TEST_CASE("Local reload retains internal compatibility references and live vendor and calibration pointers", "[Preset][Bundle][Regression]") +{ + LocalReloadFixture f; + VendorProfile& vendor = f.bundle.vendors["TestVendor"]; + vendor.id = "TestVendor"; + Preset& external = add_inmemory_preset(f.bundle.filaments, "Vendor Filament"); + external.is_system = true; + external.vendor = &vendor; + f.bundle.set_calibrate_printer(f.prefix + "Printer"); + + DynamicPrintConfig config(f.bundle.filaments.default_preset().config); + config.set_key_value("compatible_prints", new ConfigOptionStrings({f.prefix + "Print"})); + config.set_key_value("compatible_printers", new ConfigOptionStrings({f.prefix + "Printer"})); + write_preset_with_inherits(config, f.directory / PRESET_FILAMENT_NAME / "Filament.json", "Filament", ""); + f.change_sources(); + REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error)); + CHECK(f.bundle.filaments.get_selected_preset_name() == f.prefix + "Filament"); + CHECK(f.bundle.filaments.get_edited_preset().is_compatible); + CHECK(f.bundle.vendors.at("TestVendor").id == vendor.id); + CHECK(f.bundle.filaments.find_preset("Vendor Filament")->vendor == &vendor); + CHECK(f.bundle.calibrate_printer == f.bundle.printers.find_preset(f.prefix + "Printer")); + CHECK(f.bundle.calibrate_filaments.count(f.bundle.filaments.find_preset("Vendor Filament")) == 1); + CHECK(f.bundle.calibrate_filaments.count(f.bundle.filaments.find_preset(f.prefix + "Filament", false, true)) == 1); +} + +TEST_CASE("Local reload preserves dirty filament and printer options over new source values", "[Preset][Bundle][Regression]") +{ + LocalReloadFixture f; + f.bundle.filaments.get_edited_preset().config.option("filament_flow_ratio")->values = {1.13}; + f.bundle.printers.get_edited_preset().config.option("nozzle_diameter")->values = {0.6}; + f.bundle.filaments.update_dirty(); + f.bundle.printers.update_dirty(); + DynamicPrintConfig filament_config(f.bundle.filaments.get_selected_preset().config); + filament_config.option("filament_flow_ratio")->values = {0.98}; + write_preset_with_inherits(filament_config, f.directory / PRESET_FILAMENT_NAME / "Filament.json", "Filament", ""); + DynamicPrintConfig printer_config(f.bundle.printers.get_selected_preset().config); + printer_config.option("nozzle_diameter")->values = {0.8}; + write_preset_with_inherits(printer_config, f.directory / PRESET_PRINTER_NAME / "Printer.json", "Printer", ""); + + REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error)); + check_double_vector(f.bundle.filaments.get_selected_preset().config.option("filament_flow_ratio")->values, {0.98}); + check_double_vector(f.bundle.filaments.get_edited_preset().config.option("filament_flow_ratio")->values, {1.13}); + check_double_vector(f.bundle.printers.get_selected_preset().config.option("nozzle_diameter")->values, {0.8}); + check_double_vector(f.bundle.printers.get_edited_preset().config.option("nozzle_diameter")->values, {0.6}); + CHECK(f.bundle.filaments.current_is_dirty()); + CHECK(f.bundle.printers.current_is_dirty()); +} + +TEST_CASE("Local reload rejects removal or rename of a dirty selected preset in every collection", "[Preset][Bundle][Regression]") +{ + const auto type = GENERATE(Preset::TYPE_PRINT, Preset::TYPE_FILAMENT, Preset::TYPE_PRINTER); + const bool rename = GENERATE(false, true); + LocalReloadFixture f; + PresetCollection& collection = type == Preset::TYPE_PRINT ? f.bundle.prints : + type == Preset::TYPE_FILAMENT ? f.bundle.filaments : + f.bundle.printers; + const char* directory = type == Preset::TYPE_PRINT ? PRESET_PRINT_NAME : + type == Preset::TYPE_FILAMENT ? PRESET_FILAMENT_NAME : + PRESET_PRINTER_NAME; + const std::string name = type == Preset::TYPE_PRINT ? "Print" : type == Preset::TYPE_FILAMENT ? "Filament" : "Printer"; + const char* key = type == Preset::TYPE_PRINT ? "layer_height" : + type == Preset::TYPE_FILAMENT ? "filament_flow_ratio" : + "nozzle_diameter"; + collection.get_edited_preset().config.set_deserialize_strict(key, "0.37"); + collection.update_dirty(); + REQUIRE(collection.current_is_dirty()); + const PresetBundle before(f.bundle); + REQUIRE(fs::remove(f.directory / directory / (name + ".json"))); + if (rename) + write_preset_with_inherits(collection.default_preset().config, f.directory / directory / "Renamed.json", "Renamed", ""); + + CHECK_FALSE(f.bundle.reload_local_bundle("", f.id, &f.error)); + CHECK_THAT(f.error, Catch::Matchers::ContainsSubstring("modified selected preset")); + check_reload_state(f.bundle, before); +} + +TEST_CASE("Local reload reconciles filament slots and purge volumes while retaining unrelated project options", + "[Preset][Bundle][Regression]") +{ + LocalReloadFixture f; + DynamicPrintConfig printer_config(f.bundle.printers.get_selected_preset().config); + printer_config.option("nozzle_diameter")->values = {0.4, 0.4}; + write_preset_with_inherits(printer_config, f.directory / PRESET_PRINTER_NAME / "Printer.json", "Printer", ""); + f.bundle.project_config.option("flush_volumes_matrix")->values = {0.}; + f.bundle.project_config.option("flush_volumes_vector")->values = {111., 222.}; + f.bundle.project_config.option("flush_multiplier")->values = {0.75}; + f.bundle.project_config.option("filament_colour")->values = {"#123456"}; + const ConfigOption* colour = f.bundle.project_config.option("filament_colour"); + + REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error)); + CHECK(f.bundle.filament_presets == std::vector{f.prefix + "Filament", f.prefix + "Filament"}); + check_double_vector(f.bundle.project_config.option("flush_multiplier")->values, {0.75, 1.}); + check_double_vector(f.bundle.project_config.option("flush_volumes_vector")->values, {111., 222., 111., 222.}); + check_double_vector(f.bundle.project_config.option("flush_volumes_matrix")->values, + {0., 333., 333., 0., 0., 333., 333., 0.}); + CHECK(f.bundle.project_config.option("filament_colour") == colour); + CHECK(f.bundle.project_config.option("filament_colour")->values == std::vector{"#123456"}); +} + +TEST_CASE("Local reload rejects a non-FFF printer selection without changing live presets", "[Preset][Bundle][Regression]") +{ + const bool from_source = GENERATE(false, true); + LocalReloadFixture f; + DynamicPrintConfig printer_config(f.bundle.printers.get_selected_preset().config); + printer_config.set_key_value("printer_technology", new ConfigOptionEnum(ptSLA)); + if (from_source) + write_preset_with_inherits(printer_config, f.directory / PRESET_PRINTER_NAME / "Printer.json", "Printer", ""); + else + f.bundle.printers.load_preset("", "Non-FFF Printer", printer_config, true); + const PresetBundle before(f.bundle); + + CHECK_FALSE(f.bundle.reload_local_bundle("", f.id, &f.error)); + CHECK_THAT(f.error, Catch::Matchers::ContainsSubstring(from_source ? "invalid preset" : "FFF printer selection")); + check_reload_state(f.bundle, before); + CHECK(f.bundle.bundles.m_bundles.at(f.id).printer_presets == std::vector{f.prefix + "Printer"}); +} + +TEST_CASE("Listing local bundles returns only ids that reload accepts", "[Preset][Bundle]") +{ + ScopedPresetDataDir data_dir; + PresetBundle bundle; + const fs::path local_dir = data_dir.bundle_dir("unused").parent_path(); + std::string error; + + CHECK(bundle.list_local_bundle_ids("").empty()); + CHECK_FALSE(fs::exists(local_dir)); + CHECK(bundle.list_local_bundle_ids("../outside").empty()); + + write_bundle_metadata(data_dir.bundle_dir("valid-b"), "valid-b"); + write_print_preset_with_layer_height(bundle.prints.default_preset().config, + data_dir.bundle_dir("valid-b") / PRESET_PRINT_NAME / "Listed Print.json", + "Listed Print", 0.20); + write_bundle_metadata(data_dir.bundle_dir("valid-a"), "valid-a"); + fs::create_directories(data_dir.bundle_dir("no-metadata")); + write_bundle_metadata(data_dir.bundle_dir("wrong-id"), "other-id"); + write_bundle_metadata(data_dir.bundle_dir("subscribed-folder"), "subscribed-folder"); + fs::create_directories(data_dir.tmp.path() / PRESET_USER_DIR / DEFAULT_USER_FOLDER_NAME / PRESET_SUBSCRIBED_DIR / + "subscribed-folder"); + write_bundle_metadata(data_dir.bundle_dir("subscribed-registry"), "subscribed-registry"); + BundleMetadata subscribed; + subscribed.id = "subscribed-registry"; + subscribed.bundle_type = BundleType::Subscribed; + bundle.bundles.m_bundles.emplace(subscribed.id, subscribed); + std::ofstream((local_dir / "stray-file.json").string()) << "{}"; + + const size_t print_count = bundle.prints.size(); + const std::vector expected{"valid-a", "valid-b"}; + CHECK(bundle.list_local_bundle_ids("") == expected); + CHECK(bundle.prints.size() == print_count); + CHECK_FALSE(fs::exists(data_dir.bundle_dir("no-metadata") / PRESET_BUNDLE_METADATA)); + + for (const std::string& rejected : {"no-metadata", "wrong-id", "subscribed-folder", "subscribed-registry"}) + CHECK_FALSE(bundle.reload_local_bundle("", rejected, &error)); + + REQUIRE(bundle.reload_local_bundle("", "valid-b", &error)); + CHECK(bundle.prints.find_preset(std::string(PRESET_LOCAL_DIR) + "/valid-b/Listed Print", false, true) != nullptr); +} + TEST_CASE("Selected printer uses its default or saved bed type", "[Preset][Bundle]") { PresetBundle bundle; @@ -5958,6 +6508,176 @@ TEST_CASE("Config import confines zip entries, preset names and bundle ids to th } } +namespace { +void change_json_value(const fs::path& path, const std::string& key, const std::string& value) +{ + nlohmann::json document; + { + std::ifstream input(path.string()); + input >> document; + } + document[key] = value; + std::ofstream(path.string()) << document.dump(2); +} +} // namespace + +TEST_CASE("Shrinking an edited vector survives local reload", "[Preset][Bundle][Regression]") +{ + LocalReloadFixture f; + DynamicPrintConfig config(f.bundle.printers.get_selected_preset().config); + config.option("nozzle_diameter")->values = {0.4, 0.4}; + write_preset_with_inherits(config, f.directory / PRESET_PRINTER_NAME / "Printer.json", "Printer", ""); + REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error)); + f.bundle.printers.get_edited_preset().config.option("nozzle_diameter")->values = {0.4}; + f.bundle.printers.update_dirty(); + REQUIRE(f.bundle.printers.current_is_dirty()); + REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error)); + CHECK(f.bundle.printers.get_edited_preset().config.option("nozzle_diameter")->values.size() == 1); + CHECK(f.bundle.printers.current_is_dirty()); +} + +TEST_CASE("Removing a selected printer preserves external dirty process edits", "[Preset][Bundle][Regression]") +{ + LocalReloadFixture f; + DynamicPrintConfig printer_config(f.bundle.printers.get_selected_preset().config); + printer_config.option("nozzle_diameter")->values = {0.6}; + write_preset_with_inherits(printer_config, f.directory / PRESET_PRINTER_NAME / "Printer.json", "Printer", ""); + REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error)); + Preset& external = add_inmemory_preset(f.bundle.prints, "External Process"); + external.config.set_key_value("compatible_printers_condition", new ConfigOptionString("nozzle_diameter[0] == 0.6")); + f.bundle.prints.select_preset_by_name("External Process", true); + f.bundle.update_compatible(PresetSelectCompatibleType::Never); + REQUIRE(f.bundle.prints.get_edited_preset().is_compatible); + f.bundle.prints.get_edited_preset().config.option("layer_height")->value = 0.27; + f.bundle.prints.update_dirty(); + REQUIRE(f.bundle.prints.current_is_dirty()); + REQUIRE(fs::remove(f.directory / PRESET_PRINTER_NAME / "Printer.json")); + const PresetBundle before(f.bundle); + const bool reloaded = f.bundle.reload_local_bundle("", f.id, &f.error); + CAPTURE(f.error); + CHECK_FALSE(reloaded); + check_reload_state(f.bundle, before); + CHECK(f.bundle.prints.get_selected_preset_name() == "External Process"); + CHECK_THAT(layer_height(f.bundle.prints.get_edited_preset()), Catch::Matchers::WithinAbs(0.27, 1e-6)); + CHECK(f.bundle.prints.current_is_dirty()); +} + +TEST_CASE("A preset with an invalid version does not silently disappear on reload", "[Preset][Bundle][Regression]") +{ + const auto type = GENERATE(Preset::TYPE_PRINT, Preset::TYPE_FILAMENT, Preset::TYPE_PRINTER); + LocalReloadFixture f; + const char* directory = type == Preset::TYPE_PRINT ? PRESET_PRINT_NAME : + type == Preset::TYPE_FILAMENT ? PRESET_FILAMENT_NAME : + PRESET_PRINTER_NAME; + const char* name = type == Preset::TYPE_PRINT ? "Print.json" : type == Preset::TYPE_FILAMENT ? "Filament.json" : "Printer.json"; + change_json_value(f.directory / directory / name, "version", "broken-version"); + const PresetBundle before(f.bundle); + const bool reloaded = f.bundle.reload_local_bundle("", f.id, &f.error); + CAPTURE(f.error); + CHECK_FALSE(reloaded); + check_reload_state(f.bundle, before); + CHECK(f.bundle.prints.find_preset(f.prefix + "Print", false, true) != nullptr); +} + +TEST_CASE("Hiding a selected source preset does not replay its edits onto another preset", "[Preset][Bundle][Regression]") +{ + LocalReloadFixture f; + add_inmemory_preset(f.bundle.prints, "External Process"); + f.bundle.prints.get_edited_preset().config.option("layer_height")->value = 0.27; + f.bundle.prints.update_dirty(); + REQUIRE(f.bundle.prints.current_is_dirty()); + change_json_value(f.directory / PRESET_PRINT_NAME / "Print.json", "instantiation", "false"); + const PresetBundle before(f.bundle); + const bool reloaded = f.bundle.reload_local_bundle("", f.id, &f.error); + CAPTURE(f.error); + CHECK_FALSE(reloaded); + check_reload_state(f.bundle, before); + CHECK(f.bundle.prints.get_selected_preset_name() == f.prefix + "Print"); + CHECK_THAT(layer_height(f.bundle.prints.get_edited_preset()), Catch::Matchers::WithinAbs(0.27, 1e-6)); + CHECK(f.bundle.prints.current_is_dirty()); +} + +TEST_CASE("Background subscription flags are retained at the reload commit", "[Preset][Bundle][Regression]") +{ + LocalReloadFixture f; + bool registered = false; + f.bundle.printers.get_edited_preset().config.set_key_value("machine_start_gcode", new ReloadEdit([&] { + std::async(std::launch::async, [&] { + std::unique_lock lock(f.bundle.bundles.RWMtx); + auto& metadata = f.bundle.bundles.m_bundles.at(f.id); + metadata.is_subscribed = true; + metadata.update_available = true; + }).get(); + registered = true; + })); + f.bundle.printers.update_dirty(); + const PresetBundle before(f.bundle); + const bool reloaded = f.bundle.reload_local_bundle("", f.id, &f.error); + REQUIRE(registered); + CAPTURE(f.error); + CHECK_FALSE(reloaded); + check_reload_state(f.bundle, before); + CHECK(f.bundle.bundles.m_bundles.at(f.id).is_subscribed); + CHECK(f.bundle.bundles.m_bundles.at(f.id).update_available); +} + +TEST_CASE("An unrelated stored preset remains at the address exposed to consumers", "[Preset][Bundle][Regression]") +{ + LocalReloadFixture f; + const std::string name = "External Process"; + const Preset* external = &add_inmemory_preset(f.bundle.prints, name); + const Preset* selected = &f.bundle.prints.get_selected_preset(); + const Preset* edited = &f.bundle.prints.get_edited_preset(); + REQUIRE(f.bundle.prints.get_selected_preset_name() != name); + write_print_preset_with_layer_height(f.bundle.prints.default_preset().config, f.directory / PRESET_PRINT_NAME / "Print.json", "Print", + 0.30); + REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error)); + REQUIRE(f.bundle.prints.find_preset(name, false, true) == external); + REQUIRE(&f.bundle.prints.get_selected_preset() == selected); + REQUIRE(&f.bundle.prints.get_edited_preset() == edited); + CHECK(external->name == name); + CHECK_THAT(layer_height(*selected), Catch::Matchers::WithinAbs(0.30, 1e-6)); + CHECK_THAT(layer_height(*edited), Catch::Matchers::WithinAbs(0.30, 1e-6)); +} +TEST_CASE("Calibration compatibility reflects the reloaded printer settings", "[Preset][Bundle][Regression]") +{ + LocalReloadFixture f; + Preset& external = add_inmemory_preset(f.bundle.filaments, "Calibration Filament"); + external.config.set_key_value("compatible_printers_condition", new ConfigOptionString("nozzle_diameter[0] == 0.4")); + f.bundle.set_calibrate_printer(f.prefix + "Printer"); + REQUIRE(f.bundle.calibrate_filaments.count(&external) == 1); + DynamicPrintConfig config(f.bundle.printers.get_selected_preset().config); + config.option("nozzle_diameter")->values = {0.6}; + write_preset_with_inherits(config, f.directory / PRESET_PRINTER_NAME / "Printer.json", "Printer", ""); + REQUIRE(f.bundle.reload_local_bundle("", f.id, &f.error)); + const Preset* filament = f.bundle.filaments.find_preset("Calibration Filament", false, true); + REQUIRE(filament != nullptr); + REQUIRE_FALSE(is_compatible_with_printer(f.bundle.filaments.get_preset_with_vendor_profile(*filament), + f.bundle.printers.get_preset_with_vendor_profile(*f.bundle.calibrate_printer))); + CHECK(f.bundle.calibrate_filaments.count(filament) == 0); +} +TEST_CASE("Local reload rolls back prepared collection growth on a late allocation failure", "[Preset][Bundle][Regression]") +{ + LocalReloadFixture f; + const size_t original_print_count = f.bundle.prints.size(); + bool failed = false; + Preset& external = add_inmemory_preset(f.bundle.printers, "zzzz External Printer"); + external.config.set_key_value("machine_start_gcode", new ReloadEdit([&] { + if (f.bundle.prints.size() > original_print_count) { + failed = true; + throw std::bad_alloc(); + } + })); + const PresetBundle before(f.bundle); + f.change_sources(); + write_print_preset_with_layer_height(f.bundle.prints.default_preset().config, f.directory / PRESET_PRINT_NAME / "Added Print.json", + "Added Print", 0.25); + CHECK_FALSE(f.bundle.reload_local_bundle("", f.id, &f.error)); + REQUIRE(failed); + check_reload_state(f.bundle, before); + CHECK(f.bundle.bundles.m_bundles.at(f.id).bundle_type == BundleType::Local); +} + namespace { const std::string kTextureBytes("\x89PNG\x00tex", 8); diff --git a/tests/slic3rutils/test_plugin_host_api.cpp b/tests/slic3rutils/test_plugin_host_api.cpp index 0841a130d7..301acbc7cc 100644 --- a/tests/slic3rutils/test_plugin_host_api.cpp +++ b/tests/slic3rutils/test_plugin_host_api.cpp @@ -56,6 +56,14 @@ TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Pytho REQUIRE(has_attr(host, "Model")); REQUIRE(has_attr(host, "ModelObject")); REQUIRE(has_attr(host, "Plater")); + REQUIRE(has_attr(host, "preset")); + py::object preset = host.attr("preset"); + REQUIRE(has_attr(preset, "bundles")); + py::object bundles = preset.attr("bundles"); + CHECK(has_attr(bundles, "list_ids")); + CHECK(has_attr(bundles, "reload_local")); + CHECK_FALSE(has_attr(host, "reload_local_bundle")); + CHECK_FALSE(has_attr(host, "list_bundle_ids")); py::object preset_bundle_type = host.attr("PresetBundle"); CHECK(has_attr(preset_bundle_type, "prints")); @@ -132,6 +140,7 @@ TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Pytho TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initialization", "[PluginHost][Python]") { py::object host = import_orca_module().attr("host"); + py::object bundles = host.attr("preset").attr("bundles"); for (const char* function_name : { "preset_bundle", "plater", "model", "app_info", "selected_printer" }) { CAPTURE(function_name); @@ -143,6 +152,22 @@ TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initi CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos); } } + + try { + bundles.attr("reload_local")("test-bundle"); + FAIL("host.preset.bundles.reload_local unexpectedly succeeded without a wx application"); + } catch (const py::error_already_set& error) { + CHECK(error.matches(PyExc_RuntimeError)); + CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos); + } + + try { + bundles.attr("list_ids")(); + FAIL("host.preset.bundles.list_ids unexpectedly succeeded without a wx application"); + } catch (const py::error_already_set& error) { + CHECK(error.matches(PyExc_RuntimeError)); + CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos); + } } TEST_CASE("Plugin host API reports no GL details before the 3D view exists", "[PluginHost][Python]")