fix: send the AMS tray's own coordinates on tray selection

command_ams_select_tray split the BBL tray id into ams_id/slot_id inline and untested. Extract build_ams_change_filament_body so the mapping is a named unit that command_ams_select_tray routes verbatim, and lock it with a test: tray 9 (a 6-slot box's slot 5) sends (2, 1), never a fabricated flat lane.
This commit is contained in:
Lam Wei Lun
2026-09-23 17:00:39 +08:00
470 changed files with 26483 additions and 7965 deletions
+4
View File
@@ -7,6 +7,8 @@ add_executable(${_TEST_NAME}_tests
test_filament_bitmap_utils.cpp
test_device_progress.cpp
test_device_manager.cpp
test_device_manager_integration.cpp
test_web_media_controller.cpp
test_network_versions.cpp
test_action_source.cpp
test_plugin_host_api.cpp
@@ -20,11 +22,13 @@ add_executable(${_TEST_NAME}_tests
test_orca_printer_agent.cpp
test_plugin_install.cpp
test_plugin_lifecycle.cpp
test_plugin_printer_agent.cpp
test_slicing_pipeline_bindings.cpp
test_slicing_pipeline_config.cpp
test_plugin_sort.cpp
test_plugin_cloud_metadata.cpp
test_plugin_audit.cpp
test_shortcuts.cpp
../fff_print/test_helpers.cpp
)
@@ -0,0 +1,159 @@
#include <catch2/catch_all.hpp>
#include <slic3r/GUI/DeviceCore/DevManager.h>
#include <slic3r/GUI/DeviceManager.hpp>
#include <libslic3r/AppConfig.hpp>
#include <slic3r/Utils/NetworkAgent.hpp>
#include <slic3r/Utils/OrcaCloudServiceAgent.hpp>
#include <slic3r/Utils/OrcaPrinterAgent.hpp>
#include <nlohmann/json.hpp>
#include <memory>
#include <cstdint>
#include <string>
#include <utility>
using namespace Slic3r;
using json = nlohmann::json;
namespace {
class StubCloudAgent final : public OrcaCloudServiceAgent
{
public:
StubCloudAgent() : OrcaCloudServiceAgent("") {}
int get_user_print_info(unsigned int* http_code, std::string* http_body) override
{
if (http_code)
*http_code = 200;
if (http_body)
*http_body = R"({"devices":[]})";
return 0;
}
std::string get_user_name() override { return "integration-test-user"; }
};
class TestPrinterAgent final : public OrcaPrinterAgent
{
public:
explicit TestPrinterAgent(std::string id)
: OrcaPrinterAgent(""), m_info{std::move(id), "Integration Test Agent", "1.0", "test agent"}
{
}
AgentInfo get_agent_info() override { return m_info; }
private:
AgentInfo m_info;
};
struct ScopedAppConfig
{
AppConfig config;
};
std::string machine_list_response(const std::string& provider, const std::string& agent_id,
std::uint64_t generation, const std::string& name)
{
json machine;
machine["dev_id"] = "integration-device";
machine["dev_name"] = name;
machine["dev_online"] = true;
machine["task_status"] = "idle";
json response;
response["provider"] = provider;
response["agent_id"] = agent_id;
response["generation"] = generation;
response["devices"] = json::array({machine});
return response.dump();
}
} // namespace
TEST_CASE("Network agent stamps user-machine responses with request context", "[DeviceManager][integration]")
{
auto cloud = std::make_shared<StubCloudAgent>();
NetworkAgent network(cloud, nullptr);
network.set_printer_agent(std::make_shared<TestPrinterAgent>("integration-agent-a"));
const std::uint64_t generation_before = network.get_user_machine_list_generation();
unsigned int http_code = 0;
std::string body;
REQUIRE(network.get_user_print_info(&http_code, &body, ORCA_CLOUD_PROVIDER) == 0);
const json response = json::parse(body);
CHECK(http_code == 200);
CHECK(response["provider"] == ORCA_CLOUD_PROVIDER);
CHECK(response["agent_id"] == "integration-agent-a");
CHECK(response["generation"] == generation_before + 1);
}
TEST_CASE("Device manager ignores stale cloud machine responses", "[DeviceManager][integration]")
{
ScopedAppConfig app_config;
NetworkAgent network(nullptr, std::make_shared<TestPrinterAgent>("integration-agent"));
network.set_printer_agent(std::make_shared<TestPrinterAgent>("integration-agent"));
DeviceManager manager(&network, false, &app_config.config);
const std::uint64_t current_generation = network.get_user_machine_list_generation();
manager.parse_user_print_info(machine_list_response(ORCA_CLOUD_PROVIDER, "integration-agent",
current_generation, "Fresh name"));
auto machines = manager.get_user_machinelist();
REQUIRE(machines.size() == 1);
REQUIRE(machines.at("integration-device") != nullptr);
CHECK(machines.at("integration-device")->get_dev_name() == "Fresh name");
manager.parse_user_print_info(machine_list_response(BBL_CLOUD_PROVIDER, "integration-agent",
current_generation, "Stale provider"));
manager.parse_user_print_info(machine_list_response(ORCA_CLOUD_PROVIDER, "other-agent",
current_generation, "Stale agent"));
manager.parse_user_print_info(machine_list_response(ORCA_CLOUD_PROVIDER, "integration-agent",
current_generation + 1, "Stale generation"));
machines = manager.get_user_machinelist();
REQUIRE(machines.size() == 1);
CHECK(machines.at("integration-device")->get_dev_name() == "Fresh name");
}
TEST_CASE("Device manager filters and rehomes devices by printer-agent ownership", "[DeviceManager][integration]")
{
ScopedAppConfig app_config;
auto agent_a = std::make_shared<TestPrinterAgent>("integration-agent-a");
auto agent_b = std::make_shared<TestPrinterAgent>("integration-agent-b");
NetworkAgent network(nullptr, agent_a);
DeviceManager manager(&network, false, &app_config.config);
BBLocalMachine machine;
machine.dev_id = "integration-lan-device";
machine.dev_name = "Integration LAN device";
machine.dev_ip = "192.0.2.10";
machine.printer_type = "C11";
MachineObject* object = manager.insert_local_device(machine, "lan", "free", "", "access-code");
REQUIRE(object != nullptr);
CHECK(object->printer_agent_id == "integration-agent-a");
CHECK(manager.get_my_machine_list("integration-agent-a").count(machine.dev_id) == 1);
CHECK(manager.get_my_machine_list("integration-agent-b").empty());
network.set_printer_agent(agent_b);
CHECK(manager.get_my_machine_list("integration-agent-b").empty());
manager.on_machine_alive(R"({
"dev_name":"Rediscovered device",
"dev_id":"integration-lan-device",
"dev_ip":"192.0.2.10",
"dev_type":"C11",
"dev_signal":"strong",
"connect_type":"lan",
"bind_state":"free"
})");
CHECK(object->printer_agent_id == "integration-agent-b");
CHECK(manager.get_my_machine_list("integration-agent-a").empty());
CHECK(manager.get_my_machine_list("integration-agent-b").count(machine.dev_id) == 1);
}
+1 -1
View File
@@ -115,7 +115,7 @@ TEST_CASE("Malformed string progress leaves a fresh machine unchanged", "[Device
MachineObject machine(nullptr, nullptr, "test", "test-device", "127.0.0.1");
REQUIRE(machine.subtask_ == nullptr);
CHECK_THROWS_AS(machine.update_print_progress(json("not-a-percent")), std::invalid_argument);
CHECK_NOTHROW(machine.update_print_progress(json("not-a-percent")));
CHECK(machine.mc_print_percent == 0);
CHECK(machine.subtask_ == nullptr);
}
@@ -423,3 +423,21 @@ TEST_CASE("a filament_slots reply admits ams_filament_setting without ams_ops",
&unsupported);
CHECK(unsupported);
}
// A box wider than 4 slots is shown as several 4-tray units, so the BBL tray id
// a panel reports must split into that unit's own (ams_id, slot_id). A flat lane
// would be the tray id, which is not the layout lane for a wide box
// (REQ-STS-008 §7.8).
TEST_CASE("an AMS tray selection sends the tray's ams_id and slot_id", "[OrcaPrinterAgent]") {
// Unit 2 tray 1 is BBL tray 9: a 6-slot box's slot 5 addressed as (2, 1).
auto body = nlohmann::json::parse(OrcaPrinterAgent::build_ams_change_filament_body(9, 123));
CHECK(body["print"]["selector"] == "lane");
CHECK(body["print"]["ams_id"] == 2);
CHECK(body["print"]["slot_id"] == 1);
CHECK_FALSE(body["print"].contains("lane"));
// Unit 0 tray 3 is tray 3.
body = nlohmann::json::parse(OrcaPrinterAgent::build_ams_change_filament_body(3, 124));
CHECK(body["print"]["ams_id"] == 0);
CHECK(body["print"]["slot_id"] == 3);
}
@@ -3,8 +3,12 @@
#include <libslic3r/Model.hpp>
#include <libslic3r/PresetBundle.hpp>
#include <libslic3r/TriangleMesh.hpp>
#include <slic3r/GUI/DockPanel.hpp>
#include <slic3r/GUI/AuiPaneLayout.hpp>
#include <slic3r/GUI/Widgets/WebHosting.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include "plugin_test_utils.hpp"
#include "python_test_support.hpp"
#include <pybind11/embed.h>
@@ -141,6 +145,8 @@ TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app i
CHECK(ui.attr("WINDOW_MODELESS").cast<long>() == 0);
CHECK(ui.attr("WINDOW_MODAL").cast<long>() == 1);
CHECK(has_attr(ui, "UiWindow"));
CHECK(has_attr(ui, "create_dock_panel"));
CHECK(has_attr(ui, "UiDockPanel"));
// 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.
@@ -151,6 +157,74 @@ TEST_CASE("Plugin host API exposes the UI module and guards it before Orca app i
CHECK(error.matches(PyExc_RuntimeError));
CHECK(std::string(error.what()).find("OrcaSlicer application is not initialized") != std::string::npos);
}
try {
ui.attr("create_dock_panel")("<p>panel</p>");
FAIL("orca.host.ui.create_dock_panel 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);
}
// Positional arguments follow create_window(): width and height come straight after the title.
try {
ui.attr("create_dock_panel")("<p>panel</p>", "Panel", 400, 300);
FAIL("orca.host.ui.create_dock_panel unexpectedly succeeded without a wx application");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_RuntimeError));
}
// An unknown dock position is rejected before the application is needed.
try {
ui.attr("create_dock_panel")("<p>panel</p>", py::arg("dock") = "top");
FAIL("orca.host.ui.create_dock_panel accepted an unknown dock position");
} catch (const py::error_already_set& error) {
CHECK(error.matches(PyExc_ValueError));
}
}
TEST_CASE("Plugin pane names identify the plugin and title without layout delimiters", "[PluginHost]")
{
using Slic3r::GUI::plugin_pane_name;
CHECK(plugin_pane_name("dock_demo", "Scene") == "plugin:dock_demo:Scene");
CHECK(plugin_pane_name("key", "a|b;c=d\\e").find_first_of("|;=\\") == std::string::npos);
}
TEST_CASE("A pane's saved layout entry is found by pane name", "[PluginHost]")
{
using Slic3r::GUI::aui_pane_layout_entry;
const std::string sidebar = "name=sidebar;caption=;state=2099196;dir=4;layer=0;row=0;pos=0;bestw=390;besth=900";
// The caption holds an escaped '|', which must not end the entry.
const std::string plugin = "name=plugin:demo:Scene;caption=Scene \\| stats;state=2099198;dir=2;layer=0;row=1;pos=0;bestw=320;besth=480";
const std::string layout = "layout3|" + sidebar + "|" + plugin + "|dock_size(4,0,0)=392|";
CHECK(aui_pane_layout_entry(layout, "plugin:demo:Scene") == plugin);
CHECK(aui_pane_layout_entry(layout, "sidebar") == sidebar);
CHECK(aui_pane_layout_entry(layout, "plugin:demo").empty());
CHECK(aui_pane_layout_entry("", "plugin:demo:Scene").empty());
}
TEST_CASE("A reloaded plugin page is recognised by its base URL, fragment aside", "[PluginHost]")
{
using namespace Slic3r::GUI::web_hosting;
// A resources path holding a space, which the web view reports escaped.
const Slic3r::ScopedResourcesDir resources("web content check");
// The swapped-in page, then after an in-page anchor and a reload.
CHECK(is_content_url(content_base_url()));
CHECK(is_content_url(content_base_url() + "#tab2"));
wxString escaped = content_base_url();
escaped.Replace(" ", "%20");
REQUIRE(escaped != content_base_url());
CHECK(is_content_url(escaped));
CHECK(is_content_url(escaped + "#tab2"));
// A page the plugin linked to keeps its own URL and must be left alone.
CHECK_FALSE(is_content_url(content_base_url() + "guide.html"));
CHECK_FALSE(is_content_url("https://example.com/"));
CHECK_FALSE(is_content_url(""));
}
TEST_CASE("Plugin host API exposes model geometry and structure to Python", "[PluginHost][Python]")
@@ -0,0 +1,177 @@
#include <catch2/catch_all.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include <slic3r/plugin/PythonPluginBridge.hpp>
#include <slic3r/plugin/PythonPluginInterface.hpp>
#include <slic3r/Utils/IPrinterAgent.hpp>
#include <pybind11/embed.h>
#include <pybind11/pybind11.h>
#include <memory>
#include <string>
namespace py = pybind11;
using namespace Slic3r;
namespace {
// Same idiom as ScopedPluginManager in test_plugin_lifecycle.cpp: the trampolines refuse to call
// into Python unless PythonInterpreter::instance() reports initialized.
struct ScopedPluginManager
{
bool initialized = PluginManager::instance().initialize();
~ScopedPluginManager()
{
PluginManager::instance().shutdown();
PythonInterpreter::instance().shutdown();
}
};
// The host reaches a printer agent plugin through IPrinterAgent, so the tests do too. The Python
// instance carries the overrides, so it has to outlive every call, as PluginInstanceHandle ensures
// in production.
struct Agent
{
py::object instance;
std::shared_ptr<IPrinterAgent> agent;
IPrinterAgent* operator->() const { return agent.get(); }
IPrinterAgent& operator*() const { return *agent; }
};
Agent make_agent(const std::string& body)
{
(void) PythonPluginBridge::instance(); // force the embedded module registration into the binary
py::dict globals;
globals["orca"] = py::module_::import("orca");
py::exec("class Agent(orca.printer_agent.PrinterAgentBase):\n"
" def get_name(self): return 'agent'\n" + body, globals);
py::object instance = globals["Agent"]();
auto capability = instance.cast<std::shared_ptr<PluginCapabilityInterface>>();
capability->set_audit_plugin_key("agent_plugin");
return {instance, std::dynamic_pointer_cast<IPrinterAgent>(capability)};
}
// One operation per return type and dispatch shape; the rest share their macro.
const std::string OPERATIONS[] = {"get_agent_info", "disconnect_printer", "start_discovery", "get_user_selected_machine",
"get_filament_sync_mode", "install_device_cert", "start_local_print", "request_bind_ticket"};
// What NetworkAgent answers when no printer agent is set.
void check_answers_like_no_agent(IPrinterAgent& agent)
{
std::string ticket = "untouched";
CHECK(agent.get_agent_info().id.empty());
CHECK(agent.disconnect_printer() == -1);
CHECK_FALSE(agent.start_discovery(true, false));
CHECK(agent.get_user_selected_machine().empty());
CHECK(agent.get_filament_sync_mode() == FilamentSyncMode::none);
CHECK_NOTHROW(agent.install_device_cert("dev", true));
CHECK(agent.start_local_print(PrintParams{}, nullptr, nullptr) == -1);
CHECK(agent.request_bind_ticket(&ticket) == -1);
CHECK(ticket == "untouched");
}
std::string define_all(const std::string& signature_tail, const std::string& statement)
{
std::string body;
for (const std::string& operation : OPERATIONS)
body += " def " + operation + "(self" + signature_tail + "): " + statement + "\n";
return body;
}
} // namespace
TEST_CASE("A printer agent operation that raises answers like a missing agent", "[PluginPrinterAgent][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
auto agent = make_agent(define_all(", *args", "raise RuntimeError('boom')"));
REQUIRE(agent.agent);
check_answers_like_no_agent(*agent);
// The interpreter stays usable.
CHECK(py::eval("1 + 1").cast<int>() == 2);
}
TEST_CASE("A printer agent that omits its operations answers like a missing agent", "[PluginPrinterAgent][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil;
auto agent = make_agent("");
REQUIRE(agent.agent);
check_answers_like_no_agent(*agent);
}
TEST_CASE("A printer agent uses IPrinterAgent defaults for omitted commands", "[PluginPrinterAgent][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil;
auto agent = make_agent(" def send_message(self, dev_id, json_str, qos, flag): return 7\n"
" def send_message_to_printer(self, dev_id, json_str, qos, flag): return 8\n");
REQUIRE(agent.agent);
CHECK(agent->command_xyz_abs("dev", 1, false) == 7);
CHECK(agent->command_set_nozzle("dev", 200, 2, true) == 8);
CHECK(agent->command_ams_refresh_rfid("dev", "tray", 3, false) == ORCA_NETWORK_ERR_CMD_NOT_SUPPORTED);
}
TEST_CASE("A printer agent operation returning the wrong type answers like a missing agent", "[PluginPrinterAgent][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil;
auto agent = make_agent(define_all(", *args", "return object()"));
REQUIRE(agent.agent);
check_answers_like_no_agent(*agent);
}
TEST_CASE("A working printer agent's answers reach the host unchanged", "[PluginPrinterAgent][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
py::gil_scoped_acquire gil;
auto agent = make_agent(" def get_agent_info(self): return orca.printer_agent.AgentInfo('id', 'name', '1', 'description')\n"
" def disconnect_printer(self): return 7\n"
" def start_discovery(self, start, sending): return start and not sending\n"
" def get_user_selected_machine(self): return 'machine'\n"
" def get_filament_sync_mode(self): return orca.printer_agent.FilamentSyncMode.Pull\n"
" def request_bind_ticket(self): return (3, 'ticket')\n"
" def bind_detect(self, dev_ip, sec_link, detect):\n"
" detect.dev_id = dev_ip\n"
" return 0\n");
REQUIRE(agent.agent);
std::string ticket;
detectResult detect;
CHECK(agent->get_agent_info().id == "id");
CHECK(agent->disconnect_printer() == 7);
CHECK(agent->start_discovery(true, false));
CHECK(agent->get_user_selected_machine() == "machine");
CHECK(agent->get_filament_sync_mode() == FilamentSyncMode::pull);
CHECK(agent->request_bind_ticket(&ticket) == 3);
CHECK(ticket == "ticket");
CHECK(agent->bind_detect("192.168.0.2", "secure", detect) == 0);
CHECK(detect.dev_id == "192.168.0.2");
}
+329
View File
@@ -0,0 +1,329 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include "libslic3r/AppConfig.hpp"
#include "slic3r/GUI/KeyChord.hpp"
#include "slic3r/GUI/Shortcuts.hpp"
#include <wx/event.h>
using namespace Slic3r;
using namespace Slic3r::GUI;
namespace {
wxKeyEvent key_event(wxEventType type, int key_code, int modifiers = wxMOD_NONE)
{
wxKeyEvent evt(type);
evt.m_keyCode = key_code;
evt.SetControlDown(modifiers & wxMOD_CONTROL);
evt.SetShiftDown(modifiers & wxMOD_SHIFT);
evt.SetAltDown(modifiers & wxMOD_ALT);
return evt;
}
} // namespace
TEST_CASE("KeyChord round-trips through its canonical text", "[Shortcuts]")
{
const auto [chord, text] = GENERATE(table<KeyChord, std::string>({
{ { 'N', wxMOD_CONTROL }, "Ctrl+N" },
{ { 'S', wxMOD_CONTROL | wxMOD_SHIFT }, "Ctrl+Shift+S" },
{ { WXK_RETURN, wxMOD_SHIFT | wxMOD_ALT }, "Shift+Alt+Enter" },
{ { WXK_TAB, wxMOD_SHIFT }, "Shift+Tab" },
{ { WXK_DELETE }, "Del" },
{ { WXK_F5 }, "F5" },
{ { WXK_F12, wxMOD_CONTROL }, "Ctrl+F12" },
{ { '+' }, "+" },
{ { '-', wxMOD_CONTROL }, "Ctrl+-" },
{ { '?' }, "?" },
{ { ',', wxMOD_CONTROL }, "Ctrl+," },
}));
CAPTURE(text);
CHECK(chord.to_string() == text);
REQUIRE(KeyChord::parse(text).has_value());
CHECK(*KeyChord::parse(text) == chord);
}
TEST_CASE("KeyChord::parse accepts aliases and rejects malformed text", "[Shortcuts]")
{
CHECK(KeyChord::parse("control+n") == KeyChord{ 'N', wxMOD_CONTROL });
CHECK(KeyChord::parse("Cmd+Shift+Delete") == KeyChord{ WXK_DELETE, wxMOD_CONTROL | wxMOD_SHIFT });
CHECK(KeyChord::parse("PageUp") == KeyChord{ WXK_PAGEUP });
CHECK(KeyChord::parse("f3") == KeyChord{ WXK_F3 });
CHECK_FALSE(KeyChord::parse("").has_value());
CHECK_FALSE(KeyChord::parse("Ctrl+").has_value());
CHECK_FALSE(KeyChord::parse("Meta+A").has_value());
CHECK_FALSE(KeyChord::parse("F25").has_value());
CHECK_FALSE(KeyChord::parse("Shift+/").has_value()); // Shift is part of the punctuation character
}
TEST_CASE("Key events normalize to the key-down key codes", "[Shortcuts]")
{
CHECK(KeyChord::from_event(key_event(wxEVT_KEY_DOWN, 'A', wxMOD_CONTROL)) == KeyChord{ 'A', wxMOD_CONTROL });
CHECK(KeyChord::from_event(key_event(wxEVT_KEY_DOWN, WXK_NUMPAD5, wxMOD_CONTROL)) == KeyChord{ '5', wxMOD_CONTROL });
CHECK(KeyChord::from_event(key_event(wxEVT_KEY_DOWN, WXK_NUMPAD_ADD)) == KeyChord{ '+' });
CHECK(KeyChord::from_event(key_event(wxEVT_KEY_DOWN, WXK_NUMPAD_PAGEUP)) == KeyChord{ WXK_PAGEUP });
CHECK_FALSE(KeyChord::from_event(key_event(wxEVT_KEY_DOWN, WXK_SHIFT, wxMOD_SHIFT)).valid());
CHECK_FALSE(KeyChord::from_event(key_event(wxEVT_KEY_DOWN, WXK_CONTROL, wxMOD_CONTROL)).valid());
CHECK(KeyChord::from_event(key_event(wxEVT_CHAR, 'a')) == KeyChord{ 'A' });
CHECK(KeyChord::from_event(key_event(wxEVT_CHAR, 'A', wxMOD_SHIFT)) == KeyChord{ 'A', wxMOD_SHIFT });
CHECK(KeyChord::from_event(key_event(wxEVT_CHAR, WXK_CONTROL_C, wxMOD_CONTROL)) == KeyChord{ 'C', wxMOD_CONTROL });
CHECK(KeyChord::from_event(key_event(wxEVT_CHAR, '+', wxMOD_SHIFT)) == KeyChord{ '+' });
CHECK(KeyChord::from_event(key_event(wxEVT_CHAR, WXK_DELETE)) == KeyChord{ WXK_DELETE });
CHECK_FALSE(KeyChord::from_event(key_event(wxEVT_CHAR, 0x444)).valid()); // a Cyrillic letter is not bindable
}
TEST_CASE("Punctuation chords are the ones matched on char events", "[Shortcuts]")
{
CHECK(KeyChord{ '+' }.is_punctuation());
CHECK(KeyChord{ '?' }.is_punctuation());
CHECK_FALSE(KeyChord{ 'A' }.is_punctuation());
CHECK_FALSE(KeyChord{ '1' }.is_punctuation());
CHECK_FALSE(KeyChord{ '=', wxMOD_CONTROL }.is_punctuation());
CHECK_FALSE(KeyChord{ WXK_DELETE }.is_punctuation());
}
TEST_CASE("Chords that only the char event can resolve are recognized", "[Shortcuts]")
{
CHECK(KeyChord{ '/', wxMOD_SHIFT }.needs_char_event());
CHECK(KeyChord{ '-' }.needs_char_event());
CHECK_FALSE(KeyChord{ '=', wxMOD_CONTROL }.needs_char_event());
CHECK_FALSE(KeyChord{ 'A', wxMOD_SHIFT }.needs_char_event());
CHECK_FALSE(KeyChord{ '1' }.needs_char_event());
CHECK_FALSE(KeyChord{ WXK_F5 }.needs_char_event());
}
TEST_CASE("Only modified or non-printable chords qualify as menu accelerators", "[Shortcuts]")
{
CHECK(KeyChord{ 'N', wxMOD_CONTROL }.is_menu_accelerator());
CHECK(KeyChord{ 'N', wxMOD_ALT }.is_menu_accelerator());
CHECK(KeyChord{ WXK_DELETE }.is_menu_accelerator());
CHECK(KeyChord{ WXK_F5 }.is_menu_accelerator());
CHECK_FALSE(KeyChord{ 'A' }.is_menu_accelerator());
CHECK_FALSE(KeyChord{ 'A', wxMOD_SHIFT }.is_menu_accelerator());
CHECK_FALSE(KeyChord{ '?' }.is_menu_accelerator());
CHECK_FALSE(KeyChord{ WXK_SPACE }.is_menu_accelerator());
CHECK_FALSE(KeyChord{}.is_menu_accelerator());
ShortcutRegistry registry;
CHECK(registry.accelerator(Shortcut::NewProject) == "Ctrl+N");
#ifdef __APPLE__
CHECK(registry.accelerator(Shortcut::DeleteSelected) == "Backspace");
#else
CHECK(registry.accelerator(Shortcut::DeleteSelected) == "Del");
#endif
CHECK(registry.accelerator(Shortcut::Arrange).empty());
CHECK(registry.accelerator(Shortcut::ArrangePlate).empty());
CHECK(registry.accelerator(Shortcut::KeyboardShortcuts).empty());
}
TEST_CASE("Chords convert to wx accelerator entries", "[Shortcuts]")
{
const wxAcceleratorEntry entry = KeyChord{ 'S', wxMOD_CONTROL | wxMOD_SHIFT }.to_accelerator_entry(42);
CHECK(entry.GetFlags() == (wxACCEL_CTRL | wxACCEL_SHIFT));
CHECK(entry.GetKeyCode() == 'S');
CHECK(entry.GetCommand() == 42);
const wxAcceleratorEntry bare = KeyChord{ WXK_BACK }.to_accelerator_entry(7);
CHECK(bare.GetFlags() == wxACCEL_NORMAL);
CHECK(bare.GetKeyCode() == WXK_BACK);
}
#ifndef __APPLE__
TEST_CASE("Display text matches the canonical text without translations", "[Shortcuts]")
{
CHECK(KeyChord{ WXK_DELETE, wxMOD_CONTROL | wxMOD_SHIFT }.display() == "Ctrl+Shift+Del");
CHECK(KeyChord{ WXK_DELETE, wxMOD_CONTROL | wxMOD_SHIFT }.display_parts() == std::vector<std::string>{ "Ctrl", "Shift", "Del" });
CHECK(KeyChord{ '+' }.display() == "+");
CHECK(KeyChord{ WXK_UP, wxMOD_SHIFT }.display() == "Shift+Arrow Up"); // the arrows keep the old dialog's names
CHECK(KeyChord{ WXK_UP, wxMOD_SHIFT }.to_string() == "Shift+Up");
CHECK(KeyChord{}.display().empty());
}
#endif
TEST_CASE("Every shortcut is listed under the section of its table row", "[Shortcuts]")
{
CHECK(shortcut_section(Shortcut::NewProject) == ShortcutSection::Project);
CHECK(shortcut_section(Shortcut::Publish3mf) == ShortcutSection::Project);
CHECK(shortcut_section(Shortcut::SlicePlate) == ShortcutSection::SlicingAndPrinting);
CHECK(shortcut_section(Shortcut::GizmoBrimEars) == ShortcutSection::Gizmos);
CHECK(shortcut_section(Shortcut::MovesSliderEnd) == ShortcutSection::Sliders);
CHECK(shortcut_section(Shortcut::ViewDefault) == ShortcutSection::Camera);
CHECK(shortcut_section(Shortcut::KeyboardShortcuts) == ShortcutSection::Application);
CHECK(std::string(section_name(ShortcutSection::SlicingAndPrinting)) == "Slicing and printing");
}
TEST_CASE("Default bindings never collide inside a context", "[Shortcuts]")
{
ShortcutRegistry registry;
for (size_t i = 0; i < size_t(Shortcut::Count); ++i) {
const Shortcut shortcut = Shortcut(i);
CAPTURE(shortcut_info(shortcut).key);
CHECK(registry.conflicts(shortcut, registry.binding(shortcut)).empty());
}
}
TEST_CASE("Shift and Ctrl variants of stepping shortcuts are left unbound", "[Shortcuts]")
{
ShortcutRegistry registry;
for (size_t i = 0; i < size_t(Shortcut::Count); ++i) {
const ShortcutInfo& info = shortcut_info(Shortcut(i));
if (!info.modifier_variants)
continue;
CAPTURE(info.key);
const KeyChord chord = registry.binding(info.id);
for (int modifier : { int(wxMOD_SHIFT), int(wxMOD_CONTROL), int(wxMOD_SHIFT | wxMOD_CONTROL) })
for (size_t c = 0; c < size_t(ShortcutContext::Count); ++c)
if (info.contexts & context_bit(ShortcutContext(c)))
CHECK_FALSE(registry.lookup(ShortcutContext(c), KeyChord{ chord.key, chord.modifiers | modifier }).has_value());
}
}
TEST_CASE("Lookups are scoped to the context of the key press", "[Shortcuts]")
{
ShortcutRegistry registry;
const KeyChord ctrl_n{ 'N', wxMOD_CONTROL };
const KeyChord ctrl_c{ 'C', wxMOD_CONTROL };
const KeyChord a{ 'A' };
const KeyChord c{ 'C' };
CHECK(registry.lookup(ShortcutContext::Global, ctrl_n) == Shortcut::NewProject);
CHECK_FALSE(registry.lookup(ShortcutContext::Plater, ctrl_n).has_value());
CHECK(registry.lookup(ShortcutContext::Plater, ctrl_c) == Shortcut::Copy);
CHECK(registry.lookup(ShortcutContext::ObjectList, ctrl_c) == Shortcut::Copy);
CHECK_FALSE(registry.lookup(ShortcutContext::Global, ctrl_c).has_value());
CHECK(registry.lookup(ShortcutContext::Plater, a) == Shortcut::Arrange);
CHECK_FALSE(registry.lookup(ShortcutContext::Preview, a).has_value());
CHECK(registry.lookup(ShortcutContext::Plater, c) == Shortcut::GizmoCut);
CHECK(registry.lookup(ShortcutContext::Preview, c) == Shortcut::ToggleGcodeWindow);
CHECK(registry.lookup(ShortcutContext::Painting, c) == Shortcut::PaintToolCircle);
}
TEST_CASE("Stepping shortcuts match with Shift or Ctrl added to their binding", "[Shortcuts]")
{
ShortcutRegistry registry;
using Match = ShortcutRegistry::Match;
auto same = [](const std::optional<Match>& match, Shortcut shortcut, int step_modifiers) {
return match.has_value() && match->shortcut == shortcut && match->step_modifiers == step_modifiers;
};
CHECK(same(registry.match(ShortcutContext::Preview, { WXK_UP }), Shortcut::LayerSliderUp, 0));
CHECK(same(registry.match(ShortcutContext::Preview, { WXK_UP, wxMOD_SHIFT }), Shortcut::LayerSliderUp, wxMOD_SHIFT));
CHECK(same(registry.match(ShortcutContext::Plater, { WXK_LEFT, wxMOD_CONTROL | wxMOD_SHIFT }), Shortcut::MoveSelectionLeft, wxMOD_CONTROL | wxMOD_SHIFT));
CHECK(same(registry.match(ShortcutContext::Plater, { 'A', wxMOD_SHIFT }), Shortcut::ArrangePlate, 0)); // an exact binding wins
CHECK_FALSE(registry.match(ShortcutContext::Plater, { 'Q', wxMOD_CONTROL }).has_value()); // Orient has no variants
CHECK_FALSE(registry.match(ShortcutContext::Preview, { WXK_UP, wxMOD_ALT }).has_value()); // Alt is not a step modifier
CHECK_FALSE(registry.match(ShortcutContext::Preview, { WXK_HOME, wxMOD_SHIFT }).has_value()); // Home has no variants
// A binding with Shift or Ctrl of its own has no steps.
registry.bind(Shortcut::LayerSliderUp, { WXK_UP, wxMOD_CONTROL });
CHECK(same(registry.match(ShortcutContext::Preview, { WXK_UP, wxMOD_CONTROL }), Shortcut::LayerSliderUp, 0));
CHECK_FALSE(registry.match(ShortcutContext::Preview, { WXK_UP, wxMOD_CONTROL | wxMOD_SHIFT }).has_value());
CHECK_FALSE(registry.match(ShortcutContext::Preview, { WXK_UP, wxMOD_SHIFT }).has_value());
// An exact binding on the combined step wins over it.
registry.bind(Shortcut::Arrange, { WXK_LEFT, wxMOD_CONTROL | wxMOD_SHIFT });
CHECK(same(registry.match(ShortcutContext::Plater, { WXK_LEFT, wxMOD_CONTROL | wxMOD_SHIFT }), Shortcut::Arrange, 0));
CHECK(same(registry.match(ShortcutContext::Plater, { WXK_LEFT, wxMOD_SHIFT }), Shortcut::MoveSelectionLeft, wxMOD_SHIFT));
}
TEST_CASE("Conflicts cover shared contexts and every Global shortcut", "[Shortcuts]")
{
ShortcutRegistry registry;
CHECK(registry.conflicts(Shortcut::Arrange, { 'N', wxMOD_CONTROL }) == std::vector<Shortcut>{ Shortcut::NewProject });
CHECK(registry.conflicts(Shortcut::NewProject, { 'A' }) == std::vector<Shortcut>{ Shortcut::Arrange });
CHECK(registry.conflicts(Shortcut::ToggleGcodeWindow, { 'C' }).empty());
CHECK(registry.conflicts(Shortcut::ZoomIn, { 'C' }) == std::vector<Shortcut>{ Shortcut::GizmoCut, Shortcut::ToggleGcodeWindow });
CHECK(registry.conflicts(Shortcut::Arrange, { 'A' }).empty()); // a shortcut never conflicts with itself
// Only the exact chord conflicts; the steps of a stepping shortcut are reserved instead.
CHECK(registry.conflicts(Shortcut::GoToLayer, { WXK_UP, wxMOD_SHIFT }).empty());
CHECK(registry.conflicts(Shortcut::LayerSliderUp, { 'G', wxMOD_SHIFT }) == std::vector<Shortcut>{ Shortcut::GoToLayer });
}
TEST_CASE("Shift and Ctrl with a stepping shortcut's key are reserved for its steps", "[Shortcuts]")
{
ShortcutRegistry registry;
CHECK(registry.step_owner(Shortcut::GoToLayer, { WXK_UP, wxMOD_SHIFT }) == Shortcut::LayerSliderUp);
CHECK(registry.step_owner(Shortcut::NewProject, { WXK_LEFT, wxMOD_CONTROL }) == Shortcut::MoveSelectionLeft); // Global shares every context
CHECK_FALSE(registry.step_owner(Shortcut::GoToLayer, { WXK_UP, wxMOD_CONTROL | wxMOD_SHIFT }).has_value()); // the combined step is free
CHECK_FALSE(registry.step_owner(Shortcut::MoveSelectionLeft, { WXK_LEFT, wxMOD_SHIFT }).has_value()); // its own step
CHECK_FALSE(registry.step_owner(Shortcut::PaintToolCircle, { WXK_UP, wxMOD_SHIFT }).has_value()); // Painting shares no context
registry.bind(Shortcut::LayerSliderUp, { WXK_UP, wxMOD_CONTROL });
CHECK_FALSE(registry.step_owner(Shortcut::GoToLayer, { WXK_UP, wxMOD_CONTROL | wxMOD_SHIFT }).has_value()); // a modified binding has no steps
}
TEST_CASE("Custom bindings replace the default and survive a config round trip", "[Shortcuts]")
{
ShortcutRegistry registry;
const KeyChord w{ 'W' };
registry.bind(Shortcut::Arrange, w);
CHECK(registry.is_customized(Shortcut::Arrange));
CHECK(registry.lookup(ShortcutContext::Plater, w) == Shortcut::Arrange);
CHECK_FALSE(registry.lookup(ShortcutContext::Plater, { 'A' }).has_value());
AppConfig config;
registry.save(config);
CHECK(config.get("shortcuts", "arrange") == "W");
CHECK_FALSE(config.has("shortcuts", "orient"));
ShortcutRegistry loaded;
loaded.load(config);
CHECK(loaded.lookup(ShortcutContext::Plater, w) == Shortcut::Arrange);
CHECK(loaded.binding(Shortcut::Orient) == KeyChord{ 'Q' });
SECTION("rebinding to the default clears the override")
{
registry.bind(Shortcut::Arrange, { 'A' });
CHECK_FALSE(registry.is_customized(Shortcut::Arrange));
registry.save(config);
CHECK_FALSE(config.has("shortcuts", "arrange"));
}
SECTION("an invalid chord unbinds and persists as none")
{
registry.bind(Shortcut::Arrange, KeyChord{});
CHECK_FALSE(registry.binding(Shortcut::Arrange).valid());
registry.save(config);
CHECK(config.get("shortcuts", "arrange") == "none");
loaded.load(config);
CHECK_FALSE(loaded.lookup(ShortcutContext::Plater, { 'A' }).has_value());
CHECK_FALSE(loaded.lookup(ShortcutContext::Plater, w).has_value());
}
SECTION("reset_all restores every default")
{
registry.reset_all();
CHECK(registry.lookup(ShortcutContext::Plater, { 'A' }) == Shortcut::Arrange);
CHECK_FALSE(registry.is_customized(Shortcut::Arrange));
}
}
TEST_CASE("A Global shortcut refuses a config binding that would swallow typing", "[Shortcuts]")
{
AppConfig config;
// A string literal would pick AppConfig::set's bool overload.
config.set("shortcuts", "save_project", std::string("S"));
config.set("shortcuts", "new_project", std::string("F9"));
ShortcutRegistry registry;
registry.load(config);
CHECK(registry.binding(Shortcut::SaveProject) == KeyChord{ 'S', wxMOD_CONTROL });
CHECK(registry.binding(Shortcut::NewProject) == KeyChord{ WXK_F9 });
}
TEST_CASE("Unreadable config entries fall back to the default binding", "[Shortcuts]")
{
AppConfig config;
config.set("shortcuts", "arrange", std::string("Hyper+Q"));
config.set("shortcuts", "no_such_shortcut", std::string("Ctrl+Q"));
ShortcutRegistry registry;
registry.load(config);
CHECK_FALSE(registry.is_customized(Shortcut::Arrange));
CHECK(registry.lookup(ShortcutContext::Plater, { 'A' }) == Shortcut::Arrange);
}
@@ -0,0 +1,113 @@
#include <catch2/catch_all.hpp>
#include <slic3r/GUI/WebMediaController.hpp>
#include <wx/webview.h>
#include <string>
#include <vector>
using namespace Slic3r;
using namespace Slic3r::GUI;
namespace {
class StubWebView final : public wxWebView
{
public:
bool SetBackgroundColour(const wxColour&) override
{
events.emplace_back("background");
return true;
}
bool Create(wxWindow*, wxWindowID, const wxString&, const wxPoint&, const wxSize&, long, const wxString&) override { return true; }
wxString GetCurrentTitle() const override { return {}; }
wxString GetCurrentURL() const override { return {}; }
bool IsBusy() const override { return false; }
bool IsEditable() const override { return false; }
void LoadURL(const wxString& url) override
{
events.emplace_back("url");
loaded_url = url.ToStdString();
}
void Print() override {}
void RegisterHandler(wxSharedPtr<wxWebViewHandler>) override {}
void Reload(wxWebViewReloadFlags) override {}
void SetEditable(bool) override {}
void Stop() override { events.emplace_back("stop"); }
bool CanGoBack() const override { return false; }
bool CanGoForward() const override { return false; }
void GoBack() override {}
void GoForward() override {}
void ClearHistory() override { events.emplace_back("history"); }
void EnableHistory(bool) override {}
wxVector<wxSharedPtr<wxWebViewHistoryItem>> GetBackwardHistory() override { return {}; }
wxVector<wxSharedPtr<wxWebViewHistoryItem>> GetForwardHistory() override { return {}; }
void LoadHistoryItem(wxSharedPtr<wxWebViewHistoryItem>) override {}
bool CanSetZoomType(wxWebViewZoomType) const override { return false; }
float GetZoomFactor() const override { return 1.0f; }
wxWebViewZoomType GetZoomType() const override { return wxWEBVIEW_ZOOM_TYPE_LAYOUT; }
void SetZoomFactor(float) override {}
void SetZoomType(wxWebViewZoomType) override {}
bool CanUndo() const override { return false; }
bool CanRedo() const override { return false; }
void Undo() override {}
void Redo() override {}
void* GetNativeBackend() const override { return nullptr; }
bool RunScript(const wxString& javascript, wxString*) const override
{
events.emplace_back("script");
script = javascript.ToStdString();
return true;
}
protected:
void DoSetPage(const wxString& html, const wxString& base_url) override
{
events.emplace_back("page");
page = html.ToStdString();
page_base = base_url.ToStdString();
}
public:
mutable std::vector<std::string> events;
std::string page;
std::string page_base;
mutable std::string script;
std::string loaded_url;
};
} // namespace
TEST_CASE("Web media controller tears down a snapshot lifecycle", "[WebMediaController][integration]")
{
StubWebView view;
WebMediaController controller(&view);
controller.set_mode(CameraStreamMode::http_snapshot);
controller.Load(wxURI("http://camera.example/frame.jpg"));
controller.Play();
REQUIRE(view.events.size() == 3);
CHECK(view.events[0] == "background");
CHECK(view.events[1] == "page");
CHECK(view.events[2] == "page");
CHECK(view.page.find("stopCameraRefresh") != std::string::npos);
CHECK(view.page.find("http://camera.example/frame.jpg") != std::string::npos);
controller.Stop();
REQUIRE(view.events.size() == 7);
CHECK(view.events[3] == "script");
CHECK(view.events[4] == "stop");
CHECK(view.events[5] == "page");
CHECK(view.events[6] == "history");
CHECK(view.script == "if(typeof stopCameraRefresh==='function') stopCameraRefresh();");
CHECK(view.page.empty());
CHECK(view.page_base == "about:blank");
controller.Play();
CHECK(view.page.find("http://camera.example/frame.jpg") == std::string::npos);
}