362 lines
15 KiB
C++
362 lines
15 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;
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
} // 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;
|
|
}
|