#include #include #include #include #include #include #include "core.h" #include "node/color/colormanager/colormanager.h" #include "node/output/track/track.h" #include "node/project.h" #include "node/project/folder/folder.h" #include "node/project/footage/footage.h" #include "node/project/sequence/sequence.h" #include "render/diskmanager.h" #include "widget/projectexplorer/projectexplorer.h" #include "widget/projectexplorer/projectviewmodel.h" #include "widget/projecttoolbar/projecttoolbar.h" using namespace olive; namespace { // Renames and moves go through the global undo stack hosted by Core void ensure_app_singletons() { if (!olive::Core::instance()) { new olive::Core(olive::Core::CoreParams()); // intentionally leaked } if (!olive::DiskManager::instance()) { olive::DiskManager::create_instance(); } } } // namespace class ProjectViewModelTest : public ::testing::Test { protected: void SetUp() override { ColorManager::set_up_default_config(); ensure_app_singletons(); project_ = std::make_unique(); project_->initialize(); model_.set_project(project_.get()); } template T *add_item(Folder *parent) { auto *node = new T(); node->setParent(project_.get()); FolderAddChild(parent, node).redo_now(); return node; } std::unique_ptr project_; ProjectViewModel model_{ nullptr }; }; TEST_F(ProjectViewModelTest, ModelWithoutProjectIsEmpty) { ProjectViewModel empty(nullptr); EXPECT_EQ(empty.rowCount(), 0); EXPECT_EQ(empty.columnCount(), 0); EXPECT_EQ(empty.project(), nullptr); } TEST_F(ProjectViewModelTest, HierarchyIndexesAndParents) { Folder *folder = add_item(project_->root()); Footage *footage = add_item(project_->root()); Sequence *sequence = add_item(project_->root()); Footage *nested = add_item(folder); ASSERT_EQ(model_.rowCount(), 3); EXPECT_EQ(model_.columnCount(), ProjectViewModel::k_column_count); // Root children appear in insertion order EXPECT_EQ(model_.index(0, 0).internalPointer(), folder); EXPECT_EQ(model_.index(1, 0).internalPointer(), footage); EXPECT_EQ(model_.index(2, 0).internalPointer(), sequence); // Children of the root report an invalid parent index EXPECT_EQ(model_.parent(model_.index(0, 0)), QModelIndex()); // Nested items resolve to their folder EXPECT_EQ(model_.rowCount(model_.index(0, 0)), 1); QModelIndex nested_index = model_.index(0, 0, model_.index(0, 0)); EXPECT_EQ(nested_index.internalPointer(), nested); EXPECT_EQ(model_.parent(nested_index), model_.index(0, 0)); // CreateIndexFromItem round-trips through the same object EXPECT_EQ(model_.create_index_from_item(footage), model_.index(1, 0)); EXPECT_EQ(model_.create_index_from_item(nested).internalPointer(), nested); // Only folders report children, even when empty EXPECT_TRUE(model_.hasChildren(model_.index(0, 0))); EXPECT_FALSE(model_.hasChildren(model_.index(1, 0))); } TEST_F(ProjectViewModelTest, ItemInsertAndRemoveEmitModelSignals) { QSignalSpy about_to_insert(&model_, &QAbstractItemModel::rowsAboutToBeInserted); QSignalSpy inserted(&model_, &QAbstractItemModel::rowsInserted); QSignalSpy about_to_remove(&model_, &QAbstractItemModel::rowsAboutToBeRemoved); QSignalSpy removed(&model_, &QAbstractItemModel::rowsRemoved); Footage *footage = add_item(project_->root()); EXPECT_EQ(about_to_insert.count(), 1); EXPECT_EQ(inserted.count(), 1); EXPECT_EQ(model_.rowCount(), 1); // Deleting the node disconnects the folder edge and removes the row delete footage; EXPECT_EQ(about_to_remove.count(), 1); EXPECT_EQ(removed.count(), 1); EXPECT_EQ(model_.rowCount(), 0); } TEST_F(ProjectViewModelTest, DataColumnsAndHeader) { Folder *folder = add_item(project_->root()); folder->set_label(QStringLiteral("Media")); QModelIndex name_index = model_.create_index_from_item(folder, ProjectViewModel::k_name); EXPECT_EQ(model_.data(name_index, Qt::DisplayRole).toString(), QStringLiteral("Media")); EXPECT_EQ(model_.data(name_index, Qt::EditRole).toString(), QStringLiteral("Media")); EXPECT_EQ(model_.data(name_index, ProjectViewModel::k_inner_text_role).toString(), QStringLiteral("Media")); // A folder carries no duration/rate/timestamps EXPECT_FALSE(model_.data(model_.create_index_from_item(folder, ProjectViewModel::k_duration), Qt::DisplayRole) .isValid()); EXPECT_FALSE(model_.data(model_.create_index_from_item(folder, ProjectViewModel::k_rate), Qt::DisplayRole) .isValid()); // EditRole is only served for the name column EXPECT_FALSE(model_.data(model_.create_index_from_item(folder, ProjectViewModel::k_duration), Qt::EditRole) .isValid()); EXPECT_EQ(model_.headerData(ProjectViewModel::k_name, Qt::Horizontal).toString(), QStringLiteral("Name")); EXPECT_EQ(model_.headerData(ProjectViewModel::k_duration, Qt::Horizontal).toString(), QStringLiteral("Duration")); EXPECT_EQ(model_.headerData(ProjectViewModel::k_rate, Qt::Horizontal).toString(), QStringLiteral("Rate")); EXPECT_EQ(model_.headerData(ProjectViewModel::k_last_modified, Qt::Horizontal).toString(), QStringLiteral("Modified")); EXPECT_EQ(model_.headerData(ProjectViewModel::k_created_time, Qt::Horizontal).toString(), QStringLiteral("Created")); } TEST_F(ProjectViewModelTest, FlagsMarkNameEditableAndFoldersDroppable) { Folder *folder = add_item(project_->root()); Footage *footage = add_item(project_->root()); const Qt::ItemFlags folder_name_flags = model_.flags(model_.create_index_from_item(folder, ProjectViewModel::k_name)); EXPECT_TRUE(folder_name_flags & Qt::ItemIsEditable); EXPECT_TRUE(folder_name_flags & Qt::ItemIsDragEnabled); EXPECT_TRUE(folder_name_flags & Qt::ItemIsDropEnabled); // Non-name columns are not editable, non-folders do not accept drops const Qt::ItemFlags footage_duration_flags = model_.flags(model_.create_index_from_item(footage, ProjectViewModel::k_duration)); EXPECT_FALSE(footage_duration_flags & Qt::ItemIsEditable); EXPECT_FALSE(footage_duration_flags & Qt::ItemIsDropEnabled); // The background accepts external file drops EXPECT_EQ(model_.flags(QModelIndex()), Qt::ItemIsDropEnabled); } TEST_F(ProjectViewModelTest, SetDataRenamesItemThroughUndoStack) { Folder *folder = add_item(project_->root()); folder->set_label(QStringLiteral("Before")); QSignalSpy data_changed(&model_, &QAbstractItemModel::dataChanged); QModelIndex name_index = model_.create_index_from_item(folder, ProjectViewModel::k_name); EXPECT_TRUE(model_.setData(name_index, QStringLiteral("After"), Qt::EditRole)); EXPECT_EQ(folder->get_label(), QStringLiteral("After")); EXPECT_GE(data_changed.count(), 1); // The rename is a regular undo command Core::instance()->undo_stack()->undo(); EXPECT_EQ(folder->get_label(), QStringLiteral("Before")); Core::instance()->undo_stack()->clear(); // Empty names and other columns are rejected EXPECT_FALSE(model_.setData(name_index, QString(), Qt::EditRole)); EXPECT_FALSE(model_.setData(model_.create_index_from_item(folder, ProjectViewModel::k_rate), QStringLiteral("After"), Qt::EditRole)); EXPECT_EQ(folder->get_label(), QStringLiteral("Before")); } TEST_F(ProjectViewModelTest, MimeDataEncodesEachRowOnce) { EXPECT_EQ(model_.mimeTypes(), (QStringList{ Project::k_item_mime_type, QStringLiteral("text/uri-list") })); Footage *footage = add_item(project_->root()); Folder *folder = add_item(project_->root()); // Passing every column of two rows must still encode only two items QModelIndexList indexes{ model_.create_index_from_item(footage, ProjectViewModel::k_name), model_.create_index_from_item(footage, ProjectViewModel::k_duration), model_.create_index_from_item(folder, ProjectViewModel::k_name) }; std::unique_ptr mime(model_.mimeData(indexes)); ASSERT_NE(mime, nullptr); ASSERT_TRUE(mime->hasFormat(Project::k_item_mime_type)); QByteArray encoded = mime->data(Project::k_item_mime_type); QDataStream stream(&encoded, QIODevice::ReadOnly); QVector streams; quintptr ptr = 0; stream >> streams >> ptr; EXPECT_EQ(reinterpret_cast(ptr), footage); stream >> streams >> ptr; EXPECT_EQ(reinterpret_cast(ptr), folder); EXPECT_TRUE(stream.atEnd()); // An empty selection produces no mime data EXPECT_EQ(model_.mimeData(QModelIndexList()), nullptr); } TEST_F(ProjectViewModelTest, DropMimeDataMovesItemIntoFolder) { Folder *folder = add_item(project_->root()); Footage *footage = add_item(project_->root()); ASSERT_EQ(model_.rowCount(), 2); std::unique_ptr mime( model_.mimeData({ model_.create_index_from_item(footage) })); ASSERT_NE(mime, nullptr); EXPECT_TRUE(model_.dropMimeData(mime.get(), Qt::CopyAction, -1, -1, model_.create_index_from_item(folder))); EXPECT_EQ(footage->folder(), folder); EXPECT_EQ(model_.rowCount(), 1); EXPECT_EQ(model_.rowCount(model_.create_index_from_item(folder)), 1); // The move is undoable Core::instance()->undo_stack()->undo(); EXPECT_EQ(footage->folder(), project_->root()); EXPECT_EQ(model_.rowCount(), 2); Core::instance()->undo_stack()->clear(); } TEST_F(ProjectViewModelTest, DropRejectsNonFolderAndSelfNesting) { Folder *folder = add_item(project_->root()); Folder *subfolder = add_item(folder); Footage *footage = add_item(project_->root()); // Cannot drop onto a non-folder item std::unique_ptr footage_mime( model_.mimeData({ model_.create_index_from_item(footage) })); EXPECT_FALSE(model_.dropMimeData(footage_mime.get(), Qt::CopyAction, -1, -1, model_.create_index_from_item(footage))); EXPECT_EQ(footage->folder(), project_->root()); // Dropping a folder into its own descendant is skipped as a no-op std::unique_ptr folder_mime( model_.mimeData({ model_.create_index_from_item(folder) })); EXPECT_TRUE(model_.dropMimeData(folder_mime.get(), Qt::CopyAction, -1, -1, model_.create_index_from_item(subfolder))); EXPECT_EQ(folder->folder(), project_->root()); // Dropping onto the background moves items to the root std::unique_ptr sub_mime( model_.mimeData({ model_.create_index_from_item(subfolder) })); EXPECT_TRUE(model_.dropMimeData(sub_mime.get(), Qt::CopyAction, -1, -1, QModelIndex())); EXPECT_EQ(subfolder->folder(), project_->root()); Core::instance()->undo_stack()->clear(); } class ProjectExplorerTest : public ::testing::Test { protected: void SetUp() override { ColorManager::set_up_default_config(); ensure_app_singletons(); project_ = std::make_unique(); project_->initialize(); } template T *add_item(Folder *parent) { auto *node = new T(); node->setParent(project_.get()); FolderAddChild(parent, node).redo_now(); return node; } std::unique_ptr project_; }; TEST_F(ProjectExplorerTest, SetProjectAndSwitchViewType) { ProjectExplorer explorer(nullptr); EXPECT_EQ(explorer.project(), nullptr); explorer.set_project(project_.get()); EXPECT_EQ(explorer.project(), project_.get()); // Tree view is the default EXPECT_EQ(explorer.view_type(), ProjectToolbar::tree_view); explorer.set_view_type(ProjectToolbar::list_view); EXPECT_EQ(explorer.view_type(), ProjectToolbar::list_view); explorer.set_view_type(ProjectToolbar::icon_view); EXPECT_EQ(explorer.view_type(), ProjectToolbar::icon_view); } TEST_F(ProjectExplorerTest, GetSelectedFolderFallsBackToRoot) { Folder *folder = add_item(project_->root()); Footage *footage = add_item(project_->root()); ProjectExplorer explorer(nullptr); explorer.set_project(project_.get()); // No selection: heuristic returns the project root EXPECT_EQ(explorer.get_selected_folder(), project_->root()); // A selected folder is returned directly EXPECT_TRUE(explorer.select_item(folder)); EXPECT_EQ(explorer.get_selected_folder(), folder); // A selected non-folder resolves to its parent folder EXPECT_TRUE(explorer.select_item(footage)); EXPECT_EQ(explorer.get_selected_folder(), project_->root()); } TEST_F(ProjectExplorerTest, SelectItemUpdatesSelectedItems) { Footage *footage = add_item(project_->root()); ProjectExplorer explorer(nullptr); explorer.set_project(project_.get()); EXPECT_TRUE(explorer.selected_items().isEmpty()); EXPECT_TRUE(explorer.select_item(footage)); EXPECT_EQ(explorer.selected_items().size(), 1); EXPECT_EQ(explorer.selected_items().first(), footage); explorer.deselect_all(); EXPECT_TRUE(explorer.selected_items().isEmpty()); } class ProjectToolbarTest : public ::testing::Test { }; TEST_F(ProjectToolbarTest, ActionButtonsEmitSignals) { ProjectToolbar toolbar(nullptr); // Buttons in creation order: new, open, save, tree, list, icon const QList buttons = toolbar.findChildren(); ASSERT_EQ(buttons.size(), 6); QSignalSpy new_spy(&toolbar, &ProjectToolbar::new_clicked); QSignalSpy open_spy(&toolbar, &ProjectToolbar::open_clicked); QSignalSpy save_spy(&toolbar, &ProjectToolbar::save_clicked); buttons.at(0)->click(); EXPECT_EQ(new_spy.count(), 1); buttons.at(1)->click(); EXPECT_EQ(open_spy.count(), 1); buttons.at(2)->click(); EXPECT_EQ(save_spy.count(), 1); } TEST_F(ProjectToolbarTest, SearchFieldForwardsTextChanges) { ProjectToolbar toolbar(nullptr); auto *search = toolbar.findChild(); ASSERT_NE(search, nullptr); QSignalSpy search_spy(&toolbar, &ProjectToolbar::search_changed); search->setText(QStringLiteral("media")); ASSERT_EQ(search_spy.count(), 1); EXPECT_EQ(search_spy.first().first().toString(), QStringLiteral("media")); } TEST_F(ProjectToolbarTest, ViewButtonsAreExclusiveAndEmitViewChanged) { ProjectToolbar toolbar(nullptr); const QList buttons = toolbar.findChildren(); ASSERT_EQ(buttons.size(), 6); QPushButton *tree_button = buttons.at(3); QPushButton *list_button = buttons.at(4); QPushButton *icon_button = buttons.at(5); ProjectToolbar::ViewType received = ProjectToolbar::tree_view; int emissions = 0; QObject::connect(&toolbar, &ProjectToolbar::view_changed, [&received, &emissions](ProjectToolbar::ViewType type) { received = type; ++emissions; }); list_button->click(); EXPECT_EQ(emissions, 1); EXPECT_EQ(received, ProjectToolbar::list_view); EXPECT_TRUE(list_button->isChecked()); EXPECT_FALSE(tree_button->isChecked()); EXPECT_FALSE(icon_button->isChecked()); icon_button->click(); EXPECT_EQ(emissions, 2); EXPECT_EQ(received, ProjectToolbar::icon_view); EXPECT_TRUE(icon_button->isChecked()); EXPECT_FALSE(list_button->isChecked()); // SetView only checks the button; it does not re-emit ViewChanged toolbar.set_view(ProjectToolbar::tree_view); EXPECT_EQ(emissions, 2); EXPECT_TRUE(tree_button->isChecked()); EXPECT_FALSE(icon_button->isChecked()); }