Files
effector/tests/gui_tests.cpp
T
SiandClaude Opus 5.5 431fcf9c58 List recently opened projects under Open Recent
Projects opened or saved go into GTK's shared recent list, signed with the
effector application name, and the project menu lists the newest ten of
them. The GUI tests point XDG_DATA_HOME at a temporary folder so they leave
the user's own list alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 01:07:08 +02:00

437 lines
19 KiB
C++

// Checks for the interface, driven through the real window: the widgets are
// found by what a user sees (a name, a tooltip, a label) and pressed the way a
// click would press them. Needs a display; exits 77 (skipped) without one.
#include <gtkmm.h>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <iostream>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#include "cli/options.hpp"
#include "core/project.hpp"
#include "core/registry.hpp"
#include "gui/window.hpp"
namespace {
int failures = 0;
void check(bool condition, const std::string& what) {
if (condition) {
std::cout << " ok " << what << "\n";
} else {
std::cout << " FAIL " << what << "\n";
++failures;
}
}
// Lets the main loop run for a while: popovers map, renders come back, and
// the rows rebuilt by a click are laid out.
void pump(int milliseconds = 100) {
auto context = Glib::MainContext::get_default();
const auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(milliseconds);
while (std::chrono::steady_clock::now() < until) {
while (context->pending()) context->iteration(false);
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
}
template <typename T, typename Pred>
void collect(Gtk::Widget& root, Pred pred, std::vector<T*>& out) {
for (auto* child = root.get_first_child(); child != nullptr; child = child->get_next_sibling()) {
if (auto* match = dynamic_cast<T*>(child); match != nullptr && pred(*match)) out.push_back(match);
collect<T>(*child, pred, out);
}
}
template <typename T>
std::vector<T*> all(Gtk::Widget& root) {
std::vector<T*> out;
collect<T>(root, [](T&) { return true; }, out);
return out;
}
template <typename T>
T* named(Gtk::Widget& root, const std::string& name) {
std::vector<T*> out;
collect<T>(root, [&](T& w) { return w.get_name() == name; }, out);
return out.empty() ? nullptr : out.front();
}
Gtk::Button* withTooltip(Gtk::Widget& root, const std::string& tip) {
std::vector<Gtk::Button*> out;
collect<Gtk::Button>(root, [&](Gtk::Button& b) { return b.get_tooltip_text() == tip; }, out);
return out.empty() ? nullptr : out.front();
}
bool isAncestor(const Gtk::Widget& ancestor, const Gtk::Widget& widget) {
for (auto* w = widget.get_parent(); w != nullptr; w = w->get_parent()) {
if (w == &ancestor) return true;
}
return false;
}
int rowCount(Gtk::ListBox& list) {
int count = 0;
while (list.get_row_at_index(count) != nullptr) ++count;
return count;
}
std::string rowTitle(Gtk::ListBox& list, int index) {
auto* row = list.get_row_at_index(index);
if (row == nullptr) return {};
const auto labels = all<Gtk::Label>(*row);
return labels.empty() ? std::string{} : std::string(labels.front()->get_text());
}
// The top of `widget` in the window's coordinates.
double topIn(Gtk::Widget& widget, Gtk::Widget& window) {
graphene_rect_t bounds;
if (!gtk_widget_compute_bounds(widget.gobj(), window.gobj(), &bounds)) return -1.0;
return bounds.origin.y;
}
// The menu of effects: the popover the "Add effect" button opens, and the
// entry in it for one effect.
Gtk::Button* menuEntry(Gtk::Popover& menu, const std::string& title) {
for (auto* button : all<Gtk::Button>(menu)) {
if (button->get_label() == title) return button;
}
return nullptr;
}
// Presses a button the way the keyboard does. GTK shows the press for a
// quarter of a second before the button reports the click, so wait it out.
void press(Gtk::Button& button) {
button.activate();
pump(400);
}
bool addFromMenu(Gtk::MenuButton& add, const std::string& title) {
auto* menu = add.get_popover();
if (menu == nullptr) return false;
add.popup();
pump();
auto* entry = menuEntry(*menu, title);
if (entry == nullptr || !menu->get_mapped()) return false;
press(*entry);
return true;
}
// The drop target the chain list reorders its rows with.
GtkDropTarget* chainDropTarget(Gtk::Widget& list) {
GListModel* controllers = gtk_widget_observe_controllers(list.gobj());
GtkDropTarget* found = nullptr;
for (guint i = 0; i < g_list_model_get_n_items(controllers) && found == nullptr; ++i) {
auto* controller = g_list_model_get_item(controllers, i);
if (GTK_IS_DROP_TARGET(controller)) found = GTK_DROP_TARGET(controller);
g_object_unref(controller);
}
g_object_unref(controllers);
return found;
}
// What dropping the row of stage `index` at `y` does; a real drag needs a
// pointer, which the tests do not have.
void dropStage(GtkDropTarget* drop, int index, double y) {
GValue value = G_VALUE_INIT;
g_value_init(&value, G_TYPE_INT);
g_value_set_int(&value, index);
gboolean handled = FALSE;
g_signal_emit_by_name(drop, "drop", &value, 1.0, y, &handled);
g_value_unset(&value);
}
void runTests(Gtk::Window& window) {
const auto& effects = effector::effects();
std::cout << "sidebar\n";
auto* stack = all<Gtk::Stack>(window).empty() ? nullptr : all<Gtk::Stack>(window).front();
check(stack != nullptr && stack->get_child_by_name("chain") != nullptr &&
stack->get_child_by_name("setup") != nullptr,
"the sidebar has a Chain and a Setup tab");
check(stack != nullptr && stack->get_visible_child_name() == "chain",
"it opens on the Chain tab");
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
auto* add = named<Gtk::MenuButton>(window, "add-effect");
auto* sources = named<Gtk::Box>(window, "sources");
check(chainList != nullptr && add != nullptr && sources != nullptr,
"the chain list, the Add effect button and the sources are there");
if (chainList == nullptr || add == nullptr || sources == nullptr) return;
std::vector<Gtk::Paned*> panes;
collect<Gtk::Paned>(window, [&](Gtk::Paned& p) { return isAncestor(p, *chainList); }, panes);
auto* split = panes.empty() ? nullptr : panes.back();
check(split != nullptr && split->get_orientation() == Gtk::Orientation::VERTICAL,
"the Chain tab is a vertical split");
check(split != nullptr && split->get_start_child() != nullptr &&
isAncestor(*split->get_start_child(), *chainList),
"the chain list is above the split");
check(split != nullptr && split->get_end_child() != nullptr &&
!isAncestor(*split->get_end_child(), *chainList),
"the params are below it, apart from the list");
check(split != nullptr && !split->get_shrink_start_child() && !split->get_shrink_end_child(),
"neither side can be dragged below its minimum");
check(topIn(*add, window) >= 0.0 && topIn(*add, window) < topIn(*chainList, window),
"Add effect sits above the chain list, not under it");
check(isAncestor(*stack->get_child_by_name("setup"), *sources),
"the sources are on the Setup tab");
std::cout << "add effect\n";
auto* menu = add->get_popover();
check(menu != nullptr, "Add effect has a menu");
if (menu == nullptr) return;
bool listed = true;
for (const auto& effect : effects) listed = listed && menuEntry(*menu, effect->title()) != nullptr;
check(listed, "the menu has an entry for every registered effect");
check(all<Gtk::Button>(*menu).size() == effects.size(), "and nothing else");
add->popup();
pump();
check(menu->get_mapped(), "the menu opens");
// A popover taller than the room below its button is not shown at all --
// though GTK still calls it mapped -- which is what broke the button once
// there were enough effects: the entries have to scroll instead, in a
// menu short enough to open below a button near the top of a 768-pixel
// screen.
const auto scrollers = all<Gtk::ScrolledWindow>(*menu);
const int cap = scrollers.empty() ? -1 : scrollers.front()->get_max_content_height();
check(!scrollers.empty() && isAncestor(*scrollers.front(), *menuEntry(*menu, effects.front()->title())),
"the entries scroll");
check(cap > 0 && cap <= 480, "past a height that fits a small screen");
check(menu->get_height() > 0 && menu->get_height() <= cap + 40,
"and the menu stays within it however many effects there are");
add->popdown();
pump();
std::vector<Gtk::ScrolledWindow*> listScrollers;
collect<Gtk::ScrolledWindow>(window, [&](Gtk::ScrolledWindow& s) {
return s.get_child() != nullptr && isAncestor(*s.get_child(), *chainList);
}, listScrollers);
auto* listScroller = listScrollers.empty() ? nullptr : listScrollers.back();
check(listScroller != nullptr && listScroller->get_propagate_natural_height() &&
listScroller->get_max_content_height() > 0,
"the chain list asks for the height of its rows, up to a cap");
const int emptySplit = split != nullptr ? split->get_position() : -1;
check(rowCount(*chainList) == 0, "the chain starts empty");
check(addFromMenu(*add, "Blur"), "picking Blur from the menu");
check(!menu->get_mapped(), "closes the menu");
check(rowCount(*chainList) == 1 && rowTitle(*chainList, 0) == "1. Blur",
"and appends a Blur stage");
check(chainList->get_selected_row() == chainList->get_row_at_index(0),
"the new stage is selected");
check(!sources->get_visible(), "a chain led by blur hides the sources");
check(addFromMenu(*add, "Scanlines"), "picking Scanlines");
check(rowCount(*chainList) == 2 && rowTitle(*chainList, 1) == "2. Scanlines",
"appends it after Blur");
check(chainList->get_selected_row() == chainList->get_row_at_index(1),
"and selects it");
std::cout << "stage rows\n";
if (rowCount(*chainList) != 2) return;
auto* second = chainList->get_row_at_index(1);
check(second->has_css_class("stage-row"), "a row is a stage row");
std::vector<Gtk::Button*> actions;
collect<Gtk::Button>(*second, [](Gtk::Button& b) { return b.has_css_class("stage-action"); }, actions);
check(actions.size() == 3, "with a move up, a move down and a remove action");
auto* first = chainList->get_row_at_index(0);
auto* firstUp = withTooltip(*first, "Move up");
auto* lastDown = withTooltip(*second, "Move down");
check(firstUp != nullptr && !firstUp->get_sensitive(), "the first stage cannot move up");
check(lastDown != nullptr && !lastDown->get_sensitive(), "the last stage cannot move down");
auto* up = withTooltip(*second, "Move up");
check(up != nullptr, "the second stage has Move up");
if (up != nullptr) press(*up);
check(rowTitle(*chainList, 0) == "1. Scanlines" && rowTitle(*chainList, 1) == "2. Blur",
"Move up swaps the stages");
check(chainList->get_selected_row() == chainList->get_row_at_index(0),
"and the moved stage stays selected");
check(sources->get_visible(), "a chain led by scanlines shows the sources");
auto* remove = withTooltip(*chainList->get_row_at_index(0), "Remove");
if (remove != nullptr) press(*remove);
check(rowCount(*chainList) == 1 && rowTitle(*chainList, 0) == "1. Blur",
"Remove drops the stage and renumbers the rest");
check(!sources->get_visible(), "and the sources hide again");
std::cout << "drag and drop\n";
addFromMenu(*add, "Scanlines");
for (const auto& effect : effects) {
if (effect->title() != "Blur" && effect->title() != "Scanlines") {
addFromMenu(*add, effect->title());
break;
}
}
GtkDropTarget* drop = chainDropTarget(*chainList);
check(drop != nullptr, "the chain list takes drops");
if (drop != nullptr && rowCount(*chainList) == 3) {
const std::string first = rowTitle(*chainList, 0).substr(3);
const std::string second = rowTitle(*chainList, 1).substr(3);
const std::string third = rowTitle(*chainList, 2).substr(3);
const double bottom = chainList->get_row_at_index(2)->get_height() * 3.0;
GdkDragAction action = GDK_ACTION_NONE;
g_signal_emit_by_name(drop, "motion", 1.0, bottom, &action);
check(chainList->get_row_at_index(2)->has_css_class("drop-after"),
"dragging past the last row marks its bottom edge");
dropStage(drop, 0, bottom);
pump();
check(rowTitle(*chainList, 0) == "1. " + second && rowTitle(*chainList, 1) == "2. " + third &&
rowTitle(*chainList, 2) == "3. " + first,
"dropping the first stage past the last moves it to the end");
check(chainList->get_selected_row() == chainList->get_row_at_index(2),
"and selects it");
check(!chainList->get_row_at_index(2)->has_css_class("drop-after"),
"and clears the mark");
dropStage(drop, 2, 1.0);
pump();
check(rowTitle(*chainList, 0) == "1. " + first && rowTitle(*chainList, 1) == "2. " + second,
"dropping on the top edge of the first row moves it to the front");
}
for (int i = rowCount(*chainList) - 1; i > 0; --i) {
if (auto* removeRow = withTooltip(*chainList->get_row_at_index(i), "Remove")) press(*removeRow);
}
std::cout << "many stages\n";
for (int i = 0; i < 4; ++i) addFromMenu(*add, effects[static_cast<std::size_t>(i) % effects.size()]->title());
pump();
const int fewSplit = split != nullptr ? split->get_position() : -1;
check(split != nullptr && fewSplit > emptySplit,
"the split moves down as the chain grows");
for (int i = 4; i < 12; ++i) addFromMenu(*add, effects[static_cast<std::size_t>(i) % effects.size()]->title());
check(rowCount(*chainList) == 13, "the menu still works with a long chain");
check(addFromMenu(*add, effects.back()->title()) && rowCount(*chainList) == 14,
"including for the last effect in the menu");
pump();
const int longSplit = split != nullptr ? split->get_position() : -1;
check(longSplit > fewSplit && listScroller != nullptr &&
listScroller->get_height() <= listScroller->get_max_content_height() + 2,
"up to the cap, past which the list scrolls");
if (split != nullptr) split->set_position(emptySplit + 20);
pump();
addFromMenu(*add, effects.front()->title());
pump();
check(split != nullptr && split->get_position() == emptySplit + 20,
"once dragged, the split stays put as the chain grows");
// With the split dragged all the way down, the button must still work.
if (split != nullptr) split->set_position(10000);
pump();
add->popup();
pump();
check(menu->get_mapped(), "the menu opens with the split dragged to the bottom");
add->popdown();
pump();
}
// The labels of the entries under the "Open Recent" submenu of `model`, or
// nothing when there is no such submenu.
void recentLabels(GMenuModel* model, std::vector<std::string>& out, bool& found) {
for (int i = 0; i < g_menu_model_get_n_items(model); ++i) {
char* label = nullptr;
g_menu_model_get_item_attribute(model, i, G_MENU_ATTRIBUTE_LABEL, "s", &label);
const std::string text = label != nullptr ? label : "";
g_free(label);
if (GMenuModel* section = g_menu_model_get_item_link(model, i, G_MENU_LINK_SECTION)) {
recentLabels(section, out, found);
g_object_unref(section);
}
if (GMenuModel* submenu = g_menu_model_get_item_link(model, i, G_MENU_LINK_SUBMENU)) {
if (text == "Open Recent") {
found = true;
for (int j = 0; j < g_menu_model_get_n_items(submenu); ++j) {
char* entry = nullptr;
g_menu_model_get_item_attribute(submenu, j, G_MENU_ATTRIBUTE_LABEL, "s", &entry);
out.emplace_back(entry != nullptr ? entry : "");
g_free(entry);
}
}
g_object_unref(submenu);
}
}
}
std::vector<std::string> recentEntries(Gtk::Window& window) {
std::vector<Gtk::MenuButton*> buttons;
collect<Gtk::MenuButton>(window, [](Gtk::MenuButton& b) { return b.get_tooltip_text() == "Project menu"; },
buttons);
std::vector<std::string> labels;
bool found = false;
if (buttons.empty()) return labels;
auto* popover = dynamic_cast<Gtk::PopoverMenu*>(buttons.front()->get_popover());
if (popover == nullptr || !popover->get_menu_model()) return labels;
recentLabels(popover->get_menu_model()->gobj(), labels, found);
if (!found) labels.emplace_back("<no Open Recent submenu>");
return labels;
}
void testRecentProjects(Gtk::Window& window, const std::filesystem::path& dir) {
std::cout << "recent projects\n";
check(recentEntries(window) == std::vector<std::string>{"No Recent Projects"},
"Open Recent starts out empty");
// Two projects of the same name in different folders, and a third.
std::filesystem::create_directories(dir / "first");
std::filesystem::create_directories(dir / "second");
const std::filesystem::path paths[] = {dir / "first" / "my_look.json", dir / "second" / "my_look.json",
dir / "other.json"};
effector::Project project;
project.stages.push_back({effector::effectByName("blur"), {}}); // renders with no sources
for (const auto& path : paths) {
effector::saveProject(path.string(), project);
window.activate_action("win.open-recent",
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
pump(50);
}
pump(500); // the recent list is written, and announced, from an idle
check(window.get_title() == "other — Effector", "an entry opens its project");
const auto labels = recentEntries(window);
check(labels == std::vector<std::string>{"other", "my__look (second)", "my__look (first)"},
"opened projects are listed newest first, a shared name with its folder");
}
} // namespace
int main(int argc, char** argv) {
// A crash should still leave every check before it on the screen.
std::cout << std::unitbuf;
const char* x11 = std::getenv("DISPLAY");
const char* wayland = std::getenv("WAYLAND_DISPLAY");
if ((x11 == nullptr || *x11 == '\0') && (wayland == nullptr || *wayland == '\0')) {
std::cout << "no display, skipped\n";
return 77;
}
// Keep the projects the tests open out of the user's own recent list.
std::string dataTemplate = (std::filesystem::temp_directory_path() / "effector-gui-XXXXXX").string();
const std::filesystem::path dataDir = g_mkdtemp(dataTemplate.data());
g_setenv("XDG_DATA_HOME", dataDir.c_str(), TRUE);
auto app = Gtk::Application::create("org.effector.GuiTests",
Gio::Application::Flags::NON_UNIQUE);
app->signal_activate().connect([&app, &dataDir] {
auto window = std::make_unique<effector::EffectorWindow>(effector::Options{});
app->add_window(*window);
window->present();
pump(300);
runTests(*window);
testRecentProjects(*window, dataDir / "projects");
app->remove_window(*window);
});
const int status = app->run(argc > 0 ? 1 : 0, argv);
std::filesystem::remove_all(dataDir);
if (status != 0) return status;
std::cout << (failures == 0 ? "all passed\n" : std::to_string(failures) + " failed\n");
return failures;
}