// 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 "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 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; } 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 << "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(); } } // 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::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; }