// 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 #include #include #include #include #include #include #include #include #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 void collect(Gtk::Widget& root, Pred pred, std::vector& out) { for (auto* child = root.get_first_child(); child != nullptr; child = child->get_next_sibling()) { if (auto* match = dynamic_cast(child); match != nullptr && pred(*match)) out.push_back(match); collect(*child, pred, out); } } template std::vector all(Gtk::Widget& root) { std::vector out; collect(root, [](T&) { return true; }, out); return out; } template T* named(Gtk::Widget& root, const std::string& name) { std::vector out; collect(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 out; collect(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(*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(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(window).empty() ? nullptr : all(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(window, "chain-list"); auto* add = named(window, "add-effect"); auto* sources = named(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 panes; collect(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(*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(*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 listScrollers; collect(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 actions; collect(*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(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(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& 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 recentEntries(Gtk::Window& window) { std::vector buttons; collect(window, [](Gtk::MenuButton& b) { return b.get_tooltip_text() == "Project menu"; }, buttons); std::vector labels; bool found = false; if (buttons.empty()) return labels; auto* popover = dynamic_cast(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(""); 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(*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(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{"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::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{"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::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; }