From 0dae545cba1f63e1bc302aa33ea13b4a1c5666a5 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 17 Jul 2026 15:48:03 +0800 Subject: [PATCH] tests: coverage for large widgets (61 cases) - curve/keyframe views: connections, selection, undo commands - time ruler / playback controls: time<->scene math, buttons, seek - project explorer: view model hierarchy, MIME drag&drop, rename undo, folder heuristics, toolbar signals - node table/tree/param views, task view, history, multicam play queue, timeline selections, node view scene --- tests/gtest/CMakeLists.txt | 4 + tests/gtest/widget_curve_keyframe_test.cpp | 393 ++++++++++ tests/gtest/widget_panels_model_test.cpp | 716 ++++++++++++++++++ tests/gtest/widget_projectexplorer_test.cpp | 445 +++++++++++ .../gtest/widget_timeruler_playback_test.cpp | 285 +++++++ 5 files changed, 1843 insertions(+) create mode 100644 tests/gtest/widget_curve_keyframe_test.cpp create mode 100644 tests/gtest/widget_panels_model_test.cpp create mode 100644 tests/gtest/widget_projectexplorer_test.cpp create mode 100644 tests/gtest/widget_timeruler_playback_test.cpp diff --git a/tests/gtest/CMakeLists.txt b/tests/gtest/CMakeLists.txt index 68d6785e4..b4c7bf55e 100644 --- a/tests/gtest/CMakeLists.txt +++ b/tests/gtest/CMakeLists.txt @@ -132,6 +132,10 @@ add_executable(olive-gtest widget_layout_test.cpp widget_combos_test.cpp widget_misc_test.cpp + widget_curve_keyframe_test.cpp + widget_timeruler_playback_test.cpp + widget_projectexplorer_test.cpp + widget_panels_model_test.cpp ) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Test) diff --git a/tests/gtest/widget_curve_keyframe_test.cpp b/tests/gtest/widget_curve_keyframe_test.cpp new file mode 100644 index 000000000..27b575cc7 --- /dev/null +++ b/tests/gtest/widget_curve_keyframe_test.cpp @@ -0,0 +1,393 @@ +#include + +#include + +#include + +#include "core.h" +#include "node/color/colormanager/colormanager.h" +#include "node/generator/solid/solid.h" +#include "node/math/math/math.h" +#include "node/nodeundo.h" +#include "node/project.h" +#include "render/diskmanager.h" +#include "widget/curvewidget/curveview.h" +#include "widget/curvewidget/curvewidget.h" +#include "widget/keyframeview/keyframeview.h" +#include "widget/keyframeview/keyframeviewundo.h" +#include "widget/nodetreeview/nodetreeview.h" + +using namespace olive; + +namespace +{ + +// Keyframe deletion goes through the global undo stack hosted by Core +void EnsureAppSingletons() +{ + if (!olive::Core::instance()) { + new olive::Core(olive::Core::CoreParams()); // intentionally leaked + } + if (!olive::DiskManager::instance()) { + olive::DiskManager::CreateInstance(); + } +} + +NodeKeyframe *InsertKeyframe(Node *node, const QString &input, + const rational &time, const QVariant &value, + int track = 0) +{ + auto *key = + new NodeKeyframe(time, value, NodeKeyframe::kLinear, track, -1, input); + NodeParamInsertKeyframeCommand(node, key).redo_now(); + return key; +} + +} // namespace + +class KeyframeViewTest : public ::testing::Test { +protected: + void SetUp() override + { + ColorManager::SetUpDefaultConfig(); + EnsureAppSingletons(); + + project_ = std::make_unique(); + project_->Initialize(); + } + + MathNode *AddMathNode() + { + auto *node = new MathNode(); + node->setParent(project_.get()); + return node; + } + + std::unique_ptr project_; +}; + +TEST_F(KeyframeViewTest, AddKeyframesOfNodeCreatesConnectionPerKeyframableInput) +{ + MathNode *node = AddMathNode(); + + KeyframeView view; + KeyframeView::NodeConnections map = view.AddKeyframesOfNode(node); + + // A float input has one element (the array-less -1) with one track + ASSERT_TRUE(map.contains(MathNode::kParamAIn)); + const KeyframeView::InputConnections ¶m_a = map[MathNode::kParamAIn]; + ASSERT_EQ(param_a.size(), 1); + ASSERT_EQ(param_a.first().size(), 1); + EXPECT_NE(param_a.first().first(), nullptr); + + ASSERT_TRUE(map.contains(MathNode::kParamBIn)); + EXPECT_EQ(map[MathNode::kParamBIn].size(), 1); + + // The base-class enabled checkbox is keyframable too + ASSERT_TRUE(map.contains(Node::kEnabledInput)); + EXPECT_EQ(map[Node::kEnabledInput].size(), 1); + + // The combo input is flagged not-keyframable, so it gets no connections + ASSERT_TRUE(map.contains(MathNode::kMethodIn)); + EXPECT_TRUE(map[MathNode::kMethodIn].isEmpty()); + + // One track connection each for enabled, param A and param B + EXPECT_EQ(view.GetKeyframeTracks().size(), 3); +} + +TEST_F(KeyframeViewTest, SelectAllAndDeselectAllUpdateSelection) +{ + MathNode *node = AddMathNode(); + NodeKeyframe *key_a = InsertKeyframe(node, MathNode::kParamAIn, rational(0), 0.0); + NodeKeyframe *key_b = InsertKeyframe(node, MathNode::kParamAIn, rational(1), 1.0); + + KeyframeView view; + view.AddKeyframesOfNode(node); + + QSignalSpy selection_spy(&view, &KeyframeView::SelectionChanged); + + EXPECT_TRUE(view.GetSelectedKeyframes().empty()); + + view.SelectAll(); + EXPECT_EQ(view.GetSelectedKeyframes().size(), 2); + EXPECT_GE(selection_spy.count(), 1); + + view.DeselectAll(); + EXPECT_TRUE(view.GetSelectedKeyframes().empty()); + EXPECT_GE(selection_spy.count(), 2); + + Q_UNUSED(key_a) + Q_UNUSED(key_b) +} + +TEST_F(KeyframeViewTest, RemoveKeyframesOfTrackDeselectsAndDetaches) +{ + MathNode *node = AddMathNode(); + InsertKeyframe(node, MathNode::kParamAIn, rational(0), 0.0); + + KeyframeView view; + KeyframeViewInputConnection *connection = view.AddKeyframesOfTrack( + NodeKeyframeTrackReference(NodeInput(node, MathNode::kParamAIn), 0)); + ASSERT_NE(connection, nullptr); + ASSERT_EQ(view.GetKeyframeTracks().size(), 1); + + view.SelectAll(); + ASSERT_EQ(view.GetSelectedKeyframes().size(), 1); + + QSignalSpy selection_spy(&view, &KeyframeView::SelectionChanged); + view.RemoveKeyframesOfTrack(connection); + + EXPECT_TRUE(view.GetKeyframeTracks().isEmpty()); + EXPECT_TRUE(view.GetSelectedKeyframes().empty()); + EXPECT_GE(selection_spy.count(), 1); + + // Removing again is a harmless no-op + view.RemoveKeyframesOfTrack(connection); + EXPECT_TRUE(view.GetKeyframeTracks().isEmpty()); +} + +TEST_F(KeyframeViewTest, ClearRemovesAllTracksAndSelection) +{ + MathNode *node = AddMathNode(); + InsertKeyframe(node, MathNode::kParamAIn, rational(0), 0.0); + + KeyframeView view; + view.AddKeyframesOfNode(node); + view.SelectAll(); + ASSERT_FALSE(view.GetKeyframeTracks().isEmpty()); + ASSERT_FALSE(view.GetSelectedKeyframes().empty()); + + view.Clear(); + EXPECT_TRUE(view.GetKeyframeTracks().isEmpty()); + EXPECT_TRUE(view.GetSelectedKeyframes().empty()); +} + +TEST_F(KeyframeViewTest, DeleteSelectedPushesUndoableRemoval) +{ + MathNode *node = AddMathNode(); + NodeKeyframe *key = InsertKeyframe(node, MathNode::kParamAIn, rational(0), 0.0); + + KeyframeView view; + view.AddKeyframesOfTrack( + NodeKeyframeTrackReference(NodeInput(node, MathNode::kParamAIn), 0)); + view.SelectAll(); + view.DeleteSelected(); + + // The command was executed on push: the keyframe is gone from the node + EXPECT_TRUE(node->GetKeyframeTracks(MathNode::kParamAIn, -1) + .at(0) + .isEmpty()); + + Core::instance()->undo_stack()->undo(); + EXPECT_TRUE(node->GetKeyframeTracks(MathNode::kParamAIn, -1) + .at(0) + .contains(key)); + + Core::instance()->undo_stack()->redo(); + EXPECT_TRUE(node->GetKeyframeTracks(MathNode::kParamAIn, -1) + .at(0) + .isEmpty()); + + // Keep the shared undo stack clean for other suites + Core::instance()->undo_stack()->clear(); +} + +class KeyframeViewUndoTest : public ::testing::Test { +protected: + void SetUp() override + { + ColorManager::SetUpDefaultConfig(); + + project_ = std::make_unique(); + project_->Initialize(); + + node_ = new MathNode(); + node_->setParent(project_.get()); + } + + std::unique_ptr project_; + MathNode *node_ = nullptr; +}; + +TEST_F(KeyframeViewUndoTest, SetTypeCommandSwitchesAndRestoresType) +{ + NodeKeyframe *key = + InsertKeyframe(node_, MathNode::kParamAIn, rational(0), 0.0); + ASSERT_EQ(key->type(), NodeKeyframe::kLinear); + + KeyframeSetTypeCommand command(key, NodeKeyframe::kBezier); + EXPECT_EQ(command.GetRelevantProject(), project_.get()); + + command.redo_now(); + EXPECT_EQ(key->type(), NodeKeyframe::kBezier); + + command.undo_now(); + EXPECT_EQ(key->type(), NodeKeyframe::kLinear); +} + +TEST_F(KeyframeViewUndoTest, SetBezierControlPointCapturesOldPointFromKeyframe) +{ + NodeKeyframe *key = + InsertKeyframe(node_, MathNode::kParamAIn, rational(0), 0.0); + key->set_type(NodeKeyframe::kBezier); + key->set_bezier_control_in(QPointF(0.1, 0.2)); + + KeyframeSetBezierControlPoint command(key, NodeKeyframe::kInHandle, + QPointF(0.5, 0.6)); + EXPECT_EQ(command.GetRelevantProject(), project_.get()); + + command.redo_now(); + EXPECT_EQ(key->bezier_control_in(), QPointF(0.5, 0.6)); + + command.undo_now(); + EXPECT_EQ(key->bezier_control_in(), QPointF(0.1, 0.2)); +} + +TEST_F(KeyframeViewUndoTest, SetBezierControlPointWithExplicitOldPoint) +{ + NodeKeyframe *key = + InsertKeyframe(node_, MathNode::kParamAIn, rational(0), 0.0); + key->set_type(NodeKeyframe::kBezier); + + // The four-argument overload does not read the current control point + KeyframeSetBezierControlPoint command(key, NodeKeyframe::kOutHandle, + QPointF(0.7, 0.8), QPointF(0.3, 0.4)); + + command.redo_now(); + EXPECT_EQ(key->bezier_control_out(), QPointF(0.7, 0.8)); + + command.undo_now(); + EXPECT_EQ(key->bezier_control_out(), QPointF(0.3, 0.4)); +} + +class CurveViewTest : public ::testing::Test { +protected: + void SetUp() override + { + ColorManager::SetUpDefaultConfig(); + + project_ = std::make_unique(); + project_->Initialize(); + + solid_ = new SolidGenerator(); + solid_->setParent(project_.get()); + } + + NodeKeyframeTrackReference ColorTrackRef(int track) const + { + return NodeKeyframeTrackReference( + NodeInput(solid_, SolidGenerator::kColorInput), track); + } + + std::unique_ptr project_; + SolidGenerator *solid_ = nullptr; +}; + +TEST_F(CurveViewTest, ConnectAndDisconnectInputManageTrackConnections) +{ + CurveView view; + EXPECT_TRUE(view.GetConnections().isEmpty()); + + view.ConnectInput(ColorTrackRef(0)); + EXPECT_EQ(view.GetConnections().size(), 1); + EXPECT_TRUE(view.GetConnections().contains(ColorTrackRef(0))); + EXPECT_EQ(view.GetConnections().value(ColorTrackRef(0))->GetReference(), + ColorTrackRef(0)); + + // Connecting the same reference twice is a no-op + view.ConnectInput(ColorTrackRef(0)); + EXPECT_EQ(view.GetConnections().size(), 1); + + // A color input has four tracks; connecting another track adds one more + view.ConnectInput(ColorTrackRef(1)); + EXPECT_EQ(view.GetConnections().size(), 2); + + view.DisconnectInput(ColorTrackRef(0)); + EXPECT_EQ(view.GetConnections().size(), 1); + EXPECT_FALSE(view.GetConnections().contains(ColorTrackRef(0))); + + // Disconnecting an unconnected reference is a no-op + view.DisconnectInput(ColorTrackRef(0)); + EXPECT_EQ(view.GetConnections().size(), 1); +} + +TEST_F(CurveViewTest, ConnectionReflectsLiveKeyframeList) +{ + CurveView view; + view.ConnectInput(ColorTrackRef(0)); + + KeyframeViewInputConnection *connection = + view.GetConnections().value(ColorTrackRef(0)); + ASSERT_NE(connection, nullptr); + EXPECT_TRUE(connection->GetKeyframes().isEmpty()); + + NodeKeyframe *key = + InsertKeyframe(solid_, SolidGenerator::kColorInput, rational(0), 0.5, 0); + EXPECT_EQ(connection->GetKeyframes().size(), 1); + EXPECT_EQ(connection->GetKeyframes().first(), key); +} + +TEST_F(CurveViewTest, SetKeyframeTrackColorAppliesToBrush) +{ + CurveView view; + + // Setting the color before connecting is picked up on connect + view.SetKeyframeTrackColor(ColorTrackRef(0), QColor(Qt::red)); + view.ConnectInput(ColorTrackRef(0)); + KeyframeViewInputConnection *connection = + view.GetConnections().value(ColorTrackRef(0)); + ASSERT_NE(connection, nullptr); + EXPECT_EQ(connection->GetBrush().color(), QColor(Qt::red)); + + // Setting it afterwards updates the live connection + view.SetKeyframeTrackColor(ColorTrackRef(0), QColor(Qt::blue)); + EXPECT_EQ(connection->GetBrush().color(), QColor(Qt::blue)); +} + +TEST(CurveWidget, VerticalScaleRoundTripsThroughView) +{ + ColorManager::SetUpDefaultConfig(); + + CurveWidget widget; + const double original = widget.GetVerticalScale(); + EXPECT_GT(original, 0.0); + + widget.SetVerticalScale(original * 2.0); + EXPECT_DOUBLE_EQ(widget.GetVerticalScale(), original * 2.0); +} + +TEST(CurveWidget, TreeSelectionConnectsTracksAndResolvesNodeId) +{ + ColorManager::SetUpDefaultConfig(); + EnsureAppSingletons(); + + Project project; + project.Initialize(); + auto *solid = new SolidGenerator(); + solid->setParent(&project); + solid->Retranslate(); + + CurveWidget widget; + widget.SetNodes({ solid }); + + auto *tree = widget.findChild(); + ASSERT_NE(tree, nullptr); + ASSERT_EQ(tree->topLevelItemCount(), 1); + + // Nothing is connected until an input is selected in the tree + EXPECT_EQ(widget.GetSelectedNodeWithID(solid->id()), nullptr); + + // The solid has two inputs ("enabled" from the base class, then "Color") + QTreeWidgetItem *node_item = tree->topLevelItem(0); + ASSERT_EQ(node_item->childCount(), 2); + QTreeWidgetItem *color_item = node_item->child(1); + + color_item->setSelected(true); + EXPECT_EQ(widget.GetSelectedNodeWithID(solid->id()), solid); + EXPECT_EQ(widget.GetSelectedNodeWithID(QStringLiteral("org.example.bogus")), + nullptr); + + // Clearing the selection disconnects the tracks again + tree->clearSelection(); + EXPECT_EQ(widget.GetSelectedNodeWithID(solid->id()), nullptr); +} diff --git a/tests/gtest/widget_panels_model_test.cpp b/tests/gtest/widget_panels_model_test.cpp new file mode 100644 index 000000000..c91aafb18 --- /dev/null +++ b/tests/gtest/widget_panels_model_test.cpp @@ -0,0 +1,716 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core.h" +#include "node/block/clip/clip.h" +#include "node/color/colormanager/colormanager.h" +#include "node/generator/solid/solid.h" +#include "node/input/multicam/multicamnode.h" +#include "node/math/math/math.h" +#include "node/output/viewer/viewer.h" +#include "node/project.h" +#include "node/project/folder/folder.h" +#include "render/diskmanager.h" +#include "render/rendermanager.h" +#include "task/task.h" +#include "undo/undostack.h" +#include "widget/colorbutton/colorbutton.h" +#include "widget/history/historywidget.h" +#include "widget/multicam/multicamwidget.h" +#include "widget/nodeparamview/nodeparamviewwidgetbridge.h" +#include "widget/nodetableview/nodetableview.h" +#include "widget/nodetreeview/nodetreeview.h" +#include "widget/nodeview/nodeviewscene.h" +#include "widget/slider/floatslider.h" +#include "widget/taskview/taskview.h" +#include "widget/taskview/taskviewitem.h" +#include "widget/timelinewidget/timelinewidgetselections.h" + +using namespace olive; + +namespace +{ + +// Bridges, history and multicam widgets all talk to the Core singleton +void EnsureAppSingletons() +{ + if (!olive::Core::instance()) { + new olive::Core(olive::Core::CoreParams()); // intentionally leaked + } + if (!olive::DiskManager::instance()) { + olive::DiskManager::CreateInstance(); + } +} + +class DummyTask : public Task { +public: + DummyTask() + { + SetTitle(QStringLiteral("Test Task")); + SetError(QStringLiteral("boom")); + } + +protected: + virtual bool Run() override + { + return true; + } +}; + +class IncrementCommand : public UndoCommand { +public: + explicit IncrementCommand(int *value) + : value_(value) + { + } + + virtual Project *GetRelevantProject() const override + { + return nullptr; + } + +protected: + virtual void redo() override + { + ++(*value_); + } + + virtual void undo() override + { + --(*value_); + } + +private: + int *value_; +}; + +// NodeTreeView stores these on its items (mirrors the private constants) +const int kItemTypeRole = Qt::UserRole; +const int kItemInputReferenceRole = Qt::UserRole + 1; + +} // namespace + +class WidgetPanelsTest : public ::testing::Test { +protected: + void SetUp() override + { + ColorManager::SetUpDefaultConfig(); + EnsureAppSingletons(); + + project_ = std::make_unique(); + project_->Initialize(); + } + + template T *AddNode() + { + auto *node = new T(); + node->setParent(project_.get()); + return node; + } + + std::unique_ptr project_; +}; + +TEST_F(WidgetPanelsTest, NodeTableSelectNodesCreatesTopLevelItems) +{ + auto *solid = AddNode(); + + NodeTableView view; + EXPECT_EQ(view.topLevelItemCount(), 0); + + view.SelectNodes({ solid }); + ASSERT_EQ(view.topLevelItemCount(), 1); + EXPECT_EQ(view.topLevelItem(0)->text(0), solid->GetLabelAndName()); + + auto *math = AddNode(); + view.SelectNodes({ math }); + EXPECT_EQ(view.topLevelItemCount(), 2); + + view.DeselectNodes({ solid }); + EXPECT_EQ(view.topLevelItemCount(), 1); + + view.DeselectNodes({ math }); + EXPECT_EQ(view.topLevelItemCount(), 0); +} + +TEST_F(WidgetPanelsTest, NodeTableSetTimePopulatesInputRows) +{ + auto *solid = AddNode(); + solid->Retranslate(); + + NodeTableView view; + view.SelectNodes({ solid }); + + QTreeWidgetItem *top = view.topLevelItem(0); + ASSERT_NE(top, nullptr); + + // Rows appear for the base-class enabled input and the color input + ASSERT_EQ(top->childCount(), 2); + + // The solid's color input appears as a named child row + QTreeWidgetItem *color_row = nullptr; + for (int i = 0; i < top->childCount(); i++) { + if (top->child(i)->data(0, Qt::UserRole).toString() == + SolidGenerator::kColorInput) { + color_row = top->child(i); + break; + } + } + ASSERT_NE(color_row, nullptr); + EXPECT_EQ(color_row->text(0), solid->GetInputName(SolidGenerator::kColorInput)); + + // The value row shows the data type name and the split RGBA columns + ASSERT_EQ(color_row->childCount(), 1); + QTreeWidgetItem *value_row = color_row->child(0); + EXPECT_EQ(value_row->text(0), + NodeValue::GetPrettyDataTypeName(NodeValue::kColor)); + EXPECT_FALSE(value_row->text(1).isEmpty()); + for (int col = 2; col <= 5; col++) { + EXPECT_FALSE(value_row->text(col).isEmpty()) << "column" << col; + } + + // Re-evaluating at another time keeps the same structure + view.SetTime(rational(1)); + EXPECT_EQ(top->childCount(), 2); + EXPECT_EQ(color_row->childCount(), 1); +} + +TEST_F(WidgetPanelsTest, NodeTreeSetNodesBuildsInputHierarchy) +{ + auto *math = AddNode(); + math->Retranslate(); + + NodeTreeView view; + view.SetNodes({ math }); + + ASSERT_EQ(view.topLevelItemCount(), 1); + QTreeWidgetItem *node_item = view.topLevelItem(0); + EXPECT_EQ(node_item->data(0, kItemTypeRole).toInt(), 0); // kItemTypeNode + + // All four inputs are visible: the base-class enabled checkbox, the + // method combo, and the two float params + ASSERT_EQ(node_item->childCount(), 4); + const QStringList expected_inputs = { Node::kEnabledInput, MathNode::kMethodIn, + MathNode::kParamAIn, MathNode::kParamBIn }; + for (int i = 0; i < expected_inputs.size(); i++) { + QTreeWidgetItem *input_item = node_item->child(i); + EXPECT_EQ(input_item->data(0, kItemTypeRole).toInt(), 1); // kItemTypeInput + const NodeKeyframeTrackReference ref = + input_item->data(0, kItemInputReferenceRole) + .value(); + EXPECT_EQ(ref.input().node(), math); + EXPECT_EQ(ref.input().input(), expected_inputs.at(i)); + } +} + +TEST_F(WidgetPanelsTest, NodeTreeOnlyShowKeyframableFiltersInputs) +{ + auto *math = AddNode(); + + NodeTreeView view; + view.SetOnlyShowKeyframable(true); + view.SetNodes({ math }); + + // The method combo is flagged not-keyframable; enabled and the two + // float params remain + ASSERT_EQ(view.topLevelItemCount(), 1); + EXPECT_EQ(view.topLevelItem(0)->childCount(), 3); + + // Of a bare viewer's inputs only "enabled" is keyframable, so it is the + // sole row left standing + auto *viewer = AddNode(); + view.SetNodes({ viewer }); + ASSERT_EQ(view.topLevelItemCount(), 1); + EXPECT_EQ(view.topLevelItem(0)->childCount(), 1); + + // Without the filter its buffer inputs show up as well + view.SetOnlyShowKeyframable(false); + view.SetNodes({ viewer }); + ASSERT_EQ(view.topLevelItemCount(), 1); + EXPECT_EQ(view.topLevelItem(0)->childCount(), 3); +} + +TEST_F(WidgetPanelsTest, NodeTreeCheckboxesToggleEnableStateAndEmit) +{ + auto *math = AddNode(); + + NodeTreeView view; + view.SetCheckBoxesEnabled(true); + view.SetNodes({ math }); + + Node *node_signal_node = nullptr; + bool node_signal_enabled = true; + int node_emissions = 0; + QObject::connect(&view, &NodeTreeView::NodeEnableChanged, + [&node_signal_node, &node_signal_enabled, + &node_emissions](Node *n, bool e) { + node_signal_node = n; + node_signal_enabled = e; + ++node_emissions; + }); + + NodeKeyframeTrackReference input_signal_ref; + bool input_signal_enabled = true; + int input_emissions = 0; + QObject::connect(&view, &NodeTreeView::InputEnableChanged, + [&input_signal_ref, &input_signal_enabled, + &input_emissions](const NodeKeyframeTrackReference &ref, + bool e) { + input_signal_ref = ref; + input_signal_enabled = e; + ++input_emissions; + }); + + QTreeWidgetItem *node_item = view.topLevelItem(0); + ASSERT_NE(node_item, nullptr); + ASSERT_EQ(node_item->checkState(0), Qt::Checked); + + // Unchecking the node disables it and emits + node_item->setCheckState(0, Qt::Unchecked); + EXPECT_EQ(node_emissions, 1); + EXPECT_EQ(node_signal_node, math); + EXPECT_FALSE(node_signal_enabled); + EXPECT_FALSE(view.IsNodeEnabled(math)); + + // Re-checking restores it + node_item->setCheckState(0, Qt::Checked); + EXPECT_EQ(node_emissions, 2); + EXPECT_TRUE(node_signal_enabled); + EXPECT_TRUE(view.IsNodeEnabled(math)); + + // Same behavior on input rows + QTreeWidgetItem *input_item = node_item->child(0); + ASSERT_NE(input_item, nullptr); + input_item->setCheckState(0, Qt::Unchecked); + EXPECT_EQ(input_emissions, 1); + EXPECT_EQ(input_signal_ref.input().input(), Node::kEnabledInput); + EXPECT_FALSE(input_signal_enabled); + EXPECT_FALSE(view.IsInputEnabled(input_signal_ref)); +} + +TEST_F(WidgetPanelsTest, NodeTreeKeyframeTracksBecomeRows) +{ + auto *solid = AddNode(); + solid->Retranslate(); + + NodeTreeView view; + view.SetShowKeyframeTracksAsRows(true); + view.SetNodes({ solid }); + + QTreeWidgetItem *node_item = view.topLevelItem(0); + ASSERT_NE(node_item, nullptr); + ASSERT_EQ(node_item->childCount(), 2); // enabled + color + + // The four-track color input expands into one row per track + QTreeWidgetItem *color_item = node_item->child(1); + EXPECT_EQ(color_item->text(0), QStringLiteral("Color")); + ASSERT_EQ(color_item->childCount(), 4); + const QStringList track_names = { QStringLiteral("R"), QStringLiteral("G"), + QStringLiteral("B"), QStringLiteral("A") }; + for (int i = 0; i < 4; i++) { + EXPECT_EQ(color_item->child(i)->text(0), track_names.at(i)); + const NodeKeyframeTrackReference ref = + color_item->child(i) + ->data(0, kItemInputReferenceRole) + .value(); + EXPECT_EQ(ref.track(), i); + } + + // A single-track float input stays a single row + auto *math = AddNode(); + math->Retranslate(); + view.SetNodes({ math }); + QTreeWidgetItem *param_item = + view.topLevelItem(0)->child(2); // after enabled and the method combo + ASSERT_NE(param_item, nullptr); + EXPECT_EQ(param_item->text(0), QStringLiteral("Value")); + EXPECT_EQ(param_item->childCount(), 0); +} + +TEST_F(WidgetPanelsTest, BridgeCreatesSliderForFloatInput) +{ + auto *math = AddNode(); + + QWidget parent; + NodeParamViewWidgetBridge bridge(NodeInput(math, MathNode::kParamAIn), &parent); + + ASSERT_EQ(bridge.widgets().size(), 1); + EXPECT_NE(qobject_cast(bridge.widgets().first()), nullptr); +} + +TEST_F(WidgetPanelsTest, BridgeCreatesColorButtonForColorInput) +{ + auto *solid = AddNode(); + + QWidget parent; + NodeParamViewWidgetBridge bridge(NodeInput(solid, SolidGenerator::kColorInput), + &parent); + + ASSERT_EQ(bridge.widgets().size(), 1); + EXPECT_NE(qobject_cast(bridge.widgets().first()), nullptr); +} + +TEST_F(WidgetPanelsTest, BridgeCreatesComboBoxForComboInput) +{ + auto *math = AddNode(); + math->Retranslate(); + + QWidget parent; + NodeParamViewWidgetBridge bridge(NodeInput(math, MathNode::kMethodIn), &parent); + + ASSERT_EQ(bridge.widgets().size(), 1); + auto *combo = qobject_cast(bridge.widgets().first()); + ASSERT_NE(combo, nullptr); + EXPECT_EQ(combo->count(), math->GetComboBoxStrings(MathNode::kMethodIn).size()); + EXPECT_GT(combo->count(), 0); +} + +TEST_F(WidgetPanelsTest, BridgeCreatesCheckBoxForBooleanInput) +{ + auto *clip = AddNode(); + + QWidget parent; + NodeParamViewWidgetBridge bridge(NodeInput(clip, ClipBlock::kReverseInput), + &parent); + + ASSERT_EQ(bridge.widgets().size(), 1); + EXPECT_NE(qobject_cast(bridge.widgets().first()), nullptr); +} + +TEST_F(WidgetPanelsTest, BridgeUpdatesWidgetWhenNodeValueChanges) +{ + auto *math = AddNode(); + auto *viewer = AddNode(); + + QWidget parent; + NodeParamViewWidgetBridge bridge(NodeInput(math, MathNode::kParamAIn), &parent); + auto *slider = qobject_cast(bridge.widgets().first()); + ASSERT_NE(slider, nullptr); + EXPECT_DOUBLE_EQ(slider->GetValue(), 0.0); + + // The bridge only refreshes widgets for value changes at the playhead + // of a connected time target + bridge.SetTimeTarget(viewer); + + math->SetStandardValue(MathNode::kParamAIn, 2.5); + EXPECT_DOUBLE_EQ(slider->GetValue(), 2.5); +} + +TEST_F(WidgetPanelsTest, BridgePushesUndoCommandWhenWidgetChanges) +{ + auto *math = AddNode(); + math->Retranslate(); + ASSERT_EQ(math->GetStandardValue(MathNode::kMethodIn).toInt(), 0); + + QWidget parent; + NodeParamViewWidgetBridge bridge(NodeInput(math, MathNode::kMethodIn), &parent); + auto *combo = qobject_cast(bridge.widgets().first()); + ASSERT_NE(combo, nullptr); + ASSERT_GT(combo->count(), 1); + + combo->setCurrentIndex(1); + EXPECT_EQ(math->GetStandardValue(MathNode::kMethodIn).toInt(), 1); + + Core::instance()->undo_stack()->undo(); + EXPECT_EQ(math->GetStandardValue(MathNode::kMethodIn).toInt(), 0); + Core::instance()->undo_stack()->clear(); +} + +TEST(TaskView, TaskLifecycleUpdatesItems) +{ + TaskView view(nullptr); + DummyTask task; + + view.AddTask(&task); + + auto *item = view.findChild(); + ASSERT_NE(item, nullptr); + + // Title label mirrors the task title + bool found_title = false; + foreach (QLabel *label, item->findChildren()) { + if (label->text() == QStringLiteral("Test Task")) { + found_title = true; + break; + } + } + EXPECT_TRUE(found_title); + + // Progress signals drive the progress bar + auto *bar = item->findChild(); + ASSERT_NE(bar, nullptr); + emit task.ProgressChanged(0.5); + EXPECT_EQ(bar->value(), 50); + + // The cancel button relays the task through TaskCancelled + Task *cancelled = nullptr; + QObject::connect(&view, &TaskView::TaskCancelled, + [&cancelled](Task *t) { cancelled = t; }); + auto *cancel_button = item->findChild(); + ASSERT_NE(cancel_button, nullptr); + cancel_button->click(); + EXPECT_EQ(cancelled, &task); + + // Failure swaps in the error label + view.TaskFailed(&task); + bool found_error = false; + foreach (QLabel *label, item->findChildren()) { + if (label->text().contains(QStringLiteral("boom"))) { + found_error = true; + break; + } + } + EXPECT_TRUE(found_error); + + // Removal deletes the item once deferred deletions are processed + view.RemoveTask(&task); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + EXPECT_EQ(view.findChild(), nullptr); +} + +TEST(HistoryWidget, ReflectsAndDrivesUndoStack) +{ + EnsureAppSingletons(); + UndoStack *stack = Core::instance()->undo_stack(); + stack->clear(); + + HistoryWidget widget; + EXPECT_EQ(widget.model(), stack); + + int counter = 0; + stack->push(new IncrementCommand(&counter), QStringLiteral("First")); + stack->push(new IncrementCommand(&counter), QStringLiteral("Second")); + EXPECT_EQ(counter, 2); + EXPECT_EQ(stack->rowCount(), 3); + + // Row 0 is the "New/Open Project" sentinel the stack starts with, so + // "First" lives at row 1 and "Second" at row 2. Moving the current row + // jumps the stack to row+1 applied commands (this is how clicking a + // history row works). + widget.selectionModel()->setCurrentIndex(stack->index(1, 0), + QItemSelectionModel::ClearAndSelect | + QItemSelectionModel::Rows); + EXPECT_EQ(counter, 1); + EXPECT_TRUE(stack->CanRedo()); + + // Moving to the second entry redoes everything again + widget.selectionModel()->setCurrentIndex(stack->index(2, 0), + QItemSelectionModel::ClearAndSelect | + QItemSelectionModel::Rows); + EXPECT_EQ(counter, 2); + EXPECT_FALSE(stack->CanRedo()); + + // Moving back to the sentinel row undoes both commands + widget.selectionModel()->setCurrentIndex(stack->index(0, 0), + QItemSelectionModel::ClearAndSelect | + QItemSelectionModel::Rows); + EXPECT_EQ(counter, 0); + EXPECT_TRUE(stack->CanRedo()); + + stack->clear(); +} + +TEST(TimelineSelections, ShiftTimeMovesAllRanges) +{ + TimelineWidgetSelections sel; + const Track::Reference video0(Track::kVideo, 0); + sel.insert(video0, + TimeRangeList({ TimeRange(rational(0), rational(10)) })); + + sel.ShiftTime(rational(5)); + const TimeRangeList list = sel.value(video0); + ASSERT_EQ(list.size(), 1); + EXPECT_EQ(list.first().in(), rational(5)); + EXPECT_EQ(list.first().out(), rational(15)); +} + +TEST(TimelineSelections, ShiftTracksReindexesMatchingTypeOnly) +{ + TimelineWidgetSelections sel; + sel.insert(Track::Reference(Track::kVideo, 0), + TimeRangeList({ TimeRange(rational(0), rational(10)) })); + sel.insert(Track::Reference(Track::kVideo, 1), + TimeRangeList({ TimeRange(rational(0), rational(10)) })); + sel.insert(Track::Reference(Track::kAudio, 0), + TimeRangeList({ TimeRange(rational(0), rational(10)) })); + + sel.ShiftTracks(Track::kVideo, 2); + + EXPECT_FALSE(sel.contains(Track::Reference(Track::kVideo, 0))); + EXPECT_FALSE(sel.contains(Track::Reference(Track::kVideo, 1))); + EXPECT_TRUE(sel.contains(Track::Reference(Track::kVideo, 2))); + EXPECT_TRUE(sel.contains(Track::Reference(Track::kVideo, 3))); + EXPECT_TRUE(sel.contains(Track::Reference(Track::kAudio, 0))); +} + +TEST(TimelineSelections, TrimInAndOutAdjustRangeEnds) +{ + TimelineWidgetSelections in_sel; + const Track::Reference video0(Track::kVideo, 0); + in_sel.insert(video0, + TimeRangeList({ TimeRange(rational(0), rational(10)) })); + in_sel.TrimIn(rational(2)); + EXPECT_EQ(in_sel.value(video0).first().in(), rational(2)); + EXPECT_EQ(in_sel.value(video0).first().out(), rational(10)); + + TimelineWidgetSelections out_sel; + out_sel.insert(video0, + TimeRangeList({ TimeRange(rational(0), rational(10)) })); + out_sel.TrimOut(rational(-3)); + EXPECT_EQ(out_sel.value(video0).first().in(), rational(0)); + EXPECT_EQ(out_sel.value(video0).first().out(), rational(7)); +} + +TEST(TimelineSelections, SubtractSplitsAndIgnoresForeignTracks) +{ + TimelineWidgetSelections ours; + const Track::Reference video0(Track::kVideo, 0); + ours.insert(video0, + TimeRangeList({ TimeRange(rational(0), rational(10)) })); + + TimelineWidgetSelections theirs; + theirs.insert(video0, + TimeRangeList({ TimeRange(rational(3), rational(5)) })); + theirs.insert(Track::Reference(Track::kAudio, 0), + TimeRangeList({ TimeRange(rational(0), rational(99)) })); + + TimelineWidgetSelections result = ours.Subtracted(theirs); + + // The original is untouched by the const version + EXPECT_EQ(ours.value(video0).size(), 1); + + const TimeRangeList remaining = result.value(video0); + ASSERT_EQ(remaining.size(), 2); + EXPECT_EQ(remaining.at(0), TimeRange(rational(0), rational(3))); + EXPECT_EQ(remaining.at(1), TimeRange(rational(5), rational(10))); + + // In-place Subtract drops the subtracted span as well + ours.Subtract(theirs); + EXPECT_EQ(ours.value(video0).size(), 2); +} + +TEST(NodeViewScene, AddAndRemoveContexts) +{ + ColorManager::SetUpDefaultConfig(); + Project project; + project.Initialize(); + auto *folder = new Folder(); + folder->setParent(&project); + + NodeViewScene scene; + EXPECT_TRUE(scene.context_map().isEmpty()); + + NodeViewContext *ctx = scene.AddContext(folder); + ASSERT_NE(ctx, nullptr); + EXPECT_TRUE(scene.context_map().contains(folder)); + EXPECT_EQ(ctx->GetContext(), folder); + EXPECT_TRUE(scene.items().contains(ctx)); + + // Re-adding the same node returns the existing context item + EXPECT_EQ(scene.AddContext(folder), ctx); + EXPECT_EQ(scene.context_map().size(), 1); + + scene.RemoveContext(folder); + EXPECT_TRUE(scene.context_map().isEmpty()); +} + +TEST(NodeViewScene, FlowDirectionControlsOrientation) +{ + NodeViewScene scene; + EXPECT_EQ(scene.GetFlowDirection(), NodeViewCommon::kLeftToRight); + EXPECT_EQ(scene.GetFlowOrientation(), Qt::Horizontal); + + scene.SetFlowDirection(NodeViewCommon::kTopToBottom); + EXPECT_EQ(scene.GetFlowDirection(), NodeViewCommon::kTopToBottom); + EXPECT_EQ(scene.GetFlowOrientation(), Qt::Vertical); +} + +class MulticamWidgetTest : public ::testing::Test { +protected: + void SetUp() override + { + ColorManager::SetUpDefaultConfig(); + EnsureAppSingletons(); + + // The display widget pulls the render backend off RenderManager, + // which the bare Core singleton does not create (Core::Start() + // would); viewer_display_repro_test does the same + created_render_manager_ = (RenderManager::instance() == nullptr); + if (created_render_manager_) { + RenderManager::CreateInstance(); + } + + project_ = std::make_unique(); + project_->Initialize(); + } + + void TearDown() override + { + project_.reset(); + + // Leave the singleton the way we found it: render suites check + // RenderManager::instance() for null in their own teardowns + if (created_render_manager_) { + RenderManager::DestroyInstance(); + created_render_manager_ = false; + } + } + + std::unique_ptr project_; + bool created_render_manager_ = false; +}; + +TEST_F(MulticamWidgetTest, ConstructionCreatesDisplay) +{ + MulticamWidget widget; + EXPECT_NE(widget.GetDisplayWidget(), nullptr); + EXPECT_EQ(widget.GetConnectedNode(), nullptr); +} + +TEST_F(MulticamWidgetTest, SwitchWithoutTimestampAppliesImmediately) +{ + auto *viewer = new ViewerOutput(); + viewer->setParent(project_.get()); + auto *node = new MultiCamNode(); + node->setParent(project_.get()); + auto *clip = new ClipBlock(); + clip->setParent(project_.get()); + + MulticamWidget widget; + widget.SetMulticamNode(viewer, node, clip, rational()); + EXPECT_EQ(widget.GetConnectedNode(), viewer); +} + +TEST_F(MulticamWidgetTest, FutureSwitchWaitsForPlayheadToAdvance) +{ + auto *viewer_a = new ViewerOutput(); + viewer_a->setParent(project_.get()); + auto *viewer_b = new ViewerOutput(); + viewer_b->setParent(project_.get()); + auto *node = new MultiCamNode(); + node->setParent(project_.get()); + auto *clip = new ClipBlock(); + clip->setParent(project_.get()); + + MulticamWidget widget; + widget.SetMulticamNode(viewer_a, node, clip, rational()); + ASSERT_EQ(widget.GetConnectedNode(), viewer_a); + + // A switch stamped for a later time is queued, not applied + widget.SetMulticamNode(viewer_b, node, clip, rational(5)); + EXPECT_EQ(widget.GetConnectedNode(), viewer_a); + + // Once playback time advances, the queued switch takes effect + viewer_a->SetPlayhead(rational(1)); + EXPECT_EQ(widget.GetConnectedNode(), viewer_b); +} diff --git a/tests/gtest/widget_projectexplorer_test.cpp b/tests/gtest/widget_projectexplorer_test.cpp new file mode 100644 index 000000000..1b2d611a4 --- /dev/null +++ b/tests/gtest/widget_projectexplorer_test.cpp @@ -0,0 +1,445 @@ +#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 EnsureAppSingletons() +{ + if (!olive::Core::instance()) { + new olive::Core(olive::Core::CoreParams()); // intentionally leaked + } + if (!olive::DiskManager::instance()) { + olive::DiskManager::CreateInstance(); + } +} + +} // namespace + +class ProjectViewModelTest : public ::testing::Test { +protected: + void SetUp() override + { + ColorManager::SetUpDefaultConfig(); + EnsureAppSingletons(); + + project_ = std::make_unique(); + project_->Initialize(); + + model_.set_project(project_.get()); + } + + template T *AddItem(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 = AddItem(project_->root()); + Footage *footage = AddItem(project_->root()); + Sequence *sequence = AddItem(project_->root()); + Footage *nested = AddItem(folder); + + ASSERT_EQ(model_.rowCount(), 3); + EXPECT_EQ(model_.columnCount(), ProjectViewModel::kColumnCount); + + // 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_.CreateIndexFromItem(footage), model_.index(1, 0)); + EXPECT_EQ(model_.CreateIndexFromItem(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 = AddItem(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 = AddItem(project_->root()); + folder->SetLabel(QStringLiteral("Media")); + + QModelIndex name_index = model_.CreateIndexFromItem(folder, ProjectViewModel::kName); + 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::kInnerTextRole).toString(), + QStringLiteral("Media")); + + // A folder carries no duration/rate/timestamps + EXPECT_FALSE(model_.data(model_.CreateIndexFromItem(folder, ProjectViewModel::kDuration), + Qt::DisplayRole) + .isValid()); + EXPECT_FALSE(model_.data(model_.CreateIndexFromItem(folder, ProjectViewModel::kRate), + Qt::DisplayRole) + .isValid()); + + // EditRole is only served for the name column + EXPECT_FALSE(model_.data(model_.CreateIndexFromItem(folder, ProjectViewModel::kDuration), + Qt::EditRole) + .isValid()); + + EXPECT_EQ(model_.headerData(ProjectViewModel::kName, Qt::Horizontal).toString(), + QStringLiteral("Name")); + EXPECT_EQ(model_.headerData(ProjectViewModel::kDuration, Qt::Horizontal).toString(), + QStringLiteral("Duration")); + EXPECT_EQ(model_.headerData(ProjectViewModel::kRate, Qt::Horizontal).toString(), + QStringLiteral("Rate")); + EXPECT_EQ(model_.headerData(ProjectViewModel::kLastModified, Qt::Horizontal).toString(), + QStringLiteral("Modified")); + EXPECT_EQ(model_.headerData(ProjectViewModel::kCreatedTime, Qt::Horizontal).toString(), + QStringLiteral("Created")); +} + +TEST_F(ProjectViewModelTest, FlagsMarkNameEditableAndFoldersDroppable) +{ + Folder *folder = AddItem(project_->root()); + Footage *footage = AddItem(project_->root()); + + const Qt::ItemFlags folder_name_flags = + model_.flags(model_.CreateIndexFromItem(folder, ProjectViewModel::kName)); + 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_.CreateIndexFromItem(footage, ProjectViewModel::kDuration)); + 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 = AddItem(project_->root()); + folder->SetLabel(QStringLiteral("Before")); + + QSignalSpy data_changed(&model_, &QAbstractItemModel::dataChanged); + + QModelIndex name_index = model_.CreateIndexFromItem(folder, ProjectViewModel::kName); + EXPECT_TRUE(model_.setData(name_index, QStringLiteral("After"), Qt::EditRole)); + EXPECT_EQ(folder->GetLabel(), QStringLiteral("After")); + EXPECT_GE(data_changed.count(), 1); + + // The rename is a regular undo command + Core::instance()->undo_stack()->undo(); + EXPECT_EQ(folder->GetLabel(), 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_.CreateIndexFromItem(folder, ProjectViewModel::kRate), + QStringLiteral("After"), Qt::EditRole)); + EXPECT_EQ(folder->GetLabel(), QStringLiteral("Before")); +} + +TEST_F(ProjectViewModelTest, MimeDataEncodesEachRowOnce) +{ + EXPECT_EQ(model_.mimeTypes(), + (QStringList{ Project::kItemMimeType, QStringLiteral("text/uri-list") })); + + Footage *footage = AddItem(project_->root()); + Folder *folder = AddItem(project_->root()); + + // Passing every column of two rows must still encode only two items + QModelIndexList indexes{ model_.CreateIndexFromItem(footage, ProjectViewModel::kName), + model_.CreateIndexFromItem(footage, ProjectViewModel::kDuration), + model_.CreateIndexFromItem(folder, ProjectViewModel::kName) }; + std::unique_ptr mime(model_.mimeData(indexes)); + ASSERT_NE(mime, nullptr); + ASSERT_TRUE(mime->hasFormat(Project::kItemMimeType)); + + QByteArray encoded = mime->data(Project::kItemMimeType); + 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 = AddItem(project_->root()); + Footage *footage = AddItem(project_->root()); + ASSERT_EQ(model_.rowCount(), 2); + + std::unique_ptr mime( + model_.mimeData({ model_.CreateIndexFromItem(footage) })); + ASSERT_NE(mime, nullptr); + + EXPECT_TRUE(model_.dropMimeData(mime.get(), Qt::CopyAction, -1, -1, + model_.CreateIndexFromItem(folder))); + EXPECT_EQ(footage->folder(), folder); + EXPECT_EQ(model_.rowCount(), 1); + EXPECT_EQ(model_.rowCount(model_.CreateIndexFromItem(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 = AddItem(project_->root()); + Folder *subfolder = AddItem(folder); + Footage *footage = AddItem(project_->root()); + + // Cannot drop onto a non-folder item + std::unique_ptr footage_mime( + model_.mimeData({ model_.CreateIndexFromItem(footage) })); + EXPECT_FALSE(model_.dropMimeData(footage_mime.get(), Qt::CopyAction, -1, -1, + model_.CreateIndexFromItem(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_.CreateIndexFromItem(folder) })); + EXPECT_TRUE(model_.dropMimeData(folder_mime.get(), Qt::CopyAction, -1, -1, + model_.CreateIndexFromItem(subfolder))); + EXPECT_EQ(folder->folder(), project_->root()); + + // Dropping onto the background moves items to the root + std::unique_ptr sub_mime( + model_.mimeData({ model_.CreateIndexFromItem(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::SetUpDefaultConfig(); + EnsureAppSingletons(); + + project_ = std::make_unique(); + project_->Initialize(); + } + + template T *AddItem(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::TreeView); + + explorer.set_view_type(ProjectToolbar::ListView); + EXPECT_EQ(explorer.view_type(), ProjectToolbar::ListView); + + explorer.set_view_type(ProjectToolbar::IconView); + EXPECT_EQ(explorer.view_type(), ProjectToolbar::IconView); +} + +TEST_F(ProjectExplorerTest, GetSelectedFolderFallsBackToRoot) +{ + Folder *folder = AddItem(project_->root()); + Footage *footage = AddItem(project_->root()); + + ProjectExplorer explorer(nullptr); + explorer.set_project(project_.get()); + + // No selection: heuristic returns the project root + EXPECT_EQ(explorer.GetSelectedFolder(), project_->root()); + + // A selected folder is returned directly + EXPECT_TRUE(explorer.SelectItem(folder)); + EXPECT_EQ(explorer.GetSelectedFolder(), folder); + + // A selected non-folder resolves to its parent folder + EXPECT_TRUE(explorer.SelectItem(footage)); + EXPECT_EQ(explorer.GetSelectedFolder(), project_->root()); +} + +TEST_F(ProjectExplorerTest, SelectItemUpdatesSelectedItems) +{ + Footage *footage = AddItem(project_->root()); + + ProjectExplorer explorer(nullptr); + explorer.set_project(project_.get()); + + EXPECT_TRUE(explorer.SelectedItems().isEmpty()); + + EXPECT_TRUE(explorer.SelectItem(footage)); + EXPECT_EQ(explorer.SelectedItems().size(), 1); + EXPECT_EQ(explorer.SelectedItems().first(), footage); + + explorer.DeselectAll(); + EXPECT_TRUE(explorer.SelectedItems().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::NewClicked); + QSignalSpy open_spy(&toolbar, &ProjectToolbar::OpenClicked); + QSignalSpy save_spy(&toolbar, &ProjectToolbar::SaveClicked); + + 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::SearchChanged); + + 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::TreeView; + int emissions = 0; + QObject::connect(&toolbar, &ProjectToolbar::ViewChanged, + [&received, &emissions](ProjectToolbar::ViewType type) { + received = type; + ++emissions; + }); + + list_button->click(); + EXPECT_EQ(emissions, 1); + EXPECT_EQ(received, ProjectToolbar::ListView); + 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::IconView); + EXPECT_TRUE(icon_button->isChecked()); + EXPECT_FALSE(list_button->isChecked()); + + // SetView only checks the button; it does not re-emit ViewChanged + toolbar.SetView(ProjectToolbar::TreeView); + EXPECT_EQ(emissions, 2); + EXPECT_TRUE(tree_button->isChecked()); + EXPECT_FALSE(icon_button->isChecked()); +} diff --git a/tests/gtest/widget_timeruler_playback_test.cpp b/tests/gtest/widget_timeruler_playback_test.cpp new file mode 100644 index 000000000..1c86594e9 --- /dev/null +++ b/tests/gtest/widget_timeruler_playback_test.cpp @@ -0,0 +1,285 @@ +#include + +#include +#include +#include +#include + +#include "core.h" +#include "node/output/viewer/viewer.h" +#include "olive/core/util/timecodefunctions.h" +#include "render/diskmanager.h" +#include "widget/playbackcontrols/playbackcontrols.h" +#include "widget/slider/base/sliderbase.h" +#include "widget/slider/base/sliderlabel.h" +#include "widget/slider/rationalslider.h" +#include "widget/timeruler/timeruler.h" + +using namespace olive; + +namespace +{ + +// PlaybackControls and playhead seeking talk to the Core singleton +void EnsureAppSingletons() +{ + if (!olive::Core::instance()) { + new olive::Core(olive::Core::CoreParams()); // intentionally leaked + } + if (!olive::DiskManager::instance()) { + olive::DiskManager::CreateInstance(); + } +} + +} // namespace + +TEST(TimeRuler, ConstructionWithAndWithoutDecorations) +{ + TimeRuler plain; + EXPECT_EQ(plain.GetMarkers(), nullptr); + EXPECT_EQ(plain.GetWorkArea(), nullptr); + + // Text hidden, cache status shown + TimeRuler decorated(false, true); + decorated.SetCenteredText(true); + SUCCEED(); +} + +TEST(TimeRuler, TimebaseAndScaleDriveTimePixelConversion) +{ + TimeRuler ruler; + + // Without a timebase everything collapses to zero + EXPECT_DOUBLE_EQ(ruler.TimeToScene(rational(1)), 0.0); + + ruler.SetTimebase(rational(1, 30)); + ruler.SetScale(100.0); + + // One second at scale 100 lands at scene x=100, half a second at 50 + EXPECT_DOUBLE_EQ(ruler.TimeToScene(rational(1)), 100.0); + EXPECT_DOUBLE_EQ(ruler.TimeToScene(rational(1, 2)), 50.0); + + // Inverse conversion returns whole frames in the ruler's timebase + EXPECT_EQ(ruler.SceneToTime(100.0), rational(1)); + EXPECT_EQ(ruler.SceneToTime(50.0), rational(1, 2)); + + // Fractional positions floor to the frame below (or ceil when negative) + EXPECT_EQ(ruler.SceneToTime(51.0), rational(1, 2)); + EXPECT_EQ(ruler.SceneToTime(-51.0), rational(-1, 2)); + + // Rounding mode snaps to the nearest frame instead + EXPECT_EQ(ruler.SceneToTime(51.0, true), rational(1, 2)); + EXPECT_EQ(ruler.SceneToTime(80.0, true), rational(4, 5)); +} + +TEST(TimeRuler, SeekToScenePointSeeksConnectedViewer) +{ + EnsureAppSingletons(); + + TimeRuler ruler; + ruler.SetTimebase(rational(1, 30)); + ruler.SetScale(100.0); + + ViewerOutput viewer; + ruler.SetViewerNode(&viewer); + + ruler.SeekToScenePoint(150.0); + EXPECT_EQ(viewer.GetPlayhead(), rational(3, 2)); + + // Positions before zero clamp to zero + viewer.SetPlayhead(rational(5)); + ruler.SeekToScenePoint(-50.0); + EXPECT_EQ(viewer.GetPlayhead(), rational(0)); +} + +TEST(TimeRuler, SeekToScenePointWithoutTimebaseIsNoOp) +{ + // No timebase and no viewer: must return before touching either + TimeRuler ruler; + ruler.SeekToScenePoint(150.0); + SUCCEED(); +} + +class PlaybackControlsTest : public ::testing::Test { +protected: + void SetUp() override + { + EnsureAppSingletons(); + } + + // The play/pause stacked widget (SliderBase is also a QStackedWidget + // and must be filtered out) + static QStackedWidget *PlayPauseStack(PlaybackControls *controls) + { + foreach (QStackedWidget *s, controls->findChildren()) { + if (!qobject_cast(s)) { + return s; + } + } + return nullptr; + } + + // The play/pause buttons live inside the play/pause stacked widget + static void PlayPauseButtons(PlaybackControls *controls, + QPushButton **play_btn, QPushButton **pause_btn) + { + QStackedWidget *stack = PlayPauseStack(controls); + *play_btn = qobject_cast(stack->widget(0)); + *pause_btn = qobject_cast(stack->widget(1)); + } + + // The remaining buttons in creation order: go-to-start, previous frame, + // next frame, go-to-end, video drag, audio drag + static QList NavigationButtons(PlaybackControls *controls) + { + QPushButton *play_btn; + QPushButton *pause_btn; + PlayPauseButtons(controls, &play_btn, &pause_btn); + + QList buttons; + foreach (QPushButton *b, controls->findChildren()) { + if (b != play_btn && b != pause_btn) { + buttons.append(b); + } + } + return buttons; + } +}; + +TEST_F(PlaybackControlsTest, NullTimebaseDisablesWidget) +{ + PlaybackControls controls; + EXPECT_FALSE(controls.isEnabled()); + + controls.SetTimebase(rational(1, 30)); + EXPECT_TRUE(controls.isEnabled()); + + controls.SetTimebase(rational()); + EXPECT_FALSE(controls.isEnabled()); +} + +TEST_F(PlaybackControlsTest, ButtonsEmitCorrespondingSignals) +{ + PlaybackControls controls; + controls.SetTimebase(rational(1, 30)); + + QPushButton *play_btn; + QPushButton *pause_btn; + PlayPauseButtons(&controls, &play_btn, &pause_btn); + ASSERT_NE(play_btn, nullptr); + ASSERT_NE(pause_btn, nullptr); + + const QList buttons = NavigationButtons(&controls); + ASSERT_EQ(buttons.size(), 6); + + QSignalSpy begin_spy(&controls, &PlaybackControls::BeginClicked); + QSignalSpy prev_spy(&controls, &PlaybackControls::PrevFrameClicked); + QSignalSpy play_spy(&controls, &PlaybackControls::PlayClicked); + QSignalSpy pause_spy(&controls, &PlaybackControls::PauseClicked); + QSignalSpy next_spy(&controls, &PlaybackControls::NextFrameClicked); + QSignalSpy end_spy(&controls, &PlaybackControls::EndClicked); + QSignalSpy video_spy(&controls, &PlaybackControls::VideoClicked); + QSignalSpy audio_spy(&controls, &PlaybackControls::AudioClicked); + + buttons.at(0)->click(); + EXPECT_EQ(begin_spy.count(), 1); + + buttons.at(1)->click(); + EXPECT_EQ(prev_spy.count(), 1); + + play_btn->click(); + EXPECT_EQ(play_spy.count(), 1); + + pause_btn->click(); + EXPECT_EQ(pause_spy.count(), 1); + + buttons.at(2)->click(); + EXPECT_EQ(next_spy.count(), 1); + + buttons.at(3)->click(); + EXPECT_EQ(end_spy.count(), 1); + + buttons.at(4)->click(); + EXPECT_EQ(video_spy.count(), 1); + + buttons.at(5)->click(); + EXPECT_EQ(audio_spy.count(), 1); +} + +TEST_F(PlaybackControlsTest, SetTimeUpdatesCurrentTimecodeWithoutEmitting) +{ + PlaybackControls controls; + controls.SetTimebase(rational(1, 30)); + + auto *slider = controls.findChild(); + ASSERT_NE(slider, nullptr); + + QSignalSpy time_spy(&controls, &PlaybackControls::TimeChanged); + + controls.SetTime(rational(3, 2)); + EXPECT_EQ(slider->GetValue(), rational(3, 2)); + + // Programmatic updates must not feed back into TimeChanged + EXPECT_EQ(time_spy.count(), 0); +} + +TEST_F(PlaybackControlsTest, SetEndTimeFormatsEndTimecodeLabel) +{ + PlaybackControls controls; + controls.SetTimebase(rational(1, 30)); + + // The only plain QLabel is the end timecode; the current-time slider + // uses a SliderLabel (a QLabel subclass) internally + QLabel *end_label = nullptr; + foreach (QLabel *l, controls.findChildren()) { + if (!qobject_cast(l)) { + end_label = l; + break; + } + } + ASSERT_NE(end_label, nullptr); + + controls.SetEndTime(rational(30)); + const QString expected = QString::fromStdString( + core::Timecode::time_to_timecode(rational(30), rational(1, 30), + Core::instance()->GetTimecodeDisplay())); + EXPECT_EQ(end_label->text(), expected); +} + +TEST_F(PlaybackControlsTest, PlayPauseStackSwitchesVisibleButton) +{ + PlaybackControls controls; + + QStackedWidget *stack = PlayPauseStack(&controls); + ASSERT_NE(stack, nullptr); + + QPushButton *play_btn; + QPushButton *pause_btn; + PlayPauseButtons(&controls, &play_btn, &pause_btn); + ASSERT_NE(play_btn, nullptr); + ASSERT_NE(pause_btn, nullptr); + + // The play button is the default page + EXPECT_EQ(stack->currentWidget(), play_btn); + + controls.ShowPauseButton(); + EXPECT_EQ(stack->currentWidget(), pause_btn); + + controls.ShowPlayButton(); + EXPECT_EQ(stack->currentWidget(), play_btn); +} + +TEST_F(PlaybackControlsTest, AudioVideoDragButtonsToggleVisibility) +{ + PlaybackControls controls; + const QList buttons = NavigationButtons(&controls); + ASSERT_EQ(buttons.size(), 6); + + // Hidden by default (constructor passes false) + EXPECT_TRUE(buttons.at(4)->isHidden()); + EXPECT_TRUE(buttons.at(5)->isHidden()); + + controls.SetAudioVideoDragButtonsVisible(true); + EXPECT_FALSE(buttons.at(4)->isHidden()); + EXPECT_FALSE(buttons.at(5)->isHidden()); +}