Files
oak-editor/tests/gtest/widget_curve_keyframe_test.cpp
T
Mike-Solar bb40b4923e style: unify identifier naming per updated conventions
Automated with clang-tidy readability-identifier-naming (config added to
.clang-tidy) plus scripted passes, per the updated rules now documented
in CONTRIBUTING.md:

- types (class/struct/enum/alias/template params): PascalCase
- functions, variables, members: snake_case (incl. rational -> Rational)
- private/protected members: trailing underscore; static member
  variables likewise (instance_, available_themes_)
- constants and enum values: snake_case (kLinear -> k_linear,
  F32P -> f32p); ALL_CAPS reserved for macros
- macros: OAK_ prefix (OLIVE_ADD_TEST/OLIVE_ASSERT/OLIVE_CONFIG ->
  OAK_ADD_TEST/OAK_ASSERT/OAK_CONFIG, GL_PREAMBLE -> OAK_GL_PREAMBLE,
  include guards -> OAK_*)
- file names: all lowercase (Current/Plugin/OliveHost/OliveClip/
  OlivePluginInstance -> current/plugin/olivehost/oliveclip/
  oliveplugininstance)
- getters share the member name sans underscore, setters set_foo()
- Qt and third-party (OpenFX) virtual overrides and framework callbacks
  keep their original names (exempt in .clang-tidy)

Manual follow-ups required where automation could not reach:
- string-based QMetaObject/SIGNAL/SLOT references updated to renamed
  methods (AddTask, CreatedFile, DeleteSpecificFile, moveSelectionUp, ...)
- macro bodies referencing renamed methods (OLIVE_CONFIG,
  NODE_DEFAULT_DESTRUCTOR, MANAGEDDISPLAYWIDGET_*)
- self-shadowing locals renamed where signals/methods became same-named
  (size_changed, worker_count, selected_items, import param, filters)
- third_party OFX member/namespace usages restored (OFX::Host::*,
  _created, _clipPrefsDirty, createInstance, clearPersistentMessage)
- STL protocol aliases restored (const_iterator) with .clang-tidy
  ignore rules; qHash overloads restored

Full build and test suite pass: ctest 4/4, ~1960 gtest cases green.
2026-07-19 16:10:54 +08:00

422 lines
12 KiB
C++

#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 ensure_app_singletons()
{
if (!olive::Core::instance()) {
new olive::Core(olive::Core::CoreParams()); // intentionally leaked
}
if (!olive::DiskManager::instance()) {
olive::DiskManager::create_instance();
}
}
NodeKeyframe *insert_keyframe(Node *node, const QString &input,
const Rational &time, const QVariant &value,
int track = 0)
{
auto *key =
new NodeKeyframe(time, value, NodeKeyframe::k_linear, track, -1, input);
NodeParamInsertKeyframeCommand(node, key).redo_now();
return key;
}
} // namespace
class KeyframeViewTest : public ::testing::Test {
protected:
void SetUp() override
{
ColorManager::set_up_default_config();
ensure_app_singletons();
project_ = std::make_unique<Project>();
project_->initialize();
}
MathNode *add_math_node()
{
auto *node = new MathNode();
node->setParent(project_.get());
return node;
}
std::unique_ptr<Project> project_;
};
TEST_F(KeyframeViewTest, AddKeyframesOfNodeCreatesConnectionPerKeyframableInput)
{
MathNode *node = add_math_node();
KeyframeView view;
KeyframeView::NodeConnections map = view.add_keyframes_of_node(node);
// A float input has one element (the array-less -1) with one track
ASSERT_TRUE(map.contains(MathNode::k_param_a_in));
const KeyframeView::InputConnections &param_a = map[MathNode::k_param_a_in];
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::k_param_b_in));
EXPECT_EQ(map[MathNode::k_param_b_in].size(), 1);
// The base-class enabled checkbox is keyframable too
ASSERT_TRUE(map.contains(Node::k_enabled_input));
EXPECT_EQ(map[Node::k_enabled_input].size(), 1);
// The combo input is flagged not-keyframable, so it gets no connections
ASSERT_TRUE(map.contains(MathNode::k_method_in));
EXPECT_TRUE(map[MathNode::k_method_in].isEmpty());
// One track connection each for enabled, param A and param B
EXPECT_EQ(view.get_keyframe_tracks().size(), 3);
}
TEST_F(KeyframeViewTest, SelectAllAndDeselectAllUpdateSelection)
{
MathNode *node = add_math_node();
NodeKeyframe *key_a = insert_keyframe(node, MathNode::k_param_a_in, Rational(0), 0.0);
NodeKeyframe *key_b = insert_keyframe(node, MathNode::k_param_a_in, Rational(1), 1.0);
KeyframeView view;
view.add_keyframes_of_node(node);
QSignalSpy selection_spy(&view, &KeyframeView::selection_changed);
EXPECT_TRUE(view.get_selected_keyframes().empty());
view.select_all();
EXPECT_EQ(view.get_selected_keyframes().size(), 2);
EXPECT_GE(selection_spy.count(), 1);
view.deselect_all();
EXPECT_TRUE(view.get_selected_keyframes().empty());
EXPECT_GE(selection_spy.count(), 2);
Q_UNUSED(key_a)
Q_UNUSED(key_b)
}
TEST_F(KeyframeViewTest, RemoveKeyframesOfTrackDeselectsAndDetaches)
{
MathNode *node = add_math_node();
insert_keyframe(node, MathNode::k_param_a_in, Rational(0), 0.0);
KeyframeView view;
KeyframeViewInputConnection *connection = view.add_keyframes_of_track(
NodeKeyframeTrackReference(NodeInput(node, MathNode::k_param_a_in), 0));
ASSERT_NE(connection, nullptr);
ASSERT_EQ(view.get_keyframe_tracks().size(), 1);
view.select_all();
ASSERT_EQ(view.get_selected_keyframes().size(), 1);
QSignalSpy selection_spy(&view, &KeyframeView::selection_changed);
view.remove_keyframes_of_track(connection);
EXPECT_TRUE(view.get_keyframe_tracks().isEmpty());
EXPECT_TRUE(view.get_selected_keyframes().empty());
EXPECT_GE(selection_spy.count(), 1);
// Removing again is a harmless no-op
view.remove_keyframes_of_track(connection);
EXPECT_TRUE(view.get_keyframe_tracks().isEmpty());
}
TEST_F(KeyframeViewTest, ClearRemovesAllTracksAndSelection)
{
MathNode *node = add_math_node();
insert_keyframe(node, MathNode::k_param_a_in, Rational(0), 0.0);
KeyframeView view;
view.add_keyframes_of_node(node);
view.select_all();
ASSERT_FALSE(view.get_keyframe_tracks().isEmpty());
ASSERT_FALSE(view.get_selected_keyframes().empty());
view.clear();
EXPECT_TRUE(view.get_keyframe_tracks().isEmpty());
EXPECT_TRUE(view.get_selected_keyframes().empty());
}
TEST_F(KeyframeViewTest, DeleteSelectedPushesUndoableRemoval)
{
MathNode *node = add_math_node();
NodeKeyframe *key = insert_keyframe(node, MathNode::k_param_a_in, Rational(0), 0.0);
KeyframeView view;
view.add_keyframes_of_track(
NodeKeyframeTrackReference(NodeInput(node, MathNode::k_param_a_in), 0));
view.select_all();
view.delete_selected();
// The command was executed on push: the keyframe is gone from the node
EXPECT_TRUE(node->get_keyframe_tracks(MathNode::k_param_a_in, -1)
.at(0)
.isEmpty());
Core::instance()->undo_stack()->undo();
EXPECT_TRUE(node->get_keyframe_tracks(MathNode::k_param_a_in, -1)
.at(0)
.contains(key));
Core::instance()->undo_stack()->redo();
EXPECT_TRUE(node->get_keyframe_tracks(MathNode::k_param_a_in, -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::set_up_default_config();
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 =
insert_keyframe(node_, MathNode::k_param_a_in, Rational(0), 0.0);
ASSERT_EQ(key->type(), NodeKeyframe::k_linear);
KeyframeSetTypeCommand command(key, NodeKeyframe::k_bezier);
EXPECT_EQ(command.get_relevant_project(), project_.get());
command.redo_now();
EXPECT_EQ(key->type(), NodeKeyframe::k_bezier);
command.undo_now();
EXPECT_EQ(key->type(), NodeKeyframe::k_linear);
}
TEST_F(KeyframeViewUndoTest, SetBezierControlPointCapturesOldPointFromKeyframe)
{
NodeKeyframe *key =
insert_keyframe(node_, MathNode::k_param_a_in, Rational(0), 0.0);
key->set_type(NodeKeyframe::k_bezier);
key->set_bezier_control_in(QPointF(0.1, 0.2));
KeyframeSetBezierControlPoint command(key, NodeKeyframe::k_in_handle,
QPointF(0.5, 0.6));
EXPECT_EQ(command.get_relevant_project(), 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 =
insert_keyframe(node_, MathNode::k_param_a_in, Rational(0), 0.0);
key->set_type(NodeKeyframe::k_bezier);
// The four-argument overload does not read the current control point
KeyframeSetBezierControlPoint command(key, NodeKeyframe::k_out_handle,
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::set_up_default_config();
project_ = std::make_unique<Project>();
project_->initialize();
solid_ = new SolidGenerator();
solid_->setParent(project_.get());
}
NodeKeyframeTrackReference color_track_ref(int track) const
{
return NodeKeyframeTrackReference(
NodeInput(solid_, SolidGenerator::k_color_input), track);
}
std::unique_ptr<Project> project_;
SolidGenerator *solid_ = nullptr;
};
TEST_F(CurveViewTest, ConnectAndDisconnectInputManageTrackConnections)
{
CurveView view;
EXPECT_TRUE(view.get_connections().isEmpty());
view.connect_input(color_track_ref(0));
EXPECT_EQ(view.get_connections().size(), 1);
EXPECT_TRUE(view.get_connections().contains(color_track_ref(0)));
EXPECT_EQ(view.get_connections().value(color_track_ref(0))->get_reference(),
color_track_ref(0));
// Connecting the same reference twice is a no-op
view.connect_input(color_track_ref(0));
EXPECT_EQ(view.get_connections().size(), 1);
// A color input has four tracks; connecting another track adds one more
view.connect_input(color_track_ref(1));
EXPECT_EQ(view.get_connections().size(), 2);
view.disconnect_input(color_track_ref(0));
EXPECT_EQ(view.get_connections().size(), 1);
EXPECT_FALSE(view.get_connections().contains(color_track_ref(0)));
// Disconnecting an unconnected reference is a no-op
view.disconnect_input(color_track_ref(0));
EXPECT_EQ(view.get_connections().size(), 1);
}
TEST_F(CurveViewTest, ConnectionReflectsLiveKeyframeList)
{
CurveView view;
view.connect_input(color_track_ref(0));
KeyframeViewInputConnection *connection =
view.get_connections().value(color_track_ref(0));
ASSERT_NE(connection, nullptr);
EXPECT_TRUE(connection->get_keyframes().isEmpty());
NodeKeyframe *key =
insert_keyframe(solid_, SolidGenerator::k_color_input, Rational(0), 0.5, 0);
EXPECT_EQ(connection->get_keyframes().size(), 1);
EXPECT_EQ(connection->get_keyframes().first(), key);
}
TEST_F(CurveViewTest, SetKeyframeTrackColorAppliesToBrush)
{
CurveView view;
// Setting the color before connecting is picked up on connect
view.set_keyframe_track_color(color_track_ref(0), QColor(Qt::red));
view.connect_input(color_track_ref(0));
KeyframeViewInputConnection *connection =
view.get_connections().value(color_track_ref(0));
ASSERT_NE(connection, nullptr);
EXPECT_EQ(connection->get_brush().color(), QColor(Qt::red));
// Setting it afterwards updates the live connection
view.set_keyframe_track_color(color_track_ref(0), QColor(Qt::blue));
EXPECT_EQ(connection->get_brush().color(), QColor(Qt::blue));
}
TEST_F(CurveViewTest, SelectKeyframesOfInputSelectsOnlyRequestedTrack)
{
class SelectionProbeCurveView : public CurveView {
public:
using KeyframeView::is_keyframe_selected;
};
SelectionProbeCurveView view;
view.connect_input(color_track_ref(0));
view.connect_input(color_track_ref(1));
NodeKeyframe *key0 =
insert_keyframe(solid_, SolidGenerator::k_color_input, Rational(0), 0.5, 0);
NodeKeyframe *key1 =
insert_keyframe(solid_, SolidGenerator::k_color_input, Rational(1), 0.6, 1);
// Previously the reference was ignored and keyframes of every connected
// track got selected.
view.select_keyframes_of_input(color_track_ref(0));
EXPECT_TRUE(view.is_keyframe_selected(key0));
EXPECT_FALSE(view.is_keyframe_selected(key1));
// Selecting the other track replaces the selection (DeselectAll first)
view.select_keyframes_of_input(color_track_ref(1));
EXPECT_FALSE(view.is_keyframe_selected(key0));
EXPECT_TRUE(view.is_keyframe_selected(key1));
}
TEST(CurveWidget, VerticalScaleRoundTripsThroughView)
{
ColorManager::set_up_default_config();
CurveWidget widget;
const double original = widget.get_vertical_scale();
EXPECT_GT(original, 0.0);
widget.set_vertical_scale(original * 2.0);
EXPECT_DOUBLE_EQ(widget.get_vertical_scale(), original * 2.0);
}
TEST(CurveWidget, TreeSelectionConnectsTracksAndResolvesNodeId)
{
ColorManager::set_up_default_config();
ensure_app_singletons();
Project project;
project.initialize();
auto *solid = new SolidGenerator();
solid->setParent(&project);
solid->retranslate();
CurveWidget widget;
widget.set_nodes({ 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.get_selected_node_with_id(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.get_selected_node_with_id(solid->id()), solid);
EXPECT_EQ(widget.get_selected_node_with_id(QStringLiteral("org.example.bogus")),
nullptr);
// Clearing the selection disconnects the tracks again
tree->clearSelection();
EXPECT_EQ(widget.get_selected_node_with_id(solid->id()), nullptr);
}