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
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include <QSignalSpy>
|
||||
|
||||
#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>();
|
||||
project_->Initialize();
|
||||
}
|
||||
|
||||
MathNode *AddMathNode()
|
||||
{
|
||||
auto *node = new MathNode();
|
||||
node->setParent(project_.get());
|
||||
return node;
|
||||
}
|
||||
|
||||
std::unique_ptr<Project> 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>();
|
||||
project_->Initialize();
|
||||
|
||||
node_ = new MathNode();
|
||||
node_->setParent(project_.get());
|
||||
}
|
||||
|
||||
std::unique_ptr<Project> 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>();
|
||||
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> 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<NodeTreeView *>();
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QCoreApplication>
|
||||
#include <QEvent>
|
||||
#include <QLabel>
|
||||
#include <QProgressBar>
|
||||
#include <QPushButton>
|
||||
#include <QTreeWidgetItem>
|
||||
|
||||
#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>();
|
||||
project_->Initialize();
|
||||
}
|
||||
|
||||
template <typename T> T *AddNode()
|
||||
{
|
||||
auto *node = new T();
|
||||
node->setParent(project_.get());
|
||||
return node;
|
||||
}
|
||||
|
||||
std::unique_ptr<Project> project_;
|
||||
};
|
||||
|
||||
TEST_F(WidgetPanelsTest, NodeTableSelectNodesCreatesTopLevelItems)
|
||||
{
|
||||
auto *solid = AddNode<SolidGenerator>();
|
||||
|
||||
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<MathNode>();
|
||||
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<SolidGenerator>();
|
||||
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<MathNode>();
|
||||
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<NodeKeyframeTrackReference>();
|
||||
EXPECT_EQ(ref.input().node(), math);
|
||||
EXPECT_EQ(ref.input().input(), expected_inputs.at(i));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(WidgetPanelsTest, NodeTreeOnlyShowKeyframableFiltersInputs)
|
||||
{
|
||||
auto *math = AddNode<MathNode>();
|
||||
|
||||
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<ViewerOutput>();
|
||||
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<MathNode>();
|
||||
|
||||
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<SolidGenerator>();
|
||||
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<NodeKeyframeTrackReference>();
|
||||
EXPECT_EQ(ref.track(), i);
|
||||
}
|
||||
|
||||
// A single-track float input stays a single row
|
||||
auto *math = AddNode<MathNode>();
|
||||
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<MathNode>();
|
||||
|
||||
QWidget parent;
|
||||
NodeParamViewWidgetBridge bridge(NodeInput(math, MathNode::kParamAIn), &parent);
|
||||
|
||||
ASSERT_EQ(bridge.widgets().size(), 1);
|
||||
EXPECT_NE(qobject_cast<FloatSlider *>(bridge.widgets().first()), nullptr);
|
||||
}
|
||||
|
||||
TEST_F(WidgetPanelsTest, BridgeCreatesColorButtonForColorInput)
|
||||
{
|
||||
auto *solid = AddNode<SolidGenerator>();
|
||||
|
||||
QWidget parent;
|
||||
NodeParamViewWidgetBridge bridge(NodeInput(solid, SolidGenerator::kColorInput),
|
||||
&parent);
|
||||
|
||||
ASSERT_EQ(bridge.widgets().size(), 1);
|
||||
EXPECT_NE(qobject_cast<ColorButton *>(bridge.widgets().first()), nullptr);
|
||||
}
|
||||
|
||||
TEST_F(WidgetPanelsTest, BridgeCreatesComboBoxForComboInput)
|
||||
{
|
||||
auto *math = AddNode<MathNode>();
|
||||
math->Retranslate();
|
||||
|
||||
QWidget parent;
|
||||
NodeParamViewWidgetBridge bridge(NodeInput(math, MathNode::kMethodIn), &parent);
|
||||
|
||||
ASSERT_EQ(bridge.widgets().size(), 1);
|
||||
auto *combo = qobject_cast<QComboBox *>(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<ClipBlock>();
|
||||
|
||||
QWidget parent;
|
||||
NodeParamViewWidgetBridge bridge(NodeInput(clip, ClipBlock::kReverseInput),
|
||||
&parent);
|
||||
|
||||
ASSERT_EQ(bridge.widgets().size(), 1);
|
||||
EXPECT_NE(qobject_cast<QCheckBox *>(bridge.widgets().first()), nullptr);
|
||||
}
|
||||
|
||||
TEST_F(WidgetPanelsTest, BridgeUpdatesWidgetWhenNodeValueChanges)
|
||||
{
|
||||
auto *math = AddNode<MathNode>();
|
||||
auto *viewer = AddNode<ViewerOutput>();
|
||||
|
||||
QWidget parent;
|
||||
NodeParamViewWidgetBridge bridge(NodeInput(math, MathNode::kParamAIn), &parent);
|
||||
auto *slider = qobject_cast<FloatSlider *>(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<MathNode>();
|
||||
math->Retranslate();
|
||||
ASSERT_EQ(math->GetStandardValue(MathNode::kMethodIn).toInt(), 0);
|
||||
|
||||
QWidget parent;
|
||||
NodeParamViewWidgetBridge bridge(NodeInput(math, MathNode::kMethodIn), &parent);
|
||||
auto *combo = qobject_cast<QComboBox *>(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<TaskViewItem *>();
|
||||
ASSERT_NE(item, nullptr);
|
||||
|
||||
// Title label mirrors the task title
|
||||
bool found_title = false;
|
||||
foreach (QLabel *label, item->findChildren<QLabel *>()) {
|
||||
if (label->text() == QStringLiteral("Test Task")) {
|
||||
found_title = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(found_title);
|
||||
|
||||
// Progress signals drive the progress bar
|
||||
auto *bar = item->findChild<QProgressBar *>();
|
||||
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<QPushButton *>();
|
||||
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<QLabel *>()) {
|
||||
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<TaskViewItem *>(), 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>();
|
||||
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> 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);
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
#include <QLineEdit>
|
||||
#include <QMimeData>
|
||||
#include <QPushButton>
|
||||
#include <QSignalSpy>
|
||||
|
||||
#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>();
|
||||
project_->Initialize();
|
||||
|
||||
model_.set_project(project_.get());
|
||||
}
|
||||
|
||||
template <typename T> T *AddItem(Folder *parent)
|
||||
{
|
||||
auto *node = new T();
|
||||
node->setParent(project_.get());
|
||||
FolderAddChild(parent, node).redo_now();
|
||||
return node;
|
||||
}
|
||||
|
||||
std::unique_ptr<Project> 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<Folder>(project_->root());
|
||||
Footage *footage = AddItem<Footage>(project_->root());
|
||||
Sequence *sequence = AddItem<Sequence>(project_->root());
|
||||
Footage *nested = AddItem<Footage>(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<Footage>(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<Folder>(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<Folder>(project_->root());
|
||||
Footage *footage = AddItem<Footage>(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<Folder>(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<Footage>(project_->root());
|
||||
Folder *folder = AddItem<Folder>(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<QMimeData> 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<Track::Reference> streams;
|
||||
quintptr ptr = 0;
|
||||
|
||||
stream >> streams >> ptr;
|
||||
EXPECT_EQ(reinterpret_cast<Node *>(ptr), footage);
|
||||
|
||||
stream >> streams >> ptr;
|
||||
EXPECT_EQ(reinterpret_cast<Node *>(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<Folder>(project_->root());
|
||||
Footage *footage = AddItem<Footage>(project_->root());
|
||||
ASSERT_EQ(model_.rowCount(), 2);
|
||||
|
||||
std::unique_ptr<QMimeData> 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<Folder>(project_->root());
|
||||
Folder *subfolder = AddItem<Folder>(folder);
|
||||
Footage *footage = AddItem<Footage>(project_->root());
|
||||
|
||||
// Cannot drop onto a non-folder item
|
||||
std::unique_ptr<QMimeData> 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<QMimeData> 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<QMimeData> 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>();
|
||||
project_->Initialize();
|
||||
}
|
||||
|
||||
template <typename T> T *AddItem(Folder *parent)
|
||||
{
|
||||
auto *node = new T();
|
||||
node->setParent(project_.get());
|
||||
FolderAddChild(parent, node).redo_now();
|
||||
return node;
|
||||
}
|
||||
|
||||
std::unique_ptr<Project> 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<Folder>(project_->root());
|
||||
Footage *footage = AddItem<Footage>(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<Footage>(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<QPushButton *> buttons = toolbar.findChildren<QPushButton *>();
|
||||
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<QLineEdit *>();
|
||||
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<QPushButton *> buttons = toolbar.findChildren<QPushButton *>();
|
||||
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());
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QSignalSpy>
|
||||
#include <QStackedWidget>
|
||||
|
||||
#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<QStackedWidget *>()) {
|
||||
if (!qobject_cast<SliderBase *>(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<QPushButton *>(stack->widget(0));
|
||||
*pause_btn = qobject_cast<QPushButton *>(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<QPushButton *> NavigationButtons(PlaybackControls *controls)
|
||||
{
|
||||
QPushButton *play_btn;
|
||||
QPushButton *pause_btn;
|
||||
PlayPauseButtons(controls, &play_btn, &pause_btn);
|
||||
|
||||
QList<QPushButton *> buttons;
|
||||
foreach (QPushButton *b, controls->findChildren<QPushButton *>()) {
|
||||
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<QPushButton *> 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<RationalSlider *>();
|
||||
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<QLabel *>()) {
|
||||
if (!qobject_cast<SliderLabel *>(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<QPushButton *> 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());
|
||||
}
|
||||
Reference in New Issue
Block a user