mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-04 22:31:02 +00:00
Merge branch 'feat/printer-agent-infra' into feat/printer-agent-impl
This commit is contained in:
@@ -84,3 +84,104 @@ SCENARIO("Polygon flattening", "[ExtrusionEntity]") {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static ExtrusionPaths straight_path(const std::vector<double> &xs)
|
||||
{
|
||||
ExtrusionPath path{erExternalPerimeter, 1.0, 0.45f, 0.2f};
|
||||
for (double x : xs)
|
||||
path.polyline.append(Point3::new_scale(x, 0., 0.));
|
||||
return {path};
|
||||
}
|
||||
|
||||
TEST_CASE("Scarf ramp ends on the next loop vertex instead of leaving a short stub", "[ExtrusionEntity]")
|
||||
{
|
||||
using Catch::Matchers::WithinAbs;
|
||||
// A 20 mm scarf in 10 steps: a remainder shorter than half a 2 mm step is snapped forward.
|
||||
const double slope_length = 20.;
|
||||
const double max_segment = scale_(slope_length / 10);
|
||||
|
||||
SECTION("a 0.09 mm remainder extends the ramp to the vertex") {
|
||||
ExtrusionPaths paths = straight_path({0., 5., 10., 15., 20.09, 25., 30.});
|
||||
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
|
||||
REQUIRE(loop.starts.size() == 1);
|
||||
REQUIRE(loop.ends.size() == 1);
|
||||
REQUIRE(loop.paths.size() == 1);
|
||||
CHECK_THAT(unscale_(loop.starts.front().polyline.last_point().x()), WithinAbs(20.09, 1e-3));
|
||||
CHECK_THAT(unscale_(loop.ends.front().polyline.last_point().x()), WithinAbs(20.09, 1e-3));
|
||||
CHECK_THAT(unscale_(loop.paths.front().polyline.first_point().x()), WithinAbs(20.09, 1e-3));
|
||||
CHECK_THAT(unscale_(loop.paths.front().polyline.lines().front().length()), WithinAbs(4.91, 1e-3));
|
||||
}
|
||||
|
||||
SECTION("a remainder longer than half a step keeps the exact scarf length") {
|
||||
ExtrusionPaths paths = straight_path({0., 5., 10., 15., 21.5, 25., 30.});
|
||||
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
|
||||
REQUIRE(loop.starts.size() == 1);
|
||||
REQUIRE(loop.paths.size() == 1);
|
||||
CHECK_THAT(unscale_(loop.starts.front().polyline.last_point().x()), WithinAbs(20., 1e-3));
|
||||
CHECK_THAT(unscale_(loop.paths.front().polyline.first_point().x()), WithinAbs(20., 1e-3));
|
||||
CHECK_THAT(unscale_(loop.paths.front().polyline.lines().front().length()), WithinAbs(1.5, 1e-3));
|
||||
}
|
||||
|
||||
SECTION("the ramp never grows by more than a millimetre, whatever the step size") {
|
||||
ExtrusionPaths paths = straight_path({0., 5., 10., 15., 21.5, 25., 30.});
|
||||
ExtrusionLoopSloped loop(paths, 0., slope_length, scale_(slope_length), 0.); // a single 20 mm step
|
||||
REQUIRE(loop.paths.size() == 1);
|
||||
CHECK_THAT(unscale_(loop.starts.front().polyline.last_point().x()), WithinAbs(20., 1e-3));
|
||||
}
|
||||
|
||||
SECTION("snapping onto the path's last vertex leaves no single-point flat path") {
|
||||
ExtrusionPaths paths = straight_path({0., 5., 10., 15., 20.5});
|
||||
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
|
||||
REQUIRE(loop.starts.size() == 1);
|
||||
CHECK(loop.paths.empty());
|
||||
CHECK_THAT(unscale_(loop.starts.front().polyline.last_point().x()), WithinAbs(20.5, 1e-3));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Scarf loop drops the micro segments the seam insertion leaves at both ends", "[ExtrusionEntity]")
|
||||
{
|
||||
using Catch::Matchers::WithinAbs;
|
||||
const double slope_length = 20.;
|
||||
const double max_segment = scale_(slope_length / 10);
|
||||
|
||||
SECTION("a 3 um segment at each end of a single path is removed, the seam point stays") {
|
||||
ExtrusionPaths paths = straight_path({0., 0.003, 5., 10., 15., 21.5, 25., 29.997, 30.});
|
||||
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
|
||||
REQUIRE(loop.starts.size() == 1);
|
||||
REQUIRE(loop.paths.size() == 1);
|
||||
const Polyline3 &start = loop.starts.front().polyline;
|
||||
CHECK_THAT(unscale_(start.first_point().x()), WithinAbs(0., 1e-4));
|
||||
CHECK_THAT(unscale_(start.lines().front().length()), WithinAbs(1.25, 1e-3)); // 5 mm halved twice
|
||||
const Polyline3 &flat = loop.paths.front().polyline;
|
||||
CHECK_THAT(unscale_(flat.last_point().x()), WithinAbs(30., 1e-4));
|
||||
CHECK_THAT(unscale_(flat.lines().back().length()), WithinAbs(5., 1e-3));
|
||||
}
|
||||
|
||||
SECTION("a micro path of its own is dropped and the neighbour ends at the seam point") {
|
||||
ExtrusionPaths paths = straight_path({0., 0.003});
|
||||
ExtrusionPaths rest = straight_path({0.003, 5., 10., 15., 21.5, 25., 30.});
|
||||
paths.push_back(rest.front());
|
||||
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
|
||||
REQUIRE(loop.starts.size() == 1);
|
||||
CHECK_THAT(unscale_(loop.starts.front().polyline.first_point().x()), WithinAbs(0., 1e-4));
|
||||
CHECK_THAT(unscale_(loop.starts.front().polyline.lines().front().length()), WithinAbs(1.25, 1e-3));
|
||||
}
|
||||
|
||||
SECTION("a scarf covering the whole loop still ends at full flow after a trim") {
|
||||
// The caller sizes the scarf from the untrimmed loop: 10.003 mm here, 10 mm after the trim.
|
||||
ExtrusionPaths paths = straight_path({0., 0.003, 5., 10.});
|
||||
ExtrusionLoopSloped loop(paths, 0., 10.003, max_segment, 0.);
|
||||
REQUIRE(loop.starts.size() == 1);
|
||||
CHECK(loop.paths.empty());
|
||||
CHECK_THAT(loop.starts.back().slope_end.e_ratio, WithinAbs(1., 1e-9));
|
||||
CHECK_THAT(unscale_(loop.starts.back().polyline.last_point().x()), WithinAbs(10., 1e-4));
|
||||
}
|
||||
|
||||
SECTION("segments longer than the tolerance are kept") {
|
||||
ExtrusionPaths paths = straight_path({0., 0.3, 5., 10., 15., 21.5, 25., 29.7, 30.});
|
||||
ExtrusionLoopSloped loop(paths, 0., slope_length, max_segment, 0.);
|
||||
REQUIRE(loop.paths.size() == 1);
|
||||
CHECK_THAT(unscale_(loop.starts.front().polyline.lines().front().length()), WithinAbs(0.3, 1e-3));
|
||||
CHECK_THAT(unscale_(loop.paths.front().polyline.lines().back().length()), WithinAbs(0.3, 1e-3));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +244,44 @@ float furthest_reading(const std::vector<ExtendedPoint<2>>& points)
|
||||
})->distance;
|
||||
}
|
||||
|
||||
// A wall along a supported edge of the previous layer, ending past or just short of the edge's end. Crossing the edge's
|
||||
// end reads half a line width out.
|
||||
constexpr double edge_run_length = 64.; // mm, wall start, measured from the end of the previous layer's edge
|
||||
constexpr double edge_step = 0.384; // mm, how far this layer's contour extends past the previous layer's end
|
||||
// The centreline is inset half a line width from the contour.
|
||||
constexpr double edge_wall_end_past = edge_step - 0.5 * caged_wall_width;
|
||||
constexpr double edge_wall_end_short = 0.05; // mm short of the edge, reading 0.21 - 0.05 = 0.16mm out
|
||||
// Segmentation splits 1.5 line widths plus the end's reading from an end, so an end's slowdown and cooling stay within this.
|
||||
constexpr double edge_affected_length = 3. * caged_wall_width;
|
||||
|
||||
std::vector<ExtendedPoint<2>> sampled_wall_along_edge(double wall_end_x,
|
||||
const std::function<float(float)>& distance_to_speed,
|
||||
float min_distance,
|
||||
float fan_overlap_threshold)
|
||||
{
|
||||
const AABBTreeLines::LinesDistancer<Linef> prev_layer(std::vector<Linef>{
|
||||
{{0., 0.}, {edge_run_length + 10., 0.}},
|
||||
{{edge_run_length + 10., 0.}, {edge_run_length + 10., -10.}},
|
||||
{{edge_run_length + 10., -10.}, {0., -10.}},
|
||||
{{0., -10.}, {0., 0.}},
|
||||
});
|
||||
const double wall_y = -0.5 * caged_wall_width;
|
||||
const Points wall{Point::new_scale(edge_run_length, wall_y), Point::new_scale(wall_end_x, wall_y)};
|
||||
|
||||
return estimate_points_properties<true, true, true, true>(wall, prev_layer, caged_wall_width, -1.f, min_distance,
|
||||
distance_to_speed, fan_overlap_threshold);
|
||||
}
|
||||
|
||||
// Length printed with the overhang fan on: segments with either end's overlap at or below the threshold.
|
||||
double cooled_length(const std::vector<ExtendedPoint<2>>& points, float fan_overlap_threshold)
|
||||
{
|
||||
double length = 0.;
|
||||
for (size_t i = 0; i + 1 < points.size(); ++i)
|
||||
if (1.f - std::max(points[i].distance, points[i + 1].distance) / float(caged_wall_width) <= fan_overlap_threshold)
|
||||
length += (points[i + 1].position - points[i].position).norm();
|
||||
return length;
|
||||
}
|
||||
|
||||
DynamicPrintConfig caged_overhang_config(const char* wall_generator){
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({
|
||||
@@ -431,6 +469,61 @@ TEST_CASE("A supported wall between overhanging corners is slowed no further tha
|
||||
REQUIRE(sampled <= unsampled);
|
||||
}
|
||||
|
||||
// Regression: the line up to a step past the previous layer was not split, so the step's slowdown and cooling covered the
|
||||
// whole wall. The split required an end reading beyond where the slowdown begins, and an edge crossing reads exactly
|
||||
// there when the wall speed is held below the reference speed (e.g. resonance avoidance).
|
||||
TEST_CASE("A wall stepping past the previous layer is slowed and cooled only beside the step", "[ExtrusionProcessor][Regression]")
|
||||
{
|
||||
const float crossing_reading = 0.5f * float(caged_wall_width);
|
||||
const std::function<float(float)> distance_to_speed = [crossing_reading](float distance) {
|
||||
return distance < crossing_reading ? 70.f : 15.f;
|
||||
};
|
||||
const float fan_overlap_threshold = 0.75f; // The fan switches on at a 25% overhang
|
||||
|
||||
const std::vector<ExtendedPoint<2>> points = sampled_wall_along_edge(-edge_wall_end_past, distance_to_speed, crossing_reading,
|
||||
fan_overlap_threshold);
|
||||
const double slowed = slowed_length(points, distance_to_speed);
|
||||
const double cooled = cooled_length(points, fan_overlap_threshold);
|
||||
|
||||
REQUIRE(slowed > 0.);
|
||||
REQUIRE(cooled > 0.);
|
||||
REQUIRE(slowed < edge_affected_length);
|
||||
REQUIRE(cooled < edge_affected_length);
|
||||
}
|
||||
|
||||
// Regression: the fan can switch on at a smaller overhang than the first slowdown. Splitting only on speed changes left
|
||||
// the whole wall cooled when its end read between the two.
|
||||
TEST_CASE("A wall is split where only the overhang fan changes", "[ExtrusionProcessor][Regression]")
|
||||
{
|
||||
const float crossing_reading = 0.5f * float(caged_wall_width);
|
||||
const std::function<float(float)> distance_to_speed = [crossing_reading](float distance) {
|
||||
return distance < crossing_reading ? 70.f : 15.f;
|
||||
};
|
||||
// The end reads 0.16mm out (overlap 0.62): cooled at a 25% threshold, but not slowed.
|
||||
const float fan_overlap_threshold = 0.75f;
|
||||
|
||||
const std::vector<ExtendedPoint<2>> points = sampled_wall_along_edge(edge_wall_end_short, distance_to_speed, crossing_reading,
|
||||
fan_overlap_threshold);
|
||||
const double cooled = cooled_length(points, fan_overlap_threshold);
|
||||
|
||||
REQUIRE_THAT(slowed_length(points, distance_to_speed), Catch::Matchers::WithinAbs(0., 1e-9));
|
||||
REQUIRE(cooled > 0.);
|
||||
REQUIRE(cooled < edge_affected_length);
|
||||
}
|
||||
|
||||
// With one speed and a fan threshold no reading reaches, only the wall's ends and the edge crossing remain.
|
||||
TEST_CASE("A wall is left whole where neither its speed nor its cooling changes", "[ExtrusionProcessor]")
|
||||
{
|
||||
const std::function<float(float)> distance_to_speed = [](float) { return 70.f; };
|
||||
// 95% overhang; the step reads 0.384mm out (overlap 0.09).
|
||||
const float fan_overlap_threshold = 0.05f;
|
||||
|
||||
const std::vector<ExtendedPoint<2>> points = sampled_wall_along_edge(-edge_wall_end_past, distance_to_speed, -1.f,
|
||||
fan_overlap_threshold);
|
||||
|
||||
REQUIRE(points.size() == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("Benchmark caged overhang interior sampling", "[ExtrusionProcessor][!benchmark]"){
|
||||
const char* wall_generator = GENERATE("classic", "arachne");
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <numeric>
|
||||
#include <sstream>
|
||||
@@ -11,8 +12,10 @@
|
||||
#include "libslic3r/ClipperUtils.hpp"
|
||||
#include "libslic3r/AABBTreeLines.hpp"
|
||||
#include "libslic3r/Fill/Fill.hpp"
|
||||
#include "libslic3r/Fill/FillAdaptive.hpp"
|
||||
#include "libslic3r/Flow.hpp"
|
||||
#include "libslic3r/Geometry.hpp"
|
||||
#include "libslic3r/IntersectionPoints.hpp"
|
||||
#include "libslic3r/Layer.hpp"
|
||||
#include "libslic3r/Print.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
@@ -1196,6 +1199,231 @@ TEST_CASE("Trapezoidal grid infill rounds its corners only with more than one li
|
||||
REQUIRE(single_smooth.length == single_sharp.length);
|
||||
}
|
||||
|
||||
TEST_CASE("Multiline cubic infill follows the cubic lines without crossing itself", "[Fill]")
|
||||
{
|
||||
const int multiline = GENERATE(2, 3);
|
||||
const double spacing = 0.45;
|
||||
const double density = 0.3;
|
||||
const double wall = multiline * spacing;
|
||||
CAPTURE(multiline);
|
||||
|
||||
const ExPolygon region{ Slic3r::Points{ Point::new_scale(0., 0.), Point::new_scale(40., 0.),
|
||||
Point::new_scale(40., 40.), Point::new_scale(0., 40.) } };
|
||||
auto fill = [®ion, spacing](int lines, double density, size_t layer_id, double z) {
|
||||
std::unique_ptr<Slic3r::Fill> filler(Slic3r::Fill::new_from_type("cubic"));
|
||||
filler->spacing = spacing;
|
||||
filler->angle = float(M_PI / 7.);
|
||||
filler->layer_id = layer_id;
|
||||
filler->z = z;
|
||||
|
||||
FillParams params;
|
||||
params.density = float(density);
|
||||
params.multiline = lines;
|
||||
params.dont_adjust = true;
|
||||
params.anchor_length_max = 0.f; // The bare pattern, without connections along the boundary.
|
||||
Slic3r::Surface surface(stInternal, region);
|
||||
return filler->fill_surface(&surface, params);
|
||||
};
|
||||
// Away from the boundary, where a line is clipped earlier than the side of its wall.
|
||||
const Polygons inner = shrink(to_polygons(region), scale_(3.));
|
||||
auto farthest = [&inner](const Polylines &from, const Polylines &to) {
|
||||
const AABBTreeLines::LinesDistancer<Line> tree(to_lines(to));
|
||||
double distance = 0.;
|
||||
for (const Polyline &path : intersection_pl(from, inner))
|
||||
for (const Point &point : path.equally_spaced_points(scale_(0.2)))
|
||||
distance = std::max(distance, tree.distance_from_lines<false>(point));
|
||||
return unscale<double>(distance);
|
||||
};
|
||||
|
||||
// One z period of the pattern: sqrt(2) / 3 of the 3 * wall / density line spacing.
|
||||
const double z_period = std::sqrt(2.) * wall / density;
|
||||
const size_t layers = 30;
|
||||
for (size_t layer_id = 0; layer_id < layers; ++layer_id) {
|
||||
const double z = z_period * (layer_id + 0.5) / layers;
|
||||
CAPTURE(layer_id, z);
|
||||
const Polylines walls = fill(multiline, density, layer_id, z);
|
||||
REQUIRE_FALSE(walls.empty());
|
||||
CHECK(get_intersections(to_lines(walls)).empty());
|
||||
// Long paths running out to the boundary, not loops around the cells.
|
||||
CHECK(std::none_of(walls.begin(), walls.end(), [](const Polyline &path) { return path.first_point() == path.last_point(); }));
|
||||
|
||||
// Single lines at the same spacing: the walls are drawn along them.
|
||||
const Polylines lines = fill(1, density / multiline, layer_id, z);
|
||||
REQUIRE_FALSE(lines.empty());
|
||||
CHECK(farthest(lines, walls) < 0.5 * wall);
|
||||
CHECK(farthest(walls, lines) < 1.5 * wall);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Multiline adaptive cubic infill keeps its lines apart without closing them around the cells", "[Fill]")
|
||||
{
|
||||
const std::string pattern = GENERATE("adaptivecubic", "supportcubic");
|
||||
const int multiline = GENERATE(2, 3);
|
||||
CAPTURE(pattern, multiline);
|
||||
|
||||
// A sphere refines the octree all around, so the finer lines end on the coarser ones at every layer.
|
||||
TriangleMesh sphere = Slic3r::Test::mesh(Slic3r::Test::TestMesh::sphere_50mm);
|
||||
sphere.scale(0.3f);
|
||||
Print print;
|
||||
Slic3r::Test::init_and_process_print({sphere}, print,
|
||||
{{"sparse_infill_pattern", pattern},
|
||||
{"sparse_infill_density", "40%"},
|
||||
{"fill_multiline", multiline},
|
||||
{"infill_anchor", 0},
|
||||
{"infill_anchor_max", 0},
|
||||
{"layer_height", 0.3}});
|
||||
|
||||
size_t paths = 0, loops = 0;
|
||||
for (const Layer *layer : print.objects().front()->layers()) {
|
||||
Polylines printed;
|
||||
Polygons sparse;
|
||||
double spacing = 0.;
|
||||
for (const LayerRegion *region : layer->regions()) {
|
||||
for (const ExtrusionEntity *entity : region->fills.flatten().entities)
|
||||
if (entity->role() == erInternalInfill)
|
||||
entity->collect_polylines(printed);
|
||||
for (const Surface &surface : region->fill_surfaces.surfaces)
|
||||
if (surface.surface_type == stInternal)
|
||||
append(sparse, shrink(to_polygons(surface.expolygon), scale_(1.)));
|
||||
spacing = region->flow(frInfill).spacing();
|
||||
}
|
||||
if (printed.empty())
|
||||
continue;
|
||||
CAPTURE(layer->print_z);
|
||||
paths += printed.size();
|
||||
loops += std::count_if(printed.begin(), printed.end(), [](const Polyline &pl) { return pl.first_point() == pl.last_point(); });
|
||||
CHECK(get_intersections(to_lines(printed)).empty());
|
||||
|
||||
// Neighbouring lines stay a line spacing apart, less the overlap of a line end with the wall it stops on.
|
||||
// Pieces of one line that meet end to end are one line.
|
||||
std::vector<size_t> line_of(printed.size());
|
||||
std::iota(line_of.begin(), line_of.end(), 0);
|
||||
std::function<size_t(size_t)> find = [&](size_t i) { return line_of[i] == i ? i : line_of[i] = find(line_of[i]); };
|
||||
for (size_t i = 0; i < printed.size(); ++i)
|
||||
for (size_t j = i + 1; j < printed.size(); ++j)
|
||||
for (const Point &a : { printed[i].first_point(), printed[i].last_point() })
|
||||
for (const Point &b : { printed[j].first_point(), printed[j].last_point() })
|
||||
if ((a - b).cast<double>().norm() < SCALED_EPSILON)
|
||||
line_of[find(i)] = find(j);
|
||||
Lines lines;
|
||||
std::vector<size_t> owner;
|
||||
for (size_t i = 0; i < printed.size(); ++i)
|
||||
for (const Line &line : printed[i].lines()) {
|
||||
lines.push_back(line);
|
||||
owner.push_back(find(i));
|
||||
}
|
||||
AABBTreeLines::LinesDistancer<Line> tree(lines);
|
||||
double closest = spacing;
|
||||
for (size_t i = 0; i < printed.size(); ++i)
|
||||
for (const Point &p : printed[i].equally_spaced_points(scale_(0.1)))
|
||||
if (contains(sparse, p))
|
||||
for (size_t k : tree.all_lines_in_radius(p, scale_(spacing)))
|
||||
if (owner[k] != find(i))
|
||||
closest = std::min(closest, unscale<double>(lines[k].distance_to(p)));
|
||||
CHECK(closest > 0.45 * spacing);
|
||||
}
|
||||
REQUIRE(paths > 0);
|
||||
// The lines run on through the cells instead of each cell getting its own loops.
|
||||
CHECK(loops < paths / 4);
|
||||
}
|
||||
|
||||
TEST_CASE("Multiline adaptive cubic paths touch where they bounce off each other", "[Fill]")
|
||||
{
|
||||
const int sweep = GENERATE(0, 1, 2);
|
||||
// Offset of the third family in walls, so the three meet in points or in small triangles.
|
||||
const double shift = GENERATE(0., 0.1, 0.5, 1., 2.5, -0.5, -1.);
|
||||
// Like finer octree lines ending on coarser ones, the 60 degree lines may start on the horizontal line through 0.
|
||||
const bool starting = GENERATE(false, true);
|
||||
CAPTURE(sweep, shift, starting);
|
||||
|
||||
const double d1 = scale_(0.8), pitch = scale_(8.), inner = scale_(12.);
|
||||
Lines lines;
|
||||
for (int k = 0; k < 3; ++k) {
|
||||
const Vec2d dir(std::cos(k * M_PI / 3.), std::sin(k * M_PI / 3.)), normal(-dir.y(), dir.x());
|
||||
for (int i = -6; i <= 6; ++i) {
|
||||
const Vec2d mid = (i * pitch + (k == 2 ? shift * d1 : 0.)) * normal;
|
||||
const double start = k == 1 && starting ? -mid.y() / dir.y() : -10. * pitch;
|
||||
lines.emplace_back((mid + start * dir).cast<coord_t>(), (mid + 10. * pitch * dir).cast<coord_t>());
|
||||
}
|
||||
}
|
||||
const Polylines paths = FillAdaptive::multiline_paths(lines, d1, 0., sweep, BoundingBox(Point::new_scale(-20., -20.), Point::new_scale(20., 20.)));
|
||||
REQUIRE_FALSE(paths.empty());
|
||||
CHECK(get_intersections(to_lines(paths)).empty());
|
||||
|
||||
Lines pieces;
|
||||
std::vector<size_t> owner;
|
||||
for (size_t i = 0; i < paths.size(); ++i)
|
||||
for (const Line &line : paths[i].lines()) {
|
||||
pieces.push_back(line);
|
||||
owner.push_back(i);
|
||||
}
|
||||
AABBTreeLines::LinesDistancer<Line> tree(pieces);
|
||||
auto clearance = [&](size_t i) {
|
||||
const Line &a = pieces[i];
|
||||
double distance = std::numeric_limits<double>::max();
|
||||
for (size_t j : tree.all_lines_in_radius(a.midpoint(), 0.5 * a.length() + 2. * d1))
|
||||
if (owner[j] != owner[i]) {
|
||||
const Line &b = pieces[j];
|
||||
distance = std::min({ distance, a.distance_to(b.a), a.distance_to(b.b), b.distance_to(a.a), b.distance_to(a.b) });
|
||||
}
|
||||
return distance;
|
||||
};
|
||||
auto inside = [inner](const Point &p) { return std::abs(p.x()) < inner && std::abs(p.y()) < inner; };
|
||||
|
||||
double closest = std::numeric_limits<double>::max();
|
||||
for (size_t i = 0; i < pieces.size(); ++i)
|
||||
if (inside(pieces[i].midpoint()))
|
||||
closest = std::min(closest, clearance(i));
|
||||
CHECK(closest > 0.99 * d1);
|
||||
|
||||
// Each path at a crossing touches another one there, none stops short of it.
|
||||
double widest = 0.;
|
||||
for (size_t i = 0; i < lines.size(); ++i)
|
||||
for (size_t j = i + 1; j < lines.size(); ++j)
|
||||
if (Point crossing; line_alg::intersection(lines[i], lines[j], &crossing) && inside(crossing)) {
|
||||
std::map<size_t, double> at;
|
||||
for (size_t k : tree.all_lines_in_radius(crossing, 1.2 * d1))
|
||||
at.emplace(owner[k], std::numeric_limits<double>::max());
|
||||
for (size_t k : tree.all_lines_in_radius(crossing, 2. * d1))
|
||||
if (auto it = at.find(owner[k]); it != at.end())
|
||||
it->second = std::min(it->second, clearance(k));
|
||||
for (const auto &path : at)
|
||||
widest = std::max(widest, path.second);
|
||||
}
|
||||
CHECK(widest < 1.02 * d1);
|
||||
}
|
||||
|
||||
TEST_CASE("Multiline adaptive cubic paths reach the line they end on when another path ends on them", "[Fill]")
|
||||
{
|
||||
const int sweep = GENERATE(0, 1, 2);
|
||||
// Where the 120 degree line starts on the horizontal one, in walls from the 60 degree line.
|
||||
const double start = GENERATE(0.3, 0.6, 1., 2.);
|
||||
CAPTURE(sweep, start);
|
||||
|
||||
const double d1 = scale_(0.8), overlap = 0.1 * d1, length = scale_(30.);
|
||||
const Vec2d diagonal(0.5, 0.5 * std::sqrt(3.)), horizontal(1., 0.), steep(-0.5, 0.5 * std::sqrt(3.));
|
||||
const Vec2d on_horizontal = start * d1 * horizontal;
|
||||
const Lines lines{ Line((-length * diagonal).cast<coord_t>(), (length * diagonal).cast<coord_t>()),
|
||||
Line(Point(0, 0), (length * horizontal).cast<coord_t>()),
|
||||
Line(on_horizontal.cast<coord_t>(), (on_horizontal - length * steep).cast<coord_t>()) };
|
||||
const Polylines paths = FillAdaptive::multiline_paths(lines, d1, overlap, sweep, BoundingBox(Point::new_scale(-40., -40.), Point::new_scale(40., 40.)));
|
||||
|
||||
// The end of the path along each line nearest to where that line starts.
|
||||
auto end_along = [&paths](const Line &line) {
|
||||
for (const Polyline &path : paths)
|
||||
if (line.distance_to(path.first_point()) < SCALED_EPSILON && line.distance_to(path.last_point()) < SCALED_EPSILON)
|
||||
return (path.first_point() - line.a).cast<double>().norm() < (path.last_point() - line.a).cast<double>().norm() ? path.first_point() : path.last_point();
|
||||
return Point(std::numeric_limits<coord_t>::max(), 0);
|
||||
};
|
||||
const Point horizontal_end = end_along(lines[1]), steep_end = end_along(lines[2]);
|
||||
REQUIRE(horizontal_end.x() != std::numeric_limits<coord_t>::max());
|
||||
REQUIRE(steep_end.x() != std::numeric_limits<coord_t>::max());
|
||||
// Both reach the overlap into the wall of the path they stop at, none stops short of it.
|
||||
CHECK_THAT(line_alg::distance_to_infinite(lines[0], horizontal_end) / d1, Catch::Matchers::WithinAbs(0.9, 0.01));
|
||||
CHECK(lines[1].distance_to(steep_end) / d1 < 0.91);
|
||||
CHECK(get_intersections(to_lines(paths)).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("3D honeycomb infill rounds its octahedral waves with the smooth factor", "[Fill]")
|
||||
{
|
||||
auto shape_for = [](const std::string &smooth_factor) {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "nlohmann/json.hpp"
|
||||
|
||||
#include "libslic3r/GCodeWriter.hpp"
|
||||
#include "libslic3r/GCode.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
@@ -726,20 +730,41 @@ static std::string slice_two_object_bbl(DynamicPrintConfig &config)
|
||||
}
|
||||
|
||||
// The real change_filament_gcode of a shipped "<printer> 0.4 nozzle" machine profile.
|
||||
// The profile does not state it inline any more: it names the template carrying it in
|
||||
// `include`, and the loader layers that template under the preset. Follow the same list
|
||||
// - so a profile that stops naming one fails here instead of silently slicing G-code it
|
||||
// no longer ships.
|
||||
static std::string shipped_change_filament_gcode(const std::string &printer)
|
||||
{
|
||||
const std::string path = std::string(PROFILES_DIR) + "/BBL/machine/Bambu Lab " + printer + " 0.4 nozzle.json";
|
||||
const std::string machine_dir = std::string(PROFILES_DIR) + "/BBL/machine/";
|
||||
const std::string machine_path = machine_dir + "Bambu Lab " + printer + " 0.4 nozzle.json";
|
||||
const std::string template_name = "Bambu Lab " + printer + " 0.4 nozzle template change_filament_gcode";
|
||||
// PROFILES_DIR is an absolute path baked in at build time; a sparse test checkout
|
||||
// without resources/ leaves it missing. Skip rather than dereference a config that
|
||||
// never loaded - this is the only fff_print test that reads a shipped profile.
|
||||
if (!boost::filesystem::exists(path))
|
||||
SKIP("shipped profile not present in this checkout: " << path);
|
||||
DynamicPrintConfig config;
|
||||
std::map<std::string, std::string> key_values;
|
||||
std::string reason;
|
||||
config.load_from_json(path, ForwardCompatibilitySubstitutionRule::Enable, key_values, reason);
|
||||
// Fail loudly on a malformed/renamed profile instead of null-dereferencing in opt_string.
|
||||
INFO("profile: " << path << (reason.empty() ? "" : (" load reason: " + reason)));
|
||||
if (!boost::filesystem::exists(machine_path))
|
||||
SKIP("shipped profile not present in this checkout: " << machine_path);
|
||||
|
||||
auto load = [](const std::string &file, std::map<std::string, std::string> &key_values) {
|
||||
DynamicPrintConfig config;
|
||||
ConfigSubstitutionContext substitutions{ForwardCompatibilitySubstitutionRule::Enable};
|
||||
std::string reason;
|
||||
// false: `inherits` and `include` stay out of the config and land in key_values,
|
||||
// for the loader to resolve - neither is a slicing setting.
|
||||
config.load_from_json(file, substitutions, false, key_values, reason);
|
||||
// Fail loudly on a malformed/renamed profile instead of null-dereferencing in opt_string.
|
||||
INFO("profile: " << file << (reason.empty() ? "" : (" load reason: " + reason)));
|
||||
return config;
|
||||
};
|
||||
|
||||
std::map<std::string, std::string> machine_values;
|
||||
load(machine_path, machine_values);
|
||||
REQUIRE(machine_values.count("include") == 1);
|
||||
const nlohmann::json includes = nlohmann::json::parse(machine_values["include"]);
|
||||
REQUIRE(std::find(includes.begin(), includes.end(), nlohmann::json(template_name)) != includes.end());
|
||||
|
||||
std::map<std::string, std::string> template_values;
|
||||
const DynamicPrintConfig config = load(machine_dir + template_name + ".json", template_values);
|
||||
REQUIRE(config.has("change_filament_gcode"));
|
||||
return config.opt_string("change_filament_gcode");
|
||||
}
|
||||
|
||||
@@ -630,3 +630,97 @@ TEST_CASE("A lower layer sliver too thin to print does not support the wall abov
|
||||
// A rib that does get printed takes the 20mm outer wall running along it out of the overhangs.
|
||||
CHECK(printable < no_rib - scale_(15.));
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// Every setting the fuzzy skin assertions below depend on.
|
||||
DynamicPrintConfig fuzzy_skin_config(const char *wall_generator)
|
||||
{
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_deserialize_strict({
|
||||
{ "wall_generator", wall_generator },
|
||||
{ "layer_height", 0.2 },
|
||||
{ "initial_layer_print_height", 0.2 },
|
||||
// One wall, so every wall point along the long sides belongs to the fuzzed outer wall.
|
||||
{ "wall_loops", 1 },
|
||||
{ "fuzzy_skin", "external" },
|
||||
{ "fuzzy_skin_noise_type", "classic" },
|
||||
{ "fuzzy_skin_thickness", 0.3 },
|
||||
{ "fuzzy_skin_point_distance", 0.8 },
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
// How far the wall points over the middle 60% of the layer's length stray across its width, worst side.
|
||||
// A negative result means there is no layer at `print_z`.
|
||||
double mid_span_wall_spread(const Print &print, double print_z)
|
||||
{
|
||||
for (const Layer *layer : print.objects().front()->layers()) {
|
||||
if (std::abs(layer->print_z - print_z) > 1e-4)
|
||||
continue;
|
||||
const BoundingBox bbox = get_extents(layer->lslices);
|
||||
const coord_t x_min = bbox.min.x() + bbox.size().x() / 5;
|
||||
const coord_t x_max = bbox.max.x() - bbox.size().x() / 5;
|
||||
Points points;
|
||||
for (const LayerRegion *region : layer->regions())
|
||||
region->perimeters.collect_points(points);
|
||||
coord_t spread = 0;
|
||||
for (const bool south : { true, false }) {
|
||||
coord_t lo = bbox.max.y(), hi = bbox.min.y();
|
||||
for (const Point &p : points)
|
||||
if (p.x() > x_min && p.x() < x_max && (p.y() < bbox.center().y()) == south) {
|
||||
lo = std::min(lo, p.y());
|
||||
hi = std::max(hi, p.y());
|
||||
}
|
||||
spread = std::max(spread, hi - lo);
|
||||
}
|
||||
return unscale<double>(spread);
|
||||
}
|
||||
return -1.;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// TestMesh::bridge is a 50x10mm deck from z=5 to z=8 on two 5mm-wide pillars, leaving a 40mm span. The deck's
|
||||
// first layer (print_z 5.2) crosses the span unsupported; the layers above it rest on the deck.
|
||||
TEST_CASE("Fuzzy skin leaves the walls of a bridge smooth", "[Perimeters]")
|
||||
{
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
CAPTURE(wall_generator);
|
||||
|
||||
Print print;
|
||||
init_and_process_print({ TestMesh::bridge }, print, fuzzy_skin_config(wall_generator));
|
||||
REQUIRE_FALSE(print.objects().empty());
|
||||
|
||||
// Control: one deck layer up the same walls rest on the deck, so they are fuzzed.
|
||||
CHECK(mid_span_wall_spread(print, 5.6) > 0.1);
|
||||
// Over the unsupported span the walls stay straight.
|
||||
const double bridged = mid_span_wall_spread(print, 5.2);
|
||||
CHECK(bridged >= 0.);
|
||||
CHECK(bridged < 0.001);
|
||||
}
|
||||
|
||||
// One object: a 20x20x3mm block on the bed and a second one floating above it from z=5 to z=8. The layers in
|
||||
// the gap are empty, so the floating block's first layer (print_z 5.2) has a layer below it with nothing
|
||||
// printed on it; the layers above rest on the floating block.
|
||||
TEST_CASE("Fuzzy skin leaves the walls over an empty layer smooth", "[Perimeters]")
|
||||
{
|
||||
const char *wall_generator = GENERATE("classic", "arachne");
|
||||
CAPTURE(wall_generator);
|
||||
|
||||
TriangleMesh mesh = make_cube(20., 20., 3.);
|
||||
TriangleMesh floating = make_cube(20., 20., 3.);
|
||||
floating.translate(0.f, 0.f, 5.f);
|
||||
mesh.merge(floating);
|
||||
|
||||
Print print;
|
||||
init_and_process_print({ mesh }, print, fuzzy_skin_config(wall_generator));
|
||||
REQUIRE_FALSE(print.objects().empty());
|
||||
|
||||
// Control: one layer up the walls rest on the floating block, so they are fuzzed.
|
||||
CHECK(mid_span_wall_spread(print, 5.6) > 0.1);
|
||||
// Nothing is printed under the first floating layer, so its walls stay straight.
|
||||
const double floating_first_layer = mid_span_wall_spread(print, 5.2);
|
||||
CHECK(floating_first_layer >= 0.);
|
||||
CHECK(floating_first_layer < 0.001);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
#include "libslic3r/Layer.hpp"
|
||||
#include "libslic3r/Model.hpp"
|
||||
#include "libslic3r/GCodeReader.hpp"
|
||||
#include "libslic3r/GCode/GCodeProcessor.hpp"
|
||||
#include "libslic3r/Exception.hpp"
|
||||
#include "libslic3r/LifecycleEvents.hpp"
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
#include "test_utils.hpp"
|
||||
@@ -22,7 +25,10 @@
|
||||
#include <algorithm>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <memory>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::Test;
|
||||
@@ -224,8 +230,88 @@ std::string resolved_output_name(Model& model, const std::string& format, const
|
||||
return print.output_filename(filename_base);
|
||||
}
|
||||
|
||||
struct ScopedLifecycleHook
|
||||
{
|
||||
explicit ScopedLifecycleHook(LifecycleHookFn hook) { set_lifecycle_hook_fn(std::move(hook)); }
|
||||
~ScopedLifecycleHook() { set_lifecycle_hook_fn(nullptr); }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Slicing lifecycle events identify the model", "[Print][LifecycleEvents]")
|
||||
{
|
||||
struct ObservedEvent {
|
||||
LifecycleEvent event;
|
||||
std::string id;
|
||||
std::string name;
|
||||
};
|
||||
std::vector<ObservedEvent> events;
|
||||
ScopedLifecycleHook hook([&](LifecycleEvent event, const LifecycleEventContext& ctx) {
|
||||
events.push_back({ event, ctx.id, ctx.name });
|
||||
});
|
||||
|
||||
Print print;
|
||||
Model model;
|
||||
ModelInfo info;
|
||||
info.model_name = "Lifecycle test model";
|
||||
model.model_info = std::make_shared<ModelInfo>(std::move(info));
|
||||
init_print({cube(20)}, print, model);
|
||||
|
||||
print.process();
|
||||
ScopedTemporaryFile temp(".gcode");
|
||||
print.export_gcode(temp.string(), nullptr, nullptr);
|
||||
GCodeProcessorResult result;
|
||||
print.export_gcode_from_previous_file(temp.string(), &result);
|
||||
|
||||
const std::string expected_id = std::to_string(print.model().id().id);
|
||||
const std::vector<LifecycleEvent> expected_events = {
|
||||
LifecycleEvent::SliceStarted,
|
||||
LifecycleEvent::SliceGeometryFinished,
|
||||
LifecycleEvent::GCodeExportStarted,
|
||||
LifecycleEvent::GCodeExportFinished,
|
||||
LifecycleEvent::GCodeExportStarted,
|
||||
LifecycleEvent::GCodeExportFinished,
|
||||
};
|
||||
REQUIRE(events.size() == expected_events.size());
|
||||
for (size_t i = 0; i < expected_events.size(); ++i) {
|
||||
CHECK(events[i].event == expected_events[i]);
|
||||
CHECK(events[i].id == expected_id);
|
||||
CHECK(events[i].name == "Lifecycle test model");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Slicing lifecycle event name is empty without model metadata", "[Print][LifecycleEvents]")
|
||||
{
|
||||
std::string event_id;
|
||||
std::string event_name = "unset";
|
||||
ScopedLifecycleHook hook([&](LifecycleEvent event, const LifecycleEventContext& ctx) {
|
||||
if (event == LifecycleEvent::SliceStarted) {
|
||||
event_id = ctx.id;
|
||||
event_name = ctx.name;
|
||||
}
|
||||
});
|
||||
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({cube(20)}, print, model);
|
||||
print.process();
|
||||
|
||||
CHECK(event_id == std::to_string(print.model().id().id));
|
||||
CHECK(event_name.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Output filenames with numeric statistics fail before slicing finishes", "[Print][Regression]")
|
||||
{
|
||||
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("filename_format", new ConfigOptionString("{int(total_weight*10) / 10.0}"));
|
||||
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({cube(20)}, print, model, config);
|
||||
|
||||
CHECK_THROWS_AS(print.output_filename(), PlaceholderParserError);
|
||||
}
|
||||
|
||||
TEST_CASE("Print: {first_object_name} names the first printable object on the plate", "[Print]")
|
||||
{
|
||||
Model model;
|
||||
@@ -505,3 +591,29 @@ TEST_CASE("Sequential printing publishes the nozzle group result", "[Print][Mult
|
||||
CHECK(gcode.find("; SEQ-ND-OK") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Slicing errors are reported per object with the object's name", "[Print]")
|
||||
{
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({Slic3r::Test::cube(20.)}, print, model);
|
||||
// Lift the cube off the bed: its first layer is empty, which G-code export reports per object.
|
||||
ModelObject *object = model.objects.front();
|
||||
object->name = "floating cube";
|
||||
object->instances.front()->set_offset(object->instances.front()->get_offset() + Vec3d(0., 0., 2.));
|
||||
print.apply(model, DynamicPrintConfig::full_print_config());
|
||||
print.set_status_silent();
|
||||
|
||||
ScopedTemporaryFile temp(".gcode");
|
||||
std::string message;
|
||||
try {
|
||||
print.process();
|
||||
print.export_gcode(temp.string(), nullptr, nullptr);
|
||||
FAIL("slicing did not report the empty first layer");
|
||||
} catch (const SlicingErrors &errors) {
|
||||
REQUIRE(errors.errors_.size() == 1);
|
||||
message = print.slicing_errors_message(errors);
|
||||
}
|
||||
CHECK(message.rfind("floating cube: ", 0) == 0);
|
||||
CHECK(message.find("empty first layer") != std::string::npos);
|
||||
}
|
||||
|
||||
@@ -308,6 +308,119 @@ TEST_CASE("A single-filament plate reserves a tower only when one is actually pr
|
||||
}
|
||||
}
|
||||
|
||||
// Filament 2 on the top surface only, so every layer below it is a toolchange-free tower layer: the
|
||||
// run "Combine sparse layers" folds. The two heights decide whether anything folds, so they are the
|
||||
// caller's business.
|
||||
static DynamicPrintConfig sparse_run_config(double layer_height, const char *max_layer_height, bool combine)
|
||||
{
|
||||
DynamicPrintConfig config = multifilament_config(2, {
|
||||
{ "top_surface_filament_id", 2 },
|
||||
{ "enable_prime_tower", true },
|
||||
{ "wipe_tower_x", 50 }, // inside the 200x200 test bed
|
||||
{ "wipe_tower_y", 50 },
|
||||
{ "prime_tower_width", 35 },
|
||||
{ "min_layer_height", "0.08"},
|
||||
{ "single_extruder_multi_material", true },
|
||||
{ "timelapse_type", "0" },
|
||||
{ "enable_wrapping_detection", false },
|
||||
{ "raft_layers", "0" } });
|
||||
// A taller first layer would top the plan and hide what the run does, so slice at one height.
|
||||
config.set_deserialize_strict({ { "layer_height", std::to_string(layer_height) },
|
||||
{ "initial_layer_print_height", std::to_string(layer_height) },
|
||||
{ "max_layer_height", max_layer_height },
|
||||
{ "wipe_tower_sparse_layers_combination", combine ? "1" : "0" } });
|
||||
return config;
|
||||
}
|
||||
|
||||
// What a sliced tower did with its sparse run.
|
||||
struct SparseRunResult { size_t planned, sparse, folded; float tallest_printed, printed_height; std::string gcode; };
|
||||
|
||||
static SparseRunResult slice_sparse_run(const DynamicPrintConfig &config)
|
||||
{
|
||||
Print print;
|
||||
Model model;
|
||||
init_print({ cube(10) }, print, model, config);
|
||||
print.apply(model, config);
|
||||
print.process();
|
||||
REQUIRE(print.is_step_done(psWipeTower));
|
||||
|
||||
SparseRunResult r{};
|
||||
for (const std::vector<WipeTower::ToolChangeResult> &layer : print.wipe_tower_data().tool_changes) {
|
||||
if (layer.empty())
|
||||
continue;
|
||||
++r.planned;
|
||||
if (wipe_tower_layer_is_sparse(layer))
|
||||
++r.sparse;
|
||||
if (wipe_tower_layer_is_combined_away(layer)) {
|
||||
++r.folded;
|
||||
} else {
|
||||
r.tallest_printed = std::max(r.tallest_printed, layer.front().layer_height);
|
||||
r.printed_height += layer.front().layer_height;
|
||||
}
|
||||
}
|
||||
r.gcode = Slic3r::Test::gcode(print);
|
||||
return r;
|
||||
}
|
||||
|
||||
// How often the G-code declares `height` in the tag this printer's processor reads. The dialect is a
|
||||
// global the exporter sets from the printer, so this is only correct after a slice - the point below.
|
||||
static size_t count_height_tags(const std::string &gcode, const char *height)
|
||||
{
|
||||
const std::string tag = ";" + GCodeProcessor::reserved_tag(GCodeProcessor::ETags::Height) + height + "\n";
|
||||
size_t n = 0;
|
||||
for (size_t p = gcode.find(tag); p != std::string::npos; p = gcode.find(tag, p + 1))
|
||||
++n;
|
||||
return n;
|
||||
}
|
||||
|
||||
TEST_CASE("Combining sparse layers folds a run into whole layers the nozzle can lay down", "[WipeTower]")
|
||||
{
|
||||
// 0.1 mm layers under a 0.32 mm cap: three fit (0.3), a fourth does not, so a run prints once
|
||||
// every three layers at 0.3 mm.
|
||||
const SparseRunResult plain = slice_sparse_run(sparse_run_config(0.1, "0.32", false));
|
||||
const SparseRunResult combined = slice_sparse_run(sparse_run_config(0.1, "0.32", true));
|
||||
|
||||
REQUIRE(plain.planned == combined.planned); // the plan still has one layer per object layer
|
||||
REQUIRE(plain.sparse > 10);
|
||||
CHECK(plain.folded == 0);
|
||||
CHECK_THAT(plain.tallest_printed, Catch::Matchers::WithinAbs(0.1f, 1e-4f));
|
||||
|
||||
CHECK(combined.folded > 0);
|
||||
CHECK_THAT(combined.tallest_printed, Catch::Matchers::WithinAbs(0.3f, 1e-4f));
|
||||
// Two of every three sparse layers fold away, leaving the toolchange layers untouched.
|
||||
CHECK(combined.folded <= plain.sparse);
|
||||
CHECK(combined.folded >= plain.sparse / 2);
|
||||
// What folds away comes back as height on the layer that prints the run: no gap, nothing twice.
|
||||
CHECK_THAT(combined.printed_height, Catch::Matchers::WithinAbs(plain.printed_height, 1e-3f));
|
||||
}
|
||||
|
||||
TEST_CASE("A run too thin to reach the nozzle's layer height is left alone", "[WipeTower]")
|
||||
{
|
||||
// Only whole layers merge, so two 0.2 mm layers (0.4) do not fit a 0.32 mm maximum and the tower
|
||||
// prints as if the option were off. This is the common 0.4 nozzle case; the tooltip says so.
|
||||
const SparseRunResult plain = slice_sparse_run(sparse_run_config(0.2, "0.32", false));
|
||||
const SparseRunResult combined = slice_sparse_run(sparse_run_config(0.2, "0.32", true));
|
||||
|
||||
REQUIRE(plain.sparse > 10);
|
||||
CHECK(combined.folded == 0);
|
||||
CHECK(combined.planned == plain.planned);
|
||||
CHECK_THAT(combined.tallest_printed, Catch::Matchers::WithinAbs(0.2f, 1e-4f));
|
||||
}
|
||||
|
||||
TEST_CASE("A merged tower layer declares its own height to the G-code processor", "[WipeTower]")
|
||||
{
|
||||
// Each writer declares a height in a hardcoded tag dialect while the processor reads only its
|
||||
// printer's, so one of them is always dropped. A merged layer is the first time that shows, as a
|
||||
// thick layer drawn and costed as a thin one. 0.2 mm layers under a 0.42 mm maximum merge in pairs.
|
||||
const SparseRunResult plain = slice_sparse_run(sparse_run_config(0.2, "0.42", false));
|
||||
const SparseRunResult combined = slice_sparse_run(sparse_run_config(0.2, "0.42", true));
|
||||
|
||||
REQUIRE(combined.folded > 0);
|
||||
CHECK_THAT(combined.tallest_printed, Catch::Matchers::WithinAbs(0.4f, 1e-4f));
|
||||
// Every layer that prints a merged run has to say so, and nothing may say so without the option.
|
||||
CHECK(count_height_tags(combined.gcode, "0.4") - count_height_tags(plain.gcode, "0.4") == combined.folded);
|
||||
}
|
||||
|
||||
TEST_CASE("A tower printed without a tool change is still validated against the bed", "[WipeTower]")
|
||||
{
|
||||
// Wrapping detection prints a tower on a plate that purges one filament. Neither the old
|
||||
|
||||
@@ -490,6 +490,24 @@ TEST_CASE("save_to_json round-trips plugin capability references as strings", "[
|
||||
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
|
||||
}
|
||||
|
||||
TEST_CASE("load_from_json hands a preset's include list to the caller instead of the config", "[Config]") {
|
||||
ScopedTemporaryFile tmp(".json");
|
||||
{
|
||||
boost::nowide::ofstream ofs(tmp.string());
|
||||
ofs << R"({"type":"machine","name":"P","instantiation":"true","include":["T start","T end"],"machine_end_gcode":"M84"})";
|
||||
}
|
||||
DynamicPrintConfig config;
|
||||
ConfigSubstitutionContext substitutions(ForwardCompatibilitySubstitutionRule::Disable);
|
||||
std::map<std::string, std::string> key_values;
|
||||
std::string reason;
|
||||
REQUIRE(config.load_from_json(tmp.string(), substitutions, false, key_values, reason) == 0);
|
||||
CHECK(reason.empty());
|
||||
CHECK(key_values["include"] == R"(["T start","T end"])");
|
||||
CHECK_FALSE(config.has("include"));
|
||||
CHECK(substitutions.unrecogized_keys.empty());
|
||||
CHECK(config.opt_string("machine_end_gcode") == "M84");
|
||||
}
|
||||
|
||||
TEST_CASE("save_to_json writes the same document to a stream as to a file", "[Config]") {
|
||||
DynamicPrintConfig config;
|
||||
config.set_key_value("layer_height", new ConfigOptionFloat(0.2));
|
||||
|
||||
@@ -53,41 +53,64 @@ void write_vendor_tree(const fs::path& dir, const std::string& vendor, const std
|
||||
}
|
||||
|
||||
// A small but complete vendor: one machine model, one process, a non-instantiated
|
||||
// base filament with an instantiated child inheriting it, a second standalone
|
||||
// filament carrying explicit metadata, and one machine preset with a rename — so
|
||||
// the equivalence test below sees every CachedPreset field populated.
|
||||
void write_full_vendor_tree(const fs::path& dir, const std::string& vendor, const std::string& version)
|
||||
// base filament with an instantiated child that inherits it and includes a
|
||||
// dual-extruder template, a second standalone filament carrying explicit
|
||||
// metadata, and one machine preset with a rename that includes a G-code template
|
||||
// — so the equivalence test below sees every CachedPreset field populated. The
|
||||
// templates are listed before the presets that include them, as update-index
|
||||
// orders them, or after them when `templates_last` asks for the broken order.
|
||||
void write_full_vendor_tree(const fs::path& dir, const std::string& vendor, const std::string& version,
|
||||
bool templates_last = false)
|
||||
{
|
||||
fs::create_directories(dir / vendor / "process");
|
||||
fs::create_directories(dir / vendor / "filament");
|
||||
fs::create_directories(dir / vendor / "machine");
|
||||
const std::string filament_template = R"({"name":")" + vendor + R"( dual template","sub_path":"filament/template.json"})";
|
||||
const std::string filament_presets = R"({"name":")" + vendor + R"( Base PLA","sub_path":"filament/base.json"},)"
|
||||
R"({"name":")" + vendor + R"( PLA @0.4","sub_path":"filament/pla.json"},)"
|
||||
R"({"name":")" + vendor + R"( Silk PLA @0.4","sub_path":"filament/silk.json"})";
|
||||
const std::string machine_template = R"({"name":")" + vendor + R"( 0.4 template machine_start_gcode","sub_path":"machine/start.json"})";
|
||||
const std::string machine_presets = R"({"name":")" + vendor + R"( 0.4 nozzle","sub_path":"machine/printer.json"})";
|
||||
std::ofstream((dir / (vendor + ".json")).string())
|
||||
<< R"({"version":")" << version << R"(","name":")" << vendor << R"(",)"
|
||||
<< R"("machine_model_list":[{"name":"Test Model","sub_path":"machine/model.json"}],)"
|
||||
<< R"("process_list":[{"name":"0.20mm Standard @)" << vendor << R"(","sub_path":"process/standard.json"}],)"
|
||||
<< R"("filament_list":[)"
|
||||
<< R"({"name":")" << vendor << R"( Base PLA","sub_path":"filament/base.json"},)"
|
||||
<< R"({"name":")" << vendor << R"( PLA @0.4","sub_path":"filament/pla.json"},)"
|
||||
<< R"({"name":")" << vendor << R"( Silk PLA @0.4","sub_path":"filament/silk.json"}],)"
|
||||
<< R"("machine_list":[{"name":")" << vendor << R"( 0.4 nozzle","sub_path":"machine/printer.json"}]})";
|
||||
<< (templates_last ? filament_presets + "," + filament_template : filament_template + "," + filament_presets)
|
||||
<< R"(],"machine_list":[)"
|
||||
<< (templates_last ? machine_presets + "," + machine_template : machine_template + "," + machine_presets)
|
||||
<< "]}";
|
||||
std::ofstream((dir / vendor / "machine" / "model.json").string())
|
||||
<< R"({"type":"machine_model","name":"Test Model","nozzle_diameter":"0.4"})";
|
||||
std::ofstream((dir / vendor / "process" / "standard.json").string())
|
||||
<< R"({"type":"process","name":"0.20mm Standard @)" << vendor
|
||||
<< R"(","from":"system","instantiation":"true","layer_height":"0.2"})";
|
||||
// The base sets two per-variant keys; the template restates one of them for
|
||||
// two variants and adds a third; the child restates the third.
|
||||
std::ofstream((dir / vendor / "filament" / "base.json").string())
|
||||
<< R"({"type":"filament","name":")" << vendor
|
||||
<< R"( Base PLA","from":"system","instantiation":"false","filament_id":"GFA_base","filament_cost":"42"})";
|
||||
<< R"( Base PLA","from":"system","instantiation":"false","filament_id":"GFA_base","filament_cost":"42",)"
|
||||
<< R"("activate_air_filtration":["1"],"filament_max_volumetric_speed":["12"]})";
|
||||
std::ofstream((dir / vendor / "filament" / "template.json").string())
|
||||
<< R"({"type":"filament","name":")" << vendor << R"( dual template","from":"system","instantiation":"false",)"
|
||||
<< R"("filament_extruder_variant":["Direct Drive Standard","Direct Drive High Flow"],)"
|
||||
<< R"("filament_max_volumetric_speed":["20","22"],"filament_flush_temp":["0","0"]})";
|
||||
std::ofstream((dir / vendor / "filament" / "pla.json").string())
|
||||
<< R"({"type":"filament","name":")" << vendor
|
||||
<< R"( PLA @0.4","from":"system","instantiation":"true","filament_id":"GFA00","filament_cost":"20",)"
|
||||
<< R"("setting_id":"GFSA04","description":"Test PLA description"})";
|
||||
std::ofstream((dir / vendor / "filament" / "silk.json").string())
|
||||
<< R"({"type":"filament","name":")" << vendor
|
||||
<< R"( Silk PLA @0.4","from":"system","instantiation":"true","inherits":")" << vendor << R"( Base PLA"})";
|
||||
<< R"( Silk PLA @0.4","from":"system","instantiation":"true","inherits":")" << vendor << R"( Base PLA",)"
|
||||
<< R"("include":[")" << vendor << R"( dual template"],"filament_flush_temp":["5","5"]})";
|
||||
// The template states two G-codes; the printer restates one of them.
|
||||
std::ofstream((dir / vendor / "machine" / "start.json").string())
|
||||
<< R"({"type":"machine","name":")" << vendor << R"( 0.4 template machine_start_gcode","from":"system",)"
|
||||
<< R"("instantiation":"false","machine_start_gcode":"G28 ; template","machine_end_gcode":"M84 ; template"})";
|
||||
std::ofstream((dir / vendor / "machine" / "printer.json").string())
|
||||
<< R"({"type":"machine","name":")" << vendor
|
||||
<< R"( 0.4 nozzle","from":"system","instantiation":"true","printer_model":"Test Model","printer_variant":"0.4",)"
|
||||
<< R"("include":[")" << vendor << R"( 0.4 template machine_start_gcode"],"machine_end_gcode":"M84 ; own",)"
|
||||
<< R"("renamed_from":")" << vendor << R"( old 0.4 nozzle"})";
|
||||
}
|
||||
|
||||
@@ -598,6 +621,7 @@ TEST_CASE("a cache-loaded vendor is indistinguishable from a JSON-loaded one", "
|
||||
const Preset* pr = from_cache.printers.find_preset("Acme 0.4 nozzle", false);
|
||||
REQUIRE(pr != nullptr);
|
||||
CHECK(pr->renamed_from == std::vector<std::string>{"Acme old 0.4 nozzle"});
|
||||
CHECK(pr->config.opt_string("machine_start_gcode") == "G28 ; template"); // through the include
|
||||
}
|
||||
|
||||
TEST_CASE("a cache-served vendor reports the errors its parse counted", "[VendorCache]")
|
||||
@@ -1618,3 +1642,73 @@ TEST_CASE("a stamp string with an absurd length is rejected, not allocated", "[V
|
||||
CHECK(VendorCacheFile::peek_version(cache, "Evil").empty());
|
||||
}
|
||||
|
||||
TEST_CASE("an included template's keys land on the preset, between the parent's and its own", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
write_full_vendor_tree(dirs.system, "Acme", "1.0.0");
|
||||
PresetBundle bundle;
|
||||
bundle.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||
CHECK(bundle.error_count() == 0);
|
||||
|
||||
const Preset* pr = bundle.printers.find_preset("Acme 0.4 nozzle", false);
|
||||
REQUIRE(pr != nullptr);
|
||||
CHECK(pr->config.opt_string("machine_start_gcode") == "G28 ; template"); // from the include
|
||||
CHECK(pr->config.opt_string("machine_end_gcode") == "M84 ; own"); // the preset's own key wins
|
||||
CHECK(presets_for(bundle.printers, "Acme").size() == 1); // the template is no preset
|
||||
|
||||
const Preset* silk = bundle.filaments.find_preset("Acme Silk PLA @0.4", false);
|
||||
REQUIRE(silk != nullptr);
|
||||
const auto* speed = silk->config.option<ConfigOptionFloats>("filament_max_volumetric_speed");
|
||||
REQUIRE(speed != nullptr);
|
||||
CHECK(speed->values == std::vector<double>{20., 22.}); // the include wins over the parent
|
||||
const auto* flush = silk->config.option<ConfigOptionInts>("filament_flush_temp");
|
||||
REQUIRE(flush != nullptr);
|
||||
CHECK(flush->values == std::vector<int>{5, 5}); // the preset's own key wins
|
||||
// A per-variant key the template never mentions keeps the parent's value.
|
||||
// The loader pads every base to its variant count; an include taken from the
|
||||
// padded copy would carry the padded default [0,0] over the parent's 1.
|
||||
const auto* air = silk->config.option<ConfigOptionBools>("activate_air_filtration");
|
||||
REQUIRE(air != nullptr);
|
||||
CHECK(air->values == std::vector<unsigned char>{1, 1});
|
||||
CHECK(presets_for(bundle.filaments, "Acme").size() == 2); // the template is no preset
|
||||
}
|
||||
|
||||
TEST_CASE("an include listed after the preset that names it is an error, and the preset loads without it", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
write_full_vendor_tree(dirs.system, "Acme", "1.0.0", /*templates_last=*/true);
|
||||
PresetBundle bundle;
|
||||
bundle.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||
// One error per unresolved include: the printer's and the filament's.
|
||||
CHECK(bundle.error_count() == 2);
|
||||
const Preset* pr = bundle.printers.find_preset("Acme 0.4 nozzle", false);
|
||||
REQUIRE(pr != nullptr);
|
||||
CHECK(pr->config.opt_string("machine_start_gcode") != "G28 ; template");
|
||||
const Preset* silk = bundle.filaments.find_preset("Acme Silk PLA @0.4", false);
|
||||
REQUIRE(silk != nullptr);
|
||||
CHECK(silk->config.option<ConfigOptionFloats>("filament_max_volumetric_speed")->values == std::vector<double>{12.});
|
||||
}
|
||||
|
||||
TEST_CASE("a G-code template that states no instantiation is included, not loaded as a preset", "[VendorCache]")
|
||||
{
|
||||
InstallDirs dirs;
|
||||
write_full_vendor_tree(dirs.system, "Acme", "1.0.0");
|
||||
const fs::path start = dirs.system / "Acme" / "machine" / "start.json";
|
||||
SECTION("named as G-code") {
|
||||
std::ofstream(start.string()) << R"({"type":"machine","name":"Acme 0.4 template machine_start_gcode","from":"system",)"
|
||||
<< R"("machine_start_gcode":"G28 ; template"})";
|
||||
}
|
||||
SECTION("not named, so included by its name in the vendor index") {
|
||||
std::ofstream(start.string()) << R"({"type":"machine","from":"system","machine_start_gcode":"G28 ; template"})";
|
||||
}
|
||||
PresetBundle bundle;
|
||||
bundle.load_vendor_configs_from_json(dirs.system.string(), "Acme", PresetBundle::LoadSystem,
|
||||
ForwardCompatibilitySubstitutionRule::EnableSilent);
|
||||
CHECK(bundle.error_count() == 0);
|
||||
const Preset* pr = bundle.printers.find_preset("Acme 0.4 nozzle", false);
|
||||
REQUIRE(pr != nullptr);
|
||||
CHECK(pr->config.opt_string("machine_start_gcode") == "G28 ; template");
|
||||
CHECK(presets_for(bundle.printers, "Acme").size() == 1);
|
||||
}
|
||||
|
||||
@@ -278,6 +278,98 @@ TEST_CASE("Only the keep-out ring an object is measured against is drawn", "[Wip
|
||||
CHECK_THAT(unscaled(get_extents(zone.grown_body).max.x()), WithinAbs(10. + 0.5 * (40. - 0.2), 0.02));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// "Combine sparse layers": folding a run of toolchange-free layers into one thicker tower layer.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
TEST_CASE("Sparse layers are combined only when every layer is still the tower's to place", "[WipeTower][CombineSparseLayers]") {
|
||||
PrintConfig cfg;
|
||||
cfg.timelapse_type.value = TimelapseType::tlTraditional;
|
||||
cfg.enable_wrapping_detection.value = false;
|
||||
cfg.wipe_tower_no_sparse_layers.value = false;
|
||||
|
||||
cfg.wipe_tower_sparse_layers_combination.value = false;
|
||||
CHECK_FALSE(wipe_tower_sparse_layers_combined(cfg));
|
||||
cfg.wipe_tower_sparse_layers_combination.value = true;
|
||||
CHECK(wipe_tower_sparse_layers_combined(cfg));
|
||||
|
||||
// Dropping the sparse layers outright leaves nothing to combine.
|
||||
cfg.wipe_tower_no_sparse_layers.value = true;
|
||||
CHECK_FALSE(wipe_tower_sparse_layers_combined(cfg));
|
||||
CHECK(wipe_tower_sparse_layers_skipped(cfg));
|
||||
cfg.wipe_tower_no_sparse_layers.value = false;
|
||||
|
||||
// Both of these park the nozzle on the tower every layer, so no layer may be folded away.
|
||||
cfg.timelapse_type.value = TimelapseType::tlSmooth;
|
||||
CHECK_FALSE(wipe_tower_sparse_layers_combined(cfg));
|
||||
cfg.timelapse_type.value = TimelapseType::tlTraditional;
|
||||
cfg.enable_wrapping_detection.value = true;
|
||||
CHECK_FALSE(wipe_tower_sparse_layers_combined(cfg));
|
||||
}
|
||||
|
||||
TEST_CASE("A layer folded into a later one is marked on the results the emitter reads", "[WipeTower][CombineSparseLayers]") {
|
||||
WipeTower::ToolChangeResult folded = make_tcr(1, 1, 0.2f);
|
||||
folded.combined_away = true;
|
||||
CHECK(wipe_tower_layer_is_combined_away({folded}));
|
||||
CHECK_FALSE(wipe_tower_layer_is_combined_away({make_tcr(1, 1, 0.2f)}));
|
||||
CHECK_FALSE(wipe_tower_layer_is_combined_away({}));
|
||||
}
|
||||
|
||||
TEST_CASE("A run of sparse layers prints once, on its last layer, at the height it covers", "[WipeTower][CombineSparseLayers]") {
|
||||
// Eight 0.1 mm layers on a 0.3 mm cap: a toolchange on the first and the last, sparse between.
|
||||
std::vector<float> heights(8, 0.1f);
|
||||
const std::vector<char> sparse{0, 1, 1, 1, 1, 1, 1, 0};
|
||||
const std::vector<float> caps(8, 0.3f);
|
||||
|
||||
const std::vector<char> combined = combine_sparse_wipe_tower_layers(heights, sparse, caps, 0);
|
||||
REQUIRE(combined.size() == heights.size());
|
||||
// Three layers fill the cap exactly: the run flushes on layers 3 and 6, the two below each go.
|
||||
CHECK(combined == std::vector<char>{0, 1, 1, 0, 1, 1, 0, 0});
|
||||
CHECK_THAT(heights[3], WithinAbs(0.3f, 1e-5f));
|
||||
CHECK_THAT(heights[6], WithinAbs(0.3f, 1e-5f));
|
||||
// Layers that print keep the object covered: nothing is lost and nothing is printed twice.
|
||||
float printed = 0.f;
|
||||
for (size_t i = 0; i < heights.size(); ++i)
|
||||
if (! combined[i])
|
||||
printed += heights[i];
|
||||
CHECK_THAT(printed, WithinAbs(0.8f, 1e-5f));
|
||||
// A toolchange has to purge at its own z, so those layers are left exactly as planned.
|
||||
CHECK_THAT(heights[0], WithinAbs(0.1f, 1e-5f));
|
||||
CHECK_THAT(heights[7], WithinAbs(0.1f, 1e-5f));
|
||||
}
|
||||
|
||||
TEST_CASE("The maximum layer height of the nozzle that prints the run caps the merge", "[WipeTower][CombineSparseLayers]") {
|
||||
// The cap that counts belongs to the layer that prints the run; one that prints nothing lays
|
||||
// nothing down, so its own cap cannot constrain it. Five 0.1 mm layers, sparse above the first,
|
||||
// layer 3's nozzle taking only 0.15. (A real run holds one filament, so this only tests the
|
||||
// look-ahead.)
|
||||
std::vector<float> heights(5, 0.1f);
|
||||
std::vector<float> caps(5, 0.3f);
|
||||
caps[3] = 0.15f;
|
||||
const std::vector<char> combined = combine_sparse_wipe_tower_layers(heights, {0, 1, 1, 1, 1}, caps, 0);
|
||||
// Layer 2 cannot hand its 0.2 mm on to layer 3, so it prints there and a fresh run starts above.
|
||||
CHECK(combined == std::vector<char>{0, 1, 0, 1, 0});
|
||||
CHECK_THAT(heights[2], WithinAbs(0.2f, 1e-5f));
|
||||
CHECK_THAT(heights[4], WithinAbs(0.2f, 1e-5f));
|
||||
|
||||
// A single layer already past the cap is printed as planned rather than shrunk.
|
||||
std::vector<float> tall{0.2f, 0.4f, 0.4f};
|
||||
const std::vector<char> tall_combined = combine_sparse_wipe_tower_layers(tall, {0, 1, 1}, {0.3f, 0.3f, 0.3f}, 0);
|
||||
CHECK(tall_combined == std::vector<char>{0, 0, 0});
|
||||
CHECK_THAT(tall[1], WithinAbs(0.4f, 1e-5f));
|
||||
}
|
||||
|
||||
TEST_CASE("The tower's first layer is never folded away", "[WipeTower][CombineSparseLayers]") {
|
||||
// It carries the brim and has to sit on the bed, however little it purges.
|
||||
std::vector<float> heights(4, 0.1f);
|
||||
const std::vector<char> combined = combine_sparse_wipe_tower_layers(heights, {1, 1, 1, 1}, std::vector<float>(4, 0.5f), 0);
|
||||
CHECK(combined.front() == 0);
|
||||
CHECK_THAT(heights.front(), WithinAbs(0.1f, 1e-5f));
|
||||
// Everything above it merges into the top layer, which the cap still fits.
|
||||
CHECK(combined == std::vector<char>{0, 1, 1, 0});
|
||||
CHECK_THAT(heights.back(), WithinAbs(0.3f, 1e-5f));
|
||||
}
|
||||
|
||||
TEST_CASE("Footprint padding covers the brim and the extrusion half width on each side", "[WipeTower][NoSparseLayers]") {
|
||||
// A nominal outline hulls extrusion centre lines and is re-centred once the real wall is known,
|
||||
// so a line width per side on top of the brim is what keeps an estimate enclosing the real tower.
|
||||
|
||||
@@ -8,6 +8,10 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_device_progress.cpp
|
||||
test_device_manager_integration.cpp
|
||||
test_web_media_controller.cpp
|
||||
test_scene_raycaster.cpp
|
||||
test_lazy.cpp
|
||||
test_prebuild_queue.cpp
|
||||
test_staged_build.cpp
|
||||
test_network_versions.cpp
|
||||
test_action_source.cpp
|
||||
test_plugin_host_api.cpp
|
||||
@@ -22,6 +26,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_plugin_install.cpp
|
||||
test_plugin_lifecycle.cpp
|
||||
test_plugin_printer_agent.cpp
|
||||
test_printhost.cpp
|
||||
test_slicing_pipeline_bindings.cpp
|
||||
test_slicing_pipeline_config.cpp
|
||||
test_plugin_sort.cpp
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <vector>
|
||||
|
||||
#include "slic3r/GUI/Lazy.hpp"
|
||||
|
||||
using Slic3r::GUI::Lazy;
|
||||
using Slic3r::GUI::LazyBase;
|
||||
using Slic3r::GUI::LazyInstance;
|
||||
using Slic3r::GUI::StagedBuild;
|
||||
|
||||
namespace {
|
||||
|
||||
struct Plain
|
||||
{
|
||||
int value{ 1 };
|
||||
};
|
||||
|
||||
struct One : LazyInstance<One>
|
||||
{
|
||||
int value{ 2 };
|
||||
};
|
||||
|
||||
// Two steps after the constructor.
|
||||
struct Staged : StagedBuild, LazyInstance<Staged>
|
||||
{
|
||||
std::vector<int> ran;
|
||||
Staged()
|
||||
{
|
||||
add_build_step([this] { ran.push_back(1); });
|
||||
add_build_step([this] { ran.push_back(2); });
|
||||
}
|
||||
void add_step(std::function<void()> step) { add_build_step(std::move(step)); }
|
||||
};
|
||||
|
||||
// Owns what the factories make, since a Lazy does not.
|
||||
template <class T>
|
||||
struct Made
|
||||
{
|
||||
std::vector<std::unique_ptr<T>> objects;
|
||||
T* make()
|
||||
{
|
||||
objects.push_back(std::make_unique<T>());
|
||||
return objects.back().get();
|
||||
}
|
||||
typename Lazy<T>::Factory factory()
|
||||
{
|
||||
return [this] { return make(); };
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("The factory runs on the first unit, not at construction", "[Lazy]")
|
||||
{
|
||||
Made<Plain> made;
|
||||
Lazy<Plain> lazy("plain", 0, made.factory());
|
||||
REQUIRE(made.objects.empty());
|
||||
REQUIRE_FALSE(lazy.built());
|
||||
REQUIRE(lazy.pending());
|
||||
REQUIRE(lazy.get() == nullptr);
|
||||
|
||||
REQUIRE_FALSE(lazy.build_step()); // the only unit
|
||||
REQUIRE(made.objects.size() == 1);
|
||||
REQUIRE(lazy.built());
|
||||
REQUIRE_FALSE(lazy.pending());
|
||||
REQUIRE(lazy.get() == made.objects[0].get());
|
||||
REQUIRE_FALSE(lazy.build_step());
|
||||
REQUIRE(made.objects.size() == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("A staged type takes one unit for the constructor and one per step", "[Lazy]")
|
||||
{
|
||||
Made<Staged> made;
|
||||
Lazy<Staged> lazy("staged", 0, made.factory());
|
||||
REQUIRE(lazy.build_step());
|
||||
REQUIRE(made.objects.size() == 1);
|
||||
REQUIRE(lazy.get() == nullptr); // exists but incomplete
|
||||
REQUIRE(lazy.build_step());
|
||||
REQUIRE(made.objects[0]->ran == std::vector<int>{1});
|
||||
REQUIRE_FALSE(lazy.build_step());
|
||||
REQUIRE(made.objects[0]->ran == std::vector<int>{1, 2});
|
||||
REQUIRE(lazy.get() == made.objects[0].get());
|
||||
}
|
||||
|
||||
TEST_CASE("ensure builds whatever is left and is a no-op afterwards", "[Lazy]")
|
||||
{
|
||||
Made<Staged> made;
|
||||
Lazy<Staged> lazy("staged", 0, made.factory());
|
||||
lazy.build_step();
|
||||
Staged* s = lazy.ensure();
|
||||
REQUIRE(s == made.objects[0].get());
|
||||
REQUIRE(s->ran == std::vector<int>{1, 2});
|
||||
REQUIRE(lazy.ensure() == s);
|
||||
REQUIRE(made.objects.size() == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("when_built waits for completion, then runs at once", "[Lazy]")
|
||||
{
|
||||
Made<Staged> made;
|
||||
Lazy<Staged> lazy("staged", 0, made.factory());
|
||||
std::vector<int> seen;
|
||||
lazy.when_built([&](Staged& s) { seen.push_back(int(s.ran.size())); });
|
||||
lazy.build_step();
|
||||
lazy.build_step();
|
||||
REQUIRE(seen.empty());
|
||||
lazy.build_step();
|
||||
REQUIRE(seen == std::vector<int>{2});
|
||||
lazy.when_built([&](Staged&) { seen.push_back(9); });
|
||||
REQUIRE(seen == std::vector<int>{2, 9});
|
||||
}
|
||||
|
||||
TEST_CASE("A LazyInstance type reaches its holder through the statics", "[Lazy]")
|
||||
{
|
||||
REQUIRE(One::if_built() == nullptr);
|
||||
REQUIRE(One::ensure() == nullptr);
|
||||
Made<One> made;
|
||||
{
|
||||
Lazy<One> lazy("one", 0, made.factory());
|
||||
REQUIRE(One::if_built() == nullptr);
|
||||
One* one = One::ensure();
|
||||
REQUIRE(one == made.objects[0].get());
|
||||
REQUIRE(One::if_built() == one);
|
||||
int seen = 0;
|
||||
One::when_built([&](One& o) { seen = o.value; });
|
||||
REQUIRE(seen == 2);
|
||||
}
|
||||
REQUIRE(One::if_built() == nullptr);
|
||||
}
|
||||
|
||||
TEST_CASE("A newer holder replaces the registration; the older one leaves it alone", "[Lazy]")
|
||||
{
|
||||
Made<One> made;
|
||||
auto first = std::make_unique<Lazy<One>>("first", 0, made.factory());
|
||||
first->ensure();
|
||||
Lazy<One> second("second", 0, made.factory());
|
||||
REQUIRE(One::if_built() == nullptr); // the new holder has not built yet
|
||||
second.ensure();
|
||||
REQUIRE(One::if_built() == made.objects[1].get());
|
||||
first.reset();
|
||||
REQUIRE(One::if_built() == made.objects[1].get());
|
||||
}
|
||||
|
||||
TEST_CASE("The holder reports the name and order it was given", "[Lazy]")
|
||||
{
|
||||
Made<Plain> made;
|
||||
Lazy<Plain> lazy("plain", 7, made.factory());
|
||||
LazyBase& base = lazy;
|
||||
REQUIRE(base.name() == "plain");
|
||||
REQUIRE(base.prebuild_order() == 7);
|
||||
}
|
||||
|
||||
TEST_CASE("A unit that re-enters the holder builds nothing twice", "[Lazy]")
|
||||
{
|
||||
Made<Plain> made;
|
||||
Lazy<Plain>* self = nullptr;
|
||||
int nested_units = 0;
|
||||
Lazy<Plain> lazy("plain", 0, [&] {
|
||||
if (self->build_step()) // as if the constructor pumped the event loop into a slice
|
||||
++nested_units;
|
||||
return made.make();
|
||||
});
|
||||
self = &lazy;
|
||||
REQUIRE_FALSE(lazy.build_step());
|
||||
REQUIRE(nested_units == 0);
|
||||
REQUIRE(made.objects.size() == 1);
|
||||
REQUIRE(lazy.built());
|
||||
}
|
||||
|
||||
TEST_CASE("A factory that returns null leaves the holder unbuilt and not pending", "[Lazy]")
|
||||
{
|
||||
int calls = 0;
|
||||
Lazy<Plain> lazy("plain", 0, [&] { ++calls; return static_cast<Plain*>(nullptr); });
|
||||
REQUIRE_FALSE(lazy.build_step());
|
||||
REQUIRE_FALSE(lazy.built());
|
||||
REQUIRE_FALSE(lazy.pending());
|
||||
REQUIRE(lazy.get() == nullptr);
|
||||
REQUIRE_FALSE(lazy.build_step()); // not retried
|
||||
REQUIRE(calls == 1);
|
||||
}
|
||||
|
||||
TEST_CASE("ensure returns null for a factory that returned null", "[Lazy]")
|
||||
{
|
||||
Lazy<Plain> lazy("plain", 0, [] { return static_cast<Plain*>(nullptr); });
|
||||
REQUIRE(lazy.ensure() == nullptr);
|
||||
REQUIRE_FALSE(lazy.built());
|
||||
}
|
||||
|
||||
TEST_CASE("A nested ensure inside the factory returns null", "[Lazy]")
|
||||
{
|
||||
Made<Plain> made;
|
||||
Lazy<Plain>* self = nullptr;
|
||||
Plain* nested = reinterpret_cast<Plain*>(1);
|
||||
Lazy<Plain> lazy("plain", 0, [&] {
|
||||
nested = self->ensure(); // as if the constructor pumped the event loop into a caller
|
||||
return made.make();
|
||||
});
|
||||
self = &lazy;
|
||||
Plain* built = lazy.ensure();
|
||||
REQUIRE(built == made.objects[0].get());
|
||||
REQUIRE(nested == nullptr);
|
||||
}
|
||||
|
||||
TEST_CASE("A nested ensure during a staged step returns null", "[Lazy]")
|
||||
{
|
||||
Made<Staged> made;
|
||||
Lazy<Staged>* self = nullptr;
|
||||
Staged* nested = reinterpret_cast<Staged*>(1);
|
||||
Lazy<Staged> lazy("staged", 0, [&] {
|
||||
Staged* s = made.make();
|
||||
s->add_step([&] { nested = self->ensure(); }); // as if a step pumped the event loop into a caller
|
||||
return s;
|
||||
});
|
||||
self = &lazy;
|
||||
Staged* built = lazy.ensure();
|
||||
REQUIRE(built == made.objects[0].get());
|
||||
REQUIRE(nested == nullptr);
|
||||
}
|
||||
|
||||
TEST_CASE("A unit that throws leaves the holder free to build the rest", "[Lazy]")
|
||||
{
|
||||
Made<Staged> made;
|
||||
bool thrown = false;
|
||||
Lazy<Staged> lazy("staged", 0, [&] {
|
||||
Staged* s = made.make();
|
||||
s->add_step([&] { thrown = true; throw std::runtime_error("step"); });
|
||||
return s;
|
||||
});
|
||||
lazy.build_step();
|
||||
lazy.build_step();
|
||||
lazy.build_step();
|
||||
REQUIRE_THROWS(lazy.build_step());
|
||||
REQUIRE(thrown);
|
||||
REQUIRE(lazy.pending());
|
||||
REQUIRE_FALSE(lazy.build_step()); // the next unit runs
|
||||
REQUIRE(lazy.built());
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "slic3r/GUI/PrebuildQueue.hpp"
|
||||
|
||||
using Slic3r::GUI::LazyBase;
|
||||
using Slic3r::GUI::PrebuildQueue;
|
||||
|
||||
namespace {
|
||||
|
||||
// A task with `left` units, each taking `unit_ms` of the shared fake clock and logging its id.
|
||||
struct Counter : LazyBase
|
||||
{
|
||||
std::string id;
|
||||
int left;
|
||||
int order;
|
||||
long long unit_ms{ 1 };
|
||||
inline static std::vector<int> log;
|
||||
inline static long long now = 0;
|
||||
|
||||
Counter(int id, int left, int order, long long unit_ms = 1) : id(std::to_string(id)), left(left), order(order), unit_ms(unit_ms) {}
|
||||
|
||||
const std::string& name() const override { return id; }
|
||||
bool built() const override { return left == 0; }
|
||||
bool build_step() override
|
||||
{
|
||||
now += unit_ms;
|
||||
log.push_back(std::stoi(id));
|
||||
return --left > 0;
|
||||
}
|
||||
int prebuild_order() const override { return order; }
|
||||
};
|
||||
|
||||
// Resets the shared log and clock at the start of a case.
|
||||
struct Reset
|
||||
{
|
||||
Reset() { Counter::log.clear(); Counter::now = 0; }
|
||||
};
|
||||
|
||||
const auto fake_clock = [] { return Counter::now; };
|
||||
const auto no_input = [] { return false; };
|
||||
|
||||
// Runs slices with an unlimited budget until nothing is pending; one task per slice.
|
||||
void drain(PrebuildQueue& q)
|
||||
{
|
||||
while (q.pending())
|
||||
q.run_slice(1000000, fake_clock, no_input);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Tasks run lowest order first, equal order in the order added", "[PrebuildQueue]")
|
||||
{
|
||||
Reset reset;
|
||||
Counter a{ 1, 1, 10 }, b{ 2, 1, 10 }, c{ 3, 1, 50 }, d{ 4, 1, 100 };
|
||||
PrebuildQueue q;
|
||||
q.add(c);
|
||||
q.add(a);
|
||||
q.add(b);
|
||||
q.add(d);
|
||||
REQUIRE(q.names() == "1, 2, 3, 4");
|
||||
drain(q);
|
||||
REQUIRE(Counter::log == std::vector<int>{1, 2, 3, 4});
|
||||
}
|
||||
|
||||
TEST_CASE("A task with nothing pending is skipped, not removed", "[PrebuildQueue]")
|
||||
{
|
||||
Reset reset;
|
||||
Counter a{ 1, 0, 0 }, b{ 2, 2, 1 };
|
||||
PrebuildQueue q;
|
||||
q.add(a);
|
||||
q.add(b);
|
||||
REQUIRE(q.pending());
|
||||
auto slice = q.run_slice(1, fake_clock, no_input); // one unit of b
|
||||
REQUIRE(slice.units == 1);
|
||||
REQUIRE(Counter::log == std::vector<int>{2});
|
||||
|
||||
a.left = 1; // a's work returned; it comes first again
|
||||
q.run_slice(1, fake_clock, no_input);
|
||||
REQUIRE(Counter::log == std::vector<int>{2, 1});
|
||||
}
|
||||
|
||||
TEST_CASE("A slice with nothing pending runs no unit", "[PrebuildQueue]")
|
||||
{
|
||||
Reset reset;
|
||||
PrebuildQueue q;
|
||||
REQUIRE_FALSE(q.pending());
|
||||
auto slice = q.run_slice(40, fake_clock, no_input);
|
||||
REQUIRE(slice.units == 0);
|
||||
REQUIRE_FALSE(slice.completed);
|
||||
REQUIRE_FALSE(slice.remaining);
|
||||
}
|
||||
|
||||
TEST_CASE("A slice stops once its budget is spent, after the unit that crossed it", "[PrebuildQueue]")
|
||||
{
|
||||
Reset reset;
|
||||
Counter a{ 1, 10, 0, 15 };
|
||||
PrebuildQueue q;
|
||||
q.add(a);
|
||||
auto slice = q.run_slice(40, fake_clock, no_input);
|
||||
REQUIRE(slice.units == 3); // units end at 15, 30 and 45 ms; the one crossing 40 is the last
|
||||
REQUIRE(slice.ms == 45);
|
||||
REQUIRE_FALSE(slice.completed);
|
||||
REQUIRE(slice.remaining);
|
||||
REQUIRE(a.left == 7);
|
||||
}
|
||||
|
||||
TEST_CASE("A slice stops after the unit during which input arrived", "[PrebuildQueue]")
|
||||
{
|
||||
Reset reset;
|
||||
Counter a{ 1, 10, 0 };
|
||||
bool input = false;
|
||||
PrebuildQueue q;
|
||||
q.add(a);
|
||||
auto slice = q.run_slice(40, fake_clock, [&] { input = a.left == 8; return input; });
|
||||
REQUIRE(slice.units == 2);
|
||||
REQUIRE_FALSE(slice.completed);
|
||||
REQUIRE(slice.remaining);
|
||||
}
|
||||
|
||||
TEST_CASE("A slice reports completion, whether work remains, and each unit's time", "[PrebuildQueue]")
|
||||
{
|
||||
Reset reset;
|
||||
Counter a{ 1, 2, 0, 5 }, b{ 2, 1, 1 };
|
||||
std::vector<long long> unit_ms;
|
||||
PrebuildQueue q;
|
||||
q.add(a);
|
||||
q.add(b);
|
||||
auto slice = q.run_slice(40, fake_clock, no_input, [&](const std::string& name, long long ms) {
|
||||
REQUIRE(name == "1");
|
||||
unit_ms.push_back(ms);
|
||||
});
|
||||
REQUIRE(slice.units == 2);
|
||||
REQUIRE(slice.completed);
|
||||
REQUIRE(slice.name == "1");
|
||||
REQUIRE(slice.remaining); // b
|
||||
REQUIRE(unit_ms == std::vector<long long>{5, 5});
|
||||
slice = q.run_slice(40, fake_clock, no_input);
|
||||
REQUIRE(slice.completed);
|
||||
REQUIRE_FALSE(slice.remaining);
|
||||
REQUIRE_FALSE(q.pending());
|
||||
}
|
||||
|
||||
TEST_CASE("A unit may add a task to the queue it runs from", "[PrebuildQueue]")
|
||||
{
|
||||
Reset reset;
|
||||
PrebuildQueue q;
|
||||
Counter later{ 2, 1, 5 };
|
||||
struct Adder : LazyBase
|
||||
{
|
||||
PrebuildQueue& q;
|
||||
Counter& later;
|
||||
std::string id{ "1" };
|
||||
bool done{ false };
|
||||
Adder(PrebuildQueue& q, Counter& later) : q(q), later(later) {}
|
||||
const std::string& name() const override { return id; }
|
||||
bool built() const override { return done; }
|
||||
bool build_step() override
|
||||
{
|
||||
Counter::log.push_back(1);
|
||||
done = true;
|
||||
q.add(later);
|
||||
return false;
|
||||
}
|
||||
int prebuild_order() const override { return 0; }
|
||||
} first{ q, later };
|
||||
q.add(first);
|
||||
drain(q);
|
||||
REQUIRE(Counter::log == std::vector<int>{1, 2});
|
||||
}
|
||||
|
||||
TEST_CASE("clear drops every task", "[PrebuildQueue]")
|
||||
{
|
||||
Reset reset;
|
||||
Counter a{ 1, 1, 0 };
|
||||
PrebuildQueue q;
|
||||
q.add(a);
|
||||
q.clear();
|
||||
REQUIRE_FALSE(q.pending());
|
||||
REQUIRE(q.run_slice(40, fake_clock, no_input).units == 0);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "slic3r/Utils/PrintHost.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
class TestPrintHost : public PrintHost
|
||||
{
|
||||
public:
|
||||
using PrintHost::format_error;
|
||||
|
||||
const char* get_name() const override { return "Test"; }
|
||||
bool test(wxString&) const override { return true; }
|
||||
wxString get_test_ok_msg() const override { return {}; }
|
||||
wxString get_test_failed_msg(wxString&) const override { return {}; }
|
||||
bool upload(PrintHostUpload, ProgressFn, ErrorFn, InfoFn) const override { return true; }
|
||||
bool has_auto_discovery() const override { return false; }
|
||||
bool can_test() const override { return false; }
|
||||
PrintHostPostUploadActions get_post_upload_actions() const override { return {}; }
|
||||
std::string get_host() const override { return {}; }
|
||||
};
|
||||
|
||||
std::string format_error(const std::string& body, const std::string& error, unsigned status)
|
||||
{
|
||||
return TestPrintHost().format_error(body, error, status).ToStdString();
|
||||
}
|
||||
|
||||
std::string envelope(int code, const std::string& message, const std::string& traceback)
|
||||
{
|
||||
return nlohmann::json{{"error", {{"code", code}, {"message", message}, {"traceback", traceback}}}}.dump();
|
||||
}
|
||||
|
||||
std::string moonraker_error(int code, const std::string& message, const std::string& detail = {})
|
||||
{
|
||||
std::string line = "tornado.web.HTTPError: HTTP " + std::to_string(code) + ": " + message;
|
||||
if (!detail.empty())
|
||||
line += " (" + detail + ")";
|
||||
return envelope(code, message, "Traceback (most recent call last):\n ...\n" + line + "\n");
|
||||
}
|
||||
|
||||
// A real Moonraker body for uploading a file that is being printed.
|
||||
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";
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("A Klipper upload error shows its reason instead of a Python traceback", "[PrintHost][Regression]")
|
||||
{
|
||||
const std::string msg = format_error(k_busy_file_403, "", 403);
|
||||
INFO("actual: " << msg);
|
||||
|
||||
CHECK(msg == "HTTP 403: Forbidden (File is loaded, upload not permitted)");
|
||||
CHECK_THAT(msg, !Catch::Matchers::ContainsSubstring("Traceback"));
|
||||
CHECK_THAT(msg, !Catch::Matchers::ContainsSubstring("file_manager.py"));
|
||||
}
|
||||
|
||||
TEST_CASE("The specific cause is recovered from a file endpoint's traceback", "[PrintHost]")
|
||||
{
|
||||
SECTION("a plain detail")
|
||||
{
|
||||
const std::string body = moonraker_error(403, "Forbidden", "File is loaded, upload not permitted");
|
||||
CHECK(format_error(body, "", 403) == "HTTP 403: Forbidden (File is loaded, upload not permitted)");
|
||||
}
|
||||
|
||||
SECTION("a detail whose own parentheses nest (a filename)")
|
||||
{
|
||||
const std::string detail = "Directory does not exist (/home/pi/gcodes/plate (1).gcode)";
|
||||
const std::string body = moonraker_error(400, "Bad Request", detail);
|
||||
CHECK(format_error(body, "", 400) == "HTTP 400: Bad Request (" + detail + ")");
|
||||
}
|
||||
|
||||
SECTION("a detail that contains the reason phrase")
|
||||
{
|
||||
const std::string body = moonraker_error(403, "Forbidden", "Forbidden zone: access denied");
|
||||
CHECK(format_error(body, "", 403) == "HTTP 403: Forbidden (Forbidden zone: access denied)");
|
||||
}
|
||||
|
||||
SECTION("a detail that spans lines")
|
||||
{
|
||||
const std::string detail = "Move out of range\nX=250.000 Y=10.000";
|
||||
const std::string body = moonraker_error(400, "Bad Request", detail);
|
||||
CHECK(format_error(body, "", 400) == "HTTP 400: Bad Request (" + detail + ")");
|
||||
}
|
||||
|
||||
SECTION("a detail that only repeats the reason phrase is dropped")
|
||||
{
|
||||
const std::string body = moonraker_error(401, "Unauthorized", "Unauthorized");
|
||||
CHECK(format_error(body, "", 401) == "HTTP 401: Unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("An unhandled exception shows its type and message", "[PrintHost]")
|
||||
{
|
||||
const std::string frame = "Traceback (most recent call last):\n"
|
||||
" File \"/home/pi/moonraker/moonraker/components/file_manager/file_manager.py\", line 1, in write\n"
|
||||
" self._write(data)\n";
|
||||
|
||||
SECTION("a one-line message")
|
||||
{
|
||||
const std::string body = envelope(500, "Internal Server Error", frame + "OSError: [Errno 28] No space left on device\n");
|
||||
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (OSError: [Errno 28] No space left on device)");
|
||||
}
|
||||
|
||||
SECTION("a message that spans lines")
|
||||
{
|
||||
const std::string body = envelope(500, "Internal Server Error", frame + "ServerError: Klippy request failed\n see klippy.log\n");
|
||||
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (ServerError: Klippy request failed\n see klippy.log)");
|
||||
}
|
||||
|
||||
SECTION("raised while handling an HTTPError with the same code")
|
||||
{
|
||||
const std::string traceback = frame + "tornado.web.HTTPError: HTTP 500: Internal Server Error (Database locked)\n\n"
|
||||
"During handling of the above exception, another exception occurred:\n\n" +
|
||||
frame + "OSError: [Errno 5] Input/output error\n";
|
||||
const std::string body = envelope(500, "Internal Server Error", traceback);
|
||||
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (OSError: [Errno 5] Input/output error)");
|
||||
}
|
||||
|
||||
SECTION("a traceback with no header")
|
||||
{
|
||||
const std::string body = envelope(500, "Internal Server Error", "OSError: [Errno 5] Input/output error");
|
||||
CHECK(format_error(body, "", 500) == "HTTP 500: Internal Server Error (OSError: [Errno 5] Input/output error)");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("A reason already complete in message is shown unchanged", "[PrintHost]")
|
||||
{
|
||||
SECTION("message is the whole reason, no trailing detail")
|
||||
{
|
||||
const std::string body = moonraker_error(503, "Klippy is not ready");
|
||||
CHECK(format_error(body, "", 503) == "HTTP 503: Klippy is not ready");
|
||||
}
|
||||
|
||||
SECTION("a message that itself contains parentheses is not duplicated")
|
||||
{
|
||||
const std::string reason = "Requested blocks (0-5) are unavailable";
|
||||
const std::string body = moonraker_error(400, reason);
|
||||
CHECK(format_error(body, "", 400) == "HTTP 400: " + reason);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("A Moonraker error with no usable detail shows just the reason phrase", "[PrintHost]")
|
||||
{
|
||||
struct Case
|
||||
{
|
||||
const char* name;
|
||||
const char* body;
|
||||
unsigned status;
|
||||
const char* expected;
|
||||
};
|
||||
|
||||
const auto c = GENERATE(
|
||||
Case{"an empty traceback", R"JSON({"error": {"code": 500, "message": "Internal Server Error", "traceback": ""}})JSON", 500,
|
||||
"HTTP 500: Internal Server Error"},
|
||||
Case{"a traceback of only whitespace", R"JSON({"error": {"code": 500, "message": "Internal Server Error", "traceback": "\n \n"}})JSON",
|
||||
500, "HTTP 500: Internal Server Error"});
|
||||
|
||||
DYNAMIC_SECTION(c.name) { CHECK(format_error(c.body, "", c.status) == c.expected); }
|
||||
}
|
||||
|
||||
TEST_CASE("A percent sign in the reason is not a format specifier", "[PrintHost]")
|
||||
{
|
||||
const std::string body = moonraker_error(507, "Insufficient Storage", "disk 100% full");
|
||||
CHECK(format_error(body, "", 507) == "HTTP 507: Insufficient Storage (disk 100% full)");
|
||||
}
|
||||
|
||||
TEST_CASE("Error bodies that are not a Moonraker envelope are left unchanged", "[PrintHost]")
|
||||
{
|
||||
SECTION("OctoPrint's string-valued error member")
|
||||
{
|
||||
const std::string body = R"JSON({"error": "File not found"})JSON";
|
||||
CHECK(format_error(body, "", 404) == "HTTP 404: " + body);
|
||||
}
|
||||
|
||||
SECTION("PrusaLink's top-level message, not under error")
|
||||
{
|
||||
const std::string body = R"JSON({"title": "Conflict", "message": "Printer is printing"})JSON";
|
||||
CHECK(format_error(body, "", 409) == "HTTP 409: " + body);
|
||||
}
|
||||
|
||||
SECTION("a body that is not JSON")
|
||||
{
|
||||
const std::string html = "<html><head><title>502 Bad Gateway</title></head></html>";
|
||||
CHECK(format_error(html, "", 502) == "HTTP 502: " + html);
|
||||
}
|
||||
|
||||
SECTION("an error object with no traceback")
|
||||
{
|
||||
const std::string body = R"JSON({"error": {"code": 500, "message": "Internal Server Error"}})JSON";
|
||||
CHECK(format_error(body, "", 500) == "HTTP 500: " + body);
|
||||
}
|
||||
|
||||
SECTION("an error object whose traceback is null")
|
||||
{
|
||||
const std::string body = R"JSON({"error": {"code": 500, "message": "Internal Server Error", "traceback": null}})JSON";
|
||||
CHECK(format_error(body, "", 500) == "HTTP 500: " + body);
|
||||
}
|
||||
|
||||
SECTION("an envelope whose reason phrase is empty")
|
||||
{
|
||||
const std::string body = envelope(403, "", "Traceback (most recent call last):\nOSError: denied\n");
|
||||
CHECK(format_error(body, "", 403) == "HTTP 403: " + body);
|
||||
}
|
||||
|
||||
SECTION("a transport error with no HTTP status")
|
||||
{
|
||||
CHECK(format_error("", "curl:Could not connect", 0) == "curl:Could not connect");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
// Orca: This suite links libslic3r_gui; navigation raycasts need no wx application or GL context.
|
||||
#ifdef WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
// Match the GUI precompiled header: wx/msw/wrapcctl.h needs HDITEM from CommCtrl.h.
|
||||
#include <CommCtrl.h>
|
||||
#endif
|
||||
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "slic3r/GUI/Camera.hpp"
|
||||
#include "slic3r/GUI/CameraUtils.hpp"
|
||||
#include "slic3r/GUI/PartPlate.hpp"
|
||||
#include "slic3r/GUI/SceneRaycaster.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::GUI;
|
||||
|
||||
namespace {
|
||||
|
||||
Camera horizontal_camera(Camera::EType type = Camera::EType::Perspective)
|
||||
{
|
||||
Camera camera;
|
||||
camera.set_type(type);
|
||||
camera.look_at({0.0, 0.0, 10.0}, {0.0, 100.0, 10.0}, Vec3d::UnitZ());
|
||||
camera.set_viewport(0, 0, 600, 600);
|
||||
camera.apply_projection(-1.0, 1.0, -1.0, 1.0, 1.0, 1000.0);
|
||||
return camera;
|
||||
}
|
||||
|
||||
SceneRaycaster::HitResult scene_hit(const SceneRaycaster& scene, const Camera& camera, const Vec3d& point,
|
||||
SceneRaycaster::EHitMode mode = SceneRaycaster::EHitMode::SceneOnly)
|
||||
{
|
||||
return scene.hit(CameraUtils::project(camera, point).cast<double>(), camera, nullptr, mode);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Navigation hits the visible bed below a horizontal perspective view", "[SceneRaycaster][Regression]")
|
||||
{
|
||||
const MeshRaycaster bed(TriangleMesh(
|
||||
{{-100.f, 20.f, 0.f}, {100.f, 20.f, 0.f}, {100.f, 200.f, 0.f}, {-100.f, 200.f, 0.f}},
|
||||
{{0, 1, 2}, {0, 2, 3}}));
|
||||
SceneRaycaster scene;
|
||||
scene.add_raycaster(SceneRaycaster::EType::Bed, 0, bed, Transform3d::Identity());
|
||||
const Camera camera = horizontal_camera();
|
||||
|
||||
const auto hit = scene_hit(scene, camera, {0.0, 100.0, 0.0});
|
||||
REQUIRE(hit.is_valid());
|
||||
CHECK(hit.type == SceneRaycaster::EType::Bed);
|
||||
CHECK_THAT(hit.position.z(), Catch::Matchers::WithinAbs(0.0, 1e-4));
|
||||
}
|
||||
|
||||
TEST_CASE("Navigation hits a visible side face away from the perspective view center", "[SceneRaycaster][Regression]")
|
||||
{
|
||||
const auto mode = GENERATE(SceneRaycaster::EHitMode::SceneOnly, SceneRaycaster::EHitMode::VolumesOnly);
|
||||
const MeshRaycaster side(TriangleMesh(
|
||||
{{10.f, 20.f, -100.f}, {10.f, 20.f, 100.f}, {10.f, 200.f, 100.f}, {10.f, 200.f, -100.f}},
|
||||
{{0, 1, 2}, {0, 2, 3}}));
|
||||
SceneRaycaster scene;
|
||||
scene.add_raycaster(SceneRaycaster::EType::Volume, 0, side, Transform3d::Identity());
|
||||
const Camera camera = horizontal_camera();
|
||||
|
||||
const auto hit = scene_hit(scene, camera, {10.0, 100.0, 10.0}, mode);
|
||||
REQUIRE(hit.is_valid());
|
||||
CHECK(hit.type == SceneRaycaster::EType::Volume);
|
||||
CHECK_THAT(hit.position.x(), Catch::Matchers::WithinAbs(10.0, 1e-4));
|
||||
}
|
||||
|
||||
TEST_CASE("Navigation ignores gizmos and inactive volumes and chooses the nearest scene surface", "[SceneRaycaster]")
|
||||
{
|
||||
const bool gizmos_on_top = GENERATE(false, true);
|
||||
const auto mode = GENERATE(SceneRaycaster::EHitMode::SceneOnly, SceneRaycaster::EHitMode::VolumesOnly);
|
||||
const auto type = GENERATE(Camera::EType::Perspective, Camera::EType::Ortho);
|
||||
const MeshRaycaster cube(make_cube(20.0, 20.0, 20.0));
|
||||
SceneRaycaster scene;
|
||||
scene.set_gizmos_on_top(gizmos_on_top);
|
||||
scene.add_raycaster(SceneRaycaster::EType::Gizmo, 0, cube, Geometry::translation_transform({-10.0, 20.0, 0.0}));
|
||||
scene.add_raycaster(SceneRaycaster::EType::FallbackGizmo, 0, cube, Geometry::translation_transform({-10.0, 30.0, 0.0}));
|
||||
scene.add_raycaster(SceneRaycaster::EType::Volume, 0, cube, Geometry::translation_transform({-10.0, 40.0, 0.0}))->set_active(false);
|
||||
scene.add_raycaster(SceneRaycaster::EType::Volume, 1, cube, Geometry::translation_transform({-10.0, 150.0, 0.0}));
|
||||
scene.add_raycaster(SceneRaycaster::EType::Volume, 2, cube, Geometry::translation_transform({-10.0, 100.0, 0.0}));
|
||||
const Camera camera = horizontal_camera(type);
|
||||
|
||||
const auto hit = scene_hit(scene, camera, {0.0, 100.0, 10.0}, mode);
|
||||
REQUIRE(hit.is_valid());
|
||||
CHECK(hit.type == SceneRaycaster::EType::Volume);
|
||||
CHECK(hit.raycaster_id == 2);
|
||||
CHECK_THAT(hit.position.y(), Catch::Matchers::WithinAbs(100.0, 1e-4));
|
||||
}
|
||||
|
||||
TEST_CASE("Navigation skips bed raycasters when the bed is hidden", "[SceneRaycaster][Regression]")
|
||||
{
|
||||
const auto mode = GENERATE(SceneRaycaster::EHitMode::SceneOnly, SceneRaycaster::EHitMode::VolumesOnly);
|
||||
const auto type = GENERATE(Camera::EType::Perspective, Camera::EType::Ortho);
|
||||
const bool looking_downward = GENERATE(false, true);
|
||||
const MeshRaycaster cube(make_cube(20.0, 20.0, 20.0));
|
||||
SceneRaycaster scene;
|
||||
scene.add_raycaster(SceneRaycaster::EType::Bed, 0, cube, Geometry::translation_transform({-10.0, 40.0, 0.0}));
|
||||
scene.add_raycaster(SceneRaycaster::EType::Volume, 0, cube, Geometry::translation_transform({-10.0, 100.0, 0.0}));
|
||||
Camera camera = horizontal_camera(type);
|
||||
if (looking_downward)
|
||||
camera.look_at({0.0, 0.0, 10.0}, {0.0, 100.0, 0.0}, Vec3d::UnitZ());
|
||||
|
||||
const auto hit = scene_hit(scene, camera, {0.0, 100.0, 10.0}, mode);
|
||||
REQUIRE(hit.is_valid());
|
||||
CHECK(hit.type == (mode == SceneRaycaster::EHitMode::SceneOnly ?
|
||||
SceneRaycaster::EType::Bed : SceneRaycaster::EType::Volume));
|
||||
|
||||
scene.remove_raycasters(SceneRaycaster::EType::Volume);
|
||||
CHECK(scene_hit(scene, camera, {0.0, 100.0, 10.0}, mode).is_valid() ==
|
||||
(mode == SceneRaycaster::EHitMode::SceneOnly));
|
||||
}
|
||||
|
||||
TEST_CASE("Navigation ignores plate controls while retaining plate surfaces and volumes", "[SceneRaycaster][Regression]")
|
||||
{
|
||||
const int plate_index = GENERATE(0, 2);
|
||||
const int component = GENERATE(range(1, int(PartPlate::GRABBER_COUNT)));
|
||||
const int bed_id = plate_index * PartPlate::GRABBER_COUNT;
|
||||
const MeshRaycaster cube(make_cube(20.0, 20.0, 20.0));
|
||||
SceneRaycaster scene;
|
||||
scene.add_raycaster(SceneRaycaster::EType::Bed, bed_id + component, cube,
|
||||
Geometry::translation_transform({-10.0, 40.0, 0.0}));
|
||||
scene.add_raycaster(SceneRaycaster::EType::Bed, bed_id, cube,
|
||||
Geometry::translation_transform({-10.0, 100.0, 0.0}));
|
||||
scene.add_raycaster(SceneRaycaster::EType::Volume, 0, cube,
|
||||
Geometry::translation_transform({-10.0, 150.0, 0.0}));
|
||||
const Camera camera = horizontal_camera();
|
||||
|
||||
auto hit = scene_hit(scene, camera, {0.0, 100.0, 10.0});
|
||||
REQUIRE(hit.is_valid());
|
||||
CHECK(hit.type == SceneRaycaster::EType::Bed);
|
||||
CHECK(hit.raycaster_id == bed_id);
|
||||
|
||||
scene.remove_raycasters(SceneRaycaster::EType::Bed, bed_id);
|
||||
hit = scene_hit(scene, camera, {0.0, 100.0, 10.0});
|
||||
REQUIRE(hit.is_valid());
|
||||
CHECK(hit.type == SceneRaycaster::EType::Volume);
|
||||
|
||||
// A control alone must leave navigation free to choose its fallback anchor.
|
||||
scene.remove_raycasters(SceneRaycaster::EType::Volume);
|
||||
CHECK_FALSE(scene_hit(scene, camera, {0.0, 100.0, 10.0}).is_valid());
|
||||
}
|
||||
|
||||
TEST_CASE("Navigation respects the back-face policy away from the perspective view center", "[SceneRaycaster]")
|
||||
{
|
||||
const bool use_back_faces = GENERATE(false, true);
|
||||
const auto mode = GENERATE(SceneRaycaster::EHitMode::SceneOnly, SceneRaycaster::EHitMode::VolumesOnly);
|
||||
const MeshRaycaster side(TriangleMesh(
|
||||
{{10.f, 20.f, -100.f}, {10.f, 20.f, 100.f}, {10.f, 200.f, 100.f}, {10.f, 200.f, -100.f}},
|
||||
{{0, 2, 1}, {0, 3, 2}}));
|
||||
SceneRaycaster scene;
|
||||
scene.add_raycaster(SceneRaycaster::EType::Volume, 0, side, Transform3d::Identity(), use_back_faces);
|
||||
|
||||
const auto hit = scene_hit(scene, horizontal_camera(), {10.0, 100.0, 10.0}, mode);
|
||||
CHECK(hit.is_valid() == use_back_faces);
|
||||
}
|
||||
|
||||
TEST_CASE("Navigation ignores volume surfaces removed by the clipping plane", "[SceneRaycaster]")
|
||||
{
|
||||
const auto mode = GENERATE(SceneRaycaster::EHitMode::SceneOnly, SceneRaycaster::EHitMode::VolumesOnly);
|
||||
const MeshRaycaster cube(make_cube(20.0, 20.0, 20.0));
|
||||
SceneRaycaster scene;
|
||||
scene.add_raycaster(SceneRaycaster::EType::Volume, 0, cube, Geometry::translation_transform({-10.0, 100.0, 0.0}));
|
||||
scene.add_raycaster(SceneRaycaster::EType::Volume, 1, cube, Geometry::translation_transform({-10.0, 150.0, 0.0}));
|
||||
const Camera camera = horizontal_camera();
|
||||
const ClippingPlane clipping_plane(-Vec3d::UnitY(), -130.0);
|
||||
|
||||
const auto hit = scene.hit({300.0, 300.0}, camera, &clipping_plane, mode);
|
||||
REQUIRE(hit.is_valid());
|
||||
CHECK(hit.raycaster_id == 1);
|
||||
CHECK_THAT(hit.position.y(), Catch::Matchers::WithinAbs(150.0, 1e-4));
|
||||
}
|
||||
@@ -120,6 +120,20 @@ TEST_CASE("Only modified or non-printable chords qualify as menu accelerators",
|
||||
CHECK(registry.accelerator(Shortcut::KeyboardShortcuts).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Chords the desktop keeps for itself are recognized", "[Shortcuts]")
|
||||
{
|
||||
#ifdef _WIN32
|
||||
CHECK(KeyChord{ WXK_F4, wxMOD_ALT }.is_system_shortcut());
|
||||
CHECK(KeyChord{ WXK_SPACE, wxMOD_ALT }.is_system_shortcut());
|
||||
#else
|
||||
CHECK_FALSE(KeyChord{ WXK_F4, wxMOD_ALT }.is_system_shortcut());
|
||||
CHECK_FALSE(KeyChord{ WXK_SPACE, wxMOD_ALT }.is_system_shortcut());
|
||||
#endif
|
||||
CHECK_FALSE(KeyChord{ WXK_F4, wxMOD_ALT | wxMOD_SHIFT }.is_system_shortcut());
|
||||
CHECK_FALSE(KeyChord{ WXK_F4, wxMOD_CONTROL }.is_system_shortcut());
|
||||
CHECK_FALSE(KeyChord{ WXK_SPACE }.is_system_shortcut());
|
||||
}
|
||||
|
||||
TEST_CASE("Chords convert to wx accelerator entries", "[Shortcuts]")
|
||||
{
|
||||
const wxAcceleratorEntry entry = KeyChord{ 'S', wxMOD_CONTROL | wxMOD_SHIFT }.to_accelerator_entry(42);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include "slic3r/GUI/StagedBuild.hpp"
|
||||
|
||||
using Slic3r::GUI::StagedBuild;
|
||||
|
||||
namespace {
|
||||
|
||||
// Exposes the protected queueing calls and records the order steps ran in.
|
||||
struct Staged : StagedBuild
|
||||
{
|
||||
std::vector<int> ran;
|
||||
void queue(int id) { add_build_step([this, id] { ran.push_back(id); }); }
|
||||
void queue_child(Staged& child) { add_build_steps_of(child); }
|
||||
void queue_nested(int id, int nested)
|
||||
{
|
||||
add_build_step([this, id, nested] {
|
||||
ran.push_back(id);
|
||||
queue(nested);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Steps run in the order they were queued, one per build_step", "[StagedBuild]")
|
||||
{
|
||||
Staged s;
|
||||
s.queue(1);
|
||||
s.queue(2);
|
||||
s.queue(3);
|
||||
REQUIRE_FALSE(s.built());
|
||||
|
||||
REQUIRE(s.build_step());
|
||||
REQUIRE(s.ran == std::vector<int>{1});
|
||||
REQUIRE(s.build_step());
|
||||
REQUIRE(s.ran == std::vector<int>{1, 2});
|
||||
REQUIRE_FALSE(s.build_step());
|
||||
REQUIRE(s.ran == std::vector<int>{1, 2, 3});
|
||||
REQUIRE(s.built());
|
||||
REQUIRE_FALSE(s.build_step());
|
||||
REQUIRE(s.ran.size() == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("A panel with no steps is built from the start", "[StagedBuild]")
|
||||
{
|
||||
Staged s;
|
||||
REQUIRE(s.built());
|
||||
REQUIRE_FALSE(s.build_step());
|
||||
}
|
||||
|
||||
TEST_CASE("A step may queue another step, which runs after the ones already queued", "[StagedBuild]")
|
||||
{
|
||||
Staged s;
|
||||
s.queue_nested(1, 3);
|
||||
s.queue(2);
|
||||
|
||||
REQUIRE(s.build_step());
|
||||
REQUIRE_FALSE(s.built());
|
||||
REQUIRE(s.build_step());
|
||||
REQUIRE_FALSE(s.build_step());
|
||||
REQUIRE(s.ran == std::vector<int>{1, 2, 3});
|
||||
REQUIRE(s.built());
|
||||
}
|
||||
|
||||
TEST_CASE("A parent waits for steps a child queues after being adopted", "[StagedBuild]")
|
||||
{
|
||||
Staged child;
|
||||
child.queue_nested(1, 2); // step 1 queues step 2 while it runs
|
||||
Staged parent;
|
||||
parent.queue_child(child); // one forwarder, for step 1
|
||||
parent.queue(10);
|
||||
|
||||
REQUIRE(parent.build_step()); // child step 1, which queues step 2
|
||||
REQUIRE(parent.build_step()); // 10; own steps exhausted, the child still has 2
|
||||
REQUIRE_FALSE(parent.built());
|
||||
REQUIRE_FALSE(parent.build_step()); // child step 2
|
||||
REQUIRE(child.ran == std::vector<int>{1, 2});
|
||||
REQUIRE(parent.ran == std::vector<int>{10});
|
||||
REQUIRE(parent.built());
|
||||
}
|
||||
|
||||
TEST_CASE("A child's remaining steps are forwarded one per parent step", "[StagedBuild]")
|
||||
{
|
||||
Staged child;
|
||||
child.queue(1);
|
||||
child.queue(2);
|
||||
child.queue(3);
|
||||
REQUIRE(child.build_step()); // the parent adopts only what is left
|
||||
|
||||
Staged parent;
|
||||
parent.queue_child(child);
|
||||
parent.queue(10);
|
||||
|
||||
REQUIRE(parent.build_step());
|
||||
REQUIRE(child.ran == std::vector<int>{1, 2});
|
||||
REQUIRE(parent.build_step());
|
||||
REQUIRE(child.ran == std::vector<int>{1, 2, 3});
|
||||
REQUIRE(child.built());
|
||||
REQUIRE_FALSE(parent.build_step());
|
||||
REQUIRE(parent.ran == std::vector<int>{10});
|
||||
REQUIRE(parent.built());
|
||||
}
|
||||
Reference in New Issue
Block a user