Files
OrcaSlicer/tests/fff_print/test_print.cpp
raistlin7447 29f31b9b38 fff_print: a maintainable testing framework (proposal + coverage) (#14426)
* fix: initialize Print::m_isBBLPrinter

Built outside the GUI/CLI (headless tests, embedded use) the member was read
uninitialized: is_BBL_printer()/wipe_tower_type() feed it into ToolOrdering,
which then non-deterministically dropped per-feature filament assignments.
Default it to false, the value the GUI and CLI already assign for non-Bambu
printers.

* docs(test): add the fff_print testing contract

tests/fff_print/README.md codifies how the suite is organized: one file per
subsystem (each owning both in-memory and emitted-G-code assertions), flat
behavioral test names with a single [Subsystem] tag, a robust-tests guide,
the shared helpers, and an add-a-test checklist. Linked from tests/CLAUDE.md.

* test(fff_print): reorganize the suite to the contract and add coverage

Bring every subsystem into one file per the README: rename the test_data
harness to test_helpers; consolidate skirt/brim; split multi-filament and
cooling into their own files; disperse the test_printgcode grab-bag and the
end-to-end smoke scenario into focused tests; fold test_gcode into
test_gcodewriter. Standardize names and tags, align cube tests on the cube()
helper, and de-qualify the flagship files.

New coverage: multi-filament per-feature and per-object routing; a skirt/brim
behavior matrix (the #14333 rework, including brim ears, with regression
coverage for #14319 and #14366); resolved extrusion-width and config
comments; custom-G-code placeholders; fan control and speed-marker
consumption.

Re-enable three slice tests previously tagged [NotWorking]: the clipper
"Coordinate outside allowed range" error that disabled them was specific to a
past CI runner environment and no longer reproduces.

* test(fff_print): tag arm64-flaky skirt/brim tests NotWorking

Four skirt/brim slice tests intermittently throw ClipperLib's "Coordinate
outside allowed range" on the macOS and Windows arm64 CI toolchains (an FP
divergence, not a slicing bug; see PR #14207). Linux x86_64 and aarch64 are
unaffected. Tag them [NotWorking] so ctest -LE NotWorking skips them.

* test(fff_print): re-enable the arm64 skirt/brim tests

These were tagged [NotWorking] as a stopgap when myfork's daily-driver build
combined them with the cross-platform CI on a base that predated upstream's
m_origin fix (99dea01cc3). With upstream merged in, Print::m_origin is
initialized and the "Coordinate outside allowed range" throw is gone, so the
tests pass on macOS/Windows arm64. Drop the tags.
2026-07-06 22:24:24 +08:00

421 lines
18 KiB
C++

#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <catch2/catch_all.hpp>
#include "libslic3r/libslic3r.h"
#include "libslic3r/Print.hpp"
#include "libslic3r/Layer.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
#include <algorithm>
#include <fstream>
#include <iterator>
using namespace Slic3r;
using namespace Slic3r::Test;
SCENARIO("Changing the number of solid shell layers does not make all surfaces internal", "[Print]") {
GIVEN("sliced 20mm cube and config with top_shell_layers = 2 and bottom_shell_layers = 1") {
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
config.set_deserialize_strict({
{ "top_shell_layers", 2 },
{ "bottom_shell_layers", 1 },
{ "layer_height", 0.25 }, // get a known number of layers
{ "initial_layer_print_height", 0.25 }
});
Slic3r::Print print;
Slic3r::Model model;
Slic3r::Test::init_print({cube(20)}, print, model, config);
// Precondition: Ensure that the model has 2 solid top layers (79, 78)
// and one solid bottom layer (0).
auto test_is_solid_infill = [&print](size_t obj_id, size_t layer_id) {
const Layer &layer = *(print.objects().at(obj_id)->get_layer((int)layer_id));
// iterate over all of the regions in the layer
for (const LayerRegion *region : layer.regions()) {
// for each region, iterate over the fill surfaces
for (const Surface &surface : region->fill_surfaces.surfaces)
CHECK(surface.is_solid());
}
};
print.process();
test_is_solid_infill(0, 0); // should be solid
test_is_solid_infill(0, 79); // should be solid
test_is_solid_infill(0, 78); // should be solid
WHEN("Model is re-sliced with top_shell_layers == 3") {
config.set("top_shell_layers", 3);
print.apply(model, config);
print.process();
THEN("Print object does not have 0 solid bottom layers.") {
test_is_solid_infill(0, 0);
}
AND_THEN("Print object has 3 top solid layers") {
test_is_solid_infill(0, 79);
test_is_solid_infill(0, 78);
test_is_solid_infill(0, 77);
}
}
}
}
// ---------------------------------------------------------------------------
// Print::validate() warning collection
//
// validate() returns its warnings in a vector. The warning paths deliberately
// differ in how many entries they produce; these tests pin down each behaviour:
// * independent checks -> stack (one entry each)
// * motion-ability -> coalesce into one (mutually exclusive, gated)
// * clumping detection -> one independent warning
// * layered clearance -> many collisions concatenated into one entry
// * null warnings pointer -> no-op, no crash, no blocking error
// ---------------------------------------------------------------------------
namespace {
// Build `n` 20mm cubes (spread apart, or stacked at the origin when `overlap`) into
// `model`/`print` and apply `config`, leaving the print ready to validate(). No slicing needed.
void build_cubes(Slic3r::Model& model, Slic3r::Print& print,
DynamicPrintConfig config, int n, bool overlap)
{
config.set_key_value("layer_change_gcode", new ConfigOptionString("G92 E0\n")); // validate() relative-E reset
for (int i = 0; i < n; ++i) {
ModelObject* object = model.add_object();
object->add_volume(cube(20));
ModelInstance* inst = object->add_instance();
inst->set_offset(Vec3d(overlap ? 0.0 : i * 60.0, 0.0, 0.0));
}
for (ModelObject* mo : model.objects) {
mo->ensure_on_bed();
print.auto_assign_extruders(mo);
}
print.apply(model, config);
}
// Build cubes and run validate(), collecting warnings; returns the blocking error.
StringObjectException validate_cubes(const DynamicPrintConfig& config,
std::vector<StringObjectException>& warnings,
int n = 1, bool overlap = false)
{
Slic3r::Model model;
Slic3r::Print print;
build_cubes(model, print, config, n, overlap);
return print.validate(&warnings);
}
size_t count_opt_key(const std::vector<StringObjectException>& warnings, const std::string& key)
{
return std::count_if(warnings.begin(), warnings.end(),
[&](const StringObjectException& w) { return w.opt_key == key; });
}
// Make `default_acceleration` exceed the machine's extruding-acceleration limit.
void trigger_acceleration_warning(DynamicPrintConfig& c)
{
c.set_key_value("machine_max_acceleration_extruding", new ConfigOptionFloats{ 100. });
c.set_key_value("default_acceleration", new ConfigOptionFloatsNullable{ 100000. });
}
// Make `default_jerk` exceed the machine's jerk limit (junction deviation off so
// the jerk check is not skipped).
void trigger_jerk_warning(DynamicPrintConfig& c)
{
c.set_key_value("machine_max_junction_deviation", new ConfigOptionFloats{ 0. });
c.set_key_value("machine_max_jerk_x", new ConfigOptionFloats{ 1. });
c.set_key_value("machine_max_jerk_y", new ConfigOptionFloats{ 1. });
c.set_key_value("default_jerk", new ConfigOptionFloatsNullable{ 9999. });
}
// Precise outer wall is ignored unless the wall sequence is inner-outer.
void trigger_precise_wall_warning(DynamicPrintConfig& c)
{
c.set_key_value("precise_outer_wall", new ConfigOptionBool(true));
c.set_key_value("wall_sequence", new ConfigOptionEnum<WallSequence>(WallSequence::OuterInner));
}
} // namespace
// ---------------------------------------------------------------------------
// {first_object_name} filename placeholder
// ---------------------------------------------------------------------------
namespace {
// Add a printable 20mm cube named `name` to `model`; returns it so the caller can tweak it.
ModelObject* add_named_cube(Model& model, const std::string& name)
{
ModelObject* obj = model.add_object();
obj->name = name;
obj->add_volume(make_cube(20.0, 20.0, 20.0));
obj->add_instance();
obj->ensure_on_bed();
return obj;
}
// Resolve `format` to an output file name for a print of `model`. `filename_base`, when set,
// is the saved-project name passed to output_filename().
std::string resolved_output_name(Model& model, const std::string& format, const std::string& filename_base = {})
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_key_value("filename_format", new ConfigOptionString(format));
Print print;
for (ModelObject* obj : model.objects)
print.auto_assign_extruders(obj);
print.apply(model, config);
return print.output_filename(filename_base);
}
} // namespace
TEST_CASE("Print: {first_object_name} names the first printable object on the plate", "[Print]")
{
Model model;
SECTION("uses the object's name") {
add_named_cube(model, "WidgetPart");
CHECK(resolved_output_name(model, "{first_object_name}") == "WidgetPart.gcode");
}
SECTION("picks the first when several objects are printable") {
add_named_cube(model, "FirstPart");
add_named_cube(model, "SecondPart");
CHECK(resolved_output_name(model, "{first_object_name}") == "FirstPart.gcode");
}
SECTION("skips objects outside the print volume (e.g. on another plate)") {
// First in model order, but not on the current plate, so is_printable() is false.
add_named_cube(model, "OtherPlatePart")->instances.front()->print_volume_state = ModelInstancePVS_Fully_Outside;
add_named_cube(model, "OnPlatePart");
CHECK(resolved_output_name(model, "{first_object_name}") == "OnPlatePart.gcode");
}
SECTION("is empty when the object has no name") {
add_named_cube(model, "");
CHECK(resolved_output_name(model, "part_{first_object_name}") == "part_.gcode");
}
}
TEST_CASE("Print: {first_object_name} is not replaced by the saved-project file name", "[Print]")
{
// Passing a saved-project file name as the filename_base must not change {first_object_name}.
Model model;
add_named_cube(model, "WidgetPart");
CHECK(resolved_output_name(model, "{first_object_name}", "SavedProject") == "WidgetPart.gcode");
}
TEST_CASE("Print::validate stacks independent warnings", "[Print][validate]")
{
// Two unrelated checks (region precise-wall + machine acceleration) must each
// contribute their own entry.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
trigger_precise_wall_warning(config);
trigger_acceleration_warning(config);
std::vector<StringObjectException> warnings;
StringObjectException err = validate_cubes(config, warnings);
CHECK(err.string.empty());
CHECK(warnings.size() >= 2);
CHECK(count_opt_key(warnings, "precise_outer_wall") == 1); // jump-to key is preserved
for (const auto& w : warnings)
CHECK(w.is_warning); // every collected entry is a warning
}
TEST_CASE("Print::validate coalesces motion-ability warnings into one", "[Print][validate]")
{
// The jerk/junction/acceleration checks are mutually exclusive (gated on a shared
// key), so adding a second motion trigger must NOT add a second warning.
DynamicPrintConfig accel_only = DynamicPrintConfig::full_print_config();
trigger_acceleration_warning(accel_only);
std::vector<StringObjectException> w_accel;
CHECK(validate_cubes(accel_only, w_accel).string.empty());
DynamicPrintConfig accel_and_jerk = DynamicPrintConfig::full_print_config();
trigger_acceleration_warning(accel_and_jerk);
trigger_jerk_warning(accel_and_jerk);
std::vector<StringObjectException> w_both;
CHECK(validate_cubes(accel_and_jerk, w_both).string.empty());
CHECK(w_accel.size() >= 1);
CHECK(w_both.size() == w_accel.size()); // the extra motion trigger collapses into the same warning
}
TEST_CASE("Print::validate reports the clumping-detection warning", "[Print][validate]")
{
// A distinct single-shot path: clumping/wrapping detection without a prime tower warns
// (and carries the enable_prime_tower jump-to key). enable_prime_tower must be off, as
// the warning lives in the no-prime-tower branch.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_key_value("enable_prime_tower", new ConfigOptionBool(false));
config.set_key_value("enable_wrapping_detection", new ConfigOptionBool(true));
std::vector<StringObjectException> warnings;
StringObjectException err = validate_cubes(config, warnings);
CHECK(err.string.empty());
CHECK(count_opt_key(warnings, "enable_prime_tower") == 1);
}
TEST_CASE("Print::validate concatenates layered-clearance collisions into one warning", "[Print][validate]")
{
// In by-layer mode, layered_print_cleareance_valid folds every too-close pair into a
// single warning entry (newline-joined), unlike the per-check stacking above. Isolate
// that entry by type so unrelated default-config warnings don't affect the assertion.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
std::vector<StringObjectException> warnings;
StringObjectException err = validate_cubes(config, warnings, /*n=*/3, /*overlap=*/true);
CHECK(err.string.empty());
auto is_layered = [](const StringObjectException& w) {
return w.type == STRING_EXCEPT_OBJECT_COLLISION_IN_LAYER_PRINT; };
REQUIRE(std::count_if(warnings.begin(), warnings.end(), is_layered) == 1); // 3 objects, 2 collisions, 1 entry
auto it = std::find_if(warnings.begin(), warnings.end(), is_layered);
CHECK(it->string.find('\n') != std::string::npos); // the collisions were concatenated
}
TEST_CASE("Print::validate tolerates a null warnings pointer", "[Print][validate]")
{
// Callers may pass no warnings sink: a warning-producing config must not crash
// and must still return without a blocking error.
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
trigger_precise_wall_warning(config);
trigger_acceleration_warning(config);
Slic3r::Model model;
Slic3r::Print print;
build_cubes(model, print, config, /*n=*/1, /*overlap=*/false);
StringObjectException err = print.validate(); // warnings == nullptr
CHECK(err.string.empty());
}
TEST_CASE("A default slice emits perimeter, infill, and skirt", "[Print]")
{
const std::string gcode = slice({ cube(20) }, {
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "z_hop", 0 } // keep recorded Z at the printed height
});
CHECK(role_passes(gcode, "perimeter") > 0);
CHECK(role_passes(gcode, "infill") > 0);
CHECK(role_passes(gcode, "skirt") > 0);
CHECK_THAT(max_z(gcode), Catch::Matchers::WithinAbs(20.0, 1e-4));
}
// The G-code carries a config-comment block describing the resolved settings. The
// per-region width lines are always present; the support and first-layer lines appear
// only when those features are configured.
TEST_CASE("G-code lists the resolved extrusion-width settings", "[Print]")
{
const std::string gcode = slice({ cube(20) }, { { "initial_layer_line_width", 0 } });
CHECK(gcode.find("; external perimeters extrusion width") != std::string::npos);
CHECK(gcode.find("; perimeters extrusion width") != std::string::npos);
CHECK(gcode.find("; infill extrusion width") != std::string::npos);
CHECK(gcode.find("; solid infill extrusion width") != std::string::npos);
CHECK(gcode.find("; top infill extrusion width") != std::string::npos);
CHECK(gcode.find("; support material extrusion width") == std::string::npos);
CHECK(gcode.find("; first layer extrusion width") == std::string::npos);
CHECK(gcode.find("; layer_height") != std::string::npos);
CHECK(gcode.find("; sparse_infill_density") != std::string::npos);
const std::string with_support = slice({ cube(20) }, {
{ "initial_layer_line_width", 0 }, { "enable_support", true }, { "raft_layers", 3 },
});
CHECK(with_support.find("; support material extrusion width") != std::string::npos);
const std::string with_first_layer = slice({ cube(20) }, { { "initial_layer_line_width", "0.5" } });
CHECK(with_first_layer.find("; first layer extrusion width") != std::string::npos);
}
// Custom G-code templates substitute placeholders during export.
TEST_CASE("Custom G-code placeholders are substituted", "[Print]")
{
// [current_extruder] in the start G-code.
CHECK(slice({ cube(20) }, { { "machine_start_gcode", "; Extruder [current_extruder]" } })
.find("; Extruder 0") != std::string::npos);
// [layer_num] / [layer_z] in the end G-code (a 20mm cube at 0.1mm is 200 layers).
const std::string end_gcode = slice({ cube(20) }, {
{ "machine_end_gcode", "; Layer_num [layer_num]\n; Layer_z [layer_z]" },
{ "layer_height", 0.1 },
{ "initial_layer_print_height", 0.1 },
});
CHECK(end_gcode.find("; Layer_num 199") != std::string::npos);
CHECK(end_gcode.find("; Layer_z 20") != std::string::npos);
// printing_by_object_gcode is emitted between sequentially printed objects.
CHECK(slice_two_cubes_arranged({
{ "print_sequence", "by object" },
{ "printing_by_object_gcode", "; between-object-gcode" },
})
.find("; between-object-gcode") != std::string::npos);
// [layer_num] keeps counting across sequentially printed objects (199 then 399).
const std::string per_layer = slice_two_cubes_arranged({
{ "print_sequence", "by object" },
{ "layer_change_gcode", ";Layer:[layer_num] ([layer_z] mm)" },
{ "layer_height", 0.1 },
{ "initial_layer_print_height", 0.1 },
});
CHECK(per_layer.find(";Layer:199 ") != std::string::npos);
CHECK(per_layer.find(";Layer:399 ") != std::string::npos);
}
TEST_CASE("export_gcode writes G-code without a result pointer", "[Print][export_gcode]")
{
Print print;
Model model;
Slic3r::Test::init_print({cube(20)}, print, model);
print.process();
SECTION("non-BBL printer") {}
SECTION("BBL printer") { print.is_BBL_printer() = true; }
ScopedTemporaryFile temp(".gcode");
REQUIRE_NOTHROW(print.export_gcode(temp.string(), nullptr, nullptr));
std::ifstream in(temp.string());
const std::string gcode((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
REQUIRE_FALSE(gcode.empty());
}
TEST_CASE("Sequential printing follows model order", "[Print]")
{
// Two objects of different heights, taller one added first. Orca prints
// sequential objects in model order, so the taller one is printed first.
const std::string gcode = Slic3r::Test::slice({ cube(20), Slic3r::make_cube(20, 20, 10) }, {
{ "print_sequence", "by object" },
{ "layer_height", 0.2 },
{ "initial_layer_print_height", 0.2 },
{ "z_hop", 0 }
});
// The first object's height is the peak Z reached before Z drops back to the
// first layer (the object change). With by-object printing only an object
// change returns Z to the bottom.
double first_object_peak_z = 0.0;
double running_peak = 0.0;
GCodeReader reader;
reader.parse_buffer(gcode, [&] (GCodeReader& self, const GCodeReader::GCodeLine& line) {
if (first_object_peak_z != 0.0 || !line.extruding(self)) return; // ignore travels (e.g. start-gcode Z lift)
if (running_peak > 1.0 && self.z() < 1.0)
first_object_peak_z = running_peak;
else
running_peak = std::max(running_peak, static_cast<double>(self.z()));
});
REQUIRE_THAT(first_object_peak_z, Catch::Matchers::WithinAbs(20.0, 0.3));
}