Files
OrcaSlicer/tests/libslic3r/test_preset_bundle_loading.cpp
SoftFever 5add7a5062 Show Orca Filament Library filaments in AMS and calibration dialogs
These dialogs treated a filament with no compatible_printers as compatible with
nothing, while the rest of the app treats it as compatible with everything, so
the entire Orca Filament Library was missing from the AMS material and
calibration filament lists. They now resolve compatibility the same way the
plater does, and a vendor profile still supersedes the library generic of the
same name.
2026-08-31 16:25:59 +08:00

976 lines
46 KiB
C++

#include <catch2/catch_all.hpp>
#include <algorithm>
#include <boost/filesystem.hpp>
#include <fstream>
#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<ConfigOptionString>("print_settings_id", true)->value = name;
config.option<ConfigOptionString>(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<ConfigOptionString>(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<ConfigOptionString>(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<std::string> 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<const PrintRegionConfig &>(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<std::string> 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 "<vendor> 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 "<vendor> 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<std::string> &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<ConfigOptionStrings>(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<std::string>{ "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<std::string>{ "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<std::string>{ "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<std::string>{ "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<std::string>{ "New Printer" });
CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_prints") ==
std::vector<std::string>{ "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<std::string>{ "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<std::string>{ "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<Preset::Type, const std::vector<std::string>*> 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<const PrintRegionConfig &>(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<const PrintRegionConfig &>(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<std::string> compatible_printers) {
Preset &preset = add_inmemory_preset(filaments, name);
preset.alias = "Generic ABS";
preset.vendor = &owner;
preset.config.option<ConfigOptionStrings>("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<ConfigOptionString>("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<std::string> 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<Preset *> 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<ConfigOptionBools>(key))
return b->values.size();
if (const auto *s = cfg.option<ConfigOptionStrings>(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<ConfigOptionStrings>("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<ConfigOptionStrings>("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<ConfigOptionBools>("filament_is_mixed")->values = { false, true };
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values = { "", "1,2" };
bundle.project_config.option<ConfigOptionStrings>("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<std::string> 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<ConfigOptionStrings>("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<std::string> 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<ConfigOptionBools>("filament_is_mixed")->values =
{ false, false, false, false, true };
bundle.project_config.option<ConfigOptionStrings>("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<ConfigOptionStrings>("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<ConfigOptionFloats>("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<ConfigOptionStrings>("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<ConfigOptionBools>("filament_is_mixed")->values =
{ false, false, false, false, true };
bundle.printers.get_edited_preset().config.option<ConfigOptionFloats>("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<ConfigOptionStrings>("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<ConfigOptionFloats>("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<ConfigOptionStrings>("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<ConfigOptionStrings>("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<ConfigOptionBools>("filament_is_mixed")->values =
{ false, false, false, false, true };
bundle.project_config.option<ConfigOptionStrings>("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<ConfigOptionStrings>("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<ConfigOptionFloats>("nozzle_diameter", true)->values = { 0.4, 0.4, 0.4, 0.4 };
p.config.option<ConfigOptionBool>("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<ConfigOptionBools>("filament_is_mixed")->values =
{ false, false, false, false, true };
bundle.project_config.option<ConfigOptionStrings>("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<ConfigOptionBools>("filament_is_mixed")->values =
{ false, false, false, false, false, true };
bundle.project_config.option<ConfigOptionStrings>("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<ConfigOptionStrings>("filament_mixed_components")->values[5] == "1,2");
}
}