mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-16 21:42:43 +00:00
2983 lines
163 KiB
C++
2983 lines
163 KiB
C++
#include <catch2/catch_all.hpp>
|
|
|
|
#include <boost/filesystem.hpp>
|
|
#include <fstream>
|
|
|
|
#include "libslic3r/PresetBundle.hpp"
|
|
#include "libslic3r/AppConfig.hpp"
|
|
|
|
#include "test_utils.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <initializer_list>
|
|
|
|
using namespace Slic3r;
|
|
|
|
namespace {
|
|
|
|
namespace fs = boost::filesystem;
|
|
|
|
// Whether a key is listed in a vector of keys (published_keys / skipped_keys).
|
|
bool contains_key(const std::vector<std::string> &keys, const std::string &key)
|
|
{
|
|
return std::find(keys.begin(), keys.end(), key) != keys.end();
|
|
}
|
|
|
|
void check_double_vector(const std::vector<double> &actual, std::initializer_list<double> expected)
|
|
{
|
|
REQUIRE(actual.size() == expected.size());
|
|
size_t index = 0;
|
|
for (double value : expected)
|
|
REQUIRE_THAT(actual[index++], Catch::Matchers::WithinAbs(value, 1e-6));
|
|
}
|
|
|
|
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 single-slot PLA file config for the published-material load tests (mirrors what the GUI
|
|
// builds from a 3mf's project settings before load_config_model).
|
|
DynamicPrintConfig published_pla_file_config()
|
|
{
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9 };
|
|
return config;
|
|
}
|
|
|
|
// 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 {
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// A "published" 3MF keeps the user's currently-selected presets and overlays only the
|
|
// author-selected process keys onto the edited preset (mirrors the GUI load path: normalize
|
|
// before load_config_model, then the overlay in load_config_file_config).
|
|
TEST_CASE("Published 3MF overlays only the author-selected process keys onto the edited preset", "[Preset][Bundle][Published]")
|
|
{
|
|
// The file config the GUI builds from a .3mf's project settings.
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
// The loader derives the filament count from filament_colour and throws when it is
|
|
// empty; a 3mf always carries it.
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt_float("layer_height") = 0.28; // process scalar
|
|
config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values = { 140., 150. }; // matching-size vector
|
|
config.opt<ConfigOptionStrings>("post_process")->values = { "script-a", "script-b" }; // mismatched vector
|
|
config.opt<ConfigOptionInts>("nozzle_temperature")->values = { 220 }; // filament key (not applied anywhere)
|
|
// Structural (denylisted) key: must be silently ignored. full_print_config() omits the
|
|
// *_settings_id keys, so create one explicitly.
|
|
config.opt_string("print_settings_id", true) = "file process";
|
|
config.opt<ConfigOptionFloats>("flush_multiplier")->values = { 2., 2. }; // must NOT cross over
|
|
config.opt<ConfigOptionFloats>("wipe_tower_x")->values = { 100. }; // plate geometry, does cross over
|
|
config.opt<ConfigOptionFloat>("wipe_tower_rotation_angle")->value = 45.; // published-only plate geometry, crosses over
|
|
config.option("curr_bed_type")->setInt(BedType::btPC); // must NOT cross over
|
|
return config;
|
|
};
|
|
|
|
const std::vector<std::string> published_keys = {
|
|
"layer_height", "wiping_volumes_extruders", "post_process", "nozzle_temperature", "print_settings_id"
|
|
};
|
|
|
|
PresetBundle bundle;
|
|
const std::string pre_load_name = bundle.prints.get_edited_preset().name;
|
|
const size_t pre_load_size = bundle.prints.size();
|
|
|
|
// The edited presets are the overlay targets: recognizable pre-load values.
|
|
bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1;
|
|
bundle.prints.get_edited_preset().config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values = { 10., 20. };
|
|
bundle.prints.get_edited_preset().config.opt<ConfigOptionStrings>("post_process")->values = { "existing-script" };
|
|
bundle.prints.get_edited_preset().config.opt_string("print_settings_id") = "user process";
|
|
// Capture the ctor-seeded project_config values so the assertions below check the load
|
|
// leaves them untouched rather than hardcoding the defaults.
|
|
const std::vector<std::string> seed_filament_colour = bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values;
|
|
const std::vector<double> seed_flush_multiplier = bundle.project_config.opt<ConfigOptionFloats>("flush_multiplier")->values;
|
|
const int seed_bed_type = bundle.project_config.option("curr_bed_type")->getInt();
|
|
|
|
DynamicPrintConfig config = make_file_config();
|
|
// The GUI normalizes the config before load; mirror that so only the production path runs.
|
|
Preset::normalize(config);
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = published_keys;
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// a) Process scalar overlaid; matching-size vector applied, mismatched one lands in
|
|
// skipped_keys; applied keys are not reported.
|
|
CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001));
|
|
check_double_vector(bundle.prints.get_edited_preset().config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values, { 140., 150. });
|
|
CHECK(bundle.prints.get_edited_preset().config.opt<ConfigOptionStrings>("post_process")->values == std::vector<std::string>{ "existing-script" });
|
|
CHECK(contains_key(pub.skipped_keys, "post_process"));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height"));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "wiping_volumes_extruders"));
|
|
|
|
// b) A filament key is never applied anywhere and is reported as skipped.
|
|
CHECK(bundle.prints.get_edited_preset().config.option("nozzle_temperature") == nullptr);
|
|
CHECK(contains_key(pub.skipped_keys, "nozzle_temperature"));
|
|
|
|
// c) A structural key is silently ignored: neither applied nor reported as skipped.
|
|
CHECK(bundle.prints.get_edited_preset().config.opt_string("print_settings_id") == "user process");
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "print_settings_id"));
|
|
|
|
// d) Only plate/bed geometry crosses in published mode: filament/purge data and bed type
|
|
// stay at the ctor seeds.
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values == seed_filament_colour);
|
|
CHECK(bundle.project_config.opt<ConfigOptionFloats>("flush_multiplier")->values == seed_flush_multiplier);
|
|
CHECK(bundle.project_config.option("curr_bed_type")->getInt() == seed_bed_type);
|
|
check_double_vector(bundle.project_config.opt<ConfigOptionFloats>("wipe_tower_x")->values, { 100. });
|
|
CHECK_THAT(bundle.project_config.opt<ConfigOptionFloat>("wipe_tower_rotation_angle")->value, Catch::Matchers::WithinAbs(45., 0.000001));
|
|
|
|
// e) The published path keeps the user's currently-selected presets: same preset, same size.
|
|
CHECK(bundle.prints.get_edited_preset().name == pre_load_name);
|
|
CHECK(bundle.prints.size() == pre_load_size);
|
|
|
|
// f) Non-published control: the overlay is disabled, the file's presets are imported
|
|
// instead, and no skipped_keys are produced.
|
|
PresetBundle control_bundle;
|
|
const size_t control_pre_size = control_bundle.prints.size();
|
|
PublishedConfig control_pub;
|
|
control_pub.published = false;
|
|
control_pub.published_keys = published_keys;
|
|
DynamicPrintConfig control_config = make_file_config();
|
|
Preset::normalize(control_config);
|
|
control_bundle.load_config_model("test.3mf", std::move(control_config), Semver(), &control_pub);
|
|
|
|
CHECK(control_pub.skipped_keys.empty());
|
|
CHECK(control_bundle.prints.size() > control_pre_size);
|
|
// The file's layer_height reached the edited preset via the normal import, not the overlay.
|
|
CHECK_THAT(control_bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001));
|
|
}
|
|
|
|
// The published printer overlay is restricted to the publishable retraction/z-hop allowlist:
|
|
// matching-size vectors apply, mismatched vectors are reported as skipped, and any other
|
|
// printer-class key (e.g. machine_start_gcode) is contract-excluded (never applied, never
|
|
// reported).
|
|
TEST_CASE("Published 3MF overlays only the allowlisted retraction and z-hop keys onto the edited printer preset", "[Preset][Bundle][Published]")
|
|
{
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
Preset::normalize(config);
|
|
config.opt<ConfigOptionFloats>("retraction_length")->values = { 1.4 }; // matching size (1 extruder)
|
|
config.opt<ConfigOptionFloats>("retraction_speed")->values = { 45., 55. }; // size 2: mismatched
|
|
config.opt_string("machine_start_gcode") = "G28 ; from file"; // outside the allowlist
|
|
|
|
PresetBundle bundle;
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.8 };
|
|
// A recognizable non-default value: the skipped mismatch below must leave it untouched
|
|
// (asserting the default instead would silently test PrintConfig's retraction_speed).
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_speed")->values = { 33. };
|
|
bundle.printers.get_edited_preset().config.opt_string("machine_start_gcode") = "G28 ; user";
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "retraction_length", "retraction_speed", "machine_start_gcode" };
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// Matching-size retraction vector applied; mismatched vector reported as skipped and the
|
|
// receiver's own value survives.
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values, { 1.4 });
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_speed")->values, { 33. });
|
|
CHECK(contains_key(pub.skipped_keys, "retraction_speed"));
|
|
// Contract-excluded printer key: silently ignored, absent from skipped_keys.
|
|
CHECK(bundle.printers.get_edited_preset().config.opt_string("machine_start_gcode") == "G28 ; user");
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "machine_start_gcode"));
|
|
}
|
|
|
|
// A published 3MF carries per-slot material keys; on load they are applied positionally to the
|
|
// receiver's slot N (a key-only entry has no type gate), written onto the slot's stored preset
|
|
// in place.
|
|
TEST_CASE("Published 3MF applies positional material keys onto the receiver's material presets", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
// Two filament slots; filament_diameter drives the normalized per-slot vector sizes.
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
// Keep the multi-extruder consistency validation happy for a 2-slot config.
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PETG" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFT99" };
|
|
// Author per-slot retraction values. These are per-filament override keys that are not
|
|
// members of the static PrintRegionConfig, so full_print_config() omits them and they
|
|
// must be created explicitly (as nullable, matching the real 3MF project config).
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9, 1.2 };
|
|
config.option<ConfigOptionFloatsNullable>("filament_z_hop", true)->values = { 0.2, 0.3 };
|
|
return config;
|
|
};
|
|
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.filament_id = "GFL99";
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt_string("filament_vendor", 0u) = "Generic";
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
pla.config.opt<ConfigOptionStrings>("filament_settings_id")->values = { "receiver-pla" };
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.filament_id = "GFT99";
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt_string("filament_vendor", 0u) = "Generic";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_z_hop", true)->values = { 0.1 };
|
|
bundle.filament_presets = { "My PLA", "My PETG" };
|
|
|
|
PublishedMaterialEntry pla_entry;
|
|
pla_entry.filament_id = "GFL99";
|
|
pla_entry.slot = 0; // the author's PLA slot
|
|
pla_entry.keys = { "filament_retraction_length", "filament_settings_id" };
|
|
PublishedMaterialEntry petg_entry;
|
|
petg_entry.filament_id = "GFT99";
|
|
petg_entry.slot = 1; // the author's PETG slot
|
|
petg_entry.keys = { "filament_retraction_length", "filament_z_hop" };
|
|
// A slot-less entry (no slot field, only possible in hand-crafted files): silently skipped.
|
|
PublishedMaterialEntry noslot_entry;
|
|
noslot_entry.filament_type = "ABS";
|
|
noslot_entry.keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { pla_entry, petg_entry, noslot_entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The author's slot values are written onto the receiver's stored presets in place.
|
|
check_double_vector(bundle.filaments.find_preset("My PLA")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
check_double_vector(bundle.filaments.find_preset("My PETG")->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 1.2 });
|
|
check_double_vector(bundle.filaments.find_preset("My PETG")->config.opt<ConfigOptionFloatsNullable>("filament_z_hop")->values, { 0.3 });
|
|
// Structural keys inside a material entry are silently ignored: the receiver's own
|
|
// filament_settings_id is untouched and nothing is reported for it.
|
|
CHECK(bundle.filaments.find_preset("My PLA")->config.opt<ConfigOptionStrings>("filament_settings_id")->values == std::vector<std::string>{ "receiver-pla" });
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "material:GFL99 (filament_settings_id)"));
|
|
// Everything applied; the slot-less entry produced no skipped entry.
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// A "full publish" slot serializes the whole filament. On load the slot always receives a
|
|
// standalone detached copy of the author's material - created even when the receiver's own
|
|
// material matches the published type - and no receiver library preset is ever mutated.
|
|
TEST_CASE("Published 3MF full-published slots are imported as standalone detached copies", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
// Two author slots; filament_diameter drives the normalized per-slot vector sizes.
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PETG" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFT99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9, 1.2 };
|
|
return config;
|
|
};
|
|
// The full dump of slot 0, publishing the whole filament as type "ABS".
|
|
auto make_full_abs_entry = [] {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "ABS";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
return entry;
|
|
};
|
|
|
|
SECTION("type match still creates a detached copy instead of mutating the receiver material") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
bundle.filament_presets = { "My PLA", "My PLA" };
|
|
|
|
PublishedMaterialEntry full = make_full_abs_entry();
|
|
full.publish_type_value = "PLA"; // author requires PLA, receiver slot is PLA
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { full };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The type matches, but the import still detaches: the slot lands on a fresh copy
|
|
// named from the published type (no identity fields in this entry), carrying the
|
|
// author's values; the receiver's own material is untouched.
|
|
CHECK(bundle.filament_presets[0] == "PLA");
|
|
Preset *copy = bundle.filaments.find_preset("PLA", false, true);
|
|
REQUIRE(copy != nullptr);
|
|
check_double_vector(copy->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PLA -> PLA (published material imported)");
|
|
}
|
|
|
|
SECTION("type mismatch also detaches: a fresh same-type copy replaces the slot") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
Preset &abs = add_inmemory_preset(bundle.filaments, "My ABS");
|
|
abs.config.opt_string("filament_type", 0u) = "ABS";
|
|
abs.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.3 };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_full_abs_entry() };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 1);
|
|
// No substitution search runs: a brand-new copy named after the published type is
|
|
// created and both pre-existing presets stay exactly as they were.
|
|
CHECK(bundle.filament_presets[0] == "ABS");
|
|
Preset *copy = bundle.filaments.find_preset("ABS", false, true);
|
|
REQUIRE(copy != nullptr);
|
|
check_double_vector(copy->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
check_double_vector(bundle.filaments.find_preset("My ABS", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.3 });
|
|
check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PLA -> ABS (published material imported)");
|
|
}
|
|
|
|
SECTION("the author's identity rides on the copy when the type has no library match") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
Preset &other = add_inmemory_preset(bundle.filaments, "Other PLA");
|
|
other.config.opt_string("filament_type", 0u) = "PLA";
|
|
other.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.7 };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedMaterialEntry full = make_full_abs_entry();
|
|
// The dump carries the identity too, so the created copy takes the author's type
|
|
// and vendor instead of the baseline clone's.
|
|
full.full_keys = { "filament_retraction_length", "filament_type", "filament_vendor" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { full };
|
|
DynamicPrintConfig config = make_file_config();
|
|
// The author's slot 0 really is ABS.
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "ABS", "PETG" };
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// No ABS preset needs to exist in the library: the copy carries the author's values,
|
|
// type and vendor included. Neither receiver preset was touched.
|
|
CHECK(bundle.filament_presets[0] == "ABS");
|
|
Preset *copy = bundle.filaments.find_preset("ABS", false, true);
|
|
REQUIRE(copy != nullptr);
|
|
check_double_vector(copy->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
CHECK(copy->config.opt_string("filament_type", 0u) == "ABS");
|
|
CHECK(copy->config.opt_string("filament_vendor", 0u) == "Generic");
|
|
check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt_string("filament_type", 0u) == "PLA");
|
|
check_double_vector(bundle.filaments.find_preset("Other PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.7 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PLA -> ABS (published material imported)");
|
|
}
|
|
}
|
|
|
|
// The author's preset name travels in the file and names the created standalone copy (variant
|
|
// tail stripped), regardless of what the receiver's library holds: an exact-name library preset
|
|
// is never reused nor mutated.
|
|
TEST_CASE("Published 3MF imports a full material under the author's stripped name", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "OGFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9 };
|
|
return config;
|
|
};
|
|
|
|
auto add_pla = [](PresetBundle &bundle, const char *name, const char *id, const char *vendor, const char *setting_id) {
|
|
Preset &preset = add_inmemory_preset(bundle.filaments, name);
|
|
preset.filament_id = id;
|
|
preset.setting_id = setting_id;
|
|
preset.config.opt_string("filament_type", 0u) = "PLA";
|
|
preset.config.opt_string("filament_vendor", 0u) = vendor;
|
|
preset.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
return &preset;
|
|
};
|
|
|
|
SECTION("an exact-name library preset exists: a detached copy is created beside it") {
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
add_pla(bundle, "Generic PLA @System", "OGFL99", "Generic", "RcBNzytWgwRrwXXz");
|
|
add_pla(bundle, "Bambu PLA Basic @System", "OGFA00", "Bambu Lab", "zkc85XTKi4cb6cOw");
|
|
bundle.filament_presets = { "My PETG" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.filament_id = "OGFL99";
|
|
entry.filament_vendor = "Generic";
|
|
entry.setting_id = "RcBNzytWgwRrwXXz";
|
|
entry.preset_name = "Generic PLA @System";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The copy is named after the stripped author name; the receiver's exact-name preset
|
|
// keeps its own values.
|
|
CHECK(bundle.filament_presets[0] == "Generic PLA");
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA @System", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.6 });
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA (published material imported)");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
SECTION("only the name is present (broken/stale ids): the copy is still created") {
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
add_pla(bundle, "Generic PLA @System", "OGFL99", "Generic", "RcBNzytWgwRrwXXz");
|
|
add_pla(bundle, "Bambu PLA Basic @System", "OGFA00", "Bambu Lab", "zkc85XTKi4cb6cOw");
|
|
bundle.filament_presets = { "My PETG" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.preset_name = "Generic PLA @System";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
CHECK(bundle.filament_presets[0] == "Generic PLA");
|
|
check_double_vector(bundle.filaments.find_preset("Bambu PLA Basic @System", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA (published material imported)");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
SECTION("grown slot (author slot 1) receives its own detached copy") {
|
|
auto two_slot_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#FFFF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "OGFL99", "OGFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9, 0.8 };
|
|
return config;
|
|
};
|
|
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
add_pla(bundle, "Generic PLA @System", "OGFL99", "Generic", "RcBNzytWgwRrwXXz");
|
|
add_pla(bundle, "Bambu PLA Basic @System", "OGFA00", "Bambu Lab", "zkc85XTKi4cb6cOw");
|
|
bundle.filament_presets = { "My PETG" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 1;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.preset_name = "Generic PLA @System";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = two_slot_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 2);
|
|
CHECK(bundle.filament_presets[0] == "My PETG");
|
|
// The grown slot lands on a freshly created copy carrying the author's slot-1 value;
|
|
// the library preset that seeded it stays untouched.
|
|
CHECK(bundle.filament_presets[1] == "Generic PLA");
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.8 });
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA @System", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 1: Generic PLA @System -> Generic PLA (published material imported)");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
}
|
|
|
|
// The stripped author name can collide with an existing library preset ("Generic PLA"): the
|
|
// created copy must uniquify with the "(Published)" suffix rather than overwrite, reuse or
|
|
// mutate any of the receiver's own presets.
|
|
TEST_CASE("Published 3MF uniquifies an imported full material name on collision", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
// A legacy bundle preset literally named "Generic PLA" - collides with the stripped name.
|
|
Preset &bare = add_inmemory_preset(bundle.filaments, "Generic PLA");
|
|
bare.config.opt_string("filament_type", 0u) = "PLA";
|
|
bare.config.opt_string("filament_vendor", 0u) = "Generic";
|
|
bare.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
// The author's exact preset.
|
|
Preset &qidi = add_inmemory_preset(bundle.filaments, "Generic PLA @Qidi Q2 0.4 nozzle");
|
|
qidi.config.opt_string("filament_type", 0u) = "PLA";
|
|
qidi.config.opt_string("filament_vendor", 0u) = "Generic";
|
|
qidi.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
// The Orca library preset.
|
|
Preset &sys = add_inmemory_preset(bundle.filaments, "Generic PLA @System");
|
|
sys.config.opt_string("filament_type", 0u) = "PLA";
|
|
sys.config.opt_string("filament_vendor", 0u) = "Generic";
|
|
sys.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
bundle.filament_presets = { "My PETG" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.preset_name = "Generic PLA @Qidi Q2 0.4 nozzle";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = published_pla_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The copy lands beside the collision, suffixed; every pre-existing preset keeps its own
|
|
// values.
|
|
CHECK(bundle.filament_presets[0] == "Generic PLA (Published)");
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA (Published)", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA @Qidi Q2 0.4 nozzle", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
check_double_vector(bundle.filaments.find_preset("Generic PLA @System", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Generic PLA (Published) (published material imported)");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// The overlay writes onto the edited layer only when that layer survives the load (slot 0's
|
|
// preset still matches the edited preset). When the user views a non-first slot's material and
|
|
// the published entry targets that slot, the final re-select would destroy the edited layer -
|
|
// so the values must land on the stored preset instead and survive.
|
|
TEST_CASE("Published 3MF writes to the stored preset when the edited layer is re-selected away", "[Preset][Bundle][Published]")
|
|
{
|
|
// Two-slot author config: slot 1 carries the published retraction value.
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PETG", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFT99", "GFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6, 0.9 };
|
|
return config;
|
|
};
|
|
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
bundle.filament_presets = { "My PETG", "My PLA" };
|
|
// The user is viewing slot 1's material.
|
|
REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false));
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 1;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
entry.keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The published values survived on the stored preset (the edited layer was re-selected to
|
|
// slot 0's material and must not have been the only copy).
|
|
Preset *stored = bundle.filaments.find_preset("My PLA", false, true);
|
|
REQUIRE(stored != nullptr);
|
|
CHECK(stored->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
check_double_vector(stored->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
// The load re-selected slot 0's material, mirroring a normal project load.
|
|
CHECK(bundle.filaments.get_edited_preset().name == "My PETG");
|
|
// Selecting the slot's material afterwards surfaces the applied values.
|
|
REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false));
|
|
CHECK(bundle.filaments.get_edited_preset().config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
check_double_vector(bundle.filaments.get_edited_preset().config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// The created copy is named after the author's preset with the "@variant" tail stripped;
|
|
// trailing whitespace left behind by the truncation must be trimmed away, and names without
|
|
// a tail pass through unchanged.
|
|
TEST_CASE("publish_material_base_name strips the variant tail from a published preset name", "[Preset][Bundle][Published]")
|
|
{
|
|
CHECK(publish_material_base_name("Generic PLA @System") == "Generic PLA");
|
|
CHECK(publish_material_base_name("Generic PLA @Qidi Q2 0.4 nozzle") == "Generic PLA");
|
|
// Truncation at '@' leaves the space before the tail; it must not survive.
|
|
CHECK(publish_material_base_name("Generic PLA @System") == "Generic PLA");
|
|
CHECK(publish_material_base_name("Voron Generic PLA") == "Voron Generic PLA");
|
|
CHECK(publish_material_base_name("") == "");
|
|
// A tail-only name strips to nothing; the caller falls back to identity fields.
|
|
CHECK(publish_material_base_name("@System").empty());
|
|
}
|
|
|
|
// A full-published material arrives as a brand-new standalone preset: parentless, visible,
|
|
// project-embedded ("Preset Inside Project"), carrying the author's values and colour - and
|
|
// never touching any of the receiver's own presets.
|
|
TEST_CASE("Published 3MF imports a full material as a detached project-embedded preset", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
Preset &spare = add_inmemory_preset(bundle.filaments, "Spare PLA");
|
|
spare.config.opt_string("filament_type", 0u) = "PLA";
|
|
spare.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.4 };
|
|
bundle.filament_presets = { "My PETG" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
entry.filament_id = "AFL01";
|
|
entry.setting_id = "Sid000111222";
|
|
entry.preset_name = "Author PLA @Vendor";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = published_pla_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The slot lands on the freshly created copy, named after the stripped author name.
|
|
CHECK(bundle.filament_presets[0] == "Author PLA");
|
|
Preset *copy = bundle.filaments.find_preset("Author PLA", false, true);
|
|
REQUIRE(copy != nullptr);
|
|
// Detached + project-embedded contract.
|
|
CHECK(copy->is_project_embedded);
|
|
CHECK(copy->inherits().empty());
|
|
CHECK(copy->setting_id.empty());
|
|
CHECK(copy->vendor == nullptr);
|
|
CHECK_FALSE(copy->is_system);
|
|
CHECK_FALSE(copy->is_default);
|
|
CHECK_FALSE(copy->is_external);
|
|
CHECK(copy->is_visible);
|
|
CHECK(copy->filament_id == "AFL01");
|
|
CHECK(copy->config.opt<ConfigOptionStrings>("filament_settings_id")->values == std::vector<std::string>{ "Author PLA" });
|
|
// The published values and colour live on the copy.
|
|
check_double_vector(copy->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
CHECK(copy->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
// Universally compatible: no printer/print restrictions survive the import.
|
|
CHECK(copy->config.opt<ConfigOptionStrings>("compatible_printers")->values.empty());
|
|
CHECK(copy->config.opt<ConfigOptionStrings>("compatible_prints")->values.empty());
|
|
CHECK(copy->config.opt<ConfigOptionString>("compatible_printers_condition")->value.empty());
|
|
CHECK(copy->config.opt<ConfigOptionString>("compatible_prints_condition")->value.empty());
|
|
// Nothing pre-existing was touched.
|
|
check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.6 });
|
|
check_double_vector(bundle.filaments.find_preset("Spare PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.4 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Author PLA (published material imported)");
|
|
}
|
|
|
|
// Compatibility restrictions riding on the receiver's baseline preset must not leak onto the
|
|
// imported copy: a detached full material is usable with every printer and print profile.
|
|
TEST_CASE("Published 3MF clears printer restrictions on the imported full material", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &restricted = add_inmemory_preset(bundle.filaments, "Restricted PLA");
|
|
restricted.config.opt_string("filament_type", 0u) = "PLA";
|
|
restricted.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
restricted.config.set_key_value("compatible_printers", new ConfigOptionStrings({ "Unrelated Printer" }));
|
|
restricted.config.set_key_value("compatible_prints", new ConfigOptionStrings({ "Unrelated Print" }));
|
|
restricted.config.option<ConfigOptionString>("compatible_printers_condition", true)->value = "printer_settings_id==\"Nope\"";
|
|
restricted.config.option<ConfigOptionString>("compatible_prints_condition", true)->value = "print_settings_id==\"Nope\"";
|
|
bundle.filament_presets = { "Restricted PLA" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = published_pla_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The copy (named from the published type; no identity fields) has no restrictions left.
|
|
CHECK(bundle.filament_presets[0] == "PLA");
|
|
Preset *copy = bundle.filaments.find_preset("PLA", false, true);
|
|
REQUIRE(copy != nullptr);
|
|
CHECK(copy->config.opt<ConfigOptionStrings>("compatible_printers")->values.empty());
|
|
CHECK(copy->config.opt<ConfigOptionStrings>("compatible_prints")->values.empty());
|
|
CHECK(copy->config.opt<ConfigOptionString>("compatible_printers_condition")->value.empty());
|
|
CHECK(copy->config.opt<ConfigOptionString>("compatible_prints_condition")->value.empty());
|
|
// The receiver's own restricted preset keeps its restrictions.
|
|
const Preset *original = bundle.filaments.find_preset("Restricted PLA", false, true);
|
|
REQUIRE(original != nullptr);
|
|
CHECK(original->config.opt<ConfigOptionStrings>("compatible_printers")->values == std::vector<std::string>{ "Unrelated Printer" });
|
|
CHECK(original->config.opt<ConfigOptionString>("compatible_printers_condition")->value == "printer_settings_id==\"Nope\"");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// Identical Full materials (same setting_id + preset_name identity) share one created
|
|
// instance: an author who pointed several slots at one material gets one standalone copy,
|
|
// and the first entry's slot values win.
|
|
TEST_CASE("Published 3MF shares one imported copy between identical full slots", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "AFL01", "AFL01" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9, 0.8 };
|
|
return config;
|
|
};
|
|
auto make_entry = [](int slot) {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = slot;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.filament_id = "AFL01";
|
|
entry.setting_id = "Sid000111222";
|
|
entry.preset_name = "Author PLA @Vendor";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
return entry;
|
|
};
|
|
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
Preset &other = add_inmemory_preset(bundle.filaments, "Other PETG");
|
|
other.config.opt_string("filament_type", 0u) = "PETG";
|
|
other.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.65 };
|
|
bundle.filament_presets = { "My PETG", "Other PETG" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_entry(0), make_entry(1) };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// Both slots point at the single shared copy; no second "(Published)" instance exists.
|
|
REQUIRE(bundle.filament_presets.size() == 2);
|
|
CHECK(bundle.filament_presets[0] == "Author PLA");
|
|
CHECK(bundle.filament_presets[1] == "Author PLA");
|
|
CHECK(bundle.filaments.find_preset("Author PLA", false, true) != nullptr);
|
|
CHECK(bundle.filaments.find_preset("Author PLA (Published)", false, true) == nullptr);
|
|
// The first entry's slot values won.
|
|
check_double_vector(bundle.filaments.find_preset("Author PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
// Both slots reported, same target; originals untouched.
|
|
REQUIRE(pub.material_replacements.size() == 2);
|
|
CHECK(pub.material_replacements[0] == "slot 0: My PETG -> Author PLA (published material imported)");
|
|
CHECK(pub.material_replacements[1] == "slot 1: Other PETG -> Author PLA (published material imported)");
|
|
check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.6 });
|
|
check_double_vector(bundle.filaments.find_preset("Other PETG", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.65 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// Without a preset name the copy falls back to the stable material id; fully anonymous
|
|
// hand-crafted entries never share instances between slots (their dedup key is slot-scoped).
|
|
TEST_CASE("Published 3MF names unidentified full materials from their fallback fields", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFT99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9, 1.2 };
|
|
return config;
|
|
};
|
|
|
|
SECTION("empty preset_name falls back to the filament_id") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.filament_id = "AFL01";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = published_pla_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
CHECK(bundle.filament_presets[0] == "AFL01");
|
|
CHECK(bundle.filaments.find_preset("AFL01", false, true) != nullptr);
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
SECTION("fully anonymous entries get slot-scoped copies") {
|
|
PresetBundle bundle;
|
|
Preset &first = add_inmemory_preset(bundle.filaments, "First PLA");
|
|
first.config.opt_string("filament_type", 0u) = "PLA";
|
|
first.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
Preset &second = add_inmemory_preset(bundle.filaments, "Second PLA");
|
|
second.config.opt_string("filament_type", 0u) = "PLA";
|
|
second.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.55 };
|
|
bundle.filament_presets = { "First PLA", "Second PLA" };
|
|
|
|
PublishedMaterialEntry entry0;
|
|
entry0.slot = 0;
|
|
entry0.full = true;
|
|
entry0.publish_type = true;
|
|
entry0.publish_type_value = "ABS"; // the only naming field present
|
|
entry0.full_keys = { "filament_retraction_length" };
|
|
PublishedMaterialEntry entry1 = entry0;
|
|
entry1.slot = 1;
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry0, entry1 };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// No shared identity: each slot gets its own uniquified copy.
|
|
CHECK(bundle.filament_presets[0] == "ABS");
|
|
CHECK(bundle.filament_presets[1] == "ABS (Published)");
|
|
CHECK(bundle.filaments.find_preset("ABS", false, true) != nullptr);
|
|
CHECK(bundle.filaments.find_preset("ABS (Published)", false, true) != nullptr);
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
}
|
|
|
|
// Within-load dedup does not span loads: importing the same published file again into the same
|
|
// session creates a second, uniquified copy instead of mutating or reusing the first.
|
|
TEST_CASE("Re-importing a published full material uniquifies the second copy", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6 };
|
|
bundle.filament_presets = { "My PETG" };
|
|
|
|
auto make_entry = [] {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.full = true;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.filament_id = "AFL01";
|
|
entry.setting_id = "Sid000111222";
|
|
entry.preset_name = "Author PLA @Vendor";
|
|
entry.full_keys = { "filament_retraction_length" };
|
|
return entry;
|
|
};
|
|
|
|
for (int round = 0; round < 2; ++round) {
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_entry() };
|
|
DynamicPrintConfig config = published_pla_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
if (round == 0) {
|
|
CHECK(bundle.filament_presets[0] == "Author PLA");
|
|
CHECK(bundle.filaments.find_preset("Author PLA (Published)", false, true) == nullptr);
|
|
} else {
|
|
// The second import uniquifies beside the first instead of touching it.
|
|
CHECK(bundle.filament_presets[0] == "Author PLA (Published)");
|
|
check_double_vector(bundle.filaments.find_preset("Author PLA (Published)", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
check_double_vector(bundle.filaments.find_preset("Author PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0] == "slot 0: Author PLA -> Author PLA (Published) (published material imported)");
|
|
}
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
}
|
|
|
|
// A partially-published slot can carry a curated type and/or colour. The colour is applied
|
|
// regardless of the type match; a type mismatch with no same-type replacement keeps the
|
|
// receiver's material and reports the slot's keys as skipped. The receiver's slot count grows
|
|
// only as far as the highest slot with published content.
|
|
TEST_CASE("Published 3MF partial slots apply colour and gate keys by the published type", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PETG" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFT99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9, 1.2 };
|
|
return config;
|
|
};
|
|
|
|
SECTION("matching type applies the keys and the colour") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
entry.keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// Type matched: keys and colour applied onto the receiver's preset in place.
|
|
Preset *pla_preset = bundle.filaments.find_preset("My PLA", false, true);
|
|
REQUIRE(pla_preset != nullptr);
|
|
check_double_vector(pla_preset->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
CHECK(pla_preset->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
CHECK(pub.skipped_keys.empty());
|
|
CHECK(pub.material_replacements.empty());
|
|
}
|
|
|
|
SECTION("type mismatch without a replacement keeps the material and skips the keys") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "ABS"; // no ABS in the receiver library
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
entry.keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The slot keeps the receiver's material: the colour applies in place, the keys are
|
|
// skipped.
|
|
CHECK(bundle.filament_presets[0] == "My PLA");
|
|
Preset *pla_preset = bundle.filaments.find_preset("My PLA", false, true);
|
|
REQUIRE(pla_preset != nullptr);
|
|
check_double_vector(pla_preset->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
CHECK(pla_preset->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
CHECK(contains_key(pub.skipped_keys, "material:ABS (filament_retraction_length)"));
|
|
CHECK(pub.material_replacements.empty());
|
|
}
|
|
|
|
SECTION("receiver slot count grows to fit the highest published slot and assigns matching type preset") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
petg.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.8 };
|
|
// The receiver has a single slot; the file carries two, only slot 1 is published.
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 1;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PETG";
|
|
entry.keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The slot list was grown so author slot 1 has a material (the PETG preset), which
|
|
// then receives the author's values in place.
|
|
REQUIRE(bundle.filament_presets.size() == 2);
|
|
CHECK(bundle.filament_presets[1] == "My PETG");
|
|
check_double_vector(bundle.filaments.find_preset("My PETG", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 1.2 });
|
|
check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
}
|
|
}
|
|
|
|
// The receiver's slot list grows only as far as the highest published slot: a file whose author
|
|
// published nothing (or only a low slot) must not pull filler materials into the receiver's
|
|
// setup, and the receiver never grows to the file's count.
|
|
TEST_CASE("Published 3MF grows the receiver's slots only as far as the published slots", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
// Four author slots (a 4-filament model).
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3, 4 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" };
|
|
return config;
|
|
};
|
|
auto add_pla_preset = [](PresetBundle &bundle) {
|
|
Preset &preset = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
preset.config.opt_string("filament_type", 0u) = "PLA";
|
|
preset.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
return &preset;
|
|
};
|
|
auto make_color_entry = [](int slot) {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = slot;
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
return entry;
|
|
};
|
|
|
|
// A file whose author published nothing for any slot: the receiver's setup is untouched.
|
|
{
|
|
PresetBundle bundle;
|
|
add_pla_preset(bundle);
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true; // no material entries at all
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
CHECK(bundle.filament_presets.size() == 1);
|
|
CHECK(bundle.filaments.find_preset("My PLA")->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#123456" });
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// Only slot 0 published: a single-slot receiver keeps its single slot; the file's other
|
|
// three slots pull nothing in.
|
|
{
|
|
PresetBundle bundle;
|
|
add_pla_preset(bundle);
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_color_entry(0) };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
CHECK(bundle.filament_presets.size() == 1);
|
|
// The published colour lands on the slot's preset in place.
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
}
|
|
|
|
// Slot 3 published: the receiver grows to 4 so the published slot exists. Unpublished
|
|
// filler slots repeat the receiver's last preset ("Add one filament" behaviour).
|
|
{
|
|
PresetBundle bundle;
|
|
add_pla_preset(bundle);
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_color_entry(3) };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 4);
|
|
CHECK(bundle.filament_presets[1] == "My PLA");
|
|
CHECK(bundle.filament_presets[2] == "My PLA");
|
|
// Only "My PLA" exists in the library, so the published slot keeps the aliasing and the
|
|
// colour is written onto the shared preset (every slot references it).
|
|
CHECK(bundle.filament_presets[3] == "My PLA");
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
// The project-level per-slot vectors were grown and seeded: fillers take their preset's
|
|
// colour, the published slot its published colour.
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values.size() == 4);
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values[1] == "#123456");
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values[3] == "#ABCDEF");
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_multi_colour")->values.size() == 4);
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour_type")->values.size() == 4);
|
|
CHECK(bundle.project_config.opt<ConfigOptionInts>("filament_map")->values.size() == 4);
|
|
CHECK(bundle.project_config.opt<ConfigOptionFloats>("flush_volumes_matrix")->values.size() == 16);
|
|
}
|
|
|
|
// Slots 0 and 2 published: the receiver grows to 3, never to the file's 4.
|
|
{
|
|
PresetBundle bundle;
|
|
add_pla_preset(bundle);
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_color_entry(0), make_color_entry(2) };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 3);
|
|
CHECK(bundle.filament_presets[1] == "My PLA");
|
|
}
|
|
|
|
// A receiver with more slots than the file's filament count keeps its setup: neither the
|
|
// preset list nor the project-level vectors are shrunk to the file's smaller size.
|
|
{
|
|
PresetBundle bundle;
|
|
add_pla_preset(bundle);
|
|
bundle.filament_presets = { "My PLA", "My PLA", "My PLA" };
|
|
// Distinct project colours make a shrink observable.
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values = { "#111111", "#222222", "#333333" };
|
|
bundle.project_config.opt<ConfigOptionStrings>("filament_multi_colour")->values = { "#111111", "#222222", "#333333" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_color_entry(0) }; // highest published slot: 0
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
// A one-filament file: num_filaments (1) is below the receiver's slot count (3).
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99" };
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 3);
|
|
// No shrink: all three project entries survive, with slot 0 synced to the published
|
|
// colour at its unshifted index and slots 1-2 untouched.
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF", "#222222", "#333333" });
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_multi_colour")->values == std::vector<std::string>{ "#ABCDEF", "#222222", "#333333" });
|
|
CHECK(bundle.project_config.opt<ConfigOptionInts>("filament_map")->values.size() == 3);
|
|
// The published colour still reached slot 0's preset in place.
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
}
|
|
}
|
|
|
|
// A published slot is seeded from an unused library preset and the values are written onto it
|
|
// in place, so the receiver's own material (slot 0) is never overwritten.
|
|
TEST_CASE("Published 3MF seeds published slots from unused presets and mutates them in place", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3, 4 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" };
|
|
return config;
|
|
};
|
|
|
|
// Receiver with its own material plus one more library preset; author publishes only slot 4
|
|
// (Red). The grown slot is seeded from the unused library preset, so the published red
|
|
// recolors that preset in place and never the receiver's own material.
|
|
PresetBundle bundle;
|
|
Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
mine.config.opt_string("filament_type", 0u) = "PLA";
|
|
mine.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
Preset &other = add_inmemory_preset(bundle.filaments, "Other PLA");
|
|
other.config.opt_string("filament_type", 0u) = "PLA";
|
|
other.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#654321" };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 3;
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 4);
|
|
// Unpublished filler slots repeat the receiver's last preset ("Add one filament").
|
|
CHECK(bundle.filament_presets[1] == "My PLA");
|
|
CHECK(bundle.filament_presets[2] == "My PLA");
|
|
// The published slot was seeded from the unused library preset; the published colour was
|
|
// written onto it in place, never onto the receiver's own material.
|
|
CHECK(bundle.filament_presets[3] == "Other PLA");
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#123456" });
|
|
CHECK(bundle.filaments.find_preset("Other PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
// The project-level colours are sized and seeded for every grown slot.
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values.size() == 4);
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values[1] == "#123456");
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values[3] == "#ABCDEF");
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_multi_colour")->values.size() == 4);
|
|
CHECK(bundle.project_config.opt<ConfigOptionStrings>("filament_colour_type")->values.size() == 4);
|
|
CHECK(bundle.project_config.opt<ConfigOptionInts>("filament_map")->values.size() == 4);
|
|
}
|
|
|
|
// Without a checked Type row, a grown published slot is still seeded from the published
|
|
// material's identity - an exact filament_id outranks any arbitrary unused preset, and an
|
|
// entry carrying only a family constrains the pick to that family.
|
|
TEST_CASE("Published 3MF seeds a grown slot by published identity or family without a type requirement", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3, 4 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" };
|
|
return config;
|
|
};
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 2;
|
|
entry.filament_type = "PLA";
|
|
entry.filament_vendor = "Generic";
|
|
// An unused preset sorting before everything else: an unconstrained pick would take it.
|
|
PresetBundle bundle;
|
|
Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
mine.config.opt_string("filament_type", 0u) = "PLA";
|
|
Preset &arbitrary = add_inmemory_preset(bundle.filaments, "Aaa PLA");
|
|
arbitrary.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
SECTION("an exact filament_id outranks the first unused preset") {
|
|
Preset &authored = add_inmemory_preset(bundle.filaments, "Zzz PLA");
|
|
authored.config.opt_string("filament_type", 0u) = "PLA";
|
|
authored.filament_id = "GFA00";
|
|
entry.filament_id = "GFA00";
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 3);
|
|
CHECK(bundle.filament_presets[1] == "My PLA");
|
|
CHECK(bundle.filament_presets[2] == "Zzz PLA");
|
|
// An exact identity match is not a substitute, so nothing is reported.
|
|
CHECK(pub.material_replacements.empty());
|
|
}
|
|
|
|
SECTION("a family-only entry picks an unused preset of that family") {
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "Bbb PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
entry.filament_type = "PETG";
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 3);
|
|
CHECK(bundle.filament_presets[2] == "Bbb PETG");
|
|
}
|
|
}
|
|
|
|
// The GUI displays the edited preset, a snapshot of the selected collection preset taken at
|
|
// selection time. Since the overlay mutates the collection presets in place, the load must
|
|
// re-select the first slot's filament so the applied values - and slot replacements - surface
|
|
// in the GUI.
|
|
TEST_CASE("Published 3MF refreshes the edited preset so the applied material values surface", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9 };
|
|
return config;
|
|
};
|
|
auto make_entry = [] {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
entry.keys = { "filament_retraction_length" };
|
|
return entry;
|
|
};
|
|
|
|
SECTION("the edited preset carries the applied colour and keys") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
bundle.filament_presets = { "My PLA" };
|
|
// Mirror the GUI: the displayed preset is the collection's edited preset.
|
|
REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false));
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_entry() };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
const Preset &edited = bundle.filaments.get_edited_preset();
|
|
// The slot references the edited preset, so the overlay lands on the edited layer:
|
|
// visible as a modification while the stored preset stays untouched.
|
|
CHECK(edited.name == "My PLA");
|
|
CHECK(edited.config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
check_double_vector(edited.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#123456" });
|
|
check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
// The overlay is a visible, revertible modification of the edited preset.
|
|
CHECK(bundle.filaments.current_is_dirty());
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
SECTION("a slot replacement is reflected in the edited preset") {
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
Preset &abs = add_inmemory_preset(bundle.filaments, "My ABS");
|
|
abs.config.opt_string("filament_type", 0u) = "ABS";
|
|
abs.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.3 };
|
|
bundle.filament_presets = { "My PLA" };
|
|
REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false));
|
|
|
|
PublishedMaterialEntry entry = make_entry();
|
|
entry.publish_type_value = "ABS"; // mismatch: replaced by the library's ABS
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets[0] == "My ABS");
|
|
// The edited preset now displays the replacement with the author's values on top.
|
|
const Preset &edited = bundle.filaments.get_edited_preset();
|
|
CHECK(edited.name == "My ABS");
|
|
CHECK(edited.config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
check_double_vector(edited.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
}
|
|
}
|
|
|
|
// The overlay lands on the edited layer when the slot references the collection's edited
|
|
// preset, so the user's unsaved in-memory edits on it survive a published load (only the
|
|
// published keys are touched) and the change shows as a visible, revertible modification.
|
|
TEST_CASE("Published 3MF preserves unsaved edits on the edited filament preset", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_z_hop", true)->values = { 0.1 };
|
|
bundle.filament_presets = { "My PLA" };
|
|
REQUIRE(bundle.filaments.select_preset_by_name("My PLA", false));
|
|
|
|
// The user has unsaved in-memory edits on the preset being shown.
|
|
bundle.filaments.get_edited_preset().config.opt<ConfigOptionFloatsNullable>("filament_z_hop")->values = { 0.7 };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.publish_type = true;
|
|
entry.publish_type_value = "PLA";
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
entry.keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
DynamicPrintConfig config = published_pla_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// Published values land on the edited layer...
|
|
const Preset &edited = bundle.filaments.get_edited_preset();
|
|
CHECK(edited.name == "My PLA");
|
|
CHECK(edited.config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
check_double_vector(edited.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
// ...the user's unsaved edit on a non-published key survives...
|
|
check_double_vector(edited.config.opt<ConfigOptionFloatsNullable>("filament_z_hop")->values, { 0.7 });
|
|
// ...and the stored preset is untouched.
|
|
Preset *stored = bundle.filaments.find_preset("My PLA", false, true);
|
|
REQUIRE(stored != nullptr);
|
|
CHECK(stored->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#123456" });
|
|
check_double_vector(stored->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.5 });
|
|
check_double_vector(stored->config.opt<ConfigOptionFloatsNullable>("filament_z_hop")->values, { 0.1 });
|
|
// The overlay is a visible, revertible modification of the edited preset.
|
|
CHECK(bundle.filaments.current_is_dirty());
|
|
CHECK(pub.skipped_keys.empty());
|
|
CHECK(pub.material_replacements.empty());
|
|
}
|
|
|
|
// The published overlay must validate '#' variant indices: an out-of-range index is reported as
|
|
// skipped and must NOT resize/corrupt the receiver's vector, and a variant suffix on a scalar
|
|
// key is rejected instead of silently no-op'd.
|
|
TEST_CASE("Published 3MF rejects out-of-range vector variants and variant-suffixed scalar keys", "[Preset][Bundle][Published]")
|
|
{
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
// Vector key, size 2 (matches the receiver's resized vector); distinct values make the
|
|
// applied element observable.
|
|
config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values = { 140., 150. };
|
|
config.opt_float("layer_height") = 0.28;
|
|
Preset::normalize(config);
|
|
|
|
PresetBundle bundle;
|
|
bundle.prints.get_edited_preset().config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values = { 10., 20. };
|
|
bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1;
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "wiping_volumes_extruders#5", "wiping_volumes_extruders#1", "wiping_volumes_extruders#abc", "layer_height#0" };
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// In-range variant applied element-wise; the out-of-range one did not resize the vector.
|
|
check_double_vector(bundle.prints.get_edited_preset().config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values, { 10., 150. });
|
|
CHECK(bundle.prints.get_edited_preset().config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values.size() == 2);
|
|
// Out-of-range variant, malformed variant and variant-suffixed scalar are reported as
|
|
// skipped; the malformed one must not fall back to element 0.
|
|
CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders#5"));
|
|
CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders#abc"));
|
|
CHECK(contains_key(pub.skipped_keys, "layer_height#0"));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "wiping_volumes_extruders#1"));
|
|
// The scalar was never applied.
|
|
CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.1, 0.000001));
|
|
}
|
|
|
|
// The print/printer overlay guards option types like the material pass does: a published key
|
|
// whose file-side option kind differs from the receiver's is reported as skipped instead of
|
|
// throwing ConfigurationError out of load_config_model, which would abort the whole project
|
|
// load. (Nullable variants share the type() of their non-nullable base, so this covers
|
|
// genuinely different option kinds - e.g. a string where a float vector is expected.)
|
|
TEST_CASE("Published 3MF reports type-mismatched keys as skipped instead of aborting", "[Preset][Bundle][Published]")
|
|
{
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
// The file carries the vector key as a string option (equal size to the receiver's)...
|
|
config.set_key_value("wiping_volumes_extruders", new ConfigOptionStrings({ "140", "150" }));
|
|
config.opt_float("layer_height") = 0.28;
|
|
|
|
PresetBundle bundle;
|
|
// ...while the receiver's edited print preset holds the float variant of the same key:
|
|
// without the type guard, ConfigOptionVector::set() throws ConfigurationError out of
|
|
// load_config_model.
|
|
bundle.prints.get_edited_preset().config.set_key_value("wiping_volumes_extruders", new ConfigOptionFloats({ 10., 20. }));
|
|
bundle.prints.get_edited_preset().config.opt_float("layer_height") = 0.1;
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "wiping_volumes_extruders", "layer_height" };
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The load completes; the type-mismatched key is reported as skipped and the receiver's
|
|
// value is untouched; the matching scalar key still applies.
|
|
CHECK(contains_key(pub.skipped_keys, "wiping_volumes_extruders"));
|
|
check_double_vector(bundle.prints.get_edited_preset().config.opt<ConfigOptionFloats>("wiping_volumes_extruders")->values, { 10., 20. });
|
|
CHECK_THAT(bundle.prints.get_edited_preset().config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.28, 0.000001));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "layer_height"));
|
|
}
|
|
|
|
// A receiver filament preset missing its material identity (hand-edited file) must not crash
|
|
// the type gate: the gate reads it as a type mismatch, and the slot falls back to the
|
|
// "no replacement" path (keys skipped, colour still applied to the slot's preset).
|
|
TEST_CASE("Published 3MF survives a receiver preset missing its material identity", "[Preset][Bundle][Published]")
|
|
{
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9 };
|
|
Preset::normalize(config);
|
|
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
// Malformed receiver preset: the identity options are missing entirely.
|
|
pla.config.erase("filament_type");
|
|
pla.config.erase("filament_vendor");
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.filament_type = "PLA";
|
|
entry.filament_vendor = "Generic";
|
|
entry.filament_id = "GFL99";
|
|
entry.slot = 0;
|
|
entry.publish_type = true; // exercises the type gate against the missing identity
|
|
// Require ABS: the receiver library (PLA-typed default preset, typeless slot preset) has no
|
|
// ABS candidate, so the gate falls into the "no replacement" path (a PLA requirement would
|
|
// legitimately replace the slot with the visible PLA default).
|
|
entry.publish_type_value = "ABS";
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
entry.keys = { "filament_retraction_length" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The gate reads the missing identity as a type mismatch; no same-type replacement exists,
|
|
// so the keys are skipped while the colour applies to the slot's preset in place. No crash.
|
|
CHECK(contains_key(pub.skipped_keys, "material:GFL99 (filament_retraction_length)"));
|
|
CHECK(bundle.filament_presets[0] == "My PLA");
|
|
Preset *mine_preset = bundle.filaments.find_preset("My PLA", false, true);
|
|
REQUIRE(mine_preset != nullptr);
|
|
CHECK(mine_preset->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
}
|
|
|
|
// Lock the exact contents and order of the printer allowlist (the union of the tab's
|
|
// "Retraction" and "Z-Hop" optgroup lists, Tab.cpp).
|
|
TEST_CASE("Printer publishable allowlist matches the printer tab's Retraction and Z-Hop optgroups", "[Preset][Bundle][Published]")
|
|
{
|
|
auto keys_of = [](const std::vector<PublishablePrinterOption>& opts) {
|
|
std::vector<std::string> keys;
|
|
keys.reserve(opts.size());
|
|
for (const PublishablePrinterOption& opt : opts)
|
|
keys.emplace_back(opt.key);
|
|
return keys;
|
|
};
|
|
|
|
const std::vector<std::string> expected_retraction = {
|
|
"retraction_length", "retract_restart_extra", "retraction_speed", "deretraction_speed",
|
|
"retraction_minimum_travel", "retract_when_changing_layer", "wipe", "wipe_distance",
|
|
"retract_before_wipe", "retract_after_wipe"
|
|
};
|
|
const std::vector<std::string> expected_z_hop = {
|
|
"retract_lift_enforce", "z_hop_types", "z_hop", "travel_slope", "retract_lift_above",
|
|
"retract_lift_below"
|
|
};
|
|
|
|
CHECK(keys_of(publishable_printer_retraction_options()) == expected_retraction);
|
|
CHECK(keys_of(publishable_printer_z_hop_options()) == expected_z_hop);
|
|
|
|
std::set<std::string> expected_union(expected_retraction.begin(), expected_retraction.end());
|
|
expected_union.insert(expected_z_hop.begin(), expected_z_hop.end());
|
|
CHECK(publishable_printer_keys() == expected_union);
|
|
}
|
|
|
|
// Loading the same published file twice must not compound values on the receiver's presets:
|
|
// each load re-applies the same absolute values, so the result is idempotent.
|
|
TEST_CASE("Published 3MF reloading does not compound values on the receiver's presets", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.9 };
|
|
return config;
|
|
};
|
|
auto make_entry = [] {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 0;
|
|
entry.publish_color = true;
|
|
entry.color = "#ABCDEF";
|
|
entry.keys = { "filament_retraction_length" };
|
|
return entry;
|
|
};
|
|
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
pla.config.opt<ConfigOptionStrings>("filament_colour", true)->values = { "#123456" };
|
|
pla.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
auto load = [&] {
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_entry() };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
};
|
|
load();
|
|
// First load: the receiver's preset carries the published values (mutated in place).
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
load();
|
|
// Each load re-applies the same values onto the (already mutated) preset: no accumulation.
|
|
CHECK(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionStrings>("filament_colour")->values == std::vector<std::string>{ "#ABCDEF" });
|
|
check_double_vector(bundle.filaments.find_preset("My PLA", false, true)->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
}
|
|
|
|
// A receiver with several slots aliasing the same preset (multi-extruder profile with one
|
|
// filament) and an author publishing keys on several slots: each published slot is re-pointed
|
|
// at its own distinct preset so values never leak between slots.
|
|
TEST_CASE("Published 3MF gives each published slot its own preset on an aliased receiver", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3, 4 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00", "#0000FF", "#FFFF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA", "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99", "GFL99", "GFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6, 0.9, 1.2, 1.5 };
|
|
return config;
|
|
};
|
|
auto make_key_entry = [](int slot) {
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = slot;
|
|
entry.keys = { "filament_retraction_length" };
|
|
return entry;
|
|
};
|
|
|
|
// A 4-extruder receiver with a single filament preset: the slots alias [A, A, A, A] before
|
|
// the published pass.
|
|
PresetBundle bundle;
|
|
Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
mine.config.opt_string("filament_type", 0u) = "PLA";
|
|
mine.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
// Spare library presets for the re-pointing to fall back on.
|
|
for (const char *name : { "Extra PLA A", "Extra PLA B", "Extra PLA C" }) {
|
|
Preset &extra = add_inmemory_preset(bundle.filaments, name);
|
|
extra.config.opt_string("filament_type", 0u) = "PLA";
|
|
extra.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
}
|
|
bundle.filament_presets = { "My PLA", "My PLA", "My PLA", "My PLA" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { make_key_entry(0), make_key_entry(1), make_key_entry(2), make_key_entry(3) };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 4);
|
|
// Every published slot references its own distinct preset: slot 0 keeps the receiver's
|
|
// material, slots 1-3 are re-pointed at the spare library presets.
|
|
CHECK(bundle.filament_presets[0] == "My PLA");
|
|
CHECK(bundle.filament_presets[1] != bundle.filament_presets[0]);
|
|
CHECK(bundle.filament_presets[2] != bundle.filament_presets[0]);
|
|
CHECK(bundle.filament_presets[2] != bundle.filament_presets[1]);
|
|
CHECK(bundle.filament_presets[3] != bundle.filament_presets[0]);
|
|
CHECK(bundle.filament_presets[3] != bundle.filament_presets[1]);
|
|
CHECK(bundle.filament_presets[3] != bundle.filament_presets[2]);
|
|
// Each slot's stored preset carries its own slot's retraction (mutated in place).
|
|
const std::vector<double> expected = { 0.6, 0.9, 1.2, 1.5 };
|
|
for (size_t slot = 0; slot < 4; ++slot) {
|
|
Preset *preset = bundle.filaments.find_preset(bundle.filament_presets[slot], false, true);
|
|
REQUIRE(preset != nullptr);
|
|
check_double_vector(preset->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { expected[slot] });
|
|
}
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// De-aliasing runs on the exported identity even without a checked Type row: the re-pointed
|
|
// slot lands on the exact published material (by filament_id) rather than an arbitrary spare,
|
|
// and the formerly silent re-point is surfaced through the replacements notification list.
|
|
TEST_CASE("Published 3MF de-aliases an aliased slot by published identity without a type requirement", "[Preset][Bundle][Published]")
|
|
{
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive Standard" };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFL99" };
|
|
config.option<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.6, 0.9 };
|
|
|
|
PresetBundle bundle;
|
|
Preset &mine = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
mine.config.opt_string("filament_type", 0u) = "PLA";
|
|
mine.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
// A spare sorting before the exact match: an unconstrained pick would take it.
|
|
Preset &spare = add_inmemory_preset(bundle.filaments, "Aaa PLA");
|
|
spare.config.opt_string("filament_type", 0u) = "PLA";
|
|
spare.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
Preset &match = add_inmemory_preset(bundle.filaments, "Zzz PLA");
|
|
match.config.opt_string("filament_type", 0u) = "PLA";
|
|
match.config.opt<ConfigOptionFloatsNullable>("filament_retraction_length", true)->values = { 0.5 };
|
|
match.filament_id = "GFA00";
|
|
bundle.filament_presets = { "My PLA", "My PLA" };
|
|
|
|
PublishedMaterialEntry entry;
|
|
entry.slot = 1;
|
|
entry.filament_type = "PLA";
|
|
entry.filament_vendor = "Generic";
|
|
entry.filament_id = "GFA00";
|
|
entry.keys = { "filament_retraction_length" };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { entry };
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 2);
|
|
CHECK(bundle.filament_presets[0] == "My PLA");
|
|
CHECK(bundle.filament_presets[1] == "Zzz PLA");
|
|
// The published key was written onto the re-pointed slot's own preset.
|
|
Preset *target = bundle.filaments.find_preset("Zzz PLA", false, true);
|
|
REQUIRE(target != nullptr);
|
|
check_double_vector(target->config.opt<ConfigOptionFloatsNullable>("filament_retraction_length")->values, { 0.9 });
|
|
REQUIRE(pub.material_replacements.size() == 1);
|
|
CHECK(pub.material_replacements[0].find("(de-aliased") != std::string::npos);
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// Printer retraction keys are published per-extruder ("#N"): a receiver with a different
|
|
// extruder count still receives the in-range elements; out-of-range variants are reported as
|
|
// skipped instead of corrupting the receiver's vector.
|
|
TEST_CASE("Published 3MF applies per-extruder printer keys across extruder-count mismatches", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
// Author has 4 extruders.
|
|
config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.6, 0.9, 1.2, 1.5 };
|
|
Preset::normalize(config);
|
|
return config;
|
|
};
|
|
|
|
// Receiver with a single extruder: only "#0" is in range; "#1..#3" are skipped.
|
|
{
|
|
PresetBundle bundle;
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.8 };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "retraction_length#0", "retraction_length#1", "retraction_length#2", "retraction_length#3" };
|
|
DynamicPrintConfig config = make_file_config();
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values, { 0.6 });
|
|
CHECK(contains_key(pub.skipped_keys, "retraction_length#1"));
|
|
CHECK(contains_key(pub.skipped_keys, "retraction_length#2"));
|
|
CHECK(contains_key(pub.skipped_keys, "retraction_length#3"));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_length#0"));
|
|
}
|
|
|
|
// Receiver with four extruders and a 1-extruder author: only "#0" is published; the
|
|
// receiver's other extruders keep their own values.
|
|
{
|
|
PresetBundle bundle;
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.8, 0.8, 0.8, 0.8 };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "retraction_length#0" };
|
|
DynamicPrintConfig config = make_file_config();
|
|
// The author's file carries a single-extruder value.
|
|
config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.7 };
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values, { 0.7, 0.8, 0.8, 0.8 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
}
|
|
|
|
// A published mixed filament serializes its definition (components, ratios, gradient) into the
|
|
// receiver's project_config - the project-level parallel arrays, not a filament preset. The
|
|
// mix's own blended colour is carried as publish_color so the receiver renders the swatch.
|
|
TEST_CASE("Published 3MF applies a mixed filament definition onto the receiver's project config", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
// Three author slots: two physical PLA/PETG plus one virtual mixed slot (index 2)
|
|
// blending slots 1 and 2 at 60/40 with a gradient.
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75, 1.75 };
|
|
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 2, 3 };
|
|
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = {
|
|
"Direct Drive Standard", "Direct Drive Standard", "Direct Drive Standard"
|
|
};
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#0000FF", "#800080" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PETG", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic", "Generic" };
|
|
config.opt<ConfigOptionStrings>("filament_ids")->values = { "GFL99", "GFT99", "GFL99" };
|
|
// The mixed slot's definition. These keys are project-level arrays in the full config;
|
|
// on export they are masked so only the published slot's entry survives.
|
|
config.opt<ConfigOptionBools>("filament_is_mixed")->values = { 0, 0, 1 };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_components")->values = { "", "", "1,2" };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "", "", "0.6,0.4" };
|
|
config.opt<ConfigOptionBools>("filament_mixed_gradient")->values = { 0, 0, 1 };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_gradient_range")->values = { "", "", "0.9,0.1" };
|
|
config.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values = { "", "", "0,0.1|1,0.9" };
|
|
config.opt<ConfigOptionBools>("filament_mixed_gradient_per_part")->values = { 0, 0, 1 };
|
|
return config;
|
|
};
|
|
|
|
// A receiver that already carries the mix slot at index 2 (e.g. a two-physical-plus-one-mix
|
|
// project with the same layout).
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
Preset &petg = add_inmemory_preset(bundle.filaments, "My PETG");
|
|
petg.config.opt_string("filament_type", 0u) = "PETG";
|
|
bundle.filament_presets = { "My PLA", "My PETG", "My PLA" };
|
|
|
|
// Grow the receiver's project arrays to 3 slots first, as set_num_filaments would.
|
|
bundle.set_num_filaments(3);
|
|
|
|
PublishedMaterialEntry mix;
|
|
mix.filament_type = "PLA";
|
|
mix.filament_vendor = "Generic";
|
|
mix.filament_id = "GFL99";
|
|
mix.slot = 2;
|
|
mix.publish_color = true;
|
|
mix.color = "#800080";
|
|
mix.keys = { "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" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { mix };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The definition landed in project_config's parallel arrays at the author slot.
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 3);
|
|
CHECK(is_mixed[2]);
|
|
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
|
|
REQUIRE(components.size() == 3);
|
|
CHECK(components[2] == "1,2");
|
|
const auto &ratios = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values;
|
|
REQUIRE(ratios.size() == 3);
|
|
CHECK(ratios[2] == "0.6,0.4");
|
|
const auto &gradient = bundle.project_config.opt<ConfigOptionBools>("filament_mixed_gradient")->values;
|
|
CHECK(gradient[2]);
|
|
const auto &range = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_gradient_range")->values;
|
|
CHECK(range[2] == "0.9,0.1");
|
|
const auto &curve = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_gradient_curve")->values;
|
|
CHECK(curve[2] == "0,0.1|1,0.9");
|
|
const auto &per_part = bundle.project_config.opt<ConfigOptionBools>("filament_mixed_gradient_per_part")->values;
|
|
CHECK(per_part[2]);
|
|
// The mix's blended colour crossed into project_config for the swatch.
|
|
const auto &colour = bundle.project_config.opt<ConfigOptionStrings>("filament_colour")->values;
|
|
REQUIRE(colour.size() == 3);
|
|
CHECK(colour[2] == "#800080");
|
|
// The other slots were not overwritten by the mask.
|
|
CHECK_FALSE(is_mixed[0]);
|
|
CHECK_FALSE(is_mixed[1]);
|
|
// Nothing skipped: every serialized mixed key was applied.
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// A receiver with fewer slots: the slot is grown and seeded before the definition applies.
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
PublishedMaterialEntry mix;
|
|
mix.slot = 2;
|
|
mix.keys = { "filament_is_mixed", "filament_mixed_components", "filament_mixed_sublayer_ratios" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { mix };
|
|
DynamicPrintConfig config = make_file_config();
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 3);
|
|
const auto &is_mixed = bundle.project_config.opt<ConfigOptionBools>("filament_is_mixed")->values;
|
|
REQUIRE(is_mixed.size() == 3);
|
|
CHECK(is_mixed[2]);
|
|
const auto &components = bundle.project_config.opt<ConfigOptionStrings>("filament_mixed_components")->values;
|
|
REQUIRE(components.size() == 3);
|
|
CHECK(components[2] == "1,2");
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
}
|
|
|
|
// A published mixed filament whose definition cannot be applied is reported as skipped instead
|
|
// of aborting the load: the entry lists a mixed key that the file's payload does not carry.
|
|
TEST_CASE("Published 3MF reports an unappliable mixed filament definition as skipped", "[Preset][Bundle][Published]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset &pla = add_inmemory_preset(bundle.filaments, "My PLA");
|
|
pla.config.opt_string("filament_type", 0u) = "PLA";
|
|
bundle.filament_presets = { "My PLA" };
|
|
|
|
// Two author slots (so slot 1 is in range) but the payload omits the mixed arrays: the
|
|
// entry lists them, the file config does not.
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
config.opt<ConfigOptionStrings>("filament_type")->values = { "PLA", "PLA" };
|
|
config.opt<ConfigOptionStrings>("filament_vendor")->values = { "Generic", "Generic" };
|
|
|
|
PublishedMaterialEntry mix;
|
|
mix.slot = 1;
|
|
mix.keys = { "filament_mixed_components", "filament_mixed_sublayer_ratios" };
|
|
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.material_keys = { mix };
|
|
Preset::normalize(config);
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
// The receiver grows to two slots; the missing payload keys are reported as skipped rather
|
|
// than dropped silently (material_label is empty for this entry).
|
|
REQUIRE(bundle.filament_presets.size() == 2);
|
|
CHECK(contains_key(pub.skipped_keys, "material: (filament_mixed_components)"));
|
|
CHECK(contains_key(pub.skipped_keys, "material: (filament_mixed_sublayer_ratios)"));
|
|
}
|
|
|
|
// A single-extruder receiver collapses the author's per-extruder printer slots onto its single
|
|
// slot: the first serialized variant of a base key is applied, the remaining variants of that
|
|
// base key are reported as skipped.
|
|
TEST_CASE("Published 3MF collapses a multi-extruder publish onto a single-extruder receiver", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000" };
|
|
// Author has two extruders.
|
|
config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.6, 0.9 };
|
|
config.opt<ConfigOptionFloats>("retraction_speed")->values = { 30.0, 40.0 };
|
|
Preset::normalize(config);
|
|
return config;
|
|
};
|
|
|
|
// Both extruders published: the first serialized variant (#0, left) lands on the receiver's
|
|
// single slot; the second variant (#1) is reported as skipped.
|
|
{
|
|
PresetBundle bundle;
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.8 };
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_speed")->values = { 25.0 };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "retraction_length#0", "retraction_length#1", "retraction_speed#0", "retraction_speed#1" };
|
|
DynamicPrintConfig config = make_file_config();
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values, { 0.6 });
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_speed")->values, { 30.0 });
|
|
CHECK(contains_key(pub.skipped_keys, "retraction_length#1"));
|
|
CHECK(contains_key(pub.skipped_keys, "retraction_speed#1"));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_length#0"));
|
|
CHECK_FALSE(contains_key(pub.skipped_keys, "retraction_speed#0"));
|
|
}
|
|
|
|
// Only the second extruder published: the single-extruder receiver still applies it (the
|
|
// author's "right" is the only serialized slot) and reports nothing skipped.
|
|
{
|
|
PresetBundle bundle;
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.8 };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "retraction_length#1" };
|
|
DynamicPrintConfig config = make_file_config();
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values, { 0.9 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
}
|
|
|
|
// A multi-extruder "similar setup" receiver overrides each published extruder slot element-wise
|
|
// (no collapsing): each '#N' variant applies to the matching receiver slot, out-of-range ones are
|
|
// reported as skipped.
|
|
TEST_CASE("Published 3MF overrides each extruder slot on a similar multi-extruder receiver", "[Preset][Bundle][Published]")
|
|
{
|
|
auto make_file_config = [] {
|
|
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
|
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
|
// Author has two extruders.
|
|
config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.6, 0.9 };
|
|
Preset::normalize(config);
|
|
return config;
|
|
};
|
|
|
|
// Receiver with two extruders: both published slots override element-wise.
|
|
{
|
|
PresetBundle bundle;
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.8, 0.8 };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "retraction_length#0", "retraction_length#1" };
|
|
DynamicPrintConfig config = make_file_config();
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values, { 0.6, 0.9 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
|
|
// Receiver with three extruders: slots 0 and 1 override, slot 2 keeps its own value.
|
|
{
|
|
PresetBundle bundle;
|
|
bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values = { 0.8, 0.8, 0.7 };
|
|
PublishedConfig pub;
|
|
pub.published = true;
|
|
pub.published_keys = { "retraction_length#0", "retraction_length#1" };
|
|
DynamicPrintConfig config = make_file_config();
|
|
bundle.load_config_model("test.3mf", std::move(config), Semver(), &pub);
|
|
|
|
check_double_vector(bundle.printers.get_edited_preset().config.opt<ConfigOptionFloats>("retraction_length")->values, { 0.6, 0.9, 0.7 });
|
|
CHECK(pub.skipped_keys.empty());
|
|
}
|
|
}
|
|
|
|
// 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");
|
|
}
|
|
}
|