mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-04 14:20:58 +00:00
Merge branch 'main' into feature/texture_displacement
This commit is contained in:
@@ -768,3 +768,128 @@ TEST_CASE("Each filament prints with its variant of the extruder's variant strin
|
||||
CHECK(gcode.find("; nozzle_temperature = " + resolved + "\n") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
// An adaptive pressure advance model predicting the same pressure advance at every flow and acceleration.
|
||||
static std::string constant_pressure_advance_model(const std::string &pa)
|
||||
{
|
||||
return pa + ",1,1000\n" + pa + ",500,1000\n" + pa + ",1,100000\n" + pa + ",500,100000";
|
||||
}
|
||||
|
||||
// The pressure advance values a Klipper G-code sets.
|
||||
static std::set<std::string> pressure_advance_values(const std::string &gcode)
|
||||
{
|
||||
const std::string token = "SET_PRESSURE_ADVANCE ADVANCE=";
|
||||
std::set<std::string> values;
|
||||
std::istringstream stream(gcode);
|
||||
for (std::string line; std::getline(stream, line);)
|
||||
if (line.rfind(token, 0) == 0)
|
||||
values.insert(line.substr(token.size(), line.find(';') - token.size()));
|
||||
return values;
|
||||
}
|
||||
|
||||
// Pressure advance, and the adaptive pressure advance model, are tuned per extruder variant like the
|
||||
// other filament variant settings.
|
||||
TEST_CASE("Each filament sets the pressure advance of its extruder variant", "[MultiFilament]")
|
||||
{
|
||||
auto [nozzle_volume_type, filament, pressure_advance, adaptive_pressure_advance] = GENERATE(table<NozzleVolumeType, int, std::string, std::string>({
|
||||
{ nvtStandard, 1, "0.021", "0.012" },
|
||||
{ nvtHighFlow, 1, "0.037", "0.034" },
|
||||
{ nvtHighFlow, 2, "0.043", "0.056" }, // filament 2 defines no High Flow variant
|
||||
}));
|
||||
const bool adaptive = GENERATE(false, true);
|
||||
DYNAMIC_SECTION(get_nozzle_volume_type_string(nozzle_volume_type) << " nozzle, filament " << filament << (adaptive ? ", adaptive" : "")) {
|
||||
DynamicPrintConfig config = multifilament_config(2, {
|
||||
{ "gcode_flavor", "klipper" },
|
||||
{ "extruder_variant_list", "Direct Drive Standard,Direct Drive High Flow" },
|
||||
// filament 1 defines Standard (0.021) and High Flow (0.037), filament 2 Standard (0.043)
|
||||
{ "filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow;Direct Drive Standard" },
|
||||
{ "filament_self_index", "1,1,2" },
|
||||
{ "enable_pressure_advance", "1,1,1" },
|
||||
{ "pressure_advance", "0.021,0.037,0.043" },
|
||||
{ "adaptive_pressure_advance", adaptive ? "1,1,1" : "0,0,0" },
|
||||
{ "sparse_infill_filament_id", filament },
|
||||
{ "internal_solid_filament_id", filament },
|
||||
{ "top_surface_filament_id", filament },
|
||||
{ "bottom_surface_filament_id", filament },
|
||||
{ "outer_wall_filament_id", filament },
|
||||
{ "inner_wall_filament_id", filament },
|
||||
{ "enable_prime_tower", 0 },
|
||||
{ "skirt_loops", 0 },
|
||||
{ "brim_type", "no_brim" },
|
||||
// custom G-code indexes the per-filament arrays by filament
|
||||
{ "machine_start_gcode", "; start pressure advance {pressure_advance[initial_extruder]}" },
|
||||
});
|
||||
config.option<ConfigOptionStrings>("adaptive_pressure_advance_model")->values = {
|
||||
constant_pressure_advance_model("0.012"), constant_pressure_advance_model("0.034"), constant_pressure_advance_model("0.056") };
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = { nozzle_volume_type };
|
||||
const std::string gcode = slice({ cube(20) }, config);
|
||||
|
||||
// The toolchange sets the variant's pressure advance; with adaptive pressure advance, the
|
||||
// prediction of the variant's model then replaces it.
|
||||
std::set<std::string> expected{ pressure_advance };
|
||||
if (adaptive)
|
||||
expected.insert(adaptive_pressure_advance);
|
||||
CHECK(pressure_advance_values(gcode) == expected);
|
||||
CHECK(gcode.find("; start pressure advance " + pressure_advance + "\n") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
// On a printer with two extruders, a filament takes the pressure advance of the variant of the extruder
|
||||
// it is mapped to, whichever filament and extruder that is.
|
||||
TEST_CASE("Each filament sets the pressure advance of its extruder variant on a two-extruder printer", "[MultiFilament]")
|
||||
{
|
||||
auto [filament, extruder, pressure_advance, adaptive_pressure_advance] = GENERATE(table<int, int, std::string, std::string>({
|
||||
{ 1, 1, "0.021", "0.012" },
|
||||
{ 1, 2, "0.037", "0.034" },
|
||||
{ 2, 1, "0.043", "0.056" },
|
||||
{ 2, 2, "0.049", "0.078" },
|
||||
}));
|
||||
const bool adaptive = GENERATE(false, true);
|
||||
DYNAMIC_SECTION("filament " << filament << " on extruder " << extruder << (adaptive ? ", adaptive" : "")) {
|
||||
// the other filament goes on the other extruder
|
||||
const std::string filament_map = filament == 1 ? std::to_string(extruder) + "," + std::to_string(3 - extruder) :
|
||||
std::to_string(3 - extruder) + "," + std::to_string(extruder);
|
||||
DynamicPrintConfig config = multifilament_config(2, {
|
||||
{ "gcode_flavor", "klipper" },
|
||||
{ "single_extruder_multi_material", 0 },
|
||||
{ "nozzle_diameter", "0.4,0.4" },
|
||||
{ "extruder_printable_height", "0,0" },
|
||||
// extruder 1 has a Standard nozzle, extruder 2 a High Flow one
|
||||
{ "printer_extruder_id", "1,2" },
|
||||
{ "printer_extruder_variant", "Direct Drive Standard;Direct Drive High Flow" },
|
||||
{ "extruder_variant_list", "Direct Drive Standard;Direct Drive High Flow" },
|
||||
{ "filament_map", filament_map },
|
||||
// both filaments define Standard and High Flow
|
||||
{ "filament_extruder_variant", "Direct Drive Standard;Direct Drive High Flow;Direct Drive Standard;Direct Drive High Flow" },
|
||||
{ "filament_self_index", "1,1,2,2" },
|
||||
{ "enable_pressure_advance", "1,1,1,1" },
|
||||
{ "pressure_advance", "0.021,0.037,0.043,0.049" },
|
||||
{ "adaptive_pressure_advance", adaptive ? "1,1,1,1" : "0,0,0,0" },
|
||||
{ "sparse_infill_filament_id", filament },
|
||||
{ "internal_solid_filament_id", filament },
|
||||
{ "top_surface_filament_id", filament },
|
||||
{ "bottom_surface_filament_id", filament },
|
||||
{ "outer_wall_filament_id", filament },
|
||||
{ "inner_wall_filament_id", filament },
|
||||
{ "enable_prime_tower", 0 },
|
||||
{ "skirt_loops", 0 },
|
||||
{ "brim_type", "no_brim" },
|
||||
// custom G-code indexes the per-filament arrays by filament
|
||||
{ "machine_start_gcode", "; start pressure advance {pressure_advance[initial_extruder]}" },
|
||||
});
|
||||
config.option<ConfigOptionStrings>("adaptive_pressure_advance_model")->values = {
|
||||
constant_pressure_advance_model("0.012"), constant_pressure_advance_model("0.034"),
|
||||
constant_pressure_advance_model("0.056"), constant_pressure_advance_model("0.078") };
|
||||
config.option<ConfigOptionEnumsGeneric>("extruder_type", true)->values = { etDirectDrive, etDirectDrive };
|
||||
config.option<ConfigOptionEnumsGeneric>("nozzle_volume_type", true)->values = { nvtStandard, nvtHighFlow };
|
||||
// keep the mapping above rather than grouping the filaments automatically
|
||||
config.option<ConfigOptionEnum<FilamentMapMode>>("filament_map_mode", true)->value = fmmManual;
|
||||
const std::string gcode = slice({ cube(20) }, config);
|
||||
|
||||
std::set<std::string> expected{ pressure_advance };
|
||||
if (adaptive)
|
||||
expected.insert(adaptive_pressure_advance);
|
||||
CHECK(pressure_advance_values(gcode) == expected);
|
||||
CHECK(gcode.find("; start pressure advance " + pressure_advance + "\n") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,6 +489,25 @@ TEST_CASE("gcode_skip_config_block omits the resolved-settings comment block", "
|
||||
CHECK(gcode.find("; EXECUTABLE_BLOCK_START") != std::string::npos);
|
||||
}
|
||||
|
||||
// Some firmwares only scan the last N lines of the file for "estimated printing time", so it
|
||||
// must stay close to EOF regardless of the resolved-settings config block's size.
|
||||
TEST_CASE("The estimated printing time comment stays near the end of the file", "[Print]")
|
||||
{
|
||||
const std::string gcode = slice({ cube(20) }, {});
|
||||
const size_t config_block_end = gcode.find("; CONFIG_BLOCK_END");
|
||||
const size_t filament_stats = gcode.find("; filament used [mm]");
|
||||
const size_t time_comment = gcode.find("estimated printing time");
|
||||
REQUIRE(config_block_end != std::string::npos);
|
||||
REQUIRE(filament_stats != std::string::npos);
|
||||
REQUIRE(time_comment != std::string::npos);
|
||||
CHECK(filament_stats > config_block_end);
|
||||
CHECK(time_comment > filament_stats);
|
||||
|
||||
const size_t line_start = gcode.rfind('\n', time_comment) + 1;
|
||||
const size_t trailing_lines = std::count(gcode.begin() + line_start, gcode.end(), '\n');
|
||||
CHECK(trailing_lines <= 5);
|
||||
}
|
||||
|
||||
// Custom G-code templates substitute placeholders during export.
|
||||
TEST_CASE("Custom G-code placeholders are substituted", "[Print]")
|
||||
{
|
||||
|
||||
@@ -16,8 +16,10 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_clipper_utils.cpp
|
||||
test_config.cpp
|
||||
test_config_variant_expansion.cpp
|
||||
test_locales_utils.cpp
|
||||
test_drc.cpp
|
||||
test_toolordering_nozzle_group.cpp
|
||||
test_parallel_resolve.cpp
|
||||
test_preset_bundle_loading.cpp
|
||||
test_preset_setting_id.cpp
|
||||
test_preset_diff.cpp
|
||||
@@ -44,6 +46,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_lay_on_face.cpp
|
||||
test_model.cpp
|
||||
test_utils.cpp
|
||||
test_miniz_extension.cpp
|
||||
test_timeutils.cpp
|
||||
test_voronoi.cpp
|
||||
test_wipe_tower_estimate.cpp
|
||||
|
||||
@@ -717,3 +717,15 @@ TEST_CASE("get_index_for_extruder scales the variant column by the requested str
|
||||
REQUIRE(col0_stride2 == 0);
|
||||
REQUIRE(col1_stride2 == 2);
|
||||
}
|
||||
|
||||
// A per-variant filament option read with a single value gives it to every filament variant. A project
|
||||
// exported by an older CLI holds a single value for an option no loaded preset defined, such as
|
||||
// filament_ironing_flow.
|
||||
TEST_CASE("A per-variant filament option read with a single value gives it to every filament variant", "[Config]")
|
||||
{
|
||||
// filament 1 defines Standard and High Flow, filament 2 Standard
|
||||
DynamicPrintConfig config;
|
||||
config.option<ConfigOptionInts>("filament_self_index", true)->values = {1, 1, 2};
|
||||
config.load_from_ini_string("pressure_advance = 0.021", ForwardCompatibilitySubstitutionRule::Disable);
|
||||
REQUIRE(config.option<ConfigOptionFloats>("pressure_advance")->values == std::vector<double>({0.021, 0.021, 0.021}));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <clocale>
|
||||
|
||||
#include "libslic3r/LocalesUtils.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
// Switches this thread's numeric locale to one whose decimal separator is a comma,
|
||||
// when the system has one installed.
|
||||
struct CommaNumericLocale
|
||||
{
|
||||
#ifdef _WIN32
|
||||
bool apply()
|
||||
{
|
||||
for (const char* name : { "de-DE", "German_Germany.1252" })
|
||||
if (std::setlocale(LC_NUMERIC, name) != nullptr)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
#else
|
||||
locale_t locale { (locale_t) 0 };
|
||||
bool apply()
|
||||
{
|
||||
for (const char* name : { "de_DE.UTF-8", "de_DE.utf8", "de_DE" })
|
||||
if ((locale = newlocale(LC_NUMERIC_MASK, name, (locale_t) 0)) != (locale_t) 0) {
|
||||
uselocale(locale);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Freed once the setters around apply() have put the thread's own locale back.
|
||||
~CommaNumericLocale()
|
||||
{
|
||||
if (locale != (locale_t) 0)
|
||||
freelocale(locale);
|
||||
}
|
||||
#endif
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("a setter nested in another leaves the C locale in place for the outer one", "[LocalesUtils]")
|
||||
{
|
||||
CNumericLocalesSetter outer;
|
||||
{
|
||||
CNumericLocalesSetter inner;
|
||||
CHECK(is_decimal_separator_point());
|
||||
}
|
||||
CHECK(is_decimal_separator_point());
|
||||
}
|
||||
|
||||
TEST_CASE("a setter nested in another sets C again when the locale changed between them", "[LocalesUtils]")
|
||||
{
|
||||
CommaNumericLocale comma;
|
||||
{
|
||||
CNumericLocalesSetter outer;
|
||||
if (! comma.apply())
|
||||
SKIP("no locale with a comma decimal separator is installed");
|
||||
REQUIRE_FALSE(is_decimal_separator_point());
|
||||
{
|
||||
CNumericLocalesSetter inner;
|
||||
CHECK(is_decimal_separator_point());
|
||||
}
|
||||
CHECK_FALSE(is_decimal_separator_point());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/miniz_extension.hpp"
|
||||
|
||||
#include "test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
// miniz refuses to write a name starting with '/', so write a placeholder of the same length and patch it in place.
|
||||
void rename_entry(const fs::path &zip_file, const std::string &from, const std::string &to)
|
||||
{
|
||||
REQUIRE(from.size() == to.size());
|
||||
std::string bytes;
|
||||
{
|
||||
std::ifstream in(zip_file.string(), std::ios::binary);
|
||||
bytes.assign(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
|
||||
}
|
||||
size_t count = 0;
|
||||
for (size_t pos = bytes.find(from); pos != std::string::npos; pos = bytes.find(from, pos + to.size()), ++count)
|
||||
bytes.replace(pos, from.size(), to);
|
||||
// Once in the local header and once in the central directory.
|
||||
REQUIRE(count == 2);
|
||||
std::ofstream out(zip_file.string(), std::ios::binary | std::ios::trunc);
|
||||
out << bytes;
|
||||
}
|
||||
|
||||
std::vector<std::string> list_dir(const fs::path &dir)
|
||||
{
|
||||
std::vector<std::string> names;
|
||||
for (const fs::directory_entry &entry : fs::directory_iterator(dir))
|
||||
names.push_back(entry.path().filename().string());
|
||||
std::sort(names.begin(), names.end());
|
||||
return names;
|
||||
}
|
||||
|
||||
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>());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Confined extraction writes a well-formed archive under the target directory", "[MinizExtension]")
|
||||
{
|
||||
ScopedTemporaryDir tmp;
|
||||
const fs::path zip_file = tmp.path() / "bundle.zip";
|
||||
const fs::path target = tmp.path() / "cache";
|
||||
fs::create_directories(target);
|
||||
write_zip(zip_file, {{"vendor/", ""}, {"vendor/machine/", ""}, {"vendor.json", "{\"a\":1}"}, {"vendor/machine/printer.json", "{\"b\":2}"}});
|
||||
|
||||
REQUIRE(extract_archive_confined(zip_file.string(), target.string()));
|
||||
CHECK(fs::is_directory(target / "vendor"));
|
||||
CHECK(read_file(target / "vendor.json") == "{\"a\":1}");
|
||||
CHECK(read_file(target / "vendor" / "machine" / "printer.json") == "{\"b\":2}");
|
||||
}
|
||||
|
||||
TEST_CASE("Confined extraction rejects an archive with an entry outside the target directory", "[MinizExtension]")
|
||||
{
|
||||
ScopedTemporaryDir tmp;
|
||||
const fs::path zip_file = tmp.path() / "bundle.zip";
|
||||
const fs::path target = tmp.path() / "cache";
|
||||
fs::create_directories(target);
|
||||
|
||||
const std::string escaping_entry = GENERATE(std::string("../escape.txt"), std::string("..\\escape.txt"),
|
||||
std::string("sub/../../escape.txt"), std::string("C:/escape.txt"),
|
||||
std::string("C:escape.txt"), std::string("\\escape.txt"));
|
||||
// The normal entry comes first so a per-entry check would already have written it.
|
||||
write_zip(zip_file, {{"normal.json", "{}"}, {escaping_entry, "escaped"}});
|
||||
|
||||
CAPTURE(escaping_entry);
|
||||
CHECK_FALSE(extract_archive_confined(zip_file.string(), target.string()));
|
||||
CHECK_FALSE(fs::exists(tmp.path() / "escape.txt"));
|
||||
CHECK(fs::is_empty(target));
|
||||
}
|
||||
|
||||
TEST_CASE("Confined extraction rejects an archive with an absolute entry name", "[MinizExtension]")
|
||||
{
|
||||
ScopedTemporaryDir tmp;
|
||||
const fs::path zip_file = tmp.path() / "bundle.zip";
|
||||
const fs::path target = tmp.path() / "cache";
|
||||
fs::create_directories(target);
|
||||
|
||||
const std::string absolute = (tmp.path() / "escape.txt").generic_string();
|
||||
const std::string placeholder = "#" + absolute.substr(1);
|
||||
write_zip(zip_file, {{"normal.json", "{}"}, {placeholder, "escaped"}});
|
||||
rename_entry(zip_file, placeholder, absolute);
|
||||
|
||||
CHECK_FALSE(extract_archive_confined(zip_file.string(), target.string()));
|
||||
CHECK_FALSE(fs::exists(tmp.path() / "escape.txt"));
|
||||
CHECK(fs::is_empty(target));
|
||||
}
|
||||
|
||||
TEST_CASE("Confined extraction rejects a directory entry outside the target directory", "[MinizExtension]")
|
||||
{
|
||||
ScopedTemporaryDir tmp;
|
||||
const fs::path zip_file = tmp.path() / "bundle.zip";
|
||||
const fs::path target = tmp.path() / "cache";
|
||||
fs::create_directories(target);
|
||||
write_zip(zip_file, {{"vendor/", ""}, {"../outside/", ""}});
|
||||
|
||||
CHECK_FALSE(extract_archive_confined(zip_file.string(), target.string()));
|
||||
CHECK_FALSE(fs::exists(tmp.path() / "outside"));
|
||||
CHECK(fs::is_empty(target));
|
||||
}
|
||||
|
||||
TEST_CASE("Confined extraction validates zero-size entries like any other", "[MinizExtension]")
|
||||
{
|
||||
ScopedTemporaryDir tmp;
|
||||
const fs::path zip_file = tmp.path() / "bundle.zip";
|
||||
const fs::path target = tmp.path() / "cache";
|
||||
fs::create_directories(target);
|
||||
|
||||
SECTION("an empty file inside the target does not fail the archive") {
|
||||
write_zip(zip_file, {{"empty.json", ""}, {"vendor.json", "{}"}});
|
||||
CHECK(extract_archive_confined(zip_file.string(), target.string()));
|
||||
CHECK(read_file(target / "vendor.json") == "{}");
|
||||
}
|
||||
SECTION("an empty file outside the target rejects the archive") {
|
||||
write_zip(zip_file, {{"vendor.json", "{}"}, {"../escape.txt", ""}});
|
||||
CHECK_FALSE(extract_archive_confined(zip_file.string(), target.string()));
|
||||
CHECK_FALSE(fs::exists(tmp.path() / "escape.txt"));
|
||||
CHECK(fs::is_empty(target));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Confined extraction writes nothing outside the target for Windows-specific name forms", "[MinizExtension]")
|
||||
{
|
||||
ScopedTemporaryDir tmp;
|
||||
const fs::path zip_file = tmp.path() / "bundle.zip";
|
||||
const fs::path target = tmp.path() / "cache";
|
||||
fs::create_directories(target);
|
||||
|
||||
// Windows strips trailing dots and spaces and maps device names; whether these extract depends on the
|
||||
// platform, but none of them may land beside the target.
|
||||
const std::string name = GENERATE(std::string("name."), std::string("name "), std::string("..."), std::string(".. "),
|
||||
std::string(".. /escape.txt"), std::string(".../escape.txt"), std::string("CON"),
|
||||
std::string("sub/NUL.txt"), std::string("C:escape.txt"));
|
||||
write_zip(zip_file, {{name, "payload"}});
|
||||
|
||||
CAPTURE(name);
|
||||
extract_archive_confined(zip_file.string(), target.string());
|
||||
CHECK(list_dir(tmp.path()) == std::vector<std::string>{"bundle.zip", "cache"});
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
TEST_CASE("Confined extraction replaces a symlink at the destination instead of writing through it", "[MinizExtension]")
|
||||
{
|
||||
ScopedTemporaryDir tmp;
|
||||
const fs::path zip_file = tmp.path() / "bundle.zip";
|
||||
const fs::path target = tmp.path() / "cache";
|
||||
const fs::path outside = tmp.path() / "outside";
|
||||
fs::create_directories(target);
|
||||
fs::create_directories(outside);
|
||||
write_zip(zip_file, {{"vendor.json", "{\"a\":1}"}});
|
||||
|
||||
SECTION("a dangling symlink") {
|
||||
fs::create_symlink(outside / "vendor.json", target / "vendor.json");
|
||||
CHECK(extract_archive_confined(zip_file.string(), target.string()));
|
||||
CHECK_FALSE(fs::exists(outside / "vendor.json"));
|
||||
CHECK_FALSE(fs::is_symlink(fs::symlink_status(target / "vendor.json")));
|
||||
CHECK(read_file(target / "vendor.json") == "{\"a\":1}");
|
||||
}
|
||||
SECTION("a symlink to an existing file") {
|
||||
{ std::ofstream((outside / "vendor.json").string()) << "original"; }
|
||||
fs::create_symlink(outside / "vendor.json", target / "vendor.json");
|
||||
extract_archive_confined(zip_file.string(), target.string());
|
||||
CHECK(read_file(outside / "vendor.json") == "original");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -19,9 +19,8 @@ struct LoadedObj
|
||||
ObjInfo info;
|
||||
};
|
||||
|
||||
// A tetrahedron with a material and two texture coordinates, (0.25, 0.5) and (0.75, 1).
|
||||
// Only the first face and the vt lines are varied; the other three faces reference vt 1.
|
||||
LoadedObj load_textured_tetrahedron(const std::string &first_face, const std::string &vts = "vt 0.25 0.5\nvt 0.75 1\n")
|
||||
// Loads an OBJ made of the given lines, with a material library that defines material "a".
|
||||
LoadedObj load_textured_obj(const std::string &body)
|
||||
{
|
||||
ScopedTemporaryFile obj(".obj");
|
||||
ScopedTemporaryFile mtl(".mtl");
|
||||
@@ -31,12 +30,7 @@ LoadedObj load_textured_tetrahedron(const std::string &first_face, const std::st
|
||||
}
|
||||
{
|
||||
boost::nowide::ofstream out(obj.string());
|
||||
out << "mtllib " << mtl.path().filename().string() << "\n"
|
||||
<< "v 0 0 0\nv 10 0 0\nv 0 10 0\nv 0 0 10\n"
|
||||
<< vts
|
||||
<< "usemtl a\n"
|
||||
<< first_face << "\n"
|
||||
<< "f 1/1 2/1 4/1\nf 1/1 4/1 3/1\nf 2/1 3/1 4/1\n";
|
||||
out << "mtllib " << mtl.path().filename().string() << "\n" << body;
|
||||
}
|
||||
LoadedObj loaded;
|
||||
std::string message;
|
||||
@@ -44,6 +38,21 @@ LoadedObj load_textured_tetrahedron(const std::string &first_face, const std::st
|
||||
return loaded;
|
||||
}
|
||||
|
||||
// A tetrahedron with a material and two texture coordinates, (0.25, 0.5) and (0.75, 1).
|
||||
// Only the first face and the vt lines are varied; the other three faces reference vt 1.
|
||||
LoadedObj load_textured_tetrahedron(const std::string &first_face, const std::string &vts = "vt 0.25 0.5\nvt 0.75 1\n")
|
||||
{
|
||||
return load_textured_obj("v 0 0 0\nv 10 0 0\nv 0 10 0\nv 0 0 10\n" + vts + "usemtl a\n" + first_face + "\n" +
|
||||
"f 1/1 2/1 4/1\nf 1/1 4/1 3/1\nf 2/1 3/1 4/1\n");
|
||||
}
|
||||
|
||||
// Texture coordinate n is (n / 10, n / 20), so a UV identifies the vt it came from.
|
||||
void check_uv_is_vt(const Vec2f &uv, int vt)
|
||||
{
|
||||
CHECK_THAT(uv.x(), WithinAbs(vt / 10., 1e-6));
|
||||
CHECK_THAT(uv.y(), WithinAbs(vt / 20., 1e-6));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("An out-of-range texture index falls back to a zero UV and keeps the geometry", "[OBJ][Regression]")
|
||||
@@ -120,3 +129,39 @@ TEST_CASE("A texture coordinate with w does not shift the indices of the ones af
|
||||
CHECK_THAT(uv[2].x(), WithinAbs(0.75, 1e-6));
|
||||
CHECK_THAT(uv[2].y(), WithinAbs(1., 1e-6));
|
||||
}
|
||||
|
||||
TEST_CASE("Both triangles of a quad take the texture coordinates of their own corners", "[OBJ][Regression]")
|
||||
{
|
||||
const LoadedObj loaded = load_textured_obj("v 0 0 0\nv 10 0 0\nv 10 10 0\nv 0 10 0\n"
|
||||
"vt 0.1 0.05\nvt 0.2 0.1\nvt 0.3 0.15\nvt 0.4 0.2\n"
|
||||
"usemtl a\n"
|
||||
"f 1/1 2/2 3/3 4/4\n");
|
||||
|
||||
REQUIRE(loaded.ok);
|
||||
REQUIRE(loaded.mesh.facets_count() == 2);
|
||||
REQUIRE(loaded.info.uvs.size() == 2);
|
||||
check_uv_is_vt(loaded.info.uvs[0][0], 1);
|
||||
check_uv_is_vt(loaded.info.uvs[0][1], 2);
|
||||
check_uv_is_vt(loaded.info.uvs[0][2], 3);
|
||||
check_uv_is_vt(loaded.info.uvs[1][0], 1);
|
||||
check_uv_is_vt(loaded.info.uvs[1][1], 3);
|
||||
check_uv_is_vt(loaded.info.uvs[1][2], 4);
|
||||
}
|
||||
|
||||
TEST_CASE("Texture coordinates follow the corners of a mesh that is flipped on load", "[OBJ][Regression]")
|
||||
{
|
||||
// The faces wind inwards, so the loader flips them. Vertex n uses vt n.
|
||||
const LoadedObj loaded = load_textured_obj("v 0 0 0\nv 10 0 0\nv 0 10 0\nv 0 0 10\n"
|
||||
"vt 0.1 0.05\nvt 0.2 0.1\nvt 0.3 0.15\nvt 0.4 0.2\n"
|
||||
"usemtl a\n"
|
||||
"f 1/1 2/2 3/3\nf 1/1 4/4 2/2\nf 1/1 3/3 4/4\nf 2/2 4/4 3/3\n");
|
||||
|
||||
REQUIRE(loaded.ok);
|
||||
const indexed_triangle_set &its = loaded.mesh.its;
|
||||
CHECK(its_volume(its) > 0.f);
|
||||
REQUIRE(its.indices.size() == 4);
|
||||
REQUIRE(loaded.info.uvs.size() == 4);
|
||||
for (size_t face = 0; face < its.indices.size(); ++face)
|
||||
for (int corner = 0; corner < 3; ++corner)
|
||||
check_uv_is_vt(loaded.info.uvs[face][corner], its.indices[face][corner] + 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <numeric>
|
||||
#include <stdexcept>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include <tbb/blocked_range.h>
|
||||
#include <tbb/parallel_for.h>
|
||||
#include <tbb/task_group.h>
|
||||
|
||||
#include "libslic3r/ParallelResolve.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
thread_local int t_live_setups = 0;
|
||||
|
||||
// Counts how many are alive on the constructing thread.
|
||||
struct CountingSetup
|
||||
{
|
||||
CountingSetup() { ++ t_live_setups; }
|
||||
~CountingSetup() { -- t_live_setups; }
|
||||
};
|
||||
|
||||
std::vector<size_t> first_indices(size_t count)
|
||||
{
|
||||
std::vector<size_t> indices(count);
|
||||
std::iota(indices.begin(), indices.end(), size_t(0));
|
||||
return indices;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("every item resolves once and commits in index order on the calling thread", "[ParallelResolve]")
|
||||
{
|
||||
const size_t count = 200;
|
||||
std::vector<int> resolves(count, 0);
|
||||
std::vector<size_t> committed, values;
|
||||
std::vector<bool> on_caller;
|
||||
const std::thread::id caller = std::this_thread::get_id();
|
||||
resolve_then_commit(count,
|
||||
[&](size_t i) { ++ resolves[i]; return i * 3; },
|
||||
[&](size_t i, size_t resolved) {
|
||||
committed.push_back(i);
|
||||
values.push_back(resolved);
|
||||
on_caller.push_back(std::this_thread::get_id() == caller);
|
||||
});
|
||||
|
||||
CHECK(committed == first_indices(count));
|
||||
for (size_t i = 0; i < count; ++ i) {
|
||||
CHECK(resolves[i] == 1);
|
||||
CHECK(values[i] == i * 3);
|
||||
CHECK(on_caller[i]);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("every item resolves inside one chunk setup", "[ParallelResolve]")
|
||||
{
|
||||
const size_t count = 200;
|
||||
std::vector<int> live(count, 0);
|
||||
resolve_then_commit<CountingSetup>(count,
|
||||
[&](size_t i) { live[i] = t_live_setups; return 0; },
|
||||
[](size_t, int) {});
|
||||
|
||||
for (size_t i = 0; i < count; ++ i)
|
||||
CHECK(live[i] == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("an exception from resolve leaves the batches before it committed", "[ParallelResolve]")
|
||||
{
|
||||
const size_t count = 200, fails_at = 150;
|
||||
REQUIRE(fails_at >= resolve_batch_size);
|
||||
std::vector<size_t> committed;
|
||||
CHECK_THROWS_AS(resolve_then_commit(count,
|
||||
[&](size_t i) {
|
||||
if (i == fails_at)
|
||||
throw std::runtime_error("resolve failed");
|
||||
return i;
|
||||
},
|
||||
[&](size_t i, size_t) { committed.push_back(i); }),
|
||||
std::runtime_error);
|
||||
|
||||
CHECK(committed == first_indices(fails_at / resolve_batch_size * resolve_batch_size));
|
||||
}
|
||||
|
||||
TEST_CASE("an exception from commit stops at the item that threw", "[ParallelResolve]")
|
||||
{
|
||||
const size_t count = 200, fails_at = 90;
|
||||
std::vector<size_t> committed;
|
||||
CHECK_THROWS_AS(resolve_then_commit(count,
|
||||
[](size_t i) { return i; },
|
||||
[&](size_t i, size_t) {
|
||||
if (i == fails_at)
|
||||
throw std::runtime_error("commit failed");
|
||||
committed.push_back(i);
|
||||
}),
|
||||
std::runtime_error);
|
||||
|
||||
CHECK(committed == first_indices(fails_at));
|
||||
}
|
||||
|
||||
TEST_CASE("a canceled task group stops before committing an item it did not resolve", "[ParallelResolve]")
|
||||
{
|
||||
std::vector<size_t> committed;
|
||||
bool threw = false;
|
||||
tbb::task_group_context context;
|
||||
tbb::parallel_for(tbb::blocked_range<size_t>(0, 1), [&](const tbb::blocked_range<size_t>&) {
|
||||
context.cancel_group_execution();
|
||||
try {
|
||||
resolve_then_commit(200,
|
||||
[](size_t i) { return i + 1; },
|
||||
[&](size_t, size_t resolved) { committed.push_back(resolved); });
|
||||
} catch (const std::runtime_error&) {
|
||||
threw = true;
|
||||
}
|
||||
}, context);
|
||||
|
||||
CHECK(threw);
|
||||
CHECK(committed.empty());
|
||||
}
|
||||
@@ -59,6 +59,16 @@ void write_preset_with_inherits(const DynamicPrintConfig &default_config, const
|
||||
config.save_to_json(file.string(), name, "User", "1.0.0");
|
||||
}
|
||||
|
||||
// A user preset file stating nothing but the preset it inherits, so every value it
|
||||
// ends up with came from resolving that parent.
|
||||
void write_minimal_child(const fs::path &file, const std::string &name, const std::string &inherits)
|
||||
{
|
||||
fs::create_directories(file.parent_path());
|
||||
std::ofstream(file.string())
|
||||
<< R"({"type":"process","name":")" << name << R"(","from":"User","version":"1.0.0","inherits":")"
|
||||
<< inherits << R"("})";
|
||||
}
|
||||
|
||||
// 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 = {})
|
||||
{
|
||||
@@ -261,6 +271,162 @@ TEST_CASE("Selected printer uses its default or saved bed type", "[Preset][Bundl
|
||||
CHECK(app_config.get_printer_setting("Test Printer", "curr_bed_type") == std::to_string(static_cast<int>(expected_bed_type)));
|
||||
}
|
||||
|
||||
TEST_CASE("A directory of user presets loads with each one resolved against its parent", "[Preset][Bundle]")
|
||||
{
|
||||
ScopedTemporaryDir temp_dir;
|
||||
RenameTestCollection coll;
|
||||
|
||||
Preset &parent = add_inmemory_preset(coll, "Parent Process");
|
||||
parent.config.option<ConfigOptionFloat>("layer_height", true)->value = 0.24;
|
||||
parent.is_system = true;
|
||||
|
||||
constexpr int children = 400;
|
||||
for (int i = 0; i < children; ++ i)
|
||||
write_minimal_child(temp_dir.path() / PRESET_PRINT_NAME / ("Child " + std::to_string(i) + ".json"),
|
||||
"Child " + std::to_string(i), "Parent Process");
|
||||
|
||||
PresetsConfigSubstitutions substitutions;
|
||||
coll.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions,
|
||||
ForwardCompatibilitySubstitutionRule::Disable);
|
||||
|
||||
CHECK(coll.size() == size_t(children) + 2); // the children, the default preset and the parent
|
||||
CHECK(coll.error_count() == 0);
|
||||
for (int i = 0; i < children; ++ i) {
|
||||
const Preset *child = coll.find_preset("Child " + std::to_string(i));
|
||||
REQUIRE(child != nullptr);
|
||||
CHECK(child->inherits() == "Parent Process");
|
||||
CHECK(child->alias == "Child " + std::to_string(i));
|
||||
CHECK(child->loaded);
|
||||
REQUIRE(child->config.option<ConfigOptionFloat>("layer_height") != nullptr);
|
||||
CHECK_THAT(child->config.opt_float("layer_height"), Catch::Matchers::WithinAbs(0.24, 1e-9));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Repeated loads of a user preset directory produce the same presets", "[Preset][Bundle]")
|
||||
{
|
||||
ScopedTemporaryDir temp_dir;
|
||||
|
||||
auto seed_directory = [&]() {
|
||||
for (int i = 0; i < 200; ++ i)
|
||||
write_minimal_child(temp_dir.path() / PRESET_PRINT_NAME / ("Child " + std::to_string(i) + ".json"),
|
||||
"Child " + std::to_string(i), "Parent Process");
|
||||
};
|
||||
|
||||
std::vector<std::vector<std::string>> names_per_run;
|
||||
for (int run = 0; run < 3; ++ run) {
|
||||
RenameTestCollection coll;
|
||||
Preset &parent = add_inmemory_preset(coll, "Parent Process");
|
||||
parent.is_system = true;
|
||||
if (run == 0)
|
||||
seed_directory();
|
||||
|
||||
PresetsConfigSubstitutions substitutions;
|
||||
coll.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions,
|
||||
ForwardCompatibilitySubstitutionRule::Disable);
|
||||
|
||||
std::vector<std::string> names;
|
||||
for (auto it = coll.begin(); it != coll.end(); ++ it)
|
||||
names.push_back(it->name + "|" + it->alias + "|" + it->inherits());
|
||||
names_per_run.push_back(std::move(names));
|
||||
}
|
||||
|
||||
REQUIRE(names_per_run[0].size() > 200);
|
||||
CHECK(names_per_run[1] == names_per_run[0]);
|
||||
CHECK(names_per_run[2] == names_per_run[0]);
|
||||
}
|
||||
|
||||
TEST_CASE("An unreadable user preset is counted and removed while the rest still load", "[Preset][Bundle]")
|
||||
{
|
||||
ScopedTemporaryDir temp_dir;
|
||||
RenameTestCollection coll;
|
||||
const fs::path dir = temp_dir.path() / PRESET_PRINT_NAME;
|
||||
|
||||
for (int i = 0; i < 20; ++ i)
|
||||
write_preset_with_inherits(coll.default_preset().config, dir / ("Good " + std::to_string(i) + ".json"),
|
||||
"Good " + std::to_string(i), std::string());
|
||||
fs::create_directories(dir);
|
||||
std::ofstream((dir / "Broken.json").string()) << "{not-json";
|
||||
|
||||
PresetsConfigSubstitutions substitutions;
|
||||
coll.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||
|
||||
CHECK(coll.error_count() == 1);
|
||||
CHECK(coll.find_preset("Broken") == nullptr);
|
||||
CHECK_FALSE(fs::exists(dir / "Broken.json"));
|
||||
for (int i = 0; i < 20; ++ i)
|
||||
CHECK(coll.find_preset("Good " + std::to_string(i)) != nullptr);
|
||||
}
|
||||
|
||||
TEST_CASE("A user filament naming no compatible printer gets the one after its @, in memory and on disk", "[Preset][Bundle]")
|
||||
{
|
||||
ScopedTemporaryDir temp_dir;
|
||||
PresetBundle bundle;
|
||||
const fs::path file = temp_dir.path() / PRESET_FILAMENT_NAME / "My PLA @Test Printer.json";
|
||||
REQUIRE(bundle.filaments.default_preset().config.option<ConfigOptionStrings>("compatible_printers")->values.empty());
|
||||
write_preset_with_inherits(bundle.filaments.default_preset().config, file, "My PLA @Test Printer", std::string());
|
||||
|
||||
PresetsConfigSubstitutions substitutions;
|
||||
bundle.filaments.load_presets(temp_dir.path().string(), PRESET_FILAMENT_NAME, substitutions,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||
|
||||
const std::vector<std::string> expected { "Test Printer" };
|
||||
const Preset *preset = bundle.filaments.find_preset("My PLA @Test Printer");
|
||||
REQUIRE(preset != nullptr);
|
||||
CHECK(preset->config.option<ConfigOptionStrings>("compatible_printers")->values == expected);
|
||||
|
||||
DynamicPrintConfig saved;
|
||||
std::map<std::string, std::string> key_values;
|
||||
std::string reason;
|
||||
saved.load_from_json(file.string(), ForwardCompatibilitySubstitutionRule::EnableSilent, key_values, reason);
|
||||
REQUIRE(reason.empty());
|
||||
REQUIRE(saved.option<ConfigOptionStrings>("compatible_printers") != nullptr);
|
||||
CHECK(saved.option<ConfigOptionStrings>("compatible_printers")->values == expected);
|
||||
}
|
||||
|
||||
TEST_CASE("A user preset's setting id equal to its base id is dropped in memory, not in the .info written back", "[Preset][Bundle]")
|
||||
{
|
||||
ScopedTemporaryDir temp_dir;
|
||||
PresetBundle bundle;
|
||||
const fs::path file = temp_dir.path() / PRESET_FILAMENT_NAME / "My PLA @Test Printer.json";
|
||||
write_preset_with_inherits(bundle.filaments.default_preset().config, file, "My PLA @Test Printer", std::string());
|
||||
fs::path info = file;
|
||||
info.replace_extension(".info");
|
||||
std::ofstream(info.string()) << "sync_info = \nuser_id = \nsetting_id = PFUS1\nbase_id = PFUS1\nupdated_time = 0\n";
|
||||
|
||||
PresetsConfigSubstitutions substitutions;
|
||||
bundle.filaments.load_presets(temp_dir.path().string(), PRESET_FILAMENT_NAME, substitutions,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||
|
||||
const Preset *preset = bundle.filaments.find_preset("My PLA @Test Printer");
|
||||
REQUIRE(preset != nullptr);
|
||||
CHECK(preset->setting_id.empty());
|
||||
CHECK(preset->base_id == "PFUS1");
|
||||
Preset reloaded(Preset::TYPE_FILAMENT, "My PLA @Test Printer");
|
||||
reloaded.load_info(info.string());
|
||||
CHECK(reloaded.setting_id == "PFUS1");
|
||||
}
|
||||
|
||||
TEST_CASE("A user preset that is not loaded still reports its substituted values", "[Preset][Bundle]")
|
||||
{
|
||||
ScopedTemporaryDir temp_dir;
|
||||
RenameTestCollection coll;
|
||||
const fs::path dir = temp_dir.path() / PRESET_PRINT_NAME;
|
||||
fs::create_directories(dir);
|
||||
std::ofstream((dir / "Orphan.json").string())
|
||||
<< R"({"type":"process","name":"Orphan","from":"User","version":"1.0.0","inherits":"No Such Parent",)"
|
||||
<< R"("wall_generator":"no_such_generator"})";
|
||||
|
||||
PresetsConfigSubstitutions substitutions;
|
||||
coll.load_presets(temp_dir.path().string(), PRESET_PRINT_NAME, substitutions,
|
||||
ForwardCompatibilitySubstitutionRule::Enable);
|
||||
|
||||
CHECK(coll.find_preset("Orphan") == nullptr);
|
||||
CHECK(coll.error_count() == 1);
|
||||
REQUIRE(substitutions.size() == 1);
|
||||
CHECK(substitutions.front().preset_name == "Orphan");
|
||||
}
|
||||
|
||||
TEST_CASE("find_preset resolves a system preset's renamed_from", "[Preset][Rename]")
|
||||
{
|
||||
RenameTestCollection coll;
|
||||
@@ -5482,3 +5648,39 @@ TEST_CASE("Config import confines zip entries, preset names and bundle ids to th
|
||||
CHECK_FALSE(any_filename_contains(temp_dir.path(), "bundle-escape"));
|
||||
}
|
||||
}
|
||||
|
||||
// A project saved before a key joined filament_options_with_variant stores it once per filament,
|
||||
// while the keys that were already per variant store it once per filament variant. Loading such a
|
||||
// project gives every variant of a filament that filament's value.
|
||||
TEST_CASE("A project saved with pressure advance per filament applies it to every variant of the filament", "[Preset][Bundle]")
|
||||
{
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.opt<ConfigOptionStrings>("filament_colour")->values = { "#FF0000", "#00FF00" };
|
||||
config.opt<ConfigOptionFloats>("filament_diameter")->values = { 1.75, 1.75 };
|
||||
config.option<ConfigOptionStrings>("filament_settings_id", true)->values = { "Project PLA", "Project PETG" };
|
||||
// A multi-variant printer: full_print_config() leaves the list out, and the loader splits the
|
||||
// variant keys per filament only when the project carries it.
|
||||
config.option<ConfigOptionStrings>("extruder_variant_list", true)->values = { "Direct Drive Standard,Direct Drive High Flow" };
|
||||
// filament 1 defines Standard and High Flow, filament 2 Standard
|
||||
config.opt<ConfigOptionStrings>("filament_extruder_variant")->values = { "Direct Drive Standard", "Direct Drive High Flow", "Direct Drive Standard" };
|
||||
config.opt<ConfigOptionInts>("filament_self_index")->values = { 1, 1, 2 };
|
||||
config.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values = { 0.95, 0.96, 0.97 }; // one per filament variant
|
||||
// One per filament, read through the loader that project files go through.
|
||||
config.load_from_ini_string("pressure_advance = 0.021,0.043", ForwardCompatibilitySubstitutionRule::Disable);
|
||||
// The CLI slices the config as loaded.
|
||||
check_double_vector(config.opt<ConfigOptionFloats>("pressure_advance")->values, { 0.021, 0.021, 0.043 });
|
||||
check_double_vector(config.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values, { 0.95, 0.96, 0.97 });
|
||||
// The GUI normalizes the config before load; mirror that so only the production path runs.
|
||||
Preset::normalize(config);
|
||||
|
||||
PresetBundle bundle;
|
||||
bundle.load_config_model("test.3mf", std::move(config));
|
||||
|
||||
REQUIRE(bundle.filament_presets.size() == 2);
|
||||
const DynamicPrintConfig &pla = bundle.filaments.find_preset(bundle.filament_presets[0], false, true)->config;
|
||||
const DynamicPrintConfig &petg = bundle.filaments.find_preset(bundle.filament_presets[1], false, true)->config;
|
||||
check_double_vector(pla.opt<ConfigOptionFloats>("pressure_advance")->values, { 0.021, 0.021 });
|
||||
check_double_vector(petg.opt<ConfigOptionFloats>("pressure_advance")->values, { 0.043 });
|
||||
check_double_vector(pla.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values, { 0.95, 0.96 });
|
||||
check_double_vector(petg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values, { 0.97 });
|
||||
}
|
||||
|
||||
@@ -243,3 +243,147 @@ TEST_CASE("find_unused_filename gives up after 999 versions", "[Utils]") {
|
||||
REQUIRE_FALSE(find_unused_filename(dir.path(), "model.3mf", {}, name));
|
||||
CHECK(name == "model(999).3mf");
|
||||
}
|
||||
|
||||
TEST_CASE("is_path_within_root accepts a root given with a trailing separator", "[utils]") {
|
||||
ScopedTemporaryDir tmp;
|
||||
const std::string root = tmp.path().string();
|
||||
const std::string with_separator = GENERATE_COPY(root + "/", root + std::string(1, static_cast<char>(boost::filesystem::path::preferred_separator)));
|
||||
|
||||
CAPTURE(with_separator);
|
||||
CHECK(is_path_within_root("vendor.json", with_separator));
|
||||
CHECK(is_path_within_root("vendor/machine/printer.json", with_separator));
|
||||
CHECK_FALSE(is_path_within_root("../vendor.json", with_separator));
|
||||
}
|
||||
|
||||
TEST_CASE("is_path_within_root treats Windows-specific name forms the same on every platform", "[utils]") {
|
||||
ScopedTemporaryDir tmp;
|
||||
|
||||
SECTION("names ending in dots or spaces stay inside the root") {
|
||||
const std::string name = GENERATE(std::string("name."), std::string("name "), std::string("dir./file.json"), std::string("dir /file.json"));
|
||||
CAPTURE(name);
|
||||
CHECK(is_path_within_root(name, tmp.path()));
|
||||
}
|
||||
SECTION("drive-relative names are rejected") {
|
||||
const std::string name = GENERATE(std::string("C:x"), std::string("c:x/y.json"), std::string("C:"));
|
||||
CAPTURE(name);
|
||||
CHECK_FALSE(is_path_within_root(name, tmp.path()));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("is_path_within_root rejects a name with an embedded NUL", "[utils]") {
|
||||
ScopedTemporaryDir tmp;
|
||||
// The filesystem calls stop at the NUL, so they would act on a different path than the one checked.
|
||||
const std::string name = GENERATE(std::string("..\0", 3), std::string("..\0x/file.json", 14), std::string("sub/..\0x", 8),
|
||||
std::string("file.json\0", 10), std::string("\0file.json", 10));
|
||||
CAPTURE(name.size());
|
||||
CHECK_FALSE(is_path_within_root(name, tmp.path()));
|
||||
}
|
||||
|
||||
TEST_CASE("is_symlink_target_within_root accepts relative targets that stay inside the root", "[utils]") {
|
||||
ScopedTemporaryDir tmp;
|
||||
const auto [link, target] = GENERATE(std::make_pair(std::string("Versions/Current"), std::string("A")),
|
||||
std::make_pair(std::string("Foo.framework/Foo"), std::string("Versions/Current/Foo")),
|
||||
std::make_pair(std::string("libfoo.so"), std::string("libfoo.so.1")),
|
||||
std::make_pair(std::string("a/b/link"), std::string("c/d")));
|
||||
CAPTURE(link, target);
|
||||
CHECK(is_symlink_target_within_root(link, target, tmp.path()));
|
||||
}
|
||||
|
||||
TEST_CASE("is_symlink_target_within_root rejects absolute targets and targets that climb out", "[utils]") {
|
||||
ScopedTemporaryDir tmp;
|
||||
const std::string outside = (tmp.path().parent_path() / "outside").generic_string();
|
||||
const auto [link, target] = GENERATE_COPY(std::make_pair(std::string("sub/link"), outside),
|
||||
std::make_pair(std::string("sub/link"), std::string("/etc/passwd")),
|
||||
std::make_pair(std::string("sub/link"), std::string("\\outside")),
|
||||
std::make_pair(std::string("sub/link"), std::string("C:/outside")),
|
||||
std::make_pair(std::string("sub/link"), std::string("C:outside")),
|
||||
std::make_pair(std::string("sub/link"), std::string("")),
|
||||
std::make_pair(std::string("link"), std::string("..")),
|
||||
std::make_pair(std::string("link"), std::string("../outside")),
|
||||
std::make_pair(std::string("sub/link"), std::string("../../outside")),
|
||||
std::make_pair(std::string("sub/link"), std::string("x/../../../outside")),
|
||||
std::make_pair(std::string("sub/link"), std::string("..\\..\\outside")),
|
||||
// symlink() stops at the NUL, so this target would be created as "..".
|
||||
std::make_pair(std::string("link"), std::string("..\0", 3)));
|
||||
CAPTURE(link, target);
|
||||
CHECK_FALSE(is_symlink_target_within_root(link, target, tmp.path()));
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
TEST_CASE("is_symlink_target_within_root rejects a target that passes through a symlink leading out", "[utils]") {
|
||||
ScopedTemporaryDir tmp;
|
||||
const boost::filesystem::path root = tmp.path() / "root";
|
||||
const boost::filesystem::path outside = tmp.path() / "outside";
|
||||
boost::filesystem::create_directories(root);
|
||||
boost::filesystem::create_directories(outside);
|
||||
boost::filesystem::create_symlink(outside, root / "out");
|
||||
|
||||
CHECK_FALSE(is_symlink_target_within_root("link", "out/lib.so", root));
|
||||
CHECK(is_symlink_target_within_root("link", "in/lib.so", root));
|
||||
}
|
||||
#endif
|
||||
|
||||
TEST_CASE("is_absolute_path_within_root accepts only entries inside the root", "[utils]") {
|
||||
namespace fs = boost::filesystem;
|
||||
ScopedTemporaryDir outer;
|
||||
const fs::path root = outer.path() / "Auxiliaries";
|
||||
fs::create_directories(root / "Others");
|
||||
const fs::path inside = root / "Others" / "note.txt";
|
||||
const fs::path outside = outer.path() / "secret.txt";
|
||||
std::ofstream(inside.string()) << "inside";
|
||||
std::ofstream(outside.string()) << "outside";
|
||||
|
||||
SECTION("a file inside the root") {
|
||||
REQUIRE(is_absolute_path_within_root(inside, root));
|
||||
}
|
||||
SECTION("a path inside the root whose file does not exist yet") {
|
||||
REQUIRE(is_absolute_path_within_root(root / "Others" / "missing.txt", root));
|
||||
}
|
||||
SECTION("the root itself") {
|
||||
REQUIRE_FALSE(is_absolute_path_within_root(root, root));
|
||||
}
|
||||
SECTION("a parent-directory escape spelled under the root") {
|
||||
REQUIRE_FALSE(is_absolute_path_within_root(root / "Others" / ".." / ".." / "secret.txt", root));
|
||||
}
|
||||
SECTION("an absolute path elsewhere") {
|
||||
REQUIRE_FALSE(is_absolute_path_within_root(outside, root));
|
||||
}
|
||||
SECTION("a sibling directory sharing the root's name as a prefix") {
|
||||
const fs::path sibling = outer.path() / "Auxiliaries2" / "note.txt";
|
||||
REQUIRE_FALSE(is_absolute_path_within_root(sibling, root));
|
||||
}
|
||||
SECTION("a relative path") {
|
||||
REQUIRE_FALSE(is_absolute_path_within_root(fs::path("Others") / "note.txt", root));
|
||||
}
|
||||
SECTION("an empty path") {
|
||||
REQUIRE_FALSE(is_absolute_path_within_root(fs::path(), root));
|
||||
}
|
||||
#ifndef _WIN32
|
||||
// Creating symlinks on Windows needs elevated rights or developer mode.
|
||||
SECTION("a symlink inside the root that points outside") {
|
||||
const fs::path link = root / "Others" / "link.txt";
|
||||
fs::create_symlink(outside, link);
|
||||
REQUIRE_FALSE(is_absolute_path_within_root(link, root));
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST_CASE("is_safe_to_open_file_name accepts plain documents, images and models", "[utils]") {
|
||||
const std::string safe = GENERATE(as<std::string>{},
|
||||
"Manual.pdf", "BOM.xlsx", "BOM.csv", "guide.docx", "notes.txt", "README.md", "photo.JPG", "render.png",
|
||||
"assembly.step", "part.stl", "project.3mf", "drawing.dxf", "build.mp4", "setup.exe.pdf", ".pdf");
|
||||
INFO(safe);
|
||||
CHECK(is_safe_to_open_file_name(safe));
|
||||
}
|
||||
|
||||
TEST_CASE("is_safe_to_open_file_name rejects programs and anything it does not know", "[utils]") {
|
||||
const std::string unsafe = GENERATE(as<std::string>{},
|
||||
"setup.exe", "SETUP.EXE", "Manual.pdf.exe", "run.bat", "shortcut.lnk", "site.url", "script.ps1", "help.chm",
|
||||
"tool.jar", "script.py", "Install.command", "install.sh", "launcher.desktop", "Printer.AppImage",
|
||||
// Documents that can carry macros or scripts.
|
||||
"BOM.xls", "BOM.xlsm", "guide.doc", "guide.docm", "sheet.ods", "page.html", "logo.svg", "bundle.zip",
|
||||
// No extension, an unknown one, or a name the desktop would read differently.
|
||||
"readme", "pdf", "data.xyz", "", "...", "Manual.pdf.", "Manual.pdf ", "setup.exe:note.txt", "dir.pdf/readme");
|
||||
INFO(unsafe);
|
||||
CHECK_FALSE(is_safe_to_open_file_name(unsafe));
|
||||
}
|
||||
|
||||
@@ -3,12 +3,21 @@
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/crc.hpp>
|
||||
#include <cereal/archives/binary.hpp>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <numeric>
|
||||
#include <random>
|
||||
#include <set>
|
||||
#include <sstream>
|
||||
|
||||
#include <tbb/blocked_range.h>
|
||||
#include <tbb/parallel_for.h>
|
||||
#include <tbb/task_group.h>
|
||||
|
||||
#include "libslic3r/PresetBundle.hpp"
|
||||
#include "libslic3r/PresetCacheFormat.hpp"
|
||||
#include "libslic3r/Preset.hpp"
|
||||
@@ -52,6 +61,27 @@ void write_vendor_tree(const fs::path& dir, const std::string& vendor, const std
|
||||
<< R"(","from":"system","instantiation":"true","layer_height":"0.2"})";
|
||||
}
|
||||
|
||||
// A vendor whose process list is `processes`, each a preset name and the text of
|
||||
// its sub-file, listed in that order.
|
||||
void write_process_vendor(const fs::path& dir, const std::string& vendor,
|
||||
const std::vector<std::pair<std::string, std::string>>& processes)
|
||||
{
|
||||
fs::create_directories(dir / vendor / "process");
|
||||
std::ofstream index((dir / (vendor + ".json")).string());
|
||||
index << R"({"version":"1.0.0","name":")" << vendor << R"(","process_list":[)";
|
||||
for (size_t i = 0; i < processes.size(); ++ i) {
|
||||
const std::string sub_path = "process/p" + std::to_string(i) + ".json";
|
||||
index << (i ? "," : "") << R"({"name":")" << processes[i].first << R"(","sub_path":")" << sub_path << R"("})";
|
||||
std::ofstream((dir / vendor / sub_path).string()) << processes[i].second;
|
||||
}
|
||||
index << "]}";
|
||||
}
|
||||
|
||||
std::string process_json(const std::string& name, const std::string& extra = std::string())
|
||||
{
|
||||
return R"({"type":"process","name":")" + name + R"(","from":"system",)" + extra + R"("layer_height":"0.2"})";
|
||||
}
|
||||
|
||||
// A small but complete vendor: one machine model, one process, a non-instantiated
|
||||
// base filament with an instantiated child that inherits it and includes a
|
||||
// dual-extruder template, a second standalone filament carrying explicit
|
||||
@@ -128,6 +158,80 @@ void write_lib_tree(const fs::path& dir, const std::string& version, const std::
|
||||
<< R"("filament_id":"GFL99","filament_cost":")" << cost << R"("})";
|
||||
}
|
||||
|
||||
struct FixtureEntry {
|
||||
std::string name, sub_path;
|
||||
std::vector<size_t> deps; // indices of the entries this one inherits or includes
|
||||
};
|
||||
|
||||
// Filaments with two levels of inheritance, a template to include, and `leaves`
|
||||
// presets that inherit either level, some including the template. Only the
|
||||
// subfiles are written; write_filament_list lists them.
|
||||
std::vector<FixtureEntry> write_layered_filaments(const fs::path& dir, const std::string& vendor, int leaves)
|
||||
{
|
||||
fs::create_directories(dir / vendor / "filament");
|
||||
std::vector<FixtureEntry> entries {
|
||||
{ vendor + " Base PLA", "filament/base.json", {} },
|
||||
{ vendor + " Mid PLA", "filament/mid.json", {0} },
|
||||
{ vendor + " dual template", "filament/template.json", {} },
|
||||
};
|
||||
std::ofstream((dir / vendor / "filament" / "base.json").string())
|
||||
<< R"({"type":"filament","name":")" << entries[0].name << R"(","from":"system","instantiation":"false",)"
|
||||
<< R"("filament_id":"GFA_base","filament_cost":"42","filament_max_volumetric_speed":["12"]})";
|
||||
std::ofstream((dir / vendor / "filament" / "mid.json").string())
|
||||
<< R"({"type":"filament","name":")" << entries[1].name << R"(","from":"system","instantiation":"false",)"
|
||||
<< R"("inherits":")" << entries[0].name << R"(","filament_flow_ratio":"0.95"})";
|
||||
std::ofstream((dir / vendor / "filament" / "template.json").string())
|
||||
<< R"({"type":"filament","name":")" << entries[2].name << R"(","from":"system","instantiation":"false",)"
|
||||
<< R"("filament_extruder_variant":["Direct Drive Standard","Direct Drive High Flow"],)"
|
||||
<< R"("filament_max_volumetric_speed":["20","22"]})";
|
||||
for (int i = 0; i < leaves; ++ i) {
|
||||
const size_t parent = i % 2;
|
||||
const bool include = i % 3 == 0;
|
||||
entries.push_back({ vendor + " PLA " + std::to_string(i) + " @0.4", "filament/leaf" + std::to_string(i) + ".json",
|
||||
include ? std::vector<size_t>{parent, 2} : std::vector<size_t>{parent} });
|
||||
std::ofstream((dir / vendor / entries.back().sub_path).string())
|
||||
<< R"({"type":"filament","name":")" << entries.back().name << R"(","from":"system","instantiation":"true",)"
|
||||
<< R"("inherits":")" << entries[parent].name << R"(",)"
|
||||
<< (include ? R"("include":[")" + entries[2].name + R"("],)" : std::string())
|
||||
<< R"("nozzle_temperature":[")" << (200 + i % 40) << R"("]})";
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// The vendor profile for write_layered_filaments, listing its entries in `order`,
|
||||
// or as they were written when `order` is empty.
|
||||
void write_filament_list(const fs::path& dir, const std::string& vendor, const std::vector<FixtureEntry>& entries,
|
||||
std::vector<size_t> order = {})
|
||||
{
|
||||
if (order.empty()) {
|
||||
order.resize(entries.size());
|
||||
std::iota(order.begin(), order.end(), 0);
|
||||
}
|
||||
std::ofstream f((dir / (vendor + ".json")).string());
|
||||
f << R"({"version":"1.0.0","name":")" << vendor << R"(","filament_list":[)";
|
||||
for (size_t k = 0; k < order.size(); ++ k)
|
||||
f << (k ? "," : "") << R"({"name":")" << entries[order[k]].name << R"(","sub_path":")"
|
||||
<< entries[order[k]].sub_path << R"("})";
|
||||
f << "]}";
|
||||
}
|
||||
|
||||
// A random order of `entries` that lists each one after everything it depends on.
|
||||
std::vector<size_t> shuffled_after_dependencies(const std::vector<FixtureEntry>& entries, std::mt19937& rng)
|
||||
{
|
||||
std::vector<size_t> order;
|
||||
std::vector<bool> listed(entries.size(), false);
|
||||
while (order.size() < entries.size()) {
|
||||
std::vector<size_t> ready;
|
||||
for (size_t i = 0; i < entries.size(); ++ i)
|
||||
if (! listed[i] && std::all_of(entries[i].deps.begin(), entries[i].deps.end(), [&](size_t d) { return listed[d]; }))
|
||||
ready.push_back(i);
|
||||
const size_t next = ready[std::uniform_int_distribution<size_t>(0, ready.size() - 1)(rng)];
|
||||
listed[next] = true;
|
||||
order.push_back(next);
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
// A vendor whose one filament inherits the library's base and states nothing of
|
||||
// its own — everything it shows comes from the library it is resolved against.
|
||||
void write_vendor_with_lib_filament(const fs::path& dir, const std::string& vendor, const std::string& version)
|
||||
@@ -624,6 +728,299 @@ TEST_CASE("a cache-loaded vendor is indistinguishable from a JSON-loaded one", "
|
||||
CHECK(pr->config.opt_string("machine_start_gcode") == "G28 ; template"); // through the include
|
||||
}
|
||||
|
||||
TEST_CASE("a wide vendor loads from its cache exactly as it loads from JSON", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
constexpr int leaves = 400;
|
||||
write_filament_list(dirs.system, "Acme", write_layered_filaments(dirs.system, "Acme", leaves));
|
||||
|
||||
PresetBundle from_json;
|
||||
from_json.set_generate_vendor_caches(true);
|
||||
from_json.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||
REQUIRE(fs::exists(dirs.system / "Acme.opc"));
|
||||
|
||||
fs::remove_all(dirs.system / "Acme");
|
||||
PresetBundle from_cache;
|
||||
from_cache.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||
|
||||
auto a = presets_for(from_json.filaments, "Acme");
|
||||
auto b = presets_for(from_cache.filaments, "Acme");
|
||||
REQUIRE(a.size() == size_t(leaves));
|
||||
REQUIRE(b.size() == a.size());
|
||||
for (size_t i = 0; i < a.size(); ++ i) {
|
||||
CHECK(a[i]->name == b[i]->name);
|
||||
CHECK(preset_deep_equal(*a[i], *b[i]));
|
||||
}
|
||||
CHECK(from_cache.error_count() == from_json.error_count());
|
||||
// The two-level chain resolved, so the leaf has the middle profile's flow ratio
|
||||
// and the base profile's filament id, neither of which it states.
|
||||
const Preset* leaf = from_cache.filaments.find_preset("Acme PLA 7 @0.4", false);
|
||||
REQUIRE(leaf != nullptr);
|
||||
CHECK(leaf->filament_id == "GFA_base");
|
||||
const auto* flow = leaf->config.option<ConfigOptionFloats>("filament_flow_ratio");
|
||||
REQUIRE(flow != nullptr);
|
||||
CHECK_THAT(flow->values.front(), WithinAbs(0.95, 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("presets install the same in any order that lists each after what it depends on", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
const std::vector<FixtureEntry> entries = write_layered_filaments(dirs.system, "Acme", 150);
|
||||
auto load = [&](const std::vector<size_t>& order) {
|
||||
write_filament_list(dirs.system, "Acme", entries, order);
|
||||
auto bundle = std::make_unique<PresetBundle>();
|
||||
bundle->load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||
return bundle;
|
||||
};
|
||||
const auto reference = load({});
|
||||
CHECK(reference->error_count() == 0);
|
||||
const auto expected = presets_for(reference->filaments, "Acme");
|
||||
REQUIRE(expected.size() == 150);
|
||||
|
||||
// Every seed checks against one reference load, which GENERATE would repeat per seed.
|
||||
for (unsigned seed = 0; seed < 8; ++ seed) {
|
||||
CAPTURE(seed);
|
||||
std::mt19937 rng(seed);
|
||||
const auto shuffled = load(shuffled_after_dependencies(entries, rng));
|
||||
CHECK(shuffled->error_count() == 0);
|
||||
const auto actual = presets_for(shuffled->filaments, "Acme");
|
||||
REQUIRE(actual.size() == expected.size());
|
||||
for (size_t i = 0; i < actual.size(); ++ i)
|
||||
CHECK(preset_deep_equal(*actual[i], *expected[i]));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("a key misplaced into a vendor preset is reported and removed", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
write_process_vendor(dirs.system, "Acme", {
|
||||
{ "Acme template", R"({"type":"process","name":"Acme template","from":"system","instantiation":"false","wall_loops":"5","filament_density":"1.2"})" },
|
||||
{ "Acme base", R"({"type":"process","name":"Acme base","from":"system","instantiation":"false","filament_cost":"5"})" },
|
||||
{ "0.20mm Standard @Acme", process_json("0.20mm Standard @Acme",
|
||||
R"("instantiation":"true","inherits":"Acme base","include":["Acme template"],"nozzle_temperature":["210"],)") } });
|
||||
PresetBundle bundle;
|
||||
bundle.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||
|
||||
CHECK(bundle.error_count() == 3);
|
||||
const Preset* preset = bundle.prints.find_preset("0.20mm Standard @Acme", false);
|
||||
REQUIRE(preset != nullptr);
|
||||
CHECK_FALSE(preset->config.has("filament_cost"));
|
||||
CHECK_FALSE(preset->config.has("filament_density"));
|
||||
CHECK_FALSE(preset->config.has("nozzle_temperature"));
|
||||
CHECK(preset->config.opt_int("wall_loops") == 5);
|
||||
}
|
||||
|
||||
TEST_CASE("each vendor loads from the directory it is listed with", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
write_vendor_tree(dirs.system, "Acme", "1.0.0");
|
||||
write_vendor_tree(dirs.profiles, "Zeta", "2.0.0");
|
||||
PresetBundle bundle;
|
||||
bundle.load_vendors({ { "Acme", dirs.system }, { "Zeta", dirs.profiles } },
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent, /*allow_cache=*/false);
|
||||
|
||||
CHECK(bundle.vendors.count("Acme") == 1);
|
||||
CHECK(bundle.vendors.count("Zeta") == 1);
|
||||
CHECK(bundle.prints.find_preset("0.20mm Standard @Acme", false) != nullptr);
|
||||
CHECK(bundle.prints.find_preset("0.20mm Standard @Zeta", false) != nullptr);
|
||||
}
|
||||
|
||||
TEST_CASE("a vendor that fails to load is left out, reported, and the others still load", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
write_vendor_tree(dirs.system, "Acme", "1.0.0");
|
||||
write_process_vendor(dirs.system, "Broken", { { "Broken A", "{not-json" } });
|
||||
PresetBundle bundle;
|
||||
std::vector<std::string> failed;
|
||||
const std::string errors = bundle.load_vendors({ { "Acme", dirs.system }, { "Broken", dirs.system } },
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent,
|
||||
/*allow_cache=*/false, nullptr, &failed).second;
|
||||
|
||||
CHECK(bundle.vendors.count("Acme") == 1);
|
||||
CHECK(bundle.vendors.count("Broken") == 0);
|
||||
CHECK(bundle.prints.find_preset("0.20mm Standard @Acme", false) != nullptr);
|
||||
CHECK(errors.find("Broken") != std::string::npos);
|
||||
CHECK(failed == std::vector<std::string>{ "Broken" });
|
||||
}
|
||||
|
||||
TEST_CASE("a filament library that fails partway is reported, and vendors inheriting from it are left out", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
const std::string lib(PresetBundle::ORCA_FILAMENT_LIBRARY);
|
||||
write_lib_tree(dirs.system, "1.0.0", "20");
|
||||
std::ofstream((dirs.system / (lib + ".json")).string())
|
||||
<< R"({"version":"1.0.0","name":")" << lib << R"(","filament_list":[)"
|
||||
<< R"({"name":"Generic PLA","sub_path":"filament/generic_pla.json"},)"
|
||||
<< R"({"name":"Generic PETG","sub_path":"filament/generic_petg.json"}]})";
|
||||
std::ofstream((dirs.system / lib / "filament" / "generic_petg.json").string()) << "{not-json";
|
||||
write_vendor_with_lib_filament(dirs.system, "Acme", "1.0.0");
|
||||
write_vendor_tree(dirs.system, "Zeta", "1.0.0");
|
||||
|
||||
PresetBundle bundle;
|
||||
std::vector<std::string> failed;
|
||||
const std::string errors = bundle.load_vendors({ { "Acme", dirs.system }, { lib, dirs.system }, { "Zeta", dirs.system } },
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent,
|
||||
/*allow_cache=*/false, nullptr, &failed).second;
|
||||
|
||||
CHECK(failed == std::vector<std::string>{ lib, "Acme" });
|
||||
CHECK(errors.find("generic_petg.json") != std::string::npos);
|
||||
CHECK(bundle.vendors.count(lib) == 1);
|
||||
CHECK(bundle.vendors.count("Acme") == 0);
|
||||
CHECK(bundle.filaments.find_preset("Acme PLA @0.4", false) == nullptr);
|
||||
CHECK(bundle.vendors.count("Zeta") == 1);
|
||||
CHECK(bundle.prints.find_preset("0.20mm Standard @Zeta", false) != nullptr);
|
||||
}
|
||||
|
||||
TEST_CASE("a vendor whose profile cannot be read is left out, reported, and the others still load", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
write_vendor_tree(dirs.system, "Acme", "1.0.0");
|
||||
std::ofstream((dirs.system / "Broken.json").string()) << "{not-json";
|
||||
PresetBundle bundle;
|
||||
const std::string errors = bundle.load_vendors({ { "Acme", dirs.system }, { "Broken", dirs.system } },
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent,
|
||||
/*allow_cache=*/false).second;
|
||||
|
||||
CHECK(bundle.vendors.count("Acme") == 1);
|
||||
CHECK(bundle.vendors.count("Broken") == 0);
|
||||
CHECK(bundle.prints.find_preset("0.20mm Standard @Acme", false) != nullptr);
|
||||
CHECK(errors.find("Broken.json") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("a canceled vendor load starts no vendor", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
write_vendor_tree(dirs.system, "Acme", "1.0.0");
|
||||
write_vendor_tree(dirs.system, "Zeta", "1.0.0");
|
||||
const std::atomic<bool> cancel { true };
|
||||
PresetBundle bundle;
|
||||
bundle.load_vendors({ { "Acme", dirs.system }, { "Zeta", dirs.system } },
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent, /*allow_cache=*/false, &cancel);
|
||||
|
||||
CHECK(bundle.vendors.empty());
|
||||
CHECK(bundle.prints.find_preset("0.20mm Standard @Acme", false) == nullptr);
|
||||
}
|
||||
|
||||
TEST_CASE("a preset two vendors both define is kept from the first listed and reported under the others", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
for (const std::string vendor : { "Acme", "Mira", "Zeta" })
|
||||
write_process_vendor(dirs.system, vendor, {
|
||||
{ "Shared", process_json("Shared", R"("instantiation":"true",)") },
|
||||
{ "Own @" + vendor, process_json("Own @" + vendor, R"("instantiation":"true",)") } });
|
||||
PresetBundle bundle;
|
||||
const std::string errors = bundle.load_vendors({ { "Mira", dirs.system }, { "Acme", dirs.system }, { "Zeta", dirs.system } },
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent,
|
||||
/*allow_cache=*/false).second;
|
||||
|
||||
const Preset* shared = bundle.prints.find_preset("Shared", false);
|
||||
REQUIRE(shared != nullptr);
|
||||
REQUIRE(shared->vendor != nullptr);
|
||||
CHECK(shared->vendor->id == "Mira");
|
||||
CHECK(errors.find("vendor Mira") == std::string::npos);
|
||||
CHECK(errors.find("Found duplicated settings in vendor Acme's json file lists: Shared") != std::string::npos);
|
||||
CHECK(errors.find("Found duplicated settings in vendor Zeta's json file lists: Shared") != std::string::npos);
|
||||
CHECK(bundle.error_count() == 2);
|
||||
for (const std::string vendor : { "Acme", "Mira", "Zeta" }) {
|
||||
const Preset* own = bundle.prints.find_preset("Own @" + vendor, false);
|
||||
REQUIRE(own != nullptr);
|
||||
CHECK(own->vendor == &bundle.vendors.at(vendor));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("filaments merged from several vendors come out generic first, then by name", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
auto write_filaments = [&](const std::string& vendor, const std::vector<std::string>& names) {
|
||||
fs::create_directories(dirs.system / vendor / "filament");
|
||||
std::ofstream index((dirs.system / (vendor + ".json")).string());
|
||||
index << R"({"version":"1.0.0","name":")" << vendor << R"(","filament_list":[)";
|
||||
for (size_t i = 0; i < names.size(); ++ i) {
|
||||
const std::string sub_path = "filament/f" + std::to_string(i) + ".json";
|
||||
index << (i ? "," : "") << R"({"name":")" << names[i] << R"(","sub_path":")" << sub_path << R"("})";
|
||||
std::ofstream((dirs.system / vendor / sub_path).string())
|
||||
<< R"({"type":"filament","name":")" << names[i] << R"(","from":"system","instantiation":"true",)"
|
||||
<< R"("filament_id":"GF)" << vendor << i << R"("})";
|
||||
}
|
||||
index << "]}";
|
||||
};
|
||||
write_filaments("Zeta", { "Zeta PLA @0.4", "Generic PETG @Zeta" });
|
||||
write_filaments("Acme", { "Acme PLA @0.4", "Generic PLA @Acme" });
|
||||
PresetBundle bundle;
|
||||
bundle.load_vendors({ { "Zeta", dirs.system }, { "Acme", dirs.system } },
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent, /*allow_cache=*/false);
|
||||
|
||||
std::vector<std::string> names;
|
||||
for (const Preset& preset : bundle.filaments.get_presets())
|
||||
if (! preset.is_default)
|
||||
names.push_back(preset.name);
|
||||
CHECK(names == std::vector<std::string>{ "Generic PETG @Zeta", "Generic PLA @Acme", "Acme PLA @0.4", "Zeta PLA @0.4" });
|
||||
for (const std::string& name : names)
|
||||
CHECK(bundle.filaments.find_preset(name, false) != nullptr);
|
||||
}
|
||||
|
||||
TEST_CASE("a vendor read while the filament library loads resolves against it, from JSON and from its cache", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
const std::string lib(PresetBundle::ORCA_FILAMENT_LIBRARY);
|
||||
write_lib_tree(dirs.system, "1.0.0", "20");
|
||||
write_vendor_with_lib_filament(dirs.system, "Acme", "1.0.0");
|
||||
auto load = [&] {
|
||||
auto bundle = std::make_unique<PresetBundle>();
|
||||
bundle->set_generate_vendor_caches(true);
|
||||
bundle->load_vendors({ { "Acme", dirs.system }, { lib, dirs.system } },
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent, /*allow_cache=*/true);
|
||||
return bundle;
|
||||
};
|
||||
|
||||
const auto from_json = load();
|
||||
REQUIRE(fs::exists(dirs.system / "Acme.opc"));
|
||||
fs::remove_all(dirs.system / "Acme");
|
||||
const auto from_cache = load();
|
||||
|
||||
for (const PresetBundle* bundle : { from_json.get(), from_cache.get() }) {
|
||||
const Preset* pla = bundle->filaments.find_preset("Acme PLA @0.4", false);
|
||||
REQUIRE(pla != nullptr);
|
||||
CHECK(pla->filament_id == "GFL99");
|
||||
CHECK_THAT(pla->config.option<ConfigOptionFloats>("filament_cost")->values.front(), WithinAbs(20., 1e-9));
|
||||
CHECK(bundle->error_count() == 0);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("repeated cache loads of one vendor produce the same presets", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
write_filament_list(dirs.system, "Acme", write_layered_filaments(dirs.system, "Acme", 400));
|
||||
|
||||
PresetBundle seed;
|
||||
seed.set_generate_vendor_caches(true);
|
||||
seed.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||
REQUIRE(fs::exists(dirs.system / "Acme.opc"));
|
||||
fs::remove_all(dirs.system / "Acme");
|
||||
|
||||
std::vector<PresetBundle> loads(3);
|
||||
for (PresetBundle& bundle : loads)
|
||||
bundle.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||
|
||||
auto first = presets_for(loads[0].filaments, "Acme");
|
||||
REQUIRE(!first.empty());
|
||||
for (size_t run = 1; run < loads.size(); ++ run) {
|
||||
auto again = presets_for(loads[run].filaments, "Acme");
|
||||
REQUIRE(again.size() == first.size());
|
||||
for (size_t i = 0; i < first.size(); ++ i) {
|
||||
CHECK(first[i]->name == again[i]->name);
|
||||
CHECK(preset_deep_equal(*first[i], *again[i]));
|
||||
}
|
||||
CHECK(loads[run].error_count() == loads[0].error_count());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("a cache-served vendor reports the errors its parse counted", "[VendorCache]")
|
||||
{
|
||||
TempDir tmp;
|
||||
@@ -866,6 +1263,27 @@ TEST_CASE("a vendor installed as its cache alone still loads after a library upd
|
||||
CHECK_THAT(cost->values.front(), WithinAbs(30., 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("a cache install stopped by a canceled task group throws instead of rejecting the cache", "[VendorCache]")
|
||||
{
|
||||
TempDir tmp;
|
||||
const std::string cache = (tmp.path / "Acme.opc").string();
|
||||
REQUIRE(save_one_vendor(cache, one_vendor("Acme"), "Acme", "1.0.0", {filament_entry("Acme PLA @0.4")}));
|
||||
|
||||
PresetBundle bundle;
|
||||
bool threw = false;
|
||||
tbb::task_group_context context;
|
||||
tbb::parallel_for(tbb::blocked_range<size_t>(0, 1), [&](const tbb::blocked_range<size_t>&) {
|
||||
context.cancel_group_execution();
|
||||
try {
|
||||
bundle.load_vendor_cache(cache, "Acme", Semver(1, 0, 0));
|
||||
} catch (const std::runtime_error&) {
|
||||
threw = true;
|
||||
}
|
||||
}, context);
|
||||
|
||||
CHECK(threw);
|
||||
}
|
||||
|
||||
TEST_CASE("a cache entry whose parent is missing falls back to the vendor's JSONs", "[VendorCache]")
|
||||
{
|
||||
TempDir tmp;
|
||||
@@ -893,6 +1311,12 @@ TEST_CASE("a cache entry whose parent is missing falls back to the vendor's JSON
|
||||
user.string(), "Acme", PresetBundle::LoadSystem, ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||
CHECK(presets_loaded == 1);
|
||||
CHECK(out.vendors.at("Acme").name == "Acme"); // the profile's name, not the cache's
|
||||
|
||||
// Through load_vendors, which reads the cache before installing it.
|
||||
PresetBundle several;
|
||||
several.load_vendors({ { "Acme", user } }, ForwardCompatibilitySubstitutionRule::EnableSilent, /*allow_cache=*/true);
|
||||
CHECK(several.vendors.at("Acme").name == "Acme");
|
||||
CHECK(several.prints.find_preset("0.20mm Standard @Acme", false) != nullptr);
|
||||
}
|
||||
|
||||
TEST_CASE("a profile with no usable version is never served from cache", "[VendorCache]")
|
||||
@@ -1691,6 +2115,31 @@ TEST_CASE("an include listed after the preset that names it is an error, and the
|
||||
CHECK(silk->config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>{12.});
|
||||
}
|
||||
|
||||
TEST_CASE("an include listed after the preset that names it is missing from the cache load too", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
write_full_vendor_tree(dirs.system, "Acme", "1.0.0", /*templates_last=*/true);
|
||||
PresetBundle from_json;
|
||||
from_json.set_generate_vendor_caches(true);
|
||||
from_json.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||
REQUIRE(fs::exists(dirs.system / "Acme.opc"));
|
||||
|
||||
fs::remove_all(dirs.system / "Acme");
|
||||
PresetBundle from_cache;
|
||||
from_cache.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||
CHECK(from_cache.error_count() == 2);
|
||||
const Preset* pr = from_cache.printers.find_preset("Acme 0.4 nozzle", false);
|
||||
REQUIRE(pr != nullptr);
|
||||
CHECK(pr->config.opt_string("machine_start_gcode") != "G28 ; template");
|
||||
const Preset* silk = from_cache.filaments.find_preset("Acme Silk PLA @0.4", false);
|
||||
REQUIRE(silk != nullptr);
|
||||
const auto& speed = silk->config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values;
|
||||
REQUIRE(speed.size() == 1);
|
||||
CHECK_THAT(speed.front(), WithinAbs(12., 1e-9));
|
||||
}
|
||||
|
||||
TEST_CASE("a G-code template that states no instantiation is included, not loaded as a preset", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
@@ -1712,3 +2161,35 @@ TEST_CASE("a G-code template that states no instantiation is included, not loade
|
||||
CHECK(pr->config.opt_string("machine_start_gcode") == "G28 ; template");
|
||||
CHECK(presets_for(bundle.printers, "Acme").size() == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("a sub-file that fails to parse leaves the ones listed before it installed", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
write_process_vendor(dirs.system, "Acme", {
|
||||
{ "Acme A", process_json("Acme A", R"("instantiation":"true",)") },
|
||||
{ "Acme B", "{not-json" },
|
||||
{ "Acme C", process_json("Acme C", R"("instantiation":"true",)") } });
|
||||
PresetBundle bundle;
|
||||
CHECK_THROWS_AS(bundle.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent),
|
||||
ConfigurationError);
|
||||
CHECK(bundle.prints.find_preset("Acme A", false) != nullptr);
|
||||
CHECK(bundle.prints.find_preset("Acme C", false) == nullptr);
|
||||
}
|
||||
|
||||
TEST_CASE("a load that fails to install partway counts no errors of the sub-files after it", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
// B names a parent nobody defines. C states no instantiation, an error of its own.
|
||||
write_process_vendor(dirs.system, "Acme", {
|
||||
{ "Acme A", process_json("Acme A", R"("instantiation":"true",)") },
|
||||
{ "Acme B", process_json("Acme B", R"("instantiation":"true","inherits":"Nobody",)") },
|
||||
{ "Acme C", process_json("Acme C") } });
|
||||
PresetBundle bundle;
|
||||
CHECK_THROWS_AS(bundle.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent),
|
||||
ConfigurationError);
|
||||
// B's missing parent and B's failed install.
|
||||
CHECK(bundle.error_count() == 2);
|
||||
CHECK(bundle.prints.find_preset("Acme A", false) != nullptr);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_plugin_cloud_metadata.cpp
|
||||
test_plugin_audit.cpp
|
||||
test_shortcuts.cpp
|
||||
test_file_url.cpp
|
||||
test_user_manager.cpp
|
||||
../fff_print/test_helpers.cpp
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/generators/catch_generators.hpp>
|
||||
|
||||
#include "slic3r/GUI/GUI.hpp"
|
||||
|
||||
#include <boost/filesystem/path.hpp>
|
||||
|
||||
#include <wx/uri.h>
|
||||
|
||||
using namespace Slic3r::GUI;
|
||||
|
||||
#ifndef _WIN32
|
||||
TEST_CASE("A file URL keeps characters special to URLs in its path", "[FileUrl]")
|
||||
{
|
||||
const std::string path = GENERATE(as<std::string>{},
|
||||
"/opt/test#dir/resources/web/homepage/index.html",
|
||||
"/opt/test%20x/resources/web/homepage/index.html",
|
||||
"/opt/Orca Slicer/resources/web/homepage/index.html",
|
||||
"/opt/what?/resources/web/homepage/index.html",
|
||||
"/home/Jos\xC3\xA9/\xE8\xB5\x84\xE6\xBA\x90/resources/web/homepage/index.html");
|
||||
|
||||
const wxString url = file_url_from_path(boost::filesystem::path(path));
|
||||
CAPTURE(path, url.utf8_string());
|
||||
|
||||
// WebView::CreateWebView() and WebView::LoadUrl() re-parse the URL before loading it.
|
||||
const wxURI uri(wxURI(url).BuildURI());
|
||||
CHECK(uri.GetScheme() == "file");
|
||||
CHECK(!uri.HasQuery());
|
||||
CHECK(!uri.HasFragment());
|
||||
CHECK(wxURI::Unescape(uri.GetPath()).utf8_string() == path);
|
||||
}
|
||||
|
||||
TEST_CASE("A file URL of a plain path is the path behind file://", "[FileUrl]")
|
||||
{
|
||||
const std::string path = "/opt/OrcaSlicer/resources/web/homepage/index.html";
|
||||
CHECK(file_url_from_path(boost::filesystem::path(path)) == "file://" + path);
|
||||
}
|
||||
|
||||
TEST_CASE("A query appended to a file URL stays separate from its path", "[FileUrl]")
|
||||
{
|
||||
const std::string path = "/opt/test#dir%20x/resources/web/guide/0/index.html";
|
||||
const wxURI uri(file_url_from_path(boost::filesystem::path(path)) + "?target=21&lang=de");
|
||||
CHECK(wxURI::Unescape(uri.GetPath()).utf8_string() == path);
|
||||
CHECK(uri.GetQuery() == "target=21&lang=de");
|
||||
CHECK(!uri.HasFragment());
|
||||
}
|
||||
#else
|
||||
TEST_CASE("A file URL of a Windows path has a drive letter and forward slashes", "[FileUrl]")
|
||||
{
|
||||
const auto [path, url] = GENERATE(table<std::wstring, std::string>({
|
||||
{ L"C:\\Program Files\\OrcaSlicer\\resources\\web\\homepage\\index.html",
|
||||
"file:///C:/Program%20Files/OrcaSlicer/resources/web/homepage/index.html" },
|
||||
{ L"D:\\#OneDrive\\OrcaSlicer\\resources\\web\\guide\\0\\index.html",
|
||||
"file:///D:/%23OneDrive/OrcaSlicer/resources/web/guide/0/index.html" },
|
||||
{ L"D:\\100%\\OrcaSlicer\\resources\\web\\homepage\\index.html",
|
||||
"file:///D:/100%25/OrcaSlicer/resources/web/homepage/index.html" },
|
||||
// Callers join the resources directory with a forward-slash relative path.
|
||||
{ L"D:\\#OneDrive\\OrcaSlicer\\resources/web/homepage/index.html",
|
||||
"file:///D:/%23OneDrive/OrcaSlicer/resources/web/homepage/index.html" },
|
||||
{ L"\\\\server\\share\\OrcaSlicer\\resources\\web\\homepage\\index.html",
|
||||
"file://server/share/OrcaSlicer/resources/web/homepage/index.html" },
|
||||
}));
|
||||
CHECK(file_url_from_path(boost::filesystem::path(path)).utf8_string() == url);
|
||||
}
|
||||
|
||||
TEST_CASE("A query appended to a Windows file URL stays separate from its path", "[FileUrl]")
|
||||
{
|
||||
const wxURI uri(file_url_from_path(boost::filesystem::path(L"D:\\#OneDrive\\OrcaSlicer\\resources\\web\\guide\\0\\index.html")) +
|
||||
"?target=21&lang=de");
|
||||
CHECK(wxURI::Unescape(uri.GetPath()) == "/D:/#OneDrive/OrcaSlicer/resources/web/guide/0/index.html");
|
||||
CHECK(uri.GetQuery() == "target=21&lang=de");
|
||||
CHECK(!uri.HasFragment());
|
||||
}
|
||||
#endif
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
#include <string>
|
||||
|
||||
#include <wx/uri.h>
|
||||
|
||||
namespace py = pybind11;
|
||||
|
||||
namespace {
|
||||
@@ -210,23 +212,33 @@ TEST_CASE("A reloaded plugin page is recognised by its base URL, fragment aside"
|
||||
{
|
||||
using namespace Slic3r::GUI::web_hosting;
|
||||
|
||||
// A resources path holding a space, which the web view reports escaped.
|
||||
// A resources path holding a space, which the web view may report escaped differently.
|
||||
const Slic3r::ScopedResourcesDir resources("web content check");
|
||||
|
||||
// The swapped-in page, then after an in-page anchor and a reload.
|
||||
CHECK(is_content_url(content_base_url()));
|
||||
CHECK(is_content_url(content_base_url() + "#tab2"));
|
||||
wxString escaped = content_base_url();
|
||||
escaped.Replace(" ", "%20");
|
||||
REQUIRE(escaped != content_base_url());
|
||||
CHECK(is_content_url(escaped));
|
||||
CHECK(is_content_url(escaped + "#tab2"));
|
||||
const wxString unescaped = wxURI::Unescape(content_base_url());
|
||||
REQUIRE(unescaped != content_base_url());
|
||||
CHECK(is_content_url(unescaped));
|
||||
CHECK(is_content_url(unescaped + "#tab2"));
|
||||
// A page the plugin linked to keeps its own URL and must be left alone.
|
||||
CHECK_FALSE(is_content_url(content_base_url() + "guide.html"));
|
||||
CHECK_FALSE(is_content_url("https://example.com/"));
|
||||
CHECK_FALSE(is_content_url(""));
|
||||
}
|
||||
|
||||
TEST_CASE("A reloaded plugin page is recognised when the resources path holds a '#'", "[PluginHost]")
|
||||
{
|
||||
using namespace Slic3r::GUI::web_hosting;
|
||||
|
||||
const Slic3r::ScopedResourcesDir resources("web#content check");
|
||||
|
||||
CHECK(is_content_url(content_base_url()));
|
||||
CHECK(is_content_url(content_base_url() + "#tab2"));
|
||||
CHECK_FALSE(is_content_url(content_base_url() + "guide.html"));
|
||||
}
|
||||
|
||||
TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHost][Python]")
|
||||
{
|
||||
using Catch::Matchers::WithinAbs;
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "libslic3r/LifecycleEvents.hpp"
|
||||
#include "slic3r/Utils/PrintHost.hpp"
|
||||
#include "slic3r/Utils/Flashforge.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
@@ -24,6 +31,41 @@ public:
|
||||
std::string get_host() const override { return {}; }
|
||||
};
|
||||
|
||||
class ThrowingPrintHost : public TestPrintHost
|
||||
{
|
||||
public:
|
||||
bool upload(PrintHostUpload, ProgressFn, ErrorFn, InfoFn) const override { throw std::runtime_error("reply could not be read"); }
|
||||
};
|
||||
|
||||
struct UploadEvents
|
||||
{
|
||||
std::vector<LifecycleEvent> events;
|
||||
std::vector<LifecycleEvtCode> codes;
|
||||
std::vector<std::string> errors;
|
||||
bool uploaded{false};
|
||||
|
||||
explicit UploadEvents(std::unique_ptr<PrintHost> host)
|
||||
{
|
||||
set_lifecycle_hook_fn([this](LifecycleEvent event, const LifecycleEventContext& ctx) {
|
||||
events.push_back(event);
|
||||
codes.push_back(ctx.code);
|
||||
});
|
||||
|
||||
PrintHostJob job;
|
||||
job.printhost = std::move(host);
|
||||
job.upload_data.source_path = "plate.gcode";
|
||||
try {
|
||||
uploaded = PrintHostJobQueue::upload_job(job, [](Http::Progress, bool&) {},
|
||||
[this](wxString error) { errors.push_back(error.ToStdString()); },
|
||||
[](wxString, wxString) {});
|
||||
} catch (...) {
|
||||
set_lifecycle_hook_fn(nullptr);
|
||||
throw;
|
||||
}
|
||||
set_lifecycle_hook_fn(nullptr);
|
||||
}
|
||||
};
|
||||
|
||||
std::string format_error(const std::string& body, const std::string& error, unsigned status)
|
||||
{
|
||||
return TestPrintHost().format_error(body, error, status).ToStdString();
|
||||
@@ -46,6 +88,14 @@ std::string moonraker_error(int code, const std::string& message, const std::str
|
||||
constexpr const char* k_busy_file_403 =
|
||||
R"JSON({"error": {"code": 403, "message": "Forbidden", "traceback": "Traceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/file_manager/file_manager.py\", line 1017, in _finish_gcode_upload\n can_start = self._handle_operation_check(check_path)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\nmoonraker.utils.exceptions.ServerError: File currently in use\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/application.py\", line 1069, in post\n raise tornado.web.HTTPError(\ntornado.web.HTTPError: HTTP 403: Forbidden (File is loaded, upload not permitted)\n"}})JSON";
|
||||
|
||||
// Replies a print host can send instead of JSON: a proxy or login page, nothing, a cut-off body.
|
||||
const std::vector<std::string> non_json_replies = {
|
||||
"<html><body>proxy login required</body></html>",
|
||||
"",
|
||||
"{\"err\":",
|
||||
"{\"detail\":{\"matlStationInfo\":{\"slotInfos\":[{\"slotId\":1,",
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("A Klipper upload error shows its reason instead of a Python traceback", "[PrintHost][Regression]")
|
||||
@@ -211,3 +261,114 @@ TEST_CASE("Error bodies that are not a Moonraker envelope are left unchanged", "
|
||||
CHECK(format_error("", "curl:Could not connect", 0) == "curl:Could not connect");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Print host error code is read from a JSON reply", "[PrintHost]")
|
||||
{
|
||||
CHECK(PrintHost::get_err_code_from_body(R"({"err":0})") == 0);
|
||||
CHECK(PrintHost::get_err_code_from_body(R"({"err":2})") == 2);
|
||||
CHECK(PrintHost::get_err_code_from_body(R"({"status":"ok"})") == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("Print host error code reports a reply that is not JSON as an error", "[PrintHost]")
|
||||
{
|
||||
const std::string body = GENERATE(from_range(non_json_replies));
|
||||
int err = 0;
|
||||
REQUIRE_NOTHROW(err = PrintHost::get_err_code_from_body(body));
|
||||
CHECK(err != 0);
|
||||
}
|
||||
|
||||
TEST_CASE("Print host error code tolerates a wrongly typed err field", "[PrintHost]")
|
||||
{
|
||||
const std::string body = GENERATE(as<std::string>{}, R"({"err":"busy"})", R"({"err":{"code":1}})", R"([1,2])");
|
||||
CHECK_NOTHROW(PrintHost::get_err_code_from_body(body));
|
||||
}
|
||||
|
||||
TEST_CASE("Flashforge material slots are read from a well-formed reply", "[PrintHost][Flashforge]")
|
||||
{
|
||||
const std::string body = R"({"code":0,"detail":{"hasMatlStation":true,"matlStationInfo":{"slotCnt":2,"slotInfos":[
|
||||
{"slotId":1,"hasFilament":true,"materialName":"PLA","materialColor":"#FFFFFF"},
|
||||
{"slotId":2,"hasFilament":false,"materialName":"","materialColor":""}]}}})";
|
||||
|
||||
std::vector<FlashforgeMaterialSlot> slots;
|
||||
bool supports_station = false;
|
||||
REQUIRE(Flashforge::parse_material_slots(body, slots, &supports_station));
|
||||
CHECK(supports_station);
|
||||
REQUIRE(slots.size() == 2);
|
||||
CHECK(slots[0].slot_id == 1);
|
||||
CHECK(slots[0].has_filament);
|
||||
CHECK(slots[0].material_name == "PLA");
|
||||
CHECK(slots[0].material_color == "#FFFFFF");
|
||||
CHECK(slots[1].slot_id == 2);
|
||||
CHECK_FALSE(slots[1].has_filament);
|
||||
}
|
||||
|
||||
TEST_CASE("Flashforge material slots accept numbers as strings and flags as numbers", "[PrintHost][Flashforge]")
|
||||
{
|
||||
const std::string body = R"({"detail":{"matlStationInfo":{"slotInfos":[
|
||||
{"slotId":"3","hasFilament":1,"materialName":null,"materialColor":7}]}}})";
|
||||
|
||||
std::vector<FlashforgeMaterialSlot> slots;
|
||||
REQUIRE_NOTHROW(Flashforge::parse_material_slots(body, slots, nullptr));
|
||||
REQUIRE(slots.size() == 1);
|
||||
CHECK(slots[0].slot_id == 3);
|
||||
CHECK(slots[0].has_filament);
|
||||
CHECK(slots[0].material_name.empty());
|
||||
CHECK(slots[0].material_color.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Flashforge material slots skip entries that are not objects", "[PrintHost][Flashforge]")
|
||||
{
|
||||
const std::string body = R"({"detail":{"matlStationInfo":{"slotInfos":[5,"slot",null,[],
|
||||
{"slotId":4,"hasFilament":true,"materialName":"PETG"}]}}})";
|
||||
|
||||
std::vector<FlashforgeMaterialSlot> slots;
|
||||
REQUIRE_NOTHROW(Flashforge::parse_material_slots(body, slots, nullptr));
|
||||
REQUIRE(slots.size() == 1);
|
||||
CHECK(slots[0].slot_id == 4);
|
||||
CHECK(slots[0].material_name == "PETG");
|
||||
}
|
||||
|
||||
TEST_CASE("Flashforge material slots tolerate slot info that is not a list", "[PrintHost][Flashforge]")
|
||||
{
|
||||
const std::string body = GENERATE(as<std::string>{},
|
||||
R"({"detail":{"matlStationInfo":{"slotInfos":5}}})",
|
||||
R"({"detail":{"matlStationInfo":{"slotInfos":"none"}}})",
|
||||
R"({"detail":{"matlStationInfo":7}})",
|
||||
R"({"detail":"offline"})");
|
||||
|
||||
std::vector<FlashforgeMaterialSlot> slots;
|
||||
bool ok = false;
|
||||
REQUIRE_NOTHROW(ok = Flashforge::parse_material_slots(body, slots, nullptr));
|
||||
CHECK(ok);
|
||||
CHECK(slots.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Flashforge material slots reject a reply that is not JSON", "[PrintHost][Flashforge]")
|
||||
{
|
||||
const std::string body = GENERATE(from_range(non_json_replies));
|
||||
std::vector<FlashforgeMaterialSlot> slots;
|
||||
bool ok = true;
|
||||
REQUIRE_NOTHROW(ok = Flashforge::parse_material_slots(body, slots, nullptr));
|
||||
CHECK_FALSE(ok);
|
||||
CHECK(slots.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("An upload that throws still finishes with an error", "[PrintHost][LifecycleEvents]")
|
||||
{
|
||||
UploadEvents run(std::make_unique<ThrowingPrintHost>());
|
||||
|
||||
CHECK_FALSE(run.uploaded);
|
||||
CHECK(run.errors == std::vector<std::string>{"reply could not be read"});
|
||||
CHECK(run.events == std::vector<LifecycleEvent>{LifecycleEvent::UploadStarted, LifecycleEvent::UploadFinished});
|
||||
CHECK(run.codes == std::vector<LifecycleEvtCode>{LifecycleEvtCode::Ok, LifecycleEvtCode::Error});
|
||||
}
|
||||
|
||||
TEST_CASE("A successful upload finishes without an error", "[PrintHost][LifecycleEvents]")
|
||||
{
|
||||
UploadEvents run(std::make_unique<TestPrintHost>());
|
||||
|
||||
CHECK(run.uploaded);
|
||||
CHECK(run.errors.empty());
|
||||
CHECK(run.events == std::vector<LifecycleEvent>{LifecycleEvent::UploadStarted, LifecycleEvent::UploadFinished});
|
||||
CHECK(run.codes == std::vector<LifecycleEvtCode>{LifecycleEvtCode::Ok, LifecycleEvtCode::Ok});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "slic3r/GUI/UserManager.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
TEST_CASE("User message that is not JSON is rejected without throwing", "[UserManager]")
|
||||
{
|
||||
const std::string payload = GENERATE(as<std::string>{}, "not json", "", "<html></html>", "{\"bind\":");
|
||||
UserManager manager;
|
||||
int result = 0;
|
||||
REQUIRE_NOTHROW(result = manager.parse_json(payload));
|
||||
CHECK(result == -1);
|
||||
}
|
||||
|
||||
TEST_CASE("User message without a successful bind is ignored", "[UserManager]")
|
||||
{
|
||||
const std::string payload = GENERATE(as<std::string>{}, "{}", R"({"bind":{"command":"unbind"}})", R"({"bind":"bind"})", "[1]");
|
||||
UserManager manager;
|
||||
CHECK(manager.parse_json(payload) == -1);
|
||||
}
|
||||
Reference in New Issue
Block a user