Files
effector/tests/gui_tests.cpp
T
SiandClaude Opus 5.5 b9f905e04c Give Effector a version and a changelog, shown from the menu
The version is the project() line of CMakeLists.txt and nowhere else:
CMake writes it, with the text of CHANGELOG.md, into a generated
core/version.hpp, so the number and the release notes the binary shows
are the ones it was built from. effector --version (-V) prints it, and
the header-bar menu gains a section with Changelog, a window laying the
Markdown out as headings and bullets, and About Effector.

The core tests hold the newest changelog entry to the current version,
so a bump cannot go in without its notes. Releases are tagged vX.Y.Z;
this is 0.5.0, and the changelog reconstructs 0.1.0-0.4.0 from the
history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:14:43 +02:00

478 lines
21 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 "core/version.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;
}
// The version and the changelog, each a menu entry away.
void testHelpWindows(Gtk::Window& window) {
std::cout << "changelog and about\n";
auto toplevel = [](const std::string& title) -> Gtk::Window* {
for (auto* w : Gtk::Window::list_toplevels()) {
if (w->get_title() == title && w->get_visible()) return w;
}
return nullptr;
};
window.activate_action("win.show-changelog");
pump(200);
auto* changelog = toplevel("Changelog");
check(changelog != nullptr, "Changelog opens a window");
auto* text = changelog != nullptr ? named<Gtk::TextView>(*changelog, "changelog") : nullptr;
const std::string shown = text != nullptr ? std::string(text->get_buffer()->get_text()) : "";
check(shown.find(std::string(effector::kVersion) + " — ") != std::string::npos,
"listing this version as a release");
check(shown.find("[") == std::string::npos && shown.find("`") == std::string::npos &&
shown.find("### ") == std::string::npos,
"with the Markdown laid out rather than shown");
if (changelog != nullptr) changelog->close();
pump(50);
check(toplevel("Changelog") == nullptr, "and closes");
window.activate_action("win.about");
pump(200);
Gtk::AboutDialog* about = nullptr;
for (auto* w : Gtk::Window::list_toplevels()) {
if (auto* dialog = dynamic_cast<Gtk::AboutDialog*>(w); dialog != nullptr && dialog->get_visible()) {
about = dialog;
}
}
check(about != nullptr && about->get_version() == std::string(effector::kVersion),
"About Effector shows the version");
if (about != nullptr) about->close();
pump(50);
}
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);
testHelpWindows(*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;
}