Merge upstream, and move the IMEX placeholders onto the new def macro

Eleven commits, including a typed-config G-code export speedup, a printer agent
refactor that generalizes the infrastructure beyond Bambu, gyroid optimization,
and three CLI crash fixes.

One conflict, in PrintConfig.cpp. Upstream introduced a new_def macro and began
migrating the placeholder table onto it, adding curr_bed_type that way in the
same block where this branch had added imex_mode, imex_mode_index and
imex_mode_gcode in the older def = this->add(...) form. Both sides are kept and
ours are converted to the macro, which expands to the same three statements and
wraps label and tooltip in L() exactly as before, so nothing changes about what
is registered or what is translatable.

Note for anyone building this branch: the agent refactor adds a dependency,
LibDataChannel, so the deps tree needs dep_DataChannel built before the app will
configure. A distribution package of the same name will be found first if one is
installed, and the resulting error names a missing RelWithDebInfo location
rather than the wrong package, so point LibDataChannel_DIR at the dependency
prefix if that happens.

Verified: 789 targets build clean under -Werror, and the Release suite passes
1665 of 1665, up from 1630 before the merge -- the 35 new cases arrived with it
and all pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Clifford Garwood
2026-09-30 23:55:21 -04:00
co-authored by Claude Opus 5
344 changed files with 5686 additions and 12023 deletions
+13 -3
View File
@@ -57,18 +57,28 @@ function(orcaslicer_copy_test_dlls)
# into it). Copy them next to the executable and give it an $ORIGIN
# rpath so the loader finds them when the tests run on the CI unit-test
# runner, which only receives this build/tests tree.
get_target_property(_linked_libs ${_TEST_NAME}_tests LINK_LIBRARIES)
#
# Optional first arg: the target to patch, for directories that define
# more than one test executable. Defaults to ${_TEST_NAME}_tests so
# existing single-executable callers don't need to pass it.
if (ARGC GREATER 0)
set(_target ${ARGV0})
else()
set(_target ${_TEST_NAME}_tests)
endif()
get_target_property(_linked_libs ${_target} LINK_LIBRARIES)
if (NOT "libslic3r_gui" IN_LIST _linked_libs)
return()
endif()
set_property(TARGET ${_TEST_NAME}_tests PROPERTY BUILD_RPATH "$ORIGIN")
set_property(TARGET ${_target} PROPERTY BUILD_RPATH "$ORIGIN")
set(_configs ${CMAKE_CONFIGURATION_TYPES})
if (NOT _configs)
set(_configs "${CMAKE_BUILD_TYPE}")
endif()
foreach(_cfg IN LISTS _configs)
orcaslicer_copy_sos(${_TEST_NAME}_tests "${_cfg}" "" _unused_sos)
orcaslicer_copy_sos(${_target} "${_cfg}" "" _unused_sos)
endforeach()
endif()
endfunction()
+8
View File
@@ -15,3 +15,11 @@ set_tests_properties(cli_strict_mode PROPERTIES
LABELS "CLI;RequiresApp"
SKIP_RETURN_CODE 77
TIMEOUT 900)
add_test(NAME cli_project_missing_keys
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/test_cli_project_missing_keys.sh $<TARGET_FILE:OrcaSlicer> ${ORCA_CLI_TEST_PYTHON}
${CMAKE_SOURCE_DIR}/resources/profiles/BBL)
set_tests_properties(cli_project_missing_keys PROPERTIES
LABELS "CLI;RequiresApp"
SKIP_RETURN_CODE 77
TIMEOUT 900)
@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# End-to-end check that the CLI loads a project's printer and process settings as the GUI does.
#
# The GUI takes every key a project does not list as changed from the project's current system preset:
# keys saved before an option existed, and keys holding an older system value. Keys the project lists
# in different_settings_to_system keep the project's value. A project is exported from the shipped
# Bambu Lab P1S presets; one printer key and one process key are removed, one printer key and one
# process key are changed without being listed, one key is changed and listed, and it is sliced again.
#
# usage: test_cli_project_missing_keys.sh <orca-slicer binary> <python3> <resources/profiles/BBL>
set -u
BIN="${1:-}"
PY="${2:-python3}"
PROFILES="${3:-}"
# 77 is the test's SKIP_RETURN_CODE.
[ -x "$BIN" ] || { echo "SKIP: orca-slicer binary not found: $BIN"; exit 77; }
[ -d "$PROFILES" ] || { echo "FAIL: profiles directory not found: $PROFILES"; exit 1; }
WORK="$(mktemp -d "${TMPDIR:-/tmp}/orca-cli-missing-keys.XXXXXX")"
trap 'rm -rf "$WORK"' EXIT
"$PY" - "$WORK/cube.stl" <<'EOF'
import sys
v = [(x, y, z) for z in (0, 10) for y in (0, 10) for x in (0, 10)]
with open(sys.argv[1], "w") as f:
f.write("solid cube\n")
for a, b, c, d in ((0, 2, 3, 1), (4, 5, 7, 6), (0, 1, 5, 4), (2, 6, 7, 3), (0, 4, 6, 2), (1, 3, 7, 5)):
for tri in ((v[a], v[b], v[c]), (v[a], v[c], v[d])):
f.write("facet normal 0 0 0\nouter loop\n")
for p in tri:
f.write("vertex %g %g %g\n" % p)
f.write("endloop\nendfacet\n")
f.write("endsolid cube\n")
EOF
# slice <tag> <input> [option...]: slice into $WORK/<tag>/out.3mf with a fresh data directory.
slice() {
local out="$WORK/$1" input="$2"; shift 2
mkdir -p "$out"
timeout 300 "$BIN" --datadir "$out/datadir" "$@" --slice 0 --outputdir "$out" --export-3mf out.3mf "$input" \
> "$out/log" 2>&1 || { echo "FAIL: $1: orca-slicer exited $?"; tail -n 40 "$out/log"; exit 1; }
}
slice base "$WORK/cube.stl" \
--load-settings "$PROFILES/machine/Bambu Lab P1S 0.4 nozzle.json;$PROFILES/process/0.20mm Standard @BBL X1C.json" \
--load-filaments "$PROFILES/filament/Bambu PLA Basic @BBL P1S 0.4 nozzle.json"
# The removed keys, with their option defaults from PrintConfig.cpp; stale keys changed without being
# listed as different, which must come back with the system value; and a listed key the project keeps.
"$PY" - "$WORK/base/out.3mf" "$WORK/old.3mf" <<'EOF' || exit $?
import json, sys, zipfile
src, dst = sys.argv[1], sys.argv[2]
missing = {"extruder_clearance_dist_to_rod": "40", "sparse_infill_density": "20%"}
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
data = zin.read(item.filename)
if item.filename == "Metadata/project_settings.config":
config = json.loads(data)
for key, default in missing.items():
if config[key] == default:
print("SKIP: %s is %s in the system preset, the option default, so the test cannot tell them apart" % (key, default))
sys.exit(77)
expected = {key: config.pop(key) for key in missing}
for key in ("top_shell_layers", "extruder_clearance_height_to_rod"):
expected[key] = config[key]
config[key] = str(int(float(config[key])) + 1)
expected["wall_loops"] = str(int(config["wall_loops"]) + 1)
config["wall_loops"] = expected["wall_loops"]
different = config["different_settings_to_system"]
different[0] = ";".join([k for k in different[0].split(";") if k] + ["wall_loops"])
data = json.dumps(config, indent=4)
zout.writestr(item, data)
with open(dst + ".expected.json", "w") as f:
json.dump(expected, f)
EOF
slice project "$WORK/old.3mf"
"$PY" - "$WORK/project/out.3mf" "$WORK/old.3mf.expected.json" <<'EOF'
import json, sys, zipfile
with zipfile.ZipFile(sys.argv[1]) as z:
config = json.loads(z.read("Metadata/project_settings.config"))
with open(sys.argv[2]) as f:
expected = json.load(f)
errors = ["%s is %r, want %r" % (key, config.get(key), want) for key, want in expected.items() if config.get(key) != want]
for e in errors:
print("FAIL: " + e)
sys.exit(1 if errors else 0)
EOF
status=$?
[ "$status" -eq 0 ] || { tail -n 40 "$WORK/project/log"; exit 1; }
echo "PASS"
+56
View File
@@ -13,6 +13,7 @@
#include "libslic3r/AABBTreeLines.hpp"
#include "libslic3r/Fill/Fill.hpp"
#include "libslic3r/Fill/FillAdaptive.hpp"
#include "libslic3r/Fill/FillGyroid.hpp"
#include "libslic3r/Flow.hpp"
#include "libslic3r/Geometry.hpp"
#include "libslic3r/IntersectionPoints.hpp"
@@ -1257,6 +1258,61 @@ TEST_CASE("Multiline infill of an object matches the infill of a larger object w
}
}
TEST_CASE("Gyroid infill of an object matches the infill of a larger object with the same center", "[Fill]")
{
const bool optimized = GENERATE(false, true);
const int multiline = GENERATE(1, 2);
const float density = GENERATE(0.05f, 0.2f);
const double spacing = 0.45;
CAPTURE(optimized, multiline, density);
auto circle = [](double radius) {
Polygon contour = make_circle_num_segments(scale_(radius), 120);
contour.translate(Point::new_scale(100., 60.));
return ExPolygon(std::move(contour));
};
const ExPolygon object = circle(20.);
const ExPolygon larger = circle(30.);
auto fill = [optimized, multiline, density, spacing](const ExPolygon &region, double z) {
std::unique_ptr<Fill> filler(Fill::new_from_type(ipGyroid));
filler->spacing = spacing;
filler->angle = float(M_PI / 7.);
filler->z = z;
FillParams params;
params.density = density;
params.multiline = multiline;
params.gyroid_optimized = optimized;
params.dont_adjust = true;
Surface surface(stInternal, region);
return filler->fill_surface(&surface, params);
};
// Away from the boundary of the object, where both are clipped and connected the same way.
const Polygons inner = shrink(to_polygons(object), scale_(1.));
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);
};
// Marching squares simplifies rings that start elsewhere in each object.
const double tolerance = optimized ? SPARSE_INFILL_RESOLUTION + 0.01 : 0.01;
// Half a z period of the waves, through both switches between horizontal and vertical waves.
const double wave_distance = spacing * multiline / (density * FillGyroid::DensityAdjust);
for (int step = 0; step <= 8; ++step) {
const double z = wave_distance * M_PI * step / 8.;
CAPTURE(z);
const Polylines paths = fill(object, z);
REQUIRE_FALSE(paths.empty());
const Polylines reference = fill(larger, z);
CHECK(farthest(reference, paths) < tolerance);
CHECK(farthest(paths, reference) < tolerance);
}
}
TEST_CASE("Multiline cubic infill follows the cubic lines without crossing itself", "[Fill]")
{
const int multiline = GENERATE(2, 3);
+70
View File
@@ -1492,3 +1492,73 @@ TEST_CASE("Static print configs compare, order and hash by their option values",
REQUIRE(c.optptr("gcode_flavor") == &c.gcode_flavor);
}
}
namespace {
// Keys whose values differ between two full configs, compared as text so enum names count too.
std::vector<std::string> differing_keys(const FullPrintConfig &a, const FullPrintConfig &b)
{
std::vector<std::string> keys;
for (const std::string &key : a.keys())
if (a.opt_serialize(key) != b.opt_serialize(key))
keys.push_back(key);
return keys;
}
// Applies source to one full config member by member and to another key by key, as apply() did before
// static configs could apply themselves.
template<class Source> void check_member_apply_matches_key_apply(const Source &source)
{
FullPrintConfig by_member;
FullPrintConfig by_key;
by_member.apply(source);
by_key.apply_only(source, source.keys());
CHECK(differing_keys(by_member, by_key).empty());
CHECK_FALSE(differing_keys(by_member, FullPrintConfig()).empty());
}
} // namespace
TEST_CASE("A static config applies itself onto a config of its type as a lookup by name would", "[Config]")
{
SECTION("region config")
{
PrintRegionConfig region;
region.sparse_infill_pattern.value = ipGyroid;
region.outer_wall_speed.values = {37.};
region.sparse_infill_density.value = 35.;
FullPrintConfig full;
REQUIRE(region.apply_to(full));
check_member_apply_matches_key_apply(region);
}
SECTION("object config")
{
PrintObjectConfig object;
object.seam_position.value = spRear;
object.wall_generator.value = PerimeterGeneratorType::Arachne;
object.support_speed.values = {33.};
object.enable_support.value = true;
FullPrintConfig full;
REQUIRE(object.apply_to(full));
check_member_apply_matches_key_apply(object);
}
SECTION("G-code config, whose enum lists carry their names through a keys map")
{
GCodeConfig gcode;
gcode.z_hop_types.values = {int(zhtSpiral)};
gcode.retraction_length.values = {1.5};
FullPrintConfig full;
REQUIRE(gcode.apply_to(full));
check_member_apply_matches_key_apply(gcode);
}
}
TEST_CASE("A static config applied onto a config of another type falls back to a lookup by name", "[Config]")
{
PrintRegionConfig region;
region.sparse_infill_pattern.value = ipGyroid;
DynamicPrintConfig dynamic;
REQUIRE_FALSE(region.apply_to(dynamic));
dynamic.apply(region);
CHECK(dynamic.opt_serialize("sparse_infill_pattern") == "gyroid");
}
@@ -5572,6 +5572,35 @@ struct ScopedDataDir
~ScopedDataDir() { set_data_dir(previous); }
};
// resources_dir() is process-wide too; system preset lookups scan its profiles directory.
struct ScopedResourcesDir
{
std::string previous = resources_dir();
explicit ScopedResourcesDir(const fs::path &dir) { set_resources_dir(dir.string()); }
~ScopedResourcesDir() { set_resources_dir(previous); }
};
// An "Acme" vendor under root whose "Acme Printer" inherits extruder_clearance_dist_to_rod from an
// abstract base, with the printer in a nested sub_path so the name cannot be derived from the file.
void write_acme_printer_vendor(const fs::path &root, double dist_to_rod)
{
const fs::path machine_dir = root / "Acme" / "machine";
fs::create_directories(machine_dir / "nested");
std::ofstream((root / "Acme.json").string())
<< R"({"version":"1.0.0","name":"Acme",)"
<< R"("machine_model_list":[{"name":"Acme One","sub_path":"machine/model.json"}],"machine_list":[)"
<< R"({"name":"fdm_acme_common","sub_path":"machine/base.json"},)"
<< R"({"name":"Acme Printer","sub_path":"machine/nested/printer.json"}]})";
std::ofstream((machine_dir / "model.json").string())
<< R"({"type":"machine_model","name":"Acme One","nozzle_diameter":"0.4"})";
std::ofstream((machine_dir / "base.json").string())
<< R"({"type":"machine","name":"fdm_acme_common","from":"system","instantiation":"false",)"
<< R"("extruder_clearance_dist_to_rod":")" << dist_to_rod << R"("})";
std::ofstream((machine_dir / "nested" / "printer.json").string())
<< R"({"type":"machine","name":"Acme Printer","from":"system","instantiation":"true","inherits":"fdm_acme_common",)"
<< R"("printer_model":"Acme One","printer_variant":"0.4"})";
}
std::string read_file(const fs::path &file)
{
std::ifstream in(file.string(), std::ios::binary);
@@ -5706,3 +5735,230 @@ TEST_CASE("A project saved with pressure advance per filament applies it to ever
check_double_vector(pla.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values, { 0.95, 0.96 });
check_double_vector(petg.opt<ConfigOptionFloatsNullable>("filament_flow_ratio")->values, { 0.97 });
}
TEST_CASE("A system preset resolves by name from the bundled profiles", "[Preset][Bundle]")
{
ScopedTemporaryDir temp_dir;
ScopedDataDir data(temp_dir.path() / "data");
ScopedResourcesDir resources(temp_dir.path() / "resources");
write_acme_printer_vendor(temp_dir.path() / "resources" / "profiles", 33.);
PresetBundle bundle;
DynamicPrintConfig config;
std::string error;
REQUIRE(bundle.resolve_system_preset(config, Preset::TYPE_PRINTER, "Acme Printer",
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
CHECK(error.empty());
CHECK_THAT(config.opt_float("extruder_clearance_dist_to_rod"), Catch::Matchers::WithinAbs(33., 1e-6));
}
TEST_CASE("A system preset resolves from the data directory copy of its vendor", "[Preset][Bundle]")
{
ScopedTemporaryDir temp_dir;
ScopedDataDir data(temp_dir.path() / "data");
ScopedResourcesDir resources(temp_dir.path() / "resources");
write_acme_printer_vendor(temp_dir.path() / "resources" / "profiles", 33.);
write_acme_printer_vendor(temp_dir.path() / "data" / PRESET_SYSTEM_DIR, 35.);
PresetBundle bundle;
DynamicPrintConfig config;
std::string error;
REQUIRE(bundle.resolve_system_preset(config, Preset::TYPE_PRINTER, "Acme Printer",
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
CHECK_THAT(config.opt_float("extruder_clearance_dist_to_rod"), Catch::Matchers::WithinAbs(35., 1e-6));
}
TEST_CASE("A system preset resolves from a vendor shipped as its preset cache alone", "[Preset][Bundle]")
{
ScopedTemporaryDir temp_dir;
ScopedDataDir data(temp_dir.path() / "data");
ScopedResourcesDir resources(temp_dir.path() / "resources");
const fs::path profiles = temp_dir.path() / "resources" / "profiles";
write_acme_printer_vendor(profiles, 33.);
PresetBundle writer;
writer.set_generate_vendor_caches(true);
writer.load_vendor_configs_from_json(profiles.string(), "Acme", PresetBundle::LoadSystem,
ForwardCompatibilitySubstitutionRule::EnableSilent);
REQUIRE(fs::exists(profiles / "Acme.opc"));
// Release builds ship the cache and drop the profile JSONs, manifest included.
fs::remove(profiles / "Acme.json");
fs::remove_all(profiles / "Acme");
PresetBundle bundle;
DynamicPrintConfig config;
std::string error;
REQUIRE(bundle.resolve_system_preset(config, Preset::TYPE_PRINTER, "Acme Printer",
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
CHECK_THAT(config.opt_float("extruder_clearance_dist_to_rod"), Catch::Matchers::WithinAbs(33., 1e-6));
}
TEST_CASE("A system preset no vendor lists is not resolved", "[Preset][Bundle]")
{
ScopedTemporaryDir temp_dir;
ScopedDataDir data(temp_dir.path() / "data");
ScopedResourcesDir resources(temp_dir.path() / "resources");
write_acme_printer_vendor(temp_dir.path() / "resources" / "profiles", 33.);
PresetBundle bundle;
DynamicPrintConfig config;
std::string error;
CHECK_FALSE(bundle.resolve_system_preset(config, Preset::TYPE_PRINTER, "Unknown Printer",
ForwardCompatibilitySubstitutionRule::EnableSilent, error));
CHECK_FALSE(error.empty());
}
namespace {
// A default preset config for type, built the way PresetBundle builds its default presets.
DynamicPrintConfig external_default_config(Preset::Type type)
{
DynamicPrintConfig config;
config.apply_only(static_cast<const PrintRegionConfig &>(FullPrintConfig::defaults()),
type == Preset::TYPE_PRINTER ? Preset::printer_options() : Preset::print_options());
Preset::inherits(config);
return config;
}
// A find_base callback that counts its calls and returns base.
auto base_finder(DynamicPrintConfig *base, int &calls)
{
return [base, &calls](const std::string &) {
++calls;
return base;
};
}
} // namespace
TEST_CASE("A key missing from a project takes the default preset value when there is no base", "[Preset][ExternalPreset]")
{
DynamicPrintConfig defaults = external_default_config(Preset::TYPE_PRINT);
defaults.set("wall_loops", 7, true);
const DynamicPrintConfig project;
int calls = 0;
const DynamicPrintConfig config = Preset::load_external_config(Preset::TYPE_PRINT, defaults, project, {"sparse_infill_density"},
base_finder(nullptr, calls));
CHECK(calls == 1);
CHECK(config.opt_int("wall_loops") == 7);
}
TEST_CASE("A key missing from a project takes its base preset value", "[Preset][ExternalPreset]")
{
DynamicPrintConfig defaults = external_default_config(Preset::TYPE_PRINT);
defaults.set("wall_loops", 7, true);
DynamicPrintConfig base = defaults;
base.set("wall_loops", 5, true);
const DynamicPrintConfig project;
int calls = 0;
const DynamicPrintConfig config = Preset::load_external_config(Preset::TYPE_PRINT, defaults, project, {"sparse_infill_density"},
base_finder(&base, calls));
CHECK(config.opt_int("wall_loops") == 5);
}
TEST_CASE("A project key listed as different keeps the project value", "[Preset][ExternalPreset]")
{
const DynamicPrintConfig defaults = external_default_config(Preset::TYPE_PRINT);
DynamicPrintConfig base = defaults;
base.set("wall_loops", 5, true);
DynamicPrintConfig project;
project.set("wall_loops", 4, true);
int calls = 0;
const DynamicPrintConfig config = Preset::load_external_config(Preset::TYPE_PRINT, defaults, project, {"wall_loops"},
base_finder(&base, calls));
CHECK(config.opt_int("wall_loops") == 4);
}
TEST_CASE("A project key not listed as different is refreshed to the base value", "[Preset][ExternalPreset]")
{
const DynamicPrintConfig defaults = external_default_config(Preset::TYPE_PRINT);
DynamicPrintConfig base = defaults;
base.set("wall_loops", 5, true);
DynamicPrintConfig project;
project.set("wall_loops", 4, true);
project.set("inherits", "Base Process", true);
std::string inherits;
const DynamicPrintConfig config = Preset::load_external_config(Preset::TYPE_PRINT, defaults, project, {"sparse_infill_density"},
[&base, &inherits](const std::string &name) {
inherits = name;
return &base;
});
CHECK(inherits == "Base Process");
CHECK(config.opt_int("wall_loops") == 5);
}
TEST_CASE("An empty different settings list keeps the project values without a base", "[Preset][ExternalPreset]")
{
DynamicPrintConfig defaults = external_default_config(Preset::TYPE_PRINT);
defaults.set("top_shell_layers", 7, true);
DynamicPrintConfig base = defaults;
base.set("wall_loops", 5, true);
base.set("top_shell_layers", 9, true);
DynamicPrintConfig project;
project.set("wall_loops", 4, true);
int calls = 0;
const DynamicPrintConfig config = Preset::load_external_config(Preset::TYPE_PRINT, defaults, project, {}, base_finder(&base, calls));
CHECK(calls == 0);
CHECK(config.opt_int("wall_loops") == 4);
CHECK(config.opt_int("top_shell_layers") == 7);
}
TEST_CASE("Print-host keys are never taken from a project", "[Preset][ExternalPreset]")
{
DynamicPrintConfig defaults = external_default_config(Preset::TYPE_PRINTER);
defaults.set("print_host", "", true);
defaults.set("printhost_apikey", "", true);
DynamicPrintConfig project;
project.set("print_host", "http://project-host", true);
project.set("printhost_apikey", "project-key", true);
project.set("printer_notes", "project notes", true);
t_config_option_keys keys;
int calls = 0;
const DynamicPrintConfig config = Preset::load_external_config(Preset::TYPE_PRINTER, defaults, project, {"printer_notes"},
base_finder(nullptr, calls), &keys);
CHECK(config.opt_string("print_host").empty());
CHECK(config.opt_string("printhost_apikey").empty());
CHECK(config.opt_string("printer_notes") == "project notes");
CHECK_FALSE(contains_key(keys, "print_host"));
CHECK_FALSE(contains_key(keys, "printhost_apikey"));
CHECK(contains_key(keys, "printer_notes"));
}
TEST_CASE("A per-variant project value maps onto its base preset's variant layout", "[Preset][ExternalPreset]")
{
const DynamicPrintConfig defaults = external_default_config(Preset::TYPE_PRINT);
DynamicPrintConfig base = defaults;
base.set_key_value("print_extruder_id", new ConfigOptionInts({1, 1}));
base.set_key_value("print_extruder_variant", new ConfigOptionStrings({"Direct Drive Standard", "Direct Drive High Flow"}));
base.set_key_value("outer_wall_speed", new ConfigOptionFloats({200., 300.}));
base.set_key_value("inner_wall_speed", new ConfigOptionFloats({250., 350.}));
// A project saved before its printer had a High Flow variant.
DynamicPrintConfig project;
project.set_key_value("print_extruder_id", new ConfigOptionInts({1}));
project.set_key_value("print_extruder_variant", new ConfigOptionStrings({"Direct Drive Standard"}));
project.set_key_value("outer_wall_speed", new ConfigOptionFloats({100.}));
project.set_key_value("inner_wall_speed", new ConfigOptionFloats({150.}));
int calls = 0;
const DynamicPrintConfig config = Preset::load_external_config(Preset::TYPE_PRINT, defaults, project, {"outer_wall_speed"},
base_finder(&base, calls));
CHECK(config.option<ConfigOptionStrings>("print_extruder_variant")->values ==
std::vector<std::string>{"Direct Drive Standard", "Direct Drive High Flow"});
// The listed key keeps the project's Standard value and takes High Flow from the base.
check_double_vector(config.option<ConfigOptionFloats>("outer_wall_speed")->values, {100., 300.});
check_double_vector(config.option<ConfigOptionFloats>("inner_wall_speed")->values, {250., 350.});
}
TEST_CASE("A project's different settings always keep the preset bookkeeping keys", "[Preset][ExternalPreset]")
{
const std::set<std::string> keys = PresetBundle::project_different_keys(escape_strings_cstyle({"wall_loops", "top_shell_layers"}));
for (const char *key : {"wall_loops", "top_shell_layers", "inherits", "print_settings_id", "filament_settings_id", "printer_settings_id"})
CHECK(keys.count(key) == 1);
CHECK(PresetBundle::project_different_keys(std::string()).count("inherits") == 1);
}
+6 -1
View File
@@ -5,6 +5,9 @@ add_executable(${_TEST_NAME}_tests
test_creality_cfs_match.cpp
test_dev_mapping.cpp
test_filament_bitmap_utils.cpp
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
@@ -17,6 +20,8 @@ add_executable(${_TEST_NAME}_tests
test_plugin_capability_config.cpp
test_plugin_config.cpp
test_plugin_capabilities_in_use.cpp
test_plugin_status.cpp
test_printer_agent.cpp
test_plugin_install.cpp
test_plugin_lifecycle.cpp
test_plugin_printer_agent.cpp
@@ -39,7 +44,7 @@ endif ()
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r_gui libslic3r pybind11::embed Catch2::Catch2WithMain)
set_property(TARGET ${_TEST_NAME}_tests PROPERTY FOLDER "tests")
orcaslicer_copy_test_dlls()
orcaslicer_copy_test_dlls(${_TEST_NAME}_tests)
if (WIN32)
add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD
+31
View File
@@ -26,6 +26,37 @@
using json = nlohmann::json;
using namespace Slic3r;
TEST_CASE("AMS tray placeholder state follows the latest status", "[DevFilaSystem]")
{
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
const json empty_slot = json::parse(R"({
"ams": {
"tray_exist_bits": "0",
"ams": [ { "id": "0", "info": "00000001", "tray": [
{ "id": "0", "tray_slot_placeholder": "1", "tray_color": "00000000" }
] } ]
}
})");
DevFilaSystemParser::ParseV1_0(empty_slot, &obj, obj.GetFilaSystem().get(), false);
DevAmsTray* tray = obj.GetFilaSystem()->GetAmsTray("0", "0");
REQUIRE(tray != nullptr);
REQUIRE(tray->is_slot_placeholder);
const json loaded_slot = json::parse(R"({
"ams": {
"tray_exist_bits": "1",
"ams": [ { "id": "0", "info": "00000001", "tray": [
{ "id": "0", "tray_color": "FF0000FF" }
] } ]
}
})");
DevFilaSystemParser::ParseV1_0(loaded_slot, &obj, obj.GetFilaSystem().get(), false);
CHECK_FALSE(tray->is_slot_placeholder);
}
TEST_CASE("Switch-bound AMS trays map to the left extruder", "[DevMapping]")
{
MachineObject obj(nullptr, nullptr, "test", "test_dev", "127.0.0.1");
@@ -0,0 +1,159 @@
#include <catch2/catch_all.hpp>
#include <slic3r/GUI/DeviceCore/DevManager.h>
#include <slic3r/GUI/DeviceManager.hpp>
#include <libslic3r/AppConfig.hpp>
#include <slic3r/Utils/NetworkAgent.hpp>
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp>
#include <slic3r/Utils/OrcaPrinterAgent.hpp>
#include <nlohmann/json.hpp>
#include <memory>
#include <cstdint>
#include <string>
#include <utility>
using namespace Slic3r;
using json = nlohmann::json;
namespace {
class StubCloudAgent final : public OrcaCloudServiceAgent
{
public:
StubCloudAgent() : OrcaCloudServiceAgent("") {}
int get_user_print_info(unsigned int* http_code, std::string* http_body) override
{
if (http_code)
*http_code = 200;
if (http_body)
*http_body = R"({"devices":[]})";
return 0;
}
std::string get_user_name() override { return "integration-test-user"; }
};
class TestPrinterAgent final : public OrcaPrinterAgent
{
public:
explicit TestPrinterAgent(std::string id)
: OrcaPrinterAgent(""), m_info{std::move(id), "Integration Test Agent", "1.0", "test agent"}
{
}
AgentInfo get_agent_info() override { return m_info; }
private:
AgentInfo m_info;
};
struct ScopedAppConfig
{
AppConfig config;
};
std::string machine_list_response(const std::string& provider, const std::string& agent_id,
std::uint64_t generation, const std::string& name)
{
json machine;
machine["dev_id"] = "integration-device";
machine["dev_name"] = name;
machine["dev_online"] = true;
machine["task_status"] = "idle";
json response;
response["provider"] = provider;
response["agent_id"] = agent_id;
response["generation"] = generation;
response["devices"] = json::array({machine});
return response.dump();
}
} // namespace
TEST_CASE("Network agent stamps user-machine responses with request context", "[DeviceManager][integration]")
{
auto cloud = std::make_shared<StubCloudAgent>();
NetworkAgent network(cloud, nullptr);
network.set_printer_agent(std::make_shared<TestPrinterAgent>("integration-agent-a"));
const std::uint64_t generation_before = network.get_user_machine_list_generation();
unsigned int http_code = 0;
std::string body;
REQUIRE(network.get_user_print_info(&http_code, &body, ORCA_CLOUD_PROVIDER) == 0);
const json response = json::parse(body);
CHECK(http_code == 200);
CHECK(response["provider"] == ORCA_CLOUD_PROVIDER);
CHECK(response["agent_id"] == "integration-agent-a");
CHECK(response["generation"] == generation_before + 1);
}
TEST_CASE("Device manager ignores stale cloud machine responses", "[DeviceManager][integration]")
{
ScopedAppConfig app_config;
NetworkAgent network(nullptr, std::make_shared<TestPrinterAgent>("integration-agent"));
network.set_printer_agent(std::make_shared<TestPrinterAgent>("integration-agent"));
DeviceManager manager(&network, false, &app_config.config);
const std::uint64_t current_generation = network.get_user_machine_list_generation();
manager.parse_user_print_info(machine_list_response(ORCA_CLOUD_PROVIDER, "integration-agent",
current_generation, "Fresh name"));
auto machines = manager.get_user_machinelist();
REQUIRE(machines.size() == 1);
REQUIRE(machines.at("integration-device") != nullptr);
CHECK(machines.at("integration-device")->get_dev_name() == "Fresh name");
manager.parse_user_print_info(machine_list_response(BBL_CLOUD_PROVIDER, "integration-agent",
current_generation, "Stale provider"));
manager.parse_user_print_info(machine_list_response(ORCA_CLOUD_PROVIDER, "other-agent",
current_generation, "Stale agent"));
manager.parse_user_print_info(machine_list_response(ORCA_CLOUD_PROVIDER, "integration-agent",
current_generation + 1, "Stale generation"));
machines = manager.get_user_machinelist();
REQUIRE(machines.size() == 1);
CHECK(machines.at("integration-device")->get_dev_name() == "Fresh name");
}
TEST_CASE("Device manager filters and rehomes devices by printer-agent ownership", "[DeviceManager][integration]")
{
ScopedAppConfig app_config;
auto agent_a = std::make_shared<TestPrinterAgent>("integration-agent-a");
auto agent_b = std::make_shared<TestPrinterAgent>("integration-agent-b");
NetworkAgent network(nullptr, agent_a);
DeviceManager manager(&network, false, &app_config.config);
BBLocalMachine machine;
machine.dev_id = "integration-lan-device";
machine.dev_name = "Integration LAN device";
machine.dev_ip = "192.0.2.10";
machine.printer_type = "C11";
MachineObject* object = manager.insert_local_device(machine, "lan", "free", "", "access-code");
REQUIRE(object != nullptr);
CHECK(object->printer_agent_id == "integration-agent-a");
CHECK(manager.get_my_machine_list("integration-agent-a").count(machine.dev_id) == 1);
CHECK(manager.get_my_machine_list("integration-agent-b").empty());
network.set_printer_agent(agent_b);
CHECK(manager.get_my_machine_list("integration-agent-b").empty());
manager.on_machine_alive(R"({
"dev_name":"Rediscovered device",
"dev_id":"integration-lan-device",
"dev_ip":"192.0.2.10",
"dev_type":"C11",
"dev_signal":"strong",
"connect_type":"lan",
"bind_state":"free"
})");
CHECK(object->printer_agent_id == "integration-agent-b");
CHECK(manager.get_my_machine_list("integration-agent-a").empty());
CHECK(manager.get_my_machine_list("integration-agent-b").count(machine.dev_id) == 1);
}
+141
View File
@@ -0,0 +1,141 @@
// why: match the GUI include order to avoid rpcndr.h byte/std::byte
// ambiguity in the Windows COM headers.
// why: wx/timer.h must precede DeviceManager.hpp because
// DeviceErrorDialog.hpp uses wxTimerEvent.
#ifdef WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <Windows.h>
#endif
#include <catch2/catch_all.hpp>
#include <stdexcept>
#include <wx/timer.h>
#include "slic3r/GUI/DeviceManager.hpp"
#include <nlohmann/json.hpp>
using json = nlohmann::json;
using namespace Slic3r;
TEST_CASE("Integer progress reaches the shared subtask", "[DeviceManager][Progress]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.update_print_progress(json(37));
REQUIRE(machine.mc_print_percent == 37);
BBLSubTask* subtask = machine.get_subtask();
REQUIRE(subtask != nullptr);
CHECK(subtask->task_progress == 37);
}
TEST_CASE("String progress reaches the shared subtask", "[DeviceManager][Progress]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.update_print_progress(json("41"));
REQUIRE(machine.mc_print_percent == 41);
BBLSubTask* subtask = machine.get_subtask();
REQUIRE(subtask != nullptr);
CHECK(subtask->task_progress == 41);
}
TEST_CASE("Floating-point progress preserves the previous shared value", "[DeviceManager][Progress]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.update_print_progress(json(29));
REQUIRE(machine.mc_print_percent == 29);
BBLSubTask* subtask = machine.get_subtask();
REQUIRE(subtask != nullptr);
REQUIRE(subtask->task_progress == 29);
machine.update_print_progress(json(29.5));
BBLSubTask* current_subtask = machine.get_subtask();
REQUIRE(current_subtask != nullptr);
REQUIRE(current_subtask == subtask);
CHECK(machine.mc_print_percent == 29);
CHECK(current_subtask->task_progress == 29);
}
TEST_CASE("Unsupported progress values leave a fresh machine unchanged", "[DeviceManager][Progress]")
{
SECTION("boolean") {
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
REQUIRE(machine.subtask_ == nullptr);
machine.update_print_progress(json(true));
CHECK(machine.mc_print_percent == 0);
CHECK(machine.subtask_ == nullptr);
}
SECTION("null") {
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
REQUIRE(machine.subtask_ == nullptr);
machine.update_print_progress(json(nullptr));
CHECK(machine.mc_print_percent == 0);
CHECK(machine.subtask_ == nullptr);
}
SECTION("object") {
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
REQUIRE(machine.subtask_ == nullptr);
machine.update_print_progress(json::object());
CHECK(machine.mc_print_percent == 0);
CHECK(machine.subtask_ == nullptr);
}
SECTION("array") {
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
REQUIRE(machine.subtask_ == nullptr);
machine.update_print_progress(json::array());
CHECK(machine.mc_print_percent == 0);
CHECK(machine.subtask_ == nullptr);
}
}
TEST_CASE("Malformed string progress leaves a fresh machine unchanged", "[DeviceManager][Progress]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
REQUIRE(machine.subtask_ == nullptr);
CHECK_NOTHROW(machine.update_print_progress(json("not-a-percent")));
CHECK(machine.mc_print_percent == 0);
CHECK(machine.subtask_ == nullptr);
}
TEST_CASE("Zero progress replaces active shared progress", "[DeviceManager][Progress]")
{
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
machine.update_print_progress(json(63));
BBLSubTask* subtask = machine.get_subtask();
REQUIRE(subtask != nullptr);
REQUIRE(machine.mc_print_percent == 63);
REQUIRE(subtask->task_progress == 63);
machine.set_print_state("FAILED");
machine.update_print_progress(json(0));
BBLSubTask* current_subtask = machine.get_subtask();
REQUIRE(current_subtask != nullptr);
REQUIRE(current_subtask == subtask);
REQUIRE(machine.mc_print_percent == 0);
CHECK(current_subtask->task_progress == 0);
}
+242
View File
@@ -2,6 +2,7 @@
#include <libslic3r/LifecycleEvents.hpp>
#include <libslic3r/Utils.hpp>
#include <slic3r/Utils/NetworkAgentFactory.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
@@ -77,6 +78,96 @@ class EchoPackage(orca.base):
orca.register_capability(Echo)
)PY";
// A minimal printer-agent plugin exposing exactly one PrinterConnection capability.
const char* const PRINTER_AGENT_PLUGIN_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
# dependencies = []
#
# [tool.orcaslicer.plugin]
# name = "Lifecycle Test Agent"
# description = "Minimal printer-agent plugin for the lifecycle test."
# author = "tests"
# version = "1.0.0"
# type = "printer-connection"
# ///
import orca
class LifecycleTestAgentCapability(orca.printer_agent.PrinterAgentBase):
def get_name(self):
return "Lifecycle Test Agent"
def get_agent_info(self):
return orca.printer_agent.AgentInfo(
id="lifecycle-test-agent",
name="Lifecycle Test Agent",
version="1.0.0",
description="Lifecycle test printer agent",
)
@orca.plugin
class LifecycleTestPlugin(orca.base):
def register_capabilities(self):
orca.register_capability(LifecycleTestAgentCapability)
)PY";
// In production GUI_App::init_plugin_gui_wiring subscribes the agent-registry
// callbacks. The test binary has no GUI, so install the same unload-side wiring
// once so these tests exercise the production deregister-on-unload path.
//
// The load-side (register) wiring is deliberately not installed: duplicate-id
// coverage registers agents manually in a deterministic order instead.
void install_agent_registry_wiring()
{
static bool installed = false;
if (installed)
return;
installed = true;
PluginManager& manager = PluginManager::instance();
manager.subscribe_on_unload_callback(NetworkAgentFactory::deregister_python_plugin);
manager.subscribe_on_capability_unload_callback([](const PluginCapabilityId& capability) {
if (capability.type == PluginCapabilityType::PrinterConnection)
NetworkAgentFactory::deregister_python_printer_agent(capability.plugin_key, capability.name);
});
}
// Duplicate-id coverage needs two distinct plugins with different package keys
// and classes while both return the same AgentInfo.id.
std::string make_agent_plugin_source(const std::string& suffix,
const std::string& display_name,
const std::string& agent_id)
{
return std::string{}
+ "# /// script\n"
+ "# requires-python = \">=3.12\"\n"
+ "# dependencies = []\n"
+ "#\n"
+ "# [tool.orcaslicer.plugin]\n"
+ "# name = \"" + display_name + "\"\n"
+ "# description = \"Duplicate-id fake printer-agent plugin.\"\n"
+ "# author = \"tests\"\n"
+ "# version = \"1.0.0\"\n"
+ "# type = \"printer-connection\"\n"
+ "# ///\n"
+ "import orca\n"
+ "\n\n"
+ "class Cap" + suffix + "(orca.printer_agent.PrinterAgentBase):\n"
+ " def get_name(self):\n"
+ " return \"" + display_name + "\"\n"
+ "\n"
+ " def get_agent_info(self):\n"
+ " return orca.printer_agent.AgentInfo(\n"
+ " id=\"" + agent_id + "\", name=\"" + display_name + "\",\n"
+ " version=\"1.0.0\", description=\"duplicate id test\")\n"
+ "\n\n"
+ "@orca.plugin\n"
+ "class Plugin" + suffix + "(orca.base):\n"
+ " def register_capabilities(self):\n"
+ " orca.register_capability(Cap" + suffix + ")\n";
}
// Writes {data_dir}/orca_plugins/<stem>/<stem>.py and returns the plugin directory.
fs::path write_plugin(const ScopedDataDir& data_dir_guard, const std::string& stem, const std::string& source)
{
@@ -222,6 +313,157 @@ TEST_CASE("A discovered script plugin loads and materializes its capability", "[
manager.unload_plugin("Echo_Plugin");
}
TEST_CASE("A printer-agent plugin registers and deregisters with its lifecycle", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("printer-agent-load");
write_plugin(data_dir_guard, "LifecycleTestAgent", PRINTER_AGENT_PLUGIN_SOURCE);
PluginManager& manager = PluginManager::instance();
install_agent_registry_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
PluginDescriptor descriptor;
REQUIRE(manager.try_get_valid_plugin_descriptor("LifecycleTestAgent", descriptor));
std::string error;
REQUIRE(load_and_wait(manager, "LifecycleTestAgent", error));
INFO("load error: " << error);
const auto capabilities = manager.get_plugin_capabilities(
"LifecycleTestAgent", PluginCapabilityType::PrinterConnection);
REQUIRE(capabilities.size() == 1);
NetworkAgentFactory::register_python_printer_agent("LifecycleTestAgent", capabilities.front()->name());
CHECK(NetworkAgentFactory::is_printer_agent_registered("lifecycle-test-agent"));
REQUIRE(manager.unload_plugin("LifecycleTestAgent"));
CHECK_FALSE(NetworkAgentFactory::is_printer_agent_registered("lifecycle-test-agent"));
}
TEST_CASE("A duplicate printer-agent id is rejected without clobbering its owner", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
const std::string key_a = "DuplicateIdAgentA";
const std::string key_b = "DuplicateIdAgentB";
const std::string dup_id = "duplicate-id-agent";
ScopedDataDir data_dir_guard("printer-agent-duplicate");
write_plugin(data_dir_guard, key_a, make_agent_plugin_source("A", "Duplicate Id Agent A", dup_id));
write_plugin(data_dir_guard, key_b, make_agent_plugin_source("B", "Duplicate Id Agent B", dup_id));
PluginManager& manager = PluginManager::instance();
install_agent_registry_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
PluginDescriptor descriptor_a;
PluginDescriptor descriptor_b;
REQUIRE(manager.try_get_valid_plugin_descriptor(key_a, descriptor_a));
REQUIRE(manager.try_get_valid_plugin_descriptor(key_b, descriptor_b));
std::string error;
REQUIRE(load_and_wait(manager, key_a, error));
REQUIRE(load_and_wait(manager, key_b, error));
const auto capabilities_a = manager.get_plugin_capabilities(key_a, PluginCapabilityType::PrinterConnection);
const auto capabilities_b = manager.get_plugin_capabilities(key_b, PluginCapabilityType::PrinterConnection);
REQUIRE(capabilities_a.size() == 1);
REQUIRE(capabilities_b.size() == 1);
NetworkAgentFactory::register_python_printer_agent(key_a, capabilities_a.front()->name());
NetworkAgentFactory::register_python_printer_agent(key_b, capabilities_b.front()->name());
CHECK(NetworkAgentFactory::is_printer_agent_registered(dup_id));
const PrinterAgentInfo* info = NetworkAgentFactory::get_printer_agent_info(dup_id);
REQUIRE(info != nullptr);
CHECK(info->plugin_identifier.find(key_a) != std::string::npos);
CHECK(info->plugin_identifier.find(key_b) == std::string::npos);
REQUIRE(manager.unload_plugin(key_a));
CHECK_FALSE(NetworkAgentFactory::is_printer_agent_registered(dup_id));
manager.unload_plugin(key_b);
}
TEST_CASE("A printer-agent plugin cannot claim a built-in agent id", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
NetworkAgentFactory::register_all_agents();
REQUIRE(NetworkAgentFactory::is_printer_agent_registered(BBL_PRINTER_AGENT_ID));
const std::string plugin_key = "BuiltinClashAgent";
ScopedDataDir data_dir_guard("printer-agent-builtin-clash");
write_plugin(data_dir_guard, plugin_key,
make_agent_plugin_source("Clash", "Builtin Clash", BBL_PRINTER_AGENT_ID));
PluginManager& manager = PluginManager::instance();
install_agent_registry_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
PluginDescriptor descriptor;
REQUIRE(manager.try_get_valid_plugin_descriptor(plugin_key, descriptor));
std::string error;
REQUIRE(load_and_wait(manager, plugin_key, error));
const auto capabilities = manager.get_plugin_capabilities(plugin_key, PluginCapabilityType::PrinterConnection);
REQUIRE(capabilities.size() == 1);
NetworkAgentFactory::register_python_printer_agent(plugin_key, capabilities.front()->name());
const PrinterAgentInfo* info = NetworkAgentFactory::get_printer_agent_info(BBL_PRINTER_AGENT_ID);
REQUIRE(info != nullptr);
CHECK_FALSE(info->is_plugin());
CHECK(info->plugin_identifier.find(plugin_key) == std::string::npos);
manager.unload_plugin(plugin_key);
}
TEST_CASE("Re-registering a printer-agent capability preserves its registration", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
const std::string plugin_key = "ReRegisterAgent";
const std::string agent_id = "re-register-agent";
ScopedDataDir data_dir_guard("printer-agent-reregister");
write_plugin(data_dir_guard, plugin_key,
make_agent_plugin_source("Re", "Re Register", agent_id));
PluginManager& manager = PluginManager::instance();
install_agent_registry_wiring();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
PluginDescriptor descriptor;
REQUIRE(manager.try_get_valid_plugin_descriptor(plugin_key, descriptor));
std::string error;
REQUIRE(load_and_wait(manager, plugin_key, error));
const auto capabilities = manager.get_plugin_capabilities(plugin_key, PluginCapabilityType::PrinterConnection);
REQUIRE(capabilities.size() == 1);
NetworkAgentFactory::register_python_printer_agent(plugin_key, capabilities.front()->name());
REQUIRE(NetworkAgentFactory::is_printer_agent_registered(agent_id));
const PrinterAgentInfo* first = NetworkAgentFactory::get_printer_agent_info(agent_id);
REQUIRE(first != nullptr);
const std::string owner = first->plugin_identifier;
NetworkAgentFactory::register_python_printer_agent(plugin_key, capabilities.front()->name());
CHECK(NetworkAgentFactory::is_printer_agent_registered(agent_id));
const PrinterAgentInfo* second = NetworkAgentFactory::get_printer_agent_info(agent_id);
REQUIRE(second != nullptr);
CHECK(second->plugin_identifier == owner);
manager.unload_plugin(plugin_key);
}
TEST_CASE("Plugin manager can initialize again after shutdown", "[PluginLifecycle][Python]")
{
ScopedPluginManager plugin_system;
@@ -115,6 +115,22 @@ TEST_CASE("A printer agent that omits its operations answers like a missing agen
check_answers_like_no_agent(*agent);
}
TEST_CASE("A printer agent uses IPrinterAgent defaults for omitted commands", "[PluginPrinterAgent][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil;
auto agent = make_agent(" def send_message(self, dev_id, json_str, qos, flag): return 7\n"
" def send_message_to_printer(self, dev_id, json_str, qos, flag): return 8\n");
REQUIRE(agent.agent);
CHECK(agent->command_xyz_abs("dev", 1, false) == 7);
CHECK(agent->command_set_nozzle("dev", 200, 2, true) == 8);
CHECK(agent->command_ams_refresh_rfid("dev", -1, 0, 3, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
TEST_CASE("A printer agent operation returning the wrong type answers like a missing agent", "[PluginPrinterAgent][Python]")
{
ScopedPluginManager plugin_system;
+23
View File
@@ -0,0 +1,23 @@
#include <catch2/catch_all.hpp>
#include <slic3r/GUI/PluginStatus.hpp>
using Slic3r::GUI::PluginStatus;
using Slic3r::GUI::resolve_plugin_status;
TEST_CASE("resolve_plugin_status precedence", "[plugin][status]") {
// the new branch: loaded module + error -> runtime fault, not a load failure.
REQUIRE(resolve_plugin_status(/*loading*/ false, /*has_error*/ true, /*is_loaded*/ true) == PluginStatus::RuntimeError);
// error without a live module is a load-time Error.
REQUIRE(resolve_plugin_status(false, true, false) == PluginStatus::Error);
// loading wins over a pending error so a reload never flashes red.
REQUIRE(resolve_plugin_status(true, true, true) == PluginStatus::Loading);
// healthy loaded plugin.
REQUIRE(resolve_plugin_status(false, false, true) == PluginStatus::Activated);
// nothing loaded, no error.
REQUIRE(resolve_plugin_status(false, false, false) == PluginStatus::Inactive);
}
+173
View File
@@ -0,0 +1,173 @@
#include <catch2/catch_all.hpp>
#include <slic3r/Utils/NetworkAgentFactory.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include "python_test_support.hpp"
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
#include <string>
using namespace Slic3r;
namespace py = pybind11;
// ===========================================================================
// UNIT - printer-agent registry duplicate handling.
// Confirms a duplicate agent id is rejected so a plugin cannot shadow a built-in
// or previously registered agent.
// ===========================================================================
TEST_CASE("unit: printer-agent registry register / lookup / duplicate-reject", "[registry][unit]")
{
// why: the registry is process-global state shared by the test binary, and
// Catch2 may run cases in any order. Use an id that cannot collide with
// built-ins or other cases. Avoid SECTIONs because each section re-runs the
// body and would register the same id twice.
const std::string id = "orca-test::registry-probe-7f3a";
auto stub_factory = [](std::shared_ptr<ICloudServiceAgent>, const std::string&)
-> std::shared_ptr<IPrinterAgent> { return nullptr; };
REQUIRE_FALSE(NetworkAgentFactory::is_printer_agent_registered(id));
REQUIRE(NetworkAgentFactory::register_printer_agent(id, "Registry Probe", stub_factory));
REQUIRE(NetworkAgentFactory::is_printer_agent_registered(id));
// Re-registering the same id is rejected and does not replace the entry.
REQUIRE_FALSE(NetworkAgentFactory::register_printer_agent(id, "Impostor", stub_factory));
// The first registration's display name survives the rejected duplicate.
const PrinterAgentInfo* info = NetworkAgentFactory::get_printer_agent_info(id);
REQUIRE(info != nullptr);
CHECK(info->display_name == "Registry Probe");
// It appears exactly once in the UI-population list.
auto agents = NetworkAgentFactory::get_registered_printer_agents();
int count = 0;
for (const auto& a : agents)
if (a.id == id)
++count;
CHECK(count == 1);
}
// ===========================================================================
// INTEGRATION - the orca.printer_agent Python binding surface.
// Boots the embedded interpreter and asserts the C++ to Python contract that
// every printer-agent plugin subclasses. If a binding is renamed or removed,
// plugins fail at runtime even though C++ still compiles.
// ===========================================================================
// ensure_python_initialized()/import_orca_module() come from python_test_support.hpp:
// a bare scoped_interpreter with no PyConfig.home falls back to the build-time
// stdlib path, which doesn't exist beside this test binary, so Py_Initialize()
// fails to load the codecs needed for the filesystem encoding. The shared helper
// points PyConfig.home at the python/ runtime staged next to the test executable.
TEST_CASE("integration: orca.printer_agent binding surface", "[integration][Python]")
{
py::module_ orca = import_orca_module();
REQUIRE(py::hasattr(orca, "printer_agent"));
py::object pa = orca.attr("printer_agent");
// The base class every printer-agent plugin subclasses.
REQUIRE(py::hasattr(pa, "PrinterAgentBase"));
py::object base = pa.attr("PrinterAgentBase");
for (const char* method : { "get_agent_info", "connect_printer", "disconnect_printer",
"send_message", "send_message_to_printer",
"command_ams_refresh_rfid", "command_ams_calibrate",
"command_ams_select_tray", "command_start_camera",
"command_xyz_abs", "command_auto_leveling", "command_go_home",
"command_set_bed", "command_set_nozzle", "command_axis_control",
"start_discovery", "bind_detect",
"start_print", "get_filament_sync_mode" }) {
CAPTURE(method);
CHECK(py::hasattr(base, method));
}
// AgentInfo value type - the registry identity the host reads (id is the key).
REQUIRE(py::hasattr(pa, "AgentInfo"));
py::object info = pa.attr("AgentInfo")("moonraker", "Moonraker", "1.0", "test agent");
CHECK(info.attr("id").cast<std::string>() == "moonraker");
CHECK(info.attr("name").cast<std::string>() == "Moonraker");
// FilamentSyncMode enum the host queries to pick pull vs subscription.
REQUIRE(py::hasattr(pa, "FilamentSyncMode"));
py::object mode = pa.attr("FilamentSyncMode");
CHECK(py::hasattr(mode, "Pull"));
CHECK(py::hasattr(mode, "Subscription"));
CHECK(py::hasattr(mode, "None_"));
REQUIRE(py::hasattr(pa, "CameraStreamMode"));
py::object camera_mode = pa.attr("CameraStreamMode");
CHECK(py::hasattr(camera_mode, "HTTPS"));
CHECK_FALSE(py::hasattr(camera_mode, "WebRTC"));
// Plugin-type enum exposed at module root (host reads it without the GIL).
CHECK(py::hasattr(orca, "PluginType"));
}
// ===========================================================================
// INTEGRATION - plugin-registration API and discovery-context guards.
// These are the symbols every plugin package uses: the @orca.plugin decorator,
// orca.base, orca.register_capability, and the capability base modules. Checking
// them in the lightweight embedded-interpreter test catches binding breakage
// before the plugin-loader test needs to run.
// ===========================================================================
TEST_CASE("integration: orca plugin-registration API surface + discovery-context guards", "[integration][Python]")
{
py::module_ orca = import_orca_module();
// Module-level surface every plugin package relies on.
// note: no "gcode" module here - this branch has no G-code capability module;
// PostProcessing exists only as a PluginType value.
for (const char* name : { "plugin", "register_capability", "base", "PythonPluginBase",
"PluginType", "PluginResult", "script", "printer_agent", "host" }) {
CAPTURE(name);
CHECK(py::hasattr(orca, name));
}
// Plugin package base and capability base contract.
CHECK(py::hasattr(orca.attr("base"), "register_capabilities"));
py::object cap_base = orca.attr("PythonPluginBase");
for (const char* method : { "get_name", "get_type", "on_load", "on_unload" }) {
CAPTURE(method);
CHECK(py::hasattr(cap_base, method));
}
// The script capability module exposes its own base class.
CHECK(py::hasattr(orca.attr("script"), "ScriptPluginCapabilityBase"));
// PluginType enum carries the values that route a capability, including PrinterConnection.
// note: no PostProcessing value on this branch's binding.
py::object types = orca.attr("PluginType");
for (const char* value : { "PrinterConnection", "Script" }) {
CAPTURE(value);
CHECK(py::hasattr(types, value));
}
// note: this is testing behavior, not normal plugin loading.
// These APIs should only work while Orca is actively loading a plugin.
try {
// Calls Python's orca.register_capability(0) from C++.
// 0 is intentionally bogus. The important part is that there is no active
// plugin load context, so the function should reject the call immediately.
orca.attr("register_capability")(py::int_(0));
// If the call above does NOT throw, the test fails here.
FAIL("register_capability outside discovery context must raise");
} catch (const py::error_already_set& error) {
// pybind11 wraps Python exceptions as py::error_already_set.
// This checks the Python exception type is ValueError.
CHECK(error.matches(PyExc_ValueError));
}
try {
// This is the function behind @orca.plugin.
// Same logic as above.
orca.attr("plugin")(py::int_(0));
FAIL("@orca.plugin outside discovery context must raise");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_ValueError));
}
}
@@ -0,0 +1,113 @@
#include <catch2/catch_all.hpp>
#include <slic3r/GUI/WebMediaController.hpp>
#include <wx/webview.h>
#include <string>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::GUI;
namespace {
class StubWebView final : public wxWebView
{
public:
bool SetBackgroundColour(const wxColour&) override
{
events.emplace_back("background");
return true;
}
bool Create(wxWindow*, wxWindowID, const wxString&, const wxPoint&, const wxSize&, long, const wxString&) override { return true; }
wxString GetCurrentTitle() const override { return {}; }
wxString GetCurrentURL() const override { return {}; }
bool IsBusy() const override { return false; }
bool IsEditable() const override { return false; }
void LoadURL(const wxString& url) override
{
events.emplace_back("url");
loaded_url = url.ToStdString();
}
void Print() override {}
void RegisterHandler(wxSharedPtr<wxWebViewHandler>) override {}
void Reload(wxWebViewReloadFlags) override {}
void SetEditable(bool) override {}
void Stop() override { events.emplace_back("stop"); }
bool CanGoBack() const override { return false; }
bool CanGoForward() const override { return false; }
void GoBack() override {}
void GoForward() override {}
void ClearHistory() override { events.emplace_back("history"); }
void EnableHistory(bool) override {}
wxVector<wxSharedPtr<wxWebViewHistoryItem>> GetBackwardHistory() override { return {}; }
wxVector<wxSharedPtr<wxWebViewHistoryItem>> GetForwardHistory() override { return {}; }
void LoadHistoryItem(wxSharedPtr<wxWebViewHistoryItem>) override {}
bool CanSetZoomType(wxWebViewZoomType) const override { return false; }
float GetZoomFactor() const override { return 1.0f; }
wxWebViewZoomType GetZoomType() const override { return wxWEBVIEW_ZOOM_TYPE_LAYOUT; }
void SetZoomFactor(float) override {}
void SetZoomType(wxWebViewZoomType) override {}
bool CanUndo() const override { return false; }
bool CanRedo() const override { return false; }
void Undo() override {}
void Redo() override {}
void* GetNativeBackend() const override { return nullptr; }
bool RunScript(const wxString& javascript, wxString*) const override
{
events.emplace_back("script");
script = javascript.ToStdString();
return true;
}
protected:
void DoSetPage(const wxString& html, const wxString& base_url) override
{
events.emplace_back("page");
page = html.ToStdString();
page_base = base_url.ToStdString();
}
public:
mutable std::vector<std::string> events;
std::string page;
std::string page_base;
mutable std::string script;
std::string loaded_url;
};
} // namespace
TEST_CASE("Web media controller tears down a snapshot lifecycle", "[WebMediaController][integration]")
{
StubWebView view;
WebMediaController controller(&view);
controller.set_mode(CameraStreamMode::http_snapshot);
controller.Load(wxURI("http://camera.example/frame.jpg"));
controller.Play();
REQUIRE(view.events.size() == 3);
CHECK(view.events[0] == "background");
CHECK(view.events[1] == "page");
CHECK(view.events[2] == "page");
CHECK(view.page.find("stopCameraRefresh") != std::string::npos);
CHECK(view.page.find("http://camera.example/frame.jpg") != std::string::npos);
controller.Stop();
REQUIRE(view.events.size() == 7);
CHECK(view.events[3] == "script");
CHECK(view.events[4] == "stop");
CHECK(view.events[5] == "page");
CHECK(view.events[6] == "history");
CHECK(view.script == "if(typeof stopCameraRefresh==='function') stopCameraRefresh();");
CHECK(view.page.empty());
CHECK(view.page_base == "about:blank");
controller.Play();
CHECK(view.page.find("http://camera.example/frame.jpg") == std::string::npos);
}