#include #include #include #include #include "libslic3r/PresetBundle.hpp" #include "libslic3r/AppConfig.hpp" #include "test_utils.hpp" using namespace Slic3r; namespace { namespace fs = boost::filesystem; void write_print_preset(const DynamicPrintConfig &default_config, const fs::path &file, const std::string &name, const std::string &inherits = {}) { DynamicPrintConfig config(default_config); config.option("print_settings_id", true)->value = name; config.option(BBL_JSON_KEY_INHERITS, true)->value = inherits; fs::create_directories(file.parent_path()); config.save_to_json(file.string(), name, "User", "1.0.0"); } // Write a preset json carrying a name and an "inherits" value, using the given collection's // default config so it loads back into that collection. Works for any preset type. void write_preset_with_inherits(const DynamicPrintConfig &default_config, const fs::path &file, const std::string &name, const std::string &inherits) { DynamicPrintConfig config(default_config); config.option(BBL_JSON_KEY_INHERITS, true)->value = inherits; fs::create_directories(file.parent_path()); config.save_to_json(file.string(), name, "User", "1.0.0"); } // Add an in-memory preset (no file) with the given inherits value (empty => root preset). Preset &add_inmemory_preset(PresetCollection &coll, const std::string &name, const std::string &inherits = {}) { DynamicPrintConfig config(coll.default_preset().config); config.option(BBL_JSON_KEY_INHERITS, true)->value = inherits; return coll.load_preset(std::string(), name, config, /*select=*/false); } // Mark an already-loaded preset as renamed from one or more former names. void set_renamed_from(PresetCollection &coll, const std::string &preset_name, std::vector old_names) { for (auto it = coll.begin(); it != coll.end(); ++it) if (it->name == preset_name) it->renamed_from = std::move(old_names); } // A standalone print preset collection that exposes the protected rename-map builder, so a // renamed_from scenario can be set up without the full system-profile load pipeline. // (PresetCollection is non-copyable - it holds a mutex - so it is constructed directly with // the same type/keys/defaults PresetBundle uses for its print collection.) struct RenameTestCollection : public PresetCollection { RenameTestCollection() : PresetCollection(Preset::TYPE_PRINT, Preset::print_options(), static_cast(FullPrintConfig::defaults())) {} using PresetCollection::update_map_system_profile_renamed; }; } // namespace TEST_CASE("Preset identity is canonicalized from load path", "[Preset][Identity]") { ScopedTemporaryDir temp_dir; PresetBundle bundle; PresetsConfigSubstitutions substitutions; write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_PRINT_NAME / "User.json", "User"); write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1" / PRESET_PRINT_NAME / "LocalBundle.json", "LocalBundle"); write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1" / PRESET_PRINT_NAME / "Subscribed.json", "Subscribed"); bundle.prints.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable); bundle.prints.load_presets((temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable); bundle.prints.load_presets((temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable); const Preset *root_user = bundle.prints.find_preset("User"); REQUIRE(root_user != nullptr); CHECK(root_user->name == "User"); CHECK_FALSE(root_user->is_from_bundle()); const Preset *local_bundle = bundle.prints.find_preset("_local/bundle-1/LocalBundle"); REQUIRE(local_bundle != nullptr); CHECK(local_bundle->name == "_local/bundle-1/LocalBundle"); CHECK(local_bundle->is_from_bundle()); const Preset *subscribed = bundle.prints.find_preset("_subscribed/remote-1/Subscribed"); REQUIRE(subscribed != nullptr); CHECK(subscribed->name == "_subscribed/remote-1/Subscribed"); CHECK(subscribed->is_from_bundle()); } TEST_CASE("Legacy bundle import without bundle metadata stays in the user preset directory", "[Preset][Identity]") { ScopedTemporaryDir temp_dir; PresetBundle bundle; PresetsConfigSubstitutions substitutions; std::vector result; int overwrite = 0; std::string file = (temp_dir.path() / "legacy-bundle" / "Imported.json").string(); const fs::path user_root = temp_dir.path() / "user"; write_print_preset(bundle.prints.default_preset().config, file, "Imported"); fs::create_directories(user_root); bundle.prints.update_user_presets_directory(user_root.string(), PRESET_PRINT_NAME); REQUIRE(bundle.import_json_presets( substitutions, file, [](std::string const &) { return 1; }, ForwardCompatibilitySubstitutionRule::Disable, overwrite, result)); const Preset *imported = bundle.prints.find_preset("Imported"); REQUIRE(imported != nullptr); CHECK(imported->name == "Imported"); CHECK(imported->bundle_id.empty()); CHECK_FALSE(imported->is_from_bundle()); // Detached user presets (no inherits) are saved in the "base" subfolder of the user preset root. CHECK(fs::equivalent(fs::path(imported->file).parent_path().parent_path(), user_root / PRESET_PRINT_NAME)); } TEST_CASE("Current vendor type tolerates missing printer model", "[Preset][Bundle]") { PresetBundle bundle; VendorProfile orca_vendor; orca_vendor.id = "ORCA"; VendorProfile::PrinterModel model; model.name = "Orca Test"; orca_vendor.models.emplace_back(model); bundle.vendors.emplace("ORCA", std::move(orca_vendor)); bundle.printers.get_edited_preset().config.erase("printer_model"); CHECK(bundle.get_current_vendor_type() == VendorType::Unknown); } TEST_CASE("A malformed entry in a vendor's preset list is counted, not thrown", "[Preset][Bundle]") { ScopedTemporaryDir dir; // A bare number where the list wants an object. An array element has no key, // so reporting one as if it did throws nlohmann's invalid_iterator - which is // not a parse_error, and escapes the catch around the vendor profile parse. std::ofstream((dir.path() / "Acme.json").string()) << R"({"version":"1.0.0","name":"Acme","process_list":[123,)" << R"({"name":"0.20mm Standard @Acme","sub_path":"process/standard.json"}]})"; fs::create_directories(dir.path() / "Acme" / "process"); std::ofstream((dir.path() / "Acme" / "process" / "standard.json").string()) << R"({"type":"process","name":"0.20mm Standard @Acme","from":"system",)" << R"("instantiation":"true","layer_height":"0.2"})"; PresetBundle bundle; size_t loaded = 0; REQUIRE_NOTHROW(loaded = bundle.load_vendor_configs_from_json( dir.path().string(), "Acme", PresetBundle::LoadSystem, ForwardCompatibilitySubstitutionRule::EnableSilent).second); CHECK(bundle.error_count() > 0); // the malformed element was counted CHECK(loaded == 1); // the well-formed one beside it still loaded } TEST_CASE("Printer extruder count tolerates missing nozzle diameter", "[Preset][Bundle]") { PresetBundle bundle; DynamicPrintConfig& config = bundle.printers.get_edited_preset().config; config.erase("nozzle_diameter"); CHECK(bundle.get_printer_extruder_count() == 1); config.set_key_value("nozzle_diameter", new ConfigOptionFloats()); CHECK(bundle.get_printer_extruder_count() == 1); config.set_key_value("nozzle_diameter", new ConfigOptionFloats({ 0.4, 0.6 })); CHECK(bundle.get_printer_extruder_count() == 2); } TEST_CASE("find_preset resolves a system preset's renamed_from", "[Preset][Rename]") { RenameTestCollection coll; // "New Process" is the current preset; it was renamed from "Old Process". add_inmemory_preset(coll, "New Process"); set_renamed_from(coll, "New Process", { "Old Process" }); coll.update_map_system_profile_renamed(); // The rename map knows the old name... const std::string *renamed = coll.get_preset_name_renamed("Old Process"); REQUIRE(renamed != nullptr); CHECK(*renamed == "New Process"); // ...and plain find_preset() now follows it (the core of this PR; previously this // resolution lived only in find_preset2 and a few call sites). const Preset *resolved = coll.find_preset("Old Process"); REQUIRE(resolved != nullptr); CHECK(resolved->name == "New Process"); // A genuinely unknown name still returns null (no spurious match). CHECK(coll.find_preset("Totally Unknown") == nullptr); // A child that still inherits the OLD name resolves through the runtime walker, // which uses plain find_preset(). Preset &child = add_inmemory_preset(coll, "Child Process", "Old Process"); const Preset *parent = coll.get_preset_parent(child); REQUIRE(parent != nullptr); CHECK(parent->name == "New Process"); } TEST_CASE("find_preset resolves a preset renamed more than once", "[Preset][Rename]") { RenameTestCollection coll; // "New Process" was renamed twice, so it carries both former names in renamed_from. add_inmemory_preset(coll, "New Process"); set_renamed_from(coll, "New Process", { "Original Process", "Old Process" }); coll.update_map_system_profile_renamed(); // Each historical name resolves to the current preset. for (const char *old_name : { "Original Process", "Old Process" }) { INFO("resolving old name: " << old_name); const std::string *renamed = coll.get_preset_name_renamed(old_name); REQUIRE(renamed != nullptr); CHECK(*renamed == "New Process"); const Preset *resolved = coll.find_preset(old_name); REQUIRE(resolved != nullptr); CHECK(resolved->name == "New Process"); } // A child inheriting either former name resolves through the runtime walker. Preset &child = add_inmemory_preset(coll, "Child Process", "Original Process"); REQUIRE(coll.get_preset_parent(child) != nullptr); CHECK(coll.get_preset_parent(child)->name == "New Process"); } TEST_CASE("find_preset2 auto-matches removed Generic vendor profiles to the library", "[Preset][Rename]") { PresetBundle bundle; // The OrcaFilamentLibrary replacement that removed empty " Generic" profiles map to. add_inmemory_preset(bundle.filaments, "Generic PLA @System"); // Plain lookups do NOT fuzzy-match a removed vendor profile. CHECK(bundle.filaments.find_preset("Voron Generic PLA") == nullptr); CHECK(bundle.filaments.find_preset2("Voron Generic PLA", /*auto_match=*/false) == nullptr); // With auto_match, the removed "Voron Generic PLA" resolves to "Generic PLA @System". const Preset *matched = bundle.filaments.find_preset2("Voron Generic PLA", /*auto_match=*/true); REQUIRE(matched != nullptr); CHECK(matched->name == "Generic PLA @System"); // No library preset exists for an unrelated material => still no match. CHECK(bundle.filaments.find_preset2("BrandX Generic PETG", /*auto_match=*/true) == nullptr); } TEST_CASE("Renamed parent is normalized into a loaded preset's inherits", "[Preset][Rename]") { ScopedTemporaryDir temp_dir; RenameTestCollection coll; // Current parent, renamed from "Old Process". add_inmemory_preset(coll, "New Process"); set_renamed_from(coll, "New Process", { "Old Process" }); coll.update_map_system_profile_renamed(); // A user preset on disk that still inherits the OLD name. write_preset_with_inherits(coll.default_preset().config, temp_dir.path() / PRESET_PRINT_NAME / "Child.json", "Child", "Old Process"); PresetsConfigSubstitutions substitutions; coll.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable); const Preset *child = coll.find_preset("Child"); REQUIRE(child != nullptr); // The dangling "Old Process" was rewritten to the resolved parent name at load time, // so the runtime walker (plain find_preset) can resolve the chain. CHECK(child->inherits() == "New Process"); REQUIRE(coll.get_preset_parent(*child) != nullptr); CHECK(coll.get_preset_parent(*child)->name == "New Process"); } TEST_CASE("Removed Generic parent is normalized into a loaded filament's inherits", "[Preset][Rename]") { ScopedTemporaryDir temp_dir; PresetBundle bundle; add_inmemory_preset(bundle.filaments, "Generic PLA @System"); // A user filament that still inherits a removed " Generic PLA" profile. write_preset_with_inherits(bundle.filaments.default_preset().config, temp_dir.path() / PRESET_FILAMENT_NAME / "MyPLA.json", "MyPLA", "Voron Generic PLA"); PresetsConfigSubstitutions substitutions; bundle.filaments.load_presets(temp_dir.path().string(), PRESET_FILAMENT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable); const Preset *child = bundle.filaments.find_preset("MyPLA"); REQUIRE(child != nullptr); CHECK(child->inherits() == "Generic PLA @System"); REQUIRE(bundle.filaments.get_preset_parent(*child) != nullptr); CHECK(bundle.filaments.get_preset_parent(*child)->name == "Generic PLA @System"); } namespace { // A live reference to a preset's compatible_printers / compatible_prints list. Fetches the *stored* // preset (real=true) so writes and reads hit the same object; creates the option if absent. std::vector &compatible_list(PresetCollection &coll, const std::string &preset_name, const char *field_key) { Preset *preset = coll.find_preset(preset_name, /*first_visible_if_not_found=*/false, /*real=*/true); REQUIRE(preset != nullptr); return preset->config.option(field_key, true)->values; } } // namespace TEST_CASE("Renamed printer/process names are normalized into compatible lists on load", "[Preset][Rename]") { PresetBundle bundle; // Current printer + process, each renamed from an older name. add_inmemory_preset(bundle.printers, "New Printer"); set_renamed_from(bundle.printers, "New Printer", { "Old Printer" }); add_inmemory_preset(bundle.prints, "New Process"); set_renamed_from(bundle.prints, "New Process", { "Old Process" }); // A user process still compatible with the OLD printer name. add_inmemory_preset(bundle.prints, "My Process"); compatible_list(bundle.prints, "My Process", "compatible_printers") = { "Old Printer" }; // A user filament referencing the OLD printer AND OLD process names, plus an unknown printer. add_inmemory_preset(bundle.filaments, "My Filament"); compatible_list(bundle.filaments, "My Filament", "compatible_printers") = { "Old Printer", "Unknown Printer" }; compatible_list(bundle.filaments, "My Filament", "compatible_prints") = { "Old Process" }; // Build the rename maps (done during system load in the real pipeline), then normalize. AppConfig app_config; bundle.load_installed_printers(app_config); // rebuilds every collection's rename map bundle.normalize_compatible_presets(); // The stale printer name in a process' compatible_printers is rewritten to the current name. CHECK(compatible_list(bundle.prints, "My Process", "compatible_printers") == std::vector{ "New Printer" }); // The stale process name in a filament's compatible_prints is rewritten (this field has no // runtime rename fallback, so load-time normalization is the only fix). CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_prints") == std::vector{ "New Process" }); // The renamed printer is rewritten while the unknown/deleted name is preserved as-is. CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_printers") == (std::vector{ "New Printer", "Unknown Printer" })); // Normalizing rewrites config in place without flagging the preset dirty. CHECK_FALSE(bundle.prints.find_preset("My Process", false, true)->is_dirty); // A system preset that already references the current name is left untouched (idempotent no-op). bundle.normalize_compatible_presets(); CHECK(compatible_list(bundle.prints, "My Process", "compatible_printers") == std::vector{ "New Printer" }); } TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists", "[Preset][Rename]") { PresetBundle bundle; // Current printer + process, each renamed from an older name. add_inmemory_preset(bundle.printers, "New Printer"); set_renamed_from(bundle.printers, "New Printer", { "Old Printer" }); add_inmemory_preset(bundle.prints, "New Process"); set_renamed_from(bundle.prints, "New Process", { "Old Process" }); // A *system* (vendor) filament whose own compatible lists still reference the OLD names. A vendor // profile can point at a sibling preset that was later renamed, so system presets must be // normalized too (they are skipped by neither collection walk). add_inmemory_preset(bundle.filaments, "System Filament").is_system = true; compatible_list(bundle.filaments, "System Filament", "compatible_printers") = { "Old Printer" }; compatible_list(bundle.filaments, "System Filament", "compatible_prints") = { "Old Process" }; AppConfig app_config; bundle.load_installed_printers(app_config); // build the rename maps bundle.normalize_compatible_presets(); // The stale references in the system preset are rewritten to the current names. CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") == std::vector{ "New Printer" }); CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_prints") == std::vector{ "New Process" }); // The rewrite does not flag the system preset dirty, and is idempotent. CHECK_FALSE(bundle.filaments.find_preset("System Filament", false, true)->is_dirty); bundle.normalize_compatible_presets(); CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") == std::vector{ "New Printer" }); } TEST_CASE("compatible_prints on SLA materials resolves against sla_prints, not prints", "[Preset][Rename]") { PresetBundle bundle; // A renamed SLA process, and a same-named FFF process that must NOT be picked up: resolving the // SLA material's compatible_prints against `prints` would wrongly rewrite to "Wrong FFF Process". add_inmemory_preset(bundle.sla_prints, "New SLA Process"); set_renamed_from(bundle.sla_prints, "New SLA Process", { "Old SLA Process" }); add_inmemory_preset(bundle.prints, "Wrong FFF Process"); set_renamed_from(bundle.prints, "Wrong FFF Process", { "Old SLA Process" }); add_inmemory_preset(bundle.sla_materials, "My SLA Material"); compatible_list(bundle.sla_materials, "My SLA Material", "compatible_prints") = { "Old SLA Process" }; AppConfig app_config; bundle.load_installed_printers(app_config); bundle.normalize_compatible_presets(); CHECK(compatible_list(bundle.sla_materials, "My SLA Material", "compatible_prints") == std::vector{ "New SLA Process" }); } TEST_CASE("Profile validator flags dangling and renamed preset references", "[Preset][Validate]") { PresetBundle bundle; // Current printers: a real one, and a renamed one (its old name resolves via renamed_from). add_inmemory_preset(bundle.printers, "Real Printer"); add_inmemory_preset(bundle.printers, "New Printer"); set_renamed_from(bundle.printers, "New Printer", { "Old Printer" }); // A real process, referenced from a filament's compatible_prints. add_inmemory_preset(bundle.prints, "Real Process").is_system = true; // A fully valid system filament: references only current names. add_inmemory_preset(bundle.filaments, "Good Filament").is_system = true; compatible_list(bundle.filaments, "Good Filament", "compatible_printers") = { "Real Printer" }; compatible_list(bundle.filaments, "Good Filament", "compatible_prints") = { "Real Process" }; AppConfig app_config; bundle.load_installed_printers(app_config); // build the rename maps // With only valid references, the validator is clean. CHECK_FALSE(bundle.check_preset_references()); SECTION("deleted compatible_printers is flagged") { add_inmemory_preset(bundle.filaments, "Ghost Ref Filament").is_system = true; compatible_list(bundle.filaments, "Ghost Ref Filament", "compatible_printers") = { "Ghost Printer" }; CHECK(bundle.check_preset_references()); } SECTION("renamed compatible_printers (old name) is flagged") { add_inmemory_preset(bundle.filaments, "Old Ref Filament").is_system = true; compatible_list(bundle.filaments, "Old Ref Filament", "compatible_printers") = { "Old Printer" }; CHECK(bundle.check_preset_references()); } SECTION("deleted compatible_prints is flagged") { add_inmemory_preset(bundle.filaments, "Bad Process Ref").is_system = true; compatible_list(bundle.filaments, "Bad Process Ref", "compatible_prints") = { "Ghost Process" }; CHECK(bundle.check_preset_references()); } SECTION("deleted inherits parent is flagged") { add_inmemory_preset(bundle.filaments, "Orphan Filament", "Ghost Parent").is_system = true; CHECK(bundle.check_preset_references()); } SECTION("non-system preset with a dangling reference is ignored") { add_inmemory_preset(bundle.filaments, "User Filament"); // is_system stays false compatible_list(bundle.filaments, "User Filament", "compatible_printers") = { "Ghost Printer" }; CHECK_FALSE(bundle.check_preset_references()); } } // Under a shared override key, the last preset merged into the full config overwrote the others', so an // edited slicing-pipeline override never reached Print::apply's diff and re-configuring a plugin never // re-sliced. Per-type keys make that collision impossible; guard the scoping here. TEST_CASE("Plugin capability override keys are scoped per preset type", "[Preset][Plugin]") { // Pin the key names: presets and 3mf files store them verbatim, so a rename is a format change. CHECK(Preset::plugin_overrides_key(Preset::TYPE_PRINT) == std::string("print_plugin_config_overrides")); CHECK(Preset::plugin_overrides_key(Preset::TYPE_PRINTER) == std::string("printer_plugin_config_overrides")); CHECK(Preset::plugin_overrides_key(Preset::TYPE_FILAMENT) == std::string("filament_plugin_config_overrides")); // ...and each key lives on exactly its own preset type's option list, so no two ever share a slot. const std::pair*> scopes[] = { {Preset::TYPE_PRINT, &Preset::print_options()}, {Preset::TYPE_PRINTER, &Preset::printer_options()}, {Preset::TYPE_FILAMENT, &Preset::filament_options()}, }; for (const auto &owner : scopes) for (const auto &scoped : scopes) { const std::string key = Preset::plugin_overrides_key(scoped.first); CAPTURE(owner.first, key); CHECK(contains(*owner.second, key) == (owner.first == scoped.first)); } } namespace { // A standalone filament collection that exposes the protected library masking builder, so the Orca // Filament Library scenario can be set up without the full system-profile load pipeline. struct LibraryFilamentTestCollection : public PresetCollection { LibraryFilamentTestCollection() : PresetCollection(Preset::TYPE_FILAMENT, Preset::filament_options(), static_cast(FullPrintConfig::defaults())) {} using PresetCollection::update_library_profile_excluded_from; }; } // namespace // Orca: a filament in the Orca Filament Library that names its compatible printers has to hide the generic // library filament sharing its alias, the same way a vendor owned filament does. Otherwise both are compatible // with that printer and the plater combo box lists the shared alias twice. TEST_CASE("A printer specific filament supersedes the generic library filament with the same alias", "[Preset][Bundle]") { LibraryFilamentTestCollection filaments; PresetCollection printers(Preset::TYPE_PRINTER, Preset::printer_options(), static_cast(FullPrintConfig::defaults())); // The masking keys off the vendor name, which VendorProfile's constructor does not derive from the id. VendorProfile library(PresetBundle::ORCA_FILAMENT_LIBRARY); VendorProfile vendor("Vendor"); library.name = PresetBundle::ORCA_FILAMENT_LIBRARY; vendor.name = "Vendor"; auto add_filament = [&filaments](const VendorProfile &owner, const std::string &name, std::vector compatible_printers) { Preset &preset = add_inmemory_preset(filaments, name); preset.alias = "Generic ABS"; preset.vendor = &owner; preset.config.option("compatible_printers", true)->values = std::move(compatible_printers); }; add_filament(library, "Generic ABS @System", {}); add_filament(library, "Generic ABS @Printer A", { "Printer A" }); add_filament(vendor, "Generic ABS @Printer B", { "Printer B" }); filaments.update_library_profile_excluded_from(); const Preset *generic = filaments.find_preset("Generic ABS @System"); REQUIRE(generic != nullptr); CHECK(generic->m_excluded_from.count("Printer A") == 1); CHECK(generic->m_excluded_from.count("Printer B") == 1); CHECK(generic->m_excluded_from.size() == 2); // A printer specific profile names printers, so it is never the one being hidden - not even by itself. const Preset *specific = filaments.find_preset("Generic ABS @Printer A"); REQUIRE(specific != nullptr); CHECK(specific->m_excluded_from.empty()); // ...and the generic profile really drops out of the compatible set on the printer it is hidden from. add_inmemory_preset(printers, "Printer A"); add_inmemory_preset(printers, "Printer C"); const Preset *printer_a = printers.find_preset("Printer A"); const Preset *printer_c = printers.find_preset("Printer C"); REQUIRE(printer_a != nullptr); REQUIRE(printer_c != nullptr); const PresetWithVendorProfile generic_lib(*generic, &library); CHECK_FALSE(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_a, nullptr))); CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr))); } namespace { // One system printer plus the filament presets a machine facing dialog has to choose between: // an Orca Filament Library generic with no compatible_printers, a same alias vendor filament // that names the printer, a library filament with no vendor twin, and a vendor filament that // belongs to a different printer. struct MachineFilaments { PresetBundle bundle; VendorProfile library{PresetBundle::ORCA_FILAMENT_LIBRARY}; VendorProfile vendor{"Vendor"}; MachineFilaments() { // VendorProfile's constructor takes an id; the library rule keys off the name. library.name = PresetBundle::ORCA_FILAMENT_LIBRARY; vendor.name = "Vendor"; Preset &printer = add_inmemory_preset(bundle.printers, "Printer A 0.4 nozzle"); printer.is_system = true; printer.vendor = &vendor; printer.config.option("printer_model", true)->value = "Printer A"; add_filament(library, "Generic ABS @System", "Generic ABS", {}); add_filament(vendor, "Generic ABS @Printer A", "Generic ABS", { "Printer A 0.4 nozzle" }); add_filament(library, "FilAr ABS @System", "FilAr ABS", {}); add_filament(vendor, "Vendor PLA @Printer B", "Vendor PLA", { "Printer B 0.4 nozzle" }); // update_library_profile_excluded_from() is protected and has its own test above; record // the exclusion it derives from the same alias vendor filament. Preset *shadowed = bundle.filaments.find_preset("Generic ABS @System"); REQUIRE(shadowed != nullptr); shadowed->m_excluded_from.insert("Printer A 0.4 nozzle"); } void add_filament(const VendorProfile &owner, const std::string &name, const std::string &alias, std::vector compatible_printers) { Preset &preset = add_inmemory_preset(bundle.filaments, name); preset.is_system = true; preset.alias = alias; preset.vendor = &owner; compatible_list(bundle.filaments, name, "compatible_printers") = std::move(compatible_printers); } bool offers(const std::string &preset_name, bool include_user_presets = false) { const std::vector offered = bundle.get_filament_presets_for_machine("Printer A", "0.4", include_user_presets); return std::any_of(offered.begin(), offered.end(), [&preset_name](const Preset *p) { return p->name == preset_name; }); } }; } // namespace TEST_CASE("Filaments offered for a machine follow the app's compatibility rule", "[Preset][Bundle]") { MachineFilaments f; SECTION("a library filament with no compatible_printers is offered") { CHECK(f.offers("FilAr ABS @System")); } SECTION("a same alias vendor filament shadows the library generic") { CHECK(f.offers("Generic ABS @Printer A")); CHECK_FALSE(f.offers("Generic ABS @System")); } SECTION("a filament naming a different printer is not offered") { CHECK_FALSE(f.offers("Vendor PLA @Printer B")); } SECTION("a user filament is offered only when the printer supports user presets") { add_inmemory_preset(f.bundle.filaments, "My PLA"); CHECK_FALSE(f.offers("My PLA", /*include_user_presets=*/false)); CHECK(f.offers("My PLA", /*include_user_presets=*/true)); } } namespace { const char *kMixedKeys[] = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios", "filament_mixed_gradient", "filament_mixed_gradient_range", "filament_mixed_gradient_curve", "filament_mixed_gradient_per_part", }; } // namespace // Mixed-color filament metadata lives in project_config as parallel per-filament arrays. // set_num_filaments() is the single place that grows them alongside filament_colour; if it // misses them, creating a mixed slot writes past the end of the short arrays. TEST_CASE("set_num_filaments keeps mixed-color arrays in step with the filament count", "[Preset][Bundle][FilamentMixer]") { auto mixed_array_size = [](const DynamicPrintConfig &cfg, const std::string &key) -> size_t { if (const auto *b = cfg.option(key)) return b->values.size(); if (const auto *s = cfg.option(key)) return s->values.size(); return size_t(-1); // key missing entirely }; PresetBundle bundle; const unsigned int n = GENERATE(2u, 4u, 8u); bundle.set_num_filaments(n, std::string("#FF0000")); REQUIRE(bundle.project_config.option("filament_colour")->values.size() == n); for (const char *key : kMixedKeys) { DYNAMIC_SECTION("grown: " << key) { CHECK(mixed_array_size(bundle.project_config, key) == n); } } SECTION("shrinking keeps them in step too") { bundle.set_num_filaments(1, std::string("#00FF00")); REQUIRE(bundle.project_config.option("filament_colour")->values.size() == 1); for (const char *key : kMixedKeys) CHECK(mixed_array_size(bundle.project_config, key) == 1); } } // A mix is described by 1-based indices into the project's filament list, which Orca rebuilds // from the selected printer's snapshot (filament_%02u / filament_colors) at startup and on every // printer selection. Held anywhere but that same per-printer snapshot, the mixed arrays end up // indexing a filament list they were never saved against. TEST_CASE("Mixed-color filament metadata is snapshotted per printer, with its filament list", "[Preset][Bundle][FilamentMixer]") { PresetBundle bundle; // export_selections skips the built-in "Default Printer" placeholder entirely. add_inmemory_preset(bundle.printers, "Test Printer"); bundle.printers.select_preset_by_name("Test Printer", true); bundle.set_num_filaments(2u, std::string("#FF0000")); bundle.project_config.option("filament_is_mixed")->values = { false, true }; bundle.project_config.option("filament_mixed_components")->values = { "", "1,2" }; bundle.project_config.option("filament_mixed_sublayer_ratios")->values = { "", "0.5,0.5" }; AppConfig app_config; bundle.export_selections(app_config); const std::string printer_name = bundle.printers.get_selected_preset_name(); for (const char *key : kMixedKeys) { DYNAMIC_SECTION("per printer, not global: " << key) { CHECK(app_config.has_printer_setting(printer_name, key)); CHECK_FALSE(app_config.has("presets", key)); } } SECTION("with the encoding load_selections reads back") { CHECK(app_config.get_printer_setting(printer_name, "filament_is_mixed") == "0,1"); CHECK(app_config.get_printer_setting(printer_name, "filament_mixed_components") == "|1,2"); CHECK(app_config.get_printer_setting(printer_name, "filament_mixed_sublayer_ratios") == "|0.5,0.5"); } } // The gradient curve is the one mixed array whose values contain '|' themselves — it separates the // control points — so it cannot be '|'-joined into the app config like its siblings without a // multi-point curve being split across filament slots on the way back in. TEST_CASE("A multi-point gradient curve survives the app-config snapshot", "[Preset][Bundle][FilamentMixer]") { const std::vector curves = { "", "", "0,0|0.5,0.3|1,1" }; PresetBundle bundle; add_inmemory_preset(bundle.printers, "Test Printer"); bundle.printers.select_preset_by_name("Test Printer", true); bundle.set_num_filaments(3u, std::string("#FF0000")); bundle.project_config.option("filament_mixed_gradient_curve")->values = curves; AppConfig app_config; bundle.export_selections(app_config); // Decoding the stored form returns the three slots intact, curve delimiters and all. A plain // '|' join would decode as five slots here instead of three. std::vector decoded; REQUIRE(unescape_strings_cstyle( app_config.get_printer_setting(bundle.printers.get_selected_preset_name(), "filament_mixed_gradient_curve"), decoded)); CHECK(decoded == curves); } // A multi-tool printer sizes the filament list from its nozzle count. Mixed-color slots are extra // virtual filaments at the tail of that list with no nozzle of their own, so the count has to // allow for them: sizing to the nozzle count alone drops the project's mixes and strips every // painted facet above the new count. TEST_CASE("Sizing the filament list to a multi-tool nozzle count keeps mixed slots", "[Preset][Bundle][FilamentMixer]") { // The 5-slot layout of a 4-tool project carrying one mix of filaments 2 and 3. const size_t nozzle_count = 4; PresetBundle bundle; bundle.set_num_filaments(5u, std::string("#FF0000")); bundle.project_config.option("filament_is_mixed")->values = { false, false, false, false, true }; bundle.project_config.option("filament_mixed_components")->values = { "", "", "", "", "2,3" }; REQUIRE(bundle.num_mixed_filaments() == 1); SECTION("nozzle count plus the mixed slots preserves the mix") { bundle.set_num_filaments(nozzle_count + bundle.num_mixed_filaments(), std::string("#00FF00")); CHECK(bundle.filament_presets.size() == 5); CHECK(bundle.num_mixed_filaments() == 1); CHECK(bundle.is_mixed_filament(4)); CHECK(bundle.project_config.option("filament_mixed_components")->values[4] == "2,3"); } SECTION("the nozzle count alone is what truncated it away") { bundle.set_num_filaments(nozzle_count, std::string("#00FF00")); CHECK(bundle.filament_presets.size() == nozzle_count); CHECK(bundle.num_mixed_filaments() == 0); } } // The nozzle-count top-up in update_multi_material_filament_presets() grows filament_presets on // its own, so a physical count derived from that list reports a slot no per-filament array has // yet. That is what made the extruder-count handler conclude there was nothing to add and leave // the new sidebar combo with no colour to draw. TEST_CASE("The physical filament count is not fooled by a lone filament_presets top-up", "[Preset][Bundle][FilamentMixer]") { PresetBundle bundle; SECTION("no mixed slots") { bundle.set_num_filaments(4u, std::string("#FF0000")); bundle.printers.get_edited_preset().config.option("nozzle_diameter", true)->values = { 0.4, 0.4, 0.4, 0.4, 0.4 }; bundle.update_multi_material_filament_presets(); REQUIRE(bundle.filament_presets.size() == 5); // the top-up moved this list on its own REQUIRE(bundle.project_config.option("filament_colour")->values.size() == 4); CHECK(bundle.num_physical_filaments() == 4); } SECTION("behind a mixed tail") { bundle.set_num_filaments(5u, std::string("#FF0000")); bundle.project_config.option("filament_is_mixed")->values = { false, false, false, false, true }; bundle.printers.get_edited_preset().config.option("nozzle_diameter", true)->values = { 0.4, 0.4, 0.4, 0.4, 0.4, 0.4 }; bundle.update_multi_material_filament_presets(); REQUIRE(bundle.filament_presets.size() == 6); REQUIRE(bundle.project_config.option("filament_colour")->values.size() == 5); CHECK(bundle.num_physical_filaments() == 4); CHECK(bundle.num_mixed_filaments() == 1); } } // Which slots are new is a fact about the per-filament arrays, not about filament_presets, for the // same reason. Keyed off the wrong one, a freshly opened slot silently keeps filament 1's colour. TEST_CASE("New filament colours are placed by array position", "[Preset][Bundle][FilamentMixer]") { PresetBundle bundle; bundle.set_num_filaments(4u, std::string("#FF0000")); bundle.printers.get_edited_preset().config.option("nozzle_diameter", true)->values = { 0.4, 0.4, 0.4, 0.4, 0.4 }; bundle.update_multi_material_filament_presets(); REQUIRE(bundle.filament_presets.size() == 5); REQUIRE(bundle.project_config.option("filament_colour")->values.size() == 4); // The call Sidebar::add_custom_filament makes once the extruder count opens a slot. bundle.set_num_filaments(5u, std::string("#00FF00")); const auto &colours = bundle.project_config.option("filament_colour")->values; REQUIRE(colours.size() == 5); CHECK(colours[4] == "#00FF00"); // not colours[0], which resize() would have padded with } // The mixed-slot flags are written into the app config on exit and read back on the next start. // If the read side loses them the slots survive as filaments but stop being mixes, so the project // comes back with the mix showing as an ordinary physical filament. TEST_CASE("A saved mix is still a mix after an app restart", "[Preset][Bundle][FilamentMixer]") { AppConfig app_config; // Last session: a 4-tool project carrying one mix of filaments 2 and 3 at the tail. { PresetBundle bundle; add_inmemory_preset(bundle.printers, "Test Printer"); bundle.printers.select_preset_by_name("Test Printer", true); add_inmemory_preset(bundle.filaments, "Test Filament"); bundle.filaments.select_preset_by_name("Test Filament", true); bundle.set_num_filaments(5u, std::string("#FF0000")); bundle.filament_presets.assign(5, "Test Filament"); bundle.project_config.option("filament_is_mixed")->values = { false, false, false, false, true }; bundle.project_config.option("filament_mixed_components")->values = { "", "", "", "", "2,3" }; bundle.export_selections(app_config); REQUIRE(app_config.get_printer_setting("Test Printer", "filament_is_mixed") == "0,0,0,0,1"); } // This session. PresetBundle bundle; add_inmemory_preset(bundle.printers, "Test Printer"); add_inmemory_preset(bundle.filaments, "Test Filament"); bundle.load_selections(app_config); CHECK(bundle.filament_presets.size() == 5); CHECK(bundle.num_mixed_filaments() == 1); CHECK(bundle.is_mixed_filament(4)); CHECK(bundle.project_config.option("filament_mixed_components")->values[4] == "2,3"); } // The same restart, on the printer shape that actually shows the bug: a 4-tool changer whose // saved filament list is one longer than its nozzle count, because the extra slot is the mix. TEST_CASE("A saved mix survives a restart on a multi-tool printer", "[Preset][Bundle][FilamentMixer]") { auto make_toolchanger = [](PresetBundle &bundle) -> Preset & { Preset &p = add_inmemory_preset(bundle.printers, "Tool Changer"); p.config.option("nozzle_diameter", true)->values = { 0.4, 0.4, 0.4, 0.4 }; p.config.option("single_extruder_multi_material", true)->value = false; return p; }; AppConfig app_config; { PresetBundle bundle; make_toolchanger(bundle); bundle.printers.select_preset_by_name("Tool Changer", true); add_inmemory_preset(bundle.filaments, "Test Filament"); bundle.filaments.select_preset_by_name("Test Filament", true); bundle.set_num_filaments(5u, std::string("#FF0000")); bundle.filament_presets.assign(5, "Test Filament"); bundle.project_config.option("filament_is_mixed")->values = { false, false, false, false, true }; bundle.project_config.option("filament_mixed_components")->values = { "", "", "", "", "1,2" }; bundle.export_selections(app_config); REQUIRE(app_config.get_printer_setting("Tool Changer", "filament_is_mixed") == "0,0,0,0,1"); } PresetBundle bundle; make_toolchanger(bundle); add_inmemory_preset(bundle.filaments, "Test Filament"); bundle.load_selections(app_config); CHECK(bundle.filament_presets.size() == 5); CHECK(bundle.num_mixed_filaments() == 1); CHECK(bundle.is_mixed_filament(4)); SECTION("and through the GUI startup calls that follow it") { // GUI_App::load_current_presets sizes the list for a non-SEMM printer, growing only. const size_t target = 4u + bundle.num_mixed_filaments(); if (target > bundle.filament_presets.size()) bundle.set_num_filaments(target); CHECK(bundle.num_mixed_filaments() == 1); // TabPrinter::extruders_count_changed. bundle.on_extruders_count_changed(4); CHECK(bundle.num_mixed_filaments() == 1); // Tab::select_preset re-reads the snapshot when remember_printer_config is on. bundle.update_selections(app_config); CHECK(bundle.filament_presets.size() == 5); CHECK(bundle.num_mixed_filaments() == 1); CHECK(bundle.is_mixed_filament(4)); } } // The startup sizing in GUI_App::load_current_presets targets the nozzle count plus the mixes. // That is a floor, never a ceiling: set_num_filaments() trims at the raw tail, which is exactly // where the mixes live, so applying the target to a longer list deletes them. A list longer than // the target is reachable - raising the extruder count without saving the printer preset leaves // the extra physical slot behind on the next start - so the startup sizing must only ever grow. TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tail", "[Preset][Bundle][FilamentMixer]") { // 5 physical + 1 mix, on a printer preset still reporting 4 nozzles. const size_t nozzle_count = 4; PresetBundle bundle; bundle.set_num_filaments(6u, std::string("#FF0000")); bundle.project_config.option("filament_is_mixed")->values = { false, false, false, false, false, true }; bundle.project_config.option("filament_mixed_components")->values = { "", "", "", "", "", "1,2" }; REQUIRE(bundle.num_physical_filaments() == 5); const size_t target = nozzle_count + bundle.num_mixed_filaments(); REQUIRE(target < bundle.filament_presets.size()); SECTION("applied as written, the mix is gone and every slot reads physical") { bundle.set_num_filaments(target); CHECK(bundle.filament_presets.size() == target); CHECK(bundle.num_mixed_filaments() == 0); CHECK(bundle.num_physical_filaments() == target); } SECTION("applied as a floor, the mix is left alone") { if (target > bundle.filament_presets.size()) bundle.set_num_filaments(target); CHECK(bundle.filament_presets.size() == 6); CHECK(bundle.num_mixed_filaments() == 1); CHECK(bundle.is_mixed_filament(5)); CHECK(bundle.project_config.option("filament_mixed_components")->values[5] == "1,2"); } }