From 32d92a55d365a51621d35d03f076e319faba04dc Mon Sep 17 00:00:00 2001 From: Hanif Koh Date: Mon, 19 Oct 2026 17:02:49 +0800 Subject: [PATCH] Make Unit Tests That Could Never Fail Check Real Behavior Rework tests whose assertions held whatever the production code did: inverted or self-referential checks, checks on values the test had just set, branches that never ran, symmetric fixtures that hid the behavior under test, font paths that skipped silently, and Voronoi reproducers that only exercised boost. Each now asserts the behavior its name describes. --- tests/fff_print/test_cooling.cpp | 45 +++- tests/fff_print/test_fill.cpp | 19 +- tests/fff_print/test_print.cpp | 53 +++-- tests/fff_print/test_trianglemesh.cpp | 30 ++- .../filament_group_regression_main.cpp | 11 +- tests/libslic3r/test_3mf.cpp | 59 +++-- tests/libslic3r/test_caddocument.cpp | 203 ++++++++++-------- tests/libslic3r/test_config.cpp | 17 +- tests/libslic3r/test_filament_mixer.cpp | 28 +-- .../libslic3r/test_preset_bundle_loading.cpp | 29 ++- tests/libslic3r/test_sketchimport.cpp | 16 +- tests/libslic3r/test_sketchinference.cpp | 5 +- tests/libslic3r/test_sketchprofile.cpp | 9 +- .../test_toolordering_nozzle_group.cpp | 17 +- tests/libslic3r/test_voronoi.cpp | 96 ++++++--- .../test_plugin_capability_config.cpp | 117 +++++++--- .../test_plugin_cloud_metadata.cpp | 4 +- .../test_slicing_pipeline_bindings.cpp | 13 +- 18 files changed, 486 insertions(+), 285 deletions(-) diff --git a/tests/fff_print/test_cooling.cpp b/tests/fff_print/test_cooling.cpp index 1645d99a75..be8c29d2e3 100644 --- a/tests/fff_print/test_cooling.cpp +++ b/tests/fff_print/test_cooling.cpp @@ -1,6 +1,7 @@ #include #include +#include "catch2/catch_message.hpp" #include "test_helpers.hpp" #include @@ -17,15 +18,49 @@ using namespace Slic3r; using namespace Slic3r::Test; -// The fan is held off for the first close_fan_the_first_x_layers layers, so an explicit -// fan-off command is emitted. +// The fan stays off while printing the first close_fan_the_first_x_layers layers and runs afterwards. TEST_CASE("Fan is held off for the initial layers", "[Cooling]") { + const int fan_off_layers = 5; const std::string gcode = slice({ cube(20) }, { - { "cooling", true }, - { "close_fan_the_first_x_layers", 5 }, + { "close_fan_the_first_x_layers", fan_off_layers }, + { "full_fan_speed_layer", 0 }, + { "fan_min_speed", 50 }, + { "fan_max_speed", 100 }, + { "fan_cooling_layer_time", 1000 }, // every layer is short enough to run the fan + { "layer_height", 0.2 }, + { "initial_layer_print_height", 0.2 }, + { "layer_change_gcode", ";TEST_LAYER_CHANGE" }, }); - CHECK(gcode.find("M106 S0") != std::string::npos); + + // Record, for each layer, whether any extrusion was printed with the part fan running. + std::vector extruded_with_fan; + int layer = -1; + bool fan_on = false; + std::istringstream input(gcode); + std::string line; + while (std::getline(input, line)) { + if (line.rfind(";TEST_LAYER_CHANGE", 0) == 0) { + ++layer; + extruded_with_fan.push_back(false); + } else if (line.rfind("M107", 0) == 0) { + fan_on = false; + } else if (line.rfind("M106 ", 0) == 0 && line.find(" P") == std::string::npos) { + const size_t s = line.find(" S"); + fan_on = s != std::string::npos && std::stod(line.substr(s + 2)) > 0.; + } else if (layer >= 0 && fan_on && line.rfind("G1 ", 0) == 0 && line.find(" E") != std::string::npos && + (line.find(" X") != std::string::npos || line.find(" Y") != std::string::npos)) { + extruded_with_fan[layer] = true; + } + } + + // A 20 mm cube at 0.2 mm layers has 100 layers. + REQUIRE(extruded_with_fan.size() == 100); + for (int i = 0; i < fan_off_layers; ++i) { + CAPTURE(i); + CHECK_FALSE(extruded_with_fan[i]); + } + CHECK(extruded_with_fan[fan_off_layers]); } // The cooling pass resolves and strips its internal speed placeholders; none leak into diff --git a/tests/fff_print/test_fill.cpp b/tests/fff_print/test_fill.cpp index 1fbedcda8f..160751e1db 100644 --- a/tests/fff_print/test_fill.cpp +++ b/tests/fff_print/test_fill.cpp @@ -86,10 +86,9 @@ TEST_CASE("Pattern path length", "[Fill]") { Slic3r::Polylines paths = test(Slic3r::ExPolygon(test_set)); REQUIRE(paths.size() == 1); // one continuous path - // TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon. - // This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon. - // ok abs($paths->[0]->length - scale(3*100 + 2*50)) - scaled_epsilon, 'path has expected length'; - REQUIRE(std::abs(paths[0].length() - static_cast(scale_(3*100 + 2*50))) - SCALED_EPSILON > 0); // path has expected length + // Lines are 50 mm apart and the square is inset by half a spacing, so the lines on its edges drop out + // and a single line crosses the middle, ending within half a spacing of the edges. + CHECK_THAT(unscale(paths[0].length()), Catch::Matchers::WithinAbs(100., filler->spacing / 2.)); test_set.clear(); } @@ -148,10 +147,9 @@ TEST_CASE("Pattern path length", "[Fill]") { Slic3r::Polylines paths = test(Slic3r::ExPolygon(points)); REQUIRE(paths.size() == 1); // one continuous path - // TODO: determine what the "Expected length" should be for rectilinear fill of a 100x100 polygon. - // This check only checks that it's above scale(3*100 + 2*50) + scaled_epsilon. - // ok abs($paths->[0]->length - scale(3*100 + 2*50)) - scaled_epsilon, 'path has expected length'; - REQUIRE(std::abs(paths[0].length() - static_cast(scale_(3*100 + 2*50))) - SCALED_EPSILON > 0); // path has expected length + // A solid fill covers the area with lines one spacing apart, so a missing segment shortens the path. + const double expected_length = unscale(unscale(Slic3r::ExPolygon(points).area())) / filler->spacing; + CHECK_THAT(unscale(paths[0].length()), Catch::Matchers::WithinRel(expected_length, 0.01)); } SECTION("Rotated Square") { @@ -168,8 +166,9 @@ TEST_CASE("Pattern path length", "[Fill]") { fill_params.density = 1.0; filler->spacing = flow.spacing(); - for (auto angle : { 0.0, 45.0}) { + for (auto angle : { 0.0, PI / 4.0 }) { surface.expolygon.rotate(angle, Point(0,0)); + filler->bounding_box = get_extents(surface.expolygon.contour); Polylines paths = filler->fill_surface(&surface, fill_params); REQUIRE(paths.size() == 1); } @@ -222,7 +221,7 @@ TEST_CASE("Pattern path length", "[Fill]") { }; Slic3r::ExPolygon expolygon(points); - REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, 45.0, 0.99) == true); + REQUIRE(test_if_solid_surface_filled(expolygon, 0.5, PI / 4.0, 0.99) == true); } } diff --git a/tests/fff_print/test_print.cpp b/tests/fff_print/test_print.cpp index 670c2aa7c1..55f82894c1 100644 --- a/tests/fff_print/test_print.cpp +++ b/tests/fff_print/test_print.cpp @@ -123,38 +123,49 @@ SCENARIO("Changing the number of solid shell layers does not make all surfaces i config.set_deserialize_strict({ { "top_shell_layers", 2 }, { "bottom_shell_layers", 1 }, - { "layer_height", 0.25 }, // get a known number of layers + // Shell counts alone decide which layers are solid. + { "top_shell_thickness", 0 }, + { "bottom_shell_thickness", 0 }, + { "sparse_infill_density", "20%" }, + { "layer_height", 0.25 }, // 80 layers: 0..79 { "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) { + // True when the layer has fill surfaces and all of them are solid. + auto 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()); - } + bool has_fill = false; + for (const LayerRegion *region : layer.regions()) + for (const Surface &surface : region->fill_surfaces.surfaces) { + if (! surface.is_solid()) + return false; + has_fill = true; + } + return has_fill; }; 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 + // Precondition: 2 solid top layers (79, 78) and one solid bottom layer (0). + REQUIRE(is_solid_infill(0, 0)); + REQUIRE(is_solid_infill(0, 79)); + REQUIRE(is_solid_infill(0, 78)); + REQUIRE_FALSE(is_solid_infill(0, 77)); 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); + CHECK(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); + CHECK(is_solid_infill(0, 79)); + CHECK(is_solid_infill(0, 78)); + CHECK(is_solid_infill(0, 77)); + CHECK_FALSE(is_solid_infill(0, 76)); + } + AND_THEN("Middle layers keep sparse infill") { + CHECK_FALSE(is_solid_infill(0, 40)); } } } @@ -672,14 +683,18 @@ TEST_CASE("gcode_skip_config_block omits the resolved-settings comment block", " { const std::string gcode = slice({ cube(20) }, { { "gcode_skip_config_block", true }, - { "gcode_comments", true }, }); CHECK(gcode.find("; CONFIG_BLOCK_START") == std::string::npos); CHECK(gcode.find("; CONFIG_BLOCK_END") == std::string::npos); CHECK(gcode.find("; layer_height =") == std::string::npos); - CHECK(gcode.find("; fill_density =") == std::string::npos); + CHECK(gcode.find("; sparse_infill_density =") == std::string::npos); CHECK(gcode.find("; HEADER_BLOCK_START") != std::string::npos); CHECK(gcode.find("; EXECUTABLE_BLOCK_START") != std::string::npos); + + const std::string with_block = slice({ cube(20) }, { { "gcode_skip_config_block", false } }); + CHECK(with_block.find("; CONFIG_BLOCK_START") != std::string::npos); + CHECK(with_block.find("; layer_height =") != std::string::npos); + CHECK(with_block.find("; sparse_infill_density =") != std::string::npos); } // Some firmwares only scan the last N lines of the file for "estimated printing time", so it diff --git a/tests/fff_print/test_trianglemesh.cpp b/tests/fff_print/test_trianglemesh.cpp index ab5c334d2e..e43185d2f6 100644 --- a/tests/fff_print/test_trianglemesh.cpp +++ b/tests/fff_print/test_trianglemesh.cpp @@ -1,6 +1,8 @@ #include #include +#include +#include "catch2/matchers/catch_matchers.hpp" #include "libslic3r/TriangleMesh.hpp" #include "libslic3r/TriangleMeshSlicer.hpp" #include "libslic3r/Point.hpp" @@ -162,21 +164,29 @@ SCENARIO("Slice behavior", "[TriangleMesh]") { const std::vector vertices {{0,0,0},{0,0,20},{0,5,0},{0,5,20},{50,0,0},{50,0,20},{15,5,0},{35,5,0},{15,20,0},{50,5,0},{35,20,0},{15,5,10},{50,5,20},{35,5,10},{35,20,10},{15,20,10}}; const std::vector facets {{0,1,2},{2,1,3},{1,0,4},{5,1,4},{0,2,4},{4,2,6},{7,6,8},{4,6,7},{9,4,7},{7,8,10},{2,3,6},{11,3,12},{7,12,9},{13,12,7},{6,3,11},{11,12,13},{3,1,5},{12,3,5},{5,4,9},{12,5,9},{13,7,10},{14,13,10},{8,15,10},{10,15,14},{6,11,8},{8,11,15},{15,11,13},{14,15,13}}; - auto cube = make_cube(); + TriangleMesh mesh(vertices, facets); + // A 50x5x20 wall with a 20x15x10 block against it, so the block's top is at Z = 10. + const double wall_area = 50. * 5.; + const double wall_block_area = wall_area + 20. * 15.; + const auto area_mm2 = [](const ExPolygons &slice) { return slice.at(0).area() * SCALING_FACTOR * SCALING_FACTOR; }; WHEN(" a top tangent plane is sliced") { - // At Z = 10 we have a top horizontal surface. - std::vector slices = cube.slice({5.0, 10.0}); + std::vector slices = mesh.slice({5.0, 10.0}); THEN( "its area is included") { - REQUIRE(slices.at(0).at(0).area() > 0); - REQUIRE(slices.at(1).at(0).area() > 0); + REQUIRE(slices.at(0).size() == 1); + REQUIRE(slices.at(1).size() == 1); + CHECK_THAT(area_mm2(slices.at(0)), Catch::Matchers::WithinRel(wall_block_area, 1e-6)); + CHECK_THAT(area_mm2(slices.at(1)), Catch::Matchers::WithinRel(wall_block_area, 1e-6)); } } WHEN(" a model that has been transformed is sliced") { - cube.mirror_z(); - std::vector slices = cube.slice({-5.0, -10.0}); - THEN( "it is sliced properly (mirrored bottom plane area is included)") { - REQUIRE(slices.at(0).at(0).area() > 0); - REQUIRE(slices.at(1).at(0).area() > 0); + mesh.mirror_z(); + std::vector slices = mesh.slice({-5.0, -10.0}); + THEN( "it is sliced like the original, and the mirrored block's bottom plane is not part of it") { + REQUIRE(slices.at(0).size() == 1); + REQUIRE(slices.at(1).size() == 1); + CHECK_THAT(area_mm2(slices.at(0)), Catch::Matchers::WithinRel(wall_block_area, 1e-6)); + // A triangle owns its top edge but not its bottom edge, so a bottom tangent plane is left out. + CHECK_THAT(area_mm2(slices.at(1)), Catch::Matchers::WithinRel(wall_area, 1e-6)); } } } diff --git a/tests/filament_group/filament_group_regression_main.cpp b/tests/filament_group/filament_group_regression_main.cpp index ec3264ba2c..ff39c7639e 100644 --- a/tests/filament_group/filament_group_regression_main.cpp +++ b/tests/filament_group/filament_group_regression_main.cpp @@ -319,9 +319,18 @@ TEST_CASE("FilamentGroup property checks", "[filament_group][property]") { // build (config_b/config_c cases evaluate the full per-layer nozzle-aware reorder for every // candidate grouping; this is a guard against hangs, not a micro-perf gate). REQUIRE(result.elapsed_ms < 40000.0); - REQUIRE(result.flush_cost >= 0); + // Every case, feasible or not, must yield a usable grouping: each used filament is + // assigned to an existing nozzle. auto used_filaments = collect_sorted_used_filaments(tc.context.model_info.layer_filaments); + const auto &nozzle_list = tc.context.nozzle_info.nozzle_list; + for (unsigned int fil : used_filaments) { + INFO("Filament: " << fil); + REQUIRE(fil < result.filament_map.size()); + CHECK(result.filament_map[fil] >= 0); + CHECK(result.filament_map[fil] < static_cast(nozzle_list.size())); + } + if (is_constraint_feasible(tc.context, used_filaments)) { if (!result.constraints_ok) { for (auto& v : result.violations) diff --git a/tests/libslic3r/test_3mf.cpp b/tests/libslic3r/test_3mf.cpp index f7b5049ebe..a02b07564b 100644 --- a/tests/libslic3r/test_3mf.cpp +++ b/tests/libslic3r/test_3mf.cpp @@ -1033,18 +1033,19 @@ SCENARIO("Non-standard nozzle diameter survives .3mf save on a single-nozzle pri } } -// A legacy / foreign project (no multi-nozzle metadata) must load crash-safe through the BBS -// importer and must not fabricate a filament_volume_map. -SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle]") { - GIVEN("a project without any multi-nozzle metadata") { - std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf"; - Model model; - DynamicPrintConfig config; - ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; - PlateDataPtrs plates; - std::vector project_presets; - bool is_bbl_3mf = false, is_orca_3mf = false; - Semver file_version; +// A project without multi-nozzle metadata must load through the BBS importer and must not +// fabricate a filament_volume_map. +SCENARIO("Projects without multi-nozzle metadata load without a per-filament volume map", "[3mf][MultiNozzle]") { + ConfigSubstitutionContext ctxt{ ForwardCompatibilitySubstitutionRule::Enable }; + std::vector project_presets; + bool is_bbl_3mf = false, is_orca_3mf = false; + Semver file_version; + + GIVEN("a legacy project with a bare model and no plate metadata") { + std::string path = std::string(TEST_DATA_DIR) + "/test_3mf/Geräte/Büchse.3mf"; + Model model; + DynamicPrintConfig config; + PlateDataPtrs plates; WHEN("loaded through the BBS importer") { bool loaded = false; @@ -1052,10 +1053,32 @@ SCENARIO("Legacy project loads crash-safe via load_bbs_3mf", "[3mf][MultiNozzle] &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, nullptr, LoadStrategy::LoadModel | LoadStrategy::LoadConfig)); - THEN("it does not crash and invents no per-filament volume map") { - for (PlateData* p : plates) { - REQUIRE(p->config.option("filament_volume_map") == nullptr); - } + THEN("the model loads") { + REQUIRE(loaded); + REQUIRE_FALSE(model.objects.empty()); + } + release_PlateData_list(plates); + } + } + + GIVEN("a project with plate metadata but no filament volume map") { + ScopedTemporaryFile temp(".3mf"); + store_painted_cube(temp.string()); + ScopedTemporaryDir backup_dir("orca_volume_map_dst"); + Model model; + model.set_backup_path(backup_dir.string()); + DynamicPrintConfig config; + PlateDataPtrs plates; + + WHEN("loaded through the BBS importer") { + const bool loaded = load_bbs_3mf(temp.string().c_str(), &config, &ctxt, &model, &plates, + &project_presets, &is_bbl_3mf, &is_orca_3mf, &file_version, + nullptr, LoadStrategy::LoadModel | LoadStrategy::LoadConfig); + THEN("every plate loads without a per-filament volume map") { + REQUIRE(loaded); + REQUIRE_FALSE(plates.empty()); + for (PlateData* p : plates) + CHECK(p->config.option("filament_volume_map") == nullptr); } release_PlateData_list(plates); } @@ -2026,8 +2049,8 @@ SCENARIO("Published 3MF round-trips the extended material metadata", "[3mf]") { REQUIRE(dst_model.model_info != nullptr); REQUIRE(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG] == material_keys_json); - // The value must parse back with every extended field intact. - nlohmann::json entries = nlohmann::json::parse(material_keys_json); + // The loaded value must parse back with every extended field intact. + nlohmann::json entries = nlohmann::json::parse(dst_model.model_info->metadata_items[ORCA_PUBLISHED_MATERIAL_TAG]); REQUIRE(entries.is_array()); REQUIRE(entries.size() == 1); REQUIRE(entries[0]["full"].get() == true); diff --git a/tests/libslic3r/test_caddocument.cpp b/tests/libslic3r/test_caddocument.cpp index 4ee1de587d..28133550b8 100644 --- a/tests/libslic3r/test_caddocument.cpp +++ b/tests/libslic3r/test_caddocument.cpp @@ -53,6 +53,7 @@ #include #include #include +#include #include #include #include @@ -772,16 +773,9 @@ TEST_CASE("entity constraints: tangent/midpoint/symmetric/angle", "[CadDocument] TEST_CASE("imported text glyphs all extrude without failing (charset sweep)", "[CadDocument]") { - std::string font = resources_dir().empty() - ? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf") - : resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf"; - { - std::ifstream probe(font); - if (!probe.good()) { - SUCCEED("bundled font not reachable in this environment; covered live on :10"); - return; - } - } + // The bundled font, from the source tree: PROFILES_DIR is /resources/profiles. + const std::string font = std::string(PROFILES_DIR) + "/../fonts/HarmonyOS_Sans_SC_Regular.ttf"; + REQUIRE(std::ifstream(font).good()); const std::string charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" @@ -2853,9 +2847,10 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal using Catch::Matchers::WithinAbs; CadDocument doc; - // Build a body so we have a face to reference for FaceAndDirection. - int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 10, "Box"); - doc.add_extrude(sk, 10.0, false, BooleanMode::New, "BoxExt"); + // A cylinder's end cap is bounded by one closed circular edge, which offers no in-plane + // direction, so the frame has to fall back on coordsys_x_hint. + int sk = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 5.0, "Circle"); + doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Cyl"); REQUIRE(doc.recompute()); REQUIRE(doc.bodies.size() == 1); int n_faces = GeometryEngine::face_count(doc.bodies[0].shape); @@ -2865,34 +2860,33 @@ TEST_CASE("datum coordinate system: non-perpendicular inputs produce orthonormal if (fn.z() > 0.9) { top_face = i; break; } } REQUIRE(top_face >= 0); + const Vec3d Z = GeometryEngine::face_normal_world(GeometryEngine::face_by_index(doc.bodies[0].shape, top_face)); int cs = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS1"); REQUIRE(cs >= 0); doc.features[cs].coordsys_type = CoordSysType::FaceAndDirection; doc.features[cs].coordsys_body = 0; doc.features[cs].coordsys_face = top_face; - // Deliberately non-perpendicular X hint (NOT orthogonal to face normal ~+Z). + // Deliberately non-perpendicular X hint (NOT orthogonal to the face normal +Z). doc.features[cs].coordsys_x_hint = Vec3d(3.0, -1.0, 0.5); auto css = doc.resolve_datum_coordsys(); REQUIRE(css.size() == 1); REQUIRE(css[0].error.empty()); - Vec3d X = css[0].x, Y = css[0].y; - // Orthonormality: each axis has unit length + const Vec3d X = css[0].x, Y = css[0].y; + // X is the hint projected into the face plane. + const Vec3d expected_x = Vec3d(3.0, -1.0, 0.0).normalized(); + CHECK_THAT(X.x(), WithinAbs(expected_x.x(), 1e-9)); + CHECK_THAT(X.y(), WithinAbs(expected_x.y(), 1e-9)); + CHECK_THAT(X.z(), WithinAbs(expected_x.z(), 1e-9)); + // Orthonormal, with Z the face normal and the frame right-handed. CHECK_THAT(X.norm(), WithinAbs(1.0, 1e-9)); CHECK_THAT(Y.norm(), WithinAbs(1.0, 1e-9)); - // Pairwise dot products are ~0 - CHECK_THAT(std::abs(X.dot(Y)), WithinAbs(0.0, 1e-9)); - // Z = X x Y (derived), also unit and perpendicular - Vec3d Z = X.cross(Y); - CHECK_THAT(Z.norm(), WithinAbs(1.0, 1e-9)); - CHECK_THAT(std::abs(X.dot(Z)), WithinAbs(0.0, 1e-9)); - CHECK_THAT(std::abs(Y.dot(Z)), WithinAbs(0.0, 1e-9)); - // Right-handedness: X x Y == Z - CHECK_THAT(Z.x(), WithinAbs((X.cross(Y)).x(), 1e-9)); - CHECK_THAT(Z.y(), WithinAbs((X.cross(Y)).y(), 1e-9)); - CHECK_THAT(Z.z(), WithinAbs((X.cross(Y)).z(), 1e-9)); + CHECK_THAT(X.dot(Y), WithinAbs(0.0, 1e-9)); + CHECK_THAT(X.dot(Z), WithinAbs(0.0, 1e-9)); + CHECK_THAT(Y.dot(Z), WithinAbs(0.0, 1e-9)); + CHECK_THAT(X.cross(Y).dot(Z), WithinAbs(1.0, 1e-9)); } TEST_CASE("datum coordinate system: a face-only frame rotates with its body", "[CadDocument]") @@ -3917,17 +3911,23 @@ TEST_CASE("Use/Convert Entities: construction references never become solid", "[ } REQUIRE(top_face >= 0); - const size_t body_count_before = doc.bodies.size(); - const double volume_before = doc.body_mass_properties(0).volume; - int target = doc.add_sketch_entities({}, SketchPlane::XY(), "Use"); int appended = doc.project_edges_into_sketch(target, 0, {}, top_face); REQUIRE(appended == 4); + doc.add_extrude(target, 5.0, false, BooleanMode::New, "UseExt"); - REQUIRE(doc.recompute()); - REQUIRE(doc.error.empty()); - REQUIRE(doc.bodies.size() == body_count_before); - REQUIRE_THAT(doc.body_mass_properties(0).volume, WithinRel(volume_before, 1e-9)); + SECTION("construction edges form no profile, so the extrude fails") { + REQUIRE_FALSE(doc.recompute()); + REQUIRE_FALSE(doc.error.empty()); + } + SECTION("the same edges as solid geometry extrude a 20x20x5 body") { + for (auto& e : doc.features[target].entities) + e.construction = false; + REQUIRE(doc.recompute()); + REQUIRE(doc.error.empty()); + REQUIRE(doc.bodies.size() == 2); + REQUIRE_THAT(doc.body_mass_properties(1).volume, WithinRel(20.0 * 20.0 * 5.0, 1e-6)); + } } TEST_CASE("Use/Convert Entities: bad references return -1 and change nothing", "[CadDocument][project]") @@ -4252,7 +4252,7 @@ TEST_CASE("a previous-format recipe is refused, not silently misread", "[CadDocu REQUIRE(doc.features.empty()); // nothing half-read was left behind } -TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]") +TEST_CASE("a v4 recipe keeps every field value", "[CadDocument]") { using Catch::Matchers::WithinRel; using Catch::Matchers::WithinAbs; @@ -4282,20 +4282,16 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]") } CadDocument expected = make_golden_doc_v1(); - // Scoped tightly: this advice is ONLY valid for a count mismatch. It must not be in - // scope for the field-value assertions below, where "regenerate the fixture" is the - // one thing you must never do -- regenerating after a reorder bakes the corrupted - // layout in as the new golden and permanently disarms this test. { - INFO("Feature count changed - did you add/remove features in make_golden_doc_v1()?"); - INFO("If so: run libslic3r_tests \"[.regen]\" and re-run this test."); + INFO("Feature count changed - make_golden_doc_v1() must keep building exactly the tree " + "frozen in cad_recipe_v4.bin, so do not add or remove features there."); REQUIRE(features.size() == expected.features.size()); } // A failure BELOW this point means the on-disk serialization format changed: some field // in CadFeature::save/load was reordered, retyped, or removed. Fields may only ever be // APPENDED at the end of both lists. Do NOT regenerate the fixture to make this pass -- - // fix the field order instead. See scripts/CAD/run-kernel-tests.sh and the [.regen] case. + // fix the field order instead. // Field-by-field assertions against expected values. // Every field set to a distinctive non-default literal must be checked here. @@ -4550,35 +4546,47 @@ TEST_CASE("golden recipe v1 still deserialises", "[CadDocument]") } } - // --- Layer 2: geometry check (optional — only if the document recomputes) --- - // Build expected document and try to recompute it. - // ponytail: the field-value layer above is the real tripwire; - // this layer is a bonus sanity check on the full recompute path. + // --- Layer 2: the production v4 load rebuilds the same geometry as the in-memory tree --- + // The golden tree is serialization coverage and does not recompute as a whole, so the + // features are switched on one at a time and any the in-memory tree cannot build are left + // off in both. Every step that builds must give the loaded tree the same bodies. CadDocument exp_doc = make_golden_doc_v1(); - bool exp_ok = exp_doc.recompute(); - if (!exp_ok) { - INFO("Expected document from make_golden_doc_v1() could not recompute " - "(complex feature tree). Field-value checks above are sufficient."); + // Fields appended after v4 are not in the file, so a v4 project reads them as defaults + // (a v4 thread, for one, is not major-nominal). The reference has to match that. + const CadFeature def; + for (CadFeature& f : exp_doc.features) { + f.thread_major_nominal = def.thread_major_nominal; + f.pattern_inclusive = def.pattern_inclusive; + f.dressup_edges = def.dressup_edges; + f.text_string = def.text_string; + f.text_font = def.text_font; + f.text_height = def.text_height; + f.revolve_axis_entity = def.revolve_axis_entity; } - CadDocument doc; - bool ser_ok = doc.deserialize_recipe(blob); - - if (exp_ok && ser_ok) { - REQUIRE(doc.error.empty()); + doc.deserialize_recipe(blob); + REQUIRE(doc.features.size() == exp_doc.features.size()); + for (CadFeature& f : exp_doc.features) f.enabled = false; + for (CadFeature& f : doc.features) f.enabled = false; + size_t built_steps = 0; + for (size_t i = 0; i < exp_doc.features.size(); ++i) { + exp_doc.features[i].enabled = doc.features[i].enabled = true; + if (!exp_doc.recompute()) { + exp_doc.features[i].enabled = doc.features[i].enabled = false; + continue; + } + INFO("after feature " << i << " (" << exp_doc.features[i].name << ")"); + REQUIRE(doc.recompute()); REQUIRE(doc.bodies.size() == exp_doc.bodies.size()); - for (size_t i = 0; i < doc.bodies.size(); ++i) { - double v = double(SketchEngine::tessellate(doc.bodies[i].shape).volume()); - double ev = double(SketchEngine::tessellate(exp_doc.bodies[i].shape).volume()); + for (size_t b = 0; b < doc.bodies.size(); ++b) { + double v = double(SketchEngine::tessellate(doc.bodies[b].shape).volume()); + double ev = double(SketchEngine::tessellate(exp_doc.bodies[b].shape).volume()); REQUIRE_THAT(v, WithinRel(ev, 1e-6)); } - } else { - INFO("The golden recipe fixture was loaded and field-value checks passed."); - INFO("Recompute on the deserialized or expected document failed — this is"); - INFO("expected for the extended golden fixture (many feature types coexist"); - INFO("purely for serialization coverage). The field-value tripwire above is"); - INFO("the primary format check."); + ++built_steps; } + CHECK(built_steps > 0); + CHECK_FALSE(exp_doc.bodies.empty()); } // --- Rib tests (M5b) --- @@ -5781,8 +5789,9 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]") REQUIRE(doc.recompute()); REQUIRE(doc.error.empty()); - int sk2 = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(), 0, 0, 3, "Cyl"); - doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "CylExt"); + // A 20x10 plate, so a quarter turn about the connector's Z swaps its X and Y extents. + int sk2 = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 10, 0, "Plate"); + doc.add_extrude(sk2, 10.0, false, BooleanMode::New, "PlateExt"); REQUIRE(doc.recompute()); REQUIRE(doc.error.empty()); @@ -5801,6 +5810,13 @@ TEST_CASE("fastened mate with angle", "[CadDocument][mate]") gp_Pnt com = props.CentreOfMass(); REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4)); REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4)); + + Bnd_Box bb; + BRepBndLib::Add(doc.bodies[1].shape, bb); + double x0, y0, z0, x1, y1, z1; + bb.Get(x0, y0, z0, x1, y1, z1); + REQUIRE_THAT(x1 - x0, WithinAbs(10.0, 1e-3)); + REQUIRE_THAT(y1 - y0, WithinAbs(20.0, 1e-3)); } TEST_CASE("fastened mate with flip", "[CadDocument][mate]") @@ -5823,7 +5839,9 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]") REQUIRE(doc.recompute()); REQUIRE(doc.error.empty()); - int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 5), "CS_Moving"); + // The connector sits on the cylinder's base, so the flip decides which side of the + // fixed connector (z = 5) the 10 mm cylinder hangs: above it unflipped, below it flipped. + int cs_moving = doc.add_coordsys(CoordSysType::PointWorld, Vec3d(0, 0, 0), "CS_Moving"); doc.features[cs_moving].coordsys_body = 1; REQUIRE(doc.recompute()); REQUIRE(doc.error.empty()); @@ -5837,8 +5855,7 @@ TEST_CASE("fastened mate with flip", "[CadDocument][mate]") gp_Pnt com = props.CentreOfMass(); REQUIRE_THAT(double(com.X()), WithinAbs(5.0, 1e-4)); REQUIRE_THAT(double(com.Y()), WithinAbs(5.0, 1e-4)); - // Flip opposes Z axes: for a symmetric cylinder this is invisible in centroid, - // but the mate executed cleanly and the body moved to the target connector. + REQUIRE_THAT(double(com.Z()), WithinAbs(5.0 - 10.0 / 2, 1e-4)); } TEST_CASE("planar mate: normal distance becomes mate_offset", "[CadDocument][mate]") @@ -6525,23 +6542,19 @@ TEST_CASE("fastened mate with flip on asymmetric body", "[CadDocument][mate]") REQUIRE(doc.recompute()); REQUIRE(doc.error.empty()); - // Fastened, NO flip - doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, false, "MateNoFlip"); - REQUIRE(doc.recompute()); - REQUIRE(doc.error.empty()); - GProp_GProps nf_props; - BRepGProp::VolumeProperties(doc.bodies[1].shape, nf_props); - double nf_z = nf_props.CentreOfMass().Z(); - - doc.undo(); - - // Fastened, WITH flip - doc.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, true, "MateFlip"); - REQUIRE(doc.recompute()); - REQUIRE(doc.error.empty()); - GProp_GProps f_props; - BRepGProp::VolumeProperties(doc.bodies[1].shape, f_props); - double f_z = f_props.CentreOfMass().Z(); + // The same assembly mated once without and once with flip, each in its own copy. + auto mated_com_z = [&](bool flip) { + CadDocument d = doc; + d.add_mate(0, cs_fixed, cs_moving, 0.0, 0.0, flip, "Mate"); + REQUIRE(d.recompute()); + REQUIRE(d.error.empty()); + REQUIRE(d.mate_conflicts.empty()); + GProp_GProps props; + BRepGProp::VolumeProperties(d.bodies[1].shape, props); + return props.CentreOfMass().Z(); + }; + const double nf_z = mated_com_z(false); + const double f_z = mated_com_z(true); // Flip changes the centroid Z for an asymmetric body REQUIRE_THAT(f_z, !WithinAbs(nf_z, 1e-4)); @@ -7324,13 +7337,22 @@ TEST_CASE("interference: sheet bodies are skipped", "[CadDocument][interference] CadDocument doc; int sk_a = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 20, 20, 0, "BoxA"); doc.add_extrude(sk_a, 10.0, false, BooleanMode::New, "EA"); - // A sheet passing straight through the solid: it has no volume, so no interference. - int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 40, 40, 0, "Sheet"); + // Four 10x5 walls standing inside the 20x20x10 solid: a sheet has no volume, so no interference. + int sk_s = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(), 10, 10, 0, "Sheet"); doc.add_surface_extrude(sk_s, 5.0, "SE"); REQUIRE(doc.recompute()); REQUIRE(doc.bodies.size() == 2); REQUIRE(CadDocument::is_sheet_shape(doc.bodies[1].shape)); + // The raw overlap of the walls with the solid does measure a volume, so only the sheet + // skip keeps it out of the report. + BRepAlgoAPI_Common common(doc.bodies[0].shape, doc.bodies[1].shape); + common.Build(); + REQUIRE(common.IsDone()); + GProp_GProps props; + BRepGProp::VolumeProperties(common.Shape(), props); + REQUIRE(std::abs(props.Mass()) > 1e-6); + REQUIRE(doc.check_interference().empty()); } @@ -7540,9 +7562,8 @@ TEST_CASE("A cut that removes no material is an error, not a silent success", "[ CHECK(doc.error.find("removed no material") != std::string::npos); // And the body is left as it was, not half-applied. - CadDocument again = box(); - REQUIRE(again.recompute()); - CHECK_THAT(again.body_mass_properties(0).volume, + REQUIRE(doc.bodies.size() == 1); + CHECK_THAT(doc.body_mass_properties(0).volume, Catch::Matchers::WithinAbs(solid, 1e-6)); } diff --git a/tests/libslic3r/test_config.cpp b/tests/libslic3r/test_config.cpp index 20177bcc07..213691ae2a 100644 --- a/tests/libslic3r/test_config.cpp +++ b/tests/libslic3r/test_config.cpp @@ -75,11 +75,10 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") { } } WHEN("A boolean option is set to a string value representing something other than 0 or 1") { - THEN("A BadOptionTypeException exception is thrown.") { + config.set("gcode_comments", true); + THEN("A BadOptionTypeException exception is thrown and the value is unchanged.") { REQUIRE_THROWS_AS(config.set("gcode_comments", "Z"), BadOptionTypeException); - } - AND_THEN("Value is unchanged.") { - REQUIRE(config.opt("gcode_comments")->getBool() == false); + REQUIRE(config.opt("gcode_comments")->getBool() == true); } } WHEN("A boolean option is set to an int value") { @@ -117,12 +116,10 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") { } } WHEN("A numeric option is set to a non-numeric value.") { - auto prev_value = config.opt("max_bridge_length")->getFloat(); - THEN("A BadOptionTypeException exception is thrown.") { + config.set("max_bridge_length", 7.5); + THEN("A BadOptionValueException exception is thrown and the value does not change.") { REQUIRE_THROWS_AS(config.set_deserialize_strict("max_bridge_length", "zzzz"), BadOptionValueException); - } - THEN("The value does not change.") { - REQUIRE(config.opt("max_bridge_length")->getFloat() == prev_value); + REQUIRE_THAT(config.opt("max_bridge_length")->getFloat(), Catch::Matchers::WithinAbs(7.5, 1e-9)); } } WHEN("A string option is set through the string interface") { @@ -198,7 +195,7 @@ SCENARIO("Config accessor functions perform as expected.", "[Config]") { } } WHEN("An invalid option is requested during set.") { - THEN("A BadOptionTypeException exception is thrown.") { + THEN("A UnknownOptionException exception is thrown.") { REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1), UnknownOptionException); REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", 1.0), UnknownOptionException); REQUIRE_THROWS_AS(config.set("deadbeef_invalid_option", "1"), UnknownOptionException); diff --git a/tests/libslic3r/test_filament_mixer.cpp b/tests/libslic3r/test_filament_mixer.cpp index 6da90950bf..3093078151 100644 --- a/tests/libslic3r/test_filament_mixer.cpp +++ b/tests/libslic3r/test_filament_mixer.cpp @@ -65,41 +65,25 @@ TEST_CASE("expand_mixed_filaments replaces mixed slots with their components", " } } -TEST_CASE("belt purge tower island count ignores virtual mixed slots", "[FilamentMixer][belt]") +TEST_CASE("A mixed slot whose components are already used adds no filaments", "[FilamentMixer][belt]") { - // Regression for the "extra purge tower" on a belt printer with a mixed - // filament (MCTEST5). The belt purge prism is sized as - // n_islands = used_filaments.size() - 1 - // and GUI::ensure_belt_purge_tower() collected those filaments straight off - // the model objects' extruder assignments. A mixed slot is VIRTUAL -- no - // nozzle carries it, and ToolOrdering::resolve_mixed_filaments() replaces it - // with its components before any G-code is emitted -- so counting it as a - // filament of its own provisions one island that can never be reached. - // - // MCTEST5: five cubes on extruders 1..5, where filament 5 is a 50/50 blend of - // filaments 2 and 4. The G-code uses only T0..T3 and reports - // "filament used [g] = 53.35, 141.11, 40.84, 107.23, 0.00" -- filament 5 - // consumes nothing, exactly as a virtual slot should. + // The belt purge tower sizes its islands from the expanded set. A mixed slot is virtual: no + // nozzle carries it and ToolOrdering::resolve_mixed_filaments() replaces it with its + // components, so it must not count as a filament of its own. const std::vector is_mixed = {0, 0, 0, 0, 1}; const std::vector comp_strs = {"", "", "", "", "2,4"}; - // The set the sizer used to see: slots 0..4 (filaments 1..5). const std::vector assigned = {0, 1, 2, 3, 4}; const auto physical = expand_mixed_filaments(assigned, is_mixed, comp_strs); // Slot 4 dissolves into 1 and 3, which are already present. REQUIRE(physical == std::vector({0, 1, 2, 3})); - // Four physical filaments => three transitions => three islands, not four. - REQUIRE(int(physical.size()) - 1 == 3); - REQUIRE(int(assigned.size()) - 1 == 4); // what it produced before the fix - SECTION("A mixed slot whose components are otherwise unused still counts them") { - // Only the mixed slot is assigned: it must still yield its two components, - // i.e. one island, rather than collapsing to zero. + // Only the mixed slot is assigned: it must still yield its two components rather than + // collapsing to nothing. const auto only_mixed = expand_mixed_filaments({4}, is_mixed, comp_strs); REQUIRE(only_mixed == std::vector({1, 3})); - REQUIRE(int(only_mixed.size()) - 1 == 1); } SECTION("No mixed filaments anywhere leaves the set untouched") { diff --git a/tests/libslic3r/test_preset_bundle_loading.cpp b/tests/libslic3r/test_preset_bundle_loading.cpp index 1b4927f08c..9ff2a89b33 100644 --- a/tests/libslic3r/test_preset_bundle_loading.cpp +++ b/tests/libslic3r/test_preset_bundle_loading.cpp @@ -1334,12 +1334,18 @@ TEST_CASE("Renamed printer/process names are normalized into compatible lists on CHECK(compatible_list(bundle.filaments, "My Filament", "compatible_printers") == (std::vector{ "New Printer", "Unknown Printer" })); - // Normalizing rewrites config in place without flagging the preset dirty. - CHECK_FALSE(bundle.prints.find_preset("My Process", false, true)->is_dirty); + // Selecting the normalized preset copies the rewritten list into the edited preset, so it is + // not reported modified. + REQUIRE(bundle.prints.select_preset_by_name("My Process", true)); + CHECK(bundle.prints.get_edited_preset().config.option("compatible_printers")->values == + std::vector{ "New Printer" }); + CHECK_FALSE(bundle.prints.current_is_dirty()); - // A system preset that already references the current name is left untouched (idempotent no-op). + // A preset that already references the current name is left untouched (idempotent no-op), so + // the selection stays unmodified. bundle.normalize_compatible_presets(); CHECK(compatible_list(bundle.prints, "My Process", "compatible_printers") == std::vector{ "New Printer" }); + CHECK_FALSE(bundle.prints.current_is_dirty()); } TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists", "[Preset][Rename]") @@ -1369,11 +1375,14 @@ TEST_CASE("Renamed names are normalized into a SYSTEM preset's compatible lists" CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_prints") == std::vector{ "New Process" }); - // The rewrite does not flag the system preset dirty, and is idempotent. - CHECK_FALSE(bundle.filaments.find_preset("System Filament", false, true)->is_dirty); + // Selecting the normalized system preset does not report it modified, and the rewrite is + // idempotent. + REQUIRE(bundle.filaments.select_preset_by_name("System Filament", true)); + CHECK_FALSE(bundle.filaments.current_is_dirty()); bundle.normalize_compatible_presets(); CHECK(compatible_list(bundle.filaments, "System Filament", "compatible_printers") == std::vector{ "New Printer" }); + CHECK_FALSE(bundle.filaments.current_is_dirty()); } TEST_CASE("compatible_prints on SLA materials resolves against sla_prints, not prints", "[Preset][Rename]") @@ -5566,16 +5575,6 @@ TEST_CASE("Sizing down to the nozzle count plus mixes is what eats the mixed tai CHECK(bundle.num_mixed_filaments() == 0); CHECK(bundle.num_physical_filaments() == target); } - - SECTION("applied as a floor, the mix is left alone") { - if (target > bundle.filament_presets.size()) - bundle.set_num_filaments(target); - - CHECK(bundle.filament_presets.size() == 6); - CHECK(bundle.num_mixed_filaments() == 1); - CHECK(bundle.is_mixed_filament(5)); - CHECK(bundle.project_config.option("filament_mixed_components")->values[5] == "1,2"); - } } // After a published-3MF import relocated mixed-filament definitions, the freshly loaded diff --git a/tests/libslic3r/test_sketchimport.cpp b/tests/libslic3r/test_sketchimport.cpp index 3e6636fd7d..cdbf0b8296 100644 --- a/tests/libslic3r/test_sketchimport.cpp +++ b/tests/libslic3r/test_sketchimport.cpp @@ -5,7 +5,6 @@ #include #include #include "libslic3r/CAD/SketchImport.hpp" -#include "libslic3r/Utils.hpp" // resources_dir #include "test_utils.hpp" // ScopedTemporaryFile #include @@ -78,18 +77,9 @@ TEST_CASE("transform_regions moves and scales independently", "[SketchImport]") TEST_CASE("text_to_regions vectorizes glyphs with counters", "[SketchImport]") { - // Locate the bundled font; resources_dir() may be unset under ctest, so - // fall back to a cwd-relative path (tests run from the repo root). - std::string font = resources_dir().empty() - ? std::string("resources/fonts/HarmonyOS_Sans_SC_Regular.ttf") - : resources_dir() + "/fonts/HarmonyOS_Sans_SC_Regular.ttf"; - { - std::ifstream probe(font); - if (!probe.good()) { - SUCCEED("bundled font not reachable in this environment; covered live on :10"); - return; - } - } + // The bundled font, from the source tree: PROFILES_DIR is /resources/profiles. + const std::string font = std::string(PROFILES_DIR) + "/../fonts/HarmonyOS_Sans_SC_Regular.ttf"; + REQUIRE(std::ifstream(font).good()); // Bad input is rejected without throwing. REQUIRE(text_to_regions("", 10.0, font).empty()); diff --git a/tests/libslic3r/test_sketchinference.cpp b/tests/libslic3r/test_sketchinference.cpp index 6ff9768b8f..c1224829fa 100644 --- a/tests/libslic3r/test_sketchinference.cpp +++ b/tests/libslic3r/test_sketchinference.cpp @@ -33,8 +33,9 @@ TEST_CASE("inference: cursor near a line endpoint snaps Coincident-able to it", TEST_CASE("inference: endpoint beats midpoint when both are in range", "[inference]") { std::vector ents = { line({0, 0}, {2, 0}) }; - // Query equidistant-ish but closer to the endpoint: endpoint tier wins regardless. - auto s = infer_point_snap(ents, {1.9, 0.0}, 5.0); + // Query 0.2 from the midpoint (1,0) and 0.8 from the endpoint (2,0): the endpoint tier + // wins even though the midpoint is nearer. + auto s = infer_point_snap(ents, {1.2, 0.0}, 5.0, /*include_origin=*/false); CHECK(s.kind == K::Endpoint); CHECK(s.role == SketchPointRole::P1); } diff --git a/tests/libslic3r/test_sketchprofile.cpp b/tests/libslic3r/test_sketchprofile.cpp index 6c33599847..263a5fc73f 100644 --- a/tests/libslic3r/test_sketchprofile.cpp +++ b/tests/libslic3r/test_sketchprofile.cpp @@ -115,15 +115,14 @@ TEST_CASE("profile: offsetting a closed rectangle keeps it closed", "[SketchProf TEST_CASE("profile: offset outward grows the enclosed area by the right amount", "[SketchProfile]") { - // A rectangle offset outward by d is (w+2d) x (h+2d) with the corners rounded at r=d, - // so its area is w*h + 2d(w+h) + pi*d^2 whichever way the corners are healed... except - // for a sharp-corner offset, which is exactly (w+2d)*(h+2d). Either healing is defensible; - // a set of four disconnected segments is not, and that is what this measures. + // Outward is -d for a CCW loop. The miter join keeps the corners sharp, so a rectangle + // offset outward by d is one closed (w+2d) x (h+2d) loop, not four disconnected segments. const double w = 40, h = 20, d = 5; - const auto out = SketchEngine::offset_entities(rect(w, h), d); + const auto out = SketchEngine::offset_entities(rect(w, h), -d); const auto ws = SketchEngine::entities_to_wires(out, xy_plane()); REQUIRE(ws.size() == 1); REQUIRE(ws[0].Closed()); + REQUIRE_THAT(profile_area(out), WithinAbs((w + 2 * d) * (h + 2 * d), 1e-6)); } TEST_CASE("profile: offset sign is left-of-travel, so +d shrinks a CCW rectangle", "[SketchProfile]") diff --git a/tests/libslic3r/test_toolordering_nozzle_group.cpp b/tests/libslic3r/test_toolordering_nozzle_group.cpp index 33f8083125..dde3e1ee22 100644 --- a/tests/libslic3r/test_toolordering_nozzle_group.cpp +++ b/tests/libslic3r/test_toolordering_nozzle_group.cpp @@ -60,9 +60,8 @@ std::vector single_nozzle_per_extruder(int extruder_count) } } // namespace -TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C]") +TEST_CASE("Multi-nozzle options exist and default to one nozzle per extruder", "[ToolOrdering][H2C]") { - // The multi-nozzle gate: std::any_of(extruder_max_nozzle_count > 1). DynamicPrintConfig config = DynamicPrintConfig::full_print_config(); auto *opt = config.option("extruder_max_nozzle_count"); @@ -72,15 +71,11 @@ TEST_CASE("Multi-nozzle gate predicate mirrors BambuStudio", "[ToolOrdering][H2C // 3mf projects round-trip the per-extruder nozzle inventory (GUI producers wire it later). REQUIRE(config.option("extruder_nozzle_stats") != nullptr); - auto has_multiple_nozzle = [](const std::vector &values) { - return std::any_of(values.begin(), values.end(), [](int v) { return v > 1; }); - }; - - // Default for every existing printer: 1 nozzle per extruder => gate is closed. - REQUIRE_FALSE(has_multiple_nozzle(opt->values)); - - // Synthetic H2C-like machine: extruder 1 is a 6-nozzle cluster => gate opens. - REQUIRE(has_multiple_nozzle(std::vector{1, 6})); + // Default for every existing printer: 1 nozzle per extruder, so the multi-nozzle gate + // (any extruder_max_nozzle_count > 1) stays closed. + REQUIRE_FALSE(opt->values.empty()); + for (int count : opt->values) + CHECK(count == 1); } TEST_CASE("Single-nozzle grouping: every filament maps to its extruder nozzle", "[ToolOrdering][H2C]") diff --git a/tests/libslic3r/test_voronoi.cpp b/tests/libslic3r/test_voronoi.cpp index a08b84d3eb..46570440e0 100644 --- a/tests/libslic3r/test_voronoi.cpp +++ b/tests/libslic3r/test_voronoi.cpp @@ -198,8 +198,11 @@ TEST_CASE("Voronoi missing edges - Alessandro gapfill 12707", "[Voronoi]") REQUIRE(intersecting_edges({ poly }).empty()); Lines lines = to_lines(poly); - BoostVD vd; - construct_voronoi(lines.begin(), lines.end(), &vd); + VD vd; + vd.construct_voronoi(lines.begin(), lines.end()); + // Orca's detector finds nothing wrong with this input, so no repair is needed. + CHECK(VD::detect_known_issues(vd, lines.begin(), lines.end()) == VD::IssueType::NO_ISSUE_DETECTED); + CHECK(vd.get_state() == VD::State::REPAIR_NOT_NEEDED); #ifdef VORONOI_DEBUG_OUT dump_voronoi_to_svg(debug_out_path("voronoi-lines.svg").c_str(), @@ -305,9 +308,16 @@ TEST_CASE("Voronoi weirdness", "[Voronoi]") } #endif - BoostVD vd; + VD vd; Lines lines = to_lines(poly); - construct_voronoi(lines.begin(), lines.end(), &vd); + vd.construct_voronoi(lines.begin(), lines.end()); + // Unrepaired, the diagram is missing a Voronoi vertex. construct_voronoi detects it and repairs it + // by rotating the input. + VD raw_vd; + raw_vd.construct_voronoi(lines.begin(), lines.end(), false); + CHECK(VD::detect_known_issues(raw_vd, lines.begin(), lines.end()) == VD::IssueType::MISSING_VORONOI_VERTEX); + CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL); + CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED); #ifdef VORONOI_DEBUG_OUT dump_voronoi_to_svg(debug_out_path("voronoi-weirdness.svg").c_str(), @@ -347,9 +357,8 @@ TEST_CASE("Voronoi division by zero 12903", "[Voronoi]") // https://svn.boost.org/trac10/ticket/12139 // Funny sample from a dental industry? -// Vojtech confirms this test fails and rightly so, because the input data contain self intersections. -// This test is suppressed. -TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi][.][!mayfail]") +// The input data contain self intersections, so no valid Voronoi diagram can be built from it. +TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi]") { Lines lines = { { { 260500,1564400 }, { 261040,1562960 } }, @@ -1327,8 +1336,12 @@ TEST_CASE("Voronoi NaN coordinates 12139", "[Voronoi][.][!mayfail]") } #endif - BoostVD vd; - construct_voronoi(lines.begin(), lines.end(), &vd); + VD vd; + vd.construct_voronoi(lines.begin(), lines.end()); + // The input self-intersects, so rotation cannot repair the diagram. It must be reported as invalid, + // which is what MedialAxis::build checks before falling back to a morphological closing. + CHECK(vd.get_state() == VD::State::REPAIR_UNSUCCESSFUL); + CHECK_FALSE(vd.is_valid()); for (const auto& edge : vd.edges()) if (edge.is_finite()) { @@ -1935,7 +1948,7 @@ TEST_CASE("Voronoi skeleton", "[VoronoiSkeleton]") } // Simple detection with complexity N^2 if there is any point in the input polygons that doesn't have Voronoi vertex. -[[maybe_unused]] static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd) +static bool has_missing_voronoi_vertices(const Polygons &polygons, const VD &vd) { auto are_equal = [](const VD::vertex_type v, const Point &p) { return (Vec2d(v.x(), v.y()) - p.cast()).norm() <= SCALED_EPSILON; }; @@ -1957,7 +1970,7 @@ TEST_CASE("Voronoi skeleton", "[VoronoiSkeleton]") // into two parts. Even we add more points to the edge, and then for these points, the Voronoin vertex is also missing. An // infinity-edge passes through the missing Voronoi vertex. Therefore, this missing Voronoi vertex and edge can be reconstructed // using the intersection between the infinity-edge with the input polygon. -// Rotation of the polygon solves this problem. +// VoronoiDiagram::construct_voronoi repairs it by rotating the input. TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]") { Polygon poly = { @@ -1981,7 +1994,14 @@ TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]") dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex1-out.svg").c_str(), vd, Points(), lines); #endif -// REQUIRE(!has_missing_voronoi_vertices({poly}, vd)); + // Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by + // rotating the input. + VD raw_vd; + raw_vd.construct_voronoi(lines.begin(), lines.end(), false); + CHECK(has_missing_voronoi_vertices({poly}, raw_vd)); + CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL); + CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED); + CHECK_FALSE(has_missing_voronoi_vertices({poly}, vd)); } // This case is composed of two square polygons (contour and hole), and again one of the edges is divided into two parts by a @@ -1989,7 +2009,7 @@ TEST_CASE("Voronoi missing vertex 1", "[VoronoiMissingVertex1]") // that for this case, through the missing Voronoi vertex is passing a finite edge between two internal Voronoin vertices. // Therefore, this missing Voronoi vertex and edge can be reconstructed using the intersection between the finite edge with the // input polygon. -// Rotation of the polygons solves this problem. +// VoronoiDiagram::construct_voronoi repairs it by rotating the input. TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]") { Polygons poly = { @@ -2021,13 +2041,20 @@ TEST_CASE("Voronoi missing vertex 2", "[VoronoiMissingVertex2]") dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex2-out.svg").c_str(), vd, Points(), lines); #endif -// REQUIRE(!has_missing_voronoi_vertices(poly, vd)); + // Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by + // rotating the input. + VD raw_vd; + raw_vd.construct_voronoi(lines.begin(), lines.end(), false); + CHECK(has_missing_voronoi_vertices(poly, raw_vd)); + CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL); + CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED); + CHECK_FALSE(has_missing_voronoi_vertices(poly, vd)); } // This case is composed of two polygons, and again one of the edges is divided into two parts by a point that lies on this edge, // and for this point is Voronoi vertex missing. A difference between the previous cases and this case through the missing // Voronoi vertex is passing finite edge between one inner Voronoi vertex and one outer Voronoi vertex. -// Rotating the polygon also help solve this problem. +// VoronoiDiagram::construct_voronoi repairs it by rotating the input. TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]") { Polygons poly = { @@ -2055,20 +2082,27 @@ TEST_CASE("Voronoi missing vertex 3", "[VoronoiMissingVertex3]") // polygons_rotate(poly, PI/180); // polygons_rotate(poly, PI/6); - BoostVD vd; + VD vd; Lines lines = to_lines(poly); - construct_voronoi(lines.begin(), lines.end(), &vd); + vd.construct_voronoi(lines.begin(), lines.end()); #ifdef VORONOI_DEBUG_OUT dump_voronoi_to_svg(debug_out_path("voronoi-missing-vertex3-out.svg").c_str(), vd, Points(), lines); #endif -// REQUIRE(!has_missing_voronoi_vertices(poly, vd)); + // Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by + // rotating the input. + VD raw_vd; + raw_vd.construct_voronoi(lines.begin(), lines.end(), false); + CHECK(has_missing_voronoi_vertices(poly, raw_vd)); + CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL); + CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED); + CHECK_FALSE(has_missing_voronoi_vertices(poly, vd)); } // In this case, the Voronoi vertex (146873, -146873) is included twice. // Also, near to those duplicate Voronoi vertices is another Voronoi vertex (146872, -146872). // Rotating the polygon will help solve this problem, but then there arise three very close Voronoi vertices. -// Rotating of the input polygon will help solve this problem. +// VoronoiDiagram::construct_voronoi repairs it by rotating the input. TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]") { Polygon poly = { @@ -2095,7 +2129,7 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]") dump_voronoi_to_svg(debug_out_path("voronoi-duplicate-vertices-out.svg").c_str(), vd, Points(), lines); #endif - [[maybe_unused]] auto has_duplicate_vertices = [](const VD &vd) -> bool { + auto has_duplicate_vertices = [](const VD &vd) -> bool { std::vector vertices; for (const auto &vertex : vd.vertices()) vertices.emplace_back(Vec2d(vertex.x(), vertex.y())); @@ -2104,14 +2138,21 @@ TEST_CASE("Duplicate Voronoi vertices", "[Voronoi]") return std::unique(vertices.begin(), vertices.end()) != vertices.end(); }; -// REQUIRE(!has_duplicate_vertices(vd)); + // Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by + // rotating the input. + VD raw_vd; + raw_vd.construct_voronoi(lines.begin(), lines.end(), false); + CHECK(has_duplicate_vertices(raw_vd)); + CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL); + CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED); + CHECK_FALSE(has_duplicate_vertices(vd)); } // In this case, there are three very close Voronoi vertices like in the previous test case after rotation. There is also one // missing Voronoi vertex. One infinity-edge (after clip [(146872, -70146871), (146872, -146871)]) passes through this missing // Voronoi vertex. This infinite edge [(146872, -70146871), (146872, -146871)] and edge [(146873, -146873), (0, 0)] are intersecting. // They intersect probably because the three points are very close to each other, with a combination of the missing Voronoi vertex. -// Rotating of the input polygon will help solve this problem. +// VoronoiDiagram::construct_voronoi repairs it by rotating the input. TEST_CASE("Intersecting Voronoi edges", "[Voronoi]") { Polygon poly = { @@ -2135,7 +2176,7 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]") dump_voronoi_to_svg(debug_out_path("voronoi-intersecting-edges-out.svg").c_str(), vd, Points(), lines); #endif - [[maybe_unused]] auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool { + auto has_intersecting_edges = [](const Polygon &poly, const VD &vd) -> bool { BoundingBox bbox = get_extents(poly); const double bbox_dim_max = double(std::max(bbox.size().x(), bbox.size().y())); @@ -2166,5 +2207,12 @@ TEST_CASE("Intersecting Voronoi edges", "[Voronoi]") return false; }; -// REQUIRE(!has_intersecting_edges(poly, vd)); + // Unrepaired, the diagram shows the defect. construct_voronoi detects it and repairs it by + // rotating the input. + VD raw_vd; + raw_vd.construct_voronoi(lines.begin(), lines.end(), false); + CHECK(has_intersecting_edges(poly, raw_vd)); + CHECK(vd.get_state() == VD::State::REPAIR_SUCCESSFUL); + CHECK(vd.get_issue_type() == VD::IssueType::NO_ISSUE_DETECTED); + CHECK_FALSE(has_intersecting_edges(poly, vd)); } diff --git a/tests/slic3rutils/test_plugin_capability_config.cpp b/tests/slic3rutils/test_plugin_capability_config.cpp index bbd0368d26..b86eda8d22 100644 --- a/tests/slic3rutils/test_plugin_capability_config.cpp +++ b/tests/slic3rutils/test_plugin_capability_config.cpp @@ -8,6 +8,7 @@ #include #include +#include "catch2/catch_message.hpp" #include "plugin_test_utils.hpp" #include #include @@ -17,6 +18,12 @@ #include #include +#include +#include + +#include +#include +#include #include #include #include @@ -105,6 +112,72 @@ PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, co return {type, name, plugin_key}; } +// restore_config_response looks the capability up in PluginManager, so the restore tests load a real +// plugin rather than building a detached capability. +const char* const RESTORE_PLUGIN_KEY = "Restore_Plugin"; + +const char* const RESTORE_PLUGIN_SOURCE = R"PY(# /// script +# requires-python = ">=3.12" +# +# [tool.orcaslicer.plugin] +# name = "Restore Plugin" +# description = "Restore defaults fixture" +# author = "OrcaSlicer" +# version = "1.0" +# type = "script" +# /// +import orca + +class Target(orca.script.ScriptPluginCapabilityBase): + def get_name(self): return "Target" + def execute(self, ctx): return orca.ExecutionResult.success() + def get_default_config(self): return {"speed": 1} + +class Bystander(orca.script.ScriptPluginCapabilityBase): + def get_name(self): return "Bystander" + def execute(self, ctx): return orca.ExecutionResult.success() + def get_default_config(self): return {"speed": 2} + +# Loading seeds defaults through get_default_config and fails the plugin if it raises, so this one +# answers the load and raises from then on. +class Raising(orca.script.ScriptPluginCapabilityBase): + calls = 0 + def get_name(self): return "Raising" + def execute(self, ctx): return orca.ExecutionResult.success() + def get_default_config(self): + Raising.calls += 1 + if Raising.calls > 1: + raise RuntimeError("boom") + return {} + +@orca.plugin +class RestorePackage(orca.base): + def register_capabilities(self): + orca.register_capability(Target) + orca.register_capability(Bystander) + orca.register_capability(Raising) +)PY"; + +// Writes the restore fixture into {data_dir}/orca_plugins and loads it. Call without holding the GIL: +// the load runs on a worker thread. +bool load_restore_plugin(const ScopedDataDir& data_dir_guard) +{ + const boost::filesystem::path plugin_dir = data_dir_guard.plugins_dir() / RESTORE_PLUGIN_KEY; + boost::filesystem::create_directories(plugin_dir); + { + std::ofstream out((plugin_dir / (std::string(RESTORE_PLUGIN_KEY) + ".py")).string(), std::ios::binary); + out << RESTORE_PLUGIN_SOURCE; + } + + PluginManager& manager = PluginManager::instance(); + manager.discover_plugins(/*async=*/false, /*clear=*/true); + manager.load_plugin(RESTORE_PLUGIN_KEY, /*skip_deps=*/true); + std::string error; + const bool loaded = manager.wait_for_plugin_load(RESTORE_PLUGIN_KEY, std::chrono::seconds(120), error); + INFO("load error: " << error); + return loaded && error.empty(); +} + } // namespace TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]") @@ -357,26 +430,25 @@ TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginCo ScopedPluginManager plugin_system; // declared first: destroyed last if (!plugin_system.initialized) SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); - py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down ScopedDataDir data_dir_guard("plugin-config-py-restore"); host_config().load(); + REQUIRE(load_restore_plugin(data_dir_guard)); - const std::string defaults_body = " def get_name(self): return 'cap'\n" - " def get_default_config(self): return {'speed': 1}\n"; - py::object target = make_capability("RestoreTargetCap", defaults_body, "plugin_a", "cap_a"); - py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a"); - + const PluginCapabilityId target = capability_id(PluginCapabilityType::Script, "Target", RESTORE_PLUGIN_KEY); + const PluginCapabilityId bystander = capability_id(PluginCapabilityType::Script, "Bystander", RESTORE_PLUGIN_KEY); const json edited = json{{"speed", 99}}; - REQUIRE(py_save_config(target, edited)); - REQUIRE(py_save_config(bystander, edited)); + REQUIRE(host_config().store_capability_config(target, edited)); + REQUIRE(host_config().store_capability_config(bystander, edited)); - // What PluginsDialog::restore_capability_config does: ask the capability, store the answer. - auto iface = as_interface(target); - REQUIRE(host_config().store_capability_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), iface->get_default_config())); + const json response = PluginConfig::restore_config_response(target); - CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"speed", 1}}); - CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->config == json{{"speed", 99}}); + CHECK(response["ok"] == true); + CHECK(response["config"] == json{{"speed", 1}}); + CHECK(host_config().get_config(target)->config == json{{"speed", 1}}); + CHECK(host_config().get_config(bystander)->config == edited); + + PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY); } TEST_CASE("A raising get_default_config leaves the stored config untouched", "[PluginConfig][Python]") @@ -384,24 +456,21 @@ TEST_CASE("A raising get_default_config leaves the stored config untouched", "[P ScopedPluginManager plugin_system; // declared first: destroyed last if (!plugin_system.initialized) SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error()); - py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down ScopedDataDir data_dir_guard("plugin-config-py-restore-raise"); host_config().load(); + REQUIRE(load_restore_plugin(data_dir_guard)); - py::object cap = make_capability("RaisingDefaultsCap", - " def get_name(self): return 'cap_a'\n" - " def get_default_config(self): raise RuntimeError('boom')\n", - "plugin_a", "cap_a"); + const PluginCapabilityId raising = capability_id(PluginCapabilityType::Script, "Raising", RESTORE_PLUGIN_KEY); + REQUIRE(host_config().store_capability_config(raising, json{{"keep", "me"}})); - REQUIRE(py_save_config(cap, json{{"keep", "me"}})); + const json response = PluginConfig::restore_config_response(raising); - auto iface = as_interface(cap); - REQUIRE(iface); - CHECK_THROWS_AS(iface->get_default_config(), py::error_already_set); + CHECK(response["ok"] == false); + CHECK_FALSE(response["error"].get().empty()); + CHECK(host_config().get_config(raising)->config == json{{"keep", "me"}}); - // The dialog stores nothing when the hook throws: a broken plugin must not wipe user settings. - CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"keep", "me"}}); + PluginManager::instance().unload_plugin(RESTORE_PLUGIN_KEY); } TEST_CASE("A raising config UI hook surfaces as an exception the host can catch", "[PluginConfig][Python]") diff --git a/tests/slic3rutils/test_plugin_cloud_metadata.cpp b/tests/slic3rutils/test_plugin_cloud_metadata.cpp index 95ae6fcd43..013e6e1e9e 100644 --- a/tests/slic3rutils/test_plugin_cloud_metadata.cpp +++ b/tests/slic3rutils/test_plugin_cloud_metadata.cpp @@ -76,12 +76,12 @@ TEST_CASE("plugin latest version uses the authoritative catalog field", "[Plugin { PluginDescriptor descriptor; descriptor.version = "1.3.0"; - descriptor.latest_version = "1.3.0"; + descriptor.latest_version = "1.4.0"; PluginChangelog changelog; changelog.version = "1.2.0"; descriptor.changelog.push_back(changelog); - CHECK(descriptor.latest_available_version() == "1.3.0"); + CHECK(descriptor.latest_available_version() == "1.4.0"); } TEST_CASE("plugin latest version falls back to the descriptor version", "[PluginDescriptor]") diff --git a/tests/slic3rutils/test_slicing_pipeline_bindings.cpp b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp index d05589b552..795d66c2c5 100644 --- a/tests/slic3rutils/test_slicing_pipeline_bindings.cpp +++ b/tests/slic3rutils/test_slicing_pipeline_bindings.cpp @@ -615,12 +615,19 @@ TEST_CASE("orca.host: SurfaceCollection.set mutates geometry; lslices via make_s .attr("expolygon").attr("contour").attr("as_array")().cast(); CHECK(c.shape(0) == 4); - // lslices are derived: make_slices() re-derives them + refreshes the bbox cache. + // lslices are derived: make_slices() re-derives them from the region slices and refreshes the + // bbox cache. TestLayer layer; + layer.add_region(nullptr); py::object ly = py::cast(static_cast(&layer), py::return_value_policy::reference); - // (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.) + ly.attr("regions")().cast()[0].attr("slices").attr("set")(expolys, ST.attr("stInternalSolid")); + REQUIRE(layer.lslices.empty()); ly.attr("make_slices")(); - CHECK(layer.lslices_bboxes.size() == layer.lslices.size()); + REQUIRE(layer.lslices.size() == 1); + CHECK_THAT(layer.lslices.front().area(), WithinRel((double) s * (double) s, 1e-9)); + REQUIRE(layer.lslices_bboxes.size() == 1); + CHECK(layer.lslices_bboxes.front().min == Point(0, 0)); + CHECK(layer.lslices_bboxes.front().max == Point(s, s)); } TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") {