mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-07-20 09:22:13 +00:00
Merge branch 'main' into feature/bambu_printer_housekeeping
# Conflicts: # tests/slic3rutils/CMakeLists.txt
This commit is contained in:
@@ -14,6 +14,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_print.cpp
|
||||
test_printobject.cpp
|
||||
test_skirt_brim.cpp
|
||||
test_slicing_pipeline_hook.cpp
|
||||
test_support_material.cpp
|
||||
test_trianglemesh.cpp
|
||||
)
|
||||
|
||||
559
tests/fff_print/test_slicing_pipeline_hook.cpp
Normal file
559
tests/fff_print/test_slicing_pipeline_hook.cpp
Normal file
@@ -0,0 +1,559 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
#include "test_helpers.hpp"
|
||||
using namespace Slic3r;
|
||||
|
||||
TEST_CASE("slicing_pipeline_plugin option exists and defaults empty", "[slicing_pipeline]") {
|
||||
DynamicPrintConfig cfg = DynamicPrintConfig::full_print_config();
|
||||
const ConfigOptionStrings* opt = cfg.option<ConfigOptionStrings>("slicing_pipeline_plugin");
|
||||
REQUIRE(opt != nullptr);
|
||||
CHECK(opt->values.empty());
|
||||
const ConfigOptionDef* def = cfg.def()->get("slicing_pipeline_plugin");
|
||||
REQUIRE(def != nullptr);
|
||||
CHECK(def->plugin_type == "slicing-pipeline");
|
||||
CHECK(def->is_plugin_backed());
|
||||
CHECK(def->gui_type == ConfigOptionDef::GUIType::plugin_picker);
|
||||
}
|
||||
|
||||
#include "libslic3r/Print.hpp"
|
||||
|
||||
TEST_CASE("slicing pipeline hook setter is a no-op-safe injection", "[slicing_pipeline]") {
|
||||
int calls = 0;
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){ ++calls; });
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr); // reset — must be legal
|
||||
CHECK(calls == 0);
|
||||
}
|
||||
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
using namespace Slic3r::Test;
|
||||
|
||||
TEST_CASE("SlicingPipeline hook fires once per step per object in order", "[slicing_pipeline]") {
|
||||
struct Call { const Slic3r::PrintObject* obj; Slic3r::SlicingPipelineStepPlugin step; };
|
||||
std::vector<Call> calls;
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[&](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){ calls.push_back({o, s}); });
|
||||
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // activate
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
|
||||
using S = Slic3r::SlicingPipelineStepPlugin;
|
||||
auto count = [&](S s){ return std::count_if(calls.begin(), calls.end(), [&](const Call& c){ return c.step == s; }); };
|
||||
CHECK(count(S::posSlice) == 1);
|
||||
CHECK(count(S::posPerimeters) == 1);
|
||||
CHECK(count(S::posPrepareInfill) == 1); // the prepare-infill seam fires once per object
|
||||
CHECK(count(S::posInfill) == 1);
|
||||
CHECK(count(S::psWipeTower) == 1);
|
||||
CHECK(count(S::psSkirtBrim) == 1);
|
||||
// psGCodePostProcess fires from the GUI export path, never from process():
|
||||
CHECK(count(S::psGCodePostProcess) == 0);
|
||||
// print-wide steps carry a null object:
|
||||
for (const auto& c : calls)
|
||||
if (c.step == S::psWipeTower || c.step == S::psSkirtBrim) CHECK(c.obj == nullptr);
|
||||
// Slice must fire before Perimeters for the same object:
|
||||
auto idx = [&](S s){ for (size_t i=0;i<calls.size();++i) if (calls[i].step==s) return (int)i; return -1; };
|
||||
CHECK(idx(S::posSlice) < idx(S::posPerimeters));
|
||||
CHECK(idx(S::posPerimeters) < idx(S::posPrepareInfill)); // prepare-infill fires after perimeters...
|
||||
CHECK(idx(S::posPrepareInfill) < idx(S::posInfill)); // ...and before the fills are built
|
||||
}
|
||||
|
||||
#include <sstream>
|
||||
#include <cmath>
|
||||
|
||||
// Exported G-code carries a few nondeterministic comment lines unrelated to toolpaths: a
|
||||
// wall-clock timestamp ("; generated by ..."), ObjectID-derived ids (from a process-global
|
||||
// counter never reset between runs), and a config-dump line naming the selected plugin (an
|
||||
// active run records it, the absent baseline does not). Strip exactly those lines so a raw
|
||||
// byte-compare isolates the real motion/extrusion output; every other byte is still compared.
|
||||
static std::string strip_nondeterministic_gcode_lines(const std::string& gcode) {
|
||||
std::string out; out.reserve(gcode.size());
|
||||
std::istringstream in(gcode);
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
if (line.compare(0, 15, "; generated by ") == 0) continue; // wall-clock timestamp
|
||||
if (line.compare(0, 18, "; model label id: ") == 0) continue; // ObjectID-derived
|
||||
// "; [stop] printing object <name> id:N copy M" / "... unique label id: N" (ObjectID-derived):
|
||||
if (line.find("printing object") != std::string::npos && line.find(" id:") != std::string::npos) continue;
|
||||
if (line.find("slicing_pipeline_plugin") != std::string::npos) continue; // config-dump plugin name
|
||||
out += line; out += '\n';
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
TEST_CASE("Inactive hook: process output is byte-identical (no-op hook == unset)", "[slicing_pipeline]") {
|
||||
// Three configurations must all normalize to the same G-code:
|
||||
// (activate=false, hook=none) baseline -- feature entirely absent.
|
||||
// (activate=false, hook=noop) hook registered but option empty -> gated off, never fires.
|
||||
// (activate=true, hook=noop) hook ACTIVE and firing at every pipeline seam, mutating
|
||||
// nothing. This is the real backward-compat claim: an active
|
||||
// but non-mutating hook must not perturb the output.
|
||||
auto run = [](bool activate, bool set_noop_hook) {
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
// Activating requires BOTH a non-empty option and a registered hook (see Print::apply).
|
||||
if (activate)
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
|
||||
if (set_noop_hook)
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn([](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){});
|
||||
else
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
init_print({cube(20)}, print, model, config);
|
||||
std::string g = Slic3r::Test::gcode(print);
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
return g;
|
||||
};
|
||||
// Compare only machine-meaningful output (see strip_nondeterministic_gcode_lines): every
|
||||
// motion/extrusion byte is still compared, so this proves the inactive hook -- and the
|
||||
// active-but-non-mutating hook -- leave the real toolpath byte-identical.
|
||||
const std::string baseline = strip_nondeterministic_gcode_lines(run(false, false)); // feature absent
|
||||
CHECK(strip_nondeterministic_gcode_lines(run(false, true)) == baseline); // gated off: hook never fires
|
||||
CHECK(strip_nondeterministic_gcode_lines(run(true, true)) == baseline); // active no-op hook fires everywhere, mutates nothing
|
||||
}
|
||||
|
||||
// Gating negative path. With the option EMPTY the plugin is inactive, so a
|
||||
// registered hook must NOT fire even once across a full slice (m_pipeline_plugin_active
|
||||
// stays false in Print::apply). Distinct from the byte-identical test above: this asserts
|
||||
// the gate directly by counting invocations rather than comparing output.
|
||||
TEST_CASE("Empty option: registered hook is gated off and never fires", "[slicing_pipeline]") {
|
||||
int calls = 0;
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin){ ++calls; });
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
// option left EMPTY -> inactive regardless of the registered hook.
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
CHECK(calls == 0);
|
||||
}
|
||||
|
||||
// Duplicate-skip gating. Two ModelObjects that share one mesh_ptr are detected as
|
||||
// identical by Print::process()'s is_print_object_the_same(); the second becomes a shared
|
||||
// (duplicate) object and is NOT re-sliced, so the Slice hook must fire exactly once even
|
||||
// though there are two print objects. The clone shares mesh_ptr and copies the volume
|
||||
// transformation/config (ModelVolume copy ctor), which the equality check requires.
|
||||
TEST_CASE("Duplicate objects share a slice: Slice hook fires exactly once", "[slicing_pipeline]") {
|
||||
int slice_calls = 0, perim_calls = 0;
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[&](Slic3r::Print&, const Slic3r::PrintObject*, Slic3r::SlicingPipelineStepPlugin s){
|
||||
if (s == Slic3r::SlicingPipelineStepPlugin::posSlice) ++slice_calls;
|
||||
if (s == Slic3r::SlicingPipelineStepPlugin::posPerimeters) ++perim_calls;
|
||||
});
|
||||
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // activate
|
||||
|
||||
// init_print builds one arranged, on-bed cube object (o1).
|
||||
init_print({cube(20)}, print, model, config);
|
||||
Slic3r::ModelObject* o1 = model.objects.front();
|
||||
// Model::add_object(const ModelObject&) force-sets object extruder=1 on the clone; give o1
|
||||
// the same so the two objects' configs match (is_print_object_the_same compares config).
|
||||
if (!o1->config.has("extruder"))
|
||||
o1->config.set_key_value("extruder", new Slic3r::ConfigOptionInt(1));
|
||||
// Clone o1: shares mesh_ptr and copies the volume transformation + config (genuine duplicate).
|
||||
Slic3r::ModelObject* o2 = model.add_object(*o1);
|
||||
// Shift the clone in X so validate() sees no collision (20mm cubes -> 40mm centres = 20mm gap).
|
||||
for (Slic3r::ModelInstance* inst : o2->instances)
|
||||
inst->set_offset(inst->get_offset() + Slic3r::Vec3d(40.0, 0.0, 0.0));
|
||||
|
||||
print.apply(model, config);
|
||||
print.validate();
|
||||
print.set_status_silent();
|
||||
print.process();
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
|
||||
REQUIRE(print.objects().size() == 2); // two print objects present...
|
||||
CHECK(slice_calls == 1); // ...but the duplicate is skipped -> one slice
|
||||
CHECK(perim_calls == 1); // and one perimeters pass (the sliced object)
|
||||
}
|
||||
|
||||
#include "libslic3r/Layer.hpp" // Layer, LayerRegion (full defs for the cascade hook)
|
||||
#include "libslic3r/ClipperUtils.hpp" // offset_ex
|
||||
|
||||
// The correctness heart of the mutation feature. A C++ hook insets every
|
||||
// region's `slices` at the Slice boundary (via SurfaceCollection::set with offset
|
||||
// polygons); because make_perimeters() derives fill_surfaces from slices AFTER the
|
||||
// Slice hook fires (see Print::process's split slice loop), the downstream
|
||||
// fill_surfaces area must shrink relative to a baseline (un-inset) run. This proves
|
||||
// the mutation cascade end-to-end using the same C++ APIs the Python mutators wrap.
|
||||
TEST_CASE("Mutating slices at the Slice boundary cascades downstream", "[slicing_pipeline]") {
|
||||
auto fill_area = [](bool inset) {
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
|
||||
if (inset) Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
|
||||
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
|
||||
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
|
||||
for (Slic3r::LayerRegion* r : l->regions()) {
|
||||
Slic3r::Surfaces in = r->slices.surfaces;
|
||||
for (auto& sf : in) sf.expolygon = offset_ex(sf.expolygon, -scale_(1.0)).front();
|
||||
r->slices.set(std::move(in));
|
||||
}
|
||||
});
|
||||
else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
double a = 0; for (auto* l : print.objects().front()->layers()) for (auto* r : l->regions()) for (auto& s : r->fill_surfaces.surfaces) a += s.expolygon.area();
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
return a;
|
||||
};
|
||||
CHECK(fill_area(true) < fill_area(false));
|
||||
}
|
||||
|
||||
TEST_CASE("Changing slicing_pipeline_plugin invalidates posSlice", "[slicing_pipeline]") {
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
REQUIRE(print.objects().front()->is_step_done(posSlice));
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
|
||||
print.apply(model, config);
|
||||
CHECK_FALSE(print.objects().front()->is_step_done(posSlice)); // re-slice required
|
||||
}
|
||||
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
// A similarity transform (rotate + uniform scale) applied to slices at Step.posSlice, matching
|
||||
// what the Twistify sample (sandboxes/orca_twistify_plugin_example_any.py) does. This C++ analogue
|
||||
// rotates every region's slices a fixed 45 deg about the object's base-footprint center -- the same
|
||||
// seam and cascade the sample drives through the slices.set() + Layer::make_slices() path. Two
|
||||
// end-to-end invariants after process() confirm the approach:
|
||||
// (1) a pure rotation is a similarity with scale 1, so total fill area is preserved, and
|
||||
// (2) the mutation genuinely cascaded into make_perimeters' fill_surfaces -- a 20mm square
|
||||
// rotated 45 deg becomes a diamond whose bbox is ~sqrt(2)x wider (it did not stay
|
||||
// axis-aligned), proving downstream geometry was rebuilt from the twisted slices.
|
||||
TEST_CASE("Rotating slices at the Slice boundary cascades (area preserved, bbox rotated)", "[slicing_pipeline]") {
|
||||
using Catch::Matchers::WithinRel;
|
||||
struct Measure { double area; double width; double height; };
|
||||
auto measure = [](bool rotate) -> Measure {
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
|
||||
if (rotate) Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
|
||||
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
|
||||
auto* obj = const_cast<Slic3r::PrintObject*>(o);
|
||||
// Twist axis = center of the first sliced layer's footprint (Twistify's anchor).
|
||||
coord_t nx=0, xx=0, ny=0, xy=0; bool seeded=false;
|
||||
for (Slic3r::Layer* l : obj->layers()) {
|
||||
for (Slic3r::LayerRegion* r : l->regions())
|
||||
for (const Slic3r::Surface& sf : r->slices.surfaces)
|
||||
for (const Slic3r::Point& p : sf.expolygon.contour.points) {
|
||||
if (!seeded) { nx=xx=p.x(); ny=xy=p.y(); seeded=true; }
|
||||
else { nx=std::min(nx,p.x()); xx=std::max(xx,p.x());
|
||||
ny=std::min(ny,p.y()); xy=std::max(xy,p.y()); }
|
||||
}
|
||||
if (seeded) break;
|
||||
}
|
||||
const double cx = 0.5*((double)nx+(double)xx), cy = 0.5*((double)ny+(double)xy);
|
||||
const double ct = 0.7071067811865476, st = 0.7071067811865476; // cos/sin 45 deg
|
||||
auto rot = [&](const Slic3r::Point& p) {
|
||||
const double dx = (double)p.x()-cx, dy = (double)p.y()-cy;
|
||||
return Slic3r::Point((coord_t)std::llround(dx*ct - dy*st + cx),
|
||||
(coord_t)std::llround(dx*st + dy*ct + cy));
|
||||
};
|
||||
for (Slic3r::Layer* l : obj->layers())
|
||||
for (Slic3r::LayerRegion* r : l->regions()) {
|
||||
Slic3r::Surfaces in = r->slices.surfaces;
|
||||
for (auto& sf : in) {
|
||||
for (auto& pt : sf.expolygon.contour.points) pt = rot(pt);
|
||||
for (auto& h : sf.expolygon.holes)
|
||||
for (auto& pt : h.points) pt = rot(pt);
|
||||
}
|
||||
r->slices.set(std::move(in));
|
||||
}
|
||||
});
|
||||
else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
double area = 0;
|
||||
coord_t nx=0, xx=0, ny=0, xy=0; bool seeded=false;
|
||||
for (auto* l : print.objects().front()->layers())
|
||||
for (auto* r : l->regions())
|
||||
for (auto& sf : r->fill_surfaces.surfaces) {
|
||||
area += sf.expolygon.area();
|
||||
for (const Slic3r::Point& p : sf.expolygon.contour.points) {
|
||||
if (!seeded) { nx=xx=p.x(); ny=xy=p.y(); seeded=true; }
|
||||
else { nx=std::min(nx,p.x()); xx=std::max(xx,p.x());
|
||||
ny=std::min(ny,p.y()); xy=std::max(xy,p.y()); }
|
||||
}
|
||||
}
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
return { area, (double)(xx-nx), (double)(xy-ny) };
|
||||
};
|
||||
const Measure base = measure(false);
|
||||
const Measure rot = measure(true);
|
||||
// (1) A pure rotation preserves area (similarity, scale 1): fills add up to the same area.
|
||||
CHECK_THAT(rot.area, WithinRel(base.area, 0.05));
|
||||
// (2) The rotation cascaded downstream: the square's fill bbox grew toward the sqrt(2)
|
||||
// diagonal (diamond) instead of staying axis-aligned.
|
||||
CHECK(rot.width > 1.3 * base.width);
|
||||
CHECK(rot.width < 1.5 * base.width);
|
||||
CHECK(rot.height > 1.3 * base.height);
|
||||
CHECK(rot.height < 1.5 * base.height);
|
||||
}
|
||||
|
||||
// The Twistify sample skips exact-identity layers entirely, but every transformed layer invokes
|
||||
// the slices.set() write-back + make_perimeters re-run. This proves that write path is lossless
|
||||
// for already-normalized (CCW contour / CW hole) input -- an active hook that re-sets every
|
||||
// region's slices to their CURRENT geometry (the identity similarity transform) produces output
|
||||
// byte-identical to an active hook that mutates nothing. Both runs are active (same config dump);
|
||||
// the only difference is whether the write path ran, so equality isolates it.
|
||||
TEST_CASE("Identity round-trip through slices.set() is byte-identical", "[slicing_pipeline]") {
|
||||
auto run = [](bool roundtrip) {
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // active in both runs
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[roundtrip](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
|
||||
if (!roundtrip || s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
|
||||
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
|
||||
for (Slic3r::LayerRegion* r : l->regions()) {
|
||||
Slic3r::Surfaces in = r->slices.surfaces; // copy current (already-normalized) geometry
|
||||
r->slices.set(std::move(in)); // write back unchanged: identity transform
|
||||
}
|
||||
});
|
||||
init_print({cube(20)}, print, model, config);
|
||||
std::string g = Slic3r::Test::gcode(print);
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
return g;
|
||||
};
|
||||
CHECK(strip_nondeterministic_gcode_lines(run(true)) == strip_nondeterministic_gcode_lines(run(false)));
|
||||
}
|
||||
|
||||
#include "libslic3r/ExtrusionEntityCollection.hpp" // count fill paths in the fill-surface cascade test
|
||||
|
||||
// Total leaf ExtrusionPath count under an extrusion (sub)tree (collections recursed into).
|
||||
static size_t count_leaf_paths(const Slic3r::ExtrusionEntity* ee) {
|
||||
if (ee == nullptr) return 0;
|
||||
if (const auto* coll = dynamic_cast<const Slic3r::ExtrusionEntityCollection*>(ee)) {
|
||||
size_t n = 0;
|
||||
for (const Slic3r::ExtrusionEntity* e : coll->entities) n += count_leaf_paths(e);
|
||||
return n;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Width (scaled) of the object-wide bounding box over every region's sliced contour.
|
||||
static double outer_slices_width(const Slic3r::Print& print) {
|
||||
coord_t min_x = 0, max_x = 0; bool seeded = false;
|
||||
for (auto* l : print.objects().front()->layers())
|
||||
for (auto* r : l->regions())
|
||||
for (const Slic3r::Surface& sf : r->slices.surfaces)
|
||||
for (const Slic3r::Point& p : sf.expolygon.contour.points) {
|
||||
if (!seeded) { min_x = max_x = p.x(); seeded = true; }
|
||||
else { min_x = std::min(min_x, p.x()); max_x = std::max(max_x, p.x()); }
|
||||
}
|
||||
return (double)(max_x - min_x);
|
||||
}
|
||||
|
||||
// After the Slice hook mutates slices, raw_slices must be re-snapshotted so the mutation
|
||||
// becomes the untyped baseline. make_perimeters() restores untyped slices from raw_slices on
|
||||
// any perimeter re-run; invoking that restore directly must reproduce the mutation, not revert
|
||||
// to the pre-hook geometry (which is what happened before this fix).
|
||||
TEST_CASE("raw_slices captures post-hook geometry so a perimeter re-run keeps the mutation", "[slicing_pipeline]") {
|
||||
using Catch::Matchers::WithinRel;
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
|
||||
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
|
||||
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
|
||||
for (Slic3r::LayerRegion* r : l->regions()) {
|
||||
Slic3r::Surfaces in = r->slices.surfaces;
|
||||
for (auto& sf : in) {
|
||||
Slic3r::ExPolygons e = offset_ex(sf.expolygon, -scale_(1.0));
|
||||
if (!e.empty()) sf.expolygon = e.front();
|
||||
}
|
||||
r->slices.set(std::move(in));
|
||||
}
|
||||
});
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
const double w_mutated = outer_slices_width(print); // inset applied at the Slice hook
|
||||
|
||||
// The same restore make_perimeters() runs on a perimeter-only re-slice. With the post-hook
|
||||
// backup this reproduces the inset; without it this reverts to the wider original outline.
|
||||
for (Slic3r::Layer* l : print.objects().front()->layers())
|
||||
l->restore_untyped_slices();
|
||||
const double w_restored = outer_slices_width(print);
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
CHECK_THAT(w_restored, WithinRel(w_mutated, 0.02)); // mutation survived the restore
|
||||
}
|
||||
|
||||
// A plugin can mutate fill_surfaces at the new PrepareInfill seam and have make_fills consume
|
||||
// them, whereas the pre-existing Infill seam fires after the fills are already built.
|
||||
// All three runs register a hook (active path) so the comparison isolates only the mutation.
|
||||
TEST_CASE("fill_surfaces mutation cascades at PrepareInfill but not at Infill", "[slicing_pipeline]") {
|
||||
auto fill_paths = [](bool shrink, Slic3r::SlicingPipelineStepPlugin at) {
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[shrink, at](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
|
||||
if (!shrink || s != at || !o) return;
|
||||
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
|
||||
for (Slic3r::LayerRegion* r : l->regions()) {
|
||||
Slic3r::Surfaces in = r->fill_surfaces.surfaces, out;
|
||||
for (const Slic3r::Surface& sf : in)
|
||||
for (const Slic3r::ExPolygon& e : offset_ex(sf.expolygon, -scale_(3.0))) {
|
||||
Slic3r::Surface s2 = sf; s2.expolygon = e; out.push_back(std::move(s2));
|
||||
}
|
||||
r->fill_surfaces.set(std::move(out));
|
||||
}
|
||||
});
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
size_t n = 0;
|
||||
for (auto* l : print.objects().front()->layers())
|
||||
for (auto* r : l->regions())
|
||||
n += count_leaf_paths(&r->fills);
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
return n;
|
||||
};
|
||||
using S = Slic3r::SlicingPipelineStepPlugin;
|
||||
const size_t base = fill_paths(false, S::posPrepareInfill); // active hook, no mutation
|
||||
CHECK(base > 0);
|
||||
CHECK(fill_paths(true, S::posPrepareInfill) < base); // mutation before make_fills cascades
|
||||
CHECK(fill_paths(true, S::posInfill) == base); // mutation after make_fills is a no-op
|
||||
}
|
||||
|
||||
// lslices (the layer's merged islands) are built once in slice() and never rebuilt by
|
||||
// make_perimeters, so mutating region slices leaves them stale. The slices.set() + Layer::make_slices()
|
||||
// path re-derives them; this C++ analogue proves the mechanism -- without the
|
||||
// refresh the islands keep the original 20mm footprint, with it they track the 18mm inset.
|
||||
TEST_CASE("refreshing lslices after a slice mutation makes islands track the geometry", "[slicing_pipeline]") {
|
||||
auto lslices_width = [](bool refresh) {
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"}));
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[refresh](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
|
||||
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
|
||||
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers()) {
|
||||
for (Slic3r::LayerRegion* r : l->regions()) {
|
||||
Slic3r::Surfaces in = r->slices.surfaces;
|
||||
for (auto& sf : in) {
|
||||
Slic3r::ExPolygons e = offset_ex(sf.expolygon, -scale_(1.0));
|
||||
if (!e.empty()) sf.expolygon = e.front();
|
||||
}
|
||||
r->slices.set(std::move(in));
|
||||
}
|
||||
if (refresh) // the load-bearing half of the slices.set() + Layer::make_slices() path
|
||||
l->make_slices();
|
||||
}
|
||||
});
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
coord_t min_x = 0, max_x = 0; bool seeded = false;
|
||||
for (auto* l : print.objects().front()->layers())
|
||||
for (const Slic3r::ExPolygon& island : l->lslices)
|
||||
for (const Slic3r::Point& p : island.contour.points) {
|
||||
if (!seeded) { min_x = max_x = p.x(); seeded = true; }
|
||||
else { min_x = std::min(min_x, p.x()); max_x = std::max(max_x, p.x()); }
|
||||
}
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
return (double)(max_x - min_x);
|
||||
};
|
||||
using Catch::Matchers::WithinRel;
|
||||
const double stale = lslices_width(false); // islands keep the original ~20 mm footprint
|
||||
const double fresh = lslices_width(true); // islands track the ~18 mm inset region slices
|
||||
CHECK(fresh < stale);
|
||||
CHECK_THAT(stale, WithinRel((double) scale_(20.0), 0.05)); // stale islands = original outline
|
||||
CHECK_THAT(fresh, WithinRel((double) scale_(18.0), 0.05)); // refreshed islands = inset outline
|
||||
}
|
||||
|
||||
#include <random> // deterministic RNG for the fuzzy-skin analogue below
|
||||
|
||||
// Fuzzy skin applied to the slice contours at the Slice boundary, matching what the Fuzzy
|
||||
// Slices sample (sandboxes/orca_fuzzy_slices_plugin_any.py) does: resample every ring at
|
||||
// 3/4..5/4 * point_distance and displace each new vertex +/-thickness along the segment
|
||||
// normal (libslic3r's fuzzy_polyline with uniform noise). Unlike the count-preserving rotate
|
||||
// test above, this is a count-CHANGING rebuild -- each ring is replaced by one with a
|
||||
// different vertex count. Three end-to-end invariants after process() confirm the cascade:
|
||||
// (1) the jitter is zero-mean, so total fill area is preserved within a few %,
|
||||
// (2) the fuzz genuinely cascaded into make_perimeters' fill_surfaces -- their contours
|
||||
// carry far more vertices than the crisp baseline square's,
|
||||
// (3) displacement is bounded: the sliced footprint grows by at most ~2*thickness.
|
||||
TEST_CASE("Fuzzing slice contours at the Slice boundary cascades with bounded displacement", "[slicing_pipeline]") {
|
||||
using Catch::Matchers::WithinRel;
|
||||
static constexpr double kThickness = 0.3, kPointDist = 0.8; // mm; the built-in fuzzy-skin defaults
|
||||
struct Measure { double area; size_t verts; double width; };
|
||||
auto measure = [](bool fuzz) -> Measure {
|
||||
Slic3r::Print print; Slic3r::Model model;
|
||||
auto config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new Slic3r::ConfigOptionStrings({"probe"})); // active in both runs
|
||||
if (fuzz) Slic3r::Print::set_slicing_pipeline_hook_fn(
|
||||
[](Slic3r::Print&, const Slic3r::PrintObject* o, Slic3r::SlicingPipelineStepPlugin s){
|
||||
if (s != Slic3r::SlicingPipelineStepPlugin::posSlice || !o) return;
|
||||
const double thickness = scale_(kThickness);
|
||||
const double min_dist = scale_(kPointDist) * 0.75;
|
||||
const double rand_range = scale_(kPointDist) * 0.5;
|
||||
std::mt19937 rng(0x5EED); // fixed seed: the run is deterministic
|
||||
std::uniform_real_distribution<double> uni(0.0, 1.0);
|
||||
auto fuzz_ring = [&](Slic3r::Points& pts) {
|
||||
if (pts.size() < 3) return;
|
||||
Slic3r::Points out;
|
||||
double dist_left_over = uni(rng) * (min_dist / 2.0);
|
||||
const Slic3r::Point* p0 = &pts.back();
|
||||
for (const Slic3r::Point& p1 : pts) {
|
||||
const Slic3r::Vec2d v = (p1 - *p0).cast<double>();
|
||||
const double seg = v.norm();
|
||||
if (seg > 0.0) {
|
||||
double d = dist_left_over;
|
||||
for (; d < seg; d += min_dist + uni(rng) * rand_range) {
|
||||
const double r = (uni(rng) * 2.0 - 1.0) * thickness;
|
||||
const Slic3r::Vec2d pa = p0->cast<double>() + v * (d / seg);
|
||||
const Slic3r::Vec2d n = Slic3r::Vec2d(-v.y(), v.x()) / seg;
|
||||
out.emplace_back((coord_t) std::llround(pa.x() + n.x() * r),
|
||||
(coord_t) std::llround(pa.y() + n.y() * r));
|
||||
}
|
||||
dist_left_over = d - seg;
|
||||
}
|
||||
p0 = &p1;
|
||||
}
|
||||
if (out.size() >= 3) pts = std::move(out); // else: ring too short, keep it crisp
|
||||
};
|
||||
for (Slic3r::Layer* l : const_cast<Slic3r::PrintObject*>(o)->layers())
|
||||
for (Slic3r::LayerRegion* r : l->regions()) {
|
||||
Slic3r::Surfaces in = r->slices.surfaces;
|
||||
for (auto& sf : in) {
|
||||
fuzz_ring(sf.expolygon.contour.points);
|
||||
for (auto& h : sf.expolygon.holes) fuzz_ring(h.points);
|
||||
}
|
||||
r->slices.set(std::move(in));
|
||||
}
|
||||
});
|
||||
else Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
Measure m { 0.0, 0, outer_slices_width(print) };
|
||||
for (auto* l : print.objects().front()->layers())
|
||||
for (auto* r : l->regions())
|
||||
for (auto& sf : r->fill_surfaces.surfaces) {
|
||||
m.area += sf.expolygon.area();
|
||||
m.verts += sf.expolygon.contour.points.size();
|
||||
}
|
||||
Slic3r::Print::set_slicing_pipeline_hook_fn(nullptr);
|
||||
return m;
|
||||
};
|
||||
const Measure base = measure(false);
|
||||
const Measure fz = measure(true);
|
||||
// (1) Zero-mean jitter: the fills add up to (nearly) the same area.
|
||||
CHECK_THAT(fz.area, WithinRel(base.area, 0.05));
|
||||
// (2) The resample cascaded downstream: fill boundaries derived from the fuzzed slices
|
||||
// carry far more vertices than the baseline square's.
|
||||
CHECK(fz.verts > 4 * base.verts);
|
||||
// (3) Displacement is bounded by the +/-thickness jitter: the footprint widened, but by
|
||||
// no more than ~2*thickness (one thickness per side, plus rounding slack).
|
||||
CHECK(fz.width > base.width);
|
||||
CHECK(fz.width < base.width + 2.5 * scale_(kThickness));
|
||||
}
|
||||
@@ -16,6 +16,7 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_toolordering_nozzle_group.cpp
|
||||
test_preset_bundle_loading.cpp
|
||||
test_preset_setting_id.cpp
|
||||
test_preset_diff.cpp
|
||||
test_elephant_foot_compensation.cpp
|
||||
test_geometry.cpp
|
||||
test_placeholder_parser.cpp
|
||||
|
||||
@@ -5,10 +5,14 @@
|
||||
#include "libslic3r/LocalesUtils.hpp"
|
||||
|
||||
#include <cereal/types/polymorphic.hpp>
|
||||
#include <cereal/types/string.hpp>
|
||||
#include <cereal/types/vector.hpp>
|
||||
#include <cereal/types/string.hpp>
|
||||
#include <cereal/types/vector.hpp>
|
||||
#include <cereal/archives/binary.hpp>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/nowide/fstream.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
SCENARIO("Generic config validation performs as expected.", "[Config]") {
|
||||
@@ -402,6 +406,136 @@ SCENARIO("update_diff_values_to_child_config tolerates legacy machine-limit vect
|
||||
// }
|
||||
// }
|
||||
|
||||
TEST_CASE("save_to_json round-trips plugin capability references as strings", "[Config][plugins]") {
|
||||
namespace fs = boost::filesystem;
|
||||
const fs::path tmp = fs::temp_directory_path() / fs::unique_path("orca_plugins_%%%%-%%%%.json");
|
||||
const std::vector<std::string> refs = {
|
||||
"local_plugin;;inset",
|
||||
"cloud_plugin;550e8400-e29b-41d4-a716-446655440000;inset"
|
||||
};
|
||||
|
||||
std::unique_ptr<DynamicPrintConfig> config_ptr(
|
||||
DynamicPrintConfig::new_from_defaults_keys({"slicing_pipeline_plugin"}));
|
||||
DynamicPrintConfig config = std::move(*config_ptr);
|
||||
config.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = refs;
|
||||
config.save_to_json(tmp.string(), "test_preset", "User", "1.0.0.0");
|
||||
|
||||
nlohmann::json j;
|
||||
{
|
||||
boost::nowide::ifstream ifs(tmp.string());
|
||||
ifs >> j;
|
||||
}
|
||||
REQUIRE(j["slicing_pipeline_plugin"] == nlohmann::json(refs));
|
||||
CHECK_FALSE(j.contains("plugins"));
|
||||
|
||||
DynamicPrintConfig reloaded = DynamicPrintConfig::full_print_config();
|
||||
ConfigSubstitutionContext substitutions(ForwardCompatibilitySubstitutionRule::Disable);
|
||||
std::map<std::string, std::string> key_values;
|
||||
std::string reason;
|
||||
REQUIRE(reloaded.load_from_json(tmp.string(), substitutions, true, key_values, reason) == 0);
|
||||
CHECK(reason.empty());
|
||||
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
|
||||
|
||||
fs::remove(tmp);
|
||||
}
|
||||
|
||||
TEST_CASE("plugin capability references survive string-map serialization", "[Config][plugins]") {
|
||||
const std::vector<std::string> refs = {
|
||||
"master_plugin;;header-stamp",
|
||||
"Sample Plugin;1f998ea9-0183-4cc5-957f-4eef659ba4e6;G-code Benchmark (.py)"
|
||||
};
|
||||
|
||||
DynamicPrintConfig original = DynamicPrintConfig::full_print_config();
|
||||
original.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = refs;
|
||||
|
||||
std::map<std::string, std::string> serialized{
|
||||
{"slicing_pipeline_plugin", original.option<ConfigOptionStrings>("slicing_pipeline_plugin")->serialize()}
|
||||
};
|
||||
CHECK(serialized["slicing_pipeline_plugin"].find("\"master_plugin;;header-stamp\"") != std::string::npos);
|
||||
|
||||
DynamicPrintConfig reloaded = DynamicPrintConfig::full_print_config();
|
||||
reloaded.load_string_map(serialized, ForwardCompatibilitySubstitutionRule::Disable);
|
||||
|
||||
CHECK(reloaded.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values == refs);
|
||||
}
|
||||
|
||||
TEST_CASE("parse_capability_ref parses local and cloud references", "[Config][plugin]") {
|
||||
const auto local = Slic3r::parse_capability_ref("local_plugin;;post_process");
|
||||
REQUIRE(local.has_value());
|
||||
CHECK(local->name == "local_plugin");
|
||||
CHECK(local->capability_name == "post_process");
|
||||
CHECK(local->uuid.empty());
|
||||
|
||||
const auto cloud = Slic3r::parse_capability_ref(
|
||||
"cloud_plugin;550e8400-e29b-41d4-a716-446655440000;post_process");
|
||||
REQUIRE(cloud.has_value());
|
||||
CHECK(cloud->name == "cloud_plugin");
|
||||
CHECK(cloud->capability_name == "post_process");
|
||||
CHECK(cloud->uuid == "550e8400-e29b-41d4-a716-446655440000");
|
||||
}
|
||||
|
||||
TEST_CASE("parse_capability_ref rejects malformed input", "[Config][plugin]") {
|
||||
CHECK_FALSE(Slic3r::parse_capability_ref("").has_value());
|
||||
CHECK_FALSE(Slic3r::parse_capability_ref("plugin").has_value());
|
||||
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;uuid").has_value());
|
||||
CHECK_FALSE(Slic3r::parse_capability_ref(";;capability").has_value());
|
||||
CHECK_FALSE(Slic3r::parse_capability_ref(";uuid;capability").has_value());
|
||||
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;;").has_value());
|
||||
CHECK_FALSE(Slic3r::parse_capability_ref("plugin;uuid;").has_value());
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Installs a stub capability resolver that echoes the capability type into the reference, so tests
|
||||
// can assert each plugin-backed option resolved with its own ConfigOptionDef::plugin_type. Resets
|
||||
// the global resolver on teardown -- tests run in random order and other cases assert the
|
||||
// no-resolver behavior (an absent "plugins" manifest).
|
||||
struct PluginResolverFixture {
|
||||
PluginResolverFixture() {
|
||||
ConfigBase::set_resolve_capability_fn([](const std::string& name, const std::string& type) {
|
||||
return name.empty() ? std::string() : name + ";;" + type;
|
||||
});
|
||||
}
|
||||
~PluginResolverFixture() { ConfigBase::set_resolve_capability_fn(nullptr); }
|
||||
};
|
||||
} // namespace
|
||||
|
||||
TEST_CASE_METHOD(PluginResolverFixture,
|
||||
"update_plugin_manifest derives references generically from plugin-backed options",
|
||||
"[Config][plugins]") {
|
||||
// Both scalar (printer_agent) and vector (slicing_pipeline_plugin) options opt in via a non-empty
|
||||
// ConfigOptionDef::plugin_type (is_plugin_backed) and are resolved with it -- there is no hardcoded
|
||||
// per-option switch. printer_agent in particular relies on its plugin_type metadata being wired up
|
||||
// (it is edited via a dedicated widget, not the plugin_picker).
|
||||
std::unique_ptr<DynamicPrintConfig> config_ptr(DynamicPrintConfig::new_from_defaults_keys(
|
||||
{"slicing_pipeline_plugin", "printer_agent"}));
|
||||
DynamicPrintConfig config = std::move(*config_ptr);
|
||||
config.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = {"sp"};
|
||||
config.option<ConfigOptionString>("printer_agent", true)->value = "agent";
|
||||
|
||||
config.update_plugin_manifest();
|
||||
const std::vector<std::string> manifest = config.option<ConfigOptionStrings>("plugins")->values;
|
||||
|
||||
using Catch::Matchers::VectorContains;
|
||||
REQUIRE_THAT(manifest, VectorContains(std::string("sp;;slicing-pipeline")));
|
||||
REQUIRE_THAT(manifest, VectorContains(std::string("agent;;printer-connection")));
|
||||
CHECK(manifest.size() == 2);
|
||||
}
|
||||
|
||||
TEST_CASE_METHOD(PluginResolverFixture,
|
||||
"update_plugin_manifest de-duplicates references and skips unset options",
|
||||
"[Config][plugins]") {
|
||||
std::unique_ptr<DynamicPrintConfig> config_ptr(DynamicPrintConfig::new_from_defaults_keys(
|
||||
{"slicing_pipeline_plugin", "printer_agent"}));
|
||||
DynamicPrintConfig config = std::move(*config_ptr);
|
||||
config.option<ConfigOptionStrings>("slicing_pipeline_plugin", true)->values = {"x", "x"}; // duplicate
|
||||
// printer_agent stays at its default empty value -> contributes nothing to the manifest.
|
||||
|
||||
config.update_plugin_manifest();
|
||||
const std::vector<std::string> manifest = config.option<ConfigOptionStrings>("plugins")->values;
|
||||
|
||||
CHECK(manifest == std::vector<std::string>{"x;;slicing-pipeline"});
|
||||
}
|
||||
|
||||
TEST_CASE("H2C/A2L-era multi-nozzle and pre-heat config keys exist", "[config]") {
|
||||
// Foundation keys backing H2C 6-nozzle cluster grouping, the pre-heat/pre-cool time
|
||||
// model, and wipe-tower nozzle-change handling. Defaults must keep existing
|
||||
|
||||
35
tests/libslic3r/test_preset_diff.cpp
Normal file
35
tests/libslic3r/test_preset_diff.cpp
Normal file
@@ -0,0 +1,35 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include "libslic3r/Preset.hpp"
|
||||
#include "libslic3r/PrintConfig.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
// Regression test for the python-plugin branch's intentional divergence from
|
||||
// upstream in add_correct_opts_to_diff() (src/libslic3r/Preset.cpp): a vector
|
||||
// option entry whose index is beyond the reference vector's length is reported
|
||||
// dirty even when it duplicates an existing value. On main these duplicates
|
||||
// were NOT flagged. See the comment on add_correct_opts_to_diff() in src/libslic3r/Preset.cpp.
|
||||
TEST_CASE("deep_diff flags new vector entries that duplicate values[0]", "[PresetDiff][Config]")
|
||||
{
|
||||
// reference: single-extruder vector (one entry)
|
||||
Preset reference(Preset::TYPE_PRINTER, "ref");
|
||||
reference.config.set_key_value("nozzle_diameter", new ConfigOptionFloats{0.4});
|
||||
|
||||
// edited: a second extruder entry was added whose value duplicates the first
|
||||
Preset edited(Preset::TYPE_PRINTER, "edited");
|
||||
edited.config.set_key_value("nozzle_diameter", new ConfigOptionFloats{0.4, 0.4});
|
||||
|
||||
// deep_compare = true routes through deep_diff() -> add_correct_opts_to_diff()
|
||||
std::vector<std::string> diff =
|
||||
PresetCollection::dirty_options(&edited, &reference, /*deep_compare=*/true);
|
||||
|
||||
// The new index #1 is reported dirty even though 0.4 == values[0] (0.4).
|
||||
REQUIRE(std::find(diff.begin(), diff.end(), "nozzle_diameter#1") != diff.end());
|
||||
|
||||
// Sanity: the unchanged existing index #0 is NOT reported, so the rule is
|
||||
// specific to new indices rather than flagging the whole vector.
|
||||
REQUIRE(std::find(diff.begin(), diff.end(), "nozzle_diameter#0") == diff.end());
|
||||
}
|
||||
@@ -3,15 +3,55 @@ add_executable(${_TEST_NAME}_tests
|
||||
${_TEST_NAME}_tests_main.cpp
|
||||
test_dev_mapping.cpp
|
||||
test_network_versions.cpp
|
||||
test_action_source.cpp
|
||||
test_plugin_host_api.cpp
|
||||
test_plugin_capability_config.cpp
|
||||
test_plugin_config.cpp
|
||||
test_plugin_capabilities_in_use.cpp
|
||||
test_plugin_install.cpp
|
||||
test_plugin_lifecycle.cpp
|
||||
test_slicing_pipeline_bindings.cpp
|
||||
test_slicing_pipeline_config.cpp
|
||||
test_plugin_sort.cpp
|
||||
test_plugin_cloud_metadata.cpp
|
||||
test_plugin_audit.cpp
|
||||
../fff_print/test_helpers.cpp
|
||||
)
|
||||
|
||||
if (MSVC)
|
||||
target_link_libraries(${_TEST_NAME}_tests Setupapi.lib)
|
||||
endif ()
|
||||
|
||||
target_link_libraries(${_TEST_NAME}_tests test_common libslic3r_gui libslic3r Catch2::Catch2WithMain)
|
||||
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()
|
||||
|
||||
if (WIN32)
|
||||
add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/python"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_PREFIX_PATH}/libpython" "$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/python"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_if_different
|
||||
"${CMAKE_PREFIX_PATH}/libpython/python${_bundled_python_abi}.dll"
|
||||
"${CMAKE_PREFIX_PATH}/libpython/vcruntime140.dll"
|
||||
"${CMAKE_PREFIX_PATH}/libpython/vcruntime140_1.dll"
|
||||
"$<TARGET_FILE_DIR:${_TEST_NAME}_tests>"
|
||||
COMMENT "Copying Python runtime for slic3rutils plugin host API tests"
|
||||
VERBATIM
|
||||
)
|
||||
elseif (APPLE)
|
||||
target_link_options(${_TEST_NAME}_tests PRIVATE
|
||||
"LINKER:-rpath,@executable_path/python/lib")
|
||||
|
||||
add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD
|
||||
COMMAND ${CMAKE_COMMAND} -E rm -rf
|
||||
"$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/python"
|
||||
COMMAND ${CMAKE_COMMAND} -E copy_directory
|
||||
"${CMAKE_PREFIX_PATH}/libpython"
|
||||
"$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/python"
|
||||
COMMENT "Copying Python runtime for macOS plugin host API tests"
|
||||
VERBATIM
|
||||
)
|
||||
endif()
|
||||
|
||||
orcaslicer_discover_tests(${_TEST_NAME}_tests)
|
||||
|
||||
39
tests/slic3rutils/plugin_test_utils.hpp
Normal file
39
tests/slic3rutils/plugin_test_utils.hpp
Normal file
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace Slic3r {
|
||||
|
||||
// Point data_dir() at a throwaway directory for the lifetime of a test and
|
||||
// restore the previous value afterwards, so code under test writes into a
|
||||
// disposable tree and tests don't leak state into each other.
|
||||
struct ScopedDataDir
|
||||
{
|
||||
std::string previous;
|
||||
boost::filesystem::path dir;
|
||||
|
||||
explicit ScopedDataDir(const std::string& tag)
|
||||
{
|
||||
namespace fs = boost::filesystem;
|
||||
previous = data_dir();
|
||||
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
|
||||
fs::create_directories(dir);
|
||||
set_data_dir(dir.string());
|
||||
}
|
||||
|
||||
~ScopedDataDir()
|
||||
{
|
||||
set_data_dir(previous);
|
||||
boost::system::error_code ec;
|
||||
boost::filesystem::remove_all(dir, ec);
|
||||
}
|
||||
|
||||
ScopedDataDir(const ScopedDataDir&) = delete;
|
||||
ScopedDataDir& operator=(const ScopedDataDir&) = delete;
|
||||
};
|
||||
|
||||
} // namespace Slic3r
|
||||
53
tests/slic3rutils/python_test_support.hpp
Normal file
53
tests/slic3rutils/python_test_support.hpp
Normal file
@@ -0,0 +1,53 @@
|
||||
#pragma once
|
||||
|
||||
// Shared embedded-interpreter bootstrap for slic3rutils tests that need a live Python
|
||||
// interpreter (test_plugin_host_api.cpp, test_slicing_pipeline_bindings.cpp, ...).
|
||||
#include <boost/dll/runtime_symbol_info.hpp>
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <memory.h>
|
||||
#include <stdexcept>
|
||||
#include <pybind11/embed.h>
|
||||
#include <pybind11/pybind11.h>
|
||||
|
||||
#include <slic3r/plugin/PythonPluginBridge.hpp>
|
||||
|
||||
namespace {
|
||||
|
||||
void ensure_python_initialized()
|
||||
{
|
||||
if (Py_IsInitialized())
|
||||
return;
|
||||
|
||||
static std::unique_ptr<pybind11::scoped_interpreter> interpreter;
|
||||
|
||||
PyConfig config;
|
||||
PyConfig_InitPythonConfig(&config);
|
||||
config.parse_argv = 0;
|
||||
|
||||
const auto python_home = boost::dll::program_location().parent_path() / "python";
|
||||
|
||||
if (boost::filesystem::exists(python_home)) {
|
||||
const std::string home = python_home.string();
|
||||
const PyStatus status = PyConfig_SetBytesString(&config, &config.home, home.c_str());
|
||||
|
||||
if (PyStatus_Exception(status)) {
|
||||
const char* message = status.err_msg ? status.err_msg : "Failed to set Python home";
|
||||
PyConfig_Clear(&config);
|
||||
throw std::runtime_error(message);
|
||||
}
|
||||
}
|
||||
|
||||
interpreter = std::make_unique<pybind11::scoped_interpreter>(&config);
|
||||
}
|
||||
|
||||
pybind11::module_ import_orca_module()
|
||||
{
|
||||
ensure_python_initialized();
|
||||
|
||||
// Force PythonPluginBridge.cpp into the test binary so the embedded
|
||||
// PYBIND11_EMBEDDED_MODULE(orca, ...) registration is available.
|
||||
(void) Slic3r::PythonPluginBridge::instance();
|
||||
return pybind11::module_::import("orca");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
55
tests/slic3rutils/test_action_source.cpp
Normal file
55
tests/slic3rutils/test_action_source.cpp
Normal file
@@ -0,0 +1,55 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include "slic3r/GUI/ActionRegistry.hpp"
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
|
||||
using Slic3r::GUI::AppAction;
|
||||
using Slic3r::GUI::AppActionRunResult;
|
||||
using Slic3r::GUI::ActionRegistry;
|
||||
|
||||
namespace {
|
||||
|
||||
// AppAction is abstract; this minimal concrete action lets the tests exercise its
|
||||
// constructor-composed identity without involving a plugin runner.
|
||||
class TestAppAction final : public AppAction
|
||||
{
|
||||
public:
|
||||
TestAppAction() : AppAction("test", "Action title", "src-key", "Action source") {}
|
||||
|
||||
AppActionRunResult run() const override { return {}; }
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("AppAction composes a stable id from prefix:title:source_key", "[speeddial][actions]")
|
||||
{
|
||||
CHECK(AppAction::compose_id("test", "Action title", "src-key") == "test:Action title:src-key");
|
||||
// source_key (not the display name) carries identity, so it is the third field.
|
||||
CHECK(AppAction::compose_id("script", "Do Thing", "pack.py") == "script:Do Thing:pack.py");
|
||||
}
|
||||
|
||||
TEST_CASE("AppAction definitions are immutable after construction", "[speeddial][actions]")
|
||||
{
|
||||
using StringAccessor = const std::string& (AppAction::*)() const;
|
||||
|
||||
STATIC_CHECK(std::is_same_v<decltype(&AppAction::id), StringAccessor>);
|
||||
STATIC_CHECK(std::is_same_v<decltype(&AppAction::title), StringAccessor>);
|
||||
STATIC_CHECK(std::is_same_v<decltype(&AppAction::source_key), StringAccessor>);
|
||||
STATIC_CHECK(std::is_same_v<decltype(&AppAction::source_name), StringAccessor>);
|
||||
|
||||
const TestAppAction action;
|
||||
CHECK(action.id() == "test:Action title:src-key");
|
||||
CHECK(action.title() == "Action title");
|
||||
CHECK(action.source_key() == "src-key");
|
||||
CHECK(action.source_name() == "Action source");
|
||||
}
|
||||
|
||||
TEST_CASE("ActionRegistry takes exclusive ownership of published actions", "[speeddial][actions]")
|
||||
{
|
||||
using ExpectedUpsert = void (ActionRegistry::*)(std::unique_ptr<AppAction>);
|
||||
|
||||
STATIC_CHECK(std::is_same_v<decltype(&ActionRegistry::upsert), ExpectedUpsert>);
|
||||
}
|
||||
187
tests/slic3rutils/test_plugin_audit.cpp
Normal file
187
tests/slic3rutils/test_plugin_audit.cpp
Normal file
@@ -0,0 +1,187 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <libslic3r/libslic3r.h> // GCODEVIEWER_APP_KEY, SLIC3R_APP_KEY (via libslic3r_version.h)
|
||||
#include <slic3r/plugin/PluginAuditManager.hpp>
|
||||
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp> // secret_constants::USER_SECRET_FILENAME
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
// Seed the deny registry with the same list install_hook() uses. Both draw from
|
||||
// PluginAuditManager::default_denied_filenames(), so the test and production seeding cannot
|
||||
// drift apart. The registry is a process singleton, so repeated seeding only appends harmless
|
||||
// duplicates; matching is unaffected.
|
||||
void seed_denied_names()
|
||||
{
|
||||
PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||
for (const auto& name : PluginAuditManager::default_denied_filenames())
|
||||
mgr.add_denied_filename(name);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Plugin audit denies app config and token filenames anywhere", "[audit]")
|
||||
{
|
||||
seed_denied_names();
|
||||
const PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||
|
||||
SECTION("the seeded names are denied by their base name")
|
||||
{
|
||||
CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".conf")));
|
||||
CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".conf")));
|
||||
CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".ini")));
|
||||
CHECK(mgr.is_denied_filename(fs::path(GCODEVIEWER_APP_KEY ".ini")));
|
||||
CHECK(mgr.is_denied_filename(fs::path(secret_constants::USER_SECRET_FILENAME)));
|
||||
}
|
||||
|
||||
SECTION("companions holding the same secrets are denied by the prefix rule")
|
||||
{
|
||||
CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".conf.bak")));
|
||||
CHECK(mgr.is_denied_filename(fs::path(std::string(secret_constants::USER_SECRET_FILENAME) + ".tmp")));
|
||||
// Windows alternate data streams share the same base name.
|
||||
CHECK(mgr.is_denied_filename(fs::path(SLIC3R_APP_KEY ".conf:stream")));
|
||||
}
|
||||
|
||||
SECTION("the denial ignores the directory the file lives in")
|
||||
{
|
||||
CHECK(mgr.is_denied_filename(fs::path("/tmp") / (SLIC3R_APP_KEY ".conf")));
|
||||
CHECK(mgr.is_denied_filename(fs::path("/some/plugin/dir") / (SLIC3R_APP_KEY ".conf")));
|
||||
// Traversal is handled for free: filename() of the path below is already the denied name.
|
||||
CHECK(mgr.is_denied_filename(fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf")));
|
||||
}
|
||||
|
||||
SECTION("matching is case-insensitive on every platform")
|
||||
{
|
||||
CHECK(mgr.is_denied_filename(fs::path("orcaslicer.conf")));
|
||||
CHECK(mgr.is_denied_filename(fs::path("ORCASLICER.CONF")));
|
||||
CHECK(mgr.is_denied_filename(fs::path("ORCA_REFRESH_TOKEN.SEC")));
|
||||
}
|
||||
|
||||
SECTION("an unrelated name that merely shares a stem is not denied")
|
||||
{
|
||||
// The prefix is the full registered name ("OrcaSlicer.conf"), not the stem "OrcaSlicer",
|
||||
// so a sibling file with a different extension/suffix stays allowed.
|
||||
CHECK_FALSE(mgr.is_denied_filename(fs::path(data_dir()) / (SLIC3R_APP_KEY "_other.txt")));
|
||||
CHECK_FALSE(mgr.is_denied_filename(fs::path(data_dir()) / (SLIC3R_APP_KEY ".json")));
|
||||
CHECK_FALSE(mgr.is_denied_filename(fs::path("orca_refresh_token.txt")));
|
||||
}
|
||||
|
||||
SECTION("an empty path is not denied")
|
||||
{
|
||||
CHECK_FALSE(mgr.is_denied_filename(fs::path()));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Plugin audit deny beats allowed roots and the Loading read exemption", "[audit]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-audit-deny");
|
||||
seed_denied_names();
|
||||
|
||||
PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||
// Reproduce install_hook()'s grant: data_dir() is a global allowed root, so both the app
|
||||
// config and the token would otherwise be reachable simply by living inside it.
|
||||
mgr.add_global_allowed_root(data_dir());
|
||||
|
||||
// Enter a plugin context. The deny must hold in Loading mode, which every scope runs in.
|
||||
ScopedPluginAuditContext ctx("test_plugin", "", PluginAuditManager::AuditMode::Loading);
|
||||
|
||||
const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf");
|
||||
const fs::path token = fs::path(data_dir()) / secret_constants::USER_SECRET_FILENAME;
|
||||
|
||||
SECTION("a non-denied file inside the allowed root is writable (root really grants writes)")
|
||||
{
|
||||
AuditDecision decision = mgr.check_open((fs::path(data_dir()) / "plugin_data.txt").string(), "w");
|
||||
CHECK(decision.allowed);
|
||||
}
|
||||
|
||||
SECTION("writing the app config is blocked despite data_dir() being allowed")
|
||||
{
|
||||
AuditDecision decision = mgr.check_open(conf.string(), "w");
|
||||
CHECK_FALSE(decision.allowed);
|
||||
CHECK(decision.reason == "denied filename");
|
||||
}
|
||||
|
||||
SECTION("reading the app config is blocked even though Loading exempts reads")
|
||||
{
|
||||
// Without the deny, a read in Loading mode short-circuits to allow. The deny sits above
|
||||
// that exemption, so this must still be blocked.
|
||||
AuditDecision decision = mgr.check_open(conf.string(), "r");
|
||||
CHECK_FALSE(decision.allowed);
|
||||
CHECK(decision.reason == "denied filename");
|
||||
}
|
||||
|
||||
SECTION("reading the cloud refresh token is blocked in Loading mode")
|
||||
{
|
||||
AuditDecision decision = mgr.check_open(token.string(), "r");
|
||||
CHECK_FALSE(decision.allowed);
|
||||
}
|
||||
|
||||
SECTION("the token staging companion (.tmp) is blocked too")
|
||||
{
|
||||
AuditDecision decision = mgr.check_open((token.string() + ".tmp"), "w");
|
||||
CHECK_FALSE(decision.allowed);
|
||||
}
|
||||
|
||||
SECTION("a traversal path resolving to the config is blocked")
|
||||
{
|
||||
const fs::path traversal = fs::path(data_dir()) / "plugins" / ".." / (SLIC3R_APP_KEY ".conf");
|
||||
AuditDecision decision = mgr.check_open(traversal.string(), "r");
|
||||
CHECK_FALSE(decision.allowed);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Plugin audit deny beats a plugin's own scoped root", "[audit]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-audit-scoped");
|
||||
seed_denied_names();
|
||||
|
||||
PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||
|
||||
// A plugin's private directory, granted as a scoped root while it runs.
|
||||
const fs::path plugin_dir = fs::path(data_dir()) / "plugins" / "test_plugin";
|
||||
fs::create_directories(plugin_dir);
|
||||
|
||||
ScopedPluginAuditContext ctx("test_plugin", "", PluginAuditManager::AuditMode::Loading);
|
||||
mgr.add_scoped_allowed_root(plugin_dir);
|
||||
|
||||
SECTION("the plugin's own non-denied file opens for read and write")
|
||||
{
|
||||
const std::string own_file = (plugin_dir / "state.json").string();
|
||||
CHECK(mgr.check_open(own_file, "r").allowed);
|
||||
CHECK(mgr.check_open(own_file, "w").allowed);
|
||||
}
|
||||
|
||||
SECTION("a denied name stashed inside the plugin's own root is still blocked")
|
||||
{
|
||||
const std::string smuggled = (plugin_dir / (SLIC3R_APP_KEY ".conf")).string();
|
||||
AuditDecision decision = mgr.check_open(smuggled, "w");
|
||||
CHECK_FALSE(decision.allowed);
|
||||
CHECK(decision.reason == "denied filename");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Plugin audit does not constrain non-plugin code", "[audit]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-audit-noplugin");
|
||||
seed_denied_names();
|
||||
|
||||
PluginAuditManager& mgr = PluginAuditManager::instance();
|
||||
mgr.clear_current_plugin(); // no plugin context: this is OrcaSlicer's own C++/internal Python
|
||||
|
||||
const fs::path conf = fs::path(data_dir()) / (SLIC3R_APP_KEY ".conf");
|
||||
|
||||
// The name is still recognised as denied...
|
||||
CHECK(mgr.is_denied_filename(conf));
|
||||
// ...but with no current plugin the access check allows it: denies constrain plugin code only.
|
||||
CHECK(mgr.check_open(conf.string(), "w").allowed);
|
||||
CHECK(mgr.check_open(conf.string(), "r").allowed);
|
||||
}
|
||||
74
tests/slic3rutils/test_plugin_capabilities_in_use.cpp
Normal file
74
tests/slic3rutils/test_plugin_capabilities_in_use.cpp
Normal file
@@ -0,0 +1,74 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Preset.hpp>
|
||||
#include <libslic3r/PrintConfig.hpp>
|
||||
#include <slic3r/plugin/PluginResolver.hpp>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
|
||||
// A print preset carrying a "plugins" manifest and the one plugin-backed print option.
|
||||
Preset make_print_preset(const std::vector<std::string>& manifest, const std::vector<std::string>& pipeline)
|
||||
{
|
||||
Preset preset(Preset::TYPE_PRINT, "test-print");
|
||||
const std::unique_ptr<DynamicPrintConfig> defaults(
|
||||
DynamicPrintConfig::new_from_defaults_keys({"plugins", "slicing_pipeline_plugin"}));
|
||||
preset.config = *defaults;
|
||||
preset.config.option<ConfigOptionStrings>("plugins")->values = manifest;
|
||||
preset.config.option<ConfigOptionStrings>("slicing_pipeline_plugin")->values = pipeline;
|
||||
return preset;
|
||||
}
|
||||
|
||||
std::vector<std::string> capability_names(const std::vector<PluginCapabilityRef>& refs)
|
||||
{
|
||||
std::vector<std::string> names;
|
||||
for (const PluginCapabilityRef& ref : refs)
|
||||
names.push_back(ref.capability_name);
|
||||
return names;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("referenced_capabilities keeps only manifest entries an option points at", "[PluginResolver]")
|
||||
{
|
||||
// CapB is declared in the manifest but no option references it, so it is not in use.
|
||||
const Preset preset = make_print_preset({"acme;;CapA", "acme;;CapB"}, {"CapA"});
|
||||
|
||||
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) == std::vector<std::string>{"CapA"});
|
||||
}
|
||||
|
||||
TEST_CASE("referenced_capabilities matches every value of a vector option", "[PluginResolver]")
|
||||
{
|
||||
const Preset preset = make_print_preset({"acme;;CapA", "acme;;CapB", "acme;;CapC"}, {"CapA", "CapC"});
|
||||
|
||||
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) ==
|
||||
std::vector<std::string>{"CapA", "CapC"});
|
||||
}
|
||||
|
||||
TEST_CASE("referenced_capabilities is empty when the manifest is empty", "[PluginResolver]")
|
||||
{
|
||||
const Preset preset = make_print_preset({}, {"CapA"});
|
||||
|
||||
CHECK(referenced_capabilities(Preset::TYPE_PRINT, preset).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("referenced_capabilities ignores untracked preset types", "[PluginResolver]")
|
||||
{
|
||||
Preset preset = make_print_preset({"acme;;CapA"}, {"CapA"});
|
||||
preset.type = Preset::TYPE_SLA_PRINT;
|
||||
|
||||
CHECK(referenced_capabilities(Preset::TYPE_SLA_PRINT, preset).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("referenced_capabilities skips malformed manifest entries", "[PluginResolver]")
|
||||
{
|
||||
// parse_capability_ref rejects entries that are not "name;uuid;capability".
|
||||
const Preset preset = make_print_preset({"garbage", "acme;;CapA"}, {"CapA"});
|
||||
|
||||
CHECK(capability_names(referenced_capabilities(Preset::TYPE_PRINT, preset)) == std::vector<std::string>{"CapA"});
|
||||
}
|
||||
449
tests/slic3rutils/test_plugin_capability_config.cpp
Normal file
449
tests/slic3rutils/test_plugin_capability_config.cpp
Normal file
@@ -0,0 +1,449 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginConfig.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
#include <slic3r/plugin/PythonPluginBridge.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <pybind11/embed.h>
|
||||
#include <pybind11/pybind11.h>
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace py = pybind11;
|
||||
using namespace Slic3r;
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
// Brings the plugin system up for the duration of one test and tears it down at the end — same
|
||||
// idiom as ScopedPluginManager in test_plugin_lifecycle.cpp, and for the same reason: shutdown()
|
||||
// logs via boost::log, so it must run before boost::log tears down its thread-local storage, not be
|
||||
// left to a static destructor at process exit.
|
||||
//
|
||||
// Needed here (unlike a bare pybind11::scoped_interpreter): has_config_ui()/get_config_ui()/
|
||||
// get_name()/etc. all cross PyPluginCommonTrampoline's ORCA_PY_OVERRIDE_AUDITED, which refuses to
|
||||
// call into Python unless PythonInterpreter::instance() itself reports initialized (see
|
||||
// PythonGILState). A bare interpreter never sets that flag, so every trampoline call would report
|
||||
// "Python interpreter is shutting down" even though Python was perfectly alive.
|
||||
//
|
||||
// initialize() leaves the GIL released (production code re-acquires it per call via PythonGILState);
|
||||
// every TEST_CASE below pairs this with a py::gil_scoped_acquire, declared second so it releases
|
||||
// before this destructor's shutdown() runs.
|
||||
struct ScopedPluginManager
|
||||
{
|
||||
bool initialized = PluginManager::instance().initialize();
|
||||
|
||||
~ScopedPluginManager()
|
||||
{
|
||||
PluginManager::instance().shutdown();
|
||||
PythonInterpreter::instance().shutdown();
|
||||
}
|
||||
};
|
||||
|
||||
py::module_ import_orca_module()
|
||||
{
|
||||
(void) PythonPluginBridge::instance(); // force the embedded module registration into the binary
|
||||
return py::module_::import("orca");
|
||||
}
|
||||
|
||||
// Builds a Python capability the way PluginLoader does: the audit identity is stamped on by the
|
||||
// host, never supplied by the plugin, and it scopes every config call to this one capability.
|
||||
py::object make_capability(const std::string& class_name,
|
||||
const std::string& body,
|
||||
const std::string& plugin_key,
|
||||
const std::string& capability_name,
|
||||
PluginCapabilityType type = PluginCapabilityType::Script)
|
||||
{
|
||||
// Import first: it brings the interpreter up, and any py:: object built before it would touch a
|
||||
// Python that does not exist yet.
|
||||
py::module_ orca = import_orca_module();
|
||||
|
||||
py::dict globals;
|
||||
globals["orca"] = orca;
|
||||
|
||||
py::exec("class " + class_name + "(orca.PythonPluginBase):\n" + body, globals);
|
||||
py::object instance = globals[class_name.c_str()]();
|
||||
|
||||
if (!plugin_key.empty()) {
|
||||
auto iface = instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
|
||||
iface->set_audit_plugin_key(plugin_key);
|
||||
iface->set_resolved_identity(capability_name, type);
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
std::shared_ptr<PluginCapabilityInterface> as_interface(const py::object& instance)
|
||||
{
|
||||
return instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
|
||||
}
|
||||
|
||||
// The Python API writes through the PluginManager singleton, so that is where assertions read from.
|
||||
PluginConfig& host_config() { return PluginManager::instance().get_config(); }
|
||||
|
||||
// The Python config API speaks JSON text, not dicts; these helpers keep the tests in terms of values.
|
||||
json py_get_config(const py::object& cap) { return json::parse(cap.attr("get_config")().cast<std::string>()); }
|
||||
|
||||
bool py_save_config(const py::object& cap, const json& value) { return cap.attr("save_config")(value.dump()).cast<bool>(); }
|
||||
|
||||
PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, const char* plugin_key)
|
||||
{
|
||||
return {type, name, plugin_key};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Capability config API is exposed on every Python capability", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
||||
|
||||
py::module_ orca = import_orca_module();
|
||||
REQUIRE(py::hasattr(orca, "PythonPluginBase"));
|
||||
|
||||
py::object base = orca.attr("PythonPluginBase");
|
||||
// Host-provided: every capability has a config, so there is no hook to opt out of being
|
||||
// configurable.
|
||||
CHECK(py::hasattr(base, "get_config"));
|
||||
CHECK(py::hasattr(base, "save_config"));
|
||||
CHECK(py::hasattr(base, "get_config_version"));
|
||||
// Plugin-provided (the host calls these). All optional.
|
||||
CHECK(py::hasattr(base, "has_config_ui"));
|
||||
CHECK(py::hasattr(base, "get_config_ui"));
|
||||
CHECK(py::hasattr(base, "get_default_config"));
|
||||
|
||||
// Config is reached only through the capability, never as a free orca.config.* function, so a
|
||||
// capability cannot name — and cannot touch — a config that is not its own.
|
||||
CHECK_FALSE(py::hasattr(orca, "config"));
|
||||
}
|
||||
|
||||
TEST_CASE("get_config returns only cap_config and save_config persists it", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
||||
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-roundtrip");
|
||||
host_config().load(); // reset the singleton's in-memory store against the empty temp dir
|
||||
|
||||
py::object cap = make_capability("RoundTripCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
||||
|
||||
// Nothing stored yet: the JSON text of an empty object, not None, so a plugin can json.loads() it
|
||||
// unconditionally.
|
||||
py::object initial = cap.attr("get_config")();
|
||||
REQUIRE(py::isinstance<py::str>(initial));
|
||||
CHECK(json::parse(initial.cast<std::string>()) == json::object());
|
||||
CHECK(cap.attr("get_config_version")().cast<std::string>().empty());
|
||||
|
||||
REQUIRE(py_save_config(cap, json{{"speed", 5}, {"name", "fast"}}));
|
||||
|
||||
const auto stored = host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"));
|
||||
REQUIRE(stored);
|
||||
CHECK(stored->id == capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"));
|
||||
CHECK(stored->config == json{{"speed", 5}, {"name", "fast"}});
|
||||
|
||||
// Python reads back exactly cap_config — no host metadata.
|
||||
const json reloaded = py_get_config(cap);
|
||||
CHECK(reloaded.size() == 2);
|
||||
CHECK(reloaded.contains("speed"));
|
||||
CHECK_FALSE(reloaded.contains("plugin_key"));
|
||||
CHECK_FALSE(reloaded.contains("capability"));
|
||||
CHECK_FALSE(reloaded.contains("cap_config"));
|
||||
CHECK_FALSE(reloaded.contains("plugin_version"));
|
||||
}
|
||||
|
||||
TEST_CASE("save_config rejects a string that is not valid JSON", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
||||
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-badjson");
|
||||
host_config().load();
|
||||
|
||||
py::object cap = make_capability("BadJsonCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
||||
|
||||
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
|
||||
|
||||
// Refusing unparseable text must leave the previously stored config alone.
|
||||
CHECK_FALSE(cap.attr("save_config")("{not json").cast<bool>());
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"keep", "me"}});
|
||||
}
|
||||
|
||||
TEST_CASE("Saving one capability's config does not touch another's", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
||||
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-isolation");
|
||||
host_config().load();
|
||||
|
||||
const std::string body = " def get_name(self): return 'cap'\n";
|
||||
// Same capability name under two plugins, plus a second capability of plugin_a: each addresses
|
||||
// only the entry matching its own stamped identity.
|
||||
py::object a_cap1 = make_capability("IsoCapA1", body, "plugin_a", "cap_a");
|
||||
py::object a_cap2 = make_capability("IsoCapA2", body, "plugin_a", "cap_b");
|
||||
py::object b_cap1 = make_capability("IsoCapB1", body, "plugin_b", "cap_a");
|
||||
|
||||
REQUIRE(py_save_config(a_cap1, json{{"value", 1}}));
|
||||
REQUIRE(py_save_config(a_cap2, json{{"value", 2}}));
|
||||
REQUIRE(py_save_config(b_cap1, json{{"value", 3}}));
|
||||
|
||||
REQUIRE(py_save_config(a_cap1, json{{"value", 99}}));
|
||||
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"value", 99}});
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_b", "plugin_a"))->config == json{{"value", 2}});
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->config == json{{"value", 3}});
|
||||
|
||||
// A shared name under one plugin must remain isolated when the capability type differs.
|
||||
py::object importer = make_capability("IsoCapImporter", body, "plugin_a", "cap_a", PluginCapabilityType::Importer);
|
||||
REQUIRE(py_save_config(importer, json{{"value", 4}}));
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"value", 99}});
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Importer, "cap_a", "plugin_a"))->config == json{{"value", 4}});
|
||||
|
||||
host_config().load();
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"value", 99}});
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Importer, "cap_a", "plugin_a"))->config == json{{"value", 4}});
|
||||
|
||||
CHECK(py_get_config(a_cap2).at("value") == 2);
|
||||
CHECK(py_get_config(b_cap1).at("value") == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("Config API refuses a capability the host never materialized", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
||||
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-unowned");
|
||||
host_config().load();
|
||||
|
||||
// No audit identity: never loaded by the host, so it has no config to address. Refused rather
|
||||
// than served from, or written to, some arbitrary entry.
|
||||
py::object orphan = make_capability("OrphanCap", " def get_name(self): return 'cap'\n", "", "");
|
||||
|
||||
CHECK_THROWS(orphan.attr("get_config")());
|
||||
CHECK_THROWS(orphan.attr("get_config_version")());
|
||||
CHECK_THROWS(orphan.attr("save_config")(json::object().dump()));
|
||||
}
|
||||
|
||||
TEST_CASE("Custom config UI hooks dispatch to the Python override", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
||||
|
||||
py::object cap = make_capability("CustomUiCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def has_config_ui(self): return True\n"
|
||||
" def get_config_ui(self): return '<p>hello</p>'\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
CHECK(iface->has_config_ui());
|
||||
CHECK(iface->get_config_ui() == "<p>hello</p>");
|
||||
}
|
||||
|
||||
TEST_CASE("A capability that omits the config UI hooks gets the default editor", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
||||
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-bare");
|
||||
host_config().load();
|
||||
|
||||
// Both hooks are optional and only choose the editor: a capability that overrides neither is
|
||||
// still configurable, it just gets the host's JSON editor. There is no way to opt out.
|
||||
py::object bare = make_capability("BareCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(bare);
|
||||
REQUIRE(iface);
|
||||
CHECK_FALSE(iface->has_config_ui()); // -> default JSON editor
|
||||
CHECK(iface->get_config_ui().empty());
|
||||
|
||||
REQUIRE(py_save_config(bare, json{{"speed", 5}}));
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"speed", 5}});
|
||||
}
|
||||
|
||||
TEST_CASE("get_default_config supplies the value Restore defaults writes back", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
||||
|
||||
SECTION("not overridden -> an empty config")
|
||||
{
|
||||
// Already "restore defaults" for a capability that keeps its stored config sparse and applies
|
||||
// its own defaults on read.
|
||||
py::object bare = make_capability("NoDefaultsCap", " def get_name(self): return 'cap_a'\n", "plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(bare);
|
||||
REQUIRE(iface);
|
||||
CHECK(iface->get_default_config() == json::object());
|
||||
}
|
||||
|
||||
SECTION("overridden -> exactly what the plugin returns")
|
||||
{
|
||||
py::object cap = make_capability("DefaultsCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def get_default_config(self):\n"
|
||||
" return {'speed': 5, 'nested': {'on': True}, 'items': [1, 2]}\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
// Round-trips through py_to_json untouched: the host does not reshape or validate it.
|
||||
CHECK(iface->get_default_config() == json{{"speed", 5}, {"nested", {{"on", true}}}, {"items", {1, 2}}});
|
||||
}
|
||||
|
||||
SECTION("overridden but returns None -> an empty config, never a null")
|
||||
{
|
||||
// `def get_default_config(self): pass` is the easy mistake, and it must not store
|
||||
// "cap_config": null.
|
||||
py::object cap = make_capability("NoneDefaultsCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def get_default_config(self): pass\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
|
||||
const json restored = iface->get_default_config();
|
||||
CHECK(restored == json::object());
|
||||
CHECK_FALSE(restored.is_null());
|
||||
}
|
||||
|
||||
SECTION("overridden but returns a non-object -> an empty config")
|
||||
{
|
||||
py::object cap = make_capability("ScalarDefaultsCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def get_default_config(self): return [1, 2, 3]\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
CHECK(iface->get_default_config() == json::object());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Restoring defaults overwrites only the target capability", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
||||
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-restore");
|
||||
host_config().load();
|
||||
|
||||
const std::string defaults_body = " def get_name(self): return 'cap'\n"
|
||||
" def get_default_config(self): return {'speed': 1}\n";
|
||||
py::object target = make_capability("RestoreTargetCap", defaults_body, "plugin_a", "cap_a");
|
||||
py::object bystander = make_capability("RestoreBystanderCap", defaults_body, "plugin_b", "cap_a");
|
||||
|
||||
const json edited = json{{"speed", 99}};
|
||||
REQUIRE(py_save_config(target, edited));
|
||||
REQUIRE(py_save_config(bystander, edited));
|
||||
|
||||
// What PluginsDialog::restore_capability_config does: ask the capability, store the answer.
|
||||
auto iface = as_interface(target);
|
||||
REQUIRE(host_config().store_capability_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), iface->get_default_config()));
|
||||
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"speed", 1}});
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b"))->config == json{{"speed", 99}});
|
||||
}
|
||||
|
||||
TEST_CASE("A raising get_default_config leaves the stored config untouched", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
||||
|
||||
ScopedDataDir data_dir_guard("plugin-config-py-restore-raise");
|
||||
host_config().load();
|
||||
|
||||
py::object cap = make_capability("RaisingDefaultsCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def get_default_config(self): raise RuntimeError('boom')\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
REQUIRE(py_save_config(cap, json{{"keep", "me"}}));
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
CHECK_THROWS_AS(iface->get_default_config(), py::error_already_set);
|
||||
|
||||
// The dialog stores nothing when the hook throws: a broken plugin must not wipe user settings.
|
||||
CHECK(host_config().get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"keep", "me"}});
|
||||
}
|
||||
|
||||
TEST_CASE("A raising config UI hook surfaces as an exception the host can catch", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
||||
|
||||
py::object cap = make_capability("RaisingCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def has_config_ui(self): return True\n"
|
||||
" def get_config_ui(self): raise RuntimeError('boom')\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
|
||||
// The trampoline rethrows; callers catch it and fall back to the default JSON editor.
|
||||
CHECK_THROWS_AS(iface->get_config_ui(), py::error_already_set);
|
||||
|
||||
// Catching it leaves the interpreter usable.
|
||||
CHECK(iface->get_name() == "cap_a");
|
||||
}
|
||||
|
||||
TEST_CASE("A config UI hook returning the wrong type does not crash the host", "[PluginConfig][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system; // declared first: destroyed last
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
py::gil_scoped_acquire gil; // released before plugin_system's destructor shuts Python down
|
||||
|
||||
// has_config_ui() is plugin-authored, so it can return anything; the host must survive the call.
|
||||
py::object cap = make_capability("BadTypeCap",
|
||||
" def get_name(self): return 'cap_a'\n"
|
||||
" def has_config_ui(self): return 'not a bool'\n",
|
||||
"plugin_a", "cap_a");
|
||||
|
||||
auto iface = as_interface(cap);
|
||||
REQUIRE(iface);
|
||||
|
||||
// Deliberately not REQUIRE_THROWS: pybind may coerce or reject the value, and both are fine.
|
||||
// What must hold is that the call is survivable — PluginLoader's guard turns a throw into
|
||||
// "no custom UI".
|
||||
try {
|
||||
(void) iface->has_config_ui();
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
|
||||
// The capability is still usable afterwards.
|
||||
CHECK(iface->get_name() == "cap_a");
|
||||
CHECK(iface->get_config_ui().empty());
|
||||
}
|
||||
132
tests/slic3rutils/test_plugin_cloud_metadata.cpp
Normal file
132
tests/slic3rutils/test_plugin_cloud_metadata.cpp
Normal file
@@ -0,0 +1,132 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginConfig.hpp>
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
// Brings the plugin manager up and shuts both singletons down while boost::log is still alive;
|
||||
// left to their static destructors, shutdown()'s logging runs after boost::log tears down its
|
||||
// thread-local storage and crashes the process on exit (same reason ScopedPluginManager exists in
|
||||
// test_plugin_lifecycle.cpp). initialize() IS needed here: the plugin under test is a
|
||||
// slicing-pipeline script, so discover_plugins() brings up the Python interpreter to parse it,
|
||||
// same as any other plugin.
|
||||
struct ScopedManagerShutdown
|
||||
{
|
||||
bool initialized = PluginManager::instance().initialize();
|
||||
|
||||
~ScopedManagerShutdown()
|
||||
{
|
||||
PluginManager::instance().shutdown();
|
||||
PythonInterpreter::instance().shutdown();
|
||||
}
|
||||
};
|
||||
|
||||
const char* const CLOUD_PLUGIN_SOURCE = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Config Cloud Plugin"
|
||||
# type = "slicing-pipeline"
|
||||
# version = "1.0"
|
||||
# ///
|
||||
print('ok')
|
||||
)PY";
|
||||
|
||||
} // namespace
|
||||
|
||||
// Regression: update_cloud_metadata() replaces a matched entry's descriptor wholesale with the
|
||||
// cloud catalog record (`entry = cloud_entry`). Configuration used to ride on the descriptor, so
|
||||
// that overwrite silently wiped it and plugins fell back to their built-in defaults (found via
|
||||
// Twistify running with its demo defaults instead of the configured values, 2026-07-17).
|
||||
// Configuration now lives in PluginConfig, keyed by the capability identity and kept off the
|
||||
// descriptor entirely, so the merge cannot reach it. This asserts that end to end: a stored
|
||||
// config survives the same refresh path, while the descriptor fields the refresh owns do update.
|
||||
//
|
||||
// The capability need not exist for this to be meaningful: what is pinned is the architectural
|
||||
// invariant that config never rides on the descriptor again. Anyone reintroducing it there, or
|
||||
// adding a cloud-refresh path that prunes config, fails here.
|
||||
TEST_CASE("cloud metadata refresh preserves a plugin's stored config", "[PluginCloudMetadata]")
|
||||
{
|
||||
ScopedManagerShutdown manager_shutdown_guard; // declared first: destroyed last
|
||||
if (!manager_shutdown_guard.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
ScopedDataDir data_dir_guard("cloud-meta-config");
|
||||
|
||||
// A locally-installed cloud plugin: package .py plus an install-state sidecar carrying the
|
||||
// cloud identity. Discovery derives plugin_key from the cloud UUID.
|
||||
const std::string uuid = "11111111-2222-3333-4444-555555555555";
|
||||
const fs::path plugin_dir = fs::path(get_orca_plugins_dir()) / uuid;
|
||||
fs::create_directories(plugin_dir);
|
||||
{
|
||||
std::ofstream out((plugin_dir / "cloud_plugin-test.py").string(), std::ios::binary);
|
||||
out << CLOUD_PLUGIN_SOURCE;
|
||||
}
|
||||
PluginDescriptor sidecar;
|
||||
sidecar.name = "Config Cloud Plugin";
|
||||
sidecar.installed_version = "1.0";
|
||||
sidecar.cloud = CloudPluginState{uuid, true, false, false, false};
|
||||
REQUIRE(write_install_state(plugin_dir, sidecar));
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
const auto find_by_uuid = [&manager, &uuid]() -> PluginDescriptor {
|
||||
for (const PluginDescriptor& d : manager.get_plugin_descriptors(/*include_invalid=*/true))
|
||||
if (d.cloud_uuid() == uuid)
|
||||
return d;
|
||||
return {};
|
||||
};
|
||||
|
||||
const PluginDescriptor discovered = find_by_uuid();
|
||||
REQUIRE(discovered.plugin_key == uuid);
|
||||
REQUIRE(discovered.version == "1.0");
|
||||
|
||||
// The user has configured the plugin's capability (premise).
|
||||
const PluginCapabilityId id{PluginCapabilityType::SlicingPipeline, "Twist", uuid};
|
||||
const json configured{{"twist_deg_per_mm", 1.0}, {"taper_per_mm", 0.0}};
|
||||
REQUIRE(manager.get_config().store_capability_config(id, configured));
|
||||
|
||||
// A cloud catalog refresh for the same plugin: the record knows name/version/uuid and knows
|
||||
// nothing about local config or local paths.
|
||||
PluginDescriptor cloud_record;
|
||||
cloud_record.name = "Config Cloud Plugin";
|
||||
cloud_record.plugin_key = uuid;
|
||||
cloud_record.version = "1.1";
|
||||
cloud_record.cloud = CloudPluginState{uuid, false, false, false, false};
|
||||
manager.update_cloud_metadata({cloud_record});
|
||||
|
||||
// Cloud metadata landed on the descriptor...
|
||||
const PluginDescriptor refreshed = find_by_uuid();
|
||||
CHECK(refreshed.version == "1.1");
|
||||
CHECK(refreshed.plugin_key == uuid);
|
||||
CHECK(refreshed.installed_version == "1.0");
|
||||
|
||||
// ...and the stored config is untouched, both in the live store...
|
||||
const auto stored = manager.get_config().get_config(id);
|
||||
REQUIRE(stored);
|
||||
CHECK(stored->config == configured);
|
||||
|
||||
// ...and on disk, which is what the next run reads back.
|
||||
PluginConfig reloaded;
|
||||
reloaded.load();
|
||||
REQUIRE(reloaded.has_config(id));
|
||||
CHECK(reloaded.get_config(id)->config == configured);
|
||||
}
|
||||
264
tests/slic3rutils/test_plugin_config.cpp
Normal file
264
tests/slic3rutils/test_plugin_config.cpp
Normal file
@@ -0,0 +1,264 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginConfig.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/nowide/fstream.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
PluginCapabilityId capability_id(PluginCapabilityType type, const char* name, const char* plugin_key)
|
||||
{
|
||||
return {type, name, plugin_key};
|
||||
}
|
||||
|
||||
json read_config_file()
|
||||
{
|
||||
boost::nowide::ifstream ifs(PluginConfig::plugin_config_file().c_str());
|
||||
json root;
|
||||
ifs >> root;
|
||||
return root;
|
||||
}
|
||||
|
||||
void write_config_file(const std::string& contents)
|
||||
{
|
||||
const fs::path path(PluginConfig::plugin_config_file());
|
||||
fs::create_directories(path.parent_path());
|
||||
boost::nowide::ofstream ofs(path.string().c_str(), std::ios::out | std::ios::trunc);
|
||||
ofs << contents;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("PluginConfig creates, reads back and persists a capability config", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-roundtrip");
|
||||
|
||||
PluginConfig config;
|
||||
const PluginCapabilityId id = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
|
||||
|
||||
// A capability nobody has configured yet reads as an empty record rather than throwing.
|
||||
CHECK_FALSE(config.has_config(id));
|
||||
CHECK_FALSE(config.get_config(id));
|
||||
|
||||
REQUIRE(config.store_capability_config(id, json{{"speed", 5}}));
|
||||
|
||||
const auto stored = config.get_config(id);
|
||||
REQUIRE(stored);
|
||||
CHECK(stored->id == id);
|
||||
CHECK(stored->config == json{{"speed", 5}});
|
||||
CHECK(config.has_config(id));
|
||||
|
||||
// store_capability_config writes through, so a fresh instance (a restart, in effect) sees it.
|
||||
PluginConfig reloaded;
|
||||
reloaded.load();
|
||||
CHECK(reloaded.get_config(id)->config == json{{"speed", 5}});
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig updates only the target capability's cap_config", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-isolation");
|
||||
|
||||
PluginConfig config;
|
||||
const PluginCapabilityId a_a = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
|
||||
const PluginCapabilityId a_b = capability_id(PluginCapabilityType::Script, "cap_b", "plugin_a");
|
||||
const PluginCapabilityId b_a = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_b");
|
||||
// The identity is the full (type, capability, plugin_key) tuple, so all three below are separate records.
|
||||
REQUIRE(config.store_capability_config(a_a, json{{"value", 1}}));
|
||||
REQUIRE(config.store_capability_config(a_b, json{{"value", 2}}));
|
||||
REQUIRE(config.store_capability_config(b_a, json{{"value", 3}}));
|
||||
|
||||
REQUIRE(config.store_capability_config(a_a, json{{"value", 99}}));
|
||||
|
||||
CHECK(config.get_config(a_a)->config == json{{"value", 99}});
|
||||
CHECK(config.get_config(a_b)->config == json{{"value", 2}});
|
||||
CHECK(config.get_config(b_a)->config == json{{"value", 3}});
|
||||
|
||||
// The same holds on disk, not just in memory.
|
||||
PluginConfig reloaded;
|
||||
reloaded.load();
|
||||
CHECK(reloaded.get_config(a_a)->config == json{{"value", 99}});
|
||||
CHECK(reloaded.get_config(a_b)->config == json{{"value", 2}});
|
||||
CHECK(reloaded.get_config(b_a)->config == json{{"value", 3}});
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig isolates same-name capabilities by type", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-type-isolation");
|
||||
const PluginCapabilityId script = capability_id(PluginCapabilityType::Script, "shared", "plugin_a");
|
||||
const PluginCapabilityId importer = capability_id(PluginCapabilityType::Importer, "shared", "plugin_a");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE(config.store_capability_config(script, json{{"value", "script"}}));
|
||||
REQUIRE(config.store_capability_config(importer, json{{"value", "importer"}}));
|
||||
CHECK(config.get_config(script)->config == json{{"value", "script"}});
|
||||
CHECK(config.get_config(importer)->config == json{{"value", "importer"}});
|
||||
|
||||
PluginConfig reloaded;
|
||||
reloaded.load();
|
||||
CHECK(reloaded.get_config(script)->config == json{{"value", "script"}});
|
||||
CHECK(reloaded.get_config(importer)->config == json{{"value", "importer"}});
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig serializes the documented on-disk schema", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-schema");
|
||||
|
||||
PluginConfig config;
|
||||
const PluginCapabilityId id = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
|
||||
REQUIRE(config.store_capability_config(id, json{{"speed", 5}}));
|
||||
|
||||
// Locks the field names: an existing config.json must keep loading after any future change.
|
||||
const json root = read_config_file();
|
||||
REQUIRE(root.contains("config"));
|
||||
REQUIRE(root.at("config").is_array());
|
||||
REQUIRE(root.at("config").size() == 1);
|
||||
|
||||
const json& entry = root.at("config").front();
|
||||
CHECK(entry.at("plugin_key") == "plugin_a");
|
||||
CHECK(entry.at("capability") == "cap_a");
|
||||
CHECK(entry.at("capability_type") == "script");
|
||||
CHECK(entry.at("cap_config") == json{{"speed", 5}});
|
||||
CHECK(entry.contains("plugin_version"));
|
||||
// Only cap_config is user data; the rest of the record is host-managed.
|
||||
CHECK(entry.size() == 5);
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig keeps a capability's config after its plugin goes away", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-retention");
|
||||
|
||||
{
|
||||
PluginConfig config;
|
||||
REQUIRE(config.store_capability_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"), json{{"token", "keep me"}}));
|
||||
}
|
||||
|
||||
// config.json is deliberately not keyed to installed plugins: a record outlives its plugin and is
|
||||
// still there on reinstall. Asserts no cleanup path silently drops it.
|
||||
PluginConfig after_removal;
|
||||
after_removal.load();
|
||||
CHECK(after_removal.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"token", "keep me"}});
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig treats a missing config file as an empty store", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-missing");
|
||||
|
||||
REQUIRE_FALSE(fs::exists(PluginConfig::plugin_config_file()));
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load());
|
||||
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a")));
|
||||
CHECK_FALSE(config.dirty());
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig survives a malformed config file", "[PluginConfig]")
|
||||
{
|
||||
SECTION("not JSON at all")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-garbage");
|
||||
write_config_file("this is not json {{{");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load()); // a bad config must not block startup
|
||||
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a")));
|
||||
}
|
||||
|
||||
SECTION("valid JSON without the entries array")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-noarray");
|
||||
write_config_file(R"({"config": {"not": "an array"}})");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load());
|
||||
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a")));
|
||||
}
|
||||
|
||||
SECTION("entries without an identity are skipped, the rest still load")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-partial");
|
||||
write_config_file(R"({"config": [
|
||||
{"cap_config": {"orphan": true}},
|
||||
{"plugin_key": "plugin_a", "capability": "cap_a", "capability_type": "script", "plugin_version": "1.0.0", "cap_config": {"kept": true}}
|
||||
]})");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load());
|
||||
CHECK(config.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json{{"kept", true}});
|
||||
CHECK(config.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->plugin_version == "1.0.0");
|
||||
}
|
||||
|
||||
SECTION("an entry with no cap_config reads as an empty object")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-nocap");
|
||||
write_config_file(R"({"config": [
|
||||
{"plugin_key": "plugin_a", "capability": "cap_a", "capability_type": "script", "plugin_version": "1.0.0"}
|
||||
]})");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load());
|
||||
REQUIRE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a")));
|
||||
CHECK(config.get_config(capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a"))->config == json::object());
|
||||
}
|
||||
|
||||
SECTION("legacy entries without capability_type remain addressable")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-notype");
|
||||
write_config_file(R"({"config": [
|
||||
{"plugin_key": "plugin_a", "capability": "cap_a", "plugin_version": "1.0.0", "cap_config": {"old": true}}
|
||||
]})");
|
||||
|
||||
PluginConfig config;
|
||||
REQUIRE_NOTHROW(config.load());
|
||||
const auto id = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
|
||||
REQUIRE(config.has_config(id));
|
||||
CHECK(config.get_config(id)->config == json{{"old", true}});
|
||||
|
||||
REQUIRE(config.store_capability_config(id, json{{"migrated", true}}));
|
||||
const json root = read_config_file();
|
||||
REQUIRE(root.at("config").size() == 1);
|
||||
CHECK(root.at("config").front().at("capability_type") == "script");
|
||||
CHECK(root.at("config").front().at("cap_config") == json{{"migrated", true}});
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig refuses to store a record without an identity", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-identity");
|
||||
|
||||
PluginConfig config;
|
||||
config.save_config(CapabilityConfigEntry{capability_id(PluginCapabilityType::Script, "cap_a", ""), "1.0.0", json::object()});
|
||||
config.save_config(CapabilityConfigEntry{capability_id(PluginCapabilityType::Script, "", "plugin_a"), "1.0.0", json::object()});
|
||||
|
||||
// Neither could ever be looked up again, so neither is kept.
|
||||
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "cap_a", "")));
|
||||
CHECK_FALSE(config.has_config(capability_id(PluginCapabilityType::Script, "", "plugin_a")));
|
||||
CHECK_FALSE(config.dirty());
|
||||
}
|
||||
|
||||
TEST_CASE("PluginConfig preserves unknown keys inside cap_config", "[PluginConfig]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("plugin-config-unknown");
|
||||
|
||||
// The host never interprets cap_config, so a nested/odd shape must round-trip untouched.
|
||||
const json nested = json{{"nested", {{"deep", json::array({1, 2, 3})}}}, {"flag", false}, {"name", "x"}};
|
||||
|
||||
PluginConfig config;
|
||||
const PluginCapabilityId id = capability_id(PluginCapabilityType::Script, "cap_a", "plugin_a");
|
||||
REQUIRE(config.store_capability_config(id, nested));
|
||||
|
||||
PluginConfig reloaded;
|
||||
reloaded.load();
|
||||
CHECK(reloaded.get_config(id)->config == nested);
|
||||
}
|
||||
338
tests/slic3rutils/test_plugin_host_api.cpp
Normal file
338
tests/slic3rutils/test_plugin_host_api.cpp
Normal file
@@ -0,0 +1,338 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Model.hpp>
|
||||
#include <libslic3r/PresetBundle.hpp>
|
||||
#include <libslic3r/TriangleMesh.hpp>
|
||||
#include <slic3r/plugin/PythonPluginBridge.hpp>
|
||||
|
||||
#include "python_test_support.hpp"
|
||||
|
||||
#include <pybind11/embed.h>
|
||||
#include <pybind11/pybind11.h>
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace py = pybind11;
|
||||
|
||||
namespace {
|
||||
|
||||
// import_orca_module() lives in python_test_support.hpp (shared with
|
||||
// test_slicing_pipeline_bindings.cpp).
|
||||
|
||||
bool has_attr(const py::handle& object, const char* name)
|
||||
{
|
||||
return py::hasattr(object, name);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Plugin host API exposes host-owned bundle and preset surface to Python", "[PluginHost][Python]")
|
||||
{
|
||||
py::module_ orca = import_orca_module();
|
||||
REQUIRE(has_attr(orca, "host"));
|
||||
|
||||
py::object host = orca.attr("host");
|
||||
REQUIRE(has_attr(host, "PresetBundle"));
|
||||
REQUIRE(has_attr(host, "Preset"));
|
||||
REQUIRE(has_attr(host, "PresetCollection"));
|
||||
REQUIRE(has_attr(host, "Model"));
|
||||
REQUIRE(has_attr(host, "ModelObject"));
|
||||
REQUIRE(has_attr(host, "Plater"));
|
||||
|
||||
py::object preset_bundle_type = host.attr("PresetBundle");
|
||||
CHECK(has_attr(preset_bundle_type, "prints"));
|
||||
CHECK(has_attr(preset_bundle_type, "printers"));
|
||||
CHECK(has_attr(preset_bundle_type, "filaments"));
|
||||
CHECK(has_attr(preset_bundle_type, "current_process_preset"));
|
||||
CHECK(has_attr(preset_bundle_type, "current_printer_preset"));
|
||||
CHECK(has_attr(preset_bundle_type, "current_filament_preset_names"));
|
||||
CHECK(has_attr(preset_bundle_type, "current_filament_presets"));
|
||||
CHECK(has_attr(preset_bundle_type, "full_config_value"));
|
||||
|
||||
py::object preset_collection_type = host.attr("PresetCollection");
|
||||
CHECK(has_attr(preset_collection_type, "get_edited_preset"));
|
||||
CHECK(has_attr(preset_collection_type, "get_selected_preset"));
|
||||
CHECK(has_attr(preset_collection_type, "get_selected_preset_name"));
|
||||
CHECK(has_attr(preset_collection_type, "edited_preset"));
|
||||
CHECK(has_attr(preset_collection_type, "selected_preset"));
|
||||
CHECK(has_attr(preset_collection_type, "selected_preset_name"));
|
||||
|
||||
py::object preset_type = host.attr("Preset");
|
||||
CHECK(has_attr(preset_type, "name"));
|
||||
CHECK(has_attr(preset_type, "type"));
|
||||
CHECK(has_attr(preset_type, "is_default"));
|
||||
CHECK(has_attr(preset_type, "is_system"));
|
||||
CHECK(has_attr(preset_type, "is_user"));
|
||||
CHECK(has_attr(preset_type, "is_from_bundle"));
|
||||
CHECK(has_attr(preset_type, "config_value"));
|
||||
|
||||
Slic3r::PresetBundle bundle;
|
||||
Slic3r::Preset& printer_preset = bundle.printers.get_edited_preset();
|
||||
Slic3r::Preset& process_preset = bundle.prints.get_edited_preset();
|
||||
Slic3r::Preset& filament_preset = bundle.filaments.get_edited_preset();
|
||||
|
||||
printer_preset.config.set("printer_model", "Plugin Host Test Printer", true);
|
||||
bundle.filament_presets = { filament_preset.name, filament_preset.name, "missing filament preset" };
|
||||
|
||||
py::object py_bundle = py::cast(&bundle, py::return_value_policy::reference);
|
||||
|
||||
CHECK(py_bundle.attr("current_printer_preset")().attr("name").cast<std::string>() == printer_preset.name);
|
||||
CHECK(py_bundle.attr("current_print_preset")().attr("name").cast<std::string>() == process_preset.name);
|
||||
CHECK(py_bundle.attr("current_process_preset")().attr("name").cast<std::string>() == process_preset.name);
|
||||
CHECK(py_bundle.attr("current_printer_preset")().attr("is_default").cast<bool>() == printer_preset.is_default);
|
||||
CHECK(py_bundle.attr("current_printer_preset")().attr("is_user")().cast<bool>() == printer_preset.is_user());
|
||||
CHECK(py_bundle.attr("current_printer_preset")().attr("config_value")("printer_model").cast<std::string>() == "Plugin Host Test Printer");
|
||||
CHECK(py_bundle.attr("current_printer_preset")().attr("config_value")("missing_test_key").is_none());
|
||||
|
||||
py::list filament_names = py_bundle.attr("current_filament_preset_names")();
|
||||
REQUIRE(py::len(filament_names) == 3);
|
||||
CHECK(filament_names[0].cast<std::string>() == filament_preset.name);
|
||||
CHECK(filament_names[1].cast<std::string>() == filament_preset.name);
|
||||
CHECK(filament_names[2].cast<std::string>() == "missing filament preset");
|
||||
|
||||
py::list filament_presets = py_bundle.attr("current_filament_presets")();
|
||||
REQUIRE(py::len(filament_presets) == 3);
|
||||
CHECK_FALSE(filament_presets[0].is_none());
|
||||
CHECK(filament_presets[0].attr("name").cast<std::string>() == filament_preset.name);
|
||||
CHECK_FALSE(filament_presets[1].is_none());
|
||||
CHECK(filament_presets[1].attr("name").cast<std::string>() == filament_preset.name);
|
||||
CHECK(filament_presets[2].is_none());
|
||||
|
||||
py::object printers = py_bundle.attr("printers");
|
||||
py::object prints = py_bundle.attr("prints");
|
||||
py::object filaments = py_bundle.attr("filaments");
|
||||
CHECK(printers.attr("get_edited_preset")().attr("name").cast<std::string>() == printer_preset.name);
|
||||
CHECK(prints.attr("get_edited_preset")().attr("name").cast<std::string>() == process_preset.name);
|
||||
CHECK(filaments.attr("get_edited_preset")().attr("name").cast<std::string>() == filament_preset.name);
|
||||
CHECK(printers.attr("get_selected_preset_name")().cast<std::string>() == bundle.printers.get_selected_preset_name());
|
||||
CHECK(printers.attr("get_selected_preset")().attr("name").cast<std::string>() == bundle.printers.get_selected_preset().name);
|
||||
CHECK(printers.attr("selected_preset_name")().cast<std::string>() == bundle.printers.get_selected_preset_name());
|
||||
CHECK(printers.attr("edited_preset")().attr("name").cast<std::string>() == printer_preset.name);
|
||||
CHECK(printers.attr("find_preset")(printer_preset.name).attr("name").cast<std::string>() == printer_preset.name);
|
||||
}
|
||||
|
||||
TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initialization", "[PluginHost][Python]")
|
||||
{
|
||||
py::object host = import_orca_module().attr("host");
|
||||
|
||||
for (const char* function_name : { "preset_bundle", "plater", "model" }) {
|
||||
CAPTURE(function_name);
|
||||
try {
|
||||
host.attr(function_name)();
|
||||
FAIL("host accessor unexpectedly succeeded without a wx application");
|
||||
} catch (const py::error_already_set& error) {
|
||||
CHECK(error.matches(PyExc_RuntimeError));
|
||||
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app initialization", "[PluginHost][Python]")
|
||||
{
|
||||
py::object host = import_orca_module().attr("host");
|
||||
REQUIRE(has_attr(host, "ui"));
|
||||
|
||||
py::object ui = host.attr("ui");
|
||||
CHECK(has_attr(ui, "message"));
|
||||
CHECK_FALSE(has_attr(ui, "show_dialog"));
|
||||
CHECK(has_attr(ui, "create_window"));
|
||||
CHECK(has_attr(ui, "WINDOW_MODELESS"));
|
||||
CHECK(has_attr(ui, "WINDOW_MODAL"));
|
||||
CHECK(ui.attr("WINDOW_MODELESS").cast<long>() == 0);
|
||||
CHECK(ui.attr("WINDOW_MODAL").cast<long>() == 1);
|
||||
CHECK(has_attr(ui, "UiWindow"));
|
||||
|
||||
// With no wx application the UI calls marshal to a main thread that does not
|
||||
// exist here; they must fail cleanly with a clear error, not crash.
|
||||
try {
|
||||
ui.attr("message")("hello");
|
||||
FAIL("orca.host.ui.message unexpectedly succeeded without a wx application");
|
||||
} catch (const py::error_already_set& error) {
|
||||
CHECK(error.matches(PyExc_RuntimeError));
|
||||
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHost][Python]")
|
||||
{
|
||||
using Catch::Matchers::WithinAbs;
|
||||
using Catch::Matchers::WithinRel;
|
||||
|
||||
py::object host = import_orca_module().attr("host");
|
||||
REQUIRE(has_attr(host, "BoundingBox"));
|
||||
REQUIRE(has_attr(host, "Model"));
|
||||
REQUIRE(has_attr(host, "ModelInstance"));
|
||||
REQUIRE(has_attr(host, "ModelVolume"));
|
||||
REQUIRE(has_attr(host, "ModelVolumeType"));
|
||||
|
||||
py::object volume_type_enum = host.attr("ModelVolumeType");
|
||||
CHECK(has_attr(volume_type_enum, "ModelPart"));
|
||||
CHECK(has_attr(volume_type_enum, "ParameterModifier"));
|
||||
CHECK(has_attr(volume_type_enum, "SupportEnforcer"));
|
||||
|
||||
// Build a model in C++: one object with a 10x20x30 mm printable part, a small
|
||||
// modifier volume, and a single instance shifted on the bed.
|
||||
Slic3r::Model model;
|
||||
Slic3r::ModelObject* object = model.add_object();
|
||||
object->name = "Plugin Host Test Cube";
|
||||
|
||||
Slic3r::ModelVolume* part = object->add_volume(Slic3r::make_cube(10.0, 20.0, 30.0));
|
||||
part->name = "cube part";
|
||||
Slic3r::ModelVolume* modifier = object->add_volume(Slic3r::make_cube(2.0, 2.0, 2.0),
|
||||
Slic3r::ModelVolumeType::PARAMETER_MODIFIER);
|
||||
modifier->name = "fit modifier";
|
||||
|
||||
Slic3r::ModelInstance* instance = object->add_instance();
|
||||
instance->set_offset(Slic3r::Vec3d(5.0, 6.0, 0.0));
|
||||
|
||||
py::object py_model = py::cast(&model, py::return_value_policy::reference);
|
||||
|
||||
// Model surface.
|
||||
CHECK(py_model.attr("object_count")().cast<size_t>() == 1);
|
||||
CHECK(py_model.attr("id")().cast<size_t>() == model.id().id);
|
||||
CHECK(py_model.attr("bounding_box")().attr("defined").cast<bool>());
|
||||
|
||||
// Object surface.
|
||||
py::object py_object = py_model.attr("object")(0);
|
||||
CHECK(py_object.attr("name").cast<std::string>() == "Plugin Host Test Cube");
|
||||
CHECK(py_object.attr("id")().cast<size_t>() == object->id().id);
|
||||
CHECK(py_object.attr("instance_count")().cast<size_t>() == 1);
|
||||
CHECK(py_object.attr("volume_count")().cast<size_t>() == 2);
|
||||
CHECK(py::len(py_object.attr("instances")()) == 1);
|
||||
CHECK(py::len(py_object.attr("volumes")()) == 2);
|
||||
CHECK(py_object.attr("is_multiparts")().cast<bool>());
|
||||
|
||||
// Intrinsic (untransformed) object size must match the printable part's dimensions.
|
||||
py::object obj_size = py_object.attr("raw_mesh_bounding_box")().attr("size");
|
||||
REQUIRE_THAT(obj_size[py::int_(0)].cast<double>(), WithinAbs(10.0, 1e-3));
|
||||
REQUIRE_THAT(obj_size[py::int_(1)].cast<double>(), WithinAbs(20.0, 1e-3));
|
||||
REQUIRE_THAT(obj_size[py::int_(2)].cast<double>(), WithinAbs(30.0, 1e-3));
|
||||
|
||||
// Instance surface.
|
||||
py::object py_instance = py_object.attr("instance")(0);
|
||||
py::object inst_offset = py_instance.attr("offset")();
|
||||
REQUIRE_THAT(inst_offset[py::int_(0)].cast<double>(), WithinAbs(5.0, 1e-6));
|
||||
REQUIRE_THAT(inst_offset[py::int_(1)].cast<double>(), WithinAbs(6.0, 1e-6));
|
||||
REQUIRE_THAT(inst_offset[py::int_(2)].cast<double>(), WithinAbs(0.0, 1e-6));
|
||||
CHECK(py_instance.attr("id")().cast<size_t>() == instance->id().id);
|
||||
|
||||
// Volume surface — part.
|
||||
py::object py_part = py_object.attr("volume")(0);
|
||||
CHECK(py_part.attr("name").cast<std::string>() == "cube part");
|
||||
CHECK(py_part.attr("is_model_part")().cast<bool>());
|
||||
CHECK_FALSE(py_part.attr("is_modifier")().cast<bool>());
|
||||
CHECK(py_part.attr("type")().cast<Slic3r::ModelVolumeType>() == Slic3r::ModelVolumeType::MODEL_PART);
|
||||
CHECK(py_part.attr("facets_count")().cast<size_t>() == 12);
|
||||
REQUIRE_THAT(py_part.attr("volume")().cast<double>(), WithinRel(6000.0, 1e-2));
|
||||
|
||||
// Volume surface — modifier.
|
||||
py::object py_modifier = py_object.attr("volume")(1);
|
||||
CHECK(py_modifier.attr("is_modifier")().cast<bool>());
|
||||
CHECK_FALSE(py_modifier.attr("is_model_part")().cast<bool>());
|
||||
CHECK(py_modifier.attr("type")().cast<Slic3r::ModelVolumeType>() == Slic3r::ModelVolumeType::PARAMETER_MODIFIER);
|
||||
}
|
||||
|
||||
TEST_CASE("Plugin host API exposes TriangleMesh geometry to Python", "[PluginHost][Python]")
|
||||
{
|
||||
using Catch::Matchers::WithinAbs;
|
||||
using Catch::Matchers::WithinRel;
|
||||
|
||||
py::object host = import_orca_module().attr("host");
|
||||
REQUIRE(has_attr(host, "TriangleMesh"));
|
||||
|
||||
py::object mesh_type = host.attr("TriangleMesh");
|
||||
for (const char* member : { "vertex_count", "triangle_count", "facets_count", "is_empty",
|
||||
"vertices", "triangles", "face_normals", "vertex", "triangle",
|
||||
"volume", "bounding_box", "is_manifold" }) {
|
||||
CAPTURE(member);
|
||||
CHECK(has_attr(mesh_type, member));
|
||||
}
|
||||
|
||||
// A 10 x 20 x 30 mm box: 8 vertices, 12 triangles.
|
||||
Slic3r::Model model;
|
||||
Slic3r::ModelObject* object = model.add_object();
|
||||
object->add_volume(Slic3r::make_cube(10.0, 20.0, 30.0));
|
||||
Slic3r::ModelInstance* instance = object->add_instance();
|
||||
instance->set_offset(Slic3r::Vec3d(5.0, 6.0, 0.0));
|
||||
|
||||
py::object py_object = py::cast(object, py::return_value_policy::reference);
|
||||
py::object py_volume = py_object.attr("volume")(0);
|
||||
py::object mesh = py_volume.attr("mesh")();
|
||||
|
||||
// Deterministic, numpy-free surface.
|
||||
CHECK(mesh.attr("vertex_count")().cast<size_t>() == 8);
|
||||
CHECK(mesh.attr("triangle_count")().cast<size_t>() == 12);
|
||||
CHECK(mesh.attr("facets_count")().cast<size_t>() == 12);
|
||||
CHECK_FALSE(mesh.attr("is_empty")().cast<bool>());
|
||||
CHECK(mesh.attr("is_manifold")().cast<bool>());
|
||||
REQUIRE_THAT(mesh.attr("volume")().cast<double>(), WithinRel(6000.0, 1e-2));
|
||||
|
||||
py::object bbox_size = mesh.attr("bounding_box")().attr("size");
|
||||
REQUIRE_THAT(bbox_size[py::int_(0)].cast<double>(), WithinAbs(10.0, 1e-3));
|
||||
REQUIRE_THAT(bbox_size[py::int_(1)].cast<double>(), WithinAbs(20.0, 1e-3));
|
||||
REQUIRE_THAT(bbox_size[py::int_(2)].cast<double>(), WithinAbs(30.0, 1e-3));
|
||||
|
||||
py::object vertex0 = mesh.attr("vertex")(0);
|
||||
REQUIRE(py::len(vertex0) == 3);
|
||||
py::object triangle0 = mesh.attr("triangle")(0);
|
||||
REQUIRE(py::len(triangle0) == 3);
|
||||
for (int k = 0; k < 3; ++k) {
|
||||
int idx = triangle0[py::int_(k)].cast<int>();
|
||||
CHECK(idx >= 0);
|
||||
CHECK(idx < 8);
|
||||
}
|
||||
CHECK_THROWS_AS(mesh.attr("vertex")(8), py::error_already_set);
|
||||
CHECK_THROWS_AS(mesh.attr("triangle")(12), py::error_already_set);
|
||||
|
||||
// numpy path: exercised when numpy is importable, otherwise assert the clear
|
||||
// "numpy required" error so the absent path is itself covered.
|
||||
bool have_numpy = false;
|
||||
try {
|
||||
py::module_::import("numpy");
|
||||
have_numpy = true;
|
||||
} catch (const py::error_already_set&) {
|
||||
have_numpy = false;
|
||||
}
|
||||
|
||||
if (!have_numpy) {
|
||||
WARN("numpy unavailable in unit-test interpreter; asserting the numpy-absent error path");
|
||||
try {
|
||||
mesh.attr("vertices")();
|
||||
FAIL("vertices() must raise ImportError when numpy is unavailable");
|
||||
} catch (const py::error_already_set& error) {
|
||||
CHECK(error.matches(PyExc_ImportError));
|
||||
CHECK(std::string(error.what()).find("numpy is required") != std::string::npos);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
py::object vertices = mesh.attr("vertices")();
|
||||
CHECK(vertices.attr("shape").cast<py::tuple>()[py::int_(0)].cast<size_t>() == 8);
|
||||
CHECK(vertices.attr("shape").cast<py::tuple>()[py::int_(1)].cast<size_t>() == 3);
|
||||
CHECK(vertices.attr("dtype").attr("name").cast<std::string>() == "float32");
|
||||
CHECK_FALSE(vertices.attr("flags").attr("writeable").cast<bool>());
|
||||
CHECK_FALSE(vertices.attr("base").is_none()); // zero-copy view keeps an owner alive
|
||||
CHECK_THROWS_AS(vertices.attr("__setitem__")(py::make_tuple(0, 0), py::float_(1.0)), py::error_already_set);
|
||||
|
||||
py::object triangles = mesh.attr("triangles")();
|
||||
CHECK(triangles.attr("shape").cast<py::tuple>()[py::int_(0)].cast<size_t>() == 12);
|
||||
CHECK(triangles.attr("shape").cast<py::tuple>()[py::int_(1)].cast<size_t>() == 3);
|
||||
CHECK(triangles.attr("dtype").attr("name").cast<std::string>() == "int32");
|
||||
CHECK_FALSE(triangles.attr("flags").attr("writeable").cast<bool>());
|
||||
|
||||
py::object face_normals = mesh.attr("face_normals")();
|
||||
CHECK(face_normals.attr("shape").cast<py::tuple>()[py::int_(0)].cast<size_t>() == 12);
|
||||
CHECK(face_normals.attr("dtype").attr("name").cast<std::string>() == "float32");
|
||||
|
||||
// World-space transform matrices.
|
||||
py::object volume_matrix = py_volume.attr("matrix")();
|
||||
CHECK(volume_matrix.attr("shape").cast<py::tuple>()[py::int_(0)].cast<size_t>() == 4);
|
||||
CHECK(volume_matrix.attr("shape").cast<py::tuple>()[py::int_(1)].cast<size_t>() == 4);
|
||||
CHECK(volume_matrix.attr("dtype").attr("name").cast<std::string>() == "float64");
|
||||
|
||||
py::object instance_matrix = py_object.attr("instance")(0).attr("matrix")();
|
||||
CHECK(instance_matrix.attr("shape").cast<py::tuple>()[py::int_(0)].cast<size_t>() == 4);
|
||||
// Instance offset (5, 6, 0) must land in the matrix translation column.
|
||||
REQUIRE_THAT(instance_matrix.attr("__getitem__")(py::make_tuple(0, 3)).cast<double>(), WithinAbs(5.0, 1e-6));
|
||||
REQUIRE_THAT(instance_matrix.attr("__getitem__")(py::make_tuple(1, 3)).cast<double>(), WithinAbs(6.0, 1e-6));
|
||||
}
|
||||
123
tests/slic3rutils/test_plugin_install.cpp
Normal file
123
tests/slic3rutils/test_plugin_install.cpp
Normal file
@@ -0,0 +1,123 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginLoader.hpp>
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
namespace {
|
||||
|
||||
fs::path write_py_file(const fs::path& dir, const std::string& filename, const std::string& contents)
|
||||
{
|
||||
fs::create_directories(dir);
|
||||
const fs::path p = dir / filename;
|
||||
std::ofstream out(p.string(), std::ios::binary);
|
||||
out << contents;
|
||||
return p;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("install_plugin rejects a cloud UUID containing path traversal", "[PluginInstall]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("cor2");
|
||||
|
||||
// Package contents are irrelevant: the UUID is validated before metadata is read.
|
||||
const fs::path py = write_py_file(data_dir_guard.dir / "src", "evil.py", "print('hi')\n");
|
||||
|
||||
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
// is_cloud_plugin() -> true; cloud_uuid() -> the traversal string.
|
||||
descriptor.cloud = CloudPluginState{"../../escape", true, false, false, false};
|
||||
|
||||
std::string error;
|
||||
const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"test-user", descriptor, error);
|
||||
|
||||
REQUIRE_FALSE(installed);
|
||||
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("valid identifier"));
|
||||
}
|
||||
|
||||
TEST_CASE("install_plugin rejects a side-loaded .py with no PEP 723 metadata", "[PluginInstall]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("cor3-bad");
|
||||
|
||||
// No `# /// script` block -> name stays empty and type stays Unknown.
|
||||
const fs::path py = write_py_file(data_dir_guard.dir / "src", "nameless.py", "print('no metadata here')\n");
|
||||
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
std::string error;
|
||||
const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"", descriptor, error);
|
||||
|
||||
REQUIRE_FALSE(installed);
|
||||
CHECK_THAT(error, Catch::Matchers::ContainsSubstring("PEP 723"));
|
||||
}
|
||||
|
||||
TEST_CASE("install_plugin accepts a side-loaded .py with complete PEP 723 metadata", "[PluginInstall]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("cor3-good");
|
||||
|
||||
const std::string contents =
|
||||
"# /// script\n"
|
||||
"# requires-python = \">=3.12\"\n"
|
||||
"#\n"
|
||||
"# [tool.orcaslicer.plugin]\n"
|
||||
"# name = \"Test Plugin\"\n"
|
||||
"# type = \"script\"\n"
|
||||
"# ///\n"
|
||||
"print('ok')\n";
|
||||
const fs::path py = write_py_file(data_dir_guard.dir / "src", "good.py", contents);
|
||||
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
std::string error;
|
||||
const bool installed = plugin_loader::install_plugin(py, /*cloud_user_id=*/"", descriptor, error);
|
||||
|
||||
// Positive control: a complete side-loaded .py must still install (guards against over-rejection).
|
||||
REQUIRE(installed);
|
||||
CHECK(error.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's installed version", "[PluginInstall]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("installed-version");
|
||||
|
||||
const fs::path plugin_dir = data_dir_guard.dir / "plugin";
|
||||
fs::create_directories(plugin_dir);
|
||||
|
||||
// A cloud plugin whose local manifest/PEP723 header lags the version actually fetched from
|
||||
// the cloud: the user bumped the version on the cloud without touching the local header.
|
||||
PluginDescriptor descriptor;
|
||||
descriptor.name = "Versioned Plugin";
|
||||
descriptor.version = "1.0.0"; // stale header version
|
||||
descriptor.installed_version = "1.2.0"; // version fetched from the cloud at install time
|
||||
descriptor.cloud = CloudPluginState{"uuid-1", true, false, false, false};
|
||||
|
||||
REQUIRE(write_install_state(plugin_dir, descriptor));
|
||||
|
||||
// The writer must persist the installed_version (1.2.0), not the header version (1.0.0),
|
||||
// so a subsequent re-write from a freshly-scanned descriptor cannot clobber it.
|
||||
PluginInstallState state;
|
||||
REQUIRE(read_install_state(plugin_dir, state));
|
||||
CHECK(state.installed_version == "1.2.0");
|
||||
|
||||
// Reading the sidecar back onto a freshly-scanned descriptor (whose header version is still
|
||||
// 1.0.0) must surface the cloud-installed 1.2.0. This is what lets update_cloud_metadata compare
|
||||
// the cloud's latest version against the installed version instead of the stale header, so an
|
||||
// already-updated plugin no longer looks perpetually out of date.
|
||||
PluginDescriptor scanned;
|
||||
scanned.version = "1.0.0"; // as parsed from the unchanged PEP723 header
|
||||
read_install_state(plugin_dir, scanned);
|
||||
CHECK(scanned.installed_version == "1.2.0");
|
||||
}
|
||||
758
tests/slic3rutils/test_plugin_lifecycle.cpp
Normal file
758
tests/slic3rutils/test_plugin_lifecycle.cpp
Normal file
@@ -0,0 +1,758 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginDescriptor.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace Slic3r;
|
||||
namespace fs = boost::filesystem;
|
||||
|
||||
// Plugin load/unload lifecycle: discovery -> load -> capability materialization -> enable/disable
|
||||
// -> unload.
|
||||
//
|
||||
// Each Catch2 test case runs in its own process (catch_discover_tests), so the PluginManager and
|
||||
// interpreter singletons are brought up at most once per test.
|
||||
|
||||
namespace {
|
||||
|
||||
// Point data_dir() at a throwaway directory for the lifetime of a test and restore the previous
|
||||
// value afterwards, so discovery scans a disposable {data_dir}/orca_plugins tree and tests don't
|
||||
// leak state into each other.
|
||||
struct ScopedDataDir
|
||||
{
|
||||
std::string previous;
|
||||
fs::path dir;
|
||||
|
||||
explicit ScopedDataDir(const std::string& tag)
|
||||
{
|
||||
previous = data_dir();
|
||||
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
|
||||
fs::create_directories(dir);
|
||||
set_data_dir(dir.string());
|
||||
}
|
||||
|
||||
~ScopedDataDir()
|
||||
{
|
||||
set_data_dir(previous);
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(dir, ec);
|
||||
}
|
||||
|
||||
fs::path plugins_dir() const { return dir / "orca_plugins"; }
|
||||
};
|
||||
|
||||
// Brings the plugin system up, and tears it down explicitly at the end of the test.
|
||||
//
|
||||
// Shutting the interpreter down here, rather than leaving it to PythonInterpreter's static
|
||||
// destructor, mirrors what the app does (GUI_App finalizes it before exit). Left to static
|
||||
// destruction, shutdown()'s logging runs after boost::log has torn down its thread-local storage
|
||||
// and throws, aborting the process after the tests have already passed.
|
||||
//
|
||||
// Declare this FIRST in a test so it is destroyed last.
|
||||
struct ScopedPluginManager
|
||||
{
|
||||
bool initialized = false;
|
||||
|
||||
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
|
||||
~ScopedPluginManager()
|
||||
{
|
||||
PluginManager::instance().shutdown();
|
||||
PythonInterpreter::instance().shutdown();
|
||||
}
|
||||
};
|
||||
|
||||
// A minimal script plugin exposing exactly one capability, "Echo".
|
||||
const char* const ECHO_PLUGIN_SOURCE = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Echo Plugin"
|
||||
# description = "Plugin lifecycle characterization fixture"
|
||||
# author = "OrcaSlicer"
|
||||
# version = "1.0"
|
||||
# type = "script"
|
||||
# ///
|
||||
import orca
|
||||
|
||||
class Echo(orca.script.ScriptPluginCapabilityBase):
|
||||
def get_name(self):
|
||||
return "Echo"
|
||||
|
||||
def execute(self, ctx):
|
||||
return orca.ExecutionResult.success()
|
||||
|
||||
@orca.plugin
|
||||
class EchoPackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(Echo)
|
||||
)PY";
|
||||
|
||||
// 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)
|
||||
{
|
||||
const fs::path plugin_dir = data_dir_guard.plugins_dir() / stem;
|
||||
fs::create_directories(plugin_dir);
|
||||
|
||||
std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary);
|
||||
out << source;
|
||||
out.close();
|
||||
|
||||
return plugin_dir;
|
||||
}
|
||||
|
||||
// Loads a plugin and blocks until the detached worker thread is done with it.
|
||||
bool load_and_wait(PluginManager& manager,
|
||||
const std::string& plugin_key,
|
||||
std::string& error,
|
||||
std::vector<std::string> capabilities_to_enable = {})
|
||||
{
|
||||
manager.load_plugin(plugin_key, /*skip_deps=*/true, std::move(capabilities_to_enable));
|
||||
return manager.wait_for_plugin_load(plugin_key, std::chrono::seconds(120), error);
|
||||
}
|
||||
|
||||
std::shared_ptr<PluginCapabilityInterface> find_capability(PluginManager& manager, const std::string& plugin_key,
|
||||
const std::string& name)
|
||||
{ return manager.get_plugin_capability({PluginCapabilityType::Unknown, name, plugin_key}, /*only_enabled=*/false); }
|
||||
|
||||
std::vector<std::shared_ptr<PluginCapabilityInterface>> capabilities_of(PluginManager& manager, const std::string& plugin_key)
|
||||
{
|
||||
return manager.get_plugin_capabilities(plugin_key, PluginCapabilityType::Unknown, /*only_enabled=*/false);
|
||||
}
|
||||
|
||||
PluginDescriptor descriptor_of(PluginManager& manager, const std::string& plugin_key)
|
||||
{
|
||||
PluginDescriptor descriptor;
|
||||
manager.try_get_plugin_descriptor(plugin_key, descriptor);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("A discovered script plugin loads and materializes its capability", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-load");
|
||||
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
REQUIRE(manager.try_get_valid_plugin_descriptor("Echo_Plugin", descriptor));
|
||||
CHECK(descriptor.name == "Echo Plugin");
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
INFO("load error: " << error);
|
||||
CHECK(error.empty());
|
||||
|
||||
CHECK(manager.is_plugin_loaded("Echo_Plugin"));
|
||||
CHECK(manager.get_plugin_load_error("Echo_Plugin").empty());
|
||||
|
||||
const auto capabilities = capabilities_of(manager, "Echo_Plugin");
|
||||
REQUIRE(capabilities.size() == 1);
|
||||
|
||||
const auto& echo = capabilities.front();
|
||||
CHECK(echo->name() == "Echo");
|
||||
CHECK(echo->type() == PluginCapabilityType::Script);
|
||||
CHECK(echo->is_enabled());
|
||||
CHECK(echo->audit_plugin_key() == "Echo_Plugin");
|
||||
|
||||
CHECK(manager.get_plugin_capability({PluginCapabilityType::Script, "Echo", "Echo_Plugin"}) == echo);
|
||||
|
||||
manager.unload_plugin("Echo_Plugin");
|
||||
}
|
||||
|
||||
TEST_CASE("Plugin manager can initialize again after shutdown", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-reinitialize");
|
||||
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
manager.shutdown();
|
||||
|
||||
REQUIRE(manager.initialize());
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
CHECK(manager.is_plugin_loaded("Echo_Plugin"));
|
||||
|
||||
manager.unload_plugin("Echo_Plugin");
|
||||
}
|
||||
|
||||
TEST_CASE("Duplicate discovered plugin keys are reported", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-duplicate-key");
|
||||
for (const char* directory_name : {"first", "second"}) {
|
||||
const fs::path plugin_dir = data_dir_guard.plugins_dir() / directory_name;
|
||||
fs::create_directories(plugin_dir);
|
||||
std::ofstream out((plugin_dir / "Shared.py").string(), std::ios::binary);
|
||||
out << ECHO_PLUGIN_SOURCE;
|
||||
}
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
PluginDescriptor descriptor;
|
||||
REQUIRE(manager.try_get_plugin_descriptor("Shared", descriptor));
|
||||
CHECK(descriptor.has_error());
|
||||
CHECK(descriptor.normalized_error().find("Duplicate plugin key") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("Unloading a plugin drops the package and its capabilities", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-unload");
|
||||
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
REQUIRE(manager.is_plugin_loaded("Echo_Plugin"));
|
||||
|
||||
CHECK(manager.unload_plugin("Echo_Plugin"));
|
||||
|
||||
CHECK_FALSE(manager.is_plugin_loaded("Echo_Plugin"));
|
||||
CHECK(manager.get_plugin_capabilities("Echo_Plugin").empty());
|
||||
CHECK(manager.get_plugin_capability({PluginCapabilityType::Script, "Echo", "Echo_Plugin"}) == nullptr);
|
||||
|
||||
// The package stays discovered, but nothing capability-shaped survives the unload.
|
||||
const PluginDescriptor descriptor = descriptor_of(manager, "Echo_Plugin");
|
||||
CHECK(descriptor.plugin_key == "Echo_Plugin");
|
||||
CHECK(capabilities_of(manager, "Echo_Plugin").empty());
|
||||
}
|
||||
|
||||
TEST_CASE("Python module release removes package submodules and owned sys.path", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("module-release");
|
||||
const fs::path package_root = data_dir_guard.dir / "reload_package";
|
||||
fs::create_directories(package_root);
|
||||
|
||||
auto write_package = [&](const std::string& value) {
|
||||
std::ofstream init((package_root / "__init__.py").string());
|
||||
init << "import reload_helper\nfrom . import sub\nVALUE = sub.VALUE\n";
|
||||
std::ofstream sub((package_root / "sub.py").string());
|
||||
sub << "VALUE = " << value << "\n";
|
||||
std::ofstream helper((package_root.parent_path() / "reload_helper.py").string());
|
||||
helper << "VALUE = 'helper'\n";
|
||||
};
|
||||
|
||||
write_package("'old'");
|
||||
|
||||
PythonInterpreter& interpreter = PythonInterpreter::instance();
|
||||
std::vector<std::string> paths;
|
||||
std::vector<std::string> modules;
|
||||
std::string error;
|
||||
PyObject* module = interpreter.load_module_from_directory(
|
||||
package_root.parent_path().string(), "reload_package", error, &paths, &modules);
|
||||
REQUIRE(module != nullptr);
|
||||
INFO("module load error: " << error);
|
||||
REQUIRE(error.empty());
|
||||
REQUIRE(paths.size() == 1);
|
||||
|
||||
{
|
||||
PythonGILState gil;
|
||||
REQUIRE(gil);
|
||||
PyObject* modules = PyImport_GetModuleDict();
|
||||
REQUIRE(modules != nullptr);
|
||||
CHECK(PyDict_GetItemString(modules, "reload_package") != nullptr);
|
||||
CHECK(PyDict_GetItemString(modules, "reload_package.sub") != nullptr);
|
||||
CHECK(PyDict_GetItemString(modules, "reload_helper") != nullptr);
|
||||
}
|
||||
|
||||
Plugin loaded;
|
||||
loaded.module = module;
|
||||
loaded.module_name = "reload_package";
|
||||
loaded.plugin_sys_paths = paths;
|
||||
loaded.plugin_modules = modules;
|
||||
loaded.release_module();
|
||||
|
||||
{
|
||||
PythonGILState gil;
|
||||
REQUIRE(gil);
|
||||
PyObject* modules = PyImport_GetModuleDict();
|
||||
REQUIRE(modules != nullptr);
|
||||
CHECK(PyDict_GetItemString(modules, "reload_package") == nullptr);
|
||||
CHECK(PyDict_GetItemString(modules, "reload_package.sub") == nullptr);
|
||||
CHECK(PyDict_GetItemString(modules, "reload_helper") == nullptr);
|
||||
|
||||
PyObject* sys_path = PySys_GetObject("path");
|
||||
REQUIRE(sys_path != nullptr);
|
||||
PyObjectPtr path(PyUnicode_DecodeFSDefault(paths.front().c_str()));
|
||||
REQUIRE(path);
|
||||
CHECK(PySequence_Contains(sys_path, path.get()) == 0);
|
||||
}
|
||||
|
||||
// Ensure the next import executes the new submodule rather than reusing a stale package child.
|
||||
write_package("'new'");
|
||||
boost::system::error_code ec;
|
||||
fs::remove_all(package_root / "__pycache__", ec);
|
||||
|
||||
paths.clear();
|
||||
modules.clear();
|
||||
module = interpreter.load_module_from_directory(
|
||||
package_root.parent_path().string(), "reload_package", error, &paths, &modules);
|
||||
REQUIRE(module != nullptr);
|
||||
REQUIRE(error.empty());
|
||||
|
||||
{
|
||||
PythonGILState gil;
|
||||
REQUIRE(gil);
|
||||
PyObjectPtr value(PyObject_GetAttrString(module, "VALUE"));
|
||||
REQUIRE(value);
|
||||
CHECK(std::string(PyUnicode_AsUTF8(value.get())) == "new");
|
||||
}
|
||||
|
||||
loaded.module = module;
|
||||
loaded.module_name = "reload_package";
|
||||
loaded.plugin_sys_paths = paths;
|
||||
loaded.plugin_modules = modules;
|
||||
loaded.release_module();
|
||||
}
|
||||
|
||||
TEST_CASE("A capability disabled in the sidecar loads disabled", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-disabled");
|
||||
const fs::path plugin_dir = write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
// Pre-seed the sidecar with the capability disabled, as a previous session would have.
|
||||
PluginInstallState state;
|
||||
state.installed_from = "local";
|
||||
state.installed_version = "1.0";
|
||||
state.plugin_name = "Echo Plugin";
|
||||
state.enabled = true;
|
||||
state.capabilities = {{"Echo", false}};
|
||||
REQUIRE(write_install_state(plugin_dir, state));
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
REQUIRE(manager.is_plugin_loaded("Echo_Plugin"));
|
||||
|
||||
// The capability still materializes — it is loaded, but logically disabled, so consumers skip it.
|
||||
const auto echo = find_capability(manager, "Echo_Plugin", "Echo");
|
||||
REQUIRE(echo != nullptr);
|
||||
CHECK_FALSE(echo->is_enabled());
|
||||
|
||||
CHECK(manager.get_plugin_capabilities("Echo_Plugin", PluginCapabilityType::Unknown, /*only_enabled=*/true).empty());
|
||||
CHECK(manager.get_plugin_capabilities("Echo_Plugin", PluginCapabilityType::Unknown, /*only_enabled=*/false).size() == 1);
|
||||
|
||||
// An empty load request must preserve the persisted disabled state even when the package is
|
||||
// already loaded.
|
||||
std::string no_request_error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", no_request_error));
|
||||
CHECK_FALSE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
|
||||
|
||||
manager.unload_plugin("Echo_Plugin");
|
||||
}
|
||||
|
||||
TEST_CASE("Disabling a capability round-trips through the sidecar and survives a reload", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-roundtrip");
|
||||
const fs::path plugin_dir = write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
REQUIRE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
|
||||
|
||||
// Disabling writes the choice through to .install_state.json.
|
||||
manager.set_capability_enabled({PluginCapabilityType::Unknown, "Echo", "Echo_Plugin"}, false);
|
||||
CHECK_FALSE(find_capability(manager, "Echo_Plugin", "Echo")->is_enabled());
|
||||
|
||||
PluginInstallState persisted;
|
||||
REQUIRE(read_install_state(plugin_dir, persisted));
|
||||
REQUIRE(persisted.capabilities.size() == 1);
|
||||
CHECK(persisted.capabilities.front().first == "Echo");
|
||||
CHECK_FALSE(persisted.capabilities.front().second);
|
||||
|
||||
// Unload and reload: the user's choice must survive.
|
||||
REQUIRE(manager.unload_plugin("Echo_Plugin"));
|
||||
|
||||
std::string reload_error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", reload_error));
|
||||
|
||||
const auto echo = find_capability(manager, "Echo_Plugin", "Echo");
|
||||
REQUIRE(echo != nullptr);
|
||||
CHECK_FALSE(echo->is_enabled());
|
||||
|
||||
manager.unload_plugin("Echo_Plugin");
|
||||
}
|
||||
|
||||
TEST_CASE("A capability disabled after load stays disabled when rediscovered and reloaded", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-reload-live");
|
||||
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
|
||||
manager.set_capability_enabled({PluginCapabilityType::Unknown, "Echo", "Echo_Plugin"}, false);
|
||||
REQUIRE(manager.unload_plugin("Echo_Plugin"));
|
||||
|
||||
// Rediscover, as the app does when a plugin is toggled off and back on. The enable flags the
|
||||
// loader seeds from must come from the sidecar just written, not from a stale cache.
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/false);
|
||||
|
||||
std::string reload_error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", reload_error));
|
||||
|
||||
const auto echo = find_capability(manager, "Echo_Plugin", "Echo");
|
||||
REQUIRE(echo != nullptr);
|
||||
CHECK_FALSE(echo->is_enabled());
|
||||
|
||||
manager.unload_plugin("Echo_Plugin");
|
||||
}
|
||||
|
||||
TEST_CASE("Re-enabling a disabled capability writes the sidecar back", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-reenable");
|
||||
const fs::path plugin_dir = write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
PluginInstallState state;
|
||||
state.installed_from = "local";
|
||||
state.plugin_name = "Echo Plugin";
|
||||
state.enabled = true;
|
||||
state.capabilities = {{"Echo", false}};
|
||||
REQUIRE(write_install_state(plugin_dir, state));
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
|
||||
// An explicit request overrides the persisted disabled state, including on a fresh load.
|
||||
REQUIRE(manager.unload_plugin("Echo_Plugin"));
|
||||
std::string enable_error;
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", enable_error, {"Echo"}));
|
||||
|
||||
const auto echo = find_capability(manager, "Echo_Plugin", "Echo");
|
||||
REQUIRE(echo != nullptr);
|
||||
CHECK(echo->is_enabled());
|
||||
|
||||
PluginInstallState persisted;
|
||||
REQUIRE(read_install_state(plugin_dir, persisted));
|
||||
REQUIRE(persisted.capabilities.size() == 1);
|
||||
CHECK(persisted.capabilities.front().first == "Echo");
|
||||
CHECK(persisted.capabilities.front().second);
|
||||
|
||||
manager.unload_plugin("Echo_Plugin");
|
||||
}
|
||||
|
||||
TEST_CASE("Overwriting a local plugin unloads its live module", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-overwrite");
|
||||
const fs::path package_dir = data_dir_guard.dir / "packages";
|
||||
fs::create_directories(package_dir);
|
||||
const fs::path package = package_dir / "Echo_Plugin.py";
|
||||
{
|
||||
std::ofstream out(package.string(), std::ios::binary);
|
||||
out << ECHO_PLUGIN_SOURCE;
|
||||
}
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
std::string error;
|
||||
REQUIRE(manager.install_plugin(package, error));
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
REQUIRE(load_and_wait(manager, "Echo_Plugin", error));
|
||||
REQUIRE(manager.is_plugin_loaded("Echo_Plugin"));
|
||||
|
||||
REQUIRE(manager.install_plugin(package, error));
|
||||
CHECK_FALSE(manager.is_plugin_loaded("Echo_Plugin"));
|
||||
}
|
||||
|
||||
TEST_CASE("capabilities_to_enable selects which capabilities come up enabled", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
// Two capabilities in one package; only the second is requested.
|
||||
const char* const two_cap_source = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Duo Plugin"
|
||||
# version = "1.0"
|
||||
# type = "script"
|
||||
# ///
|
||||
import orca
|
||||
|
||||
class Alpha(orca.script.ScriptPluginCapabilityBase):
|
||||
def get_name(self):
|
||||
return "Alpha"
|
||||
|
||||
def execute(self, ctx):
|
||||
return orca.ExecutionResult.success()
|
||||
|
||||
class Beta(orca.script.ScriptPluginCapabilityBase):
|
||||
def get_name(self):
|
||||
return "Beta"
|
||||
|
||||
def execute(self, ctx):
|
||||
return orca.ExecutionResult.success()
|
||||
|
||||
@orca.plugin
|
||||
class DuoPackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(Alpha)
|
||||
orca.register_capability(Beta)
|
||||
)PY";
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-select");
|
||||
write_plugin(data_dir_guard, "Duo_Plugin", two_cap_source);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
REQUIRE(load_and_wait(manager, "Duo_Plugin", error, {"Beta"}));
|
||||
|
||||
REQUIRE(capabilities_of(manager, "Duo_Plugin").size() == 2);
|
||||
|
||||
const auto alpha = find_capability(manager, "Duo_Plugin", "Alpha");
|
||||
const auto beta = find_capability(manager, "Duo_Plugin", "Beta");
|
||||
REQUIRE(alpha != nullptr);
|
||||
REQUIRE(beta != nullptr);
|
||||
|
||||
CHECK_FALSE(alpha->is_enabled());
|
||||
CHECK(beta->is_enabled());
|
||||
|
||||
manager.unload_plugin("Duo_Plugin");
|
||||
}
|
||||
|
||||
TEST_CASE("A cancelled load keeps blocking wait_for_all_plugin_loads until the worker exits", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
// Stalls inside the module import, so the detached load worker is still executing Python while
|
||||
// the test cancels it.
|
||||
const char* const slow_source = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Slow Load Plugin"
|
||||
# version = "1.0"
|
||||
# type = "script"
|
||||
# ///
|
||||
import time
|
||||
|
||||
import orca
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
class Slow(orca.script.ScriptPluginCapabilityBase):
|
||||
def get_name(self):
|
||||
return "Slow"
|
||||
|
||||
def execute(self, ctx):
|
||||
return orca.ExecutionResult.success()
|
||||
|
||||
@orca.plugin
|
||||
class SlowPackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(Slow)
|
||||
)PY";
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-cancel");
|
||||
write_plugin(data_dir_guard, "Slow_Load_Plugin", slow_source);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
manager.load_plugin("Slow_Load_Plugin", /*skip_deps=*/true);
|
||||
|
||||
// The key is registered before the worker is spawned, so this is not a race.
|
||||
REQUIRE(manager.is_plugin_load_in_progress("Slow_Load_Plugin"));
|
||||
REQUIRE(manager.cancel_plugin_load("Slow_Load_Plugin"));
|
||||
|
||||
// Cancelling must not release the worker's slot. shutdown() unloads everything and GUI_App
|
||||
// finalizes the interpreter as soon as this wait returns, so reporting "no loads in progress"
|
||||
// while the worker is still inside Python is how the app crashes on exit.
|
||||
CHECK(manager.is_plugin_load_in_progress("Slow_Load_Plugin"));
|
||||
CHECK_FALSE(manager.wait_for_all_plugin_loads(std::chrono::milliseconds(0)));
|
||||
|
||||
// The worker releases the slot itself, once it has unwound.
|
||||
CHECK(manager.wait_for_all_plugin_loads(std::chrono::seconds(60)));
|
||||
CHECK_FALSE(manager.is_plugin_load_in_progress("Slow_Load_Plugin"));
|
||||
CHECK_FALSE(manager.is_plugin_loaded("Slow_Load_Plugin"));
|
||||
}
|
||||
|
||||
TEST_CASE("Loading an unknown plugin key records an error instead of crashing", "[PluginLifecycle][Python]")
|
||||
{
|
||||
// discover_plugins() initializes the plugin system (and with it the interpreter), so this
|
||||
// needs the same explicit teardown as the load tests.
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-missing");
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
// Rejected synchronously: no worker thread is spawned for an unknown key.
|
||||
manager.load_plugin("No_Such_Plugin", /*skip_deps=*/true);
|
||||
|
||||
CHECK_FALSE(manager.is_plugin_loaded("No_Such_Plugin"));
|
||||
CHECK(manager.get_plugin_load_error("No_Such_Plugin") == "Plugin not found: No_Such_Plugin");
|
||||
CHECK(manager.get_plugin_capabilities("No_Such_Plugin").empty());
|
||||
}
|
||||
|
||||
TEST_CASE("The startup auto-load list only contains packages whose sidecar enables them", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-autoload");
|
||||
|
||||
// No sidecar at all: never installed through Orca, so it carries no auto-load intent.
|
||||
write_plugin(data_dir_guard, "Bare_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
// Sidecar with enabled = true: auto-loads.
|
||||
const fs::path on_dir = write_plugin(data_dir_guard, "Enabled_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
PluginInstallState on_state;
|
||||
on_state.installed_from = "local";
|
||||
on_state.enabled = true;
|
||||
REQUIRE(write_install_state(on_dir, on_state));
|
||||
|
||||
// Sidecar with enabled = false: the user turned it off.
|
||||
const fs::path off_dir = write_plugin(data_dir_guard, "Disabled_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
PluginInstallState off_state;
|
||||
off_state.installed_from = "local";
|
||||
off_state.enabled = false;
|
||||
REQUIRE(write_install_state(off_dir, off_state));
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
const std::vector<std::string> keys = manager.get_enabled_plugin_keys();
|
||||
|
||||
CHECK(std::find(keys.begin(), keys.end(), "Enabled_Plugin") != keys.end());
|
||||
CHECK(std::find(keys.begin(), keys.end(), "Disabled_Plugin") == keys.end());
|
||||
CHECK(std::find(keys.begin(), keys.end(), "Bare_Plugin") == keys.end());
|
||||
}
|
||||
|
||||
TEST_CASE("Signing out drops every cloud plugin row, installed or not", "[PluginLifecycle][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("lifecycle-signout");
|
||||
|
||||
// A local package, which must survive sign-out.
|
||||
write_plugin(data_dir_guard, "Echo_Plugin", ECHO_PLUGIN_SOURCE);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
// Two cloud rows: one merely available (nothing installed), one with a local package behind it —
|
||||
// the case that used to linger, unloaded but still listed, until the user hit refresh.
|
||||
PluginDescriptor available;
|
||||
available.plugin_key = "11111111-1111-1111-1111-111111111111";
|
||||
available.name = "Available Cloud Plugin";
|
||||
available.cloud = CloudPluginState{available.plugin_key, /*installed=*/false, false, false, false};
|
||||
|
||||
PluginDescriptor installed;
|
||||
installed.plugin_key = "22222222-2222-2222-2222-222222222222";
|
||||
installed.name = "Installed Cloud Plugin";
|
||||
installed.plugin_root = (data_dir_guard.plugins_dir() / "_subscribed" / "user" / installed.plugin_key).string();
|
||||
installed.cloud = CloudPluginState{installed.plugin_key, /*installed=*/true, false, false, false};
|
||||
|
||||
manager.update_cloud_metadata({available, installed});
|
||||
|
||||
const auto has_key = [&manager](const std::string& key) {
|
||||
PluginDescriptor descriptor;
|
||||
return manager.try_get_plugin_descriptor(key, descriptor);
|
||||
};
|
||||
|
||||
REQUIRE(has_key(available.plugin_key));
|
||||
REQUIRE(has_key(installed.plugin_key));
|
||||
REQUIRE(has_key("Echo_Plugin"));
|
||||
|
||||
// Sign out. The per-user _subscribed directory stops being scanned, so both cloud rows are now
|
||||
// stale and must go — not just the one with nothing installed behind it.
|
||||
manager.unload_cloud_plugins();
|
||||
manager.clear_cloud_plugin_metadata();
|
||||
manager.set_cloud_user("");
|
||||
|
||||
CHECK_FALSE(has_key(available.plugin_key));
|
||||
CHECK_FALSE(has_key(installed.plugin_key));
|
||||
CHECK(has_key("Echo_Plugin"));
|
||||
}
|
||||
|
||||
TEST_CASE("Unloading a plugin that is not loaded is a no-op", "[PluginLifecycle]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("lifecycle-noop-unload");
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
|
||||
// Current behavior: unloading an unknown key succeeds (it fires the unload callbacks and
|
||||
// reports success) rather than reporting "nothing to unload".
|
||||
CHECK(manager.unload_plugin("No_Such_Plugin"));
|
||||
CHECK_FALSE(manager.is_plugin_loaded("No_Such_Plugin"));
|
||||
}
|
||||
189
tests/slic3rutils/test_plugin_sort.cpp
Normal file
189
tests/slic3rutils/test_plugin_sort.cpp
Normal file
@@ -0,0 +1,189 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <slic3r/GUI/PluginSort.hpp>
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using Slic3r::GUI::compare_ascii_case_insensitive_natural;
|
||||
using Slic3r::GUI::PluginSortKey;
|
||||
using Slic3r::GUI::PluginSortOrder;
|
||||
using Slic3r::GUI::PluginSource;
|
||||
using Slic3r::GUI::PluginStatus;
|
||||
using Slic3r::GUI::plugin_sort_key_from_string;
|
||||
using Slic3r::GUI::plugin_sort_order_from_string;
|
||||
using Slic3r::GUI::sort_plugin_items_for_dialog;
|
||||
|
||||
namespace {
|
||||
|
||||
struct SortFixtureItem
|
||||
{
|
||||
std::string plugin_key;
|
||||
PluginSource source;
|
||||
PluginStatus status;
|
||||
std::string type_key;
|
||||
std::string display_name;
|
||||
std::string sort_version;
|
||||
};
|
||||
|
||||
std::vector<std::string> keys(const std::vector<SortFixtureItem>& items)
|
||||
{
|
||||
std::vector<std::string> result;
|
||||
result.reserve(items.size());
|
||||
for (const SortFixtureItem& item : items)
|
||||
result.push_back(item.plugin_key);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("plugin dialog status sort uses requested priority and base-order ties", "[plugin][sort]")
|
||||
{
|
||||
std::vector<SortFixtureItem> items = {
|
||||
{"local_inactive", PluginSource::Local, PluginStatus::Inactive, "script", "Local Inactive"},
|
||||
{"mine_error", PluginSource::Mine, PluginStatus::Error, "script", "Mine Error"},
|
||||
{"mine_activated", PluginSource::Mine, PluginStatus::Activated, "script", "Mine Activated"},
|
||||
{"local_activated", PluginSource::Local, PluginStatus::Activated, "script", "Local Activated"},
|
||||
{"subscribed_loading", PluginSource::Subscribed, PluginStatus::Loading, "script", "Subscribed Loading"},
|
||||
};
|
||||
|
||||
sort_plugin_items_for_dialog(items, PluginSortKey::Status, PluginSortOrder::Asc);
|
||||
|
||||
// why: local_activated and mine_activated tie on Status, so base order breaks the tie by name
|
||||
// (case-insensitive) - "Local Activated" before "Mine Activated".
|
||||
const std::vector<std::string> expected = {
|
||||
"local_activated",
|
||||
"mine_activated",
|
||||
"mine_error",
|
||||
"local_inactive",
|
||||
"subscribed_loading",
|
||||
};
|
||||
CHECK(keys(items) == expected);
|
||||
|
||||
sort_plugin_items_for_dialog(items, PluginSortKey::Status, PluginSortOrder::Desc);
|
||||
|
||||
// why: Desc reverses the status ordinal, but the Activated tie still resolves by ascending
|
||||
// base order (name: "Local Activated" before "Mine Activated") - direction only flips the key.
|
||||
const std::vector<std::string> desc_expected = {
|
||||
"subscribed_loading",
|
||||
"local_inactive",
|
||||
"mine_error",
|
||||
"local_activated",
|
||||
"mine_activated",
|
||||
};
|
||||
CHECK(keys(items) == desc_expected);
|
||||
}
|
||||
|
||||
TEST_CASE("plugin dialog source sort uses enum priority", "[plugin][sort]")
|
||||
{
|
||||
std::vector<SortFixtureItem> items = {
|
||||
{"local", PluginSource::Local, PluginStatus::Activated, "script", "Local"},
|
||||
{"mine", PluginSource::Mine, PluginStatus::Activated, "script", "Mine"},
|
||||
{"subscribed", PluginSource::Subscribed, PluginStatus::Activated, "script", "Subscribed"},
|
||||
};
|
||||
|
||||
sort_plugin_items_for_dialog(items, PluginSortKey::Source, PluginSortOrder::Asc);
|
||||
const std::vector<std::string> asc_expected = {"mine", "subscribed", "local"};
|
||||
CHECK(keys(items) == asc_expected);
|
||||
|
||||
sort_plugin_items_for_dialog(items, PluginSortKey::Source, PluginSortOrder::Desc);
|
||||
const std::vector<std::string> desc_expected = {"local", "subscribed", "mine"};
|
||||
CHECK(keys(items) == desc_expected);
|
||||
}
|
||||
|
||||
TEST_CASE("plugin dialog version sort is semver-aware with base-order ties", "[plugin][sort]")
|
||||
{
|
||||
std::vector<SortFixtureItem> items = {
|
||||
{"v_1_2_0", PluginSource::Local, PluginStatus::Activated, "script", "B", "1.2.0"},
|
||||
{"v_1_10_0", PluginSource::Local, PluginStatus::Activated, "script", "A", "1.10.0"},
|
||||
{"v_0_9_3", PluginSource::Local, PluginStatus::Activated, "script", "C", "0.9.3"},
|
||||
};
|
||||
|
||||
sort_plugin_items_for_dialog(items, PluginSortKey::Version, PluginSortOrder::Asc);
|
||||
// why: semver numeric compare - 1.10.0 > 1.2.0 (not lexical "1.10" < "1.2"), so ascending is
|
||||
// 0.9.3 < 1.2.0 < 1.10.0.
|
||||
const std::vector<std::string> asc_expected = {"v_0_9_3", "v_1_2_0", "v_1_10_0"};
|
||||
CHECK(keys(items) == asc_expected);
|
||||
|
||||
sort_plugin_items_for_dialog(items, PluginSortKey::Version, PluginSortOrder::Desc);
|
||||
const std::vector<std::string> desc_expected = {"v_1_10_0", "v_1_2_0", "v_0_9_3"};
|
||||
CHECK(keys(items) == desc_expected);
|
||||
}
|
||||
|
||||
TEST_CASE("plugin dialog name sort is case-insensitive and numeric-aware", "[plugin][sort]")
|
||||
{
|
||||
std::vector<SortFixtureItem> items = {
|
||||
{"rig10", PluginSource::Local, PluginStatus::Activated, "script", "Rig 10"},
|
||||
{"ada_lower", PluginSource::Local, PluginStatus::Activated, "script", "ada"},
|
||||
{"rig2", PluginSource::Local, PluginStatus::Activated, "script", "Rig 2"},
|
||||
{"ada_upper", PluginSource::Local, PluginStatus::Activated, "script", "Ada"},
|
||||
};
|
||||
|
||||
sort_plugin_items_for_dialog(items, PluginSortKey::Name, PluginSortOrder::Asc);
|
||||
|
||||
// why: "Ada"/"ada" tie on the case-insensitive name (primary AND base name level), so the tie
|
||||
// falls through source/status/type to plugin_key: "ada_lower" before "ada_upper".
|
||||
const std::vector<std::string> expected = {"ada_lower", "ada_upper", "rig2", "rig10"};
|
||||
CHECK(keys(items) == expected);
|
||||
|
||||
sort_plugin_items_for_dialog(items, PluginSortKey::Name, PluginSortOrder::Desc);
|
||||
|
||||
// why: names reverse ("Rig 10" before "Rig 2"), but "Ada"/"ada" tie on the case-insensitive
|
||||
// key and keep ascending base order, which resolves by plugin_key ("ada_lower" < "ada_upper").
|
||||
const std::vector<std::string> desc_expected = {"rig10", "rig2", "ada_lower", "ada_upper"};
|
||||
CHECK(keys(items) == desc_expected);
|
||||
}
|
||||
|
||||
TEST_CASE("natural compare handles digits, case, prefixes and leading zeros", "[plugin][sort]")
|
||||
{
|
||||
// numeric runs compare by value, not lexically
|
||||
CHECK(compare_ascii_case_insensitive_natural("item2", "item10") < 0);
|
||||
CHECK(compare_ascii_case_insensitive_natural("item10", "item2") > 0);
|
||||
CHECK(compare_ascii_case_insensitive_natural("2", "10") < 0);
|
||||
|
||||
// case is ignored on the primary comparison
|
||||
CHECK(compare_ascii_case_insensitive_natural("Camera", "camera") == 0);
|
||||
|
||||
// a prefix is less than the longer string it prefixes
|
||||
CHECK(compare_ascii_case_insensitive_natural("app", "apple") < 0);
|
||||
CHECK(compare_ascii_case_insensitive_natural("apple", "app") > 0);
|
||||
|
||||
// equal numeric value: fewer leading zeros wins the tie
|
||||
CHECK(compare_ascii_case_insensitive_natural("1", "01") < 0);
|
||||
CHECK(compare_ascii_case_insensitive_natural("01", "1") > 0);
|
||||
|
||||
// reflexivity and empty-string boundaries
|
||||
CHECK(compare_ascii_case_insensitive_natural("plugin", "plugin") == 0);
|
||||
CHECK(compare_ascii_case_insensitive_natural("", "") == 0);
|
||||
CHECK(compare_ascii_case_insensitive_natural("", "a") < 0);
|
||||
}
|
||||
|
||||
TEST_CASE("plugin dialog None sort key falls to ascending base order in both directions", "[plugin][sort]")
|
||||
{
|
||||
std::vector<SortFixtureItem> items = {
|
||||
{"z_mine", PluginSource::Mine, PluginStatus::Activated, "script", "Zebra"},
|
||||
{"a_local", PluginSource::Local, PluginStatus::Activated, "script", "Apple"},
|
||||
{"m_sub", PluginSource::Subscribed, PluginStatus::Error, "script", "Mango"},
|
||||
};
|
||||
|
||||
// why: no primary key -> pure name-first base order (Apple < Mango < Zebra). A source-first
|
||||
// baseline would instead give {z_mine, m_sub, a_local}, so this pins the name-first order.
|
||||
const std::vector<std::string> base_expected = {"a_local", "m_sub", "z_mine"};
|
||||
|
||||
sort_plugin_items_for_dialog(items, PluginSortKey::None, PluginSortOrder::Asc);
|
||||
CHECK(keys(items) == base_expected);
|
||||
|
||||
// why: None has no direction - Desc must not reverse the baseline.
|
||||
sort_plugin_items_for_dialog(items, PluginSortKey::None, PluginSortOrder::Desc);
|
||||
CHECK(keys(items) == base_expected);
|
||||
}
|
||||
|
||||
TEST_CASE("plugin dialog sort request parsing keeps previous state on invalid values", "[plugin][sort]")
|
||||
{
|
||||
CHECK(plugin_sort_key_from_string("source", PluginSortKey::Status) == PluginSortKey::Source);
|
||||
CHECK(plugin_sort_key_from_string("none", PluginSortKey::Status) == PluginSortKey::None);
|
||||
CHECK(plugin_sort_key_from_string("missing", PluginSortKey::Name) == PluginSortKey::Name);
|
||||
|
||||
CHECK(plugin_sort_order_from_string("desc", PluginSortOrder::Asc) == PluginSortOrder::Desc);
|
||||
CHECK(plugin_sort_order_from_string("down", PluginSortOrder::Asc) == PluginSortOrder::Asc);
|
||||
}
|
||||
682
tests/slic3rutils/test_slicing_pipeline_bindings.cpp
Normal file
682
tests/slic3rutils/test_slicing_pipeline_bindings.cpp
Normal file
@@ -0,0 +1,682 @@
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include "slic3r/plugin/PythonPluginInterface.hpp"
|
||||
using namespace Slic3r;
|
||||
|
||||
TEST_CASE("SlicingPipeline capability-type string maps round-trip", "[slicing_pipeline]") {
|
||||
CHECK(plugin_capability_type_to_string(PluginCapabilityType::SlicingPipeline) == "slicing-pipeline");
|
||||
CHECK(plugin_capability_type_display_name(PluginCapabilityType::SlicingPipeline) == "Slicing Pipeline");
|
||||
CHECK(plugin_capability_type_from_string("slicing-pipeline") == PluginCapabilityType::SlicingPipeline);
|
||||
CHECK(plugin_capability_type_from_string("SLICING-PIPELINE") == PluginCapabilityType::SlicingPipeline);
|
||||
CHECK(plugin_capability_type_from_string("nope") == PluginCapabilityType::Unknown);
|
||||
}
|
||||
|
||||
#include "python_test_support.hpp"
|
||||
#include "slic3r/plugin/PluginBindingUtils.hpp"
|
||||
#include "slic3r/plugin/pluginTypes/slicingPipeline/SlicingPipelinePluginCapability.hpp"
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include "libslic3r/ExPolygon.hpp"
|
||||
#include "libslic3r/Surface.hpp"
|
||||
#include "libslic3r/Layer.hpp"
|
||||
#include "libslic3r/ExtrusionEntity.hpp"
|
||||
#include "libslic3r/ExtrusionEntityCollection.hpp"
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
#include <pybind11/embed.h>
|
||||
#include <pybind11/numpy.h>
|
||||
namespace py = pybind11;
|
||||
|
||||
TEST_CASE("make_readonly_rows builds a read-only (N,2) int64 view", "[slicing_pipeline]") {
|
||||
ensure_python_initialized(); // helper already used by test_plugin_host_api.cpp
|
||||
py::gil_scoped_acquire gil;
|
||||
|
||||
// make_readonly_rows() constructs a py::array_t, which requires numpy to be
|
||||
// importable in the embedded interpreter. The unit-test interpreter ships no
|
||||
// site-packages (same condition test_plugin_host_api.cpp's TriangleMesh numpy
|
||||
// test guards against), so skip the array-backed assertions when numpy is
|
||||
// unavailable there rather than fail on an environment quirk.
|
||||
bool have_numpy = false;
|
||||
try {
|
||||
py::module_::import("numpy");
|
||||
have_numpy = true;
|
||||
} catch (const py::error_already_set&) {
|
||||
have_numpy = false;
|
||||
}
|
||||
if (!have_numpy) {
|
||||
SKIP("numpy unavailable in unit-test interpreter");
|
||||
}
|
||||
|
||||
static Slic3r::Points pts = { Slic3r::Point(10, 20), Slic3r::Point(30, 40) };
|
||||
py::capsule keepalive(&pts, [](void*){});
|
||||
py::array a = Slic3r::make_readonly_rows<coord_t, 2>(keepalive, pts.front().data(), (py::ssize_t)pts.size());
|
||||
CHECK(a.dtype().kind() == 'i');
|
||||
CHECK(a.itemsize() == 8); // int64
|
||||
CHECK(a.shape(0) == 2);
|
||||
CHECK(a.shape(1) == 2);
|
||||
CHECK_FALSE(a.writeable());
|
||||
auto r = a.unchecked<coord_t, 2>();
|
||||
CHECK(r(0,0) == 10); CHECK(r(1,1) == 40);
|
||||
}
|
||||
|
||||
TEST_CASE("make_writable_rows builds a writable (N,2) int64 view that aliases the buffer", "[slicing_pipeline]") {
|
||||
ensure_python_initialized();
|
||||
py::gil_scoped_acquire gil;
|
||||
bool have_numpy = false;
|
||||
try { py::module_::import("numpy"); have_numpy = true; }
|
||||
catch (const py::error_already_set&) { have_numpy = false; }
|
||||
if (!have_numpy) SKIP("numpy unavailable in unit-test interpreter");
|
||||
|
||||
static Slic3r::Points pts = { Slic3r::Point(10, 20), Slic3r::Point(30, 40) };
|
||||
py::capsule keepalive(&pts, [](void*){});
|
||||
py::array a = Slic3r::make_writable_rows<coord_t, 2>(keepalive, pts.front().data(), (py::ssize_t)pts.size());
|
||||
CHECK(a.writeable());
|
||||
// Writing through the view mutates the C++ buffer (zero-copy alias).
|
||||
a.attr("__setitem__")(py::make_tuple(0, 0), py::int_(99));
|
||||
CHECK(pts.front().x() == 99);
|
||||
}
|
||||
|
||||
TEST_CASE("orca.slicing module: Step enum, context, and a Python capability can execute", "[slicing_pipeline]") {
|
||||
ensure_python_initialized();
|
||||
import_orca_module(); // forces PythonPluginBridge::instance() (see import_orca_module in python_test_support.hpp)
|
||||
py::gil_scoped_acquire gil;
|
||||
py::module_ orca = py::module_::import("orca");
|
||||
REQUIRE(py::hasattr(orca, "slicing"));
|
||||
py::object slicing = orca.attr("slicing");
|
||||
CHECK(py::hasattr(slicing, "Step"));
|
||||
CHECK(py::hasattr(slicing.attr("Step"), "posSlice"));
|
||||
CHECK(py::hasattr(slicing.attr("Step"), "psGCodePostProcess"));
|
||||
CHECK(py::hasattr(slicing, "SlicingPipelineContext"));
|
||||
CHECK(py::hasattr(slicing, "SlicingPipelineCapabilityBase"));
|
||||
|
||||
// A trivial Python subclass whose execute() reports success, invoked via the C++ trampoline.
|
||||
py::exec(R"(
|
||||
import orca
|
||||
class Probe(orca.slicing.SlicingPipelineCapabilityBase):
|
||||
def get_name(self): return "probe"
|
||||
def execute(self, ctx): return orca.ExecutionResult.success("ok")
|
||||
_probe = Probe()
|
||||
)");
|
||||
// (Full C++ trampoline invocation with a real context is exercised elsewhere.)
|
||||
}
|
||||
|
||||
TEST_CASE("orca.slicing is workflow-only: context exposes raw print/object; view classes are gone", "[slicing_pipeline]") {
|
||||
using Catch::Matchers::WithinRel;
|
||||
ensure_python_initialized();
|
||||
import_orca_module();
|
||||
py::gil_scoped_acquire gil;
|
||||
py::module_ orca = py::module_::import("orca");
|
||||
py::object slicing = orca.attr("slicing");
|
||||
|
||||
// Context surface: raw graph entry points + workflow accessors.
|
||||
for (const char* name : { "print", "object", "config_value", "cancelled",
|
||||
"orca_version", "step" })
|
||||
CHECK(py::hasattr(slicing.attr("SlicingPipelineContext"), name));
|
||||
|
||||
// The wrapper layer is gone.
|
||||
for (const char* legacy : { "ExPolygonView", "SurfaceView", "LayerRegionView",
|
||||
"LayerView", "PrintObjectView", "PathData", "SurfaceType" })
|
||||
CHECK_FALSE(py::hasattr(slicing, legacy));
|
||||
|
||||
// unscale() stays in orca.slicing and reads the live SCALING_FACTOR.
|
||||
const coord_t scaled10 = (coord_t) scale_(10.0);
|
||||
double mm = slicing.attr("unscale")(scaled10).cast<double>();
|
||||
CHECK_THAT(mm, WithinRel(10.0, 1e-9));
|
||||
|
||||
// A default context casts print/object to None (no dangling wrapper).
|
||||
Slic3r::SlicingPipelineContext ctx;
|
||||
py::object pyctx = py::cast(&ctx, py::return_value_policy::reference);
|
||||
CHECK(pyctx.attr("print").is_none());
|
||||
CHECK(pyctx.attr("object").is_none());
|
||||
}
|
||||
|
||||
#include "libslic3r/PrintConfig.hpp" // DynamicPrintConfig for the psGCodePostProcess context
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/nowide/fstream.hpp>
|
||||
#include <sstream>
|
||||
|
||||
// psGCodePostProcess is the merged post-processing seam: no live Print (print/object are None), the
|
||||
// plugin edits the file at ctx.gcode_path in place, and ctx.config_value() falls back to the config
|
||||
// the export path handed in. Exercising the real bindings by calling the Python execute() directly
|
||||
// (not the C++ audit trampoline) keeps this a pure binding-surface test.
|
||||
TEST_CASE("orca.slicing psGCodePostProcess context: file edit in place + config fallback", "[slicing_pipeline]") {
|
||||
namespace fs = boost::filesystem;
|
||||
ensure_python_initialized();
|
||||
import_orca_module();
|
||||
py::gil_scoped_acquire gil;
|
||||
|
||||
const fs::path gpath = fs::temp_directory_path() / fs::unique_path("orca_pp_%%%%-%%%%.gcode");
|
||||
{
|
||||
boost::nowide::ofstream ofs(gpath.string());
|
||||
ofs << "; header\nG1 X0 Y0\n";
|
||||
}
|
||||
|
||||
// Config the plugin reads back through ctx.config_value() (there is no live Print at this step).
|
||||
Slic3r::DynamicPrintConfig config = Slic3r::DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("layer_height", new Slic3r::ConfigOptionFloat(0.2));
|
||||
|
||||
Slic3r::SlicingPipelineContext ctx;
|
||||
ctx.orca_version = "test";
|
||||
ctx.step = Slic3r::SlicingPipelineStepPlugin::psGCodePostProcess;
|
||||
ctx.gcode_path = gpath.string();
|
||||
ctx.host = "File";
|
||||
ctx.output_name = "final.gcode";
|
||||
ctx.full_config = &config; // print stays null
|
||||
|
||||
py::object pyctx = py::cast(&ctx, py::return_value_policy::reference);
|
||||
CHECK(pyctx.attr("gcode_path").cast<std::string>() == gpath.string());
|
||||
CHECK(pyctx.attr("host").cast<std::string>() == "File");
|
||||
CHECK(pyctx.attr("output_name").cast<std::string>() == "final.gcode");
|
||||
CHECK(pyctx.attr("print").is_none());
|
||||
CHECK(pyctx.attr("object").is_none());
|
||||
CHECK(pyctx.attr("step").cast<Slic3r::SlicingPipelineStepPlugin>()
|
||||
== Slic3r::SlicingPipelineStepPlugin::psGCodePostProcess);
|
||||
CHECK_FALSE(pyctx.attr("cancelled")().cast<bool>()); // null print -> not cancelled
|
||||
// config_value() resolves from full_config when print is null; unknown keys are None.
|
||||
CHECK_FALSE(pyctx.attr("config_value")("layer_height").is_none());
|
||||
CHECK(pyctx.attr("config_value")("this_key_does_not_exist").is_none());
|
||||
|
||||
// A Python capability edits the file in place through ctx.gcode_path. Calling execute() directly
|
||||
// in Python dispatches to the Python method (no C++ trampoline), so this needs no audit context.
|
||||
py::module_ main = py::module_::import("__main__");
|
||||
main.attr("_pp_ctx") = pyctx;
|
||||
py::exec(R"(
|
||||
import orca
|
||||
class Stamp(orca.slicing.SlicingPipelineCapabilityBase):
|
||||
def get_name(self): return "stamp"
|
||||
def execute(self, ctx):
|
||||
assert ctx.step == orca.slicing.Step.psGCodePostProcess
|
||||
assert ctx.print is None and ctx.object is None
|
||||
with open(ctx.gcode_path, "a") as f:
|
||||
f.write("; stamped by " + ctx.host + "\n")
|
||||
return orca.ExecutionResult.success("ok")
|
||||
_pp_result = Stamp().execute(_pp_ctx)
|
||||
)");
|
||||
CHECK(main.attr("_pp_result").attr("message").cast<std::string>() == std::string("ok"));
|
||||
|
||||
std::string contents;
|
||||
{
|
||||
boost::nowide::ifstream ifs(gpath.string());
|
||||
std::stringstream ss; ss << ifs.rdbuf(); contents = ss.str();
|
||||
}
|
||||
CHECK(contents.find("; stamped by File") != std::string::npos);
|
||||
fs::remove(gpath);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toolpath helpers for the raw-graph tests.
|
||||
//
|
||||
// LayerRegion's ctor is protected (constructed only by Layer/PrintObject). A
|
||||
// trivial derived struct lets a unit test build one with null layer/region
|
||||
// pointers — the extrusion accessors only read the public `perimeters`/`fills`
|
||||
// collections, never the layer/region back-pointers.
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace {
|
||||
struct TestLayerRegion : Slic3r::LayerRegion {
|
||||
TestLayerRegion() : Slic3r::LayerRegion(nullptr, nullptr) {}
|
||||
};
|
||||
|
||||
// Build a realistic nested perimeters collection into `region.perimeters`:
|
||||
// perimeters (outer) -> inner collection -> [ ExtrusionLoop(pathA), ExtrusionPath(pathB) ]
|
||||
// This exercises both the recursive descent through nested collections and the
|
||||
// decomposition of an ExtrusionLoop into its contained ExtrusionPath (flatten()
|
||||
// does NOT decompose loops, hence the hand-rolled recursive walk).
|
||||
static void build_nested_perimeters(TestLayerRegion& region) {
|
||||
using namespace Slic3r;
|
||||
ExtrusionPath pathA(erExternalPerimeter); // -> "Outer wall"
|
||||
pathA.mm3_per_mm = 0.05; pathA.width = 0.45f; pathA.height = 0.20f;
|
||||
pathA.polyline.points = { Point3(0, 0, 0), Point3(10, 0, 0), Point3(10, 10, 0) };
|
||||
|
||||
ExtrusionPath pathB(erInternalInfill); // -> "Sparse infill"
|
||||
pathB.mm3_per_mm = 0.03; pathB.width = 0.40f; pathB.height = 0.20f;
|
||||
pathB.polyline.points = { Point3(1, 1, 0), Point3(2, 1, 0), Point3(2, 2, 0) };
|
||||
|
||||
ExtrusionEntityCollection inner;
|
||||
inner.append(ExtrusionLoop(pathA)); // clone_move
|
||||
inner.append(pathB); // clone
|
||||
region.perimeters.append(inner); // nested (deep clone)
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw Print-graph data model (orca.host) — replaces the *View wrapper API.
|
||||
// LIFETIME: raw bindings follow C++ semantics — references into the slicing
|
||||
// graph are valid during execute(ctx) and invalidated by container-replacing
|
||||
// mutators, exactly like std::vector iterators.
|
||||
// ---------------------------------------------------------------------------
|
||||
TEST_CASE("orca.host leaf geometry: Surface/ExPolygon/Polygon raw bindings", "[slicing_pipeline]") {
|
||||
using Catch::Matchers::WithinRel;
|
||||
using Catch::Matchers::WithinAbs;
|
||||
ensure_python_initialized();
|
||||
import_orca_module();
|
||||
py::gil_scoped_acquire gil;
|
||||
py::object host = py::module_::import("orca").attr("host");
|
||||
|
||||
for (const char* name : { "SurfaceType", "Polygon", "ExPolygon", "Surface", "SurfaceCollection" })
|
||||
CHECK(py::hasattr(host, name));
|
||||
|
||||
// SurfaceType enum values round-trip to the C++ enumerators (moved from orca.slicing).
|
||||
py::object ST = host.attr("SurfaceType");
|
||||
CHECK(ST.attr("stTop").cast<Slic3r::SurfaceType>() == Slic3r::stTop);
|
||||
CHECK(ST.attr("stInternalSolid").cast<Slic3r::SurfaceType>() == Slic3r::stInternalSolid);
|
||||
CHECK(ST.attr("stPerimeter").cast<Slic3r::SurfaceType>() == Slic3r::stPerimeter);
|
||||
|
||||
// Raw Surface: scalar reads + WRITABLE surface_type (replaces SurfaceView.set_type).
|
||||
Slic3r::Surface surf(Slic3r::stInternalSolid);
|
||||
surf.thickness = 0.4;
|
||||
surf.bridge_angle = -1.0;
|
||||
surf.extra_perimeters = 2;
|
||||
py::object sv = py::cast(&surf, py::return_value_policy::reference);
|
||||
CHECK(sv.attr("surface_type").cast<Slic3r::SurfaceType>() == Slic3r::stInternalSolid);
|
||||
CHECK_THAT(sv.attr("thickness").cast<double>(), WithinRel(0.4, 1e-9));
|
||||
CHECK_THAT(sv.attr("bridge_angle").cast<double>(), WithinAbs(-1.0, 1e-12));
|
||||
CHECK(sv.attr("extra_perimeters").cast<int>() == 2);
|
||||
sv.attr("surface_type") = host.attr("SurfaceType").attr("stTop");
|
||||
CHECK(surf.surface_type == Slic3r::stTop); // C++ side reflects the assignment
|
||||
|
||||
// ExPolygon navigation without numpy: contour is a Polygon, holes an empty list.
|
||||
py::object exv = sv.attr("expolygon");
|
||||
CHECK(py::hasattr(exv, "contour"));
|
||||
CHECK(exv.attr("holes").cast<py::list>().size() == 0);
|
||||
CHECK(exv.attr("contour").attr("size")().cast<size_t>() == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("orca.host Surface/SurfaceCollection: construct, writable members, set()", "[slicing_pipeline]") {
|
||||
using Catch::Matchers::WithinRel;
|
||||
ensure_python_initialized();
|
||||
import_orca_module();
|
||||
py::gil_scoped_acquire gil;
|
||||
py::object host = py::module_::import("orca").attr("host");
|
||||
py::object ST = host.attr("SurfaceType");
|
||||
const coord_t s = (coord_t) scale_(10.0);
|
||||
|
||||
// Build an ExPolygon (Point idiom) and a Surface from it.
|
||||
py::object P = host.attr("Polygon")();
|
||||
P.attr("append")(host.attr("Point")(0, 0));
|
||||
P.attr("append")(host.attr("Point")(s, 0));
|
||||
P.attr("append")(host.attr("Point")(s, s));
|
||||
P.attr("append")(host.attr("Point")(0, s));
|
||||
py::object ex = host.attr("ExPolygon")(P);
|
||||
py::object surf = host.attr("Surface")(ST.attr("stTop"), ex);
|
||||
CHECK(surf.attr("surface_type").cast<Slic3r::SurfaceType>() == Slic3r::stTop);
|
||||
CHECK(surf.attr("is_top")().cast<bool>());
|
||||
CHECK_THAT(surf.attr("area")().cast<double>(), WithinRel((double) s * (double) s, 1e-9));
|
||||
surf.attr("thickness") = py::float_(0.3);
|
||||
CHECK_THAT(surf.attr("thickness").cast<double>(), WithinRel(0.3, 1e-9));
|
||||
|
||||
// SurfaceCollection.set(expolys, type): replace all surfaces from a list of ExPolygon tagged with one SurfaceType.
|
||||
Slic3r::SurfaceCollection coll;
|
||||
py::object cv = py::cast(&coll, py::return_value_policy::reference);
|
||||
py::list expolys; expolys.append(ex);
|
||||
cv.attr("set")(expolys, ST.attr("stInternalSolid"));
|
||||
REQUIRE(coll.surfaces.size() == 1);
|
||||
CHECK(coll.surfaces.front().surface_type == Slic3r::stInternalSolid);
|
||||
CHECK(cv.attr("has")(ST.attr("stInternalSolid")).cast<bool>());
|
||||
cv.attr("clear")();
|
||||
CHECK(coll.surfaces.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("orca.host Point: construct, read/write coords, arithmetic", "[slicing_pipeline]") {
|
||||
ensure_python_initialized();
|
||||
import_orca_module();
|
||||
py::gil_scoped_acquire gil;
|
||||
py::object host = py::module_::import("orca").attr("host");
|
||||
REQUIRE(py::hasattr(host, "Point"));
|
||||
py::object p = host.attr("Point")(3, 4);
|
||||
CHECK(p.attr("x").cast<coord_t>() == 3);
|
||||
CHECK(p.attr("y").cast<coord_t>() == 4);
|
||||
p.attr("x") = py::int_(7);
|
||||
CHECK(p.attr("x").cast<coord_t>() == 7);
|
||||
py::object q = host.attr("Point")(1, 2);
|
||||
py::object sum = p.attr("__add__")(q);
|
||||
CHECK(sum.attr("x").cast<coord_t>() == 8);
|
||||
CHECK(sum.attr("y").cast<coord_t>() == 6);
|
||||
|
||||
// __mul__ must scale as a double, not truncate to int64 before multiplying.
|
||||
py::object h = host.attr("Point")(10, 20).attr("__mul__")(py::float_(0.5));
|
||||
CHECK(h.attr("x").cast<coord_t>() == 5);
|
||||
CHECK(h.attr("y").cast<coord_t>() == 10);
|
||||
}
|
||||
|
||||
TEST_CASE("orca.host Polygon: writable as_array aliases buffer; Point refs; set_points; offset", "[slicing_pipeline]") {
|
||||
using Catch::Matchers::WithinRel;
|
||||
ensure_python_initialized();
|
||||
import_orca_module();
|
||||
py::gil_scoped_acquire gil;
|
||||
py::object host = py::module_::import("orca").attr("host");
|
||||
|
||||
const coord_t s = (coord_t) scale_(10.0);
|
||||
Slic3r::Polygon poly;
|
||||
poly.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0), Slic3r::Point(s, s), Slic3r::Point(0, s) };
|
||||
py::object pv = py::cast(&poly, py::return_value_policy::reference);
|
||||
|
||||
// Non-array surface works without numpy.
|
||||
CHECK(pv.attr("size")().cast<size_t>() == 4);
|
||||
CHECK(pv.attr("is_counter_clockwise")().cast<bool>());
|
||||
CHECK_THAT(pv.attr("area")().cast<double>(), WithinRel((double) s * (double) s, 1e-9));
|
||||
// Point-object idiom: editing a returned Point ref mutates the buffer in place.
|
||||
py::list pts = pv.attr("points").cast<py::list>();
|
||||
REQUIRE(pts.size() == 4);
|
||||
pts[0].attr("x") = py::int_(5);
|
||||
CHECK(poly.points[0].x() == 5);
|
||||
poly.points[0].x() = 0; // restore
|
||||
|
||||
// offset() returns new geometry (ClipperUtils bound as a method).
|
||||
py::list shrunk = pv.attr("offset")(py::int_(-(coord_t)scale_(1.0))).cast<py::list>();
|
||||
CHECK(shrunk.size() >= 1);
|
||||
|
||||
bool have_numpy = false;
|
||||
try { py::module_::import("numpy"); have_numpy = true; }
|
||||
catch (const py::error_already_set&) { have_numpy = false; }
|
||||
if (!have_numpy) SKIP("numpy unavailable: array-backed assertions skipped");
|
||||
|
||||
py::module_ np = py::module_::import("numpy");
|
||||
py::array a = pv.attr("as_array")().cast<py::array>();
|
||||
CHECK(a.dtype().kind() == 'i');
|
||||
CHECK(a.itemsize() == 8);
|
||||
CHECK(a.shape(0) == 4);
|
||||
CHECK(a.shape(1) == 2);
|
||||
CHECK(a.writeable()); // writable now
|
||||
a.attr("__setitem__")(py::make_tuple(0, 0), py::int_(123));
|
||||
CHECK(poly.points[0].x() == 123); // in-place bulk edit
|
||||
// set_points replaces contents (count-changing).
|
||||
py::object i64 = np.attr("int64");
|
||||
py::list rows;
|
||||
rows.append(py::make_tuple(0, 0)); rows.append(py::make_tuple(s, 0)); rows.append(py::make_tuple(s, s));
|
||||
pv.attr("set_points")(np.attr("array")(rows, py::arg("dtype") = i64));
|
||||
CHECK(poly.points.size() == 3);
|
||||
}
|
||||
|
||||
TEST_CASE("orca.host ExPolygon: construct, writable contour/holes, transforms, boolean ops", "[slicing_pipeline]") {
|
||||
using Catch::Matchers::WithinRel;
|
||||
ensure_python_initialized();
|
||||
import_orca_module();
|
||||
py::gil_scoped_acquire gil;
|
||||
py::object host = py::module_::import("orca").attr("host");
|
||||
const coord_t s = (coord_t) scale_(10.0);
|
||||
|
||||
// Construct from Polygon objects (Point idiom, no numpy).
|
||||
py::object P = host.attr("Polygon")();
|
||||
P.attr("append")(host.attr("Point")(0, 0));
|
||||
P.attr("append")(host.attr("Point")(s, 0));
|
||||
P.attr("append")(host.attr("Point")(s, s));
|
||||
P.attr("append")(host.attr("Point")(0, s));
|
||||
py::object ex = host.attr("ExPolygon")(P);
|
||||
CHECK_THAT(ex.attr("area")().cast<double>(), WithinRel((double) s * (double) s, 1e-9));
|
||||
CHECK(ex.attr("num_contours")().cast<size_t>() == 1);
|
||||
CHECK(ex.attr("contour").attr("size")().cast<size_t>() == 4);
|
||||
|
||||
// In-place transform mutates the geometry.
|
||||
ex.attr("translate")(py::float_(1000.0), py::float_(0.0));
|
||||
// Boolean op returns new geometry: A minus a smaller inset of A is a non-empty ring set.
|
||||
py::list inset = ex.attr("offset")(py::int_(-(coord_t)scale_(1.0))).cast<py::list>();
|
||||
REQUIRE(inset.size() >= 1);
|
||||
py::list ring = ex.attr("diff_ex")(inset[0]).cast<py::list>();
|
||||
CHECK(ring.size() >= 1);
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Nested collection: outer -> inner -> [ ExtrusionLoop(pathA), ExtrusionPath(pathB) ].
|
||||
// Exercises polymorphic downcast of .entities and loop decomposition in flatten_paths().
|
||||
static Slic3r::ExtrusionEntityCollection build_nested_collection() {
|
||||
using namespace Slic3r;
|
||||
ExtrusionPath pathA(erExternalPerimeter); // -> "Outer wall"
|
||||
pathA.mm3_per_mm = 0.05; pathA.width = 0.45f; pathA.height = 0.20f;
|
||||
pathA.polyline.points = { Point3(0, 0, 0), Point3(10, 0, 0), Point3(10, 10, 0) };
|
||||
|
||||
ExtrusionPath pathB(erInternalInfill); // -> "Sparse infill"
|
||||
pathB.mm3_per_mm = 0.03; pathB.width = 0.40f; pathB.height = 0.20f;
|
||||
pathB.polyline.points = { Point3(1, 1, 0), Point3(2, 1, 0), Point3(2, 2, 0) };
|
||||
|
||||
ExtrusionEntityCollection inner;
|
||||
inner.append(ExtrusionLoop(pathA));
|
||||
inner.append(pathB);
|
||||
ExtrusionEntityCollection outer;
|
||||
outer.append(inner);
|
||||
return outer;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("orca.host extrusion tree: polymorphic entities + flatten_paths", "[slicing_pipeline]") {
|
||||
using Catch::Matchers::WithinRel;
|
||||
ensure_python_initialized();
|
||||
import_orca_module();
|
||||
py::gil_scoped_acquire gil;
|
||||
py::object host = py::module_::import("orca").attr("host");
|
||||
for (const char* name : { "ExtrusionEntity", "ExtrusionPath", "ExtrusionLoop",
|
||||
"ExtrusionMultiPath", "ExtrusionEntityCollection", "PrintRegion" })
|
||||
CHECK(py::hasattr(host, name));
|
||||
|
||||
Slic3r::ExtrusionEntityCollection outer = build_nested_collection();
|
||||
py::object coll = py::cast(&outer, py::return_value_policy::reference);
|
||||
|
||||
// .entities downcasts: the single child is a collection; ITS children are a loop + a path.
|
||||
py::list kids = coll.attr("entities").cast<py::list>();
|
||||
REQUIRE(kids.size() == 1);
|
||||
py::list inner_kids = kids[0].attr("entities").cast<py::list>();
|
||||
REQUIRE(inner_kids.size() == 2);
|
||||
CHECK(py::hasattr(inner_kids[0], "paths")); // ExtrusionLoop binding
|
||||
CHECK(py::hasattr(inner_kids[1], "width")); // ExtrusionPath binding
|
||||
|
||||
// flatten_paths: loop decomposed, scalars readable.
|
||||
py::list ps = coll.attr("flatten_paths")().cast<py::list>();
|
||||
REQUIRE(ps.size() == 2);
|
||||
CHECK(ps[0].attr("role").cast<std::string>() == "Outer wall");
|
||||
CHECK_THAT(ps[0].attr("width").cast<double>(), WithinRel(0.45, 1e-6));
|
||||
CHECK_THAT(ps[0].attr("mm3_per_mm").cast<double>(), WithinRel(0.05, 1e-9));
|
||||
CHECK(ps[1].attr("role").cast<std::string>() == "Sparse infill");
|
||||
}
|
||||
|
||||
TEST_CASE("orca.host ExtrusionPath.points() is a read-only (N,3) int64 view", "[slicing_pipeline]") {
|
||||
ensure_python_initialized();
|
||||
import_orca_module();
|
||||
py::gil_scoped_acquire gil;
|
||||
bool have_numpy = false;
|
||||
try { py::module_::import("numpy"); have_numpy = true; }
|
||||
catch (const py::error_already_set&) { have_numpy = false; }
|
||||
if (!have_numpy) SKIP("numpy unavailable in unit-test interpreter");
|
||||
|
||||
Slic3r::ExtrusionEntityCollection outer = build_nested_collection();
|
||||
py::object coll = py::cast(&outer, py::return_value_policy::reference);
|
||||
py::list ps = coll.attr("flatten_paths")().cast<py::list>();
|
||||
REQUIRE(ps.size() == 2);
|
||||
py::array pts = ps[1].attr("points")().cast<py::array>(); // pathB: (1,1,0),(2,1,0),(2,2,0)
|
||||
CHECK(pts.dtype().kind() == 'i');
|
||||
CHECK(pts.itemsize() == 8);
|
||||
CHECK(pts.shape(0) == 3);
|
||||
CHECK(pts.shape(1) == 3);
|
||||
CHECK_FALSE(pts.writeable());
|
||||
auto r = pts.cast<py::array_t<coord_t>>().unchecked<2>();
|
||||
CHECK(r(0, 0) == 1); CHECK(r(1, 0) == 2); CHECK(r(2, 1) == 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw Print-graph spine (orca.host): LayerRegion / Layer / PrintObject / Print,
|
||||
// read side. LayerRegion/Layer ctors are protected (friend class PrintObject),
|
||||
// so the tests use tiny derived structs -- the pattern TestLayerRegion above
|
||||
// already establishes; TestLayer is its Layer counterpart.
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace {
|
||||
struct TestLayer : Slic3r::Layer {
|
||||
// id=0, no owning PrintObject, height/print_z/slice_z suitable for assertions.
|
||||
TestLayer() : Slic3r::Layer(0, nullptr, 0.2, 0.45, 0.35) {}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("orca.host graph classes: LayerRegion/Layer raw traversal; Print/PrintObject registered", "[slicing_pipeline]") {
|
||||
using Catch::Matchers::WithinRel;
|
||||
ensure_python_initialized();
|
||||
import_orca_module();
|
||||
py::gil_scoped_acquire gil;
|
||||
py::object host = py::module_::import("orca").attr("host");
|
||||
|
||||
for (const char* name : { "LayerRegion", "Layer", "PrintObject", "Print" })
|
||||
CHECK(py::hasattr(host, name));
|
||||
// Members needing a live Print are verified by registration only (slic3rutils
|
||||
// cannot build a Print; the fff_print C++ suite covers live-graph behavior).
|
||||
for (const char* name : { "layers", "support_layers", "model_object", "id",
|
||||
"bounding_box", "trafo", "config_value", "config_keys" })
|
||||
CHECK(py::hasattr(host.attr("PrintObject"), name));
|
||||
for (const char* name : { "objects", "model", "config_value", "config_keys", "canceled" })
|
||||
CHECK(py::hasattr(host.attr("Print"), name));
|
||||
|
||||
// Raw LayerRegion traversal over a hand-built region.
|
||||
TestLayerRegion region;
|
||||
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal));
|
||||
build_nested_perimeters(region); // helper defined earlier in this file
|
||||
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(®ion),
|
||||
py::return_value_policy::reference);
|
||||
CHECK(lr.attr("slices").attr("size")().cast<size_t>() == 1);
|
||||
CHECK(lr.attr("slices").attr("surfaces").cast<py::list>().size() == 1);
|
||||
CHECK(lr.attr("perimeters").attr("flatten_paths")().cast<py::list>().size() == 2);
|
||||
CHECK(lr.attr("fills").attr("size")().cast<size_t>() == 0);
|
||||
CHECK(lr.attr("layer")().is_none()); // hand-built region has no owning layer
|
||||
|
||||
// Raw Layer scalars + empty traversals on a hand-built layer.
|
||||
TestLayer layer;
|
||||
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer),
|
||||
py::return_value_policy::reference);
|
||||
CHECK_THAT(ly.attr("print_z").cast<double>(), WithinRel(0.45, 1e-9));
|
||||
CHECK_THAT(ly.attr("slice_z").cast<double>(), WithinRel(0.35, 1e-9));
|
||||
CHECK_THAT(ly.attr("height").cast<double>(), WithinRel(0.2, 1e-9));
|
||||
CHECK(ly.attr("regions")().cast<py::list>().size() == 0);
|
||||
CHECK(ly.attr("lslices")().cast<py::list>().size() == 0);
|
||||
CHECK(ly.attr("upper_layer").is_none());
|
||||
CHECK(ly.attr("lower_layer").is_none());
|
||||
}
|
||||
|
||||
TEST_CASE("orca.host: plugin-only mutators are gone; class-API editing works", "[slicing_pipeline]") {
|
||||
ensure_python_initialized();
|
||||
import_orca_module();
|
||||
py::gil_scoped_acquire gil;
|
||||
py::object host = py::module_::import("orca").attr("host");
|
||||
|
||||
// The three plugin-only mutators were removed in the raw-API realignment.
|
||||
CHECK_FALSE(py::hasattr(host.attr("LayerRegion"), "set_slices"));
|
||||
CHECK_FALSE(py::hasattr(host.attr("LayerRegion"), "set_fill_surfaces"));
|
||||
CHECK_FALSE(py::hasattr(host.attr("Layer"), "set_lslices"));
|
||||
// The faithful surface is present.
|
||||
CHECK(py::hasattr(host.attr("SurfaceCollection"), "set"));
|
||||
CHECK(py::hasattr(host.attr("Layer"), "make_slices"));
|
||||
|
||||
// clear() via the collection on a hand-built region (null owning layer is null-safe).
|
||||
TestLayerRegion region;
|
||||
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal));
|
||||
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(®ion), py::return_value_policy::reference);
|
||||
lr.attr("slices").attr("clear")();
|
||||
CHECK(region.slices.surfaces.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("orca.host: SurfaceCollection.set mutates geometry; lslices via make_slices", "[slicing_pipeline]") {
|
||||
using Catch::Matchers::WithinRel;
|
||||
ensure_python_initialized();
|
||||
import_orca_module();
|
||||
py::gil_scoped_acquire gil;
|
||||
bool have_numpy = false;
|
||||
try { py::module_::import("numpy"); have_numpy = true; }
|
||||
catch (const py::error_already_set&) { have_numpy = false; }
|
||||
if (!have_numpy) SKIP("numpy unavailable in unit-test interpreter");
|
||||
|
||||
py::object host = py::module_::import("orca").attr("host");
|
||||
py::module_ np = py::module_::import("numpy");
|
||||
py::object i64 = np.attr("int64");
|
||||
py::object ST = host.attr("SurfaceType");
|
||||
const coord_t s = (coord_t) scale_(10.0);
|
||||
auto arr = [&](std::initializer_list<std::pair<coord_t,coord_t>> pts) {
|
||||
py::list rows; for (auto& p : pts) rows.append(py::make_tuple(p.first, p.second));
|
||||
return np.attr("array")(rows, py::arg("dtype") = i64);
|
||||
};
|
||||
|
||||
// Build an ExPolygon from a CW ndarray; the ctor normalizes to CCW.
|
||||
py::object ex = host.attr("ExPolygon")(arr({ {0,0}, {0,s}, {s,s}, {s,0} }));
|
||||
CHECK(ex.attr("contour").attr("is_counter_clockwise")().cast<bool>());
|
||||
|
||||
TestLayerRegion region;
|
||||
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(®ion), py::return_value_policy::reference);
|
||||
py::list expolys; expolys.append(ex);
|
||||
lr.attr("slices").attr("set")(expolys, ST.attr("stInternalSolid"));
|
||||
REQUIRE(region.slices.surfaces.size() == 1);
|
||||
const Slic3r::Surface& out = region.slices.surfaces.front();
|
||||
CHECK(out.surface_type == Slic3r::stInternalSolid);
|
||||
CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9));
|
||||
// Read geometry back through the class API.
|
||||
py::array c = lr.attr("slices").attr("surfaces").cast<py::list>()[0]
|
||||
.attr("expolygon").attr("contour").attr("as_array")().cast<py::array>();
|
||||
CHECK(c.shape(0) == 4);
|
||||
|
||||
// lslices are derived: make_slices() re-derives them + refreshes the bbox cache.
|
||||
TestLayer layer;
|
||||
py::object ly = py::cast(static_cast<Slic3r::Layer*>(&layer), py::return_value_policy::reference);
|
||||
// (A hand-built layer has no regions, so make_slices() yields empty lslices — still null-safe.)
|
||||
ly.attr("make_slices")();
|
||||
CHECK(layer.lslices_bboxes.size() == layer.lslices.size());
|
||||
}
|
||||
|
||||
TEST_CASE("orca.host ExPolygon in-place transforms + SurfaceCollection.append (sample ops)", "[slicing_pipeline]") {
|
||||
using Catch::Matchers::WithinRel;
|
||||
ensure_python_initialized();
|
||||
import_orca_module();
|
||||
py::gil_scoped_acquire gil;
|
||||
py::object host = py::module_::import("orca").attr("host");
|
||||
const coord_t s = (coord_t) scale_(10.0);
|
||||
auto make_square = [&]() {
|
||||
py::object P = host.attr("Polygon")();
|
||||
P.attr("append")(host.attr("Point")(0, 0));
|
||||
P.attr("append")(host.attr("Point")(s, 0));
|
||||
P.attr("append")(host.attr("Point")(s, s));
|
||||
P.attr("append")(host.attr("Point")(0, s));
|
||||
return host.attr("ExPolygon")(P);
|
||||
};
|
||||
const double area0 = (double) s * (double) s;
|
||||
|
||||
// rotate about the square's center preserves area
|
||||
py::object ex = make_square();
|
||||
py::object center = host.attr("Point")(s / 2, s / 2);
|
||||
ex.attr("rotate")(py::float_(1.5707963267948966), center); // pi/2
|
||||
CHECK_THAT(ex.attr("area")().cast<double>(), WithinRel(area0, 1e-6));
|
||||
|
||||
// uniform scale by 2 quadruples area (scale is about the origin)
|
||||
py::object ex2 = make_square();
|
||||
ex2.attr("scale")(py::float_(2.0));
|
||||
CHECK_THAT(ex2.attr("area")().cast<double>(), WithinRel(4.0 * area0, 1e-6));
|
||||
|
||||
// translate preserves area
|
||||
py::object ex3 = make_square();
|
||||
ex3.attr("translate")(py::float_(1000.0), py::float_(-500.0));
|
||||
CHECK_THAT(ex3.attr("area")().cast<double>(), WithinRel(area0, 1e-6));
|
||||
|
||||
// SurfaceCollection.append accumulates surfaces of a second type (the sample write-back path)
|
||||
Slic3r::SurfaceCollection coll;
|
||||
py::object cv = py::cast(&coll, py::return_value_policy::reference);
|
||||
py::list g1; g1.append(make_square());
|
||||
cv.attr("set")(g1, host.attr("SurfaceType").attr("stInternalSolid"));
|
||||
py::list g2; g2.append(make_square());
|
||||
cv.attr("append")(g2, host.attr("SurfaceType").attr("stTop"));
|
||||
REQUIRE(coll.surfaces.size() == 2);
|
||||
CHECK(coll.surfaces[0].surface_type == Slic3r::stInternalSolid);
|
||||
CHECK(coll.surfaces[1].surface_type == Slic3r::stTop);
|
||||
}
|
||||
|
||||
TEST_CASE("orca.host: in-place edit of surface.expolygon through a live collection persists to C++", "[slicing_pipeline]") {
|
||||
using Catch::Matchers::WithinRel;
|
||||
ensure_python_initialized();
|
||||
import_orca_module();
|
||||
py::gil_scoped_acquire gil;
|
||||
|
||||
const coord_t s = (coord_t) scale_(10.0);
|
||||
// Live LayerRegion holding one surface (a 10mm square at the origin).
|
||||
TestLayerRegion region;
|
||||
Slic3r::ExPolygon sq;
|
||||
sq.contour.points = { Slic3r::Point(0, 0), Slic3r::Point(s, 0),
|
||||
Slic3r::Point(s, s), Slic3r::Point(0, s) };
|
||||
region.slices.surfaces.emplace_back(Slic3r::Surface(Slic3r::stInternal, sq));
|
||||
py::object lr = py::cast(static_cast<Slic3r::LayerRegion*>(®ion),
|
||||
py::return_value_policy::reference);
|
||||
|
||||
// Twistify's path: get the Surface through the live collection, mutate its expolygon in place.
|
||||
py::object surf = lr.attr("slices").attr("surfaces").cast<py::list>()[0];
|
||||
surf.attr("expolygon").attr("translate")(py::float_(1000.0), py::float_(0.0));
|
||||
|
||||
// The C++-side surface geometry reflects the Python in-place edit (proves the live ref).
|
||||
const Slic3r::Surface& out = region.slices.surfaces.front();
|
||||
CHECK(out.expolygon.contour.points[0].x() == 1000); // was 0
|
||||
CHECK(out.expolygon.contour.points[0].y() == 0);
|
||||
CHECK_THAT(out.expolygon.area(), WithinRel((double) s * (double) s, 1e-9)); // translate preserves area
|
||||
}
|
||||
153
tests/slic3rutils/test_slicing_pipeline_config.cpp
Normal file
153
tests/slic3rutils/test_slicing_pipeline_config.cpp
Normal file
@@ -0,0 +1,153 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <libslic3r/Utils.hpp>
|
||||
#include <slic3r/plugin/PluginConfig.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
#include <slic3r/plugin/PythonPluginInterface.hpp>
|
||||
|
||||
#include "fff_print/test_helpers.hpp"
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <pybind11/embed.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
|
||||
using namespace Slic3r;
|
||||
using namespace Slic3r::Test;
|
||||
namespace fs = boost::filesystem;
|
||||
using json = nlohmann::json;
|
||||
|
||||
// End-to-end coverage of a slicing-pipeline capability reading its own config: the loader seeds the
|
||||
// store from the capability's get_default_config() hook, and the real dispatch
|
||||
// (execute_capabilities_from_refs -> hook -> GIL -> trampoline) lets the plugin read back whatever
|
||||
// the host has stored, through self.get_config(). A break anywhere in that chain makes plugins
|
||||
// silently run on their built-in defaults, which is invisible to the plugin author (Twistify
|
||||
// incident, 2026-07-17).
|
||||
//
|
||||
// Note this is deliberately NOT ctx.config_value(): that reads the slicer's print config, not the
|
||||
// plugin's own config.
|
||||
|
||||
namespace {
|
||||
|
||||
struct ScopedPluginManager
|
||||
{
|
||||
bool initialized = false;
|
||||
|
||||
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
|
||||
~ScopedPluginManager()
|
||||
{
|
||||
PluginManager::instance().shutdown();
|
||||
PythonInterpreter::instance().shutdown();
|
||||
}
|
||||
};
|
||||
|
||||
const char* const CONFIG_PROBE_SOURCE = R"PY(# /// script
|
||||
# requires-python = ">=3.12"
|
||||
#
|
||||
# [tool.orcaslicer.plugin]
|
||||
# name = "Config Probe"
|
||||
# description = "Echoes its own config back to the test"
|
||||
# author = "OrcaSlicer"
|
||||
# version = "1.0"
|
||||
# type = "slicing-pipeline"
|
||||
# ///
|
||||
import json
|
||||
|
||||
import orca
|
||||
|
||||
class ConfigEcho(orca.slicing.SlicingPipelineCapabilityBase):
|
||||
def get_name(self):
|
||||
return "ConfigEcho"
|
||||
|
||||
def get_default_config(self):
|
||||
return {"alpha": "1.25", "beta": "hello"}
|
||||
|
||||
def execute(self, ctx):
|
||||
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
|
||||
return orca.ExecutionResult.success()
|
||||
try:
|
||||
text = repr(sorted(json.loads(self.get_config()).items()))
|
||||
except Exception as e: # what plugins' defaults-fallback code swallows silently
|
||||
text = "config-error: " + repr(e)
|
||||
orca._probe_config = text # read back by the test through pybind
|
||||
return orca.ExecutionResult.success("config probed")
|
||||
|
||||
@orca.plugin
|
||||
class ConfigProbePackage(orca.base):
|
||||
def register_capabilities(self):
|
||||
orca.register_capability(ConfigEcho)
|
||||
)PY";
|
||||
|
||||
fs::path write_plugin(const std::string& stem, const std::string& source)
|
||||
{
|
||||
const fs::path plugin_dir = fs::path(get_orca_plugins_dir()) / stem;
|
||||
fs::create_directories(plugin_dir);
|
||||
|
||||
std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary);
|
||||
out << source;
|
||||
out.close();
|
||||
|
||||
return plugin_dir;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("slicing-pipeline dispatch delivers the stored config to self.get_config()", "[slicing_pipeline][PluginConfig][Python]")
|
||||
{
|
||||
ScopedPluginManager plugin_system;
|
||||
if (!plugin_system.initialized)
|
||||
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
|
||||
|
||||
ScopedDataDir data_dir_guard("pipeline-config");
|
||||
write_plugin("ConfigProbe", CONFIG_PROBE_SOURCE);
|
||||
|
||||
PluginManager& manager = PluginManager::instance();
|
||||
manager.get_config().load(); // reset the singleton's store against the empty temp data dir
|
||||
manager.discover_plugins(/*async=*/false, /*clear=*/true);
|
||||
|
||||
std::string error;
|
||||
manager.load_plugin("ConfigProbe", /*skip_deps=*/true, {});
|
||||
REQUIRE(manager.wait_for_plugin_load("ConfigProbe", std::chrono::seconds(120), error));
|
||||
INFO("load error: " << error);
|
||||
REQUIRE(manager.is_plugin_loaded("ConfigProbe"));
|
||||
|
||||
const PluginCapabilityId id{PluginCapabilityType::SlicingPipeline, "ConfigEcho", "ConfigProbe"};
|
||||
|
||||
// Loading seeds the store from the capability's get_default_config() hook, so a plugin has a
|
||||
// config before anyone has opened the Config tab.
|
||||
const auto seeded = manager.get_config().get_config(id);
|
||||
REQUIRE(seeded);
|
||||
CHECK(seeded->config == json({{"alpha", "1.25"}, {"beta", "hello"}}));
|
||||
|
||||
// What editing the config in the Config tab does: the value the plugin must actually run on.
|
||||
REQUIRE(manager.get_config().store_capability_config(id, json({{"alpha", "9.5"}, {"beta", "hello"}})));
|
||||
|
||||
// Slice with the capability selected, exactly as a preset would reference it.
|
||||
Print print;
|
||||
Model model;
|
||||
auto config = DynamicPrintConfig::full_print_config();
|
||||
config.set_key_value("slicing_pipeline_plugin", new ConfigOptionStrings({"ConfigEcho"}));
|
||||
config.set_key_value("plugins", new ConfigOptionStrings({"ConfigProbe;;ConfigEcho"}));
|
||||
init_print({cube(20)}, print, model, config);
|
||||
print.process();
|
||||
|
||||
std::string observed = "<capability never executed>";
|
||||
{
|
||||
PythonGILState gil;
|
||||
REQUIRE(static_cast<bool>(gil));
|
||||
pybind11::module_ orca = pybind11::module_::import("orca");
|
||||
if (pybind11::hasattr(orca, "_probe_config"))
|
||||
observed = orca.attr("_probe_config").cast<std::string>();
|
||||
}
|
||||
INFO("config observed by Python: " << observed);
|
||||
// The edited value arrived, not the seeded default: the host's store is what reaches the plugin.
|
||||
CHECK(observed.find("'alpha', '9.5'") != std::string::npos);
|
||||
CHECK(observed.find("'beta', 'hello'") != std::string::npos);
|
||||
CHECK(observed.find("1.25") == std::string::npos);
|
||||
|
||||
manager.unload_plugin("ConfigProbe");
|
||||
}
|
||||
Reference in New Issue
Block a user