Files
effector/tests/gui_tests.cpp
T
SiandClaude Opus 5.5 a01ada5372 Fit the chain/params split to the chain's rows until it is dragged
The split between the chain list and the params sat at a fixed 300 px,
wrong for a short chain and for a long one alike. It now rests under the
chain's rows, growing with the chain up to eight stages before the list
scrolls. Once the user drags it, it stays put; a double-click on the
handle fits it to the rows again.

GtkPaned sizes the fixed pane from its minimum height, which is only the
drag floor here, so the fit is done by hand from the natural height.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:21:51 +02:00

301 lines
13 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 <iostream>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#include "cli/options.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;
}
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 << "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();
}
} // 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;
}
auto app = Gtk::Application::create("org.effector.GuiTests",
Gio::Application::Flags::NON_UNIQUE);
app->signal_activate().connect([&app] {
auto window = std::make_unique<effector::EffectorWindow>(effector::Options{});
app->add_window(*window);
window->present();
pump(300);
runTests(*window);
app->remove_window(*window);
});
const int status = app->run(argc > 0 ? 1 : 0, argv);
if (status != 0) return status;
std::cout << (failures == 0 ? "all passed\n" : std::to_string(failures) + " failed\n");
return failures;
}