diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 53d66bda..8340b14b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -179,6 +179,19 @@ target_sources( ${ADVSS_SOURCE_DIR}/lib/variables/variable-spinbox.cpp ${ADVSS_SOURCE_DIR}/lib/variables/variable-string.cpp) +# --- macro-tree --- # + +target_sources( + ${PROJECT_NAME} + PRIVATE test-macro-tree.cpp + ui-test-helpers.cpp + ui-test-helpers.hpp + stubs/macro-undo-redo.cpp + ${ADVSS_SOURCE_DIR}/lib/macro/macro-search.cpp + ${ADVSS_SOURCE_DIR}/lib/macro/macro-tree.cpp + ${ADVSS_SOURCE_DIR}/lib/macro/macro-tree.hpp + ${ADVSS_SOURCE_DIR}/lib/variables/variable-usage.cpp) + # --- macro-condition-file --- # target_include_directories(${PROJECT_NAME} @@ -232,6 +245,26 @@ if(WIN32) $ $ COMMAND_EXPAND_LISTS) + # Platform plugins are not part of TARGET_RUNTIME_DLLS but are needed to + # create the QApplication used by the widget tests + get_target_property(_qt_core_lib Qt::Core IMPORTED_LOCATION_RELEASE) + if(NOT _qt_core_lib) + get_target_property(_qt_core_lib Qt::Core IMPORTED_LOCATION_RELWITHDEBINFO) + endif() + if(_qt_core_lib) + get_filename_component(_qt_bin "${_qt_core_lib}" DIRECTORY) + get_filename_component(_qt_platforms "${_qt_bin}/../plugins/platforms" + ABSOLUTE) + add_custom_command( + TARGET ${PROJECT_NAME} + POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory "${_qt_platforms}" + "$/platforms") + else() + message(WARNING "Could not determine Qt platform plugin directory - " + "widget tests might fail to start") + endif() + # Copy remaining OBS deps (zlib, srt, librist, ffmpeg, etc.) that are not # CMake targets but are required at runtime. Derive the path from ZLIB::ZLIB # which lives in the same deps directory. diff --git a/tests/stubs/macro-undo-redo.cpp b/tests/stubs/macro-undo-redo.cpp new file mode 100644 index 00000000..eb914a61 --- /dev/null +++ b/tests/stubs/macro-undo-redo.cpp @@ -0,0 +1,8 @@ +#include "macro-undo-redo.hpp" + +namespace advss { + +void RegisterGroupCreateUndoRedo(const std::string &) {} +void RegisterGroupRemoveUndoRedo(const std::string &) {} + +} // namespace advss diff --git a/tests/test-macro-tree.cpp b/tests/test-macro-tree.cpp new file mode 100644 index 00000000..736f59d1 --- /dev/null +++ b/tests/test-macro-tree.cpp @@ -0,0 +1,1038 @@ +#include "catch.hpp" +#include "ui-test-helpers.hpp" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { + +using namespace advss; +using namespace advss::test; + +using Names = std::vector; + +const std::string newGroupName = "AdvSceneSwitcher.macroTab.defaultGroupName"; + +// Layout syntax: "A, G1[B, C], G2{D}" where [] is an expanded and {} a +// collapsed group +class LayoutParser { +public: + explicit LayoutParser(const std::string &layout) : _text(layout) {} + + std::deque> Parse() + { + std::deque> result; + ParseList(result); + return result; + } + +private: + std::vector> + ParseList(std::deque> &out) + { + std::vector> items; + SkipSpaces(); + while (_pos < _text.size() && _text[_pos] != ']' && + _text[_pos] != '}') { + items.emplace_back(ParseItem(out)); + SkipSpaces(); + if (_pos < _text.size() && _text[_pos] == ',') { + _pos++; + } + SkipSpaces(); + } + return items; + } + + std::shared_ptr + ParseItem(std::deque> &out) + { + std::string name; + while (_pos < _text.size() && + std::isalnum(static_cast(_text[_pos]))) { + name += _text[_pos++]; + } + + if (_pos >= _text.size() || + (_text[_pos] != '[' && _text[_pos] != '{')) { + auto macro = std::make_shared(name); + out.emplace_back(macro); + return macro; + } + + const bool collapsed = _text[_pos] == '{'; + _pos++; + std::deque> descendants; + auto children = ParseList(descendants); + _pos++; + + auto group = Macro::CreateGroup(name, children); + group->SetCollapsed(collapsed); + out.emplace_back(group); + out.insert(out.end(), descendants.begin(), descendants.end()); + return group; + } + + void SkipSpaces() + { + while (_pos < _text.size() && _text[_pos] == ' ') { + _pos++; + } + } + + std::string _text; + size_t _pos = 0; +}; + +std::string describeChildren(const Macro *parent) +{ + std::string result; + for (const auto ¯o : GetTopLevelMacros()) { + if (macro->Parent().get() != parent) { + continue; + } + if (!result.empty()) { + result += ", "; + } + result += macro->Name(); + if (!macro->IsGroup()) { + continue; + } + const bool collapsed = macro->IsCollapsed(); + result += collapsed ? "{" : "["; + result += describeChildren(macro.get()); + result += collapsed ? "}" : "]"; + } + return result; +} + +std::string describeMacros() +{ + return describeChildren(nullptr); +} + +std::shared_ptr getMacro(const std::string &name) +{ + return GetWeakMacroByName(name.c_str()).lock(); +} + +bool isHiddenByCollapsedAncestor(const Macro *macro) +{ + for (auto parent = macro->Parent(); parent; parent = parent->Parent()) { + if (parent->IsCollapsed()) { + return true; + } + } + return false; +} + +class TreeFixture { +public: + explicit TreeFixture(const std::string &layout) + : _window(std::make_unique()), + _tree(new MacroTree(_window.get())) + { + GetMacroSearchSettings() = {}; + auto ¯os = GetTopLevelMacros(); + macros = LayoutParser(layout).Parse(); + + // Same settings as in forms/advanced-scene-switcher.ui + _tree->setDragEnabled(true); + _tree->setDragDropMode(QAbstractItemView::InternalMove); + _tree->setDefaultDropAction(Qt::TargetMoveAction); + _tree->setSelectionMode(QAbstractItemView::ExtendedSelection); + _tree->setDropIndicatorShown(true); + + auto layoutWidget = new QVBoxLayout(_window.get()); + layoutWidget->addWidget(_tree); + _window->resize(300, 450); + _window->show(); + + Reset(); + } + + ~TreeFixture() + { + GetMacroSearchSettings() = {}; + _window.reset(); + ProcessEvents(); + GetTopLevelMacros().clear(); + } + + MacroTree *Tree() const { return _tree; } + QWidget *Viewport() const { return _tree->viewport(); } + + void Reset() + { + _tree->Reset(GetTopLevelMacros(), false); + _checker = new ModelChecker(_tree->model(), + Qt::AccessibleTextRole); + ProcessEvents(); + } + + void SetupSearch() + { + auto searchLayout = new QHBoxLayout(); + _searchText = new QLineEdit(); + auto clear = new QPushButton(); + auto type = new QComboBox(); + auto regex = new RegexConfigWidget(); + auto showSettings = new QPushButton(); + searchLayout->addWidget(_searchText); + searchLayout->addWidget(clear); + searchLayout->addWidget(type); + searchLayout->addWidget(regex); + searchLayout->addWidget(showSettings); + static_cast(_window->layout()) + ->insertLayout(0, searchLayout); + + GetMacroSearchSettings().showAlways = true; + auto tree = _tree; + SetupMacroSearchWidgets(searchLayout, _searchText, clear, type, + regex, showSettings, + [tree]() { tree->RefreshFilter(); }); + ProcessEvents(); + } + + void Search(const QString &text) + { + _searchText->setText(text); + ProcessEvents(); + } + + QModelIndex IndexOf(const std::string &name) const + { + return IndexOf(QString::fromStdString(name), QModelIndex()); + } + + QPoint RowCenter(const std::string &name) const + { + const auto index = IndexOf(name); + REQUIRE(index.isValid()); + return _tree->visualRect(index).center(); + } + + QPoint RowTop(const std::string &name) const + { + const auto index = IndexOf(name); + REQUIRE(index.isValid()); + return _tree->visualRect(index).topLeft() + QPoint(40, 1); + } + + QPoint RowBottom(const std::string &name) const + { + const auto index = IndexOf(name); + REQUIRE(index.isValid()); + return _tree->visualRect(index).bottomLeft() + QPoint(40, -1); + } + + void ClickRow(const std::string &name, + Qt::KeyboardModifiers modifiers = Qt::NoModifier) + { + Click(Viewport(), RowCenter(name), modifiers); + } + + void SelectRows(const Names &names) + { + bool first = true; + for (const auto &name : names) { + ClickRow(name, + first ? Qt::NoModifier : Qt::ControlModifier); + first = false; + } + } + + void Drag(const QPoint &from, const QPoint &to) + { + DragAndDrop(Viewport(), from, to); + } + + QWidget *RowWidget(const std::string &name) const + { + const auto index = IndexOf(name); + REQUIRE(index.isValid()); + return _tree->indexWidget(index); + } + + QCheckBox *ExpandCheckBox(const std::string &name) const + { + auto widget = RowWidget(name); + REQUIRE(widget); + return widget->findChild(); + } + + QCheckBox *RunningCheckBox(const std::string &name) const + { + auto widget = RowWidget(name); + REQUIRE(widget); + for (auto checkBox : widget->findChildren()) { + if (!qobject_cast( + checkBox)) { + return checkBox; + } + } + return nullptr; + } + + void ClickCheckBox(QCheckBox *checkBox) + { + REQUIRE(checkBox); + Click(checkBox, checkBox->rect().center()); + } + + Names CurrentMacros() const + { + Names result; + for (const auto ¯o : _tree->GetCurrentMacros()) { + result.emplace_back(macro->Name()); + } + std::sort(result.begin(), result.end()); + return result; + } + + Names VisibleRows() const + { + std::vector> rows; + CollectVisibleRows(QModelIndex(), rows); + std::sort(rows.begin(), rows.end()); + Names result; + for (const auto &[_, name] : rows) { + result.emplace_back(name); + } + return result; + } + + Names ExpectedVisibleRows() const + { + Names result; + for (const auto ¯o : GetTopLevelMacros()) { + if (isHiddenByCollapsedAncestor(macro.get()) || + !MacroMatchesSearchFilter(macro.get())) { + continue; + } + result.emplace_back(macro->Name()); + } + return result; + } + + // MacroTree::dropEvent() emits row move signals before the underlying + // data is changed + void IgnoreModelSignalErrors() { _ignoreModelSignalErrors = true; } + + void CheckInvariants() const + { + ProcessEvents(); + _checker->CheckStructure(); + for (const auto &error : _checker->Errors()) { + if (!_ignoreModelSignalErrors) { + FAIL_CHECK(error); + } + } + CheckGroupStructure(); + REQUIRE(VisibleRows() == ExpectedVisibleRows()); + for (const auto &name : VisibleRows()) { + CheckRowWidget(name); + } + } + +private: + QModelIndex IndexOf(const QString &name, + const QModelIndex &parent) const + { + auto model = _tree->model(); + for (int row = 0; row < model->rowCount(parent); row++) { + const auto index = model->index(row, 0, parent); + if (index.data(Qt::AccessibleTextRole).toString() == + name) { + return index; + } + const auto child = IndexOf(name, index); + if (child.isValid()) { + return child; + } + } + return {}; + } + + void + CollectVisibleRows(const QModelIndex &parent, + std::vector> &rows) const + { + auto model = _tree->model(); + for (int row = 0; row < model->rowCount(parent); row++) { + const auto index = model->index(row, 0, parent); + const auto rect = _tree->visualRect(index); + if (rect.isValid() && rect.height() > 0) { + rows.emplace_back( + rect.top(), + index.data(Qt::AccessibleTextRole) + .toString() + .toStdString()); + } + CollectVisibleRows(index, rows); + } + } + + void CheckGroupStructure() const + { + const auto ¯os = GetTopLevelMacros(); + std::vector openGroups; + for (const auto ¯o : macros) { + INFO("macro \"" << macro->Name() << "\" in " + << describeMacros()); + const auto parent = macro->Parent().get(); + while (!openGroups.empty() && + openGroups.back() != parent) { + openGroups.pop_back(); + } + if (parent) { + REQUIRE(!openGroups.empty()); + } + if (macro->IsGroup()) { + openGroups.emplace_back(macro.get()); + } + } + + for (const auto ¯o : macros) { + if (!macro->IsGroup()) { + continue; + } + INFO("group \"" << macro->Name() << "\" in " + << describeMacros()); + const auto children = std::count_if( + macros.begin(), macros.end(), + [¯o](const std::shared_ptr &m) { + return m->Parent() == macro; + }); + REQUIRE(macro->GroupSize() == (uint32_t)children); + } + } + + void CheckRowWidget(const std::string &name) const + { + INFO("row \"" << name << "\" in " << describeMacros()); + auto macro = getMacro(name); + REQUIRE(macro); + auto widget = RowWidget(name); + REQUIRE(widget); + + QString label; + for (auto child : widget->findChildren()) { + if (!child->text().isEmpty()) { + label = child->text(); + } + } + REQUIRE(label.toStdString() == name); + + auto expand = ExpandCheckBox(name); + REQUIRE(macro->IsGroup() == (expand != nullptr)); + if (expand) { + REQUIRE(expand->isChecked() == macro->IsCollapsed()); + } + + auto running = RunningCheckBox(name); + REQUIRE(running); + if (!macro->IsGroup()) { + REQUIRE(running->isChecked() == !macro->Paused()); + } + } + + std::unique_ptr _window; + MacroTree *_tree; + ModelChecker *_checker = nullptr; + QLineEdit *_searchText = nullptr; + bool _ignoreModelSignalErrors = false; +}; + +} // namespace + +TEST_CASE("Macro tree shows macros and groups", "[macro-tree]") +{ + TreeFixture f("A, G1[B, C], D, G2{E, F}, H"); + f.CheckInvariants(); + REQUIRE(f.VisibleRows() == Names{"A", "G1", "B", "C", "D", "G2", "H"}); +} + +TEST_CASE("Macro tree selection", "[macro-tree]") +{ + TreeFixture f("A, G1{B, C}, D, G2[E, F], H"); + + SECTION("Single click") + { + f.ClickRow("E"); + REQUIRE(f.Tree()->GetCurrentMacro()->Name() == "E"); + REQUIRE(f.Tree()->SingleItemSelected()); + REQUIRE(f.Tree()->GroupedItemsSelected()); + REQUIRE_FALSE(f.Tree()->GroupsSelected()); + } + + SECTION("Rows after collapsed group") + { + f.ClickRow("H"); + REQUIRE(f.Tree()->GetCurrentMacro()->Name() == "H"); + f.ClickRow("D"); + REQUIRE(f.Tree()->GetCurrentMacro()->Name() == "D"); + REQUIRE_FALSE(f.Tree()->GroupedItemsSelected()); + } + + SECTION("Ctrl click") + { + f.ClickRow("A"); + f.ClickRow("F", Qt::ControlModifier); + f.ClickRow("G1", Qt::ControlModifier); + REQUIRE(f.CurrentMacros() == Names{"A", "F", "G1"}); + REQUIRE(f.Tree()->GroupsSelected()); + REQUIRE(f.Tree()->GroupedItemsSelected()); + REQUIRE_FALSE(f.Tree()->SingleItemSelected()); + } + + SECTION("Shift click") + { + f.ClickRow("D"); + f.ClickRow("F", Qt::ShiftModifier); + REQUIRE(f.CurrentMacros() == Names{"D", "E", "F", "G2"}); + } + + f.CheckInvariants(); +} + +TEST_CASE("Macro tree expand and collapse", "[macro-tree]") +{ + TreeFixture f("A, G1[B, C], D, G2{E}"); + + SECTION("Expand checkbox") + { + f.ClickCheckBox(f.ExpandCheckBox("G1")); + REQUIRE(getMacro("G1")->IsCollapsed()); + f.CheckInvariants(); + REQUIRE(f.VisibleRows() == Names{"A", "G1", "D", "G2"}); + + f.ClickCheckBox(f.ExpandCheckBox("G2")); + REQUIRE_FALSE(getMacro("G2")->IsCollapsed()); + f.CheckInvariants(); + REQUIRE(f.VisibleRows() == Names{"A", "G1", "D", "G2", "E"}); + + f.ClickCheckBox(f.ExpandCheckBox("G1")); + f.CheckInvariants(); + REQUIRE(f.VisibleRows() == + Names{"A", "G1", "B", "C", "D", "G2", "E"}); + } + + SECTION("Double click") + { + DoubleClick(f.Viewport(), f.RowCenter("G2")); + REQUIRE_FALSE(getMacro("G2")->IsCollapsed()); + f.CheckInvariants(); + + DoubleClick(f.Viewport(), f.RowCenter("G1")); + REQUIRE(getMacro("G1")->IsCollapsed()); + f.CheckInvariants(); + } + + SECTION("Expand and collapse all") + { + f.Tree()->CollapseAll(); + f.CheckInvariants(); + REQUIRE(f.VisibleRows() == Names{"A", "G1", "D", "G2"}); + + f.Tree()->ExpandAll(); + f.CheckInvariants(); + REQUIRE(f.VisibleRows() == + Names{"A", "G1", "B", "C", "D", "G2", "E"}); + } + + SECTION("Selecting row after collapsing") + { + f.ClickCheckBox(f.ExpandCheckBox("G1")); + f.ClickRow("D"); + REQUIRE(f.Tree()->GetCurrentMacro()->Name() == "D"); + } +} + +TEST_CASE("Macro tree add", "[macro-tree]") +{ + TreeFixture f("A, G1[B], C, G2{D}"); + auto newMacro = std::make_shared("X"); + + SECTION("Append") + { + f.Tree()->Add(newMacro); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, G1[B], C, G2{D}, X"); + REQUIRE(f.Tree()->GetCurrentMacro() == newMacro); + } + + SECTION("After top level macro") + { + f.Tree()->Add(newMacro, getMacro("A")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, X, G1[B], C, G2{D}"); + } + + SECTION("After macro following collapsed group") + { + f.Tree()->Add(newMacro, getMacro("C")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, G1[B], C, X, G2{D}"); + } + + // Mirrors AdvSceneSwitcher::on_macroAdd_clicked() with a group selected + SECTION("Into group") + { + auto group = getMacro("G2"); + f.Tree()->ExpandGroup(group); + Macro::PrepareMoveToGroup(group, newMacro); + f.Tree()->AddToGroup(newMacro, group); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, G1[B], C, G2[X, D]"); + } + + // Mirrors AdvSceneSwitcher::on_macroAdd_clicked() with a subitem + // selected + SECTION("After subitem") + { + Macro::PrepareMoveToGroup(getMacro("G1"), newMacro); + f.Tree()->Add(newMacro, getMacro("B")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, G1[B, X], C, G2{D}"); + } +} + +TEST_CASE("Macro tree remove", "[macro-tree]") +{ + TreeFixture f("A, G1[B, C], D, G2{E, F}, H"); + + SECTION("Subitem") + { + f.Tree()->Remove(getMacro("B")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, G1[C], D, G2{E, F}, H"); + + f.Tree()->Remove(getMacro("C")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, G1[], D, G2{E, F}, H"); + } + + SECTION("Expanded group") + { + f.Tree()->Remove(getMacro("G1")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, D, G2{E, F}, H"); + } + + SECTION("Collapsed group") + { + f.Tree()->Remove(getMacro("G2")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, G1[B, C], D, H"); + } + + SECTION("Macro after collapsed group") + { + f.Tree()->Remove(getMacro("H")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, G1[B, C], D, G2{E, F}"); + } + + SECTION("Everything") + { + for (const auto &name : {"H", "A", "G2", "B", "G1", "D"}) { + f.Tree()->Remove(getMacro(name)); + f.CheckInvariants(); + } + REQUIRE(GetTopLevelMacros().empty()); + } + + SECTION("Selected macro") + { + f.ClickRow("D"); + f.Tree()->Remove(f.Tree()->GetCurrentMacro()); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, G1[B, C], G2{E, F}, H"); + REQUIRE(f.Tree()->SelectionEmpty()); + } +} + +TEST_CASE("Macro tree up and down", "[macro-tree]") +{ + TreeFixture f("A, B, G1[C, D], E, G2{F}, H"); + auto up = [&](const char *name) { + f.Tree()->Up(getMacro(name)); + f.CheckInvariants(); + }; + auto down = [&](const char *name) { + f.Tree()->Down(getMacro(name)); + f.CheckInvariants(); + }; + + SECTION("Top level") + { + up("B"); + REQUIRE(describeMacros() == "B, A, G1[C, D], E, G2{F}, H"); + down("B"); + REQUIRE(describeMacros() == "A, B, G1[C, D], E, G2{F}, H"); + } + + SECTION("First and last") + { + up("A"); + down("H"); + REQUIRE(describeMacros() == "A, B, G1[C, D], E, G2{F}, H"); + } + + SECTION("Within group") + { + up("D"); + REQUIRE(describeMacros() == "A, B, G1[D, C], E, G2{F}, H"); + up("D"); + REQUIRE(describeMacros() == "A, B, G1[D, C], E, G2{F}, H"); + down("D"); + down("D"); + REQUIRE(describeMacros() == "A, B, G1[C, D], E, G2{F}, H"); + } + + SECTION("Macro past expanded group") + { + down("B"); + REQUIRE(describeMacros() == "A, G1[C, D], B, E, G2{F}, H"); + up("E"); + up("E"); + REQUIRE(describeMacros() == "A, E, G1[C, D], B, G2{F}, H"); + } + + SECTION("Macro past collapsed group") + { + down("E"); + REQUIRE(describeMacros() == "A, B, G1[C, D], G2{F}, E, H"); + up("E"); + up("H"); + REQUIRE(describeMacros() == "A, B, G1[C, D], E, H, G2{F}"); + } + + SECTION("Expanded group") + { + down("G1"); + REQUIRE(describeMacros() == "A, B, E, G1[C, D], G2{F}, H"); + down("G1"); + REQUIRE(describeMacros() == "A, B, E, G2{F}, G1[C, D], H"); + up("G1"); + up("G1"); + up("G1"); + REQUIRE(describeMacros() == "A, G1[C, D], B, E, G2{F}, H"); + } + + SECTION("Collapsed group") + { + up("G2"); + REQUIRE(describeMacros() == "A, B, G1[C, D], G2{F}, E, H"); + up("G2"); + REQUIRE(describeMacros() == "A, B, G2{F}, G1[C, D], E, H"); + down("G2"); + down("G2"); + down("G2"); + REQUIRE(describeMacros() == "A, B, G1[C, D], E, H, G2{F}"); + } +} + +TEST_CASE("Macro tree group and ungroup", "[macro-tree]") +{ + SECTION("Group adjacent macros") + { + TreeFixture f("A, B, C, D"); + f.SelectRows({"B", "C"}); + f.Tree()->GroupSelectedItems(); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, " + newGroupName + "[B, C], D"); + } + + SECTION("Group non adjacent macros") + { + TreeFixture f("A, B, C, D"); + f.SelectRows({"D", "B"}); + f.Tree()->GroupSelectedItems(); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, " + newGroupName + "[B, D], C"); + } + + SECTION("Group after collapsed group") + { + TreeFixture f("G1{X}, A, B, C"); + f.SelectRows({"B", "C"}); + f.Tree()->GroupSelectedItems(); + f.CheckInvariants(); + REQUIRE(describeMacros() == + "G1{X}, A, " + newGroupName + "[B, C]"); + } + + SECTION("Ungroup expanded group") + { + TreeFixture f("A, G1[B, C], D"); + f.SelectRows({"G1"}); + f.Tree()->UngroupSelectedGroups(); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, B, C, D"); + } + + SECTION("Ungroup collapsed group") + { + TreeFixture f("A, G1{B, C}, D"); + f.SelectRows({"G1"}); + f.Tree()->UngroupSelectedGroups(); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, B, C, D"); + REQUIRE(f.VisibleRows() == Names{"A", "B", "C", "D"}); + } + + SECTION("Ungroup multiple groups") + { + TreeFixture f("G1{A}, B, G2[C, D]"); + f.SelectRows({"G1", "G2"}); + f.Tree()->UngroupSelectedGroups(); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, B, C, D"); + } +} + +TEST_CASE("Macro tree drag and drop", "[macro-tree]") +{ + if (!DragAndDropSupported()) { + WARN("Skipped: drag and drop is not supported by the Qt platform"); + return; + } + + SECTION("Macro below macro") + { + TreeFixture f("A, B, C, D"); + f.IgnoreModelSignalErrors(); + f.Drag(f.RowCenter("A"), f.RowBottom("C")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "B, C, A, D"); + } + + SECTION("Macro above macro") + { + TreeFixture f("A, B, C, D"); + f.IgnoreModelSignalErrors(); + f.Drag(f.RowCenter("D"), f.RowTop("A")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "D, A, B, C"); + } + + SECTION("Macro below last row") + { + TreeFixture f("A, B, C"); + f.IgnoreModelSignalErrors(); + f.Drag(f.RowCenter("A"), + QPoint(40, f.Viewport()->height() - 5)); + f.CheckInvariants(); + REQUIRE(describeMacros() == "B, C, A"); + } + + SECTION("Macro onto expanded group") + { + TreeFixture f("A, G1[B, C], D"); + f.IgnoreModelSignalErrors(); + f.Drag(f.RowCenter("A"), f.RowCenter("G1")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "G1[A, B, C], D"); + } + + SECTION("Macro between subitems") + { + TreeFixture f("A, G1[B, C], D"); + f.IgnoreModelSignalErrors(); + f.Drag(f.RowCenter("D"), f.RowTop("C")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, G1[B, D, C]"); + } + + SECTION("Subitem out of group") + { + TreeFixture f("A, G1[B, C], D"); + f.IgnoreModelSignalErrors(); + f.Drag(f.RowCenter("B"), f.RowBottom("D")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, G1[C], D, B"); + } + + SECTION("Subitem to top") + { + TreeFixture f("A, G1[B, C], D"); + f.IgnoreModelSignalErrors(); + f.Drag(f.RowCenter("C"), f.RowTop("A")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "C, A, G1[B], D"); + } + + SECTION("Macro past collapsed group") + { + TreeFixture f("A, G1{B, C}, D"); + f.IgnoreModelSignalErrors(); + f.Drag(f.RowCenter("D"), f.RowTop("A")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "D, A, G1{B, C}"); + } + + SECTION("Expanded group") + { + TreeFixture f("A, G1[B, C], D, E"); + f.IgnoreModelSignalErrors(); + f.Drag(f.RowCenter("G1"), f.RowBottom("D")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, D, G1[B, C], E"); + } + + SECTION("Collapsed group") + { + TreeFixture f("A, G1{B, C}, D, E"); + f.IgnoreModelSignalErrors(); + f.Drag(f.RowCenter("G1"), f.RowBottom("D")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, D, G1{B, C}, E"); + } + + SECTION("Group onto group is rejected") + { + TreeFixture f("G1[A], G2[B]"); + f.IgnoreModelSignalErrors(); + f.Drag(f.RowCenter("G2"), f.RowCenter("G1")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "G1[A], G2[B]"); + } + + SECTION("Multiple macros") + { + TreeFixture f("A, B, C, D"); + f.IgnoreModelSignalErrors(); + f.SelectRows({"A", "C"}); + f.Drag(f.RowCenter("C"), f.RowBottom("D")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "B, D, A, C"); + } +} + +// Known issue: dropping onto an expanded group inserts at the start of the +// group, dropping onto a collapsed group appends at the end +TEST_CASE("Macro tree drop onto collapsed group", "[macro-tree][!shouldfail]") +{ + TreeFixture f("A, G1{B, C}, D"); + f.IgnoreModelSignalErrors(); + f.Drag(f.RowCenter("D"), f.RowCenter("G1")); + f.CheckInvariants(); + REQUIRE(describeMacros() == "A, G1{D, B, C}"); +} + +TEST_CASE("Macro tree search", "[macro-tree]") +{ + SECTION("Filter rows") + { + TreeFixture f("A1, G1[B1, C2], D2, G2{E1}, G3[F1]"); + f.SetupSearch(); + f.Search("2"); + f.CheckInvariants(); + REQUIRE(f.VisibleRows() == Names{"G1", "C2", "D2"}); + + f.Search(""); + f.CheckInvariants(); + REQUIRE(f.VisibleRows() == + Names{"A1", "G1", "B1", "C2", "D2", "G2", "G3", "F1"}); + } + + SECTION("Filter rows after collapsed group") + { + TreeFixture f("G1{X1}, A1, B2, C2"); + f.SetupSearch(); + f.Search("2"); + f.CheckInvariants(); + REQUIRE(f.VisibleRows() == Names{"B2", "C2"}); + } + + SECTION("Filter survives modifications") + { + TreeFixture f("A1, G1[B1, C2], D2, E2"); + f.SetupSearch(); + f.Search("2"); + f.Tree()->Remove(getMacro("D2")); + f.CheckInvariants(); + f.Tree()->Up(getMacro("E2")); + f.CheckInvariants(); + f.ClickCheckBox(f.ExpandCheckBox("G1")); + f.CheckInvariants(); + REQUIRE(f.VisibleRows() == Names{"E2", "G1"}); + f.Tree()->Add(std::make_shared("X1")); + f.CheckInvariants(); + f.Tree()->Add(std::make_shared("Y2")); + f.CheckInvariants(); + REQUIRE(f.VisibleRows() == Names{"E2", "G1", "Y2"}); + } +} + +TEST_CASE("Macro tree running state", "[macro-tree]") +{ + SECTION("Group checkbox pauses all subitems") + { + TreeFixture f("A, G1[B, C]"); + auto groupCheckBox = f.RunningCheckBox("G1"); + REQUIRE(groupCheckBox->checkState() == Qt::Checked); + + f.ClickCheckBox(groupCheckBox); + REQUIRE(getMacro("B")->Paused()); + REQUIRE(getMacro("C")->Paused()); + REQUIRE_FALSE(getMacro("A")->Paused()); + REQUIRE(groupCheckBox->checkState() == Qt::Unchecked); + f.CheckInvariants(); + } + + SECTION("Subitem checkbox updates group") + { + TreeFixture f("A, G1[B, C]"); + f.ClickCheckBox(f.RunningCheckBox("B")); + REQUIRE(getMacro("B")->Paused()); + REQUIRE(f.RunningCheckBox("G1")->checkState() == + Qt::PartiallyChecked); + f.CheckInvariants(); + } + + SECTION("Update after backend change") + { + TreeFixture f("G0{X}, A, B"); + getMacro("B")->SetPaused(true); + f.Tree()->UpdateRunningStates(); + f.CheckInvariants(); + } +} + +// Known issue: MacroTreeItem::RunningClicked() uses the position in the macro +// list as model row, which is wrong if a collapsed group is above +TEST_CASE("Macro tree running state after collapsed group", + "[macro-tree][!shouldfail]") +{ + TreeFixture f("G0{X}, A, G1[B, C]"); + f.ClickCheckBox(f.RunningCheckBox("B")); + REQUIRE(getMacro("B")->Paused()); + REQUIRE(f.RunningCheckBox("G1")->checkState() == Qt::PartiallyChecked); +} + +TEST_CASE("Macro tree rename", "[macro-tree]") +{ + TreeFixture f("A, G1{B}, C"); + getMacro("C")->SetName("Z"); + emit MacroSignalManager::Instance()->Rename("C", "Z"); + f.CheckInvariants(); + REQUIRE(f.VisibleRows() == Names{"A", "G1", "Z"}); +} diff --git a/tests/test-main.cpp b/tests/test-main.cpp index 0c7c351f..75676217 100644 --- a/tests/test-main.cpp +++ b/tests/test-main.cpp @@ -1,2 +1,20 @@ -#define CATCH_CONFIG_MAIN +#define CATCH_CONFIG_RUNNER #include "catch.hpp" + +#include + +#include + +int main(int argc, char *argv[]) +{ + // The OBS Qt deps do not ship the offscreen platform plugin on all + // platforms, so it is only used as a fallback. + if (qEnvironmentVariableIsEmpty("QT_QPA_PLATFORM")) { + qputenv("QT_QPA_PLATFORM", "minimal;offscreen"); + } + + QApplication app(argc, argv); + std::cout << "Using Qt platform \"" + << QGuiApplication::platformName().toStdString() << "\"\n"; + return Catch::Session().run(argc, argv); +} diff --git a/tests/ui-test-helpers.cpp b/tests/ui-test-helpers.cpp new file mode 100644 index 00000000..b9a96d74 --- /dev/null +++ b/tests/ui-test-helpers.cpp @@ -0,0 +1,285 @@ +#include "ui-test-helpers.hpp" +#include "catch.hpp" + +#include +#include +#include +#include +#include + +namespace advss::test { + +void ProcessEvents() +{ + for (int i = 0; i < 3; i++) { + QCoreApplication::processEvents(); + QCoreApplication::sendPostedEvents(nullptr, + QEvent::DeferredDelete); + } +} + +static void sendMouseEvent(QWidget *widget, QEvent::Type type, + const QPoint &pos, Qt::MouseButton button, + Qt::MouseButtons buttons, + Qt::KeyboardModifiers modifiers) +{ + auto topLevel = widget->window(); + auto window = topLevel->windowHandle(); + Q_ASSERT(window); + const QPointF windowPos = widget->mapTo(topLevel, pos); + const QPointF globalPos = widget->mapToGlobal(pos); + QMouseEvent event(type, windowPos, windowPos, globalPos, button, + buttons, modifiers); + QApplication::sendEvent(window, &event); +} + +void MousePress(QWidget *widget, const QPoint &pos, + Qt::KeyboardModifiers modifiers) +{ + sendMouseEvent(widget, QEvent::MouseButtonPress, pos, Qt::LeftButton, + Qt::LeftButton, modifiers); +} + +void MouseRelease(QWidget *widget, const QPoint &pos, + Qt::KeyboardModifiers modifiers) +{ + sendMouseEvent(widget, QEvent::MouseButtonRelease, pos, Qt::LeftButton, + Qt::NoButton, modifiers); +} + +void MouseMove(QWidget *widget, const QPoint &pos, Qt::MouseButtons buttons, + Qt::KeyboardModifiers modifiers) +{ + sendMouseEvent(widget, QEvent::MouseMove, pos, Qt::NoButton, buttons, + modifiers); +} + +void Click(QWidget *widget, const QPoint &pos, Qt::KeyboardModifiers modifiers) +{ + MousePress(widget, pos, modifiers); + MouseRelease(widget, pos, modifiers); + ProcessEvents(); +} + +void DoubleClick(QWidget *widget, const QPoint &pos) +{ + MousePress(widget, pos); + MouseRelease(widget, pos); + sendMouseEvent(widget, QEvent::MouseButtonDblClick, pos, Qt::LeftButton, + Qt::LeftButton, Qt::NoModifier); + MouseRelease(widget, pos); + ProcessEvents(); +} + +bool DragAndDropSupported() +{ + // QOffscreenDrag::drag() ignores all drags + return QGuiApplication::platformName() != "offscreen"; +} + +void DragAndDrop(QWidget *widget, const QPoint &from, const QPoint &to) +{ + if (!DragAndDropSupported()) { + FAIL("drag and drop is not supported by the Qt platform"); + } + + MousePress(widget, from); + // The first move puts the view into dragging state, the second one + // starts the drag. QDrag::exec() then runs its own event loop, so the + // rest of the gesture has to be delivered from within that loop. + MouseMove(widget, from + QPoint(0, 1), Qt::LeftButton); + QTimer::singleShot(0, widget, [widget, to]() { + MouseMove(widget, to, Qt::LeftButton); + MouseMove(widget, to, Qt::LeftButton); + MouseRelease(widget, to); + }); + const int distance = QApplication::startDragDistance() + 5; + MouseMove(widget, from + QPoint(0, distance), Qt::LeftButton); + ProcessEvents(); +} + +ModelChecker::ModelChecker(QAbstractItemModel *model, int nameRole) + : QObject(model), + _model(model), + _nameRole(nameRole) +{ + connect(model, &QAbstractItemModel::rowsAboutToBeInserted, this, + &ModelChecker::RowsAboutToBeInserted); + connect(model, &QAbstractItemModel::rowsInserted, this, + &ModelChecker::RowsInserted); + connect(model, &QAbstractItemModel::rowsAboutToBeRemoved, this, + &ModelChecker::RowsAboutToBeRemoved); + connect(model, &QAbstractItemModel::rowsRemoved, this, + &ModelChecker::RowsRemoved); + connect(model, &QAbstractItemModel::rowsAboutToBeMoved, this, + &ModelChecker::RowsAboutToBeMoved); + connect(model, &QAbstractItemModel::rowsMoved, this, + &ModelChecker::RowsMoved); + connect(model, &QAbstractItemModel::modelReset, this, + [this]() { CheckStructure(); }); + connect(model, &QAbstractItemModel::layoutChanged, this, + [this]() { CheckStructure(); }); + CheckStructure(); +} + +void ModelChecker::CheckStructure() +{ + CheckStructure(QModelIndex(), 0); +} + +void ModelChecker::CheckStructure(const QModelIndex &parent, int depth) +{ + if (depth > 100) { + Error("model structure too deep - cycle?"); + return; + } + + const int count = _model->rowCount(parent); + for (int row = 0; row < count; row++) { + const auto index = _model->index(row, 0, parent); + if (!index.isValid()) { + Error(QString("invalid index at row %1 below \"%2\"") + .arg(row) + .arg(Name(parent)) + .toStdString()); + continue; + } + if (_model->parent(index) != parent) { + Error(QString("parent of \"%1\" does not match \"%2\"") + .arg(Name(index)) + .arg(Name(parent)) + .toStdString()); + } + if (Name(index).isEmpty()) { + Error(QString("no name at row %1 below \"%2\"") + .arg(row) + .arg(Name(parent)) + .toStdString()); + } + CheckStructure(index, depth + 1); + } +} + +QString ModelChecker::Name(const QModelIndex &index) const +{ + if (!index.isValid()) { + return ""; + } + return _model->data(index, _nameRole).toString(); +} + +void ModelChecker::Error(const std::string &message) +{ + _errors.emplace_back(message); +} + +void ModelChecker::RowsAboutToBeInserted(const QModelIndex &parent, int, int) +{ + _pending = {}; + _pending.parent = parent; + _pending.oldCount = _model->rowCount(parent); +} + +void ModelChecker::RowsInserted(const QModelIndex &parent, int first, int last) +{ + const int expected = _pending.oldCount + last - first + 1; + const int actual = _model->rowCount(parent); + if (actual != expected) { + Error(QString("insert below \"%1\": expected %2 rows, got %3") + .arg(Name(parent)) + .arg(expected) + .arg(actual) + .toStdString()); + } + CheckStructure(); +} + +void ModelChecker::RowsAboutToBeRemoved(const QModelIndex &parent, int first, + int last) +{ + _pending = {}; + _pending.parent = parent; + _pending.oldCount = _model->rowCount(parent); + if (last >= _pending.oldCount || first < 0) { + Error(QString("remove below \"%1\": rows %2-%3 out of range %4") + .arg(Name(parent)) + .arg(first) + .arg(last) + .arg(_pending.oldCount) + .toStdString()); + } +} + +void ModelChecker::RowsRemoved(const QModelIndex &parent, int first, int last) +{ + const int expected = _pending.oldCount - (last - first + 1); + const int actual = _model->rowCount(parent); + if (actual != expected) { + Error(QString("remove below \"%1\": expected %2 rows, got %3") + .arg(Name(parent)) + .arg(expected) + .arg(actual) + .toStdString()); + } + CheckStructure(); +} + +void ModelChecker::RowsAboutToBeMoved(const QModelIndex &sourceParent, + int sourceStart, int sourceEnd, + const QModelIndex &destinationParent, int) +{ + _pending = {}; + _pending.parent = sourceParent; + _pending.oldCount = _model->rowCount(sourceParent); + _pending.destinationParent = destinationParent; + _pending.oldDestinationCount = _model->rowCount(destinationParent); + for (int row = sourceStart; row <= sourceEnd; row++) { + _pending.names << Name(_model->index(row, 0, sourceParent)); + } +} + +void ModelChecker::RowsMoved(const QModelIndex &sourceParent, int sourceStart, + int sourceEnd, + const QModelIndex &destinationParent, + int destinationRow) +{ + const int count = sourceEnd - sourceStart + 1; + const bool sameParent = sourceParent == destinationParent; + int newStart = destinationRow; + if (sameParent && destinationRow > sourceEnd) { + newStart -= count; + } + + QStringList names; + for (int row = newStart; row < newStart + count; row++) { + names << Name(_model->index(row, 0, destinationParent)); + } + if (names != _pending.names) { + Error(QString("move to \"%1\" row %2: expected [%3], got [%4]") + .arg(Name(destinationParent)) + .arg(destinationRow) + .arg(_pending.names.join(", ")) + .arg(names.join(", ")) + .toStdString()); + } + + if (!sameParent) { + const int sourceCount = _model->rowCount(sourceParent); + const int destinationCount = + _model->rowCount(destinationParent); + if (sourceCount != _pending.oldCount - count || + destinationCount != _pending.oldDestinationCount + count) { + Error(QString("move from \"%1\" to \"%2\": row counts do not match") + .arg(Name(sourceParent)) + .arg(Name(destinationParent)) + .toStdString()); + } + } else if (_model->rowCount(sourceParent) != _pending.oldCount) { + Error(QString("move within \"%1\": row count changed") + .arg(Name(sourceParent)) + .toStdString()); + } + CheckStructure(); +} + +} // namespace advss::test diff --git a/tests/ui-test-helpers.hpp b/tests/ui-test-helpers.hpp new file mode 100644 index 00000000..9887e5d0 --- /dev/null +++ b/tests/ui-test-helpers.hpp @@ -0,0 +1,76 @@ +#pragma once +#include +#include +#include +#include +#include + +#include +#include + +class QWidget; + +namespace advss::test { + +void ProcessEvents(); + +// Mouse input is sent to the native window so that Qt performs the same hit +// testing, implicit grabbing and drag and drop handling as for real input +void MousePress(QWidget *widget, const QPoint &pos, + Qt::KeyboardModifiers modifiers = Qt::NoModifier); +void MouseRelease(QWidget *widget, const QPoint &pos, + Qt::KeyboardModifiers modifiers = Qt::NoModifier); +void MouseMove(QWidget *widget, const QPoint &pos, + Qt::MouseButtons buttons = Qt::NoButton, + Qt::KeyboardModifiers modifiers = Qt::NoModifier); +void Click(QWidget *widget, const QPoint &pos, + Qt::KeyboardModifiers modifiers = Qt::NoModifier); +void DoubleClick(QWidget *widget, const QPoint &pos); +bool DragAndDropSupported(); +void DragAndDrop(QWidget *widget, const QPoint &from, const QPoint &to); + +// Validates the rows*() signals and the parent/child structure of a model and +// records every inconsistency it detects +class ModelChecker : public QObject { + Q_OBJECT + +public: + ModelChecker(QAbstractItemModel *model, int nameRole); + const std::vector &Errors() const { return _errors; } + void CheckStructure(); + +private slots: + void RowsAboutToBeInserted(const QModelIndex &parent, int first, + int last); + void RowsInserted(const QModelIndex &parent, int first, int last); + void RowsAboutToBeRemoved(const QModelIndex &parent, int first, + int last); + void RowsRemoved(const QModelIndex &parent, int first, int last); + void RowsAboutToBeMoved(const QModelIndex &sourceParent, + int sourceStart, int sourceEnd, + const QModelIndex &destinationParent, + int destinationRow); + void RowsMoved(const QModelIndex &sourceParent, int sourceStart, + int sourceEnd, const QModelIndex &destinationParent, + int destinationRow); + +private: + void CheckStructure(const QModelIndex &parent, int depth); + QString Name(const QModelIndex &index) const; + void Error(const std::string &message); + + QAbstractItemModel *_model; + int _nameRole; + + struct Change { + QPersistentModelIndex parent; + int oldCount = 0; + QPersistentModelIndex destinationParent; + int oldDestinationCount = 0; + QStringList names; + }; + Change _pending; + std::vector _errors; +}; + +} // namespace advss::test