mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-09-16 21:42:43 +00:00
import_presets reduced each zip entry to a basename by stripping only '/', so on Windows an entry named with '\' separators kept its directory components and was extracted wherever they pointed. Strip both separators, and reject any entry whose name still escapes the extraction folder. The preset name from the JSON and the bundle id from bundle_structure.json were joined onto the preset directory unchecked as well, which let either of them write outside it on every platform. Both are now validated before anything is written. The check is the is_path_within_root helper the 3MF importer already had, moved to Utils so both importers share it. It treats '/' and '\' as separators on every platform, so a bundle that would escape on one OS is rejected on all of them.
1499 lines
70 KiB
C++
1499 lines
70 KiB
C++
#include <catch2/catch_all.hpp>
|
|
|
|
#include <algorithm>
|
|
#include <boost/filesystem.hpp>
|
|
#include <fstream>
|
|
|
|
#include "libslic3r/PresetBundle.hpp"
|
|
#include "libslic3r/AppConfig.hpp"
|
|
#include "libslic3r/Utils.hpp"
|
|
#include "libslic3r/miniz_extension.hpp"
|
|
|
|
#include "test_utils.hpp"
|
|
|
|
using namespace Slic3r;
|
|
|
|
namespace {
|
|
|
|
namespace fs = boost::filesystem;
|
|
|
|
void write_print_preset(const DynamicPrintConfig &default_config, const fs::path &file, const std::string &name, const std::string &inherits = {})
|
|
{
|
|
DynamicPrintConfig config(default_config);
|
|
config.option<ConfigOptionString>("print_settings_id", true)->value = name;
|
|
config.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = inherits;
|
|
|
|
fs::create_directories(file.parent_path());
|
|
config.save_to_json(file.string(), name, "User", "1.0.0");
|
|
}
|
|
|
|
// Write a preset json carrying a name and an "inherits" value, using the given collection's
|
|
// default config so it loads back into that collection. Works for any preset type.
|
|
void write_preset_with_inherits(const DynamicPrintConfig &default_config, const fs::path &file,
|
|
const std::string &name, const std::string &inherits)
|
|
{
|
|
DynamicPrintConfig config(default_config);
|
|
config.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = inherits;
|
|
|
|
fs::create_directories(file.parent_path());
|
|
config.save_to_json(file.string(), name, "User", "1.0.0");
|
|
}
|
|
|
|
// Add an in-memory preset (no file) with the given inherits value (empty => root preset).
|
|
Preset &add_inmemory_preset(PresetCollection &coll, const std::string &name, const std::string &inherits = {})
|
|
{
|
|
DynamicPrintConfig config(coll.default_preset().config);
|
|
config.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = inherits;
|
|
return coll.load_preset(std::string(), name, config, /*select=*/false);
|
|
}
|
|
|
|
// Mark an already-loaded preset as renamed from one or more former names.
|
|
void set_renamed_from(PresetCollection &coll, const std::string &preset_name, std::vector<std::string> old_names)
|
|
{
|
|
for (auto it = coll.begin(); it != coll.end(); ++it)
|
|
if (it->name == preset_name)
|
|
it->renamed_from = std::move(old_names);
|
|
}
|
|
|
|
// A standalone print preset collection that exposes the protected rename-map builder, so a
|
|
// renamed_from scenario can be set up without the full system-profile load pipeline.
|
|
// (PresetCollection is non-copyable - it holds a mutex - so it is constructed directly with
|
|
// the same type/keys/defaults PresetBundle uses for its print collection.)
|
|
struct RenameTestCollection : public PresetCollection
|
|
{
|
|
RenameTestCollection()
|
|
: PresetCollection(Preset::TYPE_PRINT, Preset::print_options(),
|
|
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()))
|
|
{}
|
|
using PresetCollection::update_map_system_profile_renamed;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Preset identity is canonicalized from load path", "[Preset][Identity]")
|
|
{
|
|
ScopedTemporaryDir temp_dir;
|
|
PresetBundle bundle;
|
|
PresetsConfigSubstitutions substitutions;
|
|
|
|
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_PRINT_NAME / "User.json", "User");
|
|
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1" / PRESET_PRINT_NAME / "LocalBundle.json", "LocalBundle");
|
|
write_print_preset(bundle.prints.default_preset().config, temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1" / PRESET_PRINT_NAME / "Subscribed.json", "Subscribed");
|
|
|
|
bundle.prints.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
|
bundle.prints.load_presets((temp_dir.path() / PRESET_LOCAL_DIR / "bundle-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
|
bundle.prints.load_presets((temp_dir.path() / PRESET_SUBSCRIBED_DIR / "remote-1").string(), PRESET_PRINT_NAME, substitutions, ForwardCompatibilitySubstitutionRule::Disable);
|
|
|
|
const Preset *root_user = bundle.prints.find_preset("User");
|
|
REQUIRE(root_user != nullptr);
|
|
CHECK(root_user->name == "User");
|
|
CHECK_FALSE(root_user->is_from_bundle());
|
|
|
|
const Preset *local_bundle = bundle.prints.find_preset("_local/bundle-1/LocalBundle");
|
|
REQUIRE(local_bundle != nullptr);
|
|
CHECK(local_bundle->name == "_local/bundle-1/LocalBundle");
|
|
CHECK(local_bundle->is_from_bundle());
|
|
|
|
const Preset *subscribed = bundle.prints.find_preset("_subscribed/remote-1/Subscribed");
|
|
REQUIRE(subscribed != nullptr);
|
|
CHECK(subscribed->name == "_subscribed/remote-1/Subscribed");
|
|
CHECK(subscribed->is_from_bundle());
|
|
}
|
|
|
|
TEST_CASE("Legacy bundle import without bundle metadata stays in the user preset directory", "[Preset][Identity]")
|
|
{
|
|
ScopedTemporaryDir temp_dir;
|
|
PresetBundle bundle;
|
|
|
|
PresetsConfigSubstitutions substitutions;
|
|
std::vector<std::string> result;
|
|
int overwrite = 0;
|
|
std::string file = (temp_dir.path() / "legacy-bundle" / "Imported.json").string();
|
|
const fs::path user_root = temp_dir.path() / "user";
|
|
|
|
write_print_preset(bundle.prints.default_preset().config, file, "Imported");
|
|
fs::create_directories(user_root);
|
|
bundle.prints.update_user_presets_directory(user_root.string(), PRESET_PRINT_NAME);
|
|
|
|
REQUIRE(bundle.import_json_presets(
|
|
substitutions,
|
|
file,
|
|
[](std::string const &) { return 1; },
|
|
ForwardCompatibilitySubstitutionRule::Disable,
|
|
overwrite,
|
|
result));
|
|
|
|
const Preset *imported = bundle.prints.find_preset("Imported");
|
|
REQUIRE(imported != nullptr);
|
|
CHECK(imported->name == "Imported");
|
|
CHECK(imported->bundle_id.empty());
|
|
CHECK_FALSE(imported->is_from_bundle());
|
|
// Detached user presets (no inherits) are saved in the "base" subfolder of the user preset root.
|
|
CHECK(fs::equivalent(fs::path(imported->file).parent_path().parent_path(), user_root / PRESET_PRINT_NAME));
|
|
}
|
|
|
|
TEST_CASE("Current vendor type tolerates missing printer model", "[Preset][Bundle]")
|
|
{
|
|
PresetBundle bundle;
|
|
|
|
VendorProfile orca_vendor; orca_vendor.id = "ORCA";
|
|
VendorProfile::PrinterModel model;
|
|
model.name = "Orca Test";
|
|
orca_vendor.models.emplace_back(model);
|
|
bundle.vendors.emplace("ORCA", std::move(orca_vendor));
|
|
|
|
bundle.printers.get_edited_preset().config.erase("printer_model");
|
|
|
|
CHECK(bundle.get_current_vendor_type() == VendorType::Unknown);
|
|
}
|
|
|
|
TEST_CASE("A malformed entry in a vendor's preset list is counted, not thrown", "[Preset][Bundle]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
|
|
// A bare number where the list wants an object. An array element has no key,
|
|
// so reporting one as if it did throws nlohmann's invalid_iterator - which is
|
|
// not a parse_error, and escapes the catch around the vendor profile parse.
|
|
std::ofstream((dir.path() / "Acme.json").string())
|
|
<< R"({"version":"1.0.0","name":"Acme","process_list":[123,)"
|
|
<< R"({"name":"0.20mm Standard @Acme","sub_path":"process/standard.json"}]})";
|
|
fs::create_directories(dir.path() / "Acme" / "process");
|
|
std::ofstream((dir.path() / "Acme" / "process" / "standard.json").string())
|
|
<< R"({"type":"process","name":"0.20mm Standard @Acme","from":"system",)"
|
|
<< R"("instantiation":"true","layer_height":"0.2"})";
|
|
|
|
PresetBundle bundle;
|
|
size_t loaded = 0;
|
|
REQUIRE_NOTHROW(loaded = bundle.load_vendor_configs_from_json(
|
|
dir.path().string(), "Acme", PresetBundle::LoadSystem,
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent).second);
|
|
|
|
CHECK(bundle.error_count() > 0); // the malformed element was counted
|
|
CHECK(loaded == 1); // the well-formed one beside it still loaded
|
|
}
|
|
|
|
TEST_CASE("Printer extruder count tolerates missing nozzle diameter", "[Preset][Bundle]")
|
|
{
|
|
PresetBundle bundle;
|
|
DynamicPrintConfig& config = bundle.printers.get_edited_preset().config;
|
|
|
|
config.erase("nozzle_diameter");
|
|
CHECK(bundle.get_printer_extruder_count() == 1);
|
|
|
|
config.set_key_value("nozzle_diameter", new ConfigOptionFloats());
|
|
CHECK(bundle.get_printer_extruder_count() == 1);
|
|
|
|
config.set_key_value("nozzle_diameter", new ConfigOptionFloats({ 0.4, 0.6 }));
|
|
CHECK(bundle.get_printer_extruder_count() == 2);
|
|
}
|
|
|
|
TEST_CASE("Selected printer uses its default or saved bed type", "[Preset][Bundle]")
|
|
{
|
|
PresetBundle bundle;
|
|
Preset& printer = add_inmemory_preset(bundle.printers, "Test Printer");
|
|
printer.is_system = true;
|
|
printer.config.option<ConfigOptionString>("printer_model")->value = "TEST-MODEL";
|
|
printer.config.option<ConfigOptionString>("printer_variant")->value = "0.4";
|
|
printer.config.option<ConfigOptionString>("default_bed_type")->value = "Engineering Plate";
|
|
|
|
AppConfig app_config;
|
|
app_config.set("curr_bed_type", std::to_string(static_cast<int>(btPTE)));
|
|
PresetBundle::PresetPreferences preferred_selection;
|
|
BedType expected_bed_type;
|
|
|
|
SECTION("New printer uses its symbolic default") {
|
|
expected_bed_type = btEP;
|
|
preferred_selection = {"TEST-MODEL", "0.4"};
|
|
}
|
|
SECTION("Re-enabled printer uses its saved selection") {
|
|
expected_bed_type = btPC;
|
|
preferred_selection = {"TEST-MODEL", "0.4"};
|
|
app_config.set_printer_setting("Test Printer", "curr_bed_type",
|
|
std::to_string(static_cast<int>(expected_bed_type)));
|
|
}
|
|
SECTION("Existing printer keeps its saved selection after presets reload") {
|
|
expected_bed_type = btPCT;
|
|
app_config.set("presets", PRESET_PRINTER_NAME, "Test Printer");
|
|
app_config.set_printer_setting("Test Printer", "curr_bed_type",
|
|
std::to_string(static_cast<int>(expected_bed_type)));
|
|
}
|
|
|
|
bundle.load_selections(app_config, preferred_selection);
|
|
bundle.export_selections(app_config);
|
|
|
|
CHECK(bundle.project_config.opt_enum<BedType>("curr_bed_type") == expected_bed_type);
|
|
CHECK(app_config.get_printer_setting("Test Printer", "curr_bed_type") == std::to_string(static_cast<int>(expected_bed_type)));
|
|
}
|
|
|
|
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
|
|
|
|
TEST_CASE("Missing app config is accepted as default CLI state", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
AppConfig app_config;
|
|
app_config.set_loading_path((dir.path() / "missing.conf").string());
|
|
CHECK(app_config.load_if_exists().empty());
|
|
}
|
|
|
|
TEST_CASE("Read-only user preset loading does not create or delete files", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
PresetBundle bundle;
|
|
PresetsConfigSubstitutions substitutions;
|
|
|
|
const fs::path missing_root = dir.path() / "missing-user";
|
|
bundle.prints.load_presets(missing_root.string(), PRESET_PRINT_NAME, substitutions,
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, nullptr,
|
|
PresetOrigin(), true);
|
|
CHECK_FALSE(fs::exists(missing_root / PRESET_PRINT_NAME));
|
|
|
|
const fs::path malformed = dir.path() / "existing-user" / PRESET_PRINT_NAME / "malformed.json";
|
|
fs::create_directories(malformed.parent_path());
|
|
std::ofstream(malformed.string()) << "{not-json";
|
|
bundle.prints.load_presets((dir.path() / "existing-user").string(), PRESET_PRINT_NAME, substitutions,
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, nullptr,
|
|
PresetOrigin(), true);
|
|
CHECK(fs::exists(malformed));
|
|
}
|
|
|
|
TEST_CASE("Typeless preset resolution probes loaded FFF collections", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path source_file = dir.path() / "typeless-process.json";
|
|
std::ofstream(source_file.string()) << R"({"name":"Typeless Process","from":"User"})";
|
|
|
|
PresetBundle bundle;
|
|
Preset &process = add_inmemory_preset(bundle.prints, "Typeless Process");
|
|
process.file = source_file.string();
|
|
process.config.option<ConfigOptionFloats>("travel_speed", true)->values = {321.0};
|
|
|
|
DynamicPrintConfig raw;
|
|
Preset::Type resolved_type = Preset::TYPE_INVALID;
|
|
std::string error;
|
|
REQUIRE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error, false));
|
|
CHECK(error.empty());
|
|
CHECK(resolved_type == Preset::TYPE_PRINT);
|
|
REQUIRE(raw.option<ConfigOptionFloats>("travel_speed")->values.size() == 1);
|
|
CHECK_THAT(raw.option<ConfigOptionFloats>("travel_speed")->values.front(), Catch::Matchers::WithinAbs(321.0, 1e-6));
|
|
}
|
|
|
|
TEST_CASE("Typeless preset resolution preserves duplicate identity ambiguity", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path source_file = dir.path() / "duplicate-process.json";
|
|
std::ofstream(source_file.string()) << "{}";
|
|
|
|
PresetBundle bundle;
|
|
add_inmemory_preset(bundle.prints, "First Process Identity").file = source_file.string();
|
|
add_inmemory_preset(bundle.prints, "Second Process Identity").file = source_file.string();
|
|
|
|
DynamicPrintConfig raw;
|
|
Preset::Type resolved_type = Preset::TYPE_INVALID;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error, false));
|
|
CHECK(error == "Preset identity is ambiguous");
|
|
CHECK(resolved_type == Preset::TYPE_INVALID);
|
|
}
|
|
|
|
TEST_CASE("Typeless preset resolution rejects cross-type ambiguity", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path source_file = dir.path() / "ambiguous.json";
|
|
std::ofstream(source_file.string()) << "{}";
|
|
|
|
PresetBundle bundle;
|
|
add_inmemory_preset(bundle.prints, "Process Identity").file = source_file.string();
|
|
add_inmemory_preset(bundle.filaments, "Filament Identity").file = source_file.string();
|
|
|
|
DynamicPrintConfig raw;
|
|
Preset::Type resolved_type = Preset::TYPE_INVALID;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error, false));
|
|
CHECK(error == "Preset type is ambiguous");
|
|
CHECK(resolved_type == Preset::TYPE_INVALID);
|
|
}
|
|
|
|
TEST_CASE("Typeless preset resolution rejects a missing type candidate", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path source_file = dir.path() / "unknown.json";
|
|
std::ofstream(source_file.string()) << "{}";
|
|
|
|
PresetBundle bundle;
|
|
DynamicPrintConfig raw;
|
|
Preset::Type resolved_type = Preset::TYPE_INVALID;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config_type(raw, resolved_type, source_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error, false));
|
|
CHECK(error == "Preset type could not be resolved");
|
|
CHECK(resolved_type == Preset::TYPE_INVALID);
|
|
}
|
|
|
|
TEST_CASE("Exact file resolution rejects multiple preset identities", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path source_file = dir.path() / "duplicate.json";
|
|
std::ofstream(source_file.string()) << "{}";
|
|
|
|
PresetBundle bundle;
|
|
Preset &first = add_inmemory_preset(bundle.prints, "First Identity");
|
|
first.file = source_file.string();
|
|
Preset &second = add_inmemory_preset(bundle.prints, "Second Identity");
|
|
second.file = source_file.string();
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "Parent";
|
|
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, source_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error, false));
|
|
CHECK(error == "Preset identity is ambiguous");
|
|
}
|
|
|
|
TEST_CASE("System preset resolution returns the canonical vendor configuration", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir source_dir;
|
|
PresetBundle bundle;
|
|
|
|
VendorProfile vendor("VendorB");
|
|
vendor.name = "Vendor B";
|
|
auto [vendor_it, inserted] = bundle.vendors.emplace(vendor.id, std::move(vendor));
|
|
REQUIRE(inserted);
|
|
|
|
Preset &resolved = add_inmemory_preset(bundle.prints, "Vendor B Process", "fdm_process_common");
|
|
resolved.is_system = true;
|
|
resolved.vendor = &vendor_it->second;
|
|
resolved.file = (source_dir.path() / "vendor-b-process.json").string();
|
|
std::ofstream(resolved.file) << "{}";
|
|
resolved.config.option<ConfigOptionFloats>("travel_speed", true)->values = {321.0};
|
|
resolved.config.option<ConfigOptionInt>("wall_loops", true)->value = 2;
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common";
|
|
raw.option<ConfigOptionInt>("wall_loops", true)->value = 5;
|
|
|
|
std::string error;
|
|
REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, resolved.file,
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
CHECK(error.empty());
|
|
REQUIRE(raw.option<ConfigOptionFloats>("travel_speed")->values.size() == 1);
|
|
CHECK_THAT(raw.option<ConfigOptionFloats>("travel_speed")->values.front(), Catch::Matchers::WithinAbs(321.0, 1e-6));
|
|
CHECK(raw.option<ConfigOptionInt>("wall_loops")->value == 2);
|
|
}
|
|
|
|
TEST_CASE("Manifest-backed preset resolution loads the source vendor tree", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path vendor_dir = dir.path() / "Acme";
|
|
const fs::path child_file = vendor_dir / "process" / "nested" / "child.json";
|
|
|
|
std::ofstream((dir.path() / "Acme.json").string())
|
|
<< R"({"version":"1.0.0","name":"Acme","process_list":[)"
|
|
<< R"({"name":"fdm_process_common","sub_path":"process/base.json"},)"
|
|
<< R"({"name":"Acme Process","sub_path":"process/nested/child.json"}]})";
|
|
fs::create_directories(child_file.parent_path());
|
|
std::ofstream((vendor_dir / "process" / "base.json").string())
|
|
<< R"({"type":"process","name":"fdm_process_common","from":"system",)"
|
|
<< R"("instantiation":"false","travel_speed":["321"]})";
|
|
std::ofstream(child_file.string())
|
|
<< R"({"type":"process","name":"Acme Process","from":"system",)"
|
|
<< R"("instantiation":"true","inherits":"fdm_process_common","wall_loops":"5"})";
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common";
|
|
raw.option<ConfigOptionInt>("wall_loops", true)->value = 5;
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
CHECK(error.empty());
|
|
REQUIRE(raw.option<ConfigOptionFloats>("travel_speed")->values.size() == 1);
|
|
CHECK_THAT(raw.option<ConfigOptionFloats>("travel_speed")->values.front(), Catch::Matchers::WithinAbs(321.0, 1e-6));
|
|
CHECK(raw.option<ConfigOptionInt>("wall_loops")->value == 5);
|
|
}
|
|
|
|
TEST_CASE("Manifest-backed resolution is scoped to the explicit source root", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
auto write_vendor = [&](const std::string &root_name, double travel_speed) {
|
|
const fs::path root = dir.path() / root_name;
|
|
const fs::path child_file = root / "Acme" / "process" / "child.json";
|
|
fs::create_directories(child_file.parent_path());
|
|
std::ofstream((root / "Acme.json").string())
|
|
<< R"({"version":"1.0.0","name":"Acme","process_list":[)"
|
|
<< R"({"name":"fdm_process_common","sub_path":"process/base.json"},)"
|
|
<< R"({"name":"Acme Process","sub_path":"process/child.json"}]})";
|
|
std::ofstream((root / "Acme" / "process" / "base.json").string())
|
|
<< R"({"type":"process","name":"fdm_process_common","from":"system",)"
|
|
<< R"("instantiation":"false","travel_speed":[")" << travel_speed << R"("]})";
|
|
std::ofstream(child_file.string())
|
|
<< R"({"type":"process","name":"Acme Process","from":"system",)"
|
|
<< R"("instantiation":"true","inherits":"fdm_process_common"})";
|
|
return child_file;
|
|
};
|
|
|
|
const fs::path source_a = write_vendor("root-a", 111.0);
|
|
const fs::path source_b = write_vendor("root-b", 222.0);
|
|
REQUIRE(fs::exists(source_a));
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "synthetic-parent-marker";
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, source_b.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
REQUIRE(raw.option<ConfigOptionFloats>("travel_speed")->values.size() == 1);
|
|
CHECK_THAT(raw.option<ConfigOptionFloats>("travel_speed")->values.front(), Catch::Matchers::WithinAbs(222.0, 1e-6));
|
|
}
|
|
|
|
TEST_CASE("Exact-only resolution rejects an unconfigured manifest-backed file", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path source_file = dir.path() / "Acme" / "process" / "child.json";
|
|
fs::create_directories(source_file.parent_path());
|
|
std::ofstream((dir.path() / "Acme.json").string())
|
|
<< R"({"version":"1.0.0","name":"Acme","process_list":[)"
|
|
<< R"({"name":"Acme Process","sub_path":"process/child.json"}]})";
|
|
std::ofstream(source_file.string())
|
|
<< R"({"type":"process","name":"Acme Process","from":"system",)"
|
|
<< R"("instantiation":"true","layer_height":"0.2"})";
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "Some Parent";
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, source_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error, false));
|
|
CHECK(error == "Preset was not found in the loaded bundle");
|
|
}
|
|
|
|
TEST_CASE("Vendor filament resolution uses the shared Orca library base", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path library_dir = dir.path() / PresetBundle::ORCA_FILAMENT_LIBRARY;
|
|
const fs::path vendor_dir = dir.path() / "Acme";
|
|
const fs::path child_file = vendor_dir / "filament" / "nested" / "petg.json";
|
|
|
|
std::ofstream((dir.path() / (std::string(PresetBundle::ORCA_FILAMENT_LIBRARY) + ".json")).string())
|
|
<< R"({"version":"1.0.0","name":"OrcaFilamentLibrary","filament_list":[)"
|
|
<< R"({"name":"fdm_filament_pet","sub_path":"filament/pet.json","filament_id":"GFL99"}]})";
|
|
fs::create_directories(library_dir / "filament");
|
|
std::ofstream((library_dir / "filament" / "pet.json").string())
|
|
<< R"({"type":"filament","name":"fdm_filament_pet","from":"system",)"
|
|
<< R"("filament_id":"GFL99","instantiation":"false",)"
|
|
<< R"("filament_type":["PETG"],"filament_density":["1.27"]})";
|
|
|
|
std::ofstream((dir.path() / "Acme.json").string())
|
|
<< R"({"version":"1.0.0","name":"Acme","filament_list":[)"
|
|
<< R"({"name":"Acme PETG","sub_path":"filament/nested/petg.json","filament_id":"GFA00"}]})";
|
|
fs::create_directories(child_file.parent_path());
|
|
std::ofstream(child_file.string())
|
|
<< R"({"type":"filament","name":"Acme PETG","from":"system",)"
|
|
<< R"("filament_id":"GFA00","instantiation":"true","inherits":"fdm_filament_pet"})";
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_filament_pet";
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
REQUIRE(bundle.resolve_preset_config(raw, Preset::TYPE_FILAMENT, child_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
CHECK(error.empty());
|
|
CHECK(raw.opt_string("filament_type", 0u) == "PETG");
|
|
REQUIRE(raw.option<ConfigOptionFloats>("filament_density")->values.size() == 1);
|
|
CHECK_THAT(raw.option<ConfigOptionFloats>("filament_density")->values.front(), Catch::Matchers::WithinAbs(1.27, 1e-6));
|
|
}
|
|
|
|
TEST_CASE("Manifest-backed resolution rejects a missing parent", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path child_file = dir.path() / "Acme" / "process" / "child.json";
|
|
|
|
std::ofstream((dir.path() / "Acme.json").string())
|
|
<< R"({"version":"1.0.0","name":"Acme","process_list":[)"
|
|
<< R"({"name":"Acme Process","sub_path":"process/child.json"}]})";
|
|
fs::create_directories(child_file.parent_path());
|
|
std::ofstream(child_file.string())
|
|
<< R"({"type":"process","name":"Acme Process","from":"system",)"
|
|
<< R"("instantiation":"true","inherits":"Missing Parent","layer_height":"0.2"})";
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "Missing Parent";
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
CHECK_FALSE(error.empty());
|
|
}
|
|
|
|
TEST_CASE("Manifest-backed resolution rejects a vendor load with malformed entries", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path child_file = dir.path() / "Acme" / "process" / "child.json";
|
|
|
|
std::ofstream((dir.path() / "Acme.json").string())
|
|
<< R"({"version":"1.0.0","name":"Acme","process_list":[123,)"
|
|
<< R"({"name":"Acme Process","sub_path":"process/child.json"}]})";
|
|
fs::create_directories(child_file.parent_path());
|
|
std::ofstream(child_file.string())
|
|
<< R"({"type":"process","name":"Acme Process","from":"system",)"
|
|
<< R"("instantiation":"true","layer_height":"0.2"})";
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common";
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, child_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
CHECK_FALSE(error.empty());
|
|
}
|
|
|
|
TEST_CASE("Manifest-backed resolution rejects files absent from the vendor manifest", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path listed_file = dir.path() / "Acme" / "process" / "listed.json";
|
|
const fs::path unlisted_file = dir.path() / "Acme" / "process" / "unlisted.json";
|
|
|
|
std::ofstream((dir.path() / "Acme.json").string())
|
|
<< R"({"version":"1.0.0","name":"Acme","process_list":[)"
|
|
<< R"({"name":"Listed Process","sub_path":"process/listed.json"}]})";
|
|
fs::create_directories(listed_file.parent_path());
|
|
std::ofstream(listed_file.string())
|
|
<< R"({"type":"process","name":"Listed Process","from":"system",)"
|
|
<< R"("instantiation":"true","layer_height":"0.2"})";
|
|
std::ofstream(unlisted_file.string())
|
|
<< R"({"type":"process","name":"Unlisted Process","from":"system",)"
|
|
<< R"("instantiation":"true","inherits":"fdm_process_common"})";
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_process_common";
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, unlisted_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
CHECK(error == "Source file is not an instantiated preset in its vendor manifest");
|
|
}
|
|
|
|
TEST_CASE("Manifest-backed resolution rejects a mismatched preset type", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path process_file = dir.path() / "Acme" / "process" / "child.json";
|
|
|
|
std::ofstream((dir.path() / "Acme.json").string())
|
|
<< R"({"version":"1.0.0","name":"Acme","process_list":[)"
|
|
<< R"({"name":"Acme Process","sub_path":"process/child.json"}]})";
|
|
fs::create_directories(process_file.parent_path());
|
|
std::ofstream(process_file.string())
|
|
<< R"({"type":"process","name":"Acme Process","from":"system",)"
|
|
<< R"("instantiation":"true","layer_height":"0.2"})";
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "fdm_filament_common";
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_FILAMENT, process_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
CHECK(error == "Source file is not an instantiated preset in its vendor manifest");
|
|
}
|
|
|
|
TEST_CASE("Resolution terminates when no vendor manifest exists", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir dir;
|
|
const fs::path detached_file = dir.path() / "detached.json";
|
|
std::ofstream(detached_file.string()) << "{}";
|
|
|
|
DynamicPrintConfig raw;
|
|
raw.option<ConfigOptionString>(BBL_JSON_KEY_INHERITS, true)->value = "Missing Parent";
|
|
|
|
PresetBundle bundle;
|
|
std::string error;
|
|
CHECK_FALSE(bundle.resolve_preset_config(raw, Preset::TYPE_PRINT, detached_file.string(),
|
|
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
|
|
CHECK(error == "Preset was not found in the loaded bundle");
|
|
}
|
|
|
|
// Orca: a filament in the Orca Filament Library that names its compatible printers has to hide the generic
|
|
// library filament sharing its alias, the same way a vendor owned filament does. Otherwise both are compatible
|
|
// with that printer and the plater combo box lists the shared alias twice.
|
|
TEST_CASE("A printer specific filament supersedes the generic library filament with the same alias", "[Preset][Bundle]")
|
|
{
|
|
LibraryFilamentTestCollection filaments;
|
|
PresetCollection printers(Preset::TYPE_PRINTER, Preset::printer_options(),
|
|
static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()));
|
|
// The masking keys off the vendor name, which VendorProfile's constructor does not derive from the id.
|
|
VendorProfile library(PresetBundle::ORCA_FILAMENT_LIBRARY);
|
|
VendorProfile vendor("Vendor");
|
|
library.name = PresetBundle::ORCA_FILAMENT_LIBRARY;
|
|
vendor.name = "Vendor";
|
|
|
|
auto add_filament = [&filaments](const VendorProfile &owner, const std::string &name, std::vector<std::string> compatible_printers) {
|
|
Preset &preset = add_inmemory_preset(filaments, name);
|
|
preset.alias = "Generic ABS";
|
|
preset.vendor = &owner;
|
|
preset.config.option<ConfigOptionStrings>("compatible_printers", true)->values = std::move(compatible_printers);
|
|
};
|
|
|
|
add_filament(library, "Generic ABS @System", {});
|
|
add_filament(library, "Generic ABS @Printer A", { "Printer A" });
|
|
add_filament(vendor, "Generic ABS @Printer B", { "Printer B" });
|
|
|
|
filaments.update_library_profile_excluded_from();
|
|
|
|
const Preset *generic = filaments.find_preset("Generic ABS @System");
|
|
REQUIRE(generic != nullptr);
|
|
CHECK(generic->m_excluded_from.count("Printer A") == 1);
|
|
CHECK(generic->m_excluded_from.count("Printer B") == 1);
|
|
CHECK(generic->m_excluded_from.size() == 2);
|
|
|
|
// A printer specific profile names printers, so it is never the one being hidden - not even by itself.
|
|
const Preset *specific = filaments.find_preset("Generic ABS @Printer A");
|
|
REQUIRE(specific != nullptr);
|
|
CHECK(specific->m_excluded_from.empty());
|
|
|
|
// ...and the generic profile really drops out of the compatible set on the printer it is hidden from.
|
|
add_inmemory_preset(printers, "Printer A");
|
|
add_inmemory_preset(printers, "Printer C");
|
|
const Preset *printer_a = printers.find_preset("Printer A");
|
|
const Preset *printer_c = printers.find_preset("Printer C");
|
|
REQUIRE(printer_a != nullptr);
|
|
REQUIRE(printer_c != nullptr);
|
|
|
|
const PresetWithVendorProfile generic_lib(*generic, &library);
|
|
CHECK_FALSE(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_a, nullptr)));
|
|
CHECK(is_compatible_with_printer(generic_lib, PresetWithVendorProfile(*printer_c, nullptr)));
|
|
}
|
|
|
|
|
|
namespace {
|
|
|
|
// One system printer plus the filament presets a machine facing dialog has to choose between:
|
|
// an Orca Filament Library generic with no compatible_printers, a same alias vendor filament
|
|
// that names the printer, a library filament with no vendor twin, and a vendor filament that
|
|
// belongs to a different printer.
|
|
struct MachineFilaments
|
|
{
|
|
PresetBundle bundle;
|
|
VendorProfile library{PresetBundle::ORCA_FILAMENT_LIBRARY};
|
|
VendorProfile vendor{"Vendor"};
|
|
|
|
MachineFilaments()
|
|
{
|
|
// VendorProfile's constructor takes an id; the library rule keys off the name.
|
|
library.name = PresetBundle::ORCA_FILAMENT_LIBRARY;
|
|
vendor.name = "Vendor";
|
|
|
|
Preset &printer = add_inmemory_preset(bundle.printers, "Printer A 0.4 nozzle");
|
|
printer.is_system = true;
|
|
printer.vendor = &vendor;
|
|
printer.config.option<ConfigOptionString>("printer_model", true)->value = "Printer A";
|
|
|
|
add_filament(library, "Generic ABS @System", "Generic ABS", {});
|
|
add_filament(vendor, "Generic ABS @Printer A", "Generic ABS", { "Printer A 0.4 nozzle" });
|
|
add_filament(library, "FilAr ABS @System", "FilAr ABS", {});
|
|
add_filament(vendor, "Vendor PLA @Printer B", "Vendor PLA", { "Printer B 0.4 nozzle" });
|
|
|
|
// update_library_profile_excluded_from() is protected and has its own test above; record
|
|
// the exclusion it derives from the same alias vendor filament.
|
|
Preset *shadowed = bundle.filaments.find_preset("Generic ABS @System");
|
|
REQUIRE(shadowed != nullptr);
|
|
shadowed->m_excluded_from.insert("Printer A 0.4 nozzle");
|
|
}
|
|
|
|
void add_filament(const VendorProfile &owner, const std::string &name, const std::string &alias,
|
|
std::vector<std::string> compatible_printers)
|
|
{
|
|
Preset &preset = add_inmemory_preset(bundle.filaments, name);
|
|
preset.is_system = true;
|
|
preset.alias = alias;
|
|
preset.vendor = &owner;
|
|
compatible_list(bundle.filaments, name, "compatible_printers") = std::move(compatible_printers);
|
|
}
|
|
|
|
bool offers(const std::string &preset_name, bool include_user_presets = false)
|
|
{
|
|
const std::vector<Preset *> offered =
|
|
bundle.get_filament_presets_for_machine("Printer A", "0.4", include_user_presets);
|
|
return std::any_of(offered.begin(), offered.end(),
|
|
[&preset_name](const Preset *p) { return p->name == preset_name; });
|
|
}
|
|
};
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Filaments offered for a machine follow the app's compatibility rule", "[Preset][Bundle]")
|
|
{
|
|
MachineFilaments f;
|
|
|
|
SECTION("a library filament with no compatible_printers is offered") {
|
|
CHECK(f.offers("FilAr ABS @System"));
|
|
}
|
|
|
|
SECTION("a same alias vendor filament shadows the library generic") {
|
|
CHECK(f.offers("Generic ABS @Printer A"));
|
|
CHECK_FALSE(f.offers("Generic ABS @System"));
|
|
}
|
|
|
|
SECTION("a filament naming a different printer is not offered") {
|
|
CHECK_FALSE(f.offers("Vendor PLA @Printer B"));
|
|
}
|
|
|
|
SECTION("a user filament is offered only when the printer supports user presets") {
|
|
add_inmemory_preset(f.bundle.filaments, "My PLA");
|
|
|
|
CHECK_FALSE(f.offers("My PLA", /*include_user_presets=*/false));
|
|
CHECK(f.offers("My PLA", /*include_user_presets=*/true));
|
|
}
|
|
}
|
|
|
|
|
|
namespace {
|
|
|
|
const char *kMixedKeys[] = {
|
|
"filament_is_mixed",
|
|
"filament_mixed_components",
|
|
"filament_mixed_sublayer_ratios",
|
|
"filament_mixed_gradient",
|
|
"filament_mixed_gradient_range",
|
|
"filament_mixed_gradient_curve",
|
|
"filament_mixed_gradient_per_part",
|
|
};
|
|
|
|
} // namespace
|
|
|
|
// Mixed-color filament metadata lives in project_config as parallel per-filament arrays.
|
|
// set_num_filaments() is the single place that grows them alongside filament_colour; if it
|
|
// misses them, creating a mixed slot writes past the end of the short arrays.
|
|
TEST_CASE("set_num_filaments keeps mixed-color arrays in step with the filament count", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
auto mixed_array_size = [](const DynamicPrintConfig &cfg, const std::string &key) -> size_t {
|
|
if (const auto *b = cfg.option<ConfigOptionBools>(key))
|
|
return b->values.size();
|
|
if (const auto *s = cfg.option<ConfigOptionStrings>(key))
|
|
return s->values.size();
|
|
return size_t(-1); // key missing entirely
|
|
};
|
|
|
|
PresetBundle bundle;
|
|
|
|
const unsigned int n = GENERATE(2u, 4u, 8u);
|
|
bundle.set_num_filaments(n, std::string("#FF0000"));
|
|
|
|
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == n);
|
|
for (const char *key : kMixedKeys) {
|
|
DYNAMIC_SECTION("grown: " << key) {
|
|
CHECK(mixed_array_size(bundle.project_config, key) == n);
|
|
}
|
|
}
|
|
|
|
SECTION("shrinking keeps them in step too") {
|
|
bundle.set_num_filaments(1, std::string("#00FF00"));
|
|
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 1);
|
|
for (const char *key : kMixedKeys)
|
|
CHECK(mixed_array_size(bundle.project_config, key) == 1);
|
|
}
|
|
}
|
|
|
|
// A mix is described by 1-based indices into the project's filament list, which Orca rebuilds
|
|
// from the selected printer's snapshot (filament_%02u / filament_colors) at startup and on every
|
|
// printer selection. Held anywhere but that same per-printer snapshot, the mixed arrays end up
|
|
// indexing a filament list they were never saved against.
|
|
TEST_CASE("Mixed-color filament metadata is snapshotted per printer, with its filament list", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
PresetBundle bundle;
|
|
// export_selections skips the built-in "Default Printer" placeholder entirely.
|
|
add_inmemory_preset(bundle.printers, "Test Printer");
|
|
bundle.printers.select_preset_by_name("Test Printer", true);
|
|
bundle.set_num_filaments(2u, std::string("#FF0000"));
|
|
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values = { false, true };
|
|
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values = { "", "1,2" };
|
|
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_sublayer_ratios")->values = { "", "0.5,0.5" };
|
|
|
|
AppConfig app_config;
|
|
bundle.export_selections(app_config);
|
|
|
|
const std::string printer_name = bundle.printers.get_selected_preset_name();
|
|
for (const char *key : kMixedKeys) {
|
|
DYNAMIC_SECTION("per printer, not global: " << key) {
|
|
CHECK(app_config.has_printer_setting(printer_name, key));
|
|
CHECK_FALSE(app_config.has("presets", key));
|
|
}
|
|
}
|
|
|
|
SECTION("with the encoding load_selections reads back") {
|
|
CHECK(app_config.get_printer_setting(printer_name, "filament_is_mixed") == "0,1");
|
|
CHECK(app_config.get_printer_setting(printer_name, "filament_mixed_components") == "|1,2");
|
|
CHECK(app_config.get_printer_setting(printer_name, "filament_mixed_sublayer_ratios") == "|0.5,0.5");
|
|
}
|
|
}
|
|
|
|
// The gradient curve is the one mixed array whose values contain '|' themselves — it separates the
|
|
// control points — so it cannot be '|'-joined into the app config like its siblings without a
|
|
// multi-point curve being split across filament slots on the way back in.
|
|
TEST_CASE("A multi-point gradient curve survives the app-config snapshot", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
const std::vector<std::string> curves = { "", "", "0,0|0.5,0.3|1,1" };
|
|
|
|
PresetBundle bundle;
|
|
add_inmemory_preset(bundle.printers, "Test Printer");
|
|
bundle.printers.select_preset_by_name("Test Printer", true);
|
|
bundle.set_num_filaments(3u, std::string("#FF0000"));
|
|
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_gradient_curve")->values = curves;
|
|
|
|
AppConfig app_config;
|
|
bundle.export_selections(app_config);
|
|
|
|
// Decoding the stored form returns the three slots intact, curve delimiters and all. A plain
|
|
// '|' join would decode as five slots here instead of three.
|
|
std::vector<std::string> decoded;
|
|
REQUIRE(unescape_strings_cstyle(
|
|
app_config.get_printer_setting(bundle.printers.get_selected_preset_name(), "filament_mixed_gradient_curve"), decoded));
|
|
CHECK(decoded == curves);
|
|
}
|
|
|
|
// A multi-tool printer sizes the filament list from its nozzle count. Mixed-color slots are extra
|
|
// virtual filaments at the tail of that list with no nozzle of their own, so the count has to
|
|
// allow for them: sizing to the nozzle count alone drops the project's mixes and strips every
|
|
// painted facet above the new count.
|
|
TEST_CASE("Sizing the filament list to a multi-tool nozzle count keeps mixed slots", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
// The 5-slot layout of a 4-tool project carrying one mix of filaments 2 and 3.
|
|
const size_t nozzle_count = 4;
|
|
PresetBundle bundle;
|
|
bundle.set_num_filaments(5u, std::string("#FF0000"));
|
|
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
|
|
{ false, false, false, false, true };
|
|
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
|
|
{ "", "", "", "", "2,3" };
|
|
|
|
REQUIRE(bundle.num_mixed_filaments() == 1);
|
|
|
|
SECTION("nozzle count plus the mixed slots preserves the mix") {
|
|
bundle.set_num_filaments(nozzle_count + bundle.num_mixed_filaments(), std::string("#00FF00"));
|
|
|
|
CHECK(bundle.filament_presets.size() == 5);
|
|
CHECK(bundle.num_mixed_filaments() == 1);
|
|
CHECK(bundle.is_mixed_filament(4));
|
|
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[4] == "2,3");
|
|
}
|
|
|
|
SECTION("the nozzle count alone is what truncated it away") {
|
|
bundle.set_num_filaments(nozzle_count, std::string("#00FF00"));
|
|
|
|
CHECK(bundle.filament_presets.size() == nozzle_count);
|
|
CHECK(bundle.num_mixed_filaments() == 0);
|
|
}
|
|
}
|
|
|
|
// The nozzle-count top-up in update_multi_material_filament_presets() grows filament_presets on
|
|
// its own, so a physical count derived from that list reports a slot no per-filament array has
|
|
// yet. That is what made the extruder-count handler conclude there was nothing to add and leave
|
|
// the new sidebar combo with no colour to draw.
|
|
TEST_CASE("The physical filament count is not fooled by a lone filament_presets top-up", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
PresetBundle bundle;
|
|
|
|
SECTION("no mixed slots") {
|
|
bundle.set_num_filaments(4u, std::string("#FF0000"));
|
|
bundle.printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter", true)->values =
|
|
{ 0.4, 0.4, 0.4, 0.4, 0.4 };
|
|
bundle.update_multi_material_filament_presets();
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 5); // the top-up moved this list on its own
|
|
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 4);
|
|
CHECK(bundle.num_physical_filaments() == 4);
|
|
}
|
|
|
|
SECTION("behind a mixed tail") {
|
|
bundle.set_num_filaments(5u, std::string("#FF0000"));
|
|
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
|
|
{ false, false, false, false, true };
|
|
bundle.printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter", true)->values =
|
|
{ 0.4, 0.4, 0.4, 0.4, 0.4, 0.4 };
|
|
bundle.update_multi_material_filament_presets();
|
|
|
|
REQUIRE(bundle.filament_presets.size() == 6);
|
|
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 5);
|
|
CHECK(bundle.num_physical_filaments() == 4);
|
|
CHECK(bundle.num_mixed_filaments() == 1);
|
|
}
|
|
}
|
|
|
|
// Which slots are new is a fact about the per-filament arrays, not about filament_presets, for the
|
|
// same reason. Keyed off the wrong one, a freshly opened slot silently keeps filament 1's colour.
|
|
TEST_CASE("New filament colours are placed by array position", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
PresetBundle bundle;
|
|
bundle.set_num_filaments(4u, std::string("#FF0000"));
|
|
bundle.printers.get_edited_preset().config.option<ConfigOptionFloats>("nozzle_diameter", true)->values =
|
|
{ 0.4, 0.4, 0.4, 0.4, 0.4 };
|
|
bundle.update_multi_material_filament_presets();
|
|
REQUIRE(bundle.filament_presets.size() == 5);
|
|
REQUIRE(bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values.size() == 4);
|
|
|
|
// The call Sidebar::add_custom_filament makes once the extruder count opens a slot.
|
|
bundle.set_num_filaments(5u, std::string("#00FF00"));
|
|
|
|
const auto &colours = bundle.project_config.option<ConfigOptionStrings>("filament_colour")->values;
|
|
REQUIRE(colours.size() == 5);
|
|
CHECK(colours[4] == "#00FF00"); // not colours[0], which resize() would have padded with
|
|
}
|
|
|
|
// The mixed-slot flags are written into the app config on exit and read back on the next start.
|
|
// If the read side loses them the slots survive as filaments but stop being mixes, so the project
|
|
// comes back with the mix showing as an ordinary physical filament.
|
|
TEST_CASE("A saved mix is still a mix after an app restart", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
AppConfig app_config;
|
|
|
|
// Last session: a 4-tool project carrying one mix of filaments 2 and 3 at the tail.
|
|
{
|
|
PresetBundle bundle;
|
|
add_inmemory_preset(bundle.printers, "Test Printer");
|
|
bundle.printers.select_preset_by_name("Test Printer", true);
|
|
add_inmemory_preset(bundle.filaments, "Test Filament");
|
|
bundle.filaments.select_preset_by_name("Test Filament", true);
|
|
bundle.set_num_filaments(5u, std::string("#FF0000"));
|
|
bundle.filament_presets.assign(5, "Test Filament");
|
|
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
|
|
{ false, false, false, false, true };
|
|
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
|
|
{ "", "", "", "", "2,3" };
|
|
bundle.export_selections(app_config);
|
|
|
|
REQUIRE(app_config.get_printer_setting("Test Printer", "filament_is_mixed") == "0,0,0,0,1");
|
|
}
|
|
|
|
// This session.
|
|
PresetBundle bundle;
|
|
add_inmemory_preset(bundle.printers, "Test Printer");
|
|
add_inmemory_preset(bundle.filaments, "Test Filament");
|
|
bundle.load_selections(app_config);
|
|
|
|
CHECK(bundle.filament_presets.size() == 5);
|
|
CHECK(bundle.num_mixed_filaments() == 1);
|
|
CHECK(bundle.is_mixed_filament(4));
|
|
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[4] == "2,3");
|
|
}
|
|
|
|
// The same restart, on the printer shape that actually shows the bug: a 4-tool changer whose
|
|
// saved filament list is one longer than its nozzle count, because the extra slot is the mix.
|
|
TEST_CASE("A saved mix survives a restart on a multi-tool printer", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
auto make_toolchanger = [](PresetBundle &bundle) -> Preset & {
|
|
Preset &p = add_inmemory_preset(bundle.printers, "Tool Changer");
|
|
p.config.option<ConfigOptionFloats>("nozzle_diameter", true)->values = { 0.4, 0.4, 0.4, 0.4 };
|
|
p.config.option<ConfigOptionBool>("single_extruder_multi_material", true)->value = false;
|
|
return p;
|
|
};
|
|
|
|
AppConfig app_config;
|
|
{
|
|
PresetBundle bundle;
|
|
make_toolchanger(bundle);
|
|
bundle.printers.select_preset_by_name("Tool Changer", true);
|
|
add_inmemory_preset(bundle.filaments, "Test Filament");
|
|
bundle.filaments.select_preset_by_name("Test Filament", true);
|
|
bundle.set_num_filaments(5u, std::string("#FF0000"));
|
|
bundle.filament_presets.assign(5, "Test Filament");
|
|
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
|
|
{ false, false, false, false, true };
|
|
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
|
|
{ "", "", "", "", "1,2" };
|
|
bundle.export_selections(app_config);
|
|
REQUIRE(app_config.get_printer_setting("Tool Changer", "filament_is_mixed") == "0,0,0,0,1");
|
|
}
|
|
|
|
PresetBundle bundle;
|
|
make_toolchanger(bundle);
|
|
add_inmemory_preset(bundle.filaments, "Test Filament");
|
|
bundle.load_selections(app_config);
|
|
|
|
CHECK(bundle.filament_presets.size() == 5);
|
|
CHECK(bundle.num_mixed_filaments() == 1);
|
|
CHECK(bundle.is_mixed_filament(4));
|
|
|
|
SECTION("and through the GUI startup calls that follow it") {
|
|
// GUI_App::load_current_presets sizes the list for a non-SEMM printer, growing only.
|
|
const size_t target = 4u + bundle.num_mixed_filaments();
|
|
if (target > bundle.filament_presets.size())
|
|
bundle.set_num_filaments(target);
|
|
CHECK(bundle.num_mixed_filaments() == 1);
|
|
|
|
// TabPrinter::extruders_count_changed.
|
|
bundle.on_extruders_count_changed(4);
|
|
CHECK(bundle.num_mixed_filaments() == 1);
|
|
|
|
// Tab::select_preset re-reads the snapshot when remember_printer_config is on.
|
|
bundle.update_selections(app_config);
|
|
CHECK(bundle.filament_presets.size() == 5);
|
|
CHECK(bundle.num_mixed_filaments() == 1);
|
|
CHECK(bundle.is_mixed_filament(4));
|
|
}
|
|
}
|
|
|
|
// The startup sizing in GUI_App::load_current_presets targets the nozzle count plus the mixes.
|
|
// That is a floor, never a ceiling: set_num_filaments() trims at the raw tail, which is exactly
|
|
// where the mixes live, so applying the target to a longer list deletes them. A list longer than
|
|
// the target is reachable - raising the extruder count without saving the printer preset leaves
|
|
// the extra physical slot behind on the next start - so the startup sizing must only ever grow.
|
|
TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tail", "[Preset][Bundle][FilamentMixer]")
|
|
{
|
|
// 5 physical + 1 mix, on a printer preset still reporting 4 nozzles.
|
|
const size_t nozzle_count = 4;
|
|
PresetBundle bundle;
|
|
bundle.set_num_filaments(6u, std::string("#FF0000"));
|
|
bundle.project_config.option<ConfigOptionBools>("filament_is_mixed")->values =
|
|
{ false, false, false, false, false, true };
|
|
bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values =
|
|
{ "", "", "", "", "", "1,2" };
|
|
REQUIRE(bundle.num_physical_filaments() == 5);
|
|
|
|
const size_t target = nozzle_count + bundle.num_mixed_filaments();
|
|
REQUIRE(target < bundle.filament_presets.size());
|
|
|
|
SECTION("applied as written, the mix is gone and every slot reads physical") {
|
|
bundle.set_num_filaments(target);
|
|
|
|
CHECK(bundle.filament_presets.size() == target);
|
|
CHECK(bundle.num_mixed_filaments() == 0);
|
|
CHECK(bundle.num_physical_filaments() == target);
|
|
}
|
|
|
|
SECTION("applied as a floor, the mix is left alone") {
|
|
if (target > bundle.filament_presets.size())
|
|
bundle.set_num_filaments(target);
|
|
|
|
CHECK(bundle.filament_presets.size() == 6);
|
|
CHECK(bundle.num_mixed_filaments() == 1);
|
|
CHECK(bundle.is_mixed_filament(5));
|
|
CHECK(bundle.project_config.option<ConfigOptionStrings>("filament_mixed_components")->values[5] == "1,2");
|
|
}
|
|
}
|
|
|
|
namespace {
|
|
|
|
// data_dir() is a process-wide global that import_presets extracts into; scope it to the test.
|
|
struct ScopedDataDir
|
|
{
|
|
std::string previous = data_dir();
|
|
explicit ScopedDataDir(const fs::path &dir) { set_data_dir(dir.string()); }
|
|
~ScopedDataDir() { set_data_dir(previous); }
|
|
};
|
|
|
|
std::string read_file(const fs::path &file)
|
|
{
|
|
std::ifstream in(file.string(), std::ios::binary);
|
|
return std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
|
|
}
|
|
|
|
void write_zip(const fs::path &zip_file, const std::vector<std::pair<std::string, std::string>> &entries)
|
|
{
|
|
mz_zip_archive zip;
|
|
mz_zip_zero_struct(&zip);
|
|
REQUIRE(open_zip_writer(&zip, zip_file.string()));
|
|
for (const auto &[name, content] : entries)
|
|
REQUIRE(mz_zip_writer_add_mem(&zip, name.c_str(), content.data(), content.size(), MZ_DEFAULT_COMPRESSION));
|
|
REQUIRE(mz_zip_writer_finalize_archive(&zip));
|
|
REQUIRE(close_zip_writer(&zip));
|
|
}
|
|
|
|
bool any_filename_contains(const fs::path &root, const std::string &needle)
|
|
{
|
|
for (fs::recursive_directory_iterator it(root), end; it != end; ++it)
|
|
if (it->path().filename().string().find(needle) != std::string::npos)
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("Config import confines zip entries, preset names and bundle ids to the preset directory", "[Preset][Bundle][Regression]")
|
|
{
|
|
ScopedTemporaryDir temp_dir;
|
|
const fs::path data_root = temp_dir.path() / "datadir";
|
|
const fs::path src_dir = temp_dir.path() / "src";
|
|
fs::create_directories(src_dir);
|
|
ScopedDataDir scoped_data_dir(data_root);
|
|
|
|
PresetBundle bundle;
|
|
AppConfig app_config;
|
|
const auto confirm = [](std::string const &) { return 1; };
|
|
const auto import = [&](const fs::path &file) {
|
|
std::vector<std::string> files{file.string()};
|
|
bundle.import_presets(files, confirm, ForwardCompatibilitySubstitutionRule::Disable, app_config);
|
|
return files;
|
|
};
|
|
|
|
const fs::path good_file = src_dir / "Good.json";
|
|
write_print_preset(bundle.prints.default_preset().config, good_file, "Good");
|
|
const std::string good_json = read_file(good_file);
|
|
|
|
// Four levels up from where import_presets writes (<datadir>/user/default/temp) is temp_dir
|
|
// itself, so anything that escapes lands where the scan below can see it.
|
|
const std::string up = "../../../../";
|
|
const std::string up_win = "..\\..\\..\\..\\";
|
|
|
|
SECTION("zip entry names with either separator are reduced to a basename") {
|
|
const fs::path zip = src_dir / "bundle.zip";
|
|
write_zip(zip, {{up + "zip-escape.json", "{}"}, {up_win + "zip-escape.json", "{}"}, {"presets/Good.json", good_json}});
|
|
import(zip);
|
|
CHECK(bundle.prints.find_preset("Good") != nullptr);
|
|
CHECK_FALSE(any_filename_contains(temp_dir.path(), "zip-escape"));
|
|
}
|
|
|
|
SECTION("a preset name that walks out of the preset directory is rejected") {
|
|
for (const std::string &name : {up + "name-escape", up_win + "name-escape"}) {
|
|
const fs::path file = src_dir / "escape.json";
|
|
write_print_preset(bundle.prints.default_preset().config, file, name);
|
|
CHECK(import(file).empty());
|
|
CHECK_FALSE(any_filename_contains(temp_dir.path(), "name-escape"));
|
|
}
|
|
}
|
|
|
|
SECTION("a bundle id that walks out of the bundle directory is rejected") {
|
|
const fs::path zip = src_dir / "bundle.zip";
|
|
write_zip(zip, {{BUNDLE_STRUCTURE_JSON_NAME, "{\"id\": \"" + up + "bundle-escape\"}"}, {"Good.json", good_json}});
|
|
CHECK(import(zip).empty());
|
|
CHECK_FALSE(any_filename_contains(temp_dir.path(), "bundle-escape"));
|
|
}
|
|
}
|