mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-13 02:41:16 +00:00
Merge main
This commit is contained in:
@@ -37,7 +37,9 @@ add_executable(${_TEST_NAME}_tests
|
||||
test_plugin_sort.cpp
|
||||
test_plugin_cloud_metadata.cpp
|
||||
test_plugin_audit.cpp
|
||||
test_plugin_json_depth.cpp
|
||||
test_shortcuts.cpp
|
||||
test_texture_color_palette.cpp
|
||||
test_file_url.cpp
|
||||
test_user_manager.cpp
|
||||
../fff_print/test_helpers.cpp
|
||||
|
||||
@@ -4,16 +4,23 @@
|
||||
|
||||
#include <catch2/catch_message.hpp>
|
||||
#include "slic3r/GUI/ActionRegistry.hpp"
|
||||
#include "slic3r/GUI/GUI_Factories.hpp"
|
||||
#include "slic3r/GUI/I18N.hpp"
|
||||
#include "slic3r/GUI/NativeCommands.hpp"
|
||||
#include "slic3r/GUI/OptionsGroup.hpp"
|
||||
#include "slic3r/GUI/SettingsIndex.hpp"
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <wx/string.h>
|
||||
|
||||
#include "libslic3r/Config.hpp"
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
using Slic3r::GUI::AppAction;
|
||||
@@ -27,7 +34,9 @@ namespace {
|
||||
class TestAppAction final : public AppAction
|
||||
{
|
||||
public:
|
||||
TestAppAction() : AppAction("test", "Action title", "src-key", "Action source") {}
|
||||
explicit TestAppAction(std::string title = "Action title", std::string source_name = "Action source")
|
||||
: AppAction("test", std::move(title), "src-key", std::move(source_name))
|
||||
{}
|
||||
|
||||
AppActionRunResult run(const std::string& param = {}) const override { return {}; }
|
||||
};
|
||||
@@ -93,7 +102,9 @@ TEST_CASE("Command actions are keyed by catalog key, not display title", "[Actio
|
||||
}
|
||||
|
||||
// The real catalog -> action mapping keys by the stable catalog key and copies presentation from the
|
||||
// catalog, so a rename or a UI-language switch never re-keys the action.
|
||||
// catalog, so a rename or a UI-language switch never re-keys the action. The catalog's English title
|
||||
// and group become the action's English search aliases; no translation catalog is loaded in the tests,
|
||||
// so the displayed title and group equal them.
|
||||
TEST_CASE("Command action construction keys by catalog key", "[ActionSource][SpeedDial]")
|
||||
{
|
||||
const std::vector<Slic3r::GUI::NativeCommand>& commands = Slic3r::GUI::NativeCommands::catalog();
|
||||
@@ -106,6 +117,8 @@ TEST_CASE("Command action construction keys by catalog key", "[ActionSource][Spe
|
||||
CHECK(action->id() != AppAction::compose_id("orca_command", c.title, "orca"));
|
||||
CHECK(action->title() == c.title);
|
||||
CHECK(action->group == c.group);
|
||||
CHECK(action->title_en == c.title);
|
||||
CHECK(action->group_en == c.group);
|
||||
CHECK(action->input == c.input);
|
||||
CHECK(action->icon == c.icon);
|
||||
}
|
||||
@@ -171,6 +184,7 @@ TEST_CASE("Native command catalog has unique keys and present titles", "[ActionS
|
||||
for (const auto& c : commands) {
|
||||
CHECK_FALSE(c.key.empty());
|
||||
CHECK_FALSE(c.title.empty());
|
||||
CHECK_FALSE(c.group.empty());
|
||||
// A duplicated key would silently shadow the earlier command in the palette.
|
||||
CHECK(seen.insert(c.key).second);
|
||||
}
|
||||
@@ -288,6 +302,16 @@ TEST_CASE("Native command catalog covers the Add menus", "[ActionSource][SpeedDi
|
||||
REQUIRE(group != nullptr);
|
||||
CHECK(*group == *handy_group);
|
||||
}
|
||||
|
||||
// A handy model's command is titled by the English label its Add-menu entry is built from, so it is
|
||||
// searchable in English in any UI language.
|
||||
for (const auto& model : Slic3r::GUI::MenuFactory::handy_models()) {
|
||||
const std::string key = "add_handy_" + std::string(model.key);
|
||||
INFO(key);
|
||||
const auto it = std::find_if(commands.begin(), commands.end(), [&key](const auto& c) { return c.key == key; });
|
||||
REQUIRE(it != commands.end());
|
||||
CHECK(it->title == model.label);
|
||||
}
|
||||
}
|
||||
|
||||
// A setting action is named like its settings row, not the ConfigOptionDef label: the row's
|
||||
@@ -319,6 +343,61 @@ TEST_CASE("Setting display labels mirror the settings row", "[ActionSource][Spee
|
||||
CHECK(resolve_setting_title(L"", L"", false).IsEmpty());
|
||||
}
|
||||
|
||||
// The page receives an English alias only where it differs from the displayed text, so an English UI
|
||||
// (where every alias equals its text) receives none and searches exactly the displayed text.
|
||||
TEST_CASE("Action JSON sends English aliases only when they differ from the displayed text", "[ActionSource][SpeedDial]")
|
||||
{
|
||||
const char* aliases[] = {"title_en", "group_en", "source_en", "full_label_en"};
|
||||
|
||||
TestAppAction english("Reverse on even", "Process : Quality : Overhangs");
|
||||
english.full_label = "Overhang reversal";
|
||||
english.title_en = english.title();
|
||||
english.source_en = english.source_name();
|
||||
english.full_label_en = english.full_label;
|
||||
const nlohmann::json same = Slic3r::GUI::action_json(english);
|
||||
for (const char* key : aliases) {
|
||||
INFO(key);
|
||||
CHECK_FALSE(same.contains(key));
|
||||
}
|
||||
CHECK(same.at("title") == "Reverse on even");
|
||||
|
||||
// A translated action (German stands in for any UI language) carries each differing English name.
|
||||
TestAppAction localized("Bei geraden Schichten umkehren", "Prozess : Qualitaet : Ueberhaenge");
|
||||
localized.group = "Befehle";
|
||||
localized.full_label = "Ueberhangumkehr";
|
||||
localized.title_en = "Reverse on even";
|
||||
localized.group_en = "Commands";
|
||||
localized.source_en = "Process : Quality : Overhangs";
|
||||
localized.full_label_en = "Overhang reversal";
|
||||
const nlohmann::json differs = Slic3r::GUI::action_json(localized);
|
||||
CHECK(differs.at("title_en") == "Reverse on even");
|
||||
CHECK(differs.at("group_en") == "Commands");
|
||||
CHECK(differs.at("source_en") == "Process : Quality : Overhangs");
|
||||
CHECK(differs.at("full_label_en") == "Overhang reversal");
|
||||
CHECK(differs.at("title") == "Bei geraden Schichten umkehren");
|
||||
|
||||
// No English counterpart (a plugin, a file name) sends nothing.
|
||||
const nlohmann::json none = Slic3r::GUI::action_json(TestAppAction("Optimize", "Gcode Optimizer"));
|
||||
for (const char* key : aliases) {
|
||||
INFO(key);
|
||||
CHECK_FALSE(none.contains(key));
|
||||
}
|
||||
}
|
||||
|
||||
// A settings row keeps the untranslated English of its label, from the ctor and from a runtime
|
||||
// rename, so the Speed Dial can offer the row's English name in any UI language. No catalog is loaded
|
||||
// in the tests, so the displayed label equals the English.
|
||||
TEST_CASE("Settings rows keep the English of their label", "[ActionSource][SpeedDial]")
|
||||
{
|
||||
Slic3r::GUI::Line line{L("Cool Plate"), ""};
|
||||
CHECK(line.label_en == "Cool Plate");
|
||||
CHECK(line.label == "Cool Plate");
|
||||
|
||||
line.set_label(L("Brim ear radius"));
|
||||
CHECK(line.label_en == "Brim ear radius");
|
||||
CHECK(line.label == "Brim ear radius");
|
||||
}
|
||||
|
||||
// A setting whose mode is above the user's current mode must be prompted before it can be edited.
|
||||
// Developer settings (comDevelop) are above every non-developer mode, so they always prompt then.
|
||||
TEST_CASE("Settings above the current mode require a switch", "[ActionSource][SpeedDial]")
|
||||
|
||||
@@ -2,14 +2,38 @@
|
||||
#include <boost/filesystem/operations.hpp>
|
||||
#include <catch2/catch_all.hpp>
|
||||
|
||||
#include <boost/algorithm/string/predicate.hpp>
|
||||
#include <boost/asio/buffer.hpp>
|
||||
#include <boost/asio/io_context.hpp>
|
||||
#include <boost/asio/ip/address.hpp>
|
||||
#include <boost/asio/ip/tcp.hpp>
|
||||
#include <boost/asio/read_until.hpp>
|
||||
#include <boost/asio/streambuf.hpp>
|
||||
#include <boost/asio/write.hpp>
|
||||
#include <boost/filesystem.hpp>
|
||||
#include <boost/filesystem/fstream.hpp>
|
||||
#include <boost/nowide/cstdlib.hpp>
|
||||
#include <boost/system/error_code.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <istream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include "slic3r/Utils/ICloudServiceAgent.hpp"
|
||||
#include "slic3r/Utils/OrcaCloudServiceAgent.hpp"
|
||||
#include "slic3r/Utils/bambu_networking.hpp"
|
||||
#include "test_utils.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
@@ -61,6 +85,227 @@ std::string resolved_display_name(const nlohmann::json& session)
|
||||
return agent->get_user_nickname();
|
||||
}
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
using boost::asio::ip::tcp;
|
||||
|
||||
// A loopback HTTP server for the health check to talk to. It answers every request according to
|
||||
// the current mode and records what it was sent.
|
||||
class LoopbackServer
|
||||
{
|
||||
public:
|
||||
enum class Mode { Ok, Unavailable, Silent };
|
||||
|
||||
struct Request
|
||||
{
|
||||
std::string request_line;
|
||||
bool has_authorization{false};
|
||||
};
|
||||
|
||||
LoopbackServer() : m_acceptor(m_io, tcp::endpoint(boost::asio::ip::make_address("127.0.0.1"), 0))
|
||||
{
|
||||
accept_next();
|
||||
m_thread = std::thread([this] { m_io.run(); });
|
||||
}
|
||||
|
||||
~LoopbackServer()
|
||||
{
|
||||
m_io.stop();
|
||||
m_thread.join();
|
||||
}
|
||||
|
||||
std::string url() const { return "http://127.0.0.1:" + std::to_string(m_acceptor.local_endpoint().port()); }
|
||||
void set_mode(Mode mode) { m_mode = mode; }
|
||||
|
||||
bool wait_for_accepts(size_t count, std::chrono::milliseconds timeout)
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(m_mutex);
|
||||
return m_cv.wait_for(lock, timeout, [&] { return m_connections.size() >= count; });
|
||||
}
|
||||
|
||||
std::vector<Request> requests() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
return m_requests;
|
||||
}
|
||||
|
||||
private:
|
||||
struct Connection
|
||||
{
|
||||
explicit Connection(tcp::socket socket) : socket(std::move(socket)) {}
|
||||
tcp::socket socket;
|
||||
boost::asio::streambuf buffer;
|
||||
};
|
||||
|
||||
void accept_next()
|
||||
{
|
||||
m_acceptor.async_accept([this](boost::system::error_code ec, tcp::socket socket) {
|
||||
if (ec)
|
||||
return;
|
||||
auto connection = std::make_shared<Connection>(std::move(socket));
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
// Held here so a Silent connection stays open until the client gives up.
|
||||
m_connections.push_back(connection);
|
||||
}
|
||||
m_cv.notify_all();
|
||||
read_request(connection);
|
||||
accept_next();
|
||||
});
|
||||
}
|
||||
|
||||
void read_request(const std::shared_ptr<Connection>& connection)
|
||||
{
|
||||
boost::asio::async_read_until(connection->socket, connection->buffer, "\r\n\r\n",
|
||||
[this, connection](boost::system::error_code ec, size_t) {
|
||||
if (ec)
|
||||
return;
|
||||
std::istream in(&connection->buffer);
|
||||
Request request;
|
||||
std::string line;
|
||||
std::getline(in, line);
|
||||
request.request_line = line.substr(0, line.find('\r'));
|
||||
while (std::getline(in, line) && line != "\r")
|
||||
request.has_authorization |= boost::istarts_with(line, "authorization:");
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(m_mutex);
|
||||
m_requests.push_back(request);
|
||||
}
|
||||
|
||||
const Mode mode = m_mode;
|
||||
if (mode == Mode::Silent)
|
||||
return;
|
||||
const std::string_view response = mode == Mode::Ok ?
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}" :
|
||||
"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
boost::asio::async_write(connection->socket, boost::asio::buffer(response),
|
||||
[connection](boost::system::error_code, size_t) {
|
||||
boost::system::error_code ignored;
|
||||
connection->socket.shutdown(tcp::socket::shutdown_both, ignored);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Declared first so it outlives the sockets that use it.
|
||||
boost::asio::io_context m_io;
|
||||
tcp::acceptor m_acceptor;
|
||||
std::thread m_thread;
|
||||
std::atomic<Mode> m_mode{Mode::Ok};
|
||||
mutable std::mutex m_mutex;
|
||||
std::condition_variable m_cv;
|
||||
std::vector<Request> m_requests;
|
||||
std::vector<std::shared_ptr<Connection>> m_connections;
|
||||
};
|
||||
|
||||
// Sets an environment variable for the lifetime of the guard and restores the previous value.
|
||||
class ScopedEnvVar
|
||||
{
|
||||
public:
|
||||
ScopedEnvVar(std::string name, const std::string& value) : m_name(std::move(name))
|
||||
{
|
||||
if (const char* old = boost::nowide::getenv(m_name.c_str()))
|
||||
m_previous = old;
|
||||
boost::nowide::setenv(m_name.c_str(), value.c_str(), 1);
|
||||
}
|
||||
~ScopedEnvVar()
|
||||
{
|
||||
if (m_previous)
|
||||
boost::nowide::setenv(m_name.c_str(), m_previous->c_str(), 1);
|
||||
else
|
||||
boost::nowide::unsetenv(m_name.c_str());
|
||||
}
|
||||
|
||||
private:
|
||||
std::string m_name;
|
||||
std::optional<std::string> m_previous;
|
||||
};
|
||||
|
||||
// What the agent reported through its callbacks, recorded on whichever thread delivers it so the
|
||||
// assertions can run on the test thread.
|
||||
struct CallbackLog
|
||||
{
|
||||
void add_connected(int return_code, int reason_code)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
connected.emplace_back(return_code, reason_code);
|
||||
}
|
||||
cv.notify_all();
|
||||
}
|
||||
|
||||
void add_http_error(unsigned http_code)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
http_errors.push_back(http_code);
|
||||
}
|
||||
|
||||
void add_queued(std::function<void()> task)
|
||||
{
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
queued.push_back(std::move(task));
|
||||
}
|
||||
cv.notify_all();
|
||||
}
|
||||
|
||||
bool wait_for_connected(size_t count, std::chrono::milliseconds timeout = 10s)
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(mutex);
|
||||
return cv.wait_for(lock, timeout, [&] { return connected.size() >= count; });
|
||||
}
|
||||
|
||||
bool wait_for_queued(size_t count, std::chrono::milliseconds timeout = 10s)
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(mutex);
|
||||
return cv.wait_for(lock, timeout, [&] { return queued.size() >= count; });
|
||||
}
|
||||
|
||||
std::mutex mutex;
|
||||
std::condition_variable cv;
|
||||
std::vector<std::pair<int, int>> connected;
|
||||
std::vector<unsigned> http_errors;
|
||||
std::vector<std::function<void()>> queued;
|
||||
};
|
||||
|
||||
// An agent whose API and auth endpoints both point at a loopback server, with its callbacks
|
||||
// recorded. Members are declared so the agent, and the health check thread it owns, is destroyed
|
||||
// before the server, the log and the storage it uses.
|
||||
struct HealthCheckFixture
|
||||
{
|
||||
HealthCheckFixture() : agent(make_file_backed_agent(dir.path()))
|
||||
{
|
||||
agent->set_api_base_url(server.url());
|
||||
agent->set_auth_base_url(server.url());
|
||||
agent->set_on_server_connected_fn([this](CloudEvent, int return_code, int reason_code) {
|
||||
log.add_connected(return_code, reason_code);
|
||||
});
|
||||
agent->set_on_http_error_fn([this](CloudEvent, unsigned http_code, std::string) { log.add_http_error(http_code); });
|
||||
}
|
||||
|
||||
// Defers callbacks the way the GUI's CallAfter does, instead of running them on the worker.
|
||||
void queue_callbacks() { agent->set_queue_on_main_fn([this](std::function<void()> task) { log.add_queued(std::move(task)); }); }
|
||||
|
||||
// Starts the next health check, retrying while a call is still folded into the previous one:
|
||||
// its callback fires before the worker thread finishes.
|
||||
bool start_check(size_t expected_accepts)
|
||||
{
|
||||
const auto deadline = std::chrono::steady_clock::now() + 5s;
|
||||
do {
|
||||
agent->refresh_connection();
|
||||
if (server.wait_for_accepts(expected_accepts, 10ms))
|
||||
return true;
|
||||
} while (std::chrono::steady_clock::now() < deadline);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Loopback traffic must not go through a proxy configured in the environment. curl reads no_proxy
|
||||
// before NO_PROXY.
|
||||
ScopedEnvVar no_proxy{"no_proxy", "127.0.0.1"};
|
||||
LoopbackServer server;
|
||||
CallbackLog log;
|
||||
ScopedTemporaryDir dir{"orca-health"};
|
||||
std::unique_ptr<OrcaCloudServiceAgent> agent;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("Logging out removes the secret this instance saved", "[OrcaCloudServiceAgent]")
|
||||
@@ -174,3 +419,124 @@ TEST_CASE("Orca cloud nested session resolves display name consistently", "[Orca
|
||||
{"username", "orca_username"}
|
||||
})) == "orca_username");
|
||||
}
|
||||
|
||||
TEST_CASE_METHOD(HealthCheckFixture, "Refreshing the server connection returns before the health check finishes", "[OrcaCloudServiceAgent]")
|
||||
{
|
||||
server.set_mode(LoopbackServer::Mode::Silent);
|
||||
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
CHECK(agent->refresh_connection() == BAMBU_NETWORK_SUCCESS);
|
||||
// A synchronous check would wait for the request's 30 s timeout.
|
||||
CHECK(std::chrono::steady_clock::now() - start < 2s);
|
||||
|
||||
std::lock_guard<std::mutex> lock(log.mutex);
|
||||
CHECK(log.connected.empty());
|
||||
}
|
||||
|
||||
TEST_CASE_METHOD(HealthCheckFixture, "Successive health checks follow the server's state", "[OrcaCloudServiceAgent]")
|
||||
{
|
||||
server.set_mode(LoopbackServer::Mode::Ok);
|
||||
REQUIRE(start_check(1));
|
||||
REQUIRE(log.wait_for_connected(1));
|
||||
CHECK(agent->is_server_connected());
|
||||
|
||||
server.set_mode(LoopbackServer::Mode::Unavailable);
|
||||
REQUIRE(start_check(2));
|
||||
REQUIRE(log.wait_for_connected(2));
|
||||
CHECK_FALSE(agent->is_server_connected());
|
||||
|
||||
server.set_mode(LoopbackServer::Mode::Ok);
|
||||
REQUIRE(start_check(3));
|
||||
REQUIRE(log.wait_for_connected(3));
|
||||
CHECK(agent->is_server_connected());
|
||||
|
||||
std::lock_guard<std::mutex> lock(log.mutex);
|
||||
CHECK(log.connected == std::vector<std::pair<int, int>>{{0, 200}, {-1, 503}, {0, 200}});
|
||||
CHECK(log.http_errors == std::vector<unsigned>{503});
|
||||
}
|
||||
|
||||
TEST_CASE_METHOD(HealthCheckFixture, "A refresh while a health check is in flight starts no second check", "[OrcaCloudServiceAgent]")
|
||||
{
|
||||
server.set_mode(LoopbackServer::Mode::Silent);
|
||||
|
||||
agent->refresh_connection();
|
||||
REQUIRE(server.wait_for_accepts(1, 5s));
|
||||
|
||||
agent->refresh_connection();
|
||||
agent->refresh_connection();
|
||||
CHECK_FALSE(server.wait_for_accepts(2, 200ms));
|
||||
}
|
||||
|
||||
TEST_CASE_METHOD(HealthCheckFixture, "Stopping the health check cancels the request in flight", "[OrcaCloudServiceAgent]")
|
||||
{
|
||||
server.set_mode(LoopbackServer::Mode::Silent);
|
||||
queue_callbacks();
|
||||
|
||||
agent->refresh_connection();
|
||||
REQUIRE(server.wait_for_accepts(1, 5s));
|
||||
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
agent->stop_health_check();
|
||||
// Well under the request's 30 s timeout.
|
||||
CHECK(std::chrono::steady_clock::now() - start < 5s);
|
||||
CHECK(agent->refresh_connection() == BAMBU_NETWORK_ERR_CANCELED);
|
||||
|
||||
std::lock_guard<std::mutex> lock(log.mutex);
|
||||
CHECK(log.connected.empty());
|
||||
CHECK(log.http_errors.empty());
|
||||
CHECK(log.queued.empty());
|
||||
}
|
||||
|
||||
TEST_CASE_METHOD(HealthCheckFixture, "Connecting to the server reports the result synchronously", "[OrcaCloudServiceAgent]")
|
||||
{
|
||||
server.set_mode(LoopbackServer::Mode::Ok);
|
||||
CHECK(agent->connect_server() == BAMBU_NETWORK_SUCCESS);
|
||||
CHECK(agent->is_server_connected());
|
||||
|
||||
server.set_mode(LoopbackServer::Mode::Unavailable);
|
||||
CHECK(agent->connect_server() == BAMBU_NETWORK_ERR_CONNECTION_TO_SERVER_FAILED);
|
||||
CHECK_FALSE(agent->is_server_connected());
|
||||
}
|
||||
|
||||
TEST_CASE_METHOD(HealthCheckFixture, "A health check result queued before stopping is still delivered and nothing is queued after", "[OrcaCloudServiceAgent]")
|
||||
{
|
||||
server.set_mode(LoopbackServer::Mode::Ok);
|
||||
queue_callbacks();
|
||||
|
||||
agent->refresh_connection();
|
||||
REQUIRE(log.wait_for_queued(1));
|
||||
agent->stop_health_check();
|
||||
|
||||
std::function<void()> task;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(log.mutex);
|
||||
REQUIRE(log.queued.size() == 1);
|
||||
task = log.queued.front();
|
||||
}
|
||||
// The GUI may run a task it queued after the agent is gone; the task must not need the agent.
|
||||
agent.reset();
|
||||
task();
|
||||
|
||||
std::lock_guard<std::mutex> lock(log.mutex);
|
||||
CHECK(log.connected == std::vector<std::pair<int, int>>{{0, 200}});
|
||||
}
|
||||
|
||||
TEST_CASE_METHOD(HealthCheckFixture, "The health check does no token work", "[OrcaCloudServiceAgent]")
|
||||
{
|
||||
// A non-JWT access token has no expiry, so the session reads as due for a refresh.
|
||||
REQUIRE(agent->set_user_session(flat_session_json({{"refresh_token", "test-refresh-token"}}), false));
|
||||
server.set_mode(LoopbackServer::Mode::Ok);
|
||||
|
||||
REQUIRE(start_check(1));
|
||||
REQUIRE(log.wait_for_connected(1));
|
||||
CHECK(agent->connect_server() == BAMBU_NETWORK_SUCCESS);
|
||||
|
||||
const auto requests = server.requests();
|
||||
REQUIRE(requests.size() == 2);
|
||||
for (const auto& request : requests) {
|
||||
CHECK(boost::starts_with(request.request_line, "GET /api/v1/health "));
|
||||
CHECK_FALSE(request.has_authorization);
|
||||
}
|
||||
CHECK(agent->is_user_login());
|
||||
CHECK(agent->get_access_token() == "test-token");
|
||||
}
|
||||
|
||||
@@ -222,18 +222,19 @@ TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword",
|
||||
CHECK(mgr.is_denied_path_keyword(fs::path("/resources/certificates/ca.pem")));
|
||||
}
|
||||
|
||||
SECTION("a 'conf'/'config' directory or file component is denied")
|
||||
SECTION("a 'conf'/'config' directory or config file component is denied")
|
||||
{
|
||||
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/conf/settings.json")));
|
||||
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/config/settings.json")));
|
||||
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/plugin.conf")));
|
||||
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/plugin.ini")));
|
||||
}
|
||||
|
||||
SECTION("matching is case-insensitive")
|
||||
{
|
||||
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/SECRETS/token.txt")));
|
||||
CHECK(mgr.is_denied_path_keyword(fs::path("/resources/CertBundle/ca.pem")));
|
||||
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/CONFIG.JSON")));
|
||||
CHECK(mgr.is_denied_path_keyword(fs::path("/resources/Certificates/ca.pem")));
|
||||
CHECK(mgr.is_denied_path_keyword(fs::path("/plugin/PLUGIN.CONF")));
|
||||
}
|
||||
|
||||
SECTION("matching is not limited to the base name -- any ancestor component counts")
|
||||
@@ -245,6 +246,14 @@ TEST_CASE("Plugin audit denies secret/certificate/config-like paths by keyword",
|
||||
{
|
||||
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/output/model.gcode")));
|
||||
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/plugin/storage/state.json")));
|
||||
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/numpy/__config__.py")));
|
||||
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/numpy/_core/_ufunc_config.py")));
|
||||
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/configparser.py")));
|
||||
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/sysconfig.py")));
|
||||
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/Lib/logging/config.py")));
|
||||
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/python/packages/cp312/certifi/cacert.pem")));
|
||||
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/users/Conference/output.txt")));
|
||||
CHECK_FALSE(mgr.is_denied_path_keyword(fs::path("/users/Concert/output.txt")));
|
||||
}
|
||||
|
||||
SECTION("an empty path is not denied")
|
||||
|
||||
@@ -133,7 +133,7 @@ TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initi
|
||||
{
|
||||
py::object host = import_orca_module().attr("host");
|
||||
|
||||
for (const char* function_name : { "preset_bundle", "plater", "model" }) {
|
||||
for (const char* function_name : { "preset_bundle", "plater", "model", "app_info", "selected_printer" }) {
|
||||
CAPTURE(function_name);
|
||||
try {
|
||||
host.attr(function_name)();
|
||||
@@ -145,6 +145,17 @@ TEST_CASE("Plugin host API reports unavailable GUI objects before Orca app initi
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Plugin host API reports no GL details before the 3D view exists", "[PluginHost][Python]")
|
||||
{
|
||||
py::object host = import_orca_module().attr("host");
|
||||
|
||||
CHECK(host.attr("gl_info")().is_none());
|
||||
|
||||
py::object plater_type = host.attr("Plater");
|
||||
CHECK(has_attr(plater_type, "project_path"));
|
||||
CHECK(has_attr(plater_type, "export_3mf_copy"));
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -122,6 +122,7 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins
|
||||
state.permissions.network_http = {"https://api.example.com"};
|
||||
state.permissions.network_socket = {"192.168.45.6:443"};
|
||||
state.permissions.process = {"/usr/bin/curl"};
|
||||
state.permissions.threading = {"thread"};
|
||||
REQUIRE(write_install_state(plugin_dir, state));
|
||||
|
||||
// Permission data is persisted in the same sidecar as the installation metadata.
|
||||
@@ -132,6 +133,7 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins
|
||||
CHECK(persisted.permissions.network_http == state.permissions.network_http);
|
||||
CHECK(persisted.permissions.network_socket == state.permissions.network_socket);
|
||||
CHECK(persisted.permissions.process == state.permissions.process);
|
||||
CHECK(persisted.permissions.threading == state.permissions.threading);
|
||||
|
||||
// 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
|
||||
@@ -142,3 +144,43 @@ TEST_CASE("install-state sidecar is the source of truth for a cloud plugin's ins
|
||||
read_install_state(plugin_dir, scanned);
|
||||
CHECK(scanned.installed_version == "1.2.0");
|
||||
}
|
||||
|
||||
TEST_CASE("a local plugin's installed version follows its entry file header", "[PluginInstall]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("local-installed-version");
|
||||
|
||||
const fs::path plugin_dir = data_dir_guard.dir / "plugin";
|
||||
fs::create_directories(plugin_dir);
|
||||
|
||||
// Sidecar written when the local plugin was first installed at 1.0.0.
|
||||
PluginInstallState state;
|
||||
state.installed_from = "local";
|
||||
state.installed_version = "1.0.0";
|
||||
REQUIRE(write_install_state(plugin_dir, state));
|
||||
|
||||
// The user then edited the plugin file: the freshly scanned header says 1.1.0.
|
||||
PluginDescriptor scanned;
|
||||
scanned.version = "1.1.0";
|
||||
REQUIRE(read_install_state(plugin_dir, scanned));
|
||||
CHECK(scanned.installed_version == "1.1.0");
|
||||
}
|
||||
|
||||
TEST_CASE("an unsubscribed cloud plugin follows its header version", "[PluginInstall]")
|
||||
{
|
||||
ScopedDataDir data_dir_guard("unsubscribed-installed-version");
|
||||
|
||||
const fs::path plugin_dir = data_dir_guard.dir / "plugin";
|
||||
fs::create_directories(plugin_dir);
|
||||
|
||||
// What keep_installed_plugin_as_local leaves behind: a normal local package. The sidecar still
|
||||
// holds the version fetched from the cloud (1.2.0), but the header (1.0.0) is the source of truth.
|
||||
PluginInstallState state;
|
||||
state.installed_from = "local";
|
||||
state.installed_version = "1.2.0";
|
||||
REQUIRE(write_install_state(plugin_dir, state));
|
||||
|
||||
PluginDescriptor scanned;
|
||||
scanned.version = "1.0.0";
|
||||
REQUIRE(read_install_state(plugin_dir, scanned));
|
||||
CHECK(scanned.installed_version == "1.0.0");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#include <catch2/catch_all.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
|
||||
#include <slic3r/plugin/PluginFsUtils.hpp>
|
||||
#include <slic3r/plugin/PluginManager.hpp>
|
||||
#include <slic3r/plugin/PythonInterpreter.hpp>
|
||||
|
||||
#include "plugin_test_utils.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <exception>
|
||||
#include <utility>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <pybind11/embed.h>
|
||||
#include <pybind11/gil.h>
|
||||
#include <pybind11/pytypes.h>
|
||||
|
||||
using namespace Slic3r;
|
||||
|
||||
namespace {
|
||||
// Brings the embedded interpreter up for one test and tears it down before boost::log does,
|
||||
// mirroring the ScopedPluginManager idiom in the other plugin tests.
|
||||
struct ScopedPluginManager
|
||||
{
|
||||
ScopedDataDir python_data_dir{"plugin-json-depth"};
|
||||
bool initialized = PluginManager::instance().initialize();
|
||||
~ScopedPluginManager()
|
||||
{
|
||||
PluginManager::instance().shutdown();
|
||||
PythonInterpreter::instance().shutdown();
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("py_to_json raises instead of overflowing on pathologically deep input", "[PluginHost][Python]")
|
||||
{
|
||||
ScopedPluginManager manager;
|
||||
REQUIRE(manager.initialized);
|
||||
namespace py = pybind11;
|
||||
py::gil_scoped_acquire gil;
|
||||
|
||||
// [[[ ... 0 ... ]]] nested 300 deep: past the 200 conversion-depth cap, but shallow enough
|
||||
// that the pre-fix code returns without crashing, so a regression fails cleanly rather than
|
||||
// taking the process down. Built in C++ so the test does not depend on Python builtins.
|
||||
py::object deep = py::int_(0);
|
||||
for (int i = 0; i < 300; ++i) {
|
||||
py::list wrapper;
|
||||
wrapper.append(deep);
|
||||
deep = std::move(wrapper);
|
||||
}
|
||||
CHECK_THROWS_AS(py_to_json(deep), std::exception);
|
||||
}
|
||||
|
||||
TEST_CASE("py_to_json still converts reasonably nested input", "[PluginHost][Python]")
|
||||
{
|
||||
ScopedPluginManager manager;
|
||||
REQUIRE(manager.initialized);
|
||||
namespace py = pybind11;
|
||||
py::gil_scoped_acquire gil;
|
||||
|
||||
py::dict d;
|
||||
d["a"] = py::int_(1);
|
||||
py::list inner;
|
||||
inner.append(py::str("x"));
|
||||
inner.append(py::int_(2));
|
||||
d["b"] = inner;
|
||||
|
||||
const nlohmann::json j = py_to_json(d);
|
||||
CHECK(j.at("a").get<std::int64_t>() == 1);
|
||||
CHECK(j.at("b").at(0).get<std::string>() == "x");
|
||||
CHECK(j.at("b").at(1).get<std::int64_t>() == 2);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
// The texture displacement gizmo's palette helpers live in libslic3r_gui; this is the suite that links it.
|
||||
// Same Windows include prologue as test_filament_bitmap_utils.cpp (wx pulls in <windows.h>; keep
|
||||
// WIN32_LEAN_AND_MEAN / NOMINMAX ahead of the Catch2 headers).
|
||||
#ifdef WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#ifndef NOMINMAX
|
||||
#define NOMINMAX
|
||||
#endif
|
||||
#include <Windows.h>
|
||||
#endif
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <ios>
|
||||
#include <iterator>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include <catch2/catch_all.hpp>
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
#include "libslic3r/Color.hpp"
|
||||
#include "libslic3r/FilamentMixer.hpp"
|
||||
#include "libslic3r/PNGReadWrite.hpp"
|
||||
#include "libslic3r/Point.hpp"
|
||||
#include "libslic3r/TextureDisplacement.hpp"
|
||||
#include "libslic3r/TriangleMesh.hpp"
|
||||
#include "libslic3r/TriangleSelector.hpp"
|
||||
#include "slic3r/GUI/Gizmos/GLGizmoTextureDisplacement.hpp"
|
||||
#include "slic3r/Utils/ColorSpaceConvert.hpp"
|
||||
#include "test_utils.hpp"
|
||||
|
||||
using namespace Slic3r;
|
||||
using Catch::Matchers::WithinAbs;
|
||||
using Gizmo = Slic3r::GUI::GLGizmoTextureDisplacement;
|
||||
using Entry = Gizmo::PaletteEntry;
|
||||
using MixTarget = Gizmo::MixTarget;
|
||||
|
||||
namespace {
|
||||
|
||||
const ColorRGBA BLACK{ 0.f, 0.f, 0.f, 1.f };
|
||||
const ColorRGBA WHITE{ 1.f, 1.f, 1.f, 1.f };
|
||||
const ColorRGBA RED{ 1.f, 0.f, 0.f, 1.f };
|
||||
const ColorRGBA BLUE{ 0.f, 0.f, 1.f, 1.f };
|
||||
const ColorRGBA YELLOW{ 1.f, 1.f, 0.f, 1.f };
|
||||
|
||||
MixTarget target(const Vec3f &rgb, float weight)
|
||||
{
|
||||
MixTarget t;
|
||||
RGB2Lab(rgb.x(), rgb.y(), rgb.z(), &t.lab.x(), &t.lab.y(), &t.lab.z());
|
||||
t.weight = weight;
|
||||
return t;
|
||||
}
|
||||
|
||||
// The colour a mixed slot of these two filaments shows, as the sidebar computes it.
|
||||
Vec3f slot_color(const ColorRGBA &a, const ColorRGBA &b, int a_percent)
|
||||
{
|
||||
ColorRGB c;
|
||||
REQUIRE(decode_color(blend_color_multi({ encode_color(a), encode_color(b) }, { a_percent, 100 - a_percent }), c));
|
||||
return Vec3f(c.r(), c.g(), c.b());
|
||||
}
|
||||
|
||||
// A colour image layer, through Slic3r's own PNG writer so decode_height_texture() reads it the way it
|
||||
// reads an imported texture.
|
||||
TextureDisplacementLayer color_layer(int w, int h, const std::vector<uint8_t> &rgb)
|
||||
{
|
||||
ScopedTemporaryFile png(".png");
|
||||
REQUIRE(png::write_rgb_to_file(png.string(), size_t(w), size_t(h), rgb));
|
||||
std::ifstream in(png.string(), std::ios::binary);
|
||||
std::vector<unsigned char> bytes{ std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>() };
|
||||
REQUIRE_FALSE(bytes.empty());
|
||||
|
||||
TextureDisplacementLayer layer;
|
||||
layer.image_data = std::make_shared<std::vector<unsigned char>>(std::move(bytes));
|
||||
layer.color_enabled = true;
|
||||
return layer;
|
||||
}
|
||||
|
||||
// A red/green ramp over a fixed blue: colours spread over many bins, so the image is not flat-colour.
|
||||
TextureDisplacementLayer gradient_layer()
|
||||
{
|
||||
const int n = 64;
|
||||
std::vector<uint8_t> rgb;
|
||||
for (int y = 0; y < n; ++y)
|
||||
for (int x = 0; x < n; ++x) {
|
||||
rgb.push_back(uint8_t(x * 4));
|
||||
rgb.push_back(uint8_t(y * 4));
|
||||
rgb.push_back(128);
|
||||
}
|
||||
return color_layer(n, n, rgb);
|
||||
}
|
||||
|
||||
// A 2048x1100 image, over mix_targets()' sampling budget, that is pure red wherever `red(x, y)` holds and
|
||||
// elsewhere a gradient spread over far more than eight coarse bins, so the image is not flat-colour.
|
||||
template<class RedFn> TextureDisplacementLayer striped_layer(RedFn red)
|
||||
{
|
||||
const int w = 2048, h = 1100;
|
||||
std::vector<uint8_t> rgb;
|
||||
rgb.reserve(size_t(w) * size_t(h) * 3);
|
||||
for (int y = 0; y < h; ++y)
|
||||
for (int x = 0; x < w; ++x) {
|
||||
const bool is_red = red(x, y);
|
||||
rgb.push_back(is_red ? 255 : uint8_t(x * 255 / w));
|
||||
rgb.push_back(is_red ? 0 : uint8_t(y * 255 / h));
|
||||
rgb.push_back(is_red ? 0 : 128);
|
||||
}
|
||||
return color_layer(w, h, rgb);
|
||||
}
|
||||
|
||||
// How much of the targets' weight is pure red.
|
||||
float red_weight(const std::vector<MixTarget> &targets)
|
||||
{
|
||||
Vec3f red;
|
||||
RGB2Lab(1.f, 0.f, 0.f, &red.x(), &red.y(), &red.z());
|
||||
float weight = 0.f;
|
||||
for (const MixTarget &t : targets)
|
||||
if ((t.lab - red).norm() < 3.f)
|
||||
weight += t.weight;
|
||||
return weight;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("mix targets cover each colouring photo layer once and skip the rest", "[TextureColorPalette][TextureDisplacement]")
|
||||
{
|
||||
TextureDisplacementLayer photo = gradient_layer();
|
||||
|
||||
const std::vector<MixTarget> targets = Gizmo::mix_targets({ photo });
|
||||
REQUIRE_FALSE(targets.empty());
|
||||
float total = 0.f;
|
||||
for (const MixTarget &t : targets)
|
||||
total += t.weight;
|
||||
REQUIRE_THAT(total, WithinAbs(1., 1e-4));
|
||||
|
||||
SECTION("A layer that does not colour gives no targets") {
|
||||
photo.color_enabled = false;
|
||||
REQUIRE(Gizmo::mix_targets({ photo }).empty());
|
||||
}
|
||||
SECTION("A flat-colour image prints in single filaments, so it gives no targets") {
|
||||
const TextureDisplacementLayer flat = color_layer(8, 8, std::vector<uint8_t>(8 * 8 * 3, 200));
|
||||
REQUIRE(Gizmo::mix_targets({ flat }).empty());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("mix targets of a large image weigh each colour by its share, whatever its stripes", "[TextureColorPalette][TextureDisplacement]")
|
||||
{
|
||||
// Sampled rather than read in full, these must not line up with the samples: a fixed sampling step
|
||||
// sees only one phase of a stripe pattern, and a generator whose offsets repeat sees only some.
|
||||
SECTION("Red on every other column") {
|
||||
CHECK_THAT(red_weight(Gizmo::mix_targets({ striped_layer([](int x, int) { return x % 2 == 0; }) })),
|
||||
WithinAbs(1. / 2., 0.03));
|
||||
}
|
||||
SECTION("Red on every fourth column") {
|
||||
CHECK_THAT(red_weight(Gizmo::mix_targets({ striped_layer([](int x, int) { return x % 4 == 0; }) })),
|
||||
WithinAbs(1. / 4., 0.03));
|
||||
}
|
||||
SECTION("Red on diagonals") {
|
||||
CHECK_THAT(red_weight(Gizmo::mix_targets({ striped_layer([](int x, int y) { return (x - y) % 3 == 0; }) })),
|
||||
WithinAbs(1. / 3., 0.03));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("the mix ranked first is the one the image needs most", "[TextureColorPalette][TextureDisplacement]")
|
||||
{
|
||||
// The whole image is exactly the colour of a 1:1 black/white slot.
|
||||
const std::vector<MixTarget> targets = { target(slot_color(BLACK, WHITE, 50), 1.f) };
|
||||
const std::vector<Entry> ranked = Gizmo::rank_mixes({ BLACK, WHITE }, targets, 1);
|
||||
REQUIRE(ranked.size() == 1);
|
||||
CHECK(ranked.front().a == 0);
|
||||
CHECK(ranked.front().b == 1);
|
||||
CHECK(ranked.front().num * 2 == ranked.front().den);
|
||||
}
|
||||
|
||||
TEST_CASE("an image the filaments already match ranks no mixes", "[TextureColorPalette][TextureDisplacement]")
|
||||
{
|
||||
const std::vector<MixTarget> targets = { target(Vec3f(1.f, 0.f, 0.f), 0.5f), target(Vec3f(0.f, 0.f, 1.f), 0.5f) };
|
||||
REQUIRE(Gizmo::rank_mixes({ RED, BLUE }, targets, 8).empty());
|
||||
}
|
||||
|
||||
TEST_CASE("ranked mixes stay within the limit and show their slot's colour", "[TextureColorPalette][TextureDisplacement]")
|
||||
{
|
||||
const std::vector<ColorRGBA> filaments = { RED, BLUE, YELLOW };
|
||||
const std::vector<MixTarget> targets = Gizmo::mix_targets({ gradient_layer() });
|
||||
|
||||
for (const int limit : { 1, 3 }) {
|
||||
const std::vector<Entry> ranked = Gizmo::rank_mixes(filaments, targets, limit);
|
||||
CHECK(int(ranked.size()) <= limit);
|
||||
CHECK_FALSE(ranked.empty());
|
||||
}
|
||||
|
||||
const std::vector<Entry> ranked = Gizmo::rank_mixes(filaments, targets, 6);
|
||||
for (const Entry &e : ranked) {
|
||||
REQUIRE(e.is_mix());
|
||||
const Vec3f expected = slot_color(filaments[size_t(e.a)], filaments[size_t(e.b)], e.a_percent());
|
||||
CHECK_THAT(e.rgb.x(), WithinAbs(expected.x(), 1e-6));
|
||||
CHECK_THAT(e.rgb.y(), WithinAbs(expected.y(), 1e-6));
|
||||
CHECK_THAT(e.rgb.z(), WithinAbs(expected.z(), 1e-6));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("picked mixes never need more slots than the project has free", "[TextureColorPalette][TextureDisplacement]")
|
||||
{
|
||||
const std::vector<Entry> ranking = { { Vec3f::Zero(), 0, 1, 1, 2 }, { Vec3f::Zero(), 0, 1, 1, 3 }, { Vec3f::Zero(), 0, 1, 2, 3 } };
|
||||
const auto reusable_second = [](const Entry &e) { return e.num == 1 && e.den == 3; };
|
||||
|
||||
SECTION("The count caps the pick") {
|
||||
REQUIRE(Gizmo::pick_mixes(ranking, 2, 10, nullptr).size() == 2);
|
||||
}
|
||||
SECTION("With no free slot only a mix that already has one is kept") {
|
||||
const std::vector<Entry> picked = Gizmo::pick_mixes(ranking, 3, 0, reusable_second);
|
||||
REQUIRE(picked.size() == 1);
|
||||
CHECK(picked.front().den == 3);
|
||||
CHECK(picked.front().num == 1);
|
||||
}
|
||||
SECTION("A reusable mix costs no free slot") {
|
||||
const std::vector<Entry> picked = Gizmo::pick_mixes(ranking, 3, 1, reusable_second);
|
||||
REQUIRE(picked.size() == 2);
|
||||
CHECK(picked[0].den == 2);
|
||||
CHECK(picked[1].den == 3);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("a baked mix paints the slot it was given, wherever that slot sits", "[TextureColorPalette][TextureDisplacement]")
|
||||
{
|
||||
// Two filaments, then two mixes of them. Palette index 2 is a mix, but its slot need not be
|
||||
// filament 2: a project that already holds other mixed slots puts it further along.
|
||||
const std::vector<Entry> palette = Gizmo::make_palette({ BLACK, WHITE }, { { Vec3f::Zero(), 0, 1, 1, 2 }, { Vec3f::Zero(), 0, 1, 1, 3 } });
|
||||
REQUIRE(palette.size() == 4);
|
||||
// Filament 0, the 1:1 mix twice, nothing: the 1:2 mix is never used.
|
||||
const std::vector<uint8_t> triangle_color = { 1, 3, 3, 0 };
|
||||
|
||||
std::vector<Entry> asked;
|
||||
const auto slot_seven = [&asked](const Entry &e) {
|
||||
asked.push_back(e);
|
||||
return 7;
|
||||
};
|
||||
const std::vector<int> filament = Gizmo::palette_filaments(palette, triangle_color, slot_seven);
|
||||
REQUIRE(filament.size() == 4);
|
||||
CHECK(filament[0] == 0);
|
||||
CHECK(filament[1] == 1);
|
||||
CHECK(filament[2] == 7);
|
||||
CHECK(filament[3] == -1);
|
||||
// Asking creates a slot, so an unused mix is never asked for.
|
||||
REQUIRE(asked.size() == 1);
|
||||
CHECK(asked.front().den == 2);
|
||||
|
||||
SECTION("A mix that gets no slot prints in its dominant component") {
|
||||
const std::vector<int> fallback = Gizmo::palette_filaments(palette, { 3, 4 }, [](const Entry &) { return -1; });
|
||||
CHECK(fallback[2] == 0); // 1:1 - the first component
|
||||
CHECK(fallback[3] == 1); // 1 part black in 3 - white dominates
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("the model's colour paint is drawn by filament, and only where no layer paint covers it", "[TextureColorPalette][TextureDisplacement]")
|
||||
{
|
||||
// A strip of four triangles: filament 2 on the first and last, filament 5 on the second, and the third
|
||||
// left to the volume's own filament.
|
||||
indexed_triangle_set strip;
|
||||
strip.vertices = { Vec3f(0, 0, 0), Vec3f(1, 0, 0), Vec3f(0, 1, 0), Vec3f(1, 1, 0), Vec3f(0, 2, 0), Vec3f(1, 2, 0) };
|
||||
strip.indices = { stl_triangle_vertex_indices(0, 1, 2), stl_triangle_vertex_indices(1, 3, 2), stl_triangle_vertex_indices(2, 3, 4),
|
||||
stl_triangle_vertex_indices(3, 5, 4) };
|
||||
const TriangleMesh mesh(strip);
|
||||
TriangleSelector paint(mesh);
|
||||
paint.set_facet(0, EnforcerBlockerType(2));
|
||||
paint.set_facet(1, EnforcerBlockerType(5));
|
||||
paint.set_facet(3, EnforcerBlockerType(2));
|
||||
|
||||
const Gizmo::PaintedColors colors = Gizmo::painted_colors(mesh, paint.serialize());
|
||||
// Grouped by filament; the triangle in the volume's own filament is never drawn.
|
||||
REQUIRE(colors.facets.indices.size() == 3);
|
||||
REQUIRE(colors.source.size() == 3);
|
||||
REQUIRE(colors.state.size() == 3);
|
||||
CHECK(colors.state == std::vector<int>{ 2, 2, 5 });
|
||||
CHECK(colors.source == std::vector<int>{ 0, 3, 1 });
|
||||
|
||||
SECTION("A model triangle a layer's paint covers is left to the preview") {
|
||||
std::vector<bool> excluded(mesh.its.indices.size(), false);
|
||||
excluded[3] = true;
|
||||
const std::vector<size_t> kept = colors.outside(excluded);
|
||||
REQUIRE(kept.size() == 2);
|
||||
CHECK(colors.source[kept[0]] == 0);
|
||||
CHECK(colors.source[kept[1]] == 1);
|
||||
}
|
||||
SECTION("A mask shorter than the model leaves the rest drawn") {
|
||||
CHECK(colors.outside({ true }).size() == 2);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user