// 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/image.hpp" #include "core/memory.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; } // Whether any label under `root` says `text`, shown or not. bool showsText(Gtk::Widget& root, const std::string& text) { for (auto* label : all(root)) { if (label->get_text() == text) return true; } return false; } // 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(); // What can be added to the still project the window opens on. std::vector imageEffects; for (const auto& effect : effects) { if (!effect->video()) imageEffects.push_back(effect); } 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"); bool offered = true; bool videoLast = true; bool seenVideo = false; for (const auto& effect : effects) { auto* entry = menuEntry(*menu, effect->title()); offered = offered && entry->get_sensitive() != effect->video(); videoLast = videoLast && (effect->video() || !seenVideo); seenVideo = seenVideo || effect->video(); } check(seenVideo, "there is a video effect"); check(videoLast && showsText(*menu, "Image effects") && showsText(*menu, "Video effects"), "the menu lists image effects and video effects under headings of their own"); check(offered, "in a still project only the image effects can be picked"); 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. // Image and video effects scroll apart, so the video effects never // scroll out of sight below a long list of image effects. const auto scrollers = all(*menu); int cap = 0; for (auto* scroller : scrollers) cap += scroller->get_max_content_height(); bool scrolled = scrollers.size() == 2; for (const auto& effect : effects) { scrolled = scrolled && isAncestor(*scrollers[effect->video() ? 1 : 0], *menuEntry(*menu, effect->title())); } check(scrolled, "the image and the video entries scroll, each on their own"); check(cap > 0 && cap <= 480, "past a height that fits a small screen"); // The rest is the two headings, the separator and, in a still, the hint // under the video heading. check(menu->get_height() > 0 && menu->get_height() <= cap + 170, "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, imageEffects[static_cast(i) % imageEffects.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, imageEffects[static_cast(i) % imageEffects.size()]->title()); } check(rowCount(*chainList) == 13, "the menu still works with a long chain"); check(addFromMenu(*add, imageEffects.back()->title()) && rowCount(*chainList) == 14, "including for the last image 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"); } // Visible labels in the params grid saying `text`. bool showsLabel(Gtk::Widget& root, const std::string& text) { for (auto* label : all(root)) { if (label->get_text() == text && label->is_drawable()) return true; } return false; } void testVideo(Gtk::Window& window, const std::filesystem::path& dir) { std::cout << "video\n"; std::filesystem::create_directories(dir); const auto path = dir / "moving.json"; effector::Project project; // Neither needs sources: an image effect, then the video effect. project.stages.push_back({effector::effectByName("channelsplit"), {}}); project.stages.push_back({effector::effectByName("tracking"), {}}); effector::saveProject(path.string(), project); window.activate_action("win.open-recent", Glib::Variant::create(Glib::filename_to_uri(path.string()))); pump(300); auto* stack = all(window).front(); auto* toggle = named(window, "video"); auto* preset = named(window, "video-preset"); auto* duration = named(window, "video-duration"); auto* width = named(window, "canvas-width"); auto* height = named(window, "canvas-height"); auto* playback = named(window, "playback"); auto* chainList = named(window, "chain-list"); auto* add = named(window, "add-effect"); check(toggle != nullptr && preset != nullptr && duration != nullptr && width != nullptr && height != nullptr && playback != nullptr, "the video switch, its settings and the playback bar are there"); if (toggle == nullptr || preset == nullptr || duration == nullptr || width == nullptr || height == nullptr || playback == nullptr || chainList == nullptr || add == nullptr) { return; } const std::string tracking = effector::effectByName("tracking")->title(); auto* entry = menuEntry(*add->get_popover(), tracking); // The label of a chain row, which says whether the stage runs. auto rowLabel = [&](int index) -> Gtk::Label* { auto* row = chainList->get_row_at_index(index); if (row == nullptr) return nullptr; for (auto* label : all(*row)) { if (label->get_text().substr(0, 3) == std::to_string(index + 1) + ". ") return label; } return nullptr; }; auto* videoRow = chainList->get_row_at_index(1); auto* videoLabel = rowLabel(1); check(isAncestor(*stack->get_child_by_name("setup"), *toggle), "the video switch is on Setup"); check(!toggle->get_active() && !preset->is_drawable() && !playback->get_visible(), "a still project shows no video settings and no playback"); check(videoRow != nullptr && !all(*videoRow).empty() && videoLabel != nullptr && videoLabel->has_css_class("dim-label") && videoLabel->get_tooltip_text() == "A video effect: skipped in a still picture", "a video stage is marked as one, and dimmed as skipped in a still"); check(!rowLabel(0)->has_css_class("dim-label"), "while the image stage before it runs"); check(entry != nullptr && !entry->get_sensitive(), "the video effect cannot be added to a still"); toggle->set_active(true); pump(200); videoLabel = rowLabel(1); check(videoLabel != nullptr && !videoLabel->has_css_class("dim-label"), "in a video the video stage runs"); check(entry != nullptr && entry->get_sensitive(), "and more can be added"); chainList->select_row(*chainList->get_row_at_index(1)); pump(100); check(showsLabel(window, "Wobble") && showsLabel(window, "Tracking bar"), "its settings are its motion"); stack->set_visible_child("setup"); pump(100); check(preset->is_drawable() && duration->is_drawable(), "and its preset and length"); auto* quality = named(window, "video-quality"); check(quality != nullptr && quality->is_drawable() && effector::videoQualities().at(quality->get_selected()).key == effector::kDefaultVideoQuality, "and its file quality, standard to begin with"); preset->set_selected(1); // Instagram story, the first preset pump(200); check(width->get_value() == 1080 && height->get_value() == 1920 && duration->get_value() == 15, "Instagram story picks a 1080 × 1920 canvas and 15 s"); duration->set_value(0.5); pump(100); check(preset->get_selected() == 0, "changing the length drops the preset to Custom"); for (int waited = 0; waited < 200 && !playback->get_visible(); ++waited) pump(50); check(playback->get_visible(), "the clip is rendered and plays in the preview"); auto* scale = all(*playback).empty() ? nullptr : all(*playback).front(); const double before = scale != nullptr ? scale->get_value() : -1.0; pump(250); check(scale != nullptr && scale->get_value() != before, "and it moves on by itself"); window.activate_action("win.save-project"); pump(100); const auto saved = effector::loadProject(path.string()); check(saved.video.enabled && saved.video.duration == 0.5 && saved.width == 1080, "the project is saved as a video"); toggle->set_active(false); pump(300); videoLabel = rowLabel(1); check(!playback->get_visible() && videoLabel != nullptr && videoLabel->has_css_class("dim-label") && entry != nullptr && !entry->get_sensitive(), "switched back to a still, the playback goes and the video stage is skipped again"); stack->set_visible_child("chain"); } // A font param is a font chooser showing the family, and the family picked // there is what the stage renders with and what the project saves. void testFont(Gtk::Window& window, const std::filesystem::path& dir) { std::cout << "font\n"; std::filesystem::create_directories(dir); const auto path = dir / "lettering.json"; effector::Project project; effector::ChainStage stage{effector::effectByName("text"), {}}; stage.values["font_family"] = std::string("Serif"); project.stages.push_back(stage); effector::saveProject(path.string(), project); window.activate_action("win.open-recent", Glib::Variant::create(Glib::filename_to_uri(path.string()))); pump(300); auto* chainList = named(window, "chain-list"); if (chainList != nullptr && chainList->get_row_at_index(0) != nullptr) { chainList->select_row(*chainList->get_row_at_index(0)); } pump(100); std::vector buttons; collect(window, [](Gtk::FontDialogButton& b) { return b.is_drawable(); }, buttons); check(buttons.size() == 1, "the text stage picks its font with a font chooser"); if (buttons.size() != 1) return; auto* button = buttons.front(); check(button->get_level() == Gtk::FontLevel::FAMILY, "which chooses only the family, the weight and size being params of their own"); check(button->get_font_desc().get_family() == "Serif", "and shows the family saved"); Pango::FontDescription mono; mono.set_family("Monospace"); button->set_font_desc(mono); pump(100); window.activate_action("win.save-project"); pump(100); const auto saved = effector::loadProject(path.string()); check(saved.stages.size() == 1 && effector::paramText(saved.stages[0].values, "font_family", "") == "Monospace", "a family chosen there is saved with the project"); } bool actionEnabled(Gtk::Window& window, const std::string& name) { auto action = dynamic_cast(window).lookup_action(name); return action && action->get_enabled(); } // Whether the window catches `keys` for `action` before the focused widget. bool capturesShortcut(Gtk::Window& window, const std::string& keys, const std::string& action) { auto controllers = window.observe_controllers(); for (guint i = 0; i < controllers->get_n_items(); ++i) { auto* controller = GTK_EVENT_CONTROLLER(g_list_model_get_item(controllers->gobj(), i)); const bool capture = gtk_event_controller_get_propagation_phase(controller) == GTK_PHASE_CAPTURE; if (GTK_IS_SHORTCUT_CONTROLLER(controller) && capture) { auto* list = G_LIST_MODEL(controller); for (guint j = 0; j < g_list_model_get_n_items(list); ++j) { auto* shortcut = GTK_SHORTCUT(g_list_model_get_item(list, j)); char* trigger = gtk_shortcut_trigger_to_string(gtk_shortcut_get_trigger(shortcut)); auto* named = gtk_shortcut_get_action(shortcut); const bool match = trigger == keys && GTK_IS_NAMED_ACTION(named) && action == gtk_named_action_get_action_name(GTK_NAMED_ACTION(named)); g_free(trigger); g_object_unref(shortcut); if (match) { g_object_unref(controller); return true; } } } g_object_unref(controller); } return false; } void testHistory(Gtk::Window& window, const std::filesystem::path& dir) { std::cout << "undo and redo\n"; auto* chainList = named(window, "chain-list"); auto* add = named(window, "add-effect"); if (chainList == nullptr || add == nullptr) return; check(capturesShortcut(window, "z", "win.undo") && capturesShortcut(window, "y", "win.redo") && capturesShortcut(window, "z", "win.redo"), "Ctrl+Z undoes, Ctrl+Y and Ctrl+Shift+Z redo, ahead of the focused widget"); window.activate_action("win.new-project"); pump(700); check(!actionEnabled(window, "undo") && !actionEnabled(window, "redo"), "a new project has nothing to undo or redo"); addFromMenu(*add, "Blur"); pump(700); addFromMenu(*add, "Scanlines"); pump(700); check(rowCount(*chainList) == 2 && actionEnabled(window, "undo"), "two stages added"); window.activate_action("win.undo"); pump(); check(rowCount(*chainList) == 1 && rowTitle(*chainList, 0) == "1. Blur", "undo takes the last stage off"); check(actionEnabled(window, "redo"), "and it can be redone"); window.activate_action("win.undo"); pump(); check(rowCount(*chainList) == 0 && !actionEnabled(window, "undo"), "undo again empties the chain"); window.activate_action("win.redo"); pump(); window.activate_action("win.redo"); pump(); check(rowCount(*chainList) == 2 && rowTitle(*chainList, 1) == "2. Scanlines" && !actionEnabled(window, "redo"), "redo puts both back"); // Undone straight away, before the edit has settled into a step. addFromMenu(*add, "Blur"); check(actionEnabled(window, "undo"), "an edit can be undone at once"); window.activate_action("win.undo"); pump(); check(rowCount(*chainList) == 2, "and undoing it takes that edit back"); window.activate_action("win.undo"); pump(); addFromMenu(*add, "Blur"); pump(700); check(!actionEnabled(window, "redo"), "a new edit after an undo drops what could be redone"); // The images of an opened project arrive after it, from the decoder, and // are part of it rather than a step to undo. std::filesystem::create_directories(dir); const auto source = dir / "source.png"; effector::Image(64, 64).save(source.string()); effector::Project project; project.sources.push_back(source.string()); project.stages.push_back({effector::effectByName("scanlines"), {}}); const auto path = dir / "history.json"; effector::saveProject(path.string(), project); window.activate_action("win.open-recent", Glib::Variant::create(Glib::filename_to_uri(path.string()))); pump(1000); auto* sources = named(window, "sources"); auto lists = sources != nullptr ? all(*sources) : std::vector{}; auto* sourceList = lists.empty() ? nullptr : lists.front(); check(sourceList != nullptr && rowCount(*sourceList) == 1 && !actionEnabled(window, "undo"), "opening a project with images leaves nothing to undo"); if (sourceList == nullptr || rowCount(*sourceList) != 1) return; // Removing an image is undone with the image still in hand. auto* stack = all(window).front(); stack->set_visible_child("setup"); pump(); const auto removes = all(*sourceList->get_row_at_index(0)); if (!removes.empty()) press(*removes.front()); pump(700); check(rowCount(*sourceList) == 0, "an image removed"); window.activate_action("win.undo"); pump(); check(rowCount(*sourceList) == 1, "comes back with undo"); stack->set_visible_child("chain"); window.activate_action("win.new-project"); pump(700); } // A project whose images will not fit is refused before any is decoded, and // saving it then must not quietly drop them from the file. void testMemoryGuard(Gtk::Window& window, const std::filesystem::path& dir) { std::cout << "memory guard\n"; std::filesystem::create_directories(dir); effector::Project project; for (int i = 0; i < 2; ++i) { const auto source = dir / ("big" + std::to_string(i) + ".png"); effector::Image(600, 600).save(source.string()); project.sources.push_back(source.string()); } project.stages.push_back({effector::effectByName("scanlines"), {}}); const auto path = dir / "big.json"; effector::saveProject(path.string(), project); effector::limitAvailableMemory(std::size_t{1} << 20); // 2.1 MB of images, 1 MB free window.activate_action("win.open-recent", Glib::Variant::create(Glib::filename_to_uri(path.string()))); pump(500); auto* sources = named(window, "sources"); auto lists = sources != nullptr ? all(*sources) : std::vector{}; check(!lists.empty() && rowCount(*lists.front()) == 0, "images too large for the memory are not loaded"); bool told = false; for (auto* label : all(window)) { const std::string text = label->get_text(); if (label->is_drawable() && text.find("Loading these 2 images needs about") != std::string::npos && text.find("stay in the project") != std::string::npos) { told = true; } } check(told, "and the window says why"); window.activate_action("win.save-project"); pump(100); check(effector::loadProject(path.string()).sources.size() == 2, "saving the project keeps the images it could not load"); effector::limitAvailableMemory(0); } } // 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"); testVideo(*window, dataDir / "video"); testFont(*window, dataDir / "font"); testHistory(*window, dataDir / "history"); testMemoryGuard(*window, dataDir / "memory"); 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; }