With eight video effects their list filled its 110-pixel cap, and the menu's headings take more than the 120 pixels the height test allowed for them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
670 lines
30 KiB
C++
670 lines
30 KiB
C++
// Checks for the interface, driven through the real window: the widgets are
|
||
// found by what a user sees (a name, a tooltip, a label) and pressed the way a
|
||
// click would press them. Needs a display; exits 77 (skipped) without one.
|
||
|
||
#include <gtkmm.h>
|
||
|
||
#include <chrono>
|
||
#include <cstdlib>
|
||
#include <filesystem>
|
||
#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 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");
|
||
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;
|
||
}
|