Files
effector/tests/gui_tests.cpp
T
simonandClaude Opus 5.5 ef8f728126 Show Tile swap's Swap rate as a slider, and let it go slower
A new ParamSpec::slider() marks an Int or Double param to be shown in
the interface as a Gtk::Scale with its minimum and maximum marked under
the ends, rather than a spin button; the command line and the project
file are unchanged. Swap rate uses it, and now runs from 0 to 5 in
steps of 0.01 (was 0 to 30 in steps of 0.1): with every tile swapping
on its own, even 0.1 swapped the canvas many times a second.

Version 0.8.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 23:44:27 +02:00

881 lines
39 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <filesystem>
#include <iomanip>
#include <iostream>
#include <memory>
#include <string>
#include <thread>
#include <vector>
#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 <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;
}
// Whether any label under `root` says `text`, shown or not.
bool showsText(Gtk::Widget& root, const std::string& text) {
for (auto* label : all<Gtk::Label>(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<effector::EffectPtr> imageEffects;
for (const auto& effect : effects) {
if (!effect->video()) imageEffects.push_back(effect);
}
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");
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<Gtk::ScrolledWindow>(*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<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, imageEffects[static_cast<std::size_t>(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<std::size_t>(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<std::string>& 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<std::string> recentEntries(Gtk::Window& window) {
std::vector<Gtk::MenuButton*> buttons;
collect<Gtk::MenuButton>(window, [](Gtk::MenuButton& b) { return b.get_tooltip_text() == "Project menu"; },
buttons);
std::vector<std::string> labels;
bool found = false;
if (buttons.empty()) return labels;
auto* popover = dynamic_cast<Gtk::PopoverMenu*>(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("<no Open Recent submenu>");
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<Gtk::TextView>(*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<Gtk::AboutDialog*>(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<std::string>{"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<Glib::ustring>::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<std::string>{"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<Gtk::Label>(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<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
pump(300);
auto* stack = all<Gtk::Stack>(window).front();
auto* toggle = named<Gtk::Switch>(window, "video");
auto* preset = named<Gtk::DropDown>(window, "video-preset");
auto* duration = named<Gtk::SpinButton>(window, "video-duration");
auto* width = named<Gtk::SpinButton>(window, "canvas-width");
auto* height = named<Gtk::SpinButton>(window, "canvas-height");
auto* playback = named<Gtk::Box>(window, "playback");
auto* chainList = named<Gtk::ListBox>(window, "chain-list");
auto* add = named<Gtk::MenuButton>(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<Gtk::Label>(*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<Gtk::Image>(*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<Gtk::DropDown>(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<Gtk::Scale>(*playback).empty() ? nullptr : all<Gtk::Scale>(*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<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
pump(300);
auto* chainList = named<Gtk::ListBox>(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<Gtk::FontDialogButton*> buttons;
collect<Gtk::FontDialogButton>(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");
}
void testSlider(Gtk::Window& window, const std::filesystem::path& dir) {
std::cout << "slider\n";
std::filesystem::create_directories(dir);
const auto path = dir / "swapping.json";
effector::Project project;
project.video.enabled = true;
effector::ChainStage stage{effector::effectByName("tileswap"), {}};
stage.values["tileswap_rate"] = 0.5;
project.stages.push_back(stage);
effector::saveProject(path.string(), project);
window.activate_action("win.open-recent",
Glib::Variant<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
pump(300);
auto* chainList = named<Gtk::ListBox>(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<Gtk::Scale*> scales;
collect<Gtk::Scale>(window, [](Gtk::Scale& s) { return s.is_drawable(); }, scales);
check(scales.size() == 1, "a param marked as a slider is shown as one");
if (scales.size() != 1) return;
auto* scale = scales.front();
auto adjustment = scale->get_adjustment();
// Written the way the locale writes numbers, as the interface does.
const auto mark = [](double at) {
return std::string(Glib::ustring::format(std::fixed, std::setprecision(2), at));
};
check(adjustment->get_lower() == 0.0 && adjustment->get_upper() == 5.0 &&
showsText(*scale, mark(0.0)) && showsText(*scale, mark(5.0)),
"with its range marked at both ends");
check(scale->get_value() == 0.5, "and shows the value saved");
scale->set_value(0.05);
pump(100);
window.activate_action("win.save-project");
pump(100);
const auto saved = effector::loadProject(path.string());
check(saved.stages.size() == 1 &&
std::abs(effector::paramDouble(saved.stages[0].values, "tileswap_rate", 0.0) - 0.05) < 1e-9,
"a value slid to is saved with the project");
}
bool actionEnabled(Gtk::Window& window, const std::string& name) {
auto action = dynamic_cast<Gtk::ApplicationWindow&>(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<Gtk::ListBox>(window, "chain-list");
auto* add = named<Gtk::MenuButton>(window, "add-effect");
if (chainList == nullptr || add == nullptr) return;
check(capturesShortcut(window, "<Control>z", "win.undo") &&
capturesShortcut(window, "<Control>y", "win.redo") &&
capturesShortcut(window, "<Shift><Control>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<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
pump(1000);
auto* sources = named<Gtk::Box>(window, "sources");
auto lists = sources != nullptr ? all<Gtk::ListBox>(*sources) : std::vector<Gtk::ListBox*>{};
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<Gtk::Stack>(window).front();
stack->set_visible_child("setup");
pump();
const auto removes = all<Gtk::Button>(*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<Glib::ustring>::create(Glib::filename_to_uri(path.string())));
pump(500);
auto* sources = named<Gtk::Widget>(window, "sources");
auto lists = sources != nullptr ? all<Gtk::ListBox>(*sources) : std::vector<Gtk::ListBox*>{};
check(!lists.empty() && rowCount(*lists.front()) == 0, "images too large for the memory are not loaded");
bool told = false;
for (auto* label : all<Gtk::Label>(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::EffectorWindow>(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");
testSlider(*window, dataDir / "slider");
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;
}