Compare commits

..
Author SHA1 Message Date
SoftFever 6a7e8e784c Require numpy for the plugin tests in CI 2026-09-28 14:48:30 +08:00
SoftFever c09902aa44 Merge branch 'main' into feature/enable-unit-tests-for-plugins 2026-09-28 12:40:41 +08:00
SoftFever bd3140d202 fix Windows 2026-09-28 11:40:52 +08:00
SoftFever 88586eea44 enable python unit test 2026-09-09 00:10:02 +08:00
24 changed files with 167 additions and 313 deletions
+8
View File
@@ -44,6 +44,14 @@ jobs:
uses: actions/download-artifact@v8
with:
name: ${{ inputs.artifact }}
# run_unit_tests.sh installs the plugin tests' numpy with the uv the build stages
# beside them; the Windows arm64 build bundles none, so put one on PATH there.
- name: Install uv
if: runner.os == 'Windows' && runner.arch == 'ARM64'
uses: astral-sh/setup-uv@v10.2.0
with:
version: "0.11.21" # ORCA_UV_VERSION in CMakeLists.txt
enable-cache: false
- uses: lukka/get-cmake@latest
with:
cmakeVersion: "~4.3.0" # use most recent 4.3.x version
+3 -1
View File
@@ -52,4 +52,6 @@ internal_docs/
__pycache__/
*.pyc
*.opc
docs/superpowers/
/.test/
docs/superpowers/
ctest_results.xml
+48 -1
View File
@@ -2,7 +2,8 @@
# This file is made to support the unit tests workflow.
# It should only require the directories build/tests, scripts/, and tests/ to function,
# and cmake (with ctest) installed.
# and cmake (with ctest) installed -- plus network access to PyPI whenever numpy has to
# be installed into a freshly built test tree (see below).
# (otherwise, update the workflow too, but try to avoid to keep things self-contained)
#
# Usage: run_unit_tests.sh [TEST_DIR] [BUILD_CONFIG]
@@ -18,6 +19,52 @@ cd "${ROOT_DIR}" || exit 1
TEST_DIR="${1:-build/tests}"
BUILD_CONFIG="${2:-}"
# The slic3rutils plugin-host tests build numpy arrays through the CPython copied next
# to the test binary (see tests/slic3rutils/CMakeLists.txt), which ships no numpy.
# Install it with the uv staged beside that runtime -- the tool the app installs plugin
# dependencies with -- straight into the interpreter's own site-packages: no pip needed
# in the runtime, no PYTHONPATH. Re-checked every run because a rebuild of the test
# target re-copies the runtime; needs network whenever it installs. Pinned so a numpy
# release cannot change results on its own.
NUMPY_VERSION="2.5.3"
# Without numpy those tests assert the numpy-absent error path instead, so a local run
# only warns. Under CI it fails the run, which would otherwise stay green while silently
# dropping the array coverage. (The Flatpak leg runs this inside `flatpak build`, whose
# minimal environment has no CI, and its offline build stages no uv, so numpy stays
# best-effort there.)
numpy_unavailable() {
if [ -n "${CI:-}" ]; then
echo "error: $1" >&2
exit 1
fi
echo "warning: $1; the numpy-backed binding tests will cover only the numpy-absent path."
}
has_pinned_numpy() {
"${python_exe}" -c "import sys, numpy; sys.exit(numpy.__version__ != '${NUMPY_VERSION}')" >/dev/null 2>&1
}
find_args=("${TEST_DIR}" \( -path '*/python/bin/python3' -o -path '*/python/python.exe' \))
# Multi-config trees hold one copy per configuration; only bootstrap the one being run.
[ -n "${BUILD_CONFIG}" ] && find_args+=(-path "*/${BUILD_CONFIG}/*")
python_exe="$(find "${find_args[@]}" -print -quit 2>/dev/null)"
if [ -z "${python_exe}" ]; then
numpy_unavailable "no bundled Python under ${TEST_DIR}"
elif ! has_pinned_numpy; then
uv_exe="${python_exe%/python/*}/tools/uv/uv"
# Builds that bundle no uv (Windows arm64) fall back to one on PATH.
[ -x "${uv_exe}" ] || uv_exe="$(command -v uv)"
echo "Installing numpy ${NUMPY_VERSION} into the embedded test interpreter (${python_exe})..."
if [ -z "${uv_exe}" ]; then
numpy_unavailable "no uv staged beside the tests or on PATH"
elif ! "${uv_exe}" pip install --python "${python_exe}" --only-binary :all: "numpy==${NUMPY_VERSION}" \
|| ! has_pinned_numpy; then
numpy_unavailable "could not install numpy ${NUMPY_VERSION} into ${python_exe}"
fi
fi
# Run the whole suite, excluding tests tagged [NotWorking] and tests labelled RequiresApp,
# which run the built orca-slicer binary that this directory does not contain.
# --no-tests=error fails the job if the filter matches nothing (instead of passing green).
+5 -10
View File
@@ -2504,8 +2504,7 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu
{
LifecycleEventContext ctx;
ctx.id = std::to_string(print->model().id().id);
ctx.name = print->get_model_name();
ctx.name = std::to_string(print->model().id().id);
ctx.code = LifecycleEvtCode::Ok;
ctx.msg = path;
ctx.cancellation_check = [print]() { return print->canceled(); };
@@ -2557,8 +2556,7 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu
}
{
LifecycleEventContext ctx;
ctx.id = std::to_string(print->model().id().id);
ctx.name = print->get_model_name();
ctx.name = std::to_string(print->model().id().id);
ctx.code = LifecycleEvtCode::Error;
ctx.msg = std::string(path) + "\n" + err_msg;
ctx.cancellation_check = [print]() { return print->canceled(); };
@@ -2582,8 +2580,7 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu
boost::nowide::remove(path_tmp.c_str());
{
LifecycleEventContext ctx;
ctx.id = std::to_string(print->model().id().id);
ctx.name = print->get_model_name();
ctx.name = std::to_string(print->model().id().id);
ctx.code = LifecycleEvtCode::Error;
ctx.msg = std::string(path) + "\n" + ex.what();
ctx.cancellation_check = [print]() { return print->canceled(); };
@@ -2696,8 +2693,7 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu
if (ret) {
{
LifecycleEventContext ctx;
ctx.id = std::to_string(print->model().id().id);
ctx.name = print->get_model_name();
ctx.name = std::to_string(print->model().id().id);
ctx.code = LifecycleEvtCode::Error;
ctx.msg = std::string(path) + "\nFailed to rename the output G-code file: " + ret.message();
ctx.cancellation_check = [print]() { return print->canceled(); };
@@ -2716,8 +2712,7 @@ void GCode::do_export(Print* print, const char* path, GCodeProcessorResult* resu
{
LifecycleEventContext ctx;
ctx.id = std::to_string(print->model().id().id);
ctx.name = print->get_model_name();
ctx.name = std::to_string(print->model().id().id);
ctx.code = LifecycleEvtCode::Ok;
ctx.msg = path;
ctx.cancellation_check = [print]() { return print->canceled(); };
+5 -10
View File
@@ -2717,8 +2717,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
{
LifecycleEventContext ctx;
ctx.id = std::to_string(m_model.id().id);
ctx.name = get_model_name();
ctx.name = std::to_string(m_model.id().id);
ctx.code = LifecycleEvtCode::Ok;
ctx.cancellation_check = [this]() { return canceled(); };
fire_lifecycle_event(LifecycleEvent::SliceStarted, ctx);
@@ -3345,8 +3344,7 @@ void Print::process(long long *time_cost_with_cache, bool use_cache)
{
LifecycleEventContext ctx;
ctx.id = std::to_string(m_model.id().id);
ctx.name = get_model_name();
ctx.name = std::to_string(m_model.id().id);
ctx.code = LifecycleEvtCode::Ok;
ctx.cancellation_check = [this]() { return canceled(); };
fire_lifecycle_event(LifecycleEvent::SliceGeometryFinished, ctx);
@@ -4952,8 +4950,7 @@ void Print::export_gcode_from_previous_file(const std::string& file, GCodeProces
{
{
LifecycleEventContext ctx;
ctx.id = std::to_string(m_model.id().id);
ctx.name = get_model_name();
ctx.name = std::to_string(m_model.id().id);
ctx.code = LifecycleEvtCode::Ok;
ctx.msg = file;
ctx.cancellation_check = [this]() { return canceled(); };
@@ -4983,8 +4980,7 @@ void Print::export_gcode_from_previous_file(const std::string& file, GCodeProces
BOOST_LOG_TRIVIAL(error) << __FUNCTION__ << boost::format(": found errors when process gcode file %1%") %file.c_str();
{
LifecycleEventContext ctx;
ctx.id = std::to_string(m_model.id().id);
ctx.name = get_model_name();
ctx.name = std::to_string(m_model.id().id);
ctx.code = LifecycleEvtCode::Error;
ctx.msg = file + "\n" + ex.what();
ctx.cancellation_check = [this]() { return canceled(); };
@@ -4998,8 +4994,7 @@ void Print::export_gcode_from_previous_file(const std::string& file, GCodeProces
{
LifecycleEventContext ctx;
ctx.id = std::to_string(m_model.id().id);
ctx.name = get_model_name();
ctx.name = std::to_string(m_model.id().id);
ctx.code = LifecycleEvtCode::Ok;
ctx.msg = file;
ctx.cancellation_check = [this]() { return canceled(); };
+4 -12
View File
@@ -12800,6 +12800,8 @@ void Plater::priv::on_process_completed(SlicingProcessCompletedEvent &evt)
notification_manager->set_slicing_progress_export_possible();
// Reset the "export G-code path" name, so that the automatic background processing will be enabled again.
const std::string lifecycle_job_name = this->background_process.fff_print() ?
this->background_process.fff_print()->output_filename() : std::string();
this->background_process.reset_export();
// This bool stops showing export finished notification even when process_completed_with_error is false
bool has_error = false;
@@ -12846,18 +12848,8 @@ void Plater::priv::on_process_completed(SlicingProcessCompletedEvent &evt)
{
Slic3r::LifecycleEventContext ctx;
if (const PrintBase* print = this->background_process.current_print()) {
const Model& model = print->model();
ctx.id = std::to_string(model.id().id);
if (model.model_info)
ctx.name = model.model_info->model_name;
} else {
// Realistically Printbase* print will never be null because select_technology already asserts an active print
// and the worker thread asserts it before processing.
BOOST_LOG_TRIVIAL(warning) << __FUNCTION__ << ": slicing completed without an active print; lifecycle event has no model ID";
}
ctx.code = evt.cancelled() ? Slic3r::LifecycleEvtCode::Warn :
(has_error ? Slic3r::LifecycleEvtCode::Error : Slic3r::LifecycleEvtCode::Ok);
ctx.name = lifecycle_job_name;
ctx.code = evt.cancelled() ? Slic3r::LifecycleEvtCode::Warn : (has_error ? Slic3r::LifecycleEvtCode::Error : Slic3r::LifecycleEvtCode::Ok);
ctx.msg = evt.cancelled() ? "cancelled" : (has_error ? lifecycle_error_msg : std::string());
Slic3r::fire_lifecycle_event(Slic3r::LifecycleEvent::SlicingJobComplete, ctx);
}
+4 -5
View File
@@ -31,15 +31,14 @@ int UserManager::parse_json(std::string payload)
{
bool restored_json = false;
json j;
json j_pre = json::parse(payload);
if (j_pre.empty()) {
return -1;
}
//bind/unbind
try {
json j_pre = json::parse(payload);
if (j_pre.empty()) {
return -1;
}
if (j_pre.contains("bind")) {
if (j_pre["bind"].contains("command")) {
+9
View File
@@ -274,4 +274,13 @@ bool Duet::start_print(wxString &msg, const std::string &filename, ConnectionTyp
return res;
}
int Duet::get_err_code_from_body(const std::string &body) const
{
pt::ptree root;
std::istringstream iss (body); // wrap returned json to istringstream
pt::read_json(iss, root);
return root.get<int>("err", 0);
}
}
+1
View File
@@ -40,6 +40,7 @@ private:
ConnectionType connect(wxString &msg) const;
void disconnect(ConnectionType connectionType) const;
bool start_print(wxString &msg, const std::string &filename, ConnectionType connectionType, bool simulationMode) const;
int get_err_code_from_body(const std::string &body) const;
};
}
+9
View File
@@ -146,6 +146,15 @@ bool ESP3D::start_print(wxString& msg, const std::string& filename) const
return ret;
}
int ESP3D::get_err_code_from_body(const std::string& body) const
{
pt::ptree root;
std::istringstream iss(body); // wrap returned json to istringstream
pt::read_json(iss, root);
return root.get<int>("err", 0);
}
// ESP3D only accepts 8.3 filenames else it crashes marlin and other undefined behaviour
std::string ESP3D::get_short_name(const std::string& filename) const
{
+1
View File
@@ -33,6 +33,7 @@ private:
std::string m_console_port;
bool start_print(wxString& msg, const std::string& filename) const;
int get_err_code_from_body(const std::string& body) const;
std::string get_short_name(const std::string& filename) const;
std::string format_command(const std::string& path, const std::string& arg, const std::string& val) const;
};
+15 -25
View File
@@ -510,22 +510,12 @@ bool Flashforge::fetch_material_slots(std::vector<FlashforgeMaterialSlot>& slots
if (!request_local_api_json("detail", json{{"serialNumber", m_serial_number}, {"checkCode", m_check_code}}.dump(), body, msg))
return false;
if (!parse_material_slots(body, slots, supports_material_station)) {
const auto parsed = json::parse(body, nullptr, false, true);
if (parsed.is_discarded()) {
msg = _(L("Flashforge returned an invalid JSON response."));
return false;
}
return true;
}
bool Flashforge::parse_material_slots(const std::string& body, std::vector<FlashforgeMaterialSlot>& slots, bool* supports_material_station)
{
slots.clear();
const auto parsed = json::parse(body, nullptr, false, true);
if (parsed.is_discarded())
return false;
const auto& detail = parsed.contains("detail") ? parsed["detail"] : parsed;
const auto& station = detail.contains("matlStationInfo") ? detail["matlStationInfo"] :
detail.contains("MatlStationInfo") ? detail["MatlStationInfo"] : json();
@@ -552,21 +542,12 @@ bool Flashforge::parse_material_slots(const std::string& body, std::vector<Flash
if (supports_material_station != nullptr)
*supports_material_station = reports_material_station;
// Fields are read leniently: firmware may send numbers as strings or flags as numbers.
for (const auto& slot : slot_infos) {
if (!slot.is_object())
continue;
FlashforgeMaterialSlot info;
info.slot_id = static_cast<int>(slots.size()) + 1;
if (const auto it = slot.find("slotId"); it != slot.end())
try_parse_json_int(*it, info.slot_id);
int has_filament = 0;
if (const auto it = slot.find("hasFilament"); it != slot.end() && try_parse_json_int(*it, has_filament))
info.has_filament = has_filament != 0;
if (const auto it = slot.find("materialName"); it != slot.end() && it->is_string())
info.material_name = it->get<std::string>();
if (const auto it = slot.find("materialColor"); it != slot.end() && it->is_string())
info.material_color = it->get<std::string>();
info.slot_id = slot.value("slotId", static_cast<int>(slots.size()) + 1);
info.has_filament = slot.value("hasFilament", false);
info.material_name = slot.value("materialName", std::string());
info.material_color = slot.value("materialColor", std::string());
slots.emplace_back(std::move(info));
}
@@ -689,4 +670,13 @@ std::string Flashforge::extract_host_name() const
return out;
}
int Flashforge::get_err_code_from_body(const std::string& body) const
{
pt::ptree root;
std::istringstream iss(body); // wrap returned json to istringstream
pt::read_json(iss, root);
return root.get<int>("err", 0);
}
} // namespace Slic3r
+1 -2
View File
@@ -45,8 +45,6 @@ public:
PrintHostPostUploadActions get_post_upload_actions() const override { return PrintHostPostUploadAction::StartPrint; }
std::string get_host() const override { return m_host; }
bool fetch_material_slots(std::vector<FlashforgeMaterialSlot>& slots, bool* supports_material_station, wxString& msg) const;
// Parses a local API "detail" reply. Returns false when the body is not valid JSON.
static bool parse_material_slots(const std::string& body, std::vector<FlashforgeMaterialSlot>& slots, bool* supports_material_station);
static bool discover_printers(std::vector<FlashforgeDiscoveredPrinter>& printers, wxString& msg, int timeout_ms = 10000, int idle_timeout_ms = 1500, int max_retries = 3);
private:
@@ -70,6 +68,7 @@ private:
bool request_local_api_json(const std::string& path, const std::string& body, std::string& response_body, wxString& error_msg) const;
std::string make_http_url(const std::string& path) const;
std::string extract_host_name() const;
int get_err_code_from_body(const std::string &body) const;
bool connect(wxString& msg) const;
bool start_print(wxString& msg, const std::string& filename) const;
};
+9
View File
@@ -141,4 +141,13 @@ bool MKS::start_print(wxString& msg, const std::string& filename) const
return ret;
}
int MKS::get_err_code_from_body(const std::string& body) const
{
pt::ptree root;
std::istringstream iss(body); // wrap returned json to istringstream
pt::read_json(iss, root);
return root.get<int>("err", 0);
}
} // Slic3r
+1
View File
@@ -34,6 +34,7 @@ private:
std::string get_upload_url(const std::string& filename) const;
bool start_print(wxString& msg, const std::string& filename) const;
int get_err_code_from_body(const std::string& body) const;
};
}
+1 -23
View File
@@ -3,13 +3,10 @@
#include <vector>
#include <thread>
#include <exception>
#include <sstream>
#include <boost/optional.hpp>
#include <boost/log/trivial.hpp>
#include <boost/filesystem.hpp>
#include <nlohmann/json.hpp>
#include <boost/property_tree/ptree.hpp>
#include <boost/property_tree/json_parser.hpp>
#include <wx/string.h>
#include <wx/app.h>
@@ -175,20 +172,6 @@ std::string moonraker_error_reason(const std::string &body)
} // namespace
int PrintHost::get_err_code_from_body(const std::string &body)
{
boost::property_tree::ptree root;
std::istringstream iss(body);
try {
boost::property_tree::read_json(iss, root);
} catch (const std::exception &ex) {
BOOST_LOG_TRIVIAL(error) << "PrintHost: response is not valid JSON: " << ex.what();
return -1;
}
return root.get<int>("err", 0);
}
wxString PrintHost::format_error(const std::string &body, const std::string &error, unsigned status) const
{
if (status != 0) {
@@ -320,12 +303,7 @@ void PrintHostJobQueue::priv::bg_thread_main()
% job.cancelled;
if (! job.cancelled) {
// A failing job must not stop the worker, or later jobs would stay queued forever.
try {
perform_job(std::move(job));
} catch (const std::exception &e) {
emit_error(e.what());
}
perform_job(std::move(job));
}
remove_source();
-2
View File
@@ -87,8 +87,6 @@ public:
static PrintHost* get_print_host(DynamicPrintConfig *config);
static std::string get_print_host_webui(DynamicPrintConfig *config);
// Reads the "err" field of a JSON reply, 0 when absent. Returns -1 when the body is not valid JSON.
static int get_err_code_from_body(const std::string &body);
//Support for cloud webui login
virtual bool is_cloud() const { return false; }
+9
View File
@@ -654,4 +654,13 @@ bool UltiMaker::start_print(wxString &msg, const std::string &filename, Connecti
return res;
}
int UltiMaker::get_err_code_from_body(const std::string &body) const
{
pt::ptree root;
std::istringstream iss (body); // wrap returned json to istringstream
pt::read_json(iss, root);
return root.get<int>("err", 0);
}
}
+1
View File
@@ -64,6 +64,7 @@ private:
void set_auth(Http& http) const;
void disconnect(ConnectionType connectionType) const;
bool start_print(wxString &msg, const std::string &filename, ConnectionType connectionType) const;
int get_err_code_from_body(const std::string &body) const;
};
}
+10 -5
View File
@@ -345,16 +345,21 @@ boost::filesystem::path find_bundled_python_home()
fs::path bundle_python = fs::path(resources_dir()).parent_path() / "MacOS" / "python";
if (valid_python_home(bundle_python))
return bundle_python;
#elif defined(_WIN32)
fs::path exe_python = boost::dll::program_location().parent_path() / "python";
if (valid_python_home(exe_python))
return exe_python;
#else
#elif !defined(_WIN32)
fs::path linux_python = fs::path(resources_dir()).parent_path() / "lib" / "python";
if (valid_python_home(linux_python))
return linux_python;
#endif
// Next to the executable: the Windows install layout, and the runtime copied
// beside every platform's unit-test binary (tests/slic3rutils/CMakeLists.txt).
// The CI test runner only receives the build/tests tree, so the candidates
// below -- all of which point into the deps or install trees -- never resolve
// there.
fs::path exe_python = boost::dll::program_location().parent_path() / "python";
if (valid_python_home(exe_python))
return exe_python;
fs::path configured_python = ORCA_BUNDLED_PYTHON_ROOT;
if (!configured_python.empty() && valid_python_home(configured_python))
return configured_python;
-86
View File
@@ -15,9 +15,6 @@
#include "libslic3r/Layer.hpp"
#include "libslic3r/Model.hpp"
#include "libslic3r/GCodeReader.hpp"
#include "libslic3r/GCode/GCodeProcessor.hpp"
#include "libslic3r/Exception.hpp"
#include "libslic3r/LifecycleEvents.hpp"
#include "test_helpers.hpp"
#include "test_utils.hpp"
@@ -25,10 +22,7 @@
#include <algorithm>
#include <fstream>
#include <iterator>
#include <memory>
#include <string_view>
#include <utility>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::Test;
@@ -230,88 +224,8 @@ std::string resolved_output_name(Model& model, const std::string& format, const
return print.output_filename(filename_base);
}
struct ScopedLifecycleHook
{
explicit ScopedLifecycleHook(LifecycleHookFn hook) { set_lifecycle_hook_fn(std::move(hook)); }
~ScopedLifecycleHook() { set_lifecycle_hook_fn(nullptr); }
};
} // namespace
TEST_CASE("Slicing lifecycle events identify the model", "[Print][LifecycleEvents]")
{
struct ObservedEvent {
LifecycleEvent event;
std::string id;
std::string name;
};
std::vector<ObservedEvent> events;
ScopedLifecycleHook hook([&](LifecycleEvent event, const LifecycleEventContext& ctx) {
events.push_back({ event, ctx.id, ctx.name });
});
Print print;
Model model;
ModelInfo info;
info.model_name = "Lifecycle test model";
model.model_info = std::make_shared<ModelInfo>(std::move(info));
init_print({cube(20)}, print, model);
print.process();
ScopedTemporaryFile temp(".gcode");
print.export_gcode(temp.string(), nullptr, nullptr);
GCodeProcessorResult result;
print.export_gcode_from_previous_file(temp.string(), &result);
const std::string expected_id = std::to_string(print.model().id().id);
const std::vector<LifecycleEvent> expected_events = {
LifecycleEvent::SliceStarted,
LifecycleEvent::SliceGeometryFinished,
LifecycleEvent::GCodeExportStarted,
LifecycleEvent::GCodeExportFinished,
LifecycleEvent::GCodeExportStarted,
LifecycleEvent::GCodeExportFinished,
};
REQUIRE(events.size() == expected_events.size());
for (size_t i = 0; i < expected_events.size(); ++i) {
CHECK(events[i].event == expected_events[i]);
CHECK(events[i].id == expected_id);
CHECK(events[i].name == "Lifecycle test model");
}
}
TEST_CASE("Slicing lifecycle event name is empty without model metadata", "[Print][LifecycleEvents]")
{
std::string event_id;
std::string event_name = "unset";
ScopedLifecycleHook hook([&](LifecycleEvent event, const LifecycleEventContext& ctx) {
if (event == LifecycleEvent::SliceStarted) {
event_id = ctx.id;
event_name = ctx.name;
}
});
Print print;
Model model;
init_print({cube(20)}, print, model);
print.process();
CHECK(event_id == std::to_string(print.model().id().id));
CHECK(event_name.empty());
}
TEST_CASE("Output filenames with numeric statistics fail before slicing finishes", "[Print][Regression]")
{
DynamicPrintConfig config = DynamicPrintConfig::full_print_config();
config.set_key_value("filename_format", new ConfigOptionString("{int(total_weight*10) / 10.0}"));
Print print;
Model model;
init_print({cube(20)}, print, model, config);
CHECK_THROWS_AS(print.output_filename(), PlaceholderParserError);
}
TEST_CASE("Print: {first_object_name} names the first printable object on the plate", "[Print]")
{
Model model;
+23 -5
View File
@@ -25,7 +25,6 @@ add_executable(${_TEST_NAME}_tests
test_plugin_cloud_metadata.cpp
test_plugin_audit.cpp
test_shortcuts.cpp
test_user_manager.cpp
../fff_print/test_helpers.cpp
)
@@ -50,9 +49,16 @@ if (WIN32)
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")
elseif (NOT FLATPAK)
# The CI unit-test runner only receives the build/tests tree, so both the
# interpreter and the libpython the test binary links have to travel next to
# the executable; find_bundled_python_home() picks the copy up from there.
if (APPLE)
target_link_options(${_TEST_NAME}_tests PRIVATE
"LINKER:-rpath,@executable_path/python/lib")
else ()
set_property(TARGET ${_TEST_NAME}_tests APPEND PROPERTY BUILD_RPATH "$ORIGIN/python/lib")
endif ()
add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD
COMMAND ${CMAKE_COMMAND} -E rm -rf
@@ -60,7 +66,7 @@ elseif (APPLE)
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"
COMMENT "Copying Python runtime for the plugin host API tests"
VERBATIM
)
elseif (FLATPAK)
@@ -77,4 +83,16 @@ elseif (FLATPAK)
)
endif()
# scripts/run_unit_tests.sh installs the tests' numpy into that runtime with this uv,
# staged where the app build tree keeps it (<exe dir>/tools/uv, see src/CMakeLists.txt).
if (ORCA_BUNDLED_UV_EXECUTABLE)
add_custom_command(TARGET ${_TEST_NAME}_tests POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/tools/uv"
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${ORCA_BUNDLED_UV_EXECUTABLE}"
"$<TARGET_FILE_DIR:${_TEST_NAME}_tests>/tools/uv/${ORCA_BUNDLED_UV_FILENAME}"
COMMENT "Copying uv for the plugin host API tests"
VERBATIM
)
endif()
orcaslicer_discover_tests(${_TEST_NAME}_tests)
-103
View File
@@ -1,12 +1,8 @@
#include <catch2/catch_all.hpp>
#include <string>
#include <vector>
#include <nlohmann/json.hpp>
#include "slic3r/Utils/PrintHost.hpp"
#include "slic3r/Utils/Flashforge.hpp"
using namespace Slic3r;
@@ -50,14 +46,6 @@ std::string moonraker_error(int code, const std::string& message, const std::str
constexpr const char* k_busy_file_403 =
R"JSON({"error": {"code": 403, "message": "Forbidden", "traceback": "Traceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/file_manager/file_manager.py\", line 1017, in _finish_gcode_upload\n can_start = self._handle_operation_check(check_path)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\nmoonraker.utils.exceptions.ServerError: File currently in use\n\nDuring handling of the above exception, another exception occurred:\n\nTraceback (most recent call last):\n\n File \"/home/lava/moonraker/moonraker/components/application.py\", line 1069, in post\n raise tornado.web.HTTPError(\ntornado.web.HTTPError: HTTP 403: Forbidden (File is loaded, upload not permitted)\n"}})JSON";
// Replies a print host can send instead of JSON: a proxy or login page, nothing, a cut-off body.
const std::vector<std::string> non_json_replies = {
"<html><body>proxy login required</body></html>",
"",
"{\"err\":",
"{\"detail\":{\"matlStationInfo\":{\"slotInfos\":[{\"slotId\":1,",
};
} // namespace
TEST_CASE("A Klipper upload error shows its reason instead of a Python traceback", "[PrintHost][Regression]")
@@ -223,94 +211,3 @@ TEST_CASE("Error bodies that are not a Moonraker envelope are left unchanged", "
CHECK(format_error("", "curl:Could not connect", 0) == "curl:Could not connect");
}
}
TEST_CASE("Print host error code is read from a JSON reply", "[PrintHost]")
{
CHECK(PrintHost::get_err_code_from_body(R"({"err":0})") == 0);
CHECK(PrintHost::get_err_code_from_body(R"({"err":2})") == 2);
CHECK(PrintHost::get_err_code_from_body(R"({"status":"ok"})") == 0);
}
TEST_CASE("Print host error code reports a reply that is not JSON as an error", "[PrintHost]")
{
const std::string body = GENERATE(from_range(non_json_replies));
int err = 0;
REQUIRE_NOTHROW(err = PrintHost::get_err_code_from_body(body));
CHECK(err != 0);
}
TEST_CASE("Print host error code tolerates a wrongly typed err field", "[PrintHost]")
{
const std::string body = GENERATE(as<std::string>{}, R"({"err":"busy"})", R"({"err":{"code":1}})", R"([1,2])");
CHECK_NOTHROW(PrintHost::get_err_code_from_body(body));
}
TEST_CASE("Flashforge material slots are read from a well-formed reply", "[PrintHost][Flashforge]")
{
const std::string body = R"({"code":0,"detail":{"hasMatlStation":true,"matlStationInfo":{"slotCnt":2,"slotInfos":[
{"slotId":1,"hasFilament":true,"materialName":"PLA","materialColor":"#FFFFFF"},
{"slotId":2,"hasFilament":false,"materialName":"","materialColor":""}]}}})";
std::vector<FlashforgeMaterialSlot> slots;
bool supports_station = false;
REQUIRE(Flashforge::parse_material_slots(body, slots, &supports_station));
CHECK(supports_station);
REQUIRE(slots.size() == 2);
CHECK(slots[0].slot_id == 1);
CHECK(slots[0].has_filament);
CHECK(slots[0].material_name == "PLA");
CHECK(slots[0].material_color == "#FFFFFF");
CHECK(slots[1].slot_id == 2);
CHECK_FALSE(slots[1].has_filament);
}
TEST_CASE("Flashforge material slots accept numbers as strings and flags as numbers", "[PrintHost][Flashforge]")
{
const std::string body = R"({"detail":{"matlStationInfo":{"slotInfos":[
{"slotId":"3","hasFilament":1,"materialName":null,"materialColor":7}]}}})";
std::vector<FlashforgeMaterialSlot> slots;
REQUIRE_NOTHROW(Flashforge::parse_material_slots(body, slots, nullptr));
REQUIRE(slots.size() == 1);
CHECK(slots[0].slot_id == 3);
CHECK(slots[0].has_filament);
CHECK(slots[0].material_name.empty());
CHECK(slots[0].material_color.empty());
}
TEST_CASE("Flashforge material slots skip entries that are not objects", "[PrintHost][Flashforge]")
{
const std::string body = R"({"detail":{"matlStationInfo":{"slotInfos":[5,"slot",null,[],
{"slotId":4,"hasFilament":true,"materialName":"PETG"}]}}})";
std::vector<FlashforgeMaterialSlot> slots;
REQUIRE_NOTHROW(Flashforge::parse_material_slots(body, slots, nullptr));
REQUIRE(slots.size() == 1);
CHECK(slots[0].slot_id == 4);
CHECK(slots[0].material_name == "PETG");
}
TEST_CASE("Flashforge material slots tolerate slot info that is not a list", "[PrintHost][Flashforge]")
{
const std::string body = GENERATE(as<std::string>{},
R"({"detail":{"matlStationInfo":{"slotInfos":5}}})",
R"({"detail":{"matlStationInfo":{"slotInfos":"none"}}})",
R"({"detail":{"matlStationInfo":7}})",
R"({"detail":"offline"})");
std::vector<FlashforgeMaterialSlot> slots;
bool ok = false;
REQUIRE_NOTHROW(ok = Flashforge::parse_material_slots(body, slots, nullptr));
CHECK(ok);
CHECK(slots.empty());
}
TEST_CASE("Flashforge material slots reject a reply that is not JSON", "[PrintHost][Flashforge]")
{
const std::string body = GENERATE(from_range(non_json_replies));
std::vector<FlashforgeMaterialSlot> slots;
bool ok = true;
REQUIRE_NOTHROW(ok = Flashforge::parse_material_slots(body, slots, nullptr));
CHECK_FALSE(ok);
CHECK(slots.empty());
}
-23
View File
@@ -1,23 +0,0 @@
#include <catch2/catch_all.hpp>
#include <string>
#include "slic3r/GUI/UserManager.hpp"
using namespace Slic3r;
TEST_CASE("User message that is not JSON is rejected without throwing", "[UserManager]")
{
const std::string payload = GENERATE(as<std::string>{}, "not json", "", "<html></html>", "{\"bind\":");
UserManager manager;
int result = 0;
REQUIRE_NOTHROW(result = manager.parse_json(payload));
CHECK(result == -1);
}
TEST_CASE("User message without a successful bind is ignored", "[UserManager]")
{
const std::string payload = GENERATE(as<std::string>{}, "{}", R"({"bind":{"command":"unbind"}})", R"({"bind":"bind"})", "[1]");
UserManager manager;
CHECK(manager.parse_json(payload) == -1);
}