test(app): add gtest coverage for previously untested app modules
Add 312 test cases across 14 new files under tests/gtest, covering: - app/common utility headers (tooltypes, projecttypes, nodedatatypes, trackreferencehandle, undowrapper, configwrapper, subtitleapp, ...) - all 8 standardcombos widgets - slider base classes (NumericSliderBase, SliderLadder) - timeline/track handles, marker handle/painting, drag button, elapsed counter - timebased views, selection manager, time target, resizable timeline scrollbar - viewer sizer, display buffer, playback timer, text editor - timeline common helpers, TimelineAndTrackView, TrackView(-item/-splitter) - project explorer views, navigation and undo commands - node view items/edges/connectors/minimap/toolbar/context - node param view sub-widgets - about/export/sequence/footage-properties dialogs - scope widgets (histogram/vectorscope/waveform), managed display - main menu, main window undo commands, viewer/footage panels, engine event bridge - keyframe view handle/input connection, multicam display, node combo box, audio monitor Includes regression tests for the TimelineAndTrackView teardown and TimeScaledObject inverted-limits fixes. Full suite: 2341 tests, 0 failures (64 skips are pre-existing GL-dependent cases under the offscreen QPA).
This commit is contained in:
@@ -142,6 +142,20 @@ add_executable(olive-gtest
|
|||||||
mainwindow_test.cpp
|
mainwindow_test.cpp
|
||||||
serialized_layout_xml_test.cpp
|
serialized_layout_xml_test.cpp
|
||||||
common_ratiodialog_test.cpp
|
common_ratiodialog_test.cpp
|
||||||
|
common_misc2_test.cpp
|
||||||
|
widget_standardcombos_test.cpp
|
||||||
|
widget_sliderbase_test.cpp
|
||||||
|
widget_handles_test.cpp
|
||||||
|
widget_timebased2_test.cpp
|
||||||
|
widget_viewer2_test.cpp
|
||||||
|
timeline_common_test.cpp
|
||||||
|
widget_projectexplorer2_test.cpp
|
||||||
|
widget_nodeview_test.cpp
|
||||||
|
widget_nodeparamview_test.cpp
|
||||||
|
dialog_misc2_test.cpp
|
||||||
|
widget_scope_test.cpp
|
||||||
|
mainwindow2_test.cpp
|
||||||
|
widget_keyframe_multicam_test.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Test)
|
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Test)
|
||||||
|
|||||||
@@ -0,0 +1,802 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <QBuffer>
|
||||||
|
#include <QDataStream>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QVariant>
|
||||||
|
#include <QVector2D>
|
||||||
|
#include <QVector3D>
|
||||||
|
#include <QVector4D>
|
||||||
|
|
||||||
|
#include "common/configwrapper.h"
|
||||||
|
#include "common/debugapp.h"
|
||||||
|
#include "common/nodedatatypes.h"
|
||||||
|
#include "common/nodevaluehandle.h"
|
||||||
|
#include "common/oakvaluehelper.h"
|
||||||
|
#include "common/projecttypes.h"
|
||||||
|
#include "common/subtitleapp.h"
|
||||||
|
#include "common/tooltypes.h"
|
||||||
|
#include "common/trackreferencehandle.h"
|
||||||
|
#include "common/undowrapper.h"
|
||||||
|
#include "oakengine/undo.h"
|
||||||
|
#include "oakutil/qtutils.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// OakConfigValue reads/writes go through the process-wide engine config, so
|
||||||
|
// snapshot and restore each key the tests touch
|
||||||
|
class ScopedConfigKey {
|
||||||
|
public:
|
||||||
|
explicit ScopedConfigKey(const QString &key)
|
||||||
|
: key_(key)
|
||||||
|
, old_value_(oakengine_config_string(key))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
~ScopedConfigKey()
|
||||||
|
{
|
||||||
|
oakengine_config_set_string(key_.toUtf8().constData(),
|
||||||
|
old_value_.toUtf8().constData());
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
static QString oakengine_config_string(const QString &key)
|
||||||
|
{
|
||||||
|
char buf[1024];
|
||||||
|
const int len = oakengine_config_get_string(key.toUtf8().constData(),
|
||||||
|
buf, sizeof(buf));
|
||||||
|
return QString::fromUtf8(buf, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString key_;
|
||||||
|
QString old_value_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// App-side undo command for wrap_app_undo_command(): counts invocations and
|
||||||
|
// reports its own destruction so ownership transfer can be observed
|
||||||
|
struct CountingCmd {
|
||||||
|
int *redo_count;
|
||||||
|
int *undo_count;
|
||||||
|
bool *destroyed;
|
||||||
|
|
||||||
|
void redo()
|
||||||
|
{
|
||||||
|
++*redo_count;
|
||||||
|
}
|
||||||
|
void undo()
|
||||||
|
{
|
||||||
|
++*undo_count;
|
||||||
|
}
|
||||||
|
~CountingCmd()
|
||||||
|
{
|
||||||
|
*destroyed = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(ToolTypes, AddableObjectNamesCoverAllValues)
|
||||||
|
{
|
||||||
|
EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_empty),
|
||||||
|
QStringLiteral("Empty"));
|
||||||
|
EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_bars),
|
||||||
|
QStringLiteral("Bars"));
|
||||||
|
EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_shape),
|
||||||
|
QStringLiteral("Shape"));
|
||||||
|
EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_solid),
|
||||||
|
QStringLiteral("Solid"));
|
||||||
|
EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_title),
|
||||||
|
QStringLiteral("Title"));
|
||||||
|
EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_tone),
|
||||||
|
QStringLiteral("Tone"));
|
||||||
|
EXPECT_EQ(
|
||||||
|
olive::Tool::get_addable_object_name(olive::Tool::k_addable_subtitle),
|
||||||
|
QStringLiteral("Subtitle"));
|
||||||
|
|
||||||
|
// Out-of-range values fall through to "Unknown"
|
||||||
|
EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_count),
|
||||||
|
QStringLiteral("Unknown"));
|
||||||
|
EXPECT_EQ(olive::Tool::get_addable_object_name(
|
||||||
|
static_cast<olive::Tool::AddableObject>(-1)),
|
||||||
|
QStringLiteral("Unknown"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ToolTypes, AddableObjectIdsCoverAllValues)
|
||||||
|
{
|
||||||
|
EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_empty),
|
||||||
|
QStringLiteral("empty"));
|
||||||
|
EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_bars),
|
||||||
|
QStringLiteral("bars"));
|
||||||
|
EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_shape),
|
||||||
|
QStringLiteral("shape"));
|
||||||
|
EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_solid),
|
||||||
|
QStringLiteral("solid"));
|
||||||
|
EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_title),
|
||||||
|
QStringLiteral("title"));
|
||||||
|
EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_tone),
|
||||||
|
QStringLiteral("tone"));
|
||||||
|
EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_subtitle),
|
||||||
|
QStringLiteral("subtitle"));
|
||||||
|
|
||||||
|
// Every real addable has a unique non-empty id
|
||||||
|
QSet<QString> ids;
|
||||||
|
for (int i = 0; i < olive::Tool::k_addable_count; i++) {
|
||||||
|
const QString id = olive::Tool::get_addable_object_id(
|
||||||
|
static_cast<olive::Tool::AddableObject>(i));
|
||||||
|
EXPECT_FALSE(id.isEmpty());
|
||||||
|
EXPECT_FALSE(ids.contains(id)) << id.toStdString();
|
||||||
|
ids.insert(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Out-of-range values return an empty id
|
||||||
|
EXPECT_TRUE(
|
||||||
|
olive::Tool::get_addable_object_id(olive::Tool::k_addable_count).isEmpty());
|
||||||
|
EXPECT_TRUE(olive::Tool::get_addable_object_id(
|
||||||
|
static_cast<olive::Tool::AddableObject>(-1)).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ToolTypes, ItemOrdinalsMatchEngineMirror)
|
||||||
|
{
|
||||||
|
// The C ABI transports these as ints, so the ordinals are the contract
|
||||||
|
EXPECT_EQ(olive::Tool::k_none, 0);
|
||||||
|
EXPECT_EQ(olive::Tool::k_pointer, 1);
|
||||||
|
EXPECT_EQ(olive::Tool::k_track_select, 13);
|
||||||
|
EXPECT_EQ(olive::Tool::k_count, 14);
|
||||||
|
EXPECT_EQ(olive::Tool::k_addable_count, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ProjectTypes, CacheSettingOrdinals)
|
||||||
|
{
|
||||||
|
EXPECT_EQ(olive::Project::k_cache_use_default_location, 0);
|
||||||
|
EXPECT_EQ(olive::Project::k_cache_store_alongside_project, 1);
|
||||||
|
EXPECT_EQ(olive::Project::k_cache_custom_path, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeDataTypes, OrdinalsMatchEngineMirror)
|
||||||
|
{
|
||||||
|
EXPECT_EQ(olive::k_node_data_icon, 0);
|
||||||
|
EXPECT_EQ(olive::k_node_data_duration, 1);
|
||||||
|
EXPECT_EQ(olive::k_node_data_created_time, 2);
|
||||||
|
EXPECT_EQ(olive::k_node_data_modified_time, 3);
|
||||||
|
EXPECT_EQ(olive::k_node_data_frequency_rate, 4);
|
||||||
|
EXPECT_EQ(olive::k_node_data_tooltip, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeValueHandle, TypeOrdinalsMatchEngineMirror)
|
||||||
|
{
|
||||||
|
EXPECT_EQ(olive::NodeValueType::k_none, 0);
|
||||||
|
EXPECT_EQ(olive::NodeValueType::k_boolean, 4);
|
||||||
|
EXPECT_EQ(olive::NodeValueType::k_vec2, 12);
|
||||||
|
EXPECT_EQ(olive::NodeValueType::k_data_type_count, 23);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeValueHandle, KeyframeTypeOrdinalsMatchEngineMirror)
|
||||||
|
{
|
||||||
|
EXPECT_EQ(olive::NodeKeyframeType::k_invalid, -1);
|
||||||
|
EXPECT_EQ(olive::NodeKeyframeType::k_linear, 0);
|
||||||
|
EXPECT_EQ(olive::NodeKeyframeType::k_hold, 1);
|
||||||
|
EXPECT_EQ(olive::NodeKeyframeType::k_bezier, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeValueHandle, ToCMappingsDifferFromPlainCast)
|
||||||
|
{
|
||||||
|
// The two enums do NOT share ordinals; the helper must remap each one
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_int),
|
||||||
|
OAK_NODE_VALUE_INT);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_float),
|
||||||
|
OAK_NODE_VALUE_FLOAT);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_boolean),
|
||||||
|
OAK_NODE_VALUE_BOOL);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_rational),
|
||||||
|
OAK_NODE_VALUE_RATIONAL);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_color),
|
||||||
|
OAK_NODE_VALUE_COLOR);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_vec2),
|
||||||
|
OAK_NODE_VALUE_VEC2);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_vec3),
|
||||||
|
OAK_NODE_VALUE_VEC3);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_vec4),
|
||||||
|
OAK_NODE_VALUE_VEC4);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_combo),
|
||||||
|
OAK_NODE_VALUE_COMBO);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_file),
|
||||||
|
OAK_NODE_VALUE_STRING);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_text),
|
||||||
|
OAK_NODE_VALUE_TEXT);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_font),
|
||||||
|
OAK_NODE_VALUE_FONT);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_str_combo),
|
||||||
|
OAK_NODE_VALUE_STR_COMBO);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_binary),
|
||||||
|
OAK_NODE_VALUE_BINARY);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_bezier),
|
||||||
|
OAK_NODE_VALUE_BEZIER);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_texture),
|
||||||
|
OAK_NODE_VALUE_TEXTURE);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_samples),
|
||||||
|
OAK_NODE_VALUE_SAMPLES);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_video_params),
|
||||||
|
OAK_NODE_VALUE_VIDEO_PARAMS);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_audio_params),
|
||||||
|
OAK_NODE_VALUE_AUDIO_PARAMS);
|
||||||
|
|
||||||
|
// k_boolean=4 vs OAK_NODE_VALUE_BOOL=3: a plain cast would be wrong
|
||||||
|
EXPECT_NE(olive::NodeValueType::k_boolean, OAK_NODE_VALUE_BOOL);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeValueHandle, ToCReturnsMinusOneForUnrepresentable)
|
||||||
|
{
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_none), -1);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_matrix), -1);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_subtitle_params),
|
||||||
|
-1);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_push_button), -1);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_data_type_count),
|
||||||
|
-1);
|
||||||
|
EXPECT_EQ(olive::node_value_type_to_c(-7), -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackReferenceHandle, DefaultIsInvalidNone)
|
||||||
|
{
|
||||||
|
const olive::TrackReference ref;
|
||||||
|
EXPECT_EQ(ref.type(), olive::TrackReference::k_none);
|
||||||
|
EXPECT_EQ(ref.index(), -1);
|
||||||
|
EXPECT_FALSE(ref.is_valid());
|
||||||
|
EXPECT_TRUE(ref.to_string().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackReferenceHandle, ConstructionAndValidity)
|
||||||
|
{
|
||||||
|
const olive::TrackReference video(olive::TrackReference::k_video, 3);
|
||||||
|
EXPECT_EQ(video.type(), olive::TrackReference::k_video);
|
||||||
|
EXPECT_EQ(video.index(), 3);
|
||||||
|
EXPECT_TRUE(video.is_valid());
|
||||||
|
|
||||||
|
// A known type with a negative index is not valid
|
||||||
|
EXPECT_FALSE(
|
||||||
|
olive::TrackReference(olive::TrackReference::k_video, -1).is_valid());
|
||||||
|
// k_none and k_count are never valid, even with a non-negative index
|
||||||
|
EXPECT_FALSE(olive::TrackReference(olive::TrackReference::k_none, 5).is_valid());
|
||||||
|
EXPECT_FALSE(
|
||||||
|
olive::TrackReference(olive::TrackReference::k_count, 0).is_valid());
|
||||||
|
// Index zero is a valid track
|
||||||
|
EXPECT_TRUE(
|
||||||
|
olive::TrackReference(olive::TrackReference::k_audio, 0).is_valid());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackReferenceHandle, ComparisonOperators)
|
||||||
|
{
|
||||||
|
const olive::TrackReference a(olive::TrackReference::k_audio, 1);
|
||||||
|
const olive::TrackReference a2(olive::TrackReference::k_audio, 1);
|
||||||
|
const olive::TrackReference a3(olive::TrackReference::k_audio, 2);
|
||||||
|
const olive::TrackReference v0(olive::TrackReference::k_video, 0);
|
||||||
|
|
||||||
|
EXPECT_TRUE(a == a2);
|
||||||
|
EXPECT_FALSE(a != a2);
|
||||||
|
EXPECT_TRUE(a != a3);
|
||||||
|
EXPECT_TRUE(a != v0);
|
||||||
|
|
||||||
|
// Ordering is by type first (k_video=0 < k_audio=1), then index
|
||||||
|
EXPECT_TRUE(v0 < a);
|
||||||
|
EXPECT_FALSE(a < v0);
|
||||||
|
EXPECT_TRUE(a < a3);
|
||||||
|
EXPECT_FALSE(a3 < a);
|
||||||
|
EXPECT_FALSE(a < a2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackReferenceHandle, TypeStringMappings)
|
||||||
|
{
|
||||||
|
EXPECT_EQ(olive::TrackReference::type_to_string(olive::TrackReference::k_video),
|
||||||
|
QStringLiteral("v"));
|
||||||
|
EXPECT_EQ(olive::TrackReference::type_to_string(olive::TrackReference::k_audio),
|
||||||
|
QStringLiteral("a"));
|
||||||
|
EXPECT_EQ(
|
||||||
|
olive::TrackReference::type_to_string(olive::TrackReference::k_subtitle),
|
||||||
|
QStringLiteral("s"));
|
||||||
|
EXPECT_TRUE(
|
||||||
|
olive::TrackReference::type_to_string(olive::TrackReference::k_none)
|
||||||
|
.isEmpty());
|
||||||
|
EXPECT_TRUE(
|
||||||
|
olive::TrackReference::type_to_string(olive::TrackReference::k_count)
|
||||||
|
.isEmpty());
|
||||||
|
|
||||||
|
EXPECT_EQ(olive::TrackReference::type_to_translated_string(
|
||||||
|
olive::TrackReference::k_video),
|
||||||
|
QStringLiteral("V"));
|
||||||
|
EXPECT_EQ(olive::TrackReference::type_to_translated_string(
|
||||||
|
olive::TrackReference::k_audio),
|
||||||
|
QStringLiteral("A"));
|
||||||
|
EXPECT_EQ(olive::TrackReference::type_to_translated_string(
|
||||||
|
olive::TrackReference::k_subtitle),
|
||||||
|
QStringLiteral("S"));
|
||||||
|
EXPECT_TRUE(olive::TrackReference::type_to_translated_string(
|
||||||
|
olive::TrackReference::k_none).isEmpty());
|
||||||
|
EXPECT_TRUE(olive::TrackReference::type_to_translated_string(
|
||||||
|
olive::TrackReference::k_count).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackReferenceHandle, StringRoundTrip)
|
||||||
|
{
|
||||||
|
const olive::TrackReference ref(olive::TrackReference::k_video, 12);
|
||||||
|
EXPECT_EQ(ref.to_string(), QStringLiteral("v:12"));
|
||||||
|
|
||||||
|
const olive::TrackReference parsed =
|
||||||
|
olive::TrackReference::from_string(QStringLiteral("v:12"));
|
||||||
|
EXPECT_EQ(parsed, ref);
|
||||||
|
EXPECT_TRUE(parsed.is_valid());
|
||||||
|
|
||||||
|
EXPECT_EQ(olive::TrackReference(olive::TrackReference::k_audio, 0).to_string(),
|
||||||
|
QStringLiteral("a:0"));
|
||||||
|
EXPECT_EQ(
|
||||||
|
olive::TrackReference(olive::TrackReference::k_subtitle, 7).to_string(),
|
||||||
|
QStringLiteral("s:7"));
|
||||||
|
|
||||||
|
// An invalid reference serializes to an empty string
|
||||||
|
EXPECT_TRUE(olive::TrackReference().to_string().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackReferenceHandle, FromStringRejectsGarbage)
|
||||||
|
{
|
||||||
|
// Bad prefixes and malformed input all parse back to the default ref
|
||||||
|
const olive::TrackReference fallback;
|
||||||
|
for (const QString &s : { QStringLiteral("x:1"), QStringLiteral("v"),
|
||||||
|
QStringLiteral("v:"), QStringLiteral("v:abc"),
|
||||||
|
QStringLiteral(""), QStringLiteral("a"),
|
||||||
|
QStringLiteral("vv:1") }) {
|
||||||
|
EXPECT_EQ(olive::TrackReference::from_string(s), fallback)
|
||||||
|
<< s.toStdString();
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_EQ(olive::TrackReference::type_from_string(QStringLiteral("v:1")),
|
||||||
|
olive::TrackReference::k_video);
|
||||||
|
EXPECT_EQ(olive::TrackReference::type_from_string(QStringLiteral("a:2")),
|
||||||
|
olive::TrackReference::k_audio);
|
||||||
|
EXPECT_EQ(olive::TrackReference::type_from_string(QStringLiteral("s:3")),
|
||||||
|
olive::TrackReference::k_subtitle);
|
||||||
|
EXPECT_EQ(olive::TrackReference::type_from_string(QStringLiteral("q:3")),
|
||||||
|
olive::TrackReference::k_none);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackReferenceHandle, HashAndSetSemantics)
|
||||||
|
{
|
||||||
|
QSet<olive::TrackReference> set;
|
||||||
|
set.insert(olive::TrackReference(olive::TrackReference::k_video, 0));
|
||||||
|
set.insert(olive::TrackReference(olive::TrackReference::k_video, 0));
|
||||||
|
set.insert(olive::TrackReference(olive::TrackReference::k_audio, 0));
|
||||||
|
|
||||||
|
EXPECT_EQ(set.size(), 2);
|
||||||
|
EXPECT_TRUE(
|
||||||
|
set.contains(olive::TrackReference(olive::TrackReference::k_video, 0)));
|
||||||
|
EXPECT_TRUE(
|
||||||
|
set.contains(olive::TrackReference(olive::TrackReference::k_audio, 0)));
|
||||||
|
EXPECT_FALSE(
|
||||||
|
set.contains(olive::TrackReference(olive::TrackReference::k_video, 1)));
|
||||||
|
|
||||||
|
// Equal references hash equally
|
||||||
|
EXPECT_EQ(qHash(olive::TrackReference(olive::TrackReference::k_subtitle, 2)),
|
||||||
|
qHash(olive::TrackReference(olive::TrackReference::k_subtitle, 2)));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackReferenceHandle, DataStreamRoundTrip)
|
||||||
|
{
|
||||||
|
QByteArray bytes;
|
||||||
|
{
|
||||||
|
QBuffer buffer(&bytes);
|
||||||
|
ASSERT_TRUE(buffer.open(QIODevice::WriteOnly));
|
||||||
|
QDataStream out(&buffer);
|
||||||
|
out << olive::TrackReference(olive::TrackReference::k_audio, 4)
|
||||||
|
<< olive::TrackReference();
|
||||||
|
}
|
||||||
|
|
||||||
|
QBuffer buffer(&bytes);
|
||||||
|
ASSERT_TRUE(buffer.open(QIODevice::ReadOnly));
|
||||||
|
QDataStream in(&buffer);
|
||||||
|
olive::TrackReference first, second;
|
||||||
|
in >> first >> second;
|
||||||
|
|
||||||
|
EXPECT_EQ(first, olive::TrackReference(olive::TrackReference::k_audio, 4));
|
||||||
|
EXPECT_EQ(second, olive::TrackReference());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OakValueHelper, QVariantToOakNodeValueScalars)
|
||||||
|
{
|
||||||
|
oak_node_value out;
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_int,
|
||||||
|
QVariant(42), &out));
|
||||||
|
EXPECT_EQ(out.type, OAK_NODE_VALUE_INT);
|
||||||
|
EXPECT_EQ(out.num, 42);
|
||||||
|
|
||||||
|
// Combos map to the COMBO facade type, not INT
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_combo,
|
||||||
|
QVariant(3), &out));
|
||||||
|
EXPECT_EQ(out.type, OAK_NODE_VALUE_COMBO);
|
||||||
|
EXPECT_EQ(out.num, 3);
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_float,
|
||||||
|
QVariant(2.5), &out));
|
||||||
|
EXPECT_EQ(out.type, OAK_NODE_VALUE_FLOAT);
|
||||||
|
EXPECT_DOUBLE_EQ(out.f[0], 2.5);
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_boolean,
|
||||||
|
QVariant(true), &out));
|
||||||
|
EXPECT_EQ(out.type, OAK_NODE_VALUE_BOOL);
|
||||||
|
EXPECT_EQ(out.num, 1);
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_boolean,
|
||||||
|
QVariant(false), &out));
|
||||||
|
EXPECT_EQ(out.num, 0);
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(
|
||||||
|
olive::NodeValueType::k_rational,
|
||||||
|
QVariant::fromValue(olive::core::Rational(1001, 30000)), &out));
|
||||||
|
EXPECT_EQ(out.type, OAK_NODE_VALUE_RATIONAL);
|
||||||
|
EXPECT_EQ(out.num, 1001);
|
||||||
|
EXPECT_EQ(out.den, 30000);
|
||||||
|
|
||||||
|
// Types with no POD representation fail
|
||||||
|
EXPECT_FALSE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_text,
|
||||||
|
QVariant(QStringLiteral("x")),
|
||||||
|
&out));
|
||||||
|
EXPECT_FALSE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_matrix,
|
||||||
|
QVariant(), &out));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OakValueHelper, QVariantToOakNodeValueColorAndVectors)
|
||||||
|
{
|
||||||
|
oak_node_value out;
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(
|
||||||
|
olive::NodeValueType::k_color,
|
||||||
|
QVariant::fromValue(olive::core::Color(0.25f, 0.5f, 0.75f, 1.0f)),
|
||||||
|
&out));
|
||||||
|
EXPECT_EQ(out.type, OAK_NODE_VALUE_COLOR);
|
||||||
|
EXPECT_DOUBLE_EQ(out.f[0], 0.25);
|
||||||
|
EXPECT_DOUBLE_EQ(out.f[1], 0.5);
|
||||||
|
EXPECT_DOUBLE_EQ(out.f[2], 0.75);
|
||||||
|
EXPECT_DOUBLE_EQ(out.f[3], 1.0);
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(
|
||||||
|
olive::NodeValueType::k_vec2,
|
||||||
|
QVariant::fromValue(QVector2D(1.0f, 2.0f)), &out));
|
||||||
|
EXPECT_EQ(out.type, OAK_NODE_VALUE_VEC2);
|
||||||
|
EXPECT_DOUBLE_EQ(out.f[0], 1.0);
|
||||||
|
EXPECT_DOUBLE_EQ(out.f[1], 2.0);
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(
|
||||||
|
olive::NodeValueType::k_vec3,
|
||||||
|
QVariant::fromValue(QVector3D(1.0f, 2.0f, 3.0f)), &out));
|
||||||
|
EXPECT_EQ(out.type, OAK_NODE_VALUE_VEC3);
|
||||||
|
EXPECT_DOUBLE_EQ(out.f[2], 3.0);
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(
|
||||||
|
olive::NodeValueType::k_vec4,
|
||||||
|
QVariant::fromValue(QVector4D(1.0f, 2.0f, 3.0f, 4.0f)), &out));
|
||||||
|
EXPECT_EQ(out.type, OAK_NODE_VALUE_VEC4);
|
||||||
|
EXPECT_DOUBLE_EQ(out.f[3], 4.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OakValueHelper, TrackComponentUsesDeclaredTypeWithSingleSlot)
|
||||||
|
{
|
||||||
|
oak_node_value out;
|
||||||
|
|
||||||
|
// Beziers take a FLOAT per track, not the full-bezier POD type
|
||||||
|
ASSERT_TRUE(olive::NodeTrackComponentToOakNodeValue(
|
||||||
|
olive::NodeValueType::k_bezier, QVariant(0.75), &out));
|
||||||
|
EXPECT_EQ(out.type, OAK_NODE_VALUE_FLOAT);
|
||||||
|
EXPECT_DOUBLE_EQ(out.f[0], 0.75);
|
||||||
|
|
||||||
|
// A color channel is one float in f[0] but keeps the COLOR type
|
||||||
|
ASSERT_TRUE(olive::NodeTrackComponentToOakNodeValue(
|
||||||
|
olive::NodeValueType::k_color, QVariant(0.5), &out));
|
||||||
|
EXPECT_EQ(out.type, OAK_NODE_VALUE_COLOR);
|
||||||
|
EXPECT_DOUBLE_EQ(out.f[0], 0.5);
|
||||||
|
EXPECT_DOUBLE_EQ(out.f[1], 0.0);
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::NodeTrackComponentToOakNodeValue(
|
||||||
|
olive::NodeValueType::k_vec4, QVariant(1.5), &out));
|
||||||
|
EXPECT_EQ(out.type, OAK_NODE_VALUE_VEC4);
|
||||||
|
EXPECT_DOUBLE_EQ(out.f[0], 1.5);
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::NodeTrackComponentToOakNodeValue(
|
||||||
|
olive::NodeValueType::k_combo, QVariant(2), &out));
|
||||||
|
EXPECT_EQ(out.type, OAK_NODE_VALUE_COMBO);
|
||||||
|
EXPECT_EQ(out.num, 2);
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::NodeTrackComponentToOakNodeValue(
|
||||||
|
olive::NodeValueType::k_rational,
|
||||||
|
QVariant::fromValue(olive::core::Rational(1, 24)), &out));
|
||||||
|
EXPECT_EQ(out.type, OAK_NODE_VALUE_RATIONAL);
|
||||||
|
EXPECT_EQ(out.num, 1);
|
||||||
|
EXPECT_EQ(out.den, 24);
|
||||||
|
|
||||||
|
EXPECT_FALSE(olive::NodeTrackComponentToOakNodeValue(
|
||||||
|
olive::NodeValueType::k_text, QVariant(QStringLiteral("x")), &out));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OakValueHelper, PodBackToQVariantRoundTrips)
|
||||||
|
{
|
||||||
|
oak_node_value pod;
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_int,
|
||||||
|
QVariant(17), &pod));
|
||||||
|
EXPECT_EQ(olive::OakNodeValueToQVariant(pod).toLongLong(), 17);
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_float,
|
||||||
|
QVariant(3.25), &pod));
|
||||||
|
EXPECT_DOUBLE_EQ(olive::OakNodeValueToQVariant(pod).toDouble(), 3.25);
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_boolean,
|
||||||
|
QVariant(true), &pod));
|
||||||
|
EXPECT_TRUE(olive::OakNodeValueToQVariant(pod).toBool());
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(
|
||||||
|
olive::NodeValueType::k_rational,
|
||||||
|
QVariant::fromValue(olive::core::Rational(24, 1)), &pod));
|
||||||
|
EXPECT_EQ(olive::OakNodeValueToQVariant(pod).value<olive::core::Rational>(),
|
||||||
|
olive::core::Rational(24, 1));
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(
|
||||||
|
olive::NodeValueType::k_color,
|
||||||
|
QVariant::fromValue(olive::core::Color(0.1f, 0.2f, 0.3f, 0.4f)), &pod));
|
||||||
|
const olive::core::Color c =
|
||||||
|
olive::OakNodeValueToQVariant(pod).value<olive::core::Color>();
|
||||||
|
EXPECT_FLOAT_EQ(c.red(), 0.1f);
|
||||||
|
EXPECT_FLOAT_EQ(c.green(), 0.2f);
|
||||||
|
EXPECT_FLOAT_EQ(c.blue(), 0.3f);
|
||||||
|
EXPECT_FLOAT_EQ(c.alpha(), 0.4f);
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(
|
||||||
|
olive::NodeValueType::k_vec3,
|
||||||
|
QVariant::fromValue(QVector3D(4.0f, 5.0f, 6.0f)), &pod));
|
||||||
|
EXPECT_EQ(olive::OakNodeValueToQVariant(pod).value<QVector3D>(),
|
||||||
|
QVector3D(4.0f, 5.0f, 6.0f));
|
||||||
|
|
||||||
|
// Combos come back as plain ints
|
||||||
|
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_combo,
|
||||||
|
QVariant(5), &pod));
|
||||||
|
EXPECT_EQ(olive::OakNodeValueToQVariant(pod).toInt(), 5);
|
||||||
|
|
||||||
|
// Types with no POD representation produce an invalid QVariant
|
||||||
|
pod.type = OAK_NODE_VALUE_STRING;
|
||||||
|
EXPECT_FALSE(olive::OakNodeValueToQVariant(pod).isValid());
|
||||||
|
pod.type = OAK_NODE_VALUE_BINARY;
|
||||||
|
EXPECT_FALSE(olive::OakNodeValueToQVariant(pod).isValid());
|
||||||
|
pod.type = OAK_NODE_VALUE_NONE;
|
||||||
|
EXPECT_FALSE(olive::OakNodeValueToQVariant(pod).isValid());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(OakValueHelper, KeyframeTypeToFacadeRenumbering)
|
||||||
|
{
|
||||||
|
// Facade easing: 0=linear, 1=bezier, 2=hold — NOT the engine ordinals
|
||||||
|
EXPECT_EQ(olive::NodeKeyframeTypeToFacade(olive::NodeKeyframeType::k_linear),
|
||||||
|
0);
|
||||||
|
EXPECT_EQ(olive::NodeKeyframeTypeToFacade(olive::NodeKeyframeType::k_bezier),
|
||||||
|
1);
|
||||||
|
EXPECT_EQ(olive::NodeKeyframeTypeToFacade(olive::NodeKeyframeType::k_hold), 2);
|
||||||
|
|
||||||
|
// Anything else (including k_invalid) falls back to linear
|
||||||
|
EXPECT_EQ(olive::NodeKeyframeTypeToFacade(olive::NodeKeyframeType::k_invalid),
|
||||||
|
0);
|
||||||
|
EXPECT_EQ(olive::NodeKeyframeTypeToFacade(99), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(UndoWrapper, WrapsRedoUndoAndOwnership)
|
||||||
|
{
|
||||||
|
int redo_count = 0;
|
||||||
|
int undo_count = 0;
|
||||||
|
bool destroyed = false;
|
||||||
|
|
||||||
|
void *cmd = olive::wrap_app_undo_command(
|
||||||
|
"GTest counting command",
|
||||||
|
new CountingCmd{ &redo_count, &undo_count, &destroyed });
|
||||||
|
ASSERT_NE(cmd, nullptr);
|
||||||
|
EXPECT_FALSE(destroyed);
|
||||||
|
|
||||||
|
// redo_now/undo_now forward to the wrapped object
|
||||||
|
EXPECT_EQ(oakengine_undo_command_redo_now(cmd), 0);
|
||||||
|
EXPECT_EQ(redo_count, 1);
|
||||||
|
EXPECT_EQ(undo_count, 0);
|
||||||
|
|
||||||
|
EXPECT_EQ(oakengine_undo_command_undo_now(cmd), 0);
|
||||||
|
EXPECT_EQ(undo_count, 1);
|
||||||
|
|
||||||
|
EXPECT_EQ(oakengine_undo_command_redo_now(cmd), 0);
|
||||||
|
EXPECT_EQ(redo_count, 2);
|
||||||
|
|
||||||
|
// Freeing the engine command deletes the wrapped object exactly once
|
||||||
|
oakengine_undo_command_free(cmd);
|
||||||
|
EXPECT_TRUE(destroyed);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ConfigWrapper, IntRoundTripAndComparisons)
|
||||||
|
{
|
||||||
|
const ScopedConfigKey guard(QStringLiteral("GTestConfigWrapperInt"));
|
||||||
|
|
||||||
|
olive::OakConfigValue value(QStringLiteral("GTestConfigWrapperInt"));
|
||||||
|
|
||||||
|
value = 42;
|
||||||
|
EXPECT_EQ(value.toInt(), 42);
|
||||||
|
EXPECT_EQ(static_cast<qint64>(value), 42);
|
||||||
|
EXPECT_TRUE(value == 42);
|
||||||
|
EXPECT_FALSE(value != 42);
|
||||||
|
EXPECT_TRUE(value == qint64(42));
|
||||||
|
EXPECT_TRUE(value.toBool()); // non-zero reads as true
|
||||||
|
|
||||||
|
value = 0;
|
||||||
|
EXPECT_EQ(value.toInt(), 0);
|
||||||
|
EXPECT_FALSE(value.toBool());
|
||||||
|
|
||||||
|
// Large values survive the int64 round trip
|
||||||
|
value = qint64(5000000000LL);
|
||||||
|
EXPECT_EQ(value.toLongLong(), 5000000000LL);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ConfigWrapper, StringRoundTripAndComparisons)
|
||||||
|
{
|
||||||
|
const ScopedConfigKey guard(QStringLiteral("GTestConfigWrapperString"));
|
||||||
|
|
||||||
|
olive::OakConfigValue value(QStringLiteral("GTestConfigWrapperString"));
|
||||||
|
|
||||||
|
value = QStringLiteral("hello world");
|
||||||
|
EXPECT_EQ(value.toString(), QStringLiteral("hello world"));
|
||||||
|
EXPECT_TRUE(value == QStringLiteral("hello world"));
|
||||||
|
EXPECT_TRUE(value == "hello world");
|
||||||
|
EXPECT_FALSE(value != "hello world");
|
||||||
|
EXPECT_TRUE(value != "goodbye");
|
||||||
|
|
||||||
|
// const char* assignment, including UTF-8 payloads
|
||||||
|
value = "caf\u00E9";
|
||||||
|
EXPECT_EQ(value.toString(), QStringLiteral("caf\u00E9"));
|
||||||
|
|
||||||
|
// The QVariant conversion yields a string variant
|
||||||
|
const QVariant as_variant = static_cast<QVariant>(value);
|
||||||
|
EXPECT_EQ(as_variant.typeId(), QMetaType::QString);
|
||||||
|
EXPECT_EQ(as_variant.toString(), QStringLiteral("caf\u00E9"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ConfigWrapper, QVariantAssignmentDispatchesByType)
|
||||||
|
{
|
||||||
|
const ScopedConfigKey guard(QStringLiteral("GTestConfigWrapperVariant"));
|
||||||
|
|
||||||
|
olive::OakConfigValue value(QStringLiteral("GTestConfigWrapperVariant"));
|
||||||
|
|
||||||
|
value = QVariant(true);
|
||||||
|
EXPECT_TRUE(value.toBool());
|
||||||
|
|
||||||
|
value = QVariant(7);
|
||||||
|
EXPECT_EQ(value.toInt(), 7);
|
||||||
|
|
||||||
|
// Floating point variants are truncated to int64 by design
|
||||||
|
value = QVariant(3.9);
|
||||||
|
EXPECT_EQ(value.toLongLong(), 3);
|
||||||
|
|
||||||
|
// Everything else falls back to its string form
|
||||||
|
value = QVariant(QStringLiteral("plain"));
|
||||||
|
EXPECT_EQ(value.toString(), QStringLiteral("plain"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ConfigWrapper, RationalValueParsesStoredString)
|
||||||
|
{
|
||||||
|
const ScopedConfigKey guard(QStringLiteral("GTestConfigWrapperRational"));
|
||||||
|
|
||||||
|
olive::OakConfigValue value(QStringLiteral("GTestConfigWrapperRational"));
|
||||||
|
|
||||||
|
value = QStringLiteral("1001/30000");
|
||||||
|
const olive::core::Rational r = value.value<olive::core::Rational>();
|
||||||
|
EXPECT_EQ(r, olive::core::Rational(1001, 30000));
|
||||||
|
EXPECT_EQ(r.numerator(), 1001);
|
||||||
|
EXPECT_EQ(r.denominator(), 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ConfigWrapper, MissingKeyReadsDefaults)
|
||||||
|
{
|
||||||
|
// No guard: this key must never exist, so there is nothing to restore
|
||||||
|
olive::OakConfigValue value(
|
||||||
|
QStringLiteral("GTestConfigWrapperDefinitelyMissing"));
|
||||||
|
|
||||||
|
EXPECT_FALSE(value.toBool());
|
||||||
|
EXPECT_EQ(value.toInt(), 0);
|
||||||
|
EXPECT_EQ(value.toLongLong(), 0);
|
||||||
|
EXPECT_TRUE(value.toString().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ConfigWrapper, OakConfigMacrosBuildValues)
|
||||||
|
{
|
||||||
|
const ScopedConfigKey guard(QStringLiteral("GTestConfigWrapperMacro"));
|
||||||
|
|
||||||
|
OAK_CONFIG("GTestConfigWrapperMacro") = 9;
|
||||||
|
EXPECT_EQ(OAK_CONFIG("GTestConfigWrapperMacro").toInt(), 9);
|
||||||
|
|
||||||
|
OAK_CONFIG_STR(QStringLiteral("GTestConfigWrapperMacro")) =
|
||||||
|
QStringLiteral("macro");
|
||||||
|
EXPECT_EQ(OAK_CONFIG_STR(QStringLiteral("GTestConfigWrapperMacro")).toString(),
|
||||||
|
QStringLiteral("macro"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SubtitleApp, DefaultAndParameterizedConstruction)
|
||||||
|
{
|
||||||
|
const olive::SubtitleApp empty;
|
||||||
|
EXPECT_TRUE(empty.text().isEmpty());
|
||||||
|
|
||||||
|
const olive::SubtitleApp sub(
|
||||||
|
olive::core::TimeRange(olive::core::Rational(1, 24),
|
||||||
|
olive::core::Rational(48, 24)),
|
||||||
|
QStringLiteral("Hello"));
|
||||||
|
EXPECT_EQ(sub.text(), QStringLiteral("Hello"));
|
||||||
|
EXPECT_EQ(sub.time().in(), olive::core::Rational(1, 24));
|
||||||
|
EXPECT_EQ(sub.time().out(), olive::core::Rational(48, 24));
|
||||||
|
EXPECT_EQ(sub.time().length(), olive::core::Rational(47, 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SubtitleApp, SettersRoundTrip)
|
||||||
|
{
|
||||||
|
olive::SubtitleApp sub;
|
||||||
|
|
||||||
|
sub.set_text(QStringLiteral("First"));
|
||||||
|
EXPECT_EQ(sub.text(), QStringLiteral("First"));
|
||||||
|
|
||||||
|
sub.set_time(olive::core::TimeRange(olive::core::Rational(0, 1),
|
||||||
|
olive::core::Rational(10, 1)));
|
||||||
|
EXPECT_EQ(sub.time().in(), olive::core::Rational(0, 1));
|
||||||
|
EXPECT_EQ(sub.time().out(), olive::core::Rational(10, 1));
|
||||||
|
|
||||||
|
sub.set_text(QStringLiteral("Second"));
|
||||||
|
EXPECT_EQ(sub.text(), QStringLiteral("Second"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SubtitleApp, MetatypeRoundTrip)
|
||||||
|
{
|
||||||
|
const olive::SubtitleApp sub(
|
||||||
|
olive::core::TimeRange(olive::core::Rational(2, 1),
|
||||||
|
olive::core::Rational(5, 1)),
|
||||||
|
QStringLiteral("Packed"));
|
||||||
|
|
||||||
|
const QVariant v = QVariant::fromValue(sub);
|
||||||
|
EXPECT_EQ(v.typeId(), qMetaTypeId<olive::SubtitleApp>());
|
||||||
|
|
||||||
|
const olive::SubtitleApp out = v.value<olive::SubtitleApp>();
|
||||||
|
EXPECT_EQ(out.text(), QStringLiteral("Packed"));
|
||||||
|
EXPECT_EQ(out.time(), sub.time());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DebugApp, HandlerFormatsMessageTypeAndContext)
|
||||||
|
{
|
||||||
|
testing::internal::CaptureStderr();
|
||||||
|
olive::debug_handler(QtDebugMsg, QMessageLogContext("file.cpp", 12, "func",
|
||||||
|
"category"),
|
||||||
|
QStringLiteral("hello"));
|
||||||
|
const std::string out = testing::internal::GetCapturedStderr();
|
||||||
|
|
||||||
|
EXPECT_NE(out.find("Debug: hello"), std::string::npos);
|
||||||
|
EXPECT_NE(out.find("file.cpp"), std::string::npos);
|
||||||
|
EXPECT_NE(out.find("func"), std::string::npos);
|
||||||
|
|
||||||
|
// A null context file/function is reported as <null>
|
||||||
|
testing::internal::CaptureStderr();
|
||||||
|
olive::debug_handler(QtInfoMsg, QMessageLogContext(), QStringLiteral("plain"));
|
||||||
|
const std::string out2 = testing::internal::GetCapturedStderr();
|
||||||
|
EXPECT_NE(out2.find("Info: plain"), std::string::npos);
|
||||||
|
EXPECT_NE(out2.find("<null>"), std::string::npos);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DebugApp, HandlerSuppressesFilteredWarnings)
|
||||||
|
{
|
||||||
|
// QXcbIntegration noise is always dropped
|
||||||
|
testing::internal::CaptureStderr();
|
||||||
|
olive::debug_handler(QtWarningMsg, QMessageLogContext(),
|
||||||
|
QStringLiteral("QXcbIntegration: something"));
|
||||||
|
EXPECT_TRUE(testing::internal::GetCapturedStderr().empty());
|
||||||
|
|
||||||
|
// Other warnings depend on the OAK_TESTING environment variable, which the
|
||||||
|
// handler samples once; mirror that expectation here
|
||||||
|
testing::internal::CaptureStderr();
|
||||||
|
olive::debug_handler(QtWarningMsg, QMessageLogContext(),
|
||||||
|
QStringLiteral("ordinary warning"));
|
||||||
|
const std::string out = testing::internal::GetCapturedStderr();
|
||||||
|
if (qEnvironmentVariableIsSet("OAK_TESTING")) {
|
||||||
|
EXPECT_TRUE(out.empty());
|
||||||
|
} else {
|
||||||
|
EXPECT_NE(out.find("Warning: ordinary warning"), std::string::npos);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,672 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QGroupBox>
|
||||||
|
#include <QImage>
|
||||||
|
#include <QSizePolicy>
|
||||||
|
#include <QSlider>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QXmlStreamReader>
|
||||||
|
#include <QXmlStreamWriter>
|
||||||
|
|
||||||
|
#include "dialog/about/scrollinglabel.h"
|
||||||
|
#include "dialog/export/codec/av1section.h"
|
||||||
|
#include "dialog/export/codec/cineformsection.h"
|
||||||
|
#include "dialog/export/codec/codecsection.h"
|
||||||
|
#include "dialog/export/codec/codecstack.h"
|
||||||
|
#include "dialog/footageproperties/streamproperties/audiostreamproperties.h"
|
||||||
|
#include "dialog/footageproperties/streamproperties/streamproperties.h"
|
||||||
|
#include "dialog/footageproperties/streamproperties/videostreamproperties.h"
|
||||||
|
#include "dialog/sequence/presetmanager.h"
|
||||||
|
#include "node/color/colormanager/colormanager.h"
|
||||||
|
#include "node/project.h"
|
||||||
|
#include "node/project/footage/footage.h"
|
||||||
|
#include "oakengine/encoding.h"
|
||||||
|
#include "oakengine/footage.h"
|
||||||
|
#include "oakutil/qtutils.h"
|
||||||
|
#include "render/videoparams.h"
|
||||||
|
#include "widget/slider/integerslider.h"
|
||||||
|
#include "widget/standardcombos/interlacedcombobox.h"
|
||||||
|
#include "widget/standardcombos/pixelaspectratiocombobox.h"
|
||||||
|
|
||||||
|
// dialog/about/patreon.h defines a global `QStringList patrons` (without
|
||||||
|
// extern); about.cpp already pulls that definition into the test binary, so
|
||||||
|
// including the header here would create a duplicate symbol. Reference the
|
||||||
|
// existing definition with a matching extern declaration instead.
|
||||||
|
extern QStringList patrons;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// Redirects QStandardPaths (used for the preset file location) to a
|
||||||
|
// disposable test location for the lifetime of the guard
|
||||||
|
class StandardPathsTestModeGuard {
|
||||||
|
public:
|
||||||
|
StandardPathsTestModeGuard()
|
||||||
|
{
|
||||||
|
QStandardPaths::setTestModeEnabled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
~StandardPathsTestModeGuard()
|
||||||
|
{
|
||||||
|
QStandardPaths::setTestModeEnabled(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Concrete Preset with a name and an integer so the PresetManager load/save
|
||||||
|
// cycle has something observable to round-trip
|
||||||
|
class NameValuePreset : public olive::Preset {
|
||||||
|
public:
|
||||||
|
int value_ = 0;
|
||||||
|
|
||||||
|
virtual void load(QXmlStreamReader *reader) override
|
||||||
|
{
|
||||||
|
while (olive::xml_read_next_start_element(reader)) {
|
||||||
|
if (reader->name() == QStringLiteral("name")) {
|
||||||
|
set_name(reader->readElementText());
|
||||||
|
} else if (reader->name() == QStringLiteral("value")) {
|
||||||
|
value_ = reader->readElementText().toInt();
|
||||||
|
} else {
|
||||||
|
reader->skipCurrentElement();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual void save(QXmlStreamWriter *writer) const override
|
||||||
|
{
|
||||||
|
writer->writeTextElement(QStringLiteral("name"), get_name());
|
||||||
|
writer->writeTextElement(QStringLiteral("value"),
|
||||||
|
QString::number(value_));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Footage subclass that exposes the protected stream mutator of ViewerOutput
|
||||||
|
// so tests can populate streams without probing real media
|
||||||
|
class TestableFootage : public olive::Footage {
|
||||||
|
public:
|
||||||
|
using olive::ViewerOutput::add_stream;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A footage node with two video streams; the second carries the given
|
||||||
|
// (possibly empty) explicit colorspace override
|
||||||
|
TestableFootage *make_two_stream_footage(olive::Project *project,
|
||||||
|
const QString &explicit_colorspace)
|
||||||
|
{
|
||||||
|
auto *footage = new TestableFootage();
|
||||||
|
footage->setParent(project);
|
||||||
|
|
||||||
|
olive::VideoParams v0(1920, 1080, olive::Rational(1, 24),
|
||||||
|
olive::core::PixelFormat::u8, 4);
|
||||||
|
v0.set_stream_index(0);
|
||||||
|
v0.set_duration(48);
|
||||||
|
footage->add_stream(olive::Track::k_video, QVariant::fromValue(v0));
|
||||||
|
|
||||||
|
olive::VideoParams v1(1280, 720, olive::Rational(1, 24),
|
||||||
|
olive::core::PixelFormat::u8, 4);
|
||||||
|
v1.set_stream_index(1);
|
||||||
|
v1.set_duration(48);
|
||||||
|
v1.set_colorspace(explicit_colorspace);
|
||||||
|
footage->add_stream(olive::Track::k_video, QVariant::fromValue(v1));
|
||||||
|
|
||||||
|
return footage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The plain (non-combo-class) QComboBoxes of a VideoStreamProperties, in
|
||||||
|
// layout order: color space first, then color range. The widget also holds a
|
||||||
|
// PixelAspectRatioComboBox and an InterlacedComboBox, so filter those out.
|
||||||
|
QList<QComboBox *> plain_combos(QWidget *w)
|
||||||
|
{
|
||||||
|
QList<QComboBox *> out;
|
||||||
|
foreach (QComboBox *c, w->findChildren<QComboBox *>()) {
|
||||||
|
if (!qobject_cast<olive::PixelAspectRatioComboBox *>(c) &&
|
||||||
|
!qobject_cast<olive::InterlacedComboBox *>(c)) {
|
||||||
|
out.append(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders the widget and counts pixels drawn in its palette text color.
|
||||||
|
// QWidget::render() fills an opaque window-color background, so alpha-based
|
||||||
|
// checks are useless; the scrolling text is painted in palette().text().
|
||||||
|
int count_text_pixels(QWidget *w)
|
||||||
|
{
|
||||||
|
QImage img(w->size(), QImage::Format_ARGB32);
|
||||||
|
img.fill(Qt::transparent);
|
||||||
|
w->render(&img);
|
||||||
|
|
||||||
|
const QColor text = w->palette().text().color();
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
for (int y = 0; y < img.height(); y++) {
|
||||||
|
for (int x = 0; x < img.width(); x++) {
|
||||||
|
const QColor px = img.pixelColor(x, y);
|
||||||
|
if (qAbs(px.red() - text.red()) < 16 &&
|
||||||
|
qAbs(px.green() - text.green()) < 16 &&
|
||||||
|
qAbs(px.blue() - text.blue()) < 16) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
//
|
||||||
|
// about: patreon
|
||||||
|
//
|
||||||
|
TEST(DialogAboutPatreon, PatronsListIsEmptyInSourceTree)
|
||||||
|
{
|
||||||
|
// The committed patreon.h is generated with no entries; patreon.py fills
|
||||||
|
// the list in when packaging a release
|
||||||
|
EXPECT_TRUE(patrons.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// about: scrollinglabel
|
||||||
|
//
|
||||||
|
TEST(DialogScrollingLabel, SetTextSizesMinimumToContent)
|
||||||
|
{
|
||||||
|
olive::ScrollingLabel label;
|
||||||
|
EXPECT_EQ(label.minimumSize(), QSize(0, 0));
|
||||||
|
|
||||||
|
const QString long_line =
|
||||||
|
QStringLiteral("A considerably longer line of text");
|
||||||
|
label.set_text({ QStringLiteral("Hi"), long_line });
|
||||||
|
|
||||||
|
QFontMetrics fm = label.fontMetrics();
|
||||||
|
|
||||||
|
// Minimum height is always ten lines, minimum width the widest line
|
||||||
|
EXPECT_EQ(label.minimumHeight(), fm.height() * 10);
|
||||||
|
EXPECT_EQ(label.minimumWidth(),
|
||||||
|
olive::QtUtils::q_font_metrics_width(fm, long_line));
|
||||||
|
|
||||||
|
// A narrower text shrinks the minimum width again
|
||||||
|
label.set_text({ QStringLiteral("Hi") });
|
||||||
|
EXPECT_EQ(label.minimumWidth(),
|
||||||
|
olive::QtUtils::q_font_metrics_width(fm, QStringLiteral("Hi")));
|
||||||
|
EXPECT_EQ(label.minimumHeight(), fm.height() * 10);
|
||||||
|
|
||||||
|
// The text-list constructor applies the same sizing
|
||||||
|
olive::ScrollingLabel label2({ QStringLiteral("Hi") });
|
||||||
|
EXPECT_EQ(label2.minimumWidth(),
|
||||||
|
olive::QtUtils::q_font_metrics_width(fm, QStringLiteral("Hi")));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DialogScrollingLabel, EmptyTextKeepsOnlyLineHeightMinimum)
|
||||||
|
{
|
||||||
|
olive::ScrollingLabel label;
|
||||||
|
label.set_text({});
|
||||||
|
|
||||||
|
EXPECT_EQ(label.minimumWidth(), 0);
|
||||||
|
EXPECT_EQ(label.minimumHeight(), label.fontMetrics().height() * 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DialogScrollingLabel, AnimationScrollsTextIntoView)
|
||||||
|
{
|
||||||
|
olive::ScrollingLabel label(
|
||||||
|
{ QStringLiteral("Hello"), QStringLiteral("World") });
|
||||||
|
label.resize(120, 200);
|
||||||
|
|
||||||
|
// At offset zero every line sits below the widget: nothing is painted
|
||||||
|
EXPECT_EQ(count_text_pixels(&label), 0);
|
||||||
|
|
||||||
|
// animation_update() is a private slot; drive it through the meta-object.
|
||||||
|
// Half the widget height puts the first line near the vertical middle.
|
||||||
|
for (int i = 0; i < 100; i++) {
|
||||||
|
QMetaObject::invokeMethod(&label, "animation_update");
|
||||||
|
}
|
||||||
|
EXPECT_GT(count_text_pixels(&label), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DialogScrollingLabel, AnimationWrapsAfterFullCycle)
|
||||||
|
{
|
||||||
|
olive::ScrollingLabel label(
|
||||||
|
{ QStringLiteral("Hello"), QStringLiteral("World") });
|
||||||
|
label.resize(120, 200);
|
||||||
|
|
||||||
|
// animate_ resets to 0 once it reaches height + lines * line_height
|
||||||
|
const int cycle = 200 + 2 * label.fontMetrics().height();
|
||||||
|
for (int i = 0; i < cycle; i++) {
|
||||||
|
QMetaObject::invokeMethod(&label, "animation_update");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back at offset zero the text has scrolled out of view again
|
||||||
|
EXPECT_EQ(count_text_pixels(&label), 0);
|
||||||
|
|
||||||
|
// start/stop only toggle the internal timer
|
||||||
|
label.start_animating();
|
||||||
|
label.stop_animating();
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// sequence: presetmanager
|
||||||
|
//
|
||||||
|
TEST(DialogPresetManager, StartsEmptyWhenPresetFileMissing)
|
||||||
|
{
|
||||||
|
StandardPathsTestModeGuard test_mode;
|
||||||
|
|
||||||
|
const QString preset_name =
|
||||||
|
QStringLiteral("oak-gtest-presetmanager-missing");
|
||||||
|
const QString path =
|
||||||
|
QDir(olive::FileFunctions::get_configuration_location())
|
||||||
|
.filePath(preset_name);
|
||||||
|
QFile::remove(path);
|
||||||
|
|
||||||
|
{
|
||||||
|
olive::PresetManager<NameValuePreset> mgr(nullptr, preset_name);
|
||||||
|
EXPECT_EQ(mgr.get_number_of_presets(), 0);
|
||||||
|
EXPECT_TRUE(mgr.get_preset_data().isEmpty());
|
||||||
|
EXPECT_TRUE(mgr.get_custom_preset_filename().endsWith(preset_name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The destructor wrote an empty preset file; clean it up
|
||||||
|
QFile::remove(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DialogPresetManager, LoadsPresetsFromXmlFile)
|
||||||
|
{
|
||||||
|
StandardPathsTestModeGuard test_mode;
|
||||||
|
|
||||||
|
const QString preset_name =
|
||||||
|
QStringLiteral("oak-gtest-presetmanager-load");
|
||||||
|
const QString path =
|
||||||
|
QDir(olive::FileFunctions::get_configuration_location())
|
||||||
|
.filePath(preset_name);
|
||||||
|
QFile::remove(path);
|
||||||
|
|
||||||
|
{
|
||||||
|
QFile f(path);
|
||||||
|
ASSERT_TRUE(f.open(QFile::WriteOnly));
|
||||||
|
f.write("<?xml version=\"1.0\"?>\n"
|
||||||
|
"<presets>\n"
|
||||||
|
" <preset><name>Alpha</name><value>7</value></preset>\n"
|
||||||
|
" <preset><name>Beta</name><value>8</value></preset>\n"
|
||||||
|
"</presets>\n");
|
||||||
|
f.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
olive::PresetManager<NameValuePreset> mgr(nullptr, preset_name);
|
||||||
|
|
||||||
|
ASSERT_EQ(mgr.get_number_of_presets(), 2);
|
||||||
|
EXPECT_EQ(mgr.get_preset(0)->get_name(), QStringLiteral("Alpha"));
|
||||||
|
EXPECT_EQ(mgr.get_preset(1)->get_name(), QStringLiteral("Beta"));
|
||||||
|
EXPECT_EQ(
|
||||||
|
std::static_pointer_cast<NameValuePreset>(mgr.get_preset(0))->value_,
|
||||||
|
7);
|
||||||
|
EXPECT_EQ(
|
||||||
|
std::static_pointer_cast<NameValuePreset>(mgr.get_preset(1))->value_,
|
||||||
|
8);
|
||||||
|
EXPECT_EQ(mgr.get_preset_data().size(), 2);
|
||||||
|
|
||||||
|
mgr.delete_preset(0);
|
||||||
|
ASSERT_EQ(mgr.get_number_of_presets(), 1);
|
||||||
|
EXPECT_EQ(mgr.get_preset(0)->get_name(), QStringLiteral("Beta"));
|
||||||
|
|
||||||
|
// Change the surviving preset so the destructor's save has something
|
||||||
|
// new to write
|
||||||
|
std::static_pointer_cast<NameValuePreset>(mgr.get_preset(0))->value_ =
|
||||||
|
42;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The destructor persisted the current preset list to disk
|
||||||
|
{
|
||||||
|
olive::PresetManager<NameValuePreset> mgr(nullptr, preset_name);
|
||||||
|
ASSERT_EQ(mgr.get_number_of_presets(), 1);
|
||||||
|
EXPECT_EQ(mgr.get_preset(0)->get_name(), QStringLiteral("Beta"));
|
||||||
|
EXPECT_EQ(
|
||||||
|
std::static_pointer_cast<NameValuePreset>(mgr.get_preset(0))->value_,
|
||||||
|
42);
|
||||||
|
}
|
||||||
|
|
||||||
|
QFile::remove(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// export: codecsection (base class)
|
||||||
|
//
|
||||||
|
TEST(DialogCodecSection, BaseClassOptsAreNoOps)
|
||||||
|
{
|
||||||
|
olive::CodecSection section;
|
||||||
|
|
||||||
|
OakEngineEncodingParams *params = oakengine_encoding_params_create();
|
||||||
|
ASSERT_NE(params, nullptr);
|
||||||
|
|
||||||
|
section.add_opts(params);
|
||||||
|
|
||||||
|
// A base CodecSection writes no encoder options
|
||||||
|
char buf[64];
|
||||||
|
EXPECT_EQ(oakengine_encoding_params_video_option(params, "qp", buf,
|
||||||
|
static_cast<int>(sizeof(buf))),
|
||||||
|
OAKENGINE_E_NOT_FOUND);
|
||||||
|
|
||||||
|
// set_opts must accept anything without touching the widget
|
||||||
|
section.set_opts(params);
|
||||||
|
|
||||||
|
oakengine_encoding_params_destroy(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// export: codecstack
|
||||||
|
//
|
||||||
|
TEST(DialogCodecStack, CurrentWidgetExpandsOthersAreIgnored)
|
||||||
|
{
|
||||||
|
olive::CodecStack stack;
|
||||||
|
QWidget a;
|
||||||
|
QWidget b;
|
||||||
|
|
||||||
|
stack.addWidget(&a);
|
||||||
|
stack.addWidget(&b);
|
||||||
|
|
||||||
|
ASSERT_EQ(stack.count(), 2);
|
||||||
|
ASSERT_EQ(stack.currentIndex(), 0);
|
||||||
|
|
||||||
|
// addWidget() reapplies the policies for the current index
|
||||||
|
EXPECT_EQ(a.sizePolicy().horizontalPolicy(), QSizePolicy::Expanding);
|
||||||
|
EXPECT_EQ(a.sizePolicy().verticalPolicy(), QSizePolicy::Expanding);
|
||||||
|
EXPECT_EQ(b.sizePolicy().horizontalPolicy(), QSizePolicy::Ignored);
|
||||||
|
EXPECT_EQ(b.sizePolicy().verticalPolicy(), QSizePolicy::Ignored);
|
||||||
|
|
||||||
|
// Switching pages swaps the policies
|
||||||
|
stack.setCurrentIndex(1);
|
||||||
|
EXPECT_EQ(a.sizePolicy().horizontalPolicy(), QSizePolicy::Ignored);
|
||||||
|
EXPECT_EQ(b.sizePolicy().horizontalPolicy(), QSizePolicy::Expanding);
|
||||||
|
EXPECT_EQ(b.sizePolicy().verticalPolicy(), QSizePolicy::Expanding);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// export: av1section
|
||||||
|
//
|
||||||
|
TEST(DialogAV1CRFSection, DefaultsAndSliderRange)
|
||||||
|
{
|
||||||
|
// Copy to a local first: EXPECT_EQ binds by reference, which would
|
||||||
|
// odr-use the in-class-initialized static constant and fail to link
|
||||||
|
const int default_crf = olive::AV1CRFSection::k_default_a_v1_crf;
|
||||||
|
EXPECT_EQ(default_crf, 30);
|
||||||
|
|
||||||
|
olive::AV1CRFSection section(olive::AV1CRFSection::k_default_a_v1_crf);
|
||||||
|
EXPECT_EQ(section.get_value(), 30);
|
||||||
|
|
||||||
|
QSlider *slider = section.findChild<QSlider *>();
|
||||||
|
ASSERT_NE(slider, nullptr);
|
||||||
|
EXPECT_EQ(slider->minimum(), 0);
|
||||||
|
EXPECT_EQ(slider->maximum(), 63);
|
||||||
|
|
||||||
|
// The slider is the value source, clamped to 0-63
|
||||||
|
slider->setValue(45);
|
||||||
|
EXPECT_EQ(section.get_value(), 45);
|
||||||
|
|
||||||
|
slider->setValue(99);
|
||||||
|
EXPECT_EQ(section.get_value(), 63);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DialogAV1CRFSection, SliderSyncsIntoIntegerInput)
|
||||||
|
{
|
||||||
|
olive::AV1CRFSection section(30);
|
||||||
|
|
||||||
|
QSlider *slider = section.findChild<QSlider *>();
|
||||||
|
olive::IntegerSlider *input = section.findChild<olive::IntegerSlider *>();
|
||||||
|
ASSERT_NE(slider, nullptr);
|
||||||
|
ASSERT_NE(input, nullptr);
|
||||||
|
EXPECT_EQ(input->get_value(), 30);
|
||||||
|
|
||||||
|
// QSlider::valueChanged is wired to IntegerSlider::set_value
|
||||||
|
slider->setValue(20);
|
||||||
|
EXPECT_EQ(input->get_value(), 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DialogAV1Section, DefaultsAndAddOpts)
|
||||||
|
{
|
||||||
|
olive::AV1Section section;
|
||||||
|
|
||||||
|
// First combo is the preset list (0-13, default 8), second the
|
||||||
|
// compression method list (Constant Rate Factor only)
|
||||||
|
const auto combos = section.findChildren<QComboBox *>();
|
||||||
|
ASSERT_EQ(combos.size(), 2);
|
||||||
|
EXPECT_EQ(combos.at(0)->count(), 14);
|
||||||
|
EXPECT_EQ(combos.at(0)->currentIndex(), 8);
|
||||||
|
EXPECT_EQ(combos.at(1)->count(), 1);
|
||||||
|
|
||||||
|
OakEngineEncodingParams *params = oakengine_encoding_params_create();
|
||||||
|
ASSERT_NE(params, nullptr);
|
||||||
|
|
||||||
|
char buf[64];
|
||||||
|
|
||||||
|
// Default construction uses the default CRF
|
||||||
|
section.add_opts(params);
|
||||||
|
ASSERT_GT(oakengine_encoding_params_video_option(
|
||||||
|
params, "qp", buf, static_cast<int>(sizeof(buf))),
|
||||||
|
0);
|
||||||
|
EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("30"));
|
||||||
|
ASSERT_GT(oakengine_encoding_params_video_option(
|
||||||
|
params, "preset", buf, static_cast<int>(sizeof(buf))),
|
||||||
|
0);
|
||||||
|
EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("8"));
|
||||||
|
|
||||||
|
// Changed widget state is reflected in the written options
|
||||||
|
combos.at(0)->setCurrentIndex(3);
|
||||||
|
QSlider *slider = section.findChild<QSlider *>();
|
||||||
|
ASSERT_NE(slider, nullptr);
|
||||||
|
slider->setValue(20);
|
||||||
|
|
||||||
|
section.add_opts(params);
|
||||||
|
ASSERT_GT(oakengine_encoding_params_video_option(
|
||||||
|
params, "qp", buf, static_cast<int>(sizeof(buf))),
|
||||||
|
0);
|
||||||
|
EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("20"));
|
||||||
|
ASSERT_GT(oakengine_encoding_params_video_option(
|
||||||
|
params, "preset", buf, static_cast<int>(sizeof(buf))),
|
||||||
|
0);
|
||||||
|
EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("3"));
|
||||||
|
|
||||||
|
oakengine_encoding_params_destroy(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// export: cineformsection
|
||||||
|
//
|
||||||
|
TEST(DialogCineformSection, DefaultsToMediumQuality)
|
||||||
|
{
|
||||||
|
olive::CineformSection section;
|
||||||
|
|
||||||
|
QComboBox *quality = section.findChild<QComboBox *>();
|
||||||
|
ASSERT_NE(quality, nullptr);
|
||||||
|
|
||||||
|
// 13 FFmpeg quality levels (film3+ .. low), defaulting to "medium"
|
||||||
|
EXPECT_EQ(quality->count(), 13);
|
||||||
|
EXPECT_EQ(quality->currentIndex(), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DialogCineformSection, OptsRoundTripThroughParams)
|
||||||
|
{
|
||||||
|
olive::CineformSection section;
|
||||||
|
QComboBox *quality = section.findChild<QComboBox *>();
|
||||||
|
ASSERT_NE(quality, nullptr);
|
||||||
|
|
||||||
|
OakEngineEncodingParams *params = oakengine_encoding_params_create();
|
||||||
|
ASSERT_NE(params, nullptr);
|
||||||
|
|
||||||
|
char buf[64];
|
||||||
|
|
||||||
|
section.add_opts(params);
|
||||||
|
ASSERT_GT(oakengine_encoding_params_video_option(
|
||||||
|
params, "quality", buf, static_cast<int>(sizeof(buf))),
|
||||||
|
0);
|
||||||
|
EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("10"));
|
||||||
|
|
||||||
|
quality->setCurrentIndex(5);
|
||||||
|
section.add_opts(params);
|
||||||
|
ASSERT_GT(oakengine_encoding_params_video_option(
|
||||||
|
params, "quality", buf, static_cast<int>(sizeof(buf))),
|
||||||
|
0);
|
||||||
|
EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("5"));
|
||||||
|
|
||||||
|
// set_opts applies a stored value back to the combo
|
||||||
|
oakengine_encoding_params_set_video_option(params, "quality", "2");
|
||||||
|
section.set_opts(params);
|
||||||
|
EXPECT_EQ(quality->currentIndex(), 2);
|
||||||
|
|
||||||
|
// A params handle without the key leaves the combo untouched
|
||||||
|
OakEngineEncodingParams *empty = oakengine_encoding_params_create();
|
||||||
|
ASSERT_NE(empty, nullptr);
|
||||||
|
section.set_opts(empty);
|
||||||
|
EXPECT_EQ(quality->currentIndex(), 2);
|
||||||
|
|
||||||
|
oakengine_encoding_params_destroy(empty);
|
||||||
|
oakengine_encoding_params_destroy(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// footageproperties: streamproperties (base class)
|
||||||
|
//
|
||||||
|
TEST(DialogStreamProperties, BaseClassIsANoOpStub)
|
||||||
|
{
|
||||||
|
olive::StreamProperties props;
|
||||||
|
|
||||||
|
EXPECT_TRUE(props.sanity_check());
|
||||||
|
|
||||||
|
// accept() takes an optional parent pointer and does nothing
|
||||||
|
props.accept(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// footageproperties: audiostreamproperties
|
||||||
|
//
|
||||||
|
TEST(DialogAudioStreamProperties, ConstructsAroundFootageStream)
|
||||||
|
{
|
||||||
|
olive::Project project;
|
||||||
|
TestableFootage *footage = make_two_stream_footage(&project, QString());
|
||||||
|
|
||||||
|
olive::AudioStreamProperties props(
|
||||||
|
reinterpret_cast<OakEngineNode *>(footage), 0);
|
||||||
|
|
||||||
|
// The class is currently a stub: no UI, default sanity check, no-op accept
|
||||||
|
EXPECT_EQ(props.findChildren<QWidget *>().size(), 0);
|
||||||
|
EXPECT_TRUE(props.sanity_check());
|
||||||
|
props.accept(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// footageproperties: videostreamproperties
|
||||||
|
//
|
||||||
|
TEST(DialogVideoStreamProperties, ReflectsStreamDefaults)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
|
||||||
|
TestableFootage *footage = make_two_stream_footage(&project, QString());
|
||||||
|
|
||||||
|
olive::VideoStreamProperties props(
|
||||||
|
reinterpret_cast<OakEngineNode *>(footage), 0);
|
||||||
|
|
||||||
|
auto *interlace = props.findChild<olive::InterlacedComboBox *>();
|
||||||
|
ASSERT_NE(interlace, nullptr);
|
||||||
|
EXPECT_EQ(interlace->get_interlace_mode(),
|
||||||
|
olive::VideoParams::k_interlace_none);
|
||||||
|
|
||||||
|
auto *par = props.findChild<olive::PixelAspectRatioComboBox *>();
|
||||||
|
ASSERT_NE(par, nullptr);
|
||||||
|
EXPECT_EQ(par->get_pixel_aspect_ratio(), olive::Rational(1, 1));
|
||||||
|
|
||||||
|
const auto combos = plain_combos(&props);
|
||||||
|
ASSERT_EQ(combos.size(), 2);
|
||||||
|
|
||||||
|
// Color space: "Default (...)" entry followed by every config colorspace
|
||||||
|
QComboBox *colorspace = combos.at(0);
|
||||||
|
EXPECT_TRUE(colorspace->itemText(0).startsWith(QStringLiteral("Default (")));
|
||||||
|
EXPECT_EQ(colorspace->count(),
|
||||||
|
1 + project.color_manager()->list_available_colorspaces().size());
|
||||||
|
// No override on the stream: the default entry stays selected
|
||||||
|
EXPECT_EQ(colorspace->currentIndex(), 0);
|
||||||
|
|
||||||
|
// Color range: limited/full, matching the stream's limited default
|
||||||
|
QComboBox *range = combos.at(1);
|
||||||
|
ASSERT_EQ(range->count(), 2);
|
||||||
|
EXPECT_EQ(range->itemData(0).toInt(),
|
||||||
|
olive::VideoParams::k_color_range_limited);
|
||||||
|
EXPECT_EQ(range->itemData(1).toInt(), olive::VideoParams::k_color_range_full);
|
||||||
|
EXPECT_EQ(range->currentIndex(), olive::VideoParams::k_color_range_limited);
|
||||||
|
|
||||||
|
// Regular video is not an image sequence: no image sequence group box
|
||||||
|
EXPECT_EQ(props.findChild<QGroupBox *>(), nullptr);
|
||||||
|
|
||||||
|
// The premultiplied-alpha checkbox only exists for 4-channel processing
|
||||||
|
QCheckBox *premult = props.findChild<QCheckBox *>();
|
||||||
|
if (olive::VideoParams::k_internal_channel_count == 4) {
|
||||||
|
ASSERT_NE(premult, nullptr);
|
||||||
|
EXPECT_FALSE(premult->isChecked());
|
||||||
|
} else {
|
||||||
|
EXPECT_EQ(premult, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not an image sequence, so sanity_check() passes without prompting
|
||||||
|
EXPECT_TRUE(props.sanity_check());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DialogVideoStreamProperties, ReflectsExplicitColorspaceOverride)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
|
||||||
|
const QStringList spaces =
|
||||||
|
project.color_manager()->list_available_colorspaces();
|
||||||
|
ASSERT_FALSE(spaces.isEmpty());
|
||||||
|
|
||||||
|
TestableFootage *footage =
|
||||||
|
make_two_stream_footage(&project, spaces.first());
|
||||||
|
|
||||||
|
// Stream 1 carries the explicit colorspace override
|
||||||
|
olive::VideoStreamProperties props(
|
||||||
|
reinterpret_cast<OakEngineNode *>(footage), 1);
|
||||||
|
|
||||||
|
const auto combos = plain_combos(&props);
|
||||||
|
ASSERT_EQ(combos.size(), 2);
|
||||||
|
|
||||||
|
QComboBox *colorspace = combos.at(0);
|
||||||
|
EXPECT_EQ(colorspace->currentText(), spaces.first());
|
||||||
|
EXPECT_GT(colorspace->currentIndex(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DialogVideoStreamProperties, AcceptAppliesColorRangeChange)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
|
||||||
|
TestableFootage *footage = make_two_stream_footage(&project, QString());
|
||||||
|
|
||||||
|
olive::VideoStreamProperties props(
|
||||||
|
reinterpret_cast<OakEngineNode *>(footage), 0);
|
||||||
|
|
||||||
|
const auto combos = plain_combos(&props);
|
||||||
|
ASSERT_EQ(combos.size(), 2);
|
||||||
|
QComboBox *range = combos.at(1);
|
||||||
|
|
||||||
|
auto get_color_range = [&]() {
|
||||||
|
OakEngineFootage *handle = oakengine_footage_borrow(
|
||||||
|
reinterpret_cast<OakEngineNode *>(footage));
|
||||||
|
EXPECT_NE(handle, nullptr);
|
||||||
|
int color_range = -1;
|
||||||
|
oakengine_footage_get_video_stream_overrides(handle, 0, nullptr, 0,
|
||||||
|
&color_range, nullptr,
|
||||||
|
nullptr);
|
||||||
|
oakengine_footage_free(handle);
|
||||||
|
return color_range;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Accepting without edits must not touch the stream
|
||||||
|
props.accept(nullptr);
|
||||||
|
EXPECT_EQ(get_color_range(), olive::VideoParams::k_color_range_limited);
|
||||||
|
|
||||||
|
// Switching to full range is written back through the facade
|
||||||
|
range->setCurrentIndex(1);
|
||||||
|
props.accept(nullptr);
|
||||||
|
EXPECT_EQ(get_color_range(), olive::VideoParams::k_color_range_full);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,569 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <QColor>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QScrollBar>
|
||||||
|
#include <QSignalSpy>
|
||||||
|
#include <QSplitter>
|
||||||
|
#include <QStackedWidget>
|
||||||
|
|
||||||
|
#include "core.h"
|
||||||
|
#include "node/color/colormanager/colormanager.h"
|
||||||
|
#include "node/output/track/track.h"
|
||||||
|
#include "node/output/track/tracklist.h"
|
||||||
|
#include "node/project.h"
|
||||||
|
#include "node/project/sequence/sequence.h"
|
||||||
|
#include "oakengine/node.h"
|
||||||
|
#include "oakengine/timeline.h"
|
||||||
|
#include "timeline/timelinecommonapp.h"
|
||||||
|
#include "widget/clickablelabel/clickablelabel.h"
|
||||||
|
#include "widget/focusablelineedit/focusablelineedit.h"
|
||||||
|
#include "widget/timelinewidget/timelineandtrackview.h"
|
||||||
|
#include "widget/timelinewidget/trackview/trackview.h"
|
||||||
|
#include "widget/timelinewidget/trackview/trackviewitem.h"
|
||||||
|
#include "widget/timelinewidget/trackview/trackviewsplitter.h"
|
||||||
|
#include "widget/timelinewidget/view/timelineview.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// HandMovableView (base of TimelineView) connects to Core::instance() at
|
||||||
|
// construction, so anything embedding a TimelineView needs the singleton
|
||||||
|
void ensure_core()
|
||||||
|
{
|
||||||
|
if (!olive::Core::instance()) {
|
||||||
|
new olive::Core(); // intentionally leaked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
olive::Sequence *create_sequence(olive::Project *project)
|
||||||
|
{
|
||||||
|
auto *sequence = new olive::Sequence();
|
||||||
|
sequence->setParent(project);
|
||||||
|
return sequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors sequence_test.cpp: grow the track input array, then connect the
|
||||||
|
// edge so the TrackList assigns type, index and owning sequence
|
||||||
|
olive::Track *append_track(olive::Project *project, olive::Sequence *sequence,
|
||||||
|
olive::Track::Type type)
|
||||||
|
{
|
||||||
|
auto *track = new olive::Track();
|
||||||
|
track->setParent(project);
|
||||||
|
olive::TrackList *list = sequence->track_list(type);
|
||||||
|
list->array_append();
|
||||||
|
olive::Node::connect_edge(track, list->track_input(list->array_size() - 1));
|
||||||
|
return track;
|
||||||
|
}
|
||||||
|
|
||||||
|
OakEngineSequence *seq_handle(olive::Sequence *sequence)
|
||||||
|
{
|
||||||
|
return reinterpret_cast<OakEngineSequence *>(sequence);
|
||||||
|
}
|
||||||
|
|
||||||
|
OakEngineNode *node_handle(olive::Node *node)
|
||||||
|
{
|
||||||
|
return reinterpret_cast<OakEngineNode *>(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
OakEngineTrack *track_handle(olive::Track *track)
|
||||||
|
{
|
||||||
|
return reinterpret_cast<OakEngineTrack *>(track);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackViewItem's mute/lock buttons are distinguished only by the checked
|
||||||
|
// color baked into their style sheets (red for mute, gray for lock)
|
||||||
|
QPushButton *find_button_by_checked_color(QWidget *parent, const QString &rgb)
|
||||||
|
{
|
||||||
|
for (QPushButton *b : parent->findChildren<QPushButton *>()) {
|
||||||
|
if (b->styleSheet().contains(rgb)) {
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(TimelineApp, MovementModeOrdinalsMatchEngineAbi)
|
||||||
|
{
|
||||||
|
// The C ABI transports these as ints (OAKENGINE_MOVEMENT_MODE_*), so the
|
||||||
|
// app-side enum ordinals must stay in sync with the engine
|
||||||
|
EXPECT_EQ(int(olive::TimelineApp::k_none), OAKENGINE_MOVEMENT_MODE_NONE);
|
||||||
|
EXPECT_EQ(int(olive::TimelineApp::k_move), OAKENGINE_MOVEMENT_MODE_MOVE);
|
||||||
|
EXPECT_EQ(int(olive::TimelineApp::k_trim_in), OAKENGINE_MOVEMENT_MODE_TRIM_IN);
|
||||||
|
EXPECT_EQ(int(olive::TimelineApp::k_trim_out),
|
||||||
|
OAKENGINE_MOVEMENT_MODE_TRIM_OUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimelineApp, IsATrimMode)
|
||||||
|
{
|
||||||
|
EXPECT_TRUE(olive::TimelineApp::is_a_trim_mode(olive::TimelineApp::k_trim_in));
|
||||||
|
EXPECT_TRUE(
|
||||||
|
olive::TimelineApp::is_a_trim_mode(olive::TimelineApp::k_trim_out));
|
||||||
|
EXPECT_FALSE(olive::TimelineApp::is_a_trim_mode(olive::TimelineApp::k_none));
|
||||||
|
EXPECT_FALSE(olive::TimelineApp::is_a_trim_mode(olive::TimelineApp::k_move));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimelineApp, ThumbnailAndWaveformOrdinals)
|
||||||
|
{
|
||||||
|
EXPECT_EQ(int(olive::TimelineApp::k_thumbnail_off), 0);
|
||||||
|
EXPECT_EQ(int(olive::TimelineApp::k_thumbnail_in_out), 1);
|
||||||
|
EXPECT_EQ(int(olive::TimelineApp::k_thumbnail_on), 2);
|
||||||
|
|
||||||
|
EXPECT_EQ(int(olive::TimelineApp::k_waveforms_disabled), 0);
|
||||||
|
EXPECT_EQ(int(olive::TimelineApp::k_waveforms_enabled), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimelineApp, EditToInfoIsPlainStruct)
|
||||||
|
{
|
||||||
|
olive::TimelineApp::EditToInfo info;
|
||||||
|
info.track = nullptr;
|
||||||
|
info.nearest_block = nullptr;
|
||||||
|
info.nearest_time = olive::core::Rational(5, 1);
|
||||||
|
|
||||||
|
EXPECT_EQ(info.track, nullptr);
|
||||||
|
EXPECT_EQ(info.nearest_block, nullptr);
|
||||||
|
EXPECT_EQ(info.nearest_time, olive::core::Rational(5, 1));
|
||||||
|
|
||||||
|
olive::TimelineApp::EditToInfo copy = info;
|
||||||
|
EXPECT_EQ(copy.nearest_time, olive::core::Rational(5, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimelineAndTrackView, ConstructionWiresSplitterAndViews)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
olive::TimelineAndTrackView tav;
|
||||||
|
ASSERT_NE(tav.splitter(), nullptr);
|
||||||
|
ASSERT_NE(tav.view(), nullptr);
|
||||||
|
ASSERT_NE(tav.track_view(), nullptr);
|
||||||
|
|
||||||
|
EXPECT_EQ(tav.splitter()->orientation(), Qt::Horizontal);
|
||||||
|
EXPECT_FALSE(tav.splitter()->childrenCollapsible());
|
||||||
|
ASSERT_EQ(tav.splitter()->count(), 2);
|
||||||
|
|
||||||
|
// Track headers on the left, timeline view on the right
|
||||||
|
EXPECT_EQ(tav.splitter()->widget(0), tav.track_view());
|
||||||
|
EXPECT_EQ(tav.splitter()->widget(1), tav.view());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimelineAndTrackView, ScrollbarsTrackEachOther)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
olive::TimelineAndTrackView tav;
|
||||||
|
QScrollBar *view_sb = tav.view()->verticalScrollBar();
|
||||||
|
QScrollBar *track_sb = tav.track_view()->verticalScrollBar();
|
||||||
|
ASSERT_NE(view_sb, nullptr);
|
||||||
|
ASSERT_NE(track_sb, nullptr);
|
||||||
|
|
||||||
|
// With an empty sequence both scrollbars have a degenerate range; give
|
||||||
|
// them explicit ranges so the sync math is observable. The ranges stick
|
||||||
|
// because nothing relayouts the hidden widget afterwards
|
||||||
|
view_sb->setRange(10, 100);
|
||||||
|
track_sb->setRange(0, 90);
|
||||||
|
|
||||||
|
// Scrolling the view scrolls the track headers by (value - minimum)
|
||||||
|
view_sb->setValue(50);
|
||||||
|
EXPECT_EQ(track_sb->value(), 40);
|
||||||
|
|
||||||
|
// Scrolling the track headers scrolls the view by (minimum + value)
|
||||||
|
track_sb->setValue(25);
|
||||||
|
EXPECT_EQ(view_sb->value(), 35);
|
||||||
|
|
||||||
|
// Deliberately no cleanup of the sync connections: leaving the scrollbars
|
||||||
|
// at non-zero values exercises ~TimelineAndTrackView, which must detach
|
||||||
|
// them before the child views reset their scenes during teardown
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackView, ConstructionDefaults)
|
||||||
|
{
|
||||||
|
olive::TrackView view;
|
||||||
|
EXPECT_EQ(view.horizontalScrollBarPolicy(), Qt::ScrollBarAlwaysOff);
|
||||||
|
EXPECT_EQ(view.verticalScrollBarPolicy(), Qt::ScrollBarAlwaysOff);
|
||||||
|
EXPECT_TRUE(view.widgetResizable());
|
||||||
|
EXPECT_EQ(view.alignment(), Qt::Alignment(Qt::AlignLeft | Qt::AlignTop));
|
||||||
|
|
||||||
|
auto *splitter = view.findChild<olive::TrackViewSplitter *>();
|
||||||
|
ASSERT_NE(splitter, nullptr);
|
||||||
|
EXPECT_EQ(splitter->orientation(), Qt::Vertical);
|
||||||
|
// Only the trailing spacer widget before any track list is connected
|
||||||
|
EXPECT_EQ(splitter->count(), 1);
|
||||||
|
|
||||||
|
olive::TrackView bottom(Qt::AlignBottom);
|
||||||
|
EXPECT_EQ(bottom.alignment(), Qt::Alignment(Qt::AlignLeft | Qt::AlignBottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackView, ConnectTrackListPopulatesAndDisconnectClears)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
project.initialize();
|
||||||
|
olive::Sequence *sequence = create_sequence(&project);
|
||||||
|
append_track(&project, sequence, olive::Track::k_video);
|
||||||
|
append_track(&project, sequence, olive::Track::k_video);
|
||||||
|
append_track(&project, sequence, olive::Track::k_audio);
|
||||||
|
|
||||||
|
olive::TrackView view;
|
||||||
|
auto *splitter = view.findChild<olive::TrackViewSplitter *>();
|
||||||
|
ASSERT_NE(splitter, nullptr);
|
||||||
|
EXPECT_EQ(splitter->count(), 1);
|
||||||
|
|
||||||
|
// Binding the video track list adds one item per track (plus the spacer)
|
||||||
|
view.connect_track_list(seq_handle(sequence), OAKENGINE_TRACK_TYPE_VIDEO);
|
||||||
|
EXPECT_EQ(splitter->count(), 3);
|
||||||
|
|
||||||
|
// Rebinding to the audio list swaps the items, it doesn't accumulate
|
||||||
|
view.connect_track_list(seq_handle(sequence), OAKENGINE_TRACK_TYPE_AUDIO);
|
||||||
|
EXPECT_EQ(splitter->count(), 2);
|
||||||
|
|
||||||
|
view.disconnect_track_list();
|
||||||
|
EXPECT_EQ(splitter->count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackView, InsertAndRemoveTrackUpdateSplitter)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
project.initialize();
|
||||||
|
olive::Sequence *sequence = create_sequence(&project);
|
||||||
|
append_track(&project, sequence, olive::Track::k_video);
|
||||||
|
|
||||||
|
olive::TrackView view;
|
||||||
|
auto *splitter = view.findChild<olive::TrackViewSplitter *>();
|
||||||
|
ASSERT_NE(splitter, nullptr);
|
||||||
|
|
||||||
|
view.connect_track_list(seq_handle(sequence), OAKENGINE_TRACK_TYPE_VIDEO);
|
||||||
|
ASSERT_EQ(splitter->count(), 2);
|
||||||
|
|
||||||
|
// A track added to the sequence afterwards can be inserted explicitly
|
||||||
|
olive::Track *t2 = append_track(&project, sequence, olive::Track::k_video);
|
||||||
|
view.insert_track(track_handle(t2));
|
||||||
|
ASSERT_EQ(splitter->count(), 3);
|
||||||
|
|
||||||
|
// AlignTop ordering: items first, spacer last
|
||||||
|
QPointer<QWidget> item = splitter->widget(1);
|
||||||
|
ASSERT_FALSE(item.isNull());
|
||||||
|
|
||||||
|
view.remove_track(track_handle(t2));
|
||||||
|
EXPECT_EQ(splitter->count(), 2);
|
||||||
|
// remove() deletes the item widget
|
||||||
|
EXPECT_TRUE(item.isNull());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackViewItem, LabelShowsTypePrefixAndCustomLabel)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
project.initialize();
|
||||||
|
olive::Sequence *sequence = create_sequence(&project);
|
||||||
|
olive::Track *v1 = append_track(&project, sequence, olive::Track::k_video);
|
||||||
|
olive::Track *v2 = append_track(&project, sequence, olive::Track::k_video);
|
||||||
|
olive::Track *a1 = append_track(&project, sequence, olive::Track::k_audio);
|
||||||
|
olive::Track *s1 = append_track(&project, sequence, olive::Track::k_subtitle);
|
||||||
|
|
||||||
|
ASSERT_EQ(
|
||||||
|
oakengine_node_set_label(node_handle(v1), "Hero"), 0);
|
||||||
|
|
||||||
|
olive::TrackViewItem item_v1(track_handle(v1));
|
||||||
|
olive::TrackViewItem item_v2(track_handle(v2));
|
||||||
|
olive::TrackViewItem item_a1(track_handle(a1));
|
||||||
|
olive::TrackViewItem item_s1(track_handle(s1));
|
||||||
|
|
||||||
|
auto *label_v1 = item_v1.findChild<olive::ClickableLabel *>();
|
||||||
|
auto *label_v2 = item_v2.findChild<olive::ClickableLabel *>();
|
||||||
|
auto *label_a1 = item_a1.findChild<olive::ClickableLabel *>();
|
||||||
|
auto *label_s1 = item_s1.findChild<olive::ClickableLabel *>();
|
||||||
|
ASSERT_NE(label_v1, nullptr);
|
||||||
|
ASSERT_NE(label_v2, nullptr);
|
||||||
|
ASSERT_NE(label_a1, nullptr);
|
||||||
|
ASSERT_NE(label_s1, nullptr);
|
||||||
|
|
||||||
|
// NLE-style prefix is 1-based (V1/V2, A1, S1) followed by the custom
|
||||||
|
// label or, when unset, the engine default track name
|
||||||
|
EXPECT_TRUE(label_v1->text().startsWith(QStringLiteral("V1 ")));
|
||||||
|
EXPECT_TRUE(label_v1->text().contains(QStringLiteral("Hero")));
|
||||||
|
|
||||||
|
EXPECT_TRUE(label_v2->text().startsWith(QStringLiteral("V2 ")));
|
||||||
|
EXPECT_FALSE(label_v2->text().contains(QStringLiteral("Hero")));
|
||||||
|
EXPECT_GT(label_v2->text().size(), 4);
|
||||||
|
|
||||||
|
EXPECT_TRUE(label_a1->text().startsWith(QStringLiteral("A1 ")));
|
||||||
|
EXPECT_TRUE(label_s1->text().startsWith(QStringLiteral("S1 ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackViewItem, RenameThroughLineEditRoundTrips)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
project.initialize();
|
||||||
|
olive::Sequence *sequence = create_sequence(&project);
|
||||||
|
olive::Track *track = append_track(&project, sequence, olive::Track::k_video);
|
||||||
|
|
||||||
|
olive::TrackViewItem item(track_handle(track));
|
||||||
|
auto *stack = item.findChild<QStackedWidget *>();
|
||||||
|
auto *label = item.findChild<olive::ClickableLabel *>();
|
||||||
|
auto *edit = item.findChild<olive::FocusableLineEdit *>();
|
||||||
|
ASSERT_NE(stack, nullptr);
|
||||||
|
ASSERT_NE(label, nullptr);
|
||||||
|
ASSERT_NE(edit, nullptr);
|
||||||
|
|
||||||
|
// The label is the resting page of the stack
|
||||||
|
EXPECT_EQ(stack->currentWidget(), label);
|
||||||
|
|
||||||
|
// Double-clicking the label swaps in the line edit
|
||||||
|
emit label->mouse_double_clicked();
|
||||||
|
EXPECT_EQ(stack->currentWidget(), edit);
|
||||||
|
|
||||||
|
// Confirming writes the label to the engine and swaps back
|
||||||
|
edit->setText(QStringLiteral("Renamed"));
|
||||||
|
emit edit->confirmed();
|
||||||
|
EXPECT_EQ(stack->currentWidget(), label);
|
||||||
|
EXPECT_TRUE(label->text().contains(QStringLiteral("Renamed")));
|
||||||
|
|
||||||
|
char buf[256];
|
||||||
|
buf[0] = '\0';
|
||||||
|
oakengine_node_get_label(node_handle(track), buf, sizeof(buf));
|
||||||
|
EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("Renamed"));
|
||||||
|
|
||||||
|
// Cancelling discards the edit and restores the label page
|
||||||
|
emit label->mouse_double_clicked();
|
||||||
|
EXPECT_EQ(stack->currentWidget(), edit);
|
||||||
|
edit->setText(QStringLiteral("Discarded"));
|
||||||
|
emit edit->cancelled();
|
||||||
|
EXPECT_EQ(stack->currentWidget(), label);
|
||||||
|
|
||||||
|
buf[0] = '\0';
|
||||||
|
oakengine_node_get_label(node_handle(track), buf, sizeof(buf));
|
||||||
|
EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("Renamed"));
|
||||||
|
EXPECT_TRUE(label->text().contains(QStringLiteral("Renamed")));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackViewItem, MuteAndLockButtonsDriveEngineState)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
project.initialize();
|
||||||
|
olive::Sequence *sequence = create_sequence(&project);
|
||||||
|
olive::Track *track = append_track(&project, sequence, olive::Track::k_video);
|
||||||
|
|
||||||
|
olive::TrackViewItem item(track_handle(track));
|
||||||
|
|
||||||
|
// The buttons are identified by the checked color baked into their style
|
||||||
|
// sheets (Qt::red for mute, Qt::gray for lock); resolve the colors the
|
||||||
|
// same way the implementation does -- QColor(Qt::gray).name() is not the
|
||||||
|
// SVG "#808080" one might expect
|
||||||
|
QPushButton *mute = find_button_by_checked_color(
|
||||||
|
&item, QColor(Qt::red).name());
|
||||||
|
QPushButton *lock = find_button_by_checked_color(
|
||||||
|
&item, QColor(Qt::gray).name());
|
||||||
|
ASSERT_NE(mute, nullptr);
|
||||||
|
ASSERT_NE(lock, nullptr);
|
||||||
|
EXPECT_TRUE(mute->isCheckable());
|
||||||
|
EXPECT_TRUE(lock->isCheckable());
|
||||||
|
EXPECT_FALSE(mute->isChecked());
|
||||||
|
EXPECT_FALSE(lock->isChecked());
|
||||||
|
|
||||||
|
OakEngineSequence *seq = seq_handle(sequence);
|
||||||
|
EXPECT_EQ(oakengine_track_is_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0), 0);
|
||||||
|
EXPECT_EQ(oakengine_track_is_locked(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0), 0);
|
||||||
|
|
||||||
|
// Clicking the button writes through to the engine track
|
||||||
|
mute->click();
|
||||||
|
EXPECT_EQ(oakengine_track_is_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0), 1);
|
||||||
|
EXPECT_TRUE(mute->isChecked());
|
||||||
|
|
||||||
|
// An external mute change reflects back on the button via the
|
||||||
|
// EngineEventBridge subscription
|
||||||
|
oakengine_track_set_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0);
|
||||||
|
EXPECT_FALSE(mute->isChecked());
|
||||||
|
|
||||||
|
lock->click();
|
||||||
|
EXPECT_EQ(oakengine_track_is_locked(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0), 1);
|
||||||
|
EXPECT_TRUE(lock->isChecked());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackViewItem, DeleteTrackEmitsSignalAndRemovesTrack)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
project.initialize();
|
||||||
|
olive::Sequence *sequence = create_sequence(&project);
|
||||||
|
olive::Track *track = append_track(&project, sequence, olive::Track::k_video);
|
||||||
|
|
||||||
|
// Owned by the test so the dangling engine handle after removal can't
|
||||||
|
// outlive the widget
|
||||||
|
auto *item = new olive::TrackViewItem(track_handle(track));
|
||||||
|
|
||||||
|
OakEngineTrack *received = nullptr;
|
||||||
|
QObject::connect(item, &olive::TrackViewItem::about_to_delete_track,
|
||||||
|
[&received](OakEngineTrack *t) { received = t; });
|
||||||
|
|
||||||
|
// The context menu wires this slot with a queued connection; on the same
|
||||||
|
// thread a direct meta-call is equivalent and avoids the modal menu
|
||||||
|
QMetaObject::invokeMethod(item, "delete_track");
|
||||||
|
|
||||||
|
EXPECT_EQ(received, track_handle(track));
|
||||||
|
|
||||||
|
int video = -1, audio = -1, subtitle = -1;
|
||||||
|
oakengine_sequence_track_count(seq_handle(sequence), &video, &audio,
|
||||||
|
&subtitle);
|
||||||
|
EXPECT_EQ(video, 0);
|
||||||
|
|
||||||
|
delete item;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackViewSplitter, ConstructionDefaults)
|
||||||
|
{
|
||||||
|
olive::TrackViewSplitter splitter(Qt::AlignTop);
|
||||||
|
EXPECT_EQ(splitter.orientation(), Qt::Vertical);
|
||||||
|
EXPECT_EQ(splitter.handleWidth(), 1);
|
||||||
|
// The trailing spacer is added by the constructor
|
||||||
|
EXPECT_EQ(splitter.count(), 1);
|
||||||
|
EXPECT_EQ(splitter.height(), 0);
|
||||||
|
|
||||||
|
olive::TrackViewSplitter bottom(Qt::AlignBottom);
|
||||||
|
EXPECT_EQ(bottom.count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackViewSplitter, InsertGrowsFixedHeightAndOrdersWidgets)
|
||||||
|
{
|
||||||
|
olive::TrackViewSplitter splitter(Qt::AlignTop);
|
||||||
|
|
||||||
|
auto *w1 = new QWidget();
|
||||||
|
auto *w2 = new QWidget();
|
||||||
|
splitter.insert(0, 100, w1);
|
||||||
|
|
||||||
|
// Height = item height + one handle width
|
||||||
|
EXPECT_EQ(splitter.count(), 2);
|
||||||
|
EXPECT_EQ(splitter.widget(0), w1);
|
||||||
|
EXPECT_EQ(splitter.height(), 101);
|
||||||
|
|
||||||
|
splitter.insert(1, 50, w2);
|
||||||
|
|
||||||
|
// Height = both items + two handle widths; spacer stays last
|
||||||
|
EXPECT_EQ(splitter.count(), 3);
|
||||||
|
EXPECT_EQ(splitter.widget(0), w1);
|
||||||
|
EXPECT_EQ(splitter.widget(1), w2);
|
||||||
|
EXPECT_EQ(splitter.height(), 152);
|
||||||
|
|
||||||
|
const QList<int> sz = splitter.sizes();
|
||||||
|
ASSERT_EQ(sz.size(), 3);
|
||||||
|
EXPECT_EQ(sz.at(0), 100);
|
||||||
|
EXPECT_EQ(sz.at(1), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackViewSplitter, InsertAlignBottomAppendsAfterSpacer)
|
||||||
|
{
|
||||||
|
olive::TrackViewSplitter splitter(Qt::AlignBottom);
|
||||||
|
|
||||||
|
auto *w1 = new QWidget();
|
||||||
|
auto *w2 = new QWidget();
|
||||||
|
splitter.insert(0, 60, w1);
|
||||||
|
splitter.insert(1, 40, w2);
|
||||||
|
|
||||||
|
// With bottom alignment the spacer stays at the top and later inserts
|
||||||
|
// land between the spacer and the existing items
|
||||||
|
ASSERT_EQ(splitter.count(), 3);
|
||||||
|
EXPECT_EQ(splitter.widget(1), w2);
|
||||||
|
EXPECT_EQ(splitter.widget(2), w1);
|
||||||
|
|
||||||
|
// Height = both items + two handle widths
|
||||||
|
EXPECT_EQ(splitter.height(), 102);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackViewSplitter, RemoveDeletesWidgetAndShrinksHeight)
|
||||||
|
{
|
||||||
|
olive::TrackViewSplitter splitter(Qt::AlignTop);
|
||||||
|
|
||||||
|
auto *w1 = new QWidget();
|
||||||
|
auto *w2 = new QWidget();
|
||||||
|
splitter.insert(0, 100, w1);
|
||||||
|
splitter.insert(1, 50, w2);
|
||||||
|
ASSERT_EQ(splitter.height(), 152);
|
||||||
|
|
||||||
|
QPointer<QWidget> gone = w1;
|
||||||
|
splitter.remove(0);
|
||||||
|
|
||||||
|
EXPECT_TRUE(gone.isNull());
|
||||||
|
EXPECT_EQ(splitter.count(), 2);
|
||||||
|
EXPECT_EQ(splitter.widget(0), w2);
|
||||||
|
// Removing an item subtracts its height and one handle width
|
||||||
|
EXPECT_EQ(splitter.height(), 51);
|
||||||
|
|
||||||
|
const QList<int> sz = splitter.sizes();
|
||||||
|
ASSERT_EQ(sz.size(), 2);
|
||||||
|
EXPECT_EQ(sz.at(0), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackViewSplitter, SetSpacerHeightGrowsTotalHeight)
|
||||||
|
{
|
||||||
|
olive::TrackViewSplitter splitter(Qt::AlignTop);
|
||||||
|
|
||||||
|
auto *w1 = new QWidget();
|
||||||
|
splitter.insert(0, 100, w1);
|
||||||
|
ASSERT_EQ(splitter.height(), 101);
|
||||||
|
|
||||||
|
// The spacer keeps a handle on the trailing edge; its height is added to
|
||||||
|
// the fixed total
|
||||||
|
splitter.set_spacer_height(40);
|
||||||
|
EXPECT_EQ(splitter.height(), 141);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackViewSplitter, SetTrackHeightAdjustsFixedHeight)
|
||||||
|
{
|
||||||
|
olive::TrackViewSplitter splitter(Qt::AlignTop);
|
||||||
|
|
||||||
|
splitter.insert(0, 100, new QWidget());
|
||||||
|
splitter.insert(1, 50, new QWidget());
|
||||||
|
ASSERT_EQ(splitter.height(), 152);
|
||||||
|
|
||||||
|
// Growing a track by 30px grows the whole splitter by 30px
|
||||||
|
splitter.set_track_height(0, 130);
|
||||||
|
EXPECT_EQ(splitter.height(), 182);
|
||||||
|
|
||||||
|
// The fixed height always moves by (new - old) for the addressed track;
|
||||||
|
// read the current size back instead of assuming an exact layout, since
|
||||||
|
// QSplitter redistributes sizes when the fixed height changes
|
||||||
|
const int old_size = splitter.sizes().at(1);
|
||||||
|
const int old_height = splitter.height();
|
||||||
|
splitter.set_track_height(1, 20);
|
||||||
|
EXPECT_EQ(splitter.height(), old_height + (20 - old_size));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackViewSplitter, HandlesAreTrackViewSplitterHandles)
|
||||||
|
{
|
||||||
|
olive::TrackViewSplitter splitter(Qt::AlignTop);
|
||||||
|
splitter.insert(0, 100, new QWidget());
|
||||||
|
|
||||||
|
// createHandle() must produce the custom handle type
|
||||||
|
EXPECT_NE(qobject_cast<olive::TrackViewSplitterHandle *>(splitter.handle(1)),
|
||||||
|
nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TrackViewSplitter, HandleReceiverResizesAndEmits)
|
||||||
|
{
|
||||||
|
olive::TrackViewSplitter splitter(Qt::AlignTop);
|
||||||
|
splitter.insert(0, 100, new QWidget());
|
||||||
|
splitter.insert(1, 50, new QWidget());
|
||||||
|
ASSERT_EQ(splitter.height(), 152);
|
||||||
|
|
||||||
|
const QList<int> sz = splitter.sizes();
|
||||||
|
ASSERT_EQ(sz.size(), 3);
|
||||||
|
ASSERT_EQ(sz.at(0), 100);
|
||||||
|
|
||||||
|
QSignalSpy spy(&splitter, &olive::TrackViewSplitter::track_height_changed);
|
||||||
|
|
||||||
|
// Dragging the handle below the first track grows it by the drag delta
|
||||||
|
auto *handle =
|
||||||
|
qobject_cast<olive::TrackViewSplitterHandle *>(splitter.handle(1));
|
||||||
|
ASSERT_NE(handle, nullptr);
|
||||||
|
splitter.handle_receiver(handle, 30);
|
||||||
|
|
||||||
|
ASSERT_EQ(spy.count(), 1);
|
||||||
|
EXPECT_EQ(spy.first().at(0).toInt(), 0);
|
||||||
|
EXPECT_EQ(spy.first().at(1).toInt(), 130);
|
||||||
|
EXPECT_EQ(splitter.height(), 182);
|
||||||
|
}
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QFontMetrics>
|
||||||
|
#include <QImage>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QSignalSpy>
|
||||||
|
#include <QTest>
|
||||||
|
|
||||||
|
#include "node/block/clip/clip.h"
|
||||||
|
#include "node/color/colormanager/colormanager.h"
|
||||||
|
#include "node/output/track/track.h"
|
||||||
|
#include "node/output/track/tracklist.h"
|
||||||
|
#include "node/project.h"
|
||||||
|
#include "node/project/sequence/sequence.h"
|
||||||
|
#include "timeline/timelinemarker.h"
|
||||||
|
#include "widget/playbackcontrols/dragbutton.h"
|
||||||
|
#include "widget/taskview/elapsedcounterwidget.h"
|
||||||
|
#include "widget/timelinewidget/cliphandle.h"
|
||||||
|
#include "widget/timelinewidget/trackhandle.h"
|
||||||
|
#include "widget/timeruler/markerhandle.h"
|
||||||
|
#include "widget/timeruler/markerpainting.h"
|
||||||
|
#include "oakutil/qtutils.h"
|
||||||
|
|
||||||
|
TEST(WidgetDragButton, DefaultsAndPlainClick)
|
||||||
|
{
|
||||||
|
olive::DragButton btn;
|
||||||
|
btn.resize(80, 30);
|
||||||
|
EXPECT_EQ(btn.cursor().shape(), Qt::OpenHandCursor);
|
||||||
|
|
||||||
|
QSignalSpy drag_spy(&btn, &olive::DragButton::drag_started);
|
||||||
|
QSignalSpy click_spy(&btn, &QPushButton::clicked);
|
||||||
|
|
||||||
|
// A plain click is still a normal button click, not a drag
|
||||||
|
QTest::mouseClick(&btn, Qt::LeftButton, Qt::NoModifier, QPoint(10, 10));
|
||||||
|
EXPECT_EQ(drag_spy.count(), 0);
|
||||||
|
EXPECT_EQ(click_spy.count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetDragButton, DragEmitsDragStartedOncePerPress)
|
||||||
|
{
|
||||||
|
olive::DragButton btn;
|
||||||
|
btn.resize(80, 30);
|
||||||
|
|
||||||
|
QSignalSpy drag_spy(&btn, &olive::DragButton::drag_started);
|
||||||
|
|
||||||
|
// Press then move: the first move with a button held starts the drag
|
||||||
|
QTest::mousePress(&btn, Qt::LeftButton, Qt::NoModifier, QPoint(10, 10));
|
||||||
|
QTest::mouseMove(&btn, QPoint(20, 15));
|
||||||
|
ASSERT_EQ(drag_spy.count(), 1);
|
||||||
|
|
||||||
|
// The drag state latches until release
|
||||||
|
QTest::mouseMove(&btn, QPoint(30, 20));
|
||||||
|
EXPECT_EQ(drag_spy.count(), 1);
|
||||||
|
QTest::mouseRelease(&btn, Qt::LeftButton, Qt::NoModifier, QPoint(30, 20));
|
||||||
|
|
||||||
|
// A new press re-arms the drag detection
|
||||||
|
QTest::mousePress(&btn, Qt::LeftButton, Qt::NoModifier, QPoint(10, 10));
|
||||||
|
QTest::mouseMove(&btn, QPoint(25, 15));
|
||||||
|
EXPECT_EQ(drag_spy.count(), 2);
|
||||||
|
QTest::mouseRelease(&btn, Qt::LeftButton, Qt::NoModifier, QPoint(25, 15));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetDragButton, MoveWithoutButtonsDoesNotEmit)
|
||||||
|
{
|
||||||
|
olive::DragButton btn;
|
||||||
|
btn.resize(80, 30);
|
||||||
|
|
||||||
|
QSignalSpy drag_spy(&btn, &olive::DragButton::drag_started);
|
||||||
|
|
||||||
|
QTest::mouseMove(&btn, QPoint(40, 15));
|
||||||
|
EXPECT_EQ(drag_spy.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetElapsedCounter, InitialLabelsAreZero)
|
||||||
|
{
|
||||||
|
olive::ElapsedCounterWidget w;
|
||||||
|
const auto labels = w.findChildren<QLabel *>();
|
||||||
|
ASSERT_EQ(labels.size(), 2);
|
||||||
|
|
||||||
|
EXPECT_EQ(labels.at(0)->text(), QStringLiteral("Elapsed: 00:00:00"));
|
||||||
|
EXPECT_EQ(labels.at(1)->text(), QStringLiteral("Remaining: 00:00:00"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetElapsedCounter, HalfProgressMakesElapsedEqualRemaining)
|
||||||
|
{
|
||||||
|
olive::ElapsedCounterWidget w;
|
||||||
|
const auto labels = w.findChildren<QLabel *>();
|
||||||
|
ASSERT_EQ(labels.size(), 2);
|
||||||
|
|
||||||
|
// At 50% progress, remaining time is derived from the same elapsed
|
||||||
|
// value, so both labels must agree regardless of wall-clock drift
|
||||||
|
w.start(QDateTime::currentMSecsSinceEpoch() - 60000);
|
||||||
|
w.set_progress(0.5);
|
||||||
|
|
||||||
|
const QString elapsed = labels.at(0)->text();
|
||||||
|
const QString remaining = labels.at(1)->text();
|
||||||
|
EXPECT_NE(elapsed, QStringLiteral("Elapsed: 00:00:00"));
|
||||||
|
EXPECT_EQ(elapsed, QStringLiteral("Elapsed: ") +
|
||||||
|
remaining.mid(QStringLiteral("Remaining: ").size()));
|
||||||
|
|
||||||
|
// A full progress report leaves nothing remaining
|
||||||
|
w.set_progress(1.0);
|
||||||
|
EXPECT_EQ(labels.at(1)->text(), QStringLiteral("Remaining: 00:00:00"));
|
||||||
|
|
||||||
|
w.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetElapsedCounter, ZeroProgressResetsLabels)
|
||||||
|
{
|
||||||
|
olive::ElapsedCounterWidget w;
|
||||||
|
const auto labels = w.findChildren<QLabel *>();
|
||||||
|
ASSERT_EQ(labels.size(), 2);
|
||||||
|
|
||||||
|
w.start(QDateTime::currentMSecsSinceEpoch() - 60000);
|
||||||
|
w.set_progress(0.5);
|
||||||
|
EXPECT_NE(labels.at(0)->text(), QStringLiteral("Elapsed: 00:00:00"));
|
||||||
|
|
||||||
|
// No progress means no estimate at all
|
||||||
|
w.set_progress(0.0);
|
||||||
|
EXPECT_EQ(labels.at(0)->text(), QStringLiteral("Elapsed: 00:00:00"));
|
||||||
|
EXPECT_EQ(labels.at(1)->text(), QStringLiteral("Remaining: 00:00:00"));
|
||||||
|
|
||||||
|
w.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetClipHandle, EmptyClipDefaults)
|
||||||
|
{
|
||||||
|
OakEngineBlock *clip = olive::clip_create_empty("Test Clip");
|
||||||
|
ASSERT_NE(clip, nullptr);
|
||||||
|
|
||||||
|
EXPECT_EQ(olive::cliphandle(clip),
|
||||||
|
reinterpret_cast<OakEngineClip *>(clip));
|
||||||
|
EXPECT_DOUBLE_EQ(olive::clip_speed(clip), 1.0);
|
||||||
|
EXPECT_EQ(olive::clip_loop_mode(clip), 0);
|
||||||
|
EXPECT_FALSE(olive::clip_is_reversed(clip));
|
||||||
|
EXPECT_FALSE(olive::clip_maintain_audio_pitch(clip));
|
||||||
|
EXPECT_FALSE(olive::clip_is_autocaching(clip));
|
||||||
|
|
||||||
|
// Nothing is connected to the buffer input and the clip is on no track
|
||||||
|
EXPECT_EQ(olive::clip_connected_node(clip), nullptr);
|
||||||
|
EXPECT_EQ(olive::clip_thumbnails(clip), nullptr);
|
||||||
|
EXPECT_EQ(olive::clip_waveform(clip), nullptr);
|
||||||
|
EXPECT_EQ(olive::clip_connected_video_cache(clip), nullptr);
|
||||||
|
EXPECT_EQ(olive::block_track_handle(clip), nullptr);
|
||||||
|
|
||||||
|
EXPECT_EQ(olive::clip_media_in(clip), olive::Rational(0));
|
||||||
|
const olive::TimeRange range = olive::clip_media_range(clip);
|
||||||
|
EXPECT_EQ(range.in(), olive::Rational(0));
|
||||||
|
EXPECT_EQ(range.out(), olive::Rational(0));
|
||||||
|
|
||||||
|
// Invalidation requests with nothing connected are a safe no-op
|
||||||
|
olive::clip_request_invalidate_connected(clip);
|
||||||
|
|
||||||
|
delete reinterpret_cast<olive::ClipBlock *>(clip);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetClipHandle, SetMediaInRoundTripsThroughFacade)
|
||||||
|
{
|
||||||
|
OakEngineBlock *clip = olive::clip_create_empty(nullptr);
|
||||||
|
ASSERT_NE(clip, nullptr);
|
||||||
|
|
||||||
|
olive::clip_set_media_in(clip, olive::Rational(2, 1));
|
||||||
|
EXPECT_EQ(olive::clip_media_in(clip), olive::Rational(2, 1));
|
||||||
|
|
||||||
|
// Media out is media in plus the clip's (zero) length
|
||||||
|
const olive::TimeRange range = olive::clip_media_range(clip);
|
||||||
|
EXPECT_EQ(range.in(), olive::Rational(2, 1));
|
||||||
|
EXPECT_EQ(range.out(), olive::Rational(2, 1));
|
||||||
|
|
||||||
|
delete reinterpret_cast<olive::ClipBlock *>(clip);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetClipHandle, NullClipIsTolerated)
|
||||||
|
{
|
||||||
|
olive::clip_set_media_in(nullptr, olive::Rational(1, 1));
|
||||||
|
olive::clip_request_invalidate_connected(nullptr);
|
||||||
|
olive::clip_request_invalidate_connected(
|
||||||
|
nullptr, true,
|
||||||
|
olive::TimeRange(olive::Rational(0, 1), olive::Rational(1, 1)));
|
||||||
|
SUCCEED();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetTrackHandle, NullTrackIsTolerated)
|
||||||
|
{
|
||||||
|
EXPECT_EQ(olive::trackhandle(nullptr), nullptr);
|
||||||
|
EXPECT_EQ(olive::track_sequence_handle(nullptr), nullptr);
|
||||||
|
EXPECT_EQ(olive::track_type_of(nullptr), -1);
|
||||||
|
EXPECT_EQ(olive::track_index_of(nullptr), -1);
|
||||||
|
EXPECT_FALSE(olive::track_is_locked(nullptr));
|
||||||
|
EXPECT_FALSE(olive::track_is_muted(nullptr));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetTrackHandle, ConnectedTrackReportsSequenceIndexAndFlags)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
project.initialize();
|
||||||
|
|
||||||
|
auto *sequence = new olive::Sequence();
|
||||||
|
sequence->setParent(&project);
|
||||||
|
sequence->add_default_nodes();
|
||||||
|
|
||||||
|
olive::TrackList *video = sequence->track_list(olive::Track::k_video);
|
||||||
|
olive::TrackList *audio = sequence->track_list(olive::Track::k_audio);
|
||||||
|
ASSERT_EQ(video->get_track_count(), 1);
|
||||||
|
ASSERT_EQ(audio->get_track_count(), 1);
|
||||||
|
|
||||||
|
olive::Track *video_track = video->get_track_at(0);
|
||||||
|
auto *h = reinterpret_cast<OakEngineTrack *>(video_track);
|
||||||
|
|
||||||
|
EXPECT_EQ(olive::trackhandle(h), h);
|
||||||
|
EXPECT_EQ(olive::track_type_of(h), OAKENGINE_TRACK_TYPE_VIDEO);
|
||||||
|
EXPECT_EQ(olive::track_index_of(h), 0);
|
||||||
|
EXPECT_EQ(olive::track_sequence_handle(h),
|
||||||
|
reinterpret_cast<OakEngineSequence *>(sequence));
|
||||||
|
|
||||||
|
EXPECT_FALSE(olive::track_is_locked(h));
|
||||||
|
EXPECT_FALSE(olive::track_is_muted(h));
|
||||||
|
|
||||||
|
video_track->set_locked(true);
|
||||||
|
EXPECT_TRUE(olive::track_is_locked(h));
|
||||||
|
|
||||||
|
video_track->set_muted(true);
|
||||||
|
EXPECT_TRUE(olive::track_is_muted(h));
|
||||||
|
|
||||||
|
// Lock/mute are per-track; the audio track is unaffected
|
||||||
|
auto *ah = reinterpret_cast<OakEngineTrack *>(audio->get_track_at(0));
|
||||||
|
EXPECT_EQ(olive::track_type_of(ah), OAKENGINE_TRACK_TYPE_AUDIO);
|
||||||
|
EXPECT_FALSE(olive::track_is_locked(ah));
|
||||||
|
EXPECT_FALSE(olive::track_is_muted(ah));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetMarkerHandle, DetachedMarkerAccessors)
|
||||||
|
{
|
||||||
|
OakEngineMarker *m = oakengine_marker_create(3, 5, 1, 8, 1, "Intro");
|
||||||
|
ASSERT_NE(m, nullptr);
|
||||||
|
|
||||||
|
const olive::TimeRange t = olive::marker_time(m);
|
||||||
|
EXPECT_EQ(t.in(), olive::Rational(5, 1));
|
||||||
|
EXPECT_EQ(t.out(), olive::Rational(8, 1));
|
||||||
|
EXPECT_EQ(olive::marker_name(m), QStringLiteral("Intro"));
|
||||||
|
EXPECT_EQ(olive::marker_color(m), 3);
|
||||||
|
|
||||||
|
// ADL customization points used by the selection manager
|
||||||
|
EXPECT_EQ(olive::selection_time(m), olive::Rational(5, 1));
|
||||||
|
EXPECT_EQ(olive::selection_time_end(m), olive::Rational(8, 1));
|
||||||
|
EXPECT_EQ(olive::selection_time_target_parent(m), nullptr);
|
||||||
|
|
||||||
|
oakengine_marker_free(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetMarkerHandle, SetTimeLiveUpdatesRange)
|
||||||
|
{
|
||||||
|
OakEngineMarker *m = oakengine_marker_create(0, 5, 1, 8, 1, "M");
|
||||||
|
ASSERT_NE(m, nullptr);
|
||||||
|
|
||||||
|
olive::marker_set_time_live(
|
||||||
|
m, olive::TimeRange(olive::Rational(1, 1), olive::Rational(2, 1)));
|
||||||
|
|
||||||
|
const olive::TimeRange t = olive::marker_time(m);
|
||||||
|
EXPECT_EQ(t.in(), olive::Rational(1, 1));
|
||||||
|
EXPECT_EQ(t.out(), olive::Rational(2, 1));
|
||||||
|
|
||||||
|
oakengine_marker_free(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetMarkerHandle, SelectionSetTimePreservesLength)
|
||||||
|
{
|
||||||
|
OakEngineMarker *m = oakengine_marker_create(0, 5, 1, 8, 1, nullptr);
|
||||||
|
ASSERT_NE(m, nullptr);
|
||||||
|
|
||||||
|
// Moving the in-point drags the out-point along, keeping a length of 3
|
||||||
|
olive::selection_set_time(m, olive::Rational(10, 1));
|
||||||
|
|
||||||
|
const olive::TimeRange t = olive::marker_time(m);
|
||||||
|
EXPECT_EQ(t.in(), olive::Rational(10, 1));
|
||||||
|
EXPECT_EQ(t.out(), olive::Rational(13, 1));
|
||||||
|
|
||||||
|
oakengine_marker_free(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetMarkerHandle, SiblingDetectionWithinList)
|
||||||
|
{
|
||||||
|
// The list asserts that in-points are unique, so use distinct times
|
||||||
|
olive::TimelineMarkerList list;
|
||||||
|
olive::TimelineMarker a(
|
||||||
|
1,
|
||||||
|
olive::core::TimeRange(olive::core::Rational(5, 1),
|
||||||
|
olive::core::Rational(5, 1)),
|
||||||
|
QStringLiteral("A"), &list);
|
||||||
|
olive::TimelineMarker b(
|
||||||
|
2,
|
||||||
|
olive::core::TimeRange(olive::core::Rational(7, 1),
|
||||||
|
olive::core::Rational(7, 1)),
|
||||||
|
QStringLiteral("B"), &list);
|
||||||
|
olive::TimelineMarker c(
|
||||||
|
3,
|
||||||
|
olive::core::TimeRange(olive::core::Rational(9, 1),
|
||||||
|
olive::core::Rational(9, 1)),
|
||||||
|
QStringLiteral("C"), &list);
|
||||||
|
ASSERT_EQ(list.size(), 3);
|
||||||
|
|
||||||
|
auto *hc = reinterpret_cast<OakEngineMarker *>(&c);
|
||||||
|
|
||||||
|
// Another marker sits at t=5, so c has a sibling there
|
||||||
|
EXPECT_TRUE(olive::marker_has_sibling_at_time(hc, olive::Rational(5, 1)));
|
||||||
|
|
||||||
|
// c is the only marker at t=9; a marker is not its own sibling
|
||||||
|
EXPECT_FALSE(olive::marker_has_sibling_at_time(hc, olive::Rational(9, 1)));
|
||||||
|
|
||||||
|
// No marker at all at t=11
|
||||||
|
EXPECT_FALSE(olive::marker_has_sibling_at_time(hc, olive::Rational(11, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetMarkerPainting, HeightMatchesFontMetrics)
|
||||||
|
{
|
||||||
|
QImage img(200, 100, QImage::Format_ARGB32);
|
||||||
|
img.fill(Qt::transparent);
|
||||||
|
QPainter p(&img);
|
||||||
|
|
||||||
|
EXPECT_EQ(olive::MarkerPainting::height(p.fontMetrics()),
|
||||||
|
p.fontMetrics().height());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetMarkerPainting, PointMarkerGeometryAndFill)
|
||||||
|
{
|
||||||
|
QImage img(200, 100, QImage::Format_ARGB32);
|
||||||
|
img.fill(Qt::transparent);
|
||||||
|
QPainter p(&img);
|
||||||
|
|
||||||
|
const QFontMetrics fm = p.fontMetrics();
|
||||||
|
const int h = olive::MarkerPainting::height(fm);
|
||||||
|
const int w = olive::QtUtils::q_font_metrics_width(fm, QStringLiteral("H"));
|
||||||
|
|
||||||
|
const QPoint pt(100, 90);
|
||||||
|
const QRect r = olive::MarkerPainting::draw(&p, pt, -1, 10.0, false,
|
||||||
|
QString(), 0,
|
||||||
|
olive::Rational(2, 1),
|
||||||
|
olive::Rational(2, 1));
|
||||||
|
p.end();
|
||||||
|
|
||||||
|
// A point marker (in == out) is centered horizontally on the anchor and
|
||||||
|
// sits one marker-height above it
|
||||||
|
EXPECT_EQ(r, QRect(pt.x() - w / 2, pt.y() - h, w, h));
|
||||||
|
|
||||||
|
// The polygon body is filled around its center
|
||||||
|
const QColor px = img.pixelColor(QPoint(pt.x(), pt.y() - h / 2));
|
||||||
|
EXPECT_GT(px.alpha(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetMarkerPainting, RangedMarkerGeometryScalesWithRange)
|
||||||
|
{
|
||||||
|
QImage img(400, 100, QImage::Format_ARGB32);
|
||||||
|
img.fill(Qt::transparent);
|
||||||
|
QPainter p(&img);
|
||||||
|
|
||||||
|
const QFontMetrics fm = p.fontMetrics();
|
||||||
|
const int h = olive::MarkerPainting::height(fm);
|
||||||
|
|
||||||
|
const QPoint pt(50, 90);
|
||||||
|
const QRect r = olive::MarkerPainting::draw(&p, pt, -1, 10.0, false,
|
||||||
|
QStringLiteral("Span"), 0,
|
||||||
|
olive::Rational(2, 1),
|
||||||
|
olive::Rational(5, 1));
|
||||||
|
p.end();
|
||||||
|
|
||||||
|
// A ranged marker starts at the anchor and extends (out - in) * scale px
|
||||||
|
EXPECT_EQ(r, QRect(pt.x(), pt.y() - h, 30, h));
|
||||||
|
|
||||||
|
// The rect body is filled
|
||||||
|
const QColor px = img.pixelColor(QPoint(pt.x() + 2, pt.y() - h / 2));
|
||||||
|
EXPECT_GT(px.alpha(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetMarkerPainting, SelectionAndLabelDoNotAffectGeometry)
|
||||||
|
{
|
||||||
|
QImage img(400, 100, QImage::Format_ARGB32);
|
||||||
|
img.fill(Qt::transparent);
|
||||||
|
QPainter p(&img);
|
||||||
|
|
||||||
|
const QPoint pt(100, 90);
|
||||||
|
|
||||||
|
const QRect plain = olive::MarkerPainting::draw(
|
||||||
|
&p, pt, -1, 10.0, false, QString(), 0, olive::Rational(2, 1),
|
||||||
|
olive::Rational(2, 1));
|
||||||
|
|
||||||
|
// Selected + a label to the right of the marker
|
||||||
|
const QRect selected = olive::MarkerPainting::draw(
|
||||||
|
&p, pt, 300, 10.0, true, QStringLiteral("Named"), 0,
|
||||||
|
olive::Rational(2, 1), olive::Rational(2, 1));
|
||||||
|
p.end();
|
||||||
|
|
||||||
|
EXPECT_EQ(plain, selected);
|
||||||
|
}
|
||||||
@@ -0,0 +1,660 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QSignalSpy>
|
||||||
|
#include <QTreeWidget>
|
||||||
|
|
||||||
|
#include <olive/core/render/audioparams.h>
|
||||||
|
#include <olive/core/render/samplebuffer.h>
|
||||||
|
|
||||||
|
#include "core.h"
|
||||||
|
#include "dialog/configbase/configdialogbasetab.h"
|
||||||
|
#include "dialog/otioproperties/otiopropertiesdialog.h"
|
||||||
|
#include "node/color/colormanager/colormanager.h"
|
||||||
|
#include "node/factory.h"
|
||||||
|
#include "node/input/multicam/multicamnode.h"
|
||||||
|
#include "node/keyframe.h"
|
||||||
|
#include "node/math/math/math.h"
|
||||||
|
#include "node/nodeundo.h"
|
||||||
|
#include "node/project.h"
|
||||||
|
#include "node/project/sequence/sequence.h"
|
||||||
|
#include "oakengine/node.h"
|
||||||
|
#include "render/diskmanager.h"
|
||||||
|
#include "widget/audiomonitor/audiomonitor.h"
|
||||||
|
#include "widget/keyframeview/keyframehandle.h"
|
||||||
|
#include "widget/keyframeview/keyframeview.h"
|
||||||
|
#include "widget/keyframeview/keyframeviewinputconnection.h"
|
||||||
|
#include "widget/multicam/multicamdisplay.h"
|
||||||
|
#include "widget/nodecombobox/nodecombobox.h"
|
||||||
|
|
||||||
|
using namespace olive;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// Keyframe views and the facade event subscriptions need the application
|
||||||
|
// singletons; create them once and leak them (same pattern as the other
|
||||||
|
// widget suites)
|
||||||
|
void ensure_app_singletons()
|
||||||
|
{
|
||||||
|
if (!olive::Core::instance()) {
|
||||||
|
new olive::Core(); // 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
oak::KeyframeTrackRef track_ref(Node *n, const QString &input, int track)
|
||||||
|
{
|
||||||
|
return oak::KeyframeTrackRef(
|
||||||
|
oak::Input(reinterpret_cast<OakEngineNode *>(n), input), track);
|
||||||
|
}
|
||||||
|
|
||||||
|
OakEngineKeyframe *handle_of(NodeKeyframe *key)
|
||||||
|
{
|
||||||
|
return reinterpret_cast<OakEngineKeyframe *>(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal concrete tab: the base class only provides a default validate()
|
||||||
|
class RecordingTab : public olive::ConfigDialogBaseTab {
|
||||||
|
public:
|
||||||
|
virtual void accept(void *parent) override
|
||||||
|
{
|
||||||
|
accepted_with = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
void *accepted_with = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
//
|
||||||
|
// keyframehandle.h: facade accessors over OakEngineKeyframe identity handles
|
||||||
|
//
|
||||||
|
class KeyframeHandleTest : public ::testing::Test {
|
||||||
|
protected:
|
||||||
|
void SetUp() override
|
||||||
|
{
|
||||||
|
ColorManager::set_up_default_config();
|
||||||
|
ensure_app_singletons();
|
||||||
|
|
||||||
|
project_ = std::make_unique<Project>();
|
||||||
|
project_->initialize();
|
||||||
|
|
||||||
|
node_ = new MathNode();
|
||||||
|
node_->setParent(project_.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<Project> project_;
|
||||||
|
MathNode *node_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
TEST_F(KeyframeHandleTest, AccessorsReadEngineState)
|
||||||
|
{
|
||||||
|
NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in,
|
||||||
|
Rational(1, 2), 2.5);
|
||||||
|
const OakEngineKeyframe *handle = handle_of(key);
|
||||||
|
|
||||||
|
EXPECT_EQ(key_node(handle), reinterpret_cast<OakEngineNode *>(node_));
|
||||||
|
EXPECT_EQ(key_time(handle), Rational(1, 2));
|
||||||
|
EXPECT_EQ(key_easing(handle), 0); // facade order: 0 = linear
|
||||||
|
EXPECT_EQ(key_input_id(handle), MathNode::k_param_a_in);
|
||||||
|
EXPECT_EQ(key_track(handle), 0);
|
||||||
|
EXPECT_EQ(key_element(handle), -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(KeyframeHandleTest, ValueAsDoubleConvertsNumericTypes)
|
||||||
|
{
|
||||||
|
NodeKeyframe *float_key = insert_keyframe(node_, MathNode::k_param_a_in,
|
||||||
|
Rational(0), 2.5);
|
||||||
|
const oak_node_value float_value = key_value(handle_of(float_key));
|
||||||
|
EXPECT_EQ(float_value.type, OAK_NODE_VALUE_FLOAT);
|
||||||
|
EXPECT_DOUBLE_EQ(key_value_as_double(handle_of(float_key)), 2.5);
|
||||||
|
|
||||||
|
// The base-class enabled input carries boolean keyframes
|
||||||
|
NodeKeyframe *bool_key = insert_keyframe(node_, Node::k_enabled_input,
|
||||||
|
Rational(0), true);
|
||||||
|
const oak_node_value bool_value = key_value(handle_of(bool_key));
|
||||||
|
EXPECT_EQ(bool_value.type, OAK_NODE_VALUE_BOOL);
|
||||||
|
EXPECT_DOUBLE_EQ(key_value_as_double(handle_of(bool_key)), 1.0);
|
||||||
|
|
||||||
|
NodeKeyframe *false_key = insert_keyframe(node_, Node::k_enabled_input,
|
||||||
|
Rational(1), false);
|
||||||
|
EXPECT_DOUBLE_EQ(key_value_as_double(handle_of(false_key)), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(KeyframeHandleTest, LiveValueSetUpdatesFacadeReads)
|
||||||
|
{
|
||||||
|
NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in,
|
||||||
|
Rational(0), 1.0);
|
||||||
|
|
||||||
|
oak_node_value v;
|
||||||
|
std::memset(&v, 0, sizeof(v));
|
||||||
|
v.type = OAK_NODE_VALUE_FLOAT;
|
||||||
|
v.f[0] = 7.25;
|
||||||
|
key_set_value_live(handle_of(key), v);
|
||||||
|
|
||||||
|
EXPECT_DOUBLE_EQ(key_value_as_double(handle_of(key)), 7.25);
|
||||||
|
EXPECT_DOUBLE_EQ(key->value().toDouble(), 7.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(KeyframeHandleTest, LiveTimeSetMovesKeyframe)
|
||||||
|
{
|
||||||
|
NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in,
|
||||||
|
Rational(0), 0.0);
|
||||||
|
|
||||||
|
key_set_time_live(handle_of(key), Rational(2));
|
||||||
|
EXPECT_EQ(key_time(handle_of(key)), Rational(2));
|
||||||
|
EXPECT_EQ(key->time(), Rational(2));
|
||||||
|
|
||||||
|
// The selection ADL wrappers route through the same accessors
|
||||||
|
EXPECT_EQ(selection_time(handle_of(key)), Rational(2));
|
||||||
|
selection_set_time(handle_of(key), Rational(4));
|
||||||
|
EXPECT_EQ(key->time(), Rational(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(KeyframeHandleTest, BezierPointRoundTrip)
|
||||||
|
{
|
||||||
|
NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in,
|
||||||
|
Rational(0), 0.0);
|
||||||
|
key->set_type(NodeKeyframe::k_bezier);
|
||||||
|
ASSERT_EQ(key_easing(handle_of(key)), 1); // facade order: 1 = bezier
|
||||||
|
|
||||||
|
key_set_bezier_point_live(handle_of(key), 0, QPointF(0.25, 0.75));
|
||||||
|
key_set_bezier_point_live(handle_of(key), 1, QPointF(-0.5, 1.5));
|
||||||
|
|
||||||
|
EXPECT_EQ(key_bezier_point(handle_of(key), 0), QPointF(0.25, 0.75));
|
||||||
|
EXPECT_EQ(key_bezier_point(handle_of(key), 1), QPointF(-0.5, 1.5));
|
||||||
|
|
||||||
|
// For a bezier keyframe the "valid" points are the actual points
|
||||||
|
EXPECT_EQ(key_valid_bezier_point(handle_of(key), 0), QPointF(0.25, 0.75));
|
||||||
|
EXPECT_EQ(key_valid_bezier_point(handle_of(key), 1), QPointF(-0.5, 1.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(KeyframeHandleTest, HasSiblingAtTimeExcludesSelf)
|
||||||
|
{
|
||||||
|
// The sibling lookup goes through Node::get_keyframe_at_time_on_track(),
|
||||||
|
// which only searches tracks that are not on the standard value, so
|
||||||
|
// keyframing must be enabled on the input first
|
||||||
|
node_->set_input_is_keyframing(MathNode::k_param_a_in, true);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Another keyframe sits at t=1
|
||||||
|
EXPECT_TRUE(key_has_sibling_at_time(handle_of(key_a), Rational(1)));
|
||||||
|
// t=0 resolves to key_a itself, which does not count as a sibling
|
||||||
|
EXPECT_FALSE(key_has_sibling_at_time(handle_of(key_a), Rational(0)));
|
||||||
|
// Nothing at t=5
|
||||||
|
EXPECT_FALSE(key_has_sibling_at_time(handle_of(key_a), Rational(5)));
|
||||||
|
|
||||||
|
EXPECT_TRUE(selection_has_sibling_at_time(handle_of(key_b), Rational(0)));
|
||||||
|
EXPECT_EQ(selection_time_target_parent(handle_of(key_a)),
|
||||||
|
reinterpret_cast<OakEngineNode *>(node_));
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// keyframeviewinputconnection.h: per-track connection between engine keyframe
|
||||||
|
// events and a KeyframeView
|
||||||
|
//
|
||||||
|
class KeyframeConnectionTest : public ::testing::Test {
|
||||||
|
protected:
|
||||||
|
void SetUp() override
|
||||||
|
{
|
||||||
|
ColorManager::set_up_default_config();
|
||||||
|
ensure_app_singletons();
|
||||||
|
|
||||||
|
project_ = std::make_unique<Project>();
|
||||||
|
project_->initialize();
|
||||||
|
|
||||||
|
node_ = new MathNode();
|
||||||
|
node_->setParent(project_.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
oak::KeyframeTrackRef param_a_ref() const
|
||||||
|
{
|
||||||
|
return track_ref(node_, MathNode::k_param_a_in, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<Project> project_;
|
||||||
|
MathNode *node_ = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
TEST_F(KeyframeConnectionTest, DefaultsAndAccessors)
|
||||||
|
{
|
||||||
|
KeyframeView view;
|
||||||
|
const oak::KeyframeTrackRef ref = param_a_ref();
|
||||||
|
KeyframeViewInputConnection connection(ref, &view);
|
||||||
|
|
||||||
|
EXPECT_EQ(connection.get_keyframe_y(), 0);
|
||||||
|
EXPECT_EQ(connection.get_brush().color(), QColor(Qt::white));
|
||||||
|
EXPECT_EQ(connection.get_reference(), ref);
|
||||||
|
EXPECT_TRUE(connection.get_keyframes().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(KeyframeConnectionTest, SetKeyframeYEmitsOnlyOnChange)
|
||||||
|
{
|
||||||
|
KeyframeView view;
|
||||||
|
KeyframeViewInputConnection connection(param_a_ref(), &view);
|
||||||
|
QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update);
|
||||||
|
|
||||||
|
connection.set_keyframe_y(10);
|
||||||
|
EXPECT_EQ(connection.get_keyframe_y(), 10);
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
|
||||||
|
// Same value is a no-op
|
||||||
|
connection.set_keyframe_y(10);
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
|
||||||
|
connection.set_keyframe_y(-3);
|
||||||
|
EXPECT_EQ(connection.get_keyframe_y(), -3);
|
||||||
|
EXPECT_EQ(spy.count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(KeyframeConnectionTest, SetBrushEmitsOnlyOnChange)
|
||||||
|
{
|
||||||
|
KeyframeView view;
|
||||||
|
KeyframeViewInputConnection connection(param_a_ref(), &view);
|
||||||
|
QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update);
|
||||||
|
|
||||||
|
// Same as the default brush: no emission
|
||||||
|
connection.set_brush(QBrush(Qt::white));
|
||||||
|
EXPECT_EQ(spy.count(), 0);
|
||||||
|
|
||||||
|
connection.set_brush(QBrush(Qt::red));
|
||||||
|
EXPECT_EQ(connection.get_brush().color(), QColor(Qt::red));
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(KeyframeConnectionTest, SetYBehaviorEmitsOnlyOnChange)
|
||||||
|
{
|
||||||
|
KeyframeView view;
|
||||||
|
KeyframeViewInputConnection connection(param_a_ref(), &view);
|
||||||
|
QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update);
|
||||||
|
|
||||||
|
// Default behavior is k_single_row
|
||||||
|
connection.set_y_behavior(KeyframeViewInputConnection::k_single_row);
|
||||||
|
EXPECT_EQ(spy.count(), 0);
|
||||||
|
|
||||||
|
connection.set_y_behavior(KeyframeViewInputConnection::k_value_is_height);
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
|
||||||
|
connection.set_y_behavior(KeyframeViewInputConnection::k_value_is_height);
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(KeyframeConnectionTest, MatchingKeyframeInsertionEmitsRequireUpdate)
|
||||||
|
{
|
||||||
|
KeyframeView view;
|
||||||
|
KeyframeViewInputConnection connection(param_a_ref(), &view);
|
||||||
|
QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update);
|
||||||
|
|
||||||
|
insert_keyframe(node_, MathNode::k_param_a_in, Rational(0), 0.0);
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
ASSERT_EQ(connection.get_keyframes().size(), 1);
|
||||||
|
|
||||||
|
// A keyframe on a different input of the same node does not match
|
||||||
|
insert_keyframe(node_, MathNode::k_param_b_in, Rational(0), 0.0);
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
EXPECT_EQ(connection.get_keyframes().size(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(KeyframeConnectionTest, LiveValueAndTimeChangesEmitRequireUpdate)
|
||||||
|
{
|
||||||
|
NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in,
|
||||||
|
Rational(0), 0.0);
|
||||||
|
|
||||||
|
KeyframeView view;
|
||||||
|
KeyframeViewInputConnection connection(param_a_ref(), &view);
|
||||||
|
QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update);
|
||||||
|
|
||||||
|
oak_node_value v;
|
||||||
|
std::memset(&v, 0, sizeof(v));
|
||||||
|
v.type = OAK_NODE_VALUE_FLOAT;
|
||||||
|
v.f[0] = 3.0;
|
||||||
|
key_set_value_live(handle_of(key), v);
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
|
||||||
|
key_set_time_live(handle_of(key), Rational(3));
|
||||||
|
EXPECT_EQ(spy.count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(KeyframeConnectionTest, TypeChangeEmitsTypeChangedAndRequireUpdate)
|
||||||
|
{
|
||||||
|
// Node::invalidate_from_keyframe_type_changed() returns early when the
|
||||||
|
// track holds a single keyframe (interpolation is a no-op), so the type
|
||||||
|
// change signal only reaches the connection with two or more keyframes
|
||||||
|
NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in,
|
||||||
|
Rational(0), 0.0);
|
||||||
|
insert_keyframe(node_, MathNode::k_param_a_in, Rational(1), 1.0);
|
||||||
|
|
||||||
|
KeyframeView view;
|
||||||
|
KeyframeViewInputConnection connection(param_a_ref(), &view);
|
||||||
|
QSignalSpy update_spy(&connection,
|
||||||
|
&KeyframeViewInputConnection::require_update);
|
||||||
|
QSignalSpy type_spy(&connection,
|
||||||
|
&KeyframeViewInputConnection::type_changed);
|
||||||
|
|
||||||
|
key->set_type(NodeKeyframe::k_hold);
|
||||||
|
EXPECT_EQ(update_spy.count(), 1);
|
||||||
|
EXPECT_EQ(type_spy.count(), 1);
|
||||||
|
|
||||||
|
// Setting the same type again emits nothing
|
||||||
|
key->set_type(NodeKeyframe::k_hold);
|
||||||
|
EXPECT_EQ(update_spy.count(), 1);
|
||||||
|
EXPECT_EQ(type_spy.count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(KeyframeConnectionTest, RemovalEmitsRequireUpdate)
|
||||||
|
{
|
||||||
|
NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in,
|
||||||
|
Rational(0), 0.0);
|
||||||
|
|
||||||
|
KeyframeView view;
|
||||||
|
KeyframeViewInputConnection connection(param_a_ref(), &view);
|
||||||
|
QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update);
|
||||||
|
|
||||||
|
NodeParamRemoveKeyframeCommand(key).redo_now();
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
EXPECT_TRUE(connection.get_keyframes().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// multicamdisplay.h: the shader/paint paths need a GL renderer, but the
|
||||||
|
// widget itself constructs offscreen and its node contract is facade-readable
|
||||||
|
//
|
||||||
|
TEST(WidgetMulticamDisplay, ConstructsWithoutNode)
|
||||||
|
{
|
||||||
|
ensure_app_singletons();
|
||||||
|
|
||||||
|
MulticamDisplay display;
|
||||||
|
EXPECT_FALSE(display.isVisible());
|
||||||
|
|
||||||
|
// Accepts (and clears) a null node without touching the paint path
|
||||||
|
display.set_multicam_node(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetMulticamDisplay, MulticamNodeStateMatchesPaintContract)
|
||||||
|
{
|
||||||
|
ensure_app_singletons();
|
||||||
|
|
||||||
|
MulticamDisplay display;
|
||||||
|
|
||||||
|
MultiCamNode node;
|
||||||
|
node.input_array_resize(MultiCamNode::k_sources_input, 3);
|
||||||
|
node.set_standard_value(MultiCamNode::k_current_input, 1);
|
||||||
|
|
||||||
|
auto *handle = reinterpret_cast<OakEngineNode *>(&node);
|
||||||
|
display.set_multicam_node(handle);
|
||||||
|
|
||||||
|
// on_paint() lays the highlight rect out from exactly these facade reads
|
||||||
|
EXPECT_EQ(oakengine_multicam_get_source_count(handle), 3);
|
||||||
|
EXPECT_EQ(oakengine_multicam_get_current_source(handle), 1);
|
||||||
|
|
||||||
|
int rows = 0, cols = 0;
|
||||||
|
oakengine_multicam_get_rows_and_columns(3, &rows, &cols);
|
||||||
|
EXPECT_EQ(rows, 2);
|
||||||
|
EXPECT_EQ(cols, 2);
|
||||||
|
|
||||||
|
int row = -1, col = -1;
|
||||||
|
oakengine_multicam_index_to_row_cols(1, rows, cols, &row, &col);
|
||||||
|
EXPECT_EQ(row, 0);
|
||||||
|
EXPECT_EQ(col, 1);
|
||||||
|
|
||||||
|
display.set_multicam_node(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// nodecombobox.h: the popup path is modal; the retranslation and factory-name
|
||||||
|
// paths are not (selection round-trips are covered in widget_combos_test.cpp)
|
||||||
|
//
|
||||||
|
TEST(WidgetNodeComboBox, LanguageChangeRetranslatesSelection)
|
||||||
|
{
|
||||||
|
NodeFactory::initialize();
|
||||||
|
|
||||||
|
{
|
||||||
|
NodeComboBox combo;
|
||||||
|
const QString id = QStringLiteral("org.olivevideoeditor.Olive.math");
|
||||||
|
combo.set_node(id);
|
||||||
|
ASSERT_EQ(combo.count(), 1);
|
||||||
|
const QString before = combo.itemText(0);
|
||||||
|
EXPECT_EQ(before, NodeFactory::get_name_from_id(id));
|
||||||
|
ASSERT_FALSE(before.isEmpty());
|
||||||
|
|
||||||
|
QEvent lang(QEvent::LanguageChange);
|
||||||
|
QApplication::sendEvent(&combo, &lang);
|
||||||
|
|
||||||
|
// The selection survives and the label is re-fetched from the factory
|
||||||
|
EXPECT_EQ(combo.get_selected_node(), id);
|
||||||
|
ASSERT_EQ(combo.count(), 1);
|
||||||
|
EXPECT_EQ(combo.itemText(0), before);
|
||||||
|
}
|
||||||
|
|
||||||
|
NodeFactory::destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetNodeComboBox, UnknownIdProducesEmptyText)
|
||||||
|
{
|
||||||
|
NodeFactory::initialize();
|
||||||
|
|
||||||
|
{
|
||||||
|
NodeComboBox combo;
|
||||||
|
combo.set_node(QStringLiteral("org.example.bogus"));
|
||||||
|
|
||||||
|
// The id is stored even though the factory has no name for it
|
||||||
|
EXPECT_EQ(combo.get_selected_node(), QStringLiteral("org.example.bogus"));
|
||||||
|
ASSERT_EQ(combo.count(), 1);
|
||||||
|
EXPECT_TRUE(combo.itemText(0).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
NodeFactory::destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// audiomonitor.h: painting needs a GL context; the playback state machine,
|
||||||
|
// parameter handling and the static instance broadcasters do not
|
||||||
|
//
|
||||||
|
TEST(WidgetAudioMonitor, ConstructionDefaults)
|
||||||
|
{
|
||||||
|
AudioMonitor monitor;
|
||||||
|
|
||||||
|
EXPECT_FALSE(monitor.is_playing());
|
||||||
|
// The ctor sizes the minimum width to the font height
|
||||||
|
EXPECT_EQ(monitor.minimumWidth(), monitor.fontMetrics().height());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetAudioMonitor, StartWaveformWithNullCacheStaysStopped)
|
||||||
|
{
|
||||||
|
AudioMonitor monitor;
|
||||||
|
|
||||||
|
// A null cache reports sample rate 0, so the start request is rejected
|
||||||
|
monitor.start_waveform(nullptr, Rational(0), 1);
|
||||||
|
EXPECT_FALSE(monitor.is_playing());
|
||||||
|
|
||||||
|
monitor.stop();
|
||||||
|
EXPECT_FALSE(monitor.is_playing());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetAudioMonitor, PushSampleBufferWithoutParamsIsIgnored)
|
||||||
|
{
|
||||||
|
AudioMonitor monitor;
|
||||||
|
|
||||||
|
// No params set -> channel_count() is 0 -> early return
|
||||||
|
monitor.push_sample_buffer(SampleBuffer());
|
||||||
|
EXPECT_FALSE(monitor.is_playing());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetAudioMonitor, PushSampleBufferWithParamsDoesNotStartPlayback)
|
||||||
|
{
|
||||||
|
const olive::core::AudioParams params(48000,
|
||||||
|
olive::core::k_channel_layout_stereo,
|
||||||
|
olive::core::SampleFormat::f32_p);
|
||||||
|
|
||||||
|
AudioMonitor monitor;
|
||||||
|
monitor.set_params(params);
|
||||||
|
|
||||||
|
SampleBuffer buffer(params, size_t(64));
|
||||||
|
ASSERT_TRUE(buffer.is_allocated());
|
||||||
|
float *left = buffer.data(0);
|
||||||
|
ASSERT_NE(left, nullptr);
|
||||||
|
for (int i = 0; i < 64; i++) {
|
||||||
|
left[i] = 0.5f;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level analysis runs, but sample playback is not waveform playback
|
||||||
|
monitor.push_sample_buffer(buffer);
|
||||||
|
EXPECT_FALSE(monitor.is_playing());
|
||||||
|
|
||||||
|
monitor.stop();
|
||||||
|
EXPECT_FALSE(monitor.is_playing());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetAudioMonitor, StaticBroadcastsReachAllInstances)
|
||||||
|
{
|
||||||
|
const olive::core::AudioParams params(48000,
|
||||||
|
olive::core::k_channel_layout_stereo,
|
||||||
|
olive::core::SampleFormat::f32_p);
|
||||||
|
|
||||||
|
AudioMonitor first;
|
||||||
|
AudioMonitor second;
|
||||||
|
first.set_params(params);
|
||||||
|
second.set_params(params);
|
||||||
|
|
||||||
|
// A null waveform is rejected on every registered instance
|
||||||
|
AudioMonitor::start_waveform_on_all(nullptr, Rational(0), 1);
|
||||||
|
EXPECT_FALSE(first.is_playing());
|
||||||
|
EXPECT_FALSE(second.is_playing());
|
||||||
|
|
||||||
|
SampleBuffer buffer(params, size_t(32));
|
||||||
|
AudioMonitor::push_sample_buffer_on_all(buffer);
|
||||||
|
EXPECT_FALSE(first.is_playing());
|
||||||
|
EXPECT_FALSE(second.is_playing());
|
||||||
|
|
||||||
|
AudioMonitor::stop_on_all();
|
||||||
|
EXPECT_FALSE(first.is_playing());
|
||||||
|
EXPECT_FALSE(second.is_playing());
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// configdialogbasetab.h
|
||||||
|
//
|
||||||
|
TEST(DialogConfigBaseTab, DefaultValidateAccepts)
|
||||||
|
{
|
||||||
|
RecordingTab tab;
|
||||||
|
EXPECT_TRUE(tab.validate());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DialogConfigBaseTab, AcceptReceivesParentPointer)
|
||||||
|
{
|
||||||
|
RecordingTab tab;
|
||||||
|
int parent_token = 0;
|
||||||
|
|
||||||
|
tab.accept(&parent_token);
|
||||||
|
EXPECT_EQ(tab.accepted_with, static_cast<void *>(&parent_token));
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// otiopropertiesdialog.h: the per-sequence Settings button opens a modal
|
||||||
|
// SequenceDialog (not exercised); construction, listing and OK/Cancel are not
|
||||||
|
// modal
|
||||||
|
//
|
||||||
|
TEST(DialogOTIOProperties, ListsSequencesWithIndexedSettingsButtons)
|
||||||
|
{
|
||||||
|
ColorManager::set_up_default_config();
|
||||||
|
|
||||||
|
Project project;
|
||||||
|
project.initialize();
|
||||||
|
|
||||||
|
auto *seq_a = new Sequence();
|
||||||
|
seq_a->setParent(&project);
|
||||||
|
seq_a->set_label(QStringLiteral("Alpha"));
|
||||||
|
auto *seq_b = new Sequence();
|
||||||
|
seq_b->setParent(&project);
|
||||||
|
seq_b->set_label(QStringLiteral("Beta"));
|
||||||
|
|
||||||
|
const QList<OakEngineSequence *> sequences = {
|
||||||
|
reinterpret_cast<OakEngineSequence *>(seq_a),
|
||||||
|
reinterpret_cast<OakEngineSequence *>(seq_b),
|
||||||
|
};
|
||||||
|
|
||||||
|
OTIOPropertiesDialog dialog(
|
||||||
|
sequences, reinterpret_cast<OakEngineProject *>(&project));
|
||||||
|
|
||||||
|
EXPECT_EQ(dialog.windowTitle(), QStringLiteral("Load OpenTimelineIO Project"));
|
||||||
|
|
||||||
|
auto *table = dialog.findChild<QTreeWidget *>();
|
||||||
|
ASSERT_NE(table, nullptr);
|
||||||
|
EXPECT_EQ(table->columnCount(), 2);
|
||||||
|
EXPECT_EQ(table->headerItem()->text(0), QStringLiteral("Sequence"));
|
||||||
|
EXPECT_EQ(table->headerItem()->text(1), QStringLiteral("Actions"));
|
||||||
|
EXPECT_FALSE(table->rootIsDecorated());
|
||||||
|
|
||||||
|
ASSERT_EQ(table->topLevelItemCount(), 2);
|
||||||
|
EXPECT_EQ(table->topLevelItem(0)->text(0), QStringLiteral("Alpha"));
|
||||||
|
EXPECT_EQ(table->topLevelItem(1)->text(0), QStringLiteral("Beta"));
|
||||||
|
|
||||||
|
// Each row carries a Settings button whose "index" property is its row
|
||||||
|
for (int i = 0; i < 2; i++) {
|
||||||
|
QWidget *actions = table->itemWidget(table->topLevelItem(i), 1);
|
||||||
|
ASSERT_NE(actions, nullptr) << "row " << i;
|
||||||
|
auto *button = actions->findChild<QPushButton *>();
|
||||||
|
ASSERT_NE(button, nullptr) << "row " << i;
|
||||||
|
EXPECT_EQ(button->text(), QStringLiteral("Settings"));
|
||||||
|
EXPECT_EQ(button->property("index").toInt(), i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DialogOTIOProperties, EmptyListAndButtonBoxResults)
|
||||||
|
{
|
||||||
|
ColorManager::set_up_default_config();
|
||||||
|
|
||||||
|
Project project;
|
||||||
|
project.initialize();
|
||||||
|
|
||||||
|
{
|
||||||
|
OTIOPropertiesDialog dialog(
|
||||||
|
{}, reinterpret_cast<OakEngineProject *>(&project));
|
||||||
|
|
||||||
|
auto *table = dialog.findChild<QTreeWidget *>();
|
||||||
|
ASSERT_NE(table, nullptr);
|
||||||
|
EXPECT_EQ(table->topLevelItemCount(), 0);
|
||||||
|
|
||||||
|
auto *buttons = dialog.findChild<QDialogButtonBox *>();
|
||||||
|
ASSERT_NE(buttons, nullptr);
|
||||||
|
QPushButton *ok = buttons->button(QDialogButtonBox::Ok);
|
||||||
|
ASSERT_NE(ok, nullptr);
|
||||||
|
ok->click();
|
||||||
|
EXPECT_EQ(dialog.result(), QDialog::Accepted);
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
OTIOPropertiesDialog dialog(
|
||||||
|
{}, reinterpret_cast<OakEngineProject *>(&project));
|
||||||
|
|
||||||
|
auto *buttons = dialog.findChild<QDialogButtonBox *>();
|
||||||
|
ASSERT_NE(buttons, nullptr);
|
||||||
|
QPushButton *cancel = buttons->button(QDialogButtonBox::Cancel);
|
||||||
|
ASSERT_NE(cancel, nullptr);
|
||||||
|
cancel->click();
|
||||||
|
EXPECT_EQ(dialog.result(), QDialog::Rejected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,846 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QPlainTextEdit>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QSignalSpy>
|
||||||
|
#include <QTest>
|
||||||
|
|
||||||
|
#include "core.h"
|
||||||
|
#include "node/color/colormanager/colormanager.h"
|
||||||
|
#include "node/generator/text/textv3.h"
|
||||||
|
#include "node/globals.h"
|
||||||
|
#include "node/math/math/math.h"
|
||||||
|
#include "node/project.h"
|
||||||
|
#include "node/project/folder/folder.h"
|
||||||
|
#include "oakengine/app.h"
|
||||||
|
#include "undo/undostack.h"
|
||||||
|
#include "widget/clickablelabel/clickablelabel.h"
|
||||||
|
#include "widget/collapsebutton/collapsebutton.h"
|
||||||
|
#include "widget/nodeparamview/nodeparambutton.h"
|
||||||
|
#include "widget/nodeparamview/nodeparamviewarraywidget.h"
|
||||||
|
#include "widget/nodeparamview/nodeparamviewconnectedlabel.h"
|
||||||
|
#include "widget/nodeparamview/nodeparamviewcontext.h"
|
||||||
|
#include "widget/nodeparamview/nodeparamviewitem.h"
|
||||||
|
#include "widget/nodeparamview/nodeparamviewitemtitlebar.h"
|
||||||
|
#include "widget/nodeparamview/nodeparamviewkeyframecontrol.h"
|
||||||
|
#include "widget/nodeparamview/nodeparamviewtextedit.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// Widgets that connect to Core::instance() at construction require the
|
||||||
|
// application singleton, but not a MainWindow
|
||||||
|
void ensure_core()
|
||||||
|
{
|
||||||
|
if (!olive::Core::instance()) {
|
||||||
|
new olive::Core(); // intentionally leaked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: wrap an engine Node* as oak::Node for the C ABI widget interface
|
||||||
|
inline oak::Node to_oak(olive::Node *n)
|
||||||
|
{
|
||||||
|
return oak::Node(reinterpret_cast<OakEngineNode *>(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: build an oak::Input value from an engine Node* + input id
|
||||||
|
inline oak::Input to_oak_input(olive::Node *n, const QString &input,
|
||||||
|
int element = -1)
|
||||||
|
{
|
||||||
|
return oak::Input(reinterpret_cast<OakEngineNode *>(n), input, element);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The process-wide undo stack previously reached via Core::undo_stack();
|
||||||
|
// facade calls below (array insert/remove, keyframing, edge disconnect) push
|
||||||
|
// commands onto it, so it must be cleared before the owning Project dies
|
||||||
|
inline olive::UndoStack *app_undo_stack()
|
||||||
|
{
|
||||||
|
return static_cast<olive::UndoStack *>(oakengine_app_undo_stack());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal node with a plain float output so it can feed MathNode's
|
||||||
|
// parameter inputs in connection tests
|
||||||
|
class FloatSourceNode : public olive::Node {
|
||||||
|
public:
|
||||||
|
FloatSourceNode() = default;
|
||||||
|
|
||||||
|
NODE_DEFAULT_FUNCTIONS(FloatSourceNode)
|
||||||
|
|
||||||
|
virtual QString name() const override
|
||||||
|
{
|
||||||
|
return QStringLiteral("Test Float Source");
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual QString id() const override
|
||||||
|
{
|
||||||
|
return QStringLiteral("org.oak.test.floatsource");
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual QVector<CategoryID> category() const override
|
||||||
|
{
|
||||||
|
return { k_category_math };
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual void value(const olive::NodeValueRow &value,
|
||||||
|
const olive::NodeGlobals &globals,
|
||||||
|
olive::NodeValueTable *table) const override
|
||||||
|
{
|
||||||
|
Q_UNUSED(value)
|
||||||
|
Q_UNUSED(globals)
|
||||||
|
|
||||||
|
table->push(olive::NodeValue::k_float, QVariant(1.5), this);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sends a mouse event straight to the widget, bypassing child hit-testing
|
||||||
|
// and cursor tracking (deterministic under the offscreen QPA)
|
||||||
|
void send_mouse_event(QWidget *w, QEvent::Type type, const QPointF &pos)
|
||||||
|
{
|
||||||
|
QMouseEvent ev(type, pos, pos, pos, Qt::LeftButton, Qt::LeftButton,
|
||||||
|
Qt::NoModifier);
|
||||||
|
QApplication::sendEvent(w, &ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NodeParamButton (global namespace, plain QPushButton with a stored name)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamButton, ClickEmitsStoredName)
|
||||||
|
{
|
||||||
|
NodeParamButton btn(QStringLiteral("volume"));
|
||||||
|
|
||||||
|
// The name is stored separately; it is not used as the button text
|
||||||
|
EXPECT_TRUE(btn.text().isEmpty());
|
||||||
|
|
||||||
|
QSignalSpy spy(&btn, &NodeParamButton::on_pressed);
|
||||||
|
btn.click();
|
||||||
|
|
||||||
|
ASSERT_EQ(spy.count(), 1);
|
||||||
|
EXPECT_EQ(spy.first().first().toString(), QStringLiteral("volume"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamButton, InstancesEmitTheirOwnNames)
|
||||||
|
{
|
||||||
|
NodeParamButton a(QStringLiteral("alpha"));
|
||||||
|
NodeParamButton b(QStringLiteral("beta"));
|
||||||
|
|
||||||
|
QStringList received;
|
||||||
|
QObject::connect(&a, &NodeParamButton::on_pressed,
|
||||||
|
[&received](const QString &n) { received.append(n); });
|
||||||
|
QObject::connect(&b, &NodeParamButton::on_pressed,
|
||||||
|
[&received](const QString &n) { received.append(n); });
|
||||||
|
|
||||||
|
b.click();
|
||||||
|
a.click();
|
||||||
|
b.click();
|
||||||
|
|
||||||
|
ASSERT_EQ(received.size(), 3);
|
||||||
|
EXPECT_EQ(received.at(0), QStringLiteral("beta"));
|
||||||
|
EXPECT_EQ(received.at(1), QStringLiteral("alpha"));
|
||||||
|
EXPECT_EQ(received.at(2), QStringLiteral("beta"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NodeParamViewItemTitleBar
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamViewItemTitleBar, DefaultsAreExpandedWithHiddenButtons)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewItemTitleBar bar;
|
||||||
|
|
||||||
|
// CollapseButton defaults to checked, so the bar starts expanded
|
||||||
|
EXPECT_TRUE(bar.is_expanded());
|
||||||
|
|
||||||
|
auto *collapse = bar.findChild<olive::CollapseButton *>();
|
||||||
|
ASSERT_NE(collapse, nullptr);
|
||||||
|
EXPECT_TRUE(collapse->isChecked());
|
||||||
|
EXPECT_TRUE(collapse->isVisibleTo(&bar));
|
||||||
|
|
||||||
|
// Pin / add-effect / enabled-checkbox all start hidden
|
||||||
|
QPushButton *pin = nullptr;
|
||||||
|
QPushButton *add_fx = nullptr;
|
||||||
|
for (QPushButton *b : bar.findChildren<QPushButton *>()) {
|
||||||
|
if (b == collapse) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (b->text() == QStringLiteral("P")) {
|
||||||
|
pin = b;
|
||||||
|
} else {
|
||||||
|
add_fx = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ASSERT_NE(pin, nullptr);
|
||||||
|
ASSERT_NE(add_fx, nullptr);
|
||||||
|
EXPECT_TRUE(pin->isCheckable());
|
||||||
|
EXPECT_FALSE(pin->isVisibleTo(&bar));
|
||||||
|
EXPECT_FALSE(add_fx->isVisibleTo(&bar));
|
||||||
|
|
||||||
|
auto *checkbox = bar.findChild<QCheckBox *>();
|
||||||
|
ASSERT_NE(checkbox, nullptr);
|
||||||
|
EXPECT_FALSE(checkbox->isVisibleTo(&bar));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamViewItemTitleBar, SetExpandedRoundTripsWithoutSignal)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewItemTitleBar bar;
|
||||||
|
QSignalSpy spy(&bar,
|
||||||
|
&olive::NodeParamViewItemTitleBar::expanded_state_changed);
|
||||||
|
|
||||||
|
bar.set_expanded(false);
|
||||||
|
EXPECT_FALSE(bar.is_expanded());
|
||||||
|
|
||||||
|
bar.set_expanded(true);
|
||||||
|
EXPECT_TRUE(bar.is_expanded());
|
||||||
|
|
||||||
|
// Programmatic expansion changes do not count as user interaction
|
||||||
|
EXPECT_EQ(spy.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamViewItemTitleBar, SetTextUpdatesLabelAndTooltip)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewItemTitleBar bar;
|
||||||
|
bar.set_text(QStringLiteral("My Node"));
|
||||||
|
|
||||||
|
auto *lbl = bar.findChild<QLabel *>();
|
||||||
|
ASSERT_NE(lbl, nullptr);
|
||||||
|
EXPECT_EQ(lbl->text(), QStringLiteral("My Node"));
|
||||||
|
EXPECT_EQ(lbl->toolTip(), QStringLiteral("My Node"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamViewItemTitleBar, VisibilitySettersToggleChildren)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewItemTitleBar bar;
|
||||||
|
|
||||||
|
QPushButton *pin = nullptr;
|
||||||
|
QPushButton *add_fx = nullptr;
|
||||||
|
for (QPushButton *b : bar.findChildren<QPushButton *>()) {
|
||||||
|
if (qobject_cast<olive::CollapseButton *>(b)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (b->text() == QStringLiteral("P")) {
|
||||||
|
pin = b;
|
||||||
|
} else {
|
||||||
|
add_fx = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto *checkbox = bar.findChild<QCheckBox *>();
|
||||||
|
ASSERT_NE(pin, nullptr);
|
||||||
|
ASSERT_NE(add_fx, nullptr);
|
||||||
|
ASSERT_NE(checkbox, nullptr);
|
||||||
|
|
||||||
|
bar.set_pin_button_visible(true);
|
||||||
|
EXPECT_TRUE(pin->isVisibleTo(&bar));
|
||||||
|
EXPECT_FALSE(add_fx->isVisibleTo(&bar));
|
||||||
|
|
||||||
|
bar.set_add_effect_button_visible(true);
|
||||||
|
EXPECT_TRUE(add_fx->isVisibleTo(&bar));
|
||||||
|
|
||||||
|
bar.set_enabled_check_box_visible(true);
|
||||||
|
EXPECT_TRUE(checkbox->isVisibleTo(&bar));
|
||||||
|
|
||||||
|
bar.set_pin_button_visible(false);
|
||||||
|
EXPECT_FALSE(pin->isVisibleTo(&bar));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamViewItemTitleBar, EnabledCheckBoxCheckedRoundTrips)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewItemTitleBar bar;
|
||||||
|
auto *checkbox = bar.findChild<QCheckBox *>();
|
||||||
|
ASSERT_NE(checkbox, nullptr);
|
||||||
|
EXPECT_FALSE(checkbox->isChecked());
|
||||||
|
|
||||||
|
bar.set_enabled_check_box_checked(true);
|
||||||
|
EXPECT_TRUE(checkbox->isChecked());
|
||||||
|
|
||||||
|
bar.set_enabled_check_box_checked(false);
|
||||||
|
EXPECT_FALSE(checkbox->isChecked());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamViewItemTitleBar, CollapseClickEmitsExpandedState)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewItemTitleBar bar;
|
||||||
|
auto *collapse = bar.findChild<olive::CollapseButton *>();
|
||||||
|
ASSERT_NE(collapse, nullptr);
|
||||||
|
|
||||||
|
QSignalSpy spy(&bar,
|
||||||
|
&olive::NodeParamViewItemTitleBar::expanded_state_changed);
|
||||||
|
|
||||||
|
// Starts expanded; clicking collapses
|
||||||
|
collapse->click();
|
||||||
|
ASSERT_EQ(spy.count(), 1);
|
||||||
|
EXPECT_FALSE(spy.first().first().toBool());
|
||||||
|
|
||||||
|
collapse->click();
|
||||||
|
ASSERT_EQ(spy.count(), 2);
|
||||||
|
EXPECT_TRUE(spy.at(1).first().toBool());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamViewItemTitleBar, ButtonsForwardTheirSignals)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewItemTitleBar bar;
|
||||||
|
|
||||||
|
QPushButton *pin = nullptr;
|
||||||
|
QPushButton *add_fx = nullptr;
|
||||||
|
for (QPushButton *b : bar.findChildren<QPushButton *>()) {
|
||||||
|
if (qobject_cast<olive::CollapseButton *>(b)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (b->text() == QStringLiteral("P")) {
|
||||||
|
pin = b;
|
||||||
|
} else {
|
||||||
|
add_fx = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto *checkbox = bar.findChild<QCheckBox *>();
|
||||||
|
ASSERT_NE(pin, nullptr);
|
||||||
|
ASSERT_NE(add_fx, nullptr);
|
||||||
|
ASSERT_NE(checkbox, nullptr);
|
||||||
|
|
||||||
|
QSignalSpy pin_spy(&bar, &olive::NodeParamViewItemTitleBar::pin_toggled);
|
||||||
|
QSignalSpy add_spy(
|
||||||
|
&bar, &olive::NodeParamViewItemTitleBar::add_effect_button_clicked);
|
||||||
|
QSignalSpy enabled_spy(
|
||||||
|
&bar, &olive::NodeParamViewItemTitleBar::enabled_check_box_clicked);
|
||||||
|
|
||||||
|
pin->click();
|
||||||
|
ASSERT_EQ(pin_spy.count(), 1);
|
||||||
|
EXPECT_TRUE(pin_spy.first().first().toBool());
|
||||||
|
|
||||||
|
add_fx->click();
|
||||||
|
EXPECT_EQ(add_spy.count(), 1);
|
||||||
|
|
||||||
|
checkbox->click();
|
||||||
|
ASSERT_EQ(enabled_spy.count(), 1);
|
||||||
|
EXPECT_TRUE(enabled_spy.first().first().toBool());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamViewItemTitleBar, MousePressEmitsClicked)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewItemTitleBar bar;
|
||||||
|
QSignalSpy spy(&bar, &olive::NodeParamViewItemTitleBar::clicked);
|
||||||
|
|
||||||
|
send_mouse_event(&bar, QEvent::MouseButtonPress, QPointF(5, 5));
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamViewItemTitleBar, DoubleClickTogglesExpansion)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewItemTitleBar bar;
|
||||||
|
ASSERT_TRUE(bar.is_expanded());
|
||||||
|
|
||||||
|
QSignalSpy spy(&bar,
|
||||||
|
&olive::NodeParamViewItemTitleBar::expanded_state_changed);
|
||||||
|
|
||||||
|
send_mouse_event(&bar, QEvent::MouseButtonDblClick, QPointF(5, 5));
|
||||||
|
EXPECT_FALSE(bar.is_expanded());
|
||||||
|
ASSERT_EQ(spy.count(), 1);
|
||||||
|
EXPECT_FALSE(spy.first().first().toBool());
|
||||||
|
|
||||||
|
send_mouse_event(&bar, QEvent::MouseButtonDblClick, QPointF(5, 5));
|
||||||
|
EXPECT_TRUE(bar.is_expanded());
|
||||||
|
EXPECT_EQ(spy.count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NodeParamViewTextEdit
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamViewTextEdit, SetTextRoundTripsWithoutSignal)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewTextEdit w;
|
||||||
|
QSignalSpy spy(&w, &olive::NodeParamViewTextEdit::text_edited);
|
||||||
|
|
||||||
|
w.setText(QStringLiteral("hello"));
|
||||||
|
EXPECT_EQ(w.text(), QStringLiteral("hello"));
|
||||||
|
|
||||||
|
// Programmatic changes don't count as user edits
|
||||||
|
EXPECT_EQ(spy.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamViewTextEdit, TypingEmitsTextEdited)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewTextEdit w;
|
||||||
|
auto *edit = w.findChild<QPlainTextEdit *>();
|
||||||
|
ASSERT_NE(edit, nullptr);
|
||||||
|
|
||||||
|
QSignalSpy spy(&w, &olive::NodeParamViewTextEdit::text_edited);
|
||||||
|
QTest::keyClicks(edit, QStringLiteral("hi"));
|
||||||
|
|
||||||
|
ASSERT_EQ(spy.count(), 2);
|
||||||
|
EXPECT_EQ(spy.first().first().toString(), QStringLiteral("h"));
|
||||||
|
EXPECT_EQ(spy.at(1).first().toString(), QStringLiteral("hi"));
|
||||||
|
EXPECT_EQ(w.text(), QStringLiteral("hi"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamViewTextEdit, SetTextPreservingCursorKeepsPosition)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewTextEdit w;
|
||||||
|
auto *edit = w.findChild<QPlainTextEdit *>();
|
||||||
|
ASSERT_NE(edit, nullptr);
|
||||||
|
|
||||||
|
w.setText(QStringLiteral("hello"));
|
||||||
|
QTextCursor c = edit->textCursor();
|
||||||
|
c.setPosition(5);
|
||||||
|
edit->setTextCursor(c);
|
||||||
|
|
||||||
|
w.setTextPreservingCursor(QStringLiteral("hello world"));
|
||||||
|
|
||||||
|
EXPECT_EQ(w.text(), QStringLiteral("hello world"));
|
||||||
|
EXPECT_EQ(edit->textCursor().position(), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamViewTextEdit, ViewerOnlyModeSwapsVisibleChildren)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewTextEdit w;
|
||||||
|
auto *edit = w.findChild<QPlainTextEdit *>();
|
||||||
|
ASSERT_NE(edit, nullptr);
|
||||||
|
|
||||||
|
QPushButton *viewer_btn = nullptr;
|
||||||
|
QPushButton *edit_btn = nullptr;
|
||||||
|
for (QPushButton *b : w.findChildren<QPushButton *>()) {
|
||||||
|
if (b->text() == QStringLiteral("Edit In Viewer")) {
|
||||||
|
viewer_btn = b;
|
||||||
|
} else {
|
||||||
|
edit_btn = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ASSERT_NE(viewer_btn, nullptr);
|
||||||
|
ASSERT_NE(edit_btn, nullptr);
|
||||||
|
|
||||||
|
// Default mode: plain-text edit + dialog button, no viewer button
|
||||||
|
EXPECT_TRUE(edit->isVisibleTo(&w));
|
||||||
|
EXPECT_TRUE(edit_btn->isVisibleTo(&w));
|
||||||
|
EXPECT_FALSE(viewer_btn->isVisibleTo(&w));
|
||||||
|
|
||||||
|
w.set_edit_in_viewer_only_mode(true);
|
||||||
|
EXPECT_FALSE(edit->isVisibleTo(&w));
|
||||||
|
EXPECT_FALSE(edit_btn->isVisibleTo(&w));
|
||||||
|
EXPECT_TRUE(viewer_btn->isVisibleTo(&w));
|
||||||
|
|
||||||
|
w.set_edit_in_viewer_only_mode(false);
|
||||||
|
EXPECT_TRUE(edit->isVisibleTo(&w));
|
||||||
|
EXPECT_TRUE(edit_btn->isVisibleTo(&w));
|
||||||
|
EXPECT_FALSE(viewer_btn->isVisibleTo(&w));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamViewTextEdit, EditInViewerButtonEmitsRequest)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewTextEdit w;
|
||||||
|
|
||||||
|
QPushButton *viewer_btn = nullptr;
|
||||||
|
for (QPushButton *b : w.findChildren<QPushButton *>()) {
|
||||||
|
if (b->text() == QStringLiteral("Edit In Viewer")) {
|
||||||
|
viewer_btn = b;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ASSERT_NE(viewer_btn, nullptr);
|
||||||
|
|
||||||
|
QSignalSpy spy(&w, &olive::NodeParamViewTextEdit::request_edit_in_viewer);
|
||||||
|
viewer_btn->click();
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: the other push button opens a modal TextDialog (exec()); not
|
||||||
|
// testable under the offscreen QPA.
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared fixture for the engine-backed widgets
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class NodeParamViewTest : public ::testing::Test {
|
||||||
|
protected:
|
||||||
|
void SetUp() override
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
project_ = std::make_unique<olive::Project>();
|
||||||
|
project_->initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TearDown() override
|
||||||
|
{
|
||||||
|
// Undo commands pushed by facade calls reference project nodes
|
||||||
|
app_undo_stack()->clear();
|
||||||
|
project_.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename T> T *add_node()
|
||||||
|
{
|
||||||
|
auto *node = new T();
|
||||||
|
node->setParent(project_.get());
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Project> project_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NodeParamViewConnectedLabel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_F(NodeParamViewTest, ConnectedLabelDisconnectedInputShowsNothing)
|
||||||
|
{
|
||||||
|
auto *math = add_node<olive::MathNode>();
|
||||||
|
|
||||||
|
olive::NodeParamViewConnectedLabel w(
|
||||||
|
to_oak_input(math, olive::MathNode::k_param_a_in));
|
||||||
|
|
||||||
|
auto *lbl = w.findChild<olive::ClickableLabel *>();
|
||||||
|
ASSERT_NE(lbl, nullptr);
|
||||||
|
EXPECT_EQ(lbl->text(), QStringLiteral("Nothing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeParamViewTest, ConnectedLabelShowsSourceNameWhenConnected)
|
||||||
|
{
|
||||||
|
auto *source = add_node<FloatSourceNode>();
|
||||||
|
auto *math = add_node<olive::MathNode>();
|
||||||
|
|
||||||
|
olive::Node::connect_edge(
|
||||||
|
source, olive::NodeInput(math, olive::MathNode::k_param_a_in));
|
||||||
|
|
||||||
|
olive::NodeParamViewConnectedLabel w(
|
||||||
|
to_oak_input(math, olive::MathNode::k_param_a_in));
|
||||||
|
|
||||||
|
auto *lbl = w.findChild<olive::ClickableLabel *>();
|
||||||
|
ASSERT_NE(lbl, nullptr);
|
||||||
|
EXPECT_EQ(lbl->text(), QStringLiteral("Test Float Source"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeParamViewTest, ConnectedLabelTracksLiveConnectAndDisconnect)
|
||||||
|
{
|
||||||
|
auto *source = add_node<FloatSourceNode>();
|
||||||
|
auto *math = add_node<olive::MathNode>();
|
||||||
|
|
||||||
|
olive::NodeParamViewConnectedLabel w(
|
||||||
|
to_oak_input(math, olive::MathNode::k_param_a_in));
|
||||||
|
auto *lbl = w.findChild<olive::ClickableLabel *>();
|
||||||
|
ASSERT_NE(lbl, nullptr);
|
||||||
|
EXPECT_EQ(lbl->text(), QStringLiteral("Nothing"));
|
||||||
|
|
||||||
|
// The widget subscribes to the engine's edge events via its bridge
|
||||||
|
OakEngineNode *src_handle = reinterpret_cast<OakEngineNode *>(source);
|
||||||
|
OakEngineNode *math_handle = reinterpret_cast<OakEngineNode *>(math);
|
||||||
|
const QByteArray input_id = olive::MathNode::k_param_a_in.toUtf8();
|
||||||
|
|
||||||
|
ASSERT_EQ(oakengine_node_connect(src_handle, math_handle,
|
||||||
|
input_id.constData()),
|
||||||
|
OAKENGINE_OK);
|
||||||
|
EXPECT_EQ(lbl->text(), QStringLiteral("Test Float Source"));
|
||||||
|
|
||||||
|
ASSERT_EQ(oakengine_node_disconnect(math_handle, input_id.constData()),
|
||||||
|
OAKENGINE_OK);
|
||||||
|
EXPECT_EQ(lbl->text(), QStringLiteral("Nothing"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeParamViewTest, ConnectedLabelClickRequestsSourceSelection)
|
||||||
|
{
|
||||||
|
auto *source = add_node<FloatSourceNode>();
|
||||||
|
auto *math = add_node<olive::MathNode>();
|
||||||
|
|
||||||
|
olive::Node::connect_edge(
|
||||||
|
source, olive::NodeInput(math, olive::MathNode::k_param_a_in));
|
||||||
|
|
||||||
|
olive::NodeParamViewConnectedLabel w(
|
||||||
|
to_oak_input(math, olive::MathNode::k_param_a_in));
|
||||||
|
w.resize(400, 40);
|
||||||
|
w.show();
|
||||||
|
EXPECT_TRUE(QTest::qWaitForWindowExposed(&w));
|
||||||
|
|
||||||
|
auto *lbl = w.findChild<olive::ClickableLabel *>();
|
||||||
|
ASSERT_NE(lbl, nullptr);
|
||||||
|
|
||||||
|
// ClickableLabel only emits when the cursor is over it; if the
|
||||||
|
// offscreen platform can't track the cursor there's nothing to assert
|
||||||
|
QTest::mouseMove(lbl, QPoint(5, 5));
|
||||||
|
if (!lbl->underMouse()) {
|
||||||
|
GTEST_SKIP() << "Platform does not track cursor position";
|
||||||
|
}
|
||||||
|
|
||||||
|
OakEngineNode *received = nullptr;
|
||||||
|
QObject::connect(&w, &olive::NodeParamViewConnectedLabel::request_select_node,
|
||||||
|
[&received](OakEngineNode *n) { received = n; });
|
||||||
|
|
||||||
|
QTest::mouseClick(lbl, Qt::LeftButton);
|
||||||
|
EXPECT_EQ(received, reinterpret_cast<OakEngineNode *>(source));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NodeParamViewKeyframeControl
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_F(NodeParamViewTest, KeyframeControlInvalidInputDisablesEverything)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewKeyframeControl control;
|
||||||
|
EXPECT_FALSE(control.get_connected_input().is_valid());
|
||||||
|
|
||||||
|
// Both constructor forms build the same four buttons
|
||||||
|
olive::NodeParamViewKeyframeControl left_aligned(false, nullptr);
|
||||||
|
for (QWidget *c : { static_cast<QWidget *>(&control),
|
||||||
|
static_cast<QWidget *>(&left_aligned) }) {
|
||||||
|
const auto buttons = c->findChildren<QPushButton *>();
|
||||||
|
ASSERT_EQ(buttons.size(), 4);
|
||||||
|
|
||||||
|
int checkable_count = 0;
|
||||||
|
for (QPushButton *b : buttons) {
|
||||||
|
EXPECT_FALSE(b->isEnabled());
|
||||||
|
if (b->isCheckable()) {
|
||||||
|
checkable_count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// toggle + enable are checkable, prev/next are not
|
||||||
|
EXPECT_EQ(checkable_count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// With keyframing off only the enable button is shown
|
||||||
|
int visible_count = 0;
|
||||||
|
for (QPushButton *b : control.findChildren<QPushButton *>()) {
|
||||||
|
if (b->isVisibleTo(&control)) {
|
||||||
|
visible_count++;
|
||||||
|
EXPECT_TRUE(b->isCheckable());
|
||||||
|
EXPECT_FALSE(b->isChecked());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EXPECT_EQ(visible_count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeParamViewTest, KeyframeControlSetInputEnablesButtons)
|
||||||
|
{
|
||||||
|
auto *math = add_node<olive::MathNode>();
|
||||||
|
|
||||||
|
olive::NodeParamViewKeyframeControl control;
|
||||||
|
control.set_input(to_oak_input(math, olive::MathNode::k_param_a_in));
|
||||||
|
|
||||||
|
EXPECT_TRUE(control.get_connected_input().is_valid());
|
||||||
|
EXPECT_EQ(control.get_connected_input().input_id(),
|
||||||
|
olive::MathNode::k_param_a_in);
|
||||||
|
|
||||||
|
const auto buttons = control.findChildren<QPushButton *>();
|
||||||
|
ASSERT_EQ(buttons.size(), 4);
|
||||||
|
for (QPushButton *b : buttons) {
|
||||||
|
EXPECT_TRUE(b->isEnabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input is not keyframing yet: nav buttons stay hidden, enable unchecked
|
||||||
|
for (QPushButton *b : buttons) {
|
||||||
|
if (b->isCheckable() && b->isVisibleTo(&control)) {
|
||||||
|
EXPECT_FALSE(b->isChecked()); // the enable button
|
||||||
|
} else {
|
||||||
|
EXPECT_FALSE(b->isVisibleTo(&control)); // prev/toggle/next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeParamViewTest, KeyframeControlReflectsKeyframingChanges)
|
||||||
|
{
|
||||||
|
auto *math = add_node<olive::MathNode>();
|
||||||
|
|
||||||
|
olive::NodeParamViewKeyframeControl control;
|
||||||
|
control.set_input(to_oak_input(math, olive::MathNode::k_param_a_in));
|
||||||
|
|
||||||
|
// Identify the enable button while it is the only visible checkable one
|
||||||
|
QPushButton *enable_btn = nullptr;
|
||||||
|
for (QPushButton *b : control.findChildren<QPushButton *>()) {
|
||||||
|
if (b->isCheckable() && b->isVisibleTo(&control)) {
|
||||||
|
enable_btn = b;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ASSERT_NE(enable_btn, nullptr);
|
||||||
|
EXPECT_FALSE(enable_btn->isChecked());
|
||||||
|
|
||||||
|
OakEngineNode *handle = reinterpret_cast<OakEngineNode *>(math);
|
||||||
|
const QByteArray input_id = olive::MathNode::k_param_a_in.toUtf8();
|
||||||
|
|
||||||
|
// Enabling keyframing through the facade fires the bridge event, which
|
||||||
|
// checks the enable button and reveals the navigation buttons
|
||||||
|
ASSERT_EQ(oakengine_node_set_input_keyframing(handle, input_id.constData(),
|
||||||
|
-1, 1, 0, 1, nullptr),
|
||||||
|
OAKENGINE_OK);
|
||||||
|
EXPECT_TRUE(enable_btn->isChecked());
|
||||||
|
for (QPushButton *b : control.findChildren<QPushButton *>()) {
|
||||||
|
EXPECT_TRUE(b->isVisibleTo(&control));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabling hides the navigation buttons again
|
||||||
|
ASSERT_EQ(oakengine_node_set_input_keyframing(handle, input_id.constData(),
|
||||||
|
-1, 0, 0, 1, nullptr),
|
||||||
|
OAKENGINE_OK);
|
||||||
|
EXPECT_FALSE(enable_btn->isChecked());
|
||||||
|
for (QPushButton *b : control.findChildren<QPushButton *>()) {
|
||||||
|
EXPECT_EQ(b->isVisibleTo(&control), b == enable_btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NodeParamViewArrayWidget / NodeParamViewArrayButton
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST(WidgetNodeParamViewArrayButton, TypeDeterminesText)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewArrayButton add(olive::NodeParamViewArrayButton::k_add);
|
||||||
|
EXPECT_EQ(add.text(), QStringLiteral("+"));
|
||||||
|
|
||||||
|
olive::NodeParamViewArrayButton remove(
|
||||||
|
olive::NodeParamViewArrayButton::k_remove);
|
||||||
|
EXPECT_EQ(remove.text(), QStringLiteral("-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeParamViewTest, ArrayWidgetCounterTracksArraySize)
|
||||||
|
{
|
||||||
|
auto *text = add_node<olive::TextGeneratorV3>();
|
||||||
|
|
||||||
|
olive::NodeParamViewArrayWidget w(to_oak(text),
|
||||||
|
olive::TextGeneratorV3::k_args_input);
|
||||||
|
|
||||||
|
auto *lbl = w.findChild<QLabel *>();
|
||||||
|
ASSERT_NE(lbl, nullptr);
|
||||||
|
EXPECT_EQ(lbl->text(), QStringLiteral("0 element(s)"));
|
||||||
|
|
||||||
|
OakEngineNode *handle = reinterpret_cast<OakEngineNode *>(text);
|
||||||
|
const QByteArray input_id = olive::TextGeneratorV3::k_args_input.toUtf8();
|
||||||
|
|
||||||
|
// The widget subscribes to the engine's array-size event via its bridge
|
||||||
|
ASSERT_EQ(oakengine_node_array_insert_at(handle, input_id.constData(), 0),
|
||||||
|
OAKENGINE_OK);
|
||||||
|
EXPECT_EQ(lbl->text(), QStringLiteral("1 element(s)"));
|
||||||
|
|
||||||
|
ASSERT_EQ(oakengine_node_array_insert_at(handle, input_id.constData(), 1),
|
||||||
|
OAKENGINE_OK);
|
||||||
|
EXPECT_EQ(lbl->text(), QStringLiteral("2 element(s)"));
|
||||||
|
|
||||||
|
ASSERT_EQ(oakengine_node_array_remove_at(handle, input_id.constData(), 0),
|
||||||
|
OAKENGINE_OK);
|
||||||
|
EXPECT_EQ(lbl->text(), QStringLiteral("1 element(s)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeParamViewTest, ArrayWidgetDoubleClickEmitsSignal)
|
||||||
|
{
|
||||||
|
auto *text = add_node<olive::TextGeneratorV3>();
|
||||||
|
|
||||||
|
olive::NodeParamViewArrayWidget w(to_oak(text),
|
||||||
|
olive::TextGeneratorV3::k_args_input);
|
||||||
|
|
||||||
|
QSignalSpy spy(&w, &olive::NodeParamViewArrayWidget::double_clicked);
|
||||||
|
send_mouse_event(&w, QEvent::MouseButtonDblClick, QPointF(2, 2));
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// NodeParamViewContext
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
TEST_F(NodeParamViewTest, ContextDefaultConstruction)
|
||||||
|
{
|
||||||
|
olive::NodeParamViewContext ctx;
|
||||||
|
|
||||||
|
EXPECT_NE(ctx.get_dock_area(), nullptr);
|
||||||
|
EXPECT_TRUE(ctx.get_contexts().isEmpty());
|
||||||
|
EXPECT_TRUE(ctx.get_items().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeParamViewTest, ContextAddAndRemoveContexts)
|
||||||
|
{
|
||||||
|
auto *math = add_node<olive::MathNode>();
|
||||||
|
auto *folder = add_node<olive::Folder>();
|
||||||
|
|
||||||
|
olive::NodeParamViewContext ctx;
|
||||||
|
ctx.add_context(to_oak(math));
|
||||||
|
ctx.add_context(to_oak(folder));
|
||||||
|
|
||||||
|
ASSERT_EQ(ctx.get_contexts().size(), 2);
|
||||||
|
EXPECT_EQ(ctx.get_contexts().at(0), to_oak(math));
|
||||||
|
EXPECT_EQ(ctx.get_contexts().at(1), to_oak(folder));
|
||||||
|
|
||||||
|
ctx.remove_context(to_oak(math));
|
||||||
|
ASSERT_EQ(ctx.get_contexts().size(), 1);
|
||||||
|
EXPECT_EQ(ctx.get_contexts().first(), to_oak(folder));
|
||||||
|
|
||||||
|
// Removing something that isn't there is a no-op
|
||||||
|
ctx.remove_context(to_oak(math));
|
||||||
|
EXPECT_EQ(ctx.get_contexts().size(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeParamViewTest, ContextAddGetAndRemoveNodeItems)
|
||||||
|
{
|
||||||
|
auto *math = add_node<olive::MathNode>();
|
||||||
|
auto *folder = add_node<olive::Folder>();
|
||||||
|
|
||||||
|
olive::NodeParamViewContext ctx;
|
||||||
|
|
||||||
|
auto *item = new olive::NodeParamViewItem(to_oak(math),
|
||||||
|
olive::k_no_check_boxes, &ctx);
|
||||||
|
item->set_context(to_oak(folder));
|
||||||
|
|
||||||
|
ctx.add_node(item);
|
||||||
|
ASSERT_EQ(ctx.get_items().size(), 1);
|
||||||
|
EXPECT_EQ(ctx.get_items().first(), item);
|
||||||
|
|
||||||
|
// Lookup matches on (node, context) pair only
|
||||||
|
EXPECT_EQ(ctx.get_item(to_oak(math), to_oak(folder)), item);
|
||||||
|
EXPECT_EQ(ctx.get_item(to_oak(folder), to_oak(math)), nullptr);
|
||||||
|
EXPECT_EQ(ctx.get_item(to_oak(math), to_oak(math)), nullptr);
|
||||||
|
|
||||||
|
olive::NodeParamViewItem *about_to_delete = nullptr;
|
||||||
|
QObject::connect(
|
||||||
|
&ctx, &olive::NodeParamViewContext::about_to_delete_item,
|
||||||
|
[&about_to_delete](olive::NodeParamViewItem *i) {
|
||||||
|
about_to_delete = i;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Removing a non-matching pair leaves the item alone
|
||||||
|
ctx.remove_node(to_oak(math), to_oak(math));
|
||||||
|
EXPECT_EQ(ctx.get_items().size(), 1);
|
||||||
|
EXPECT_EQ(about_to_delete, nullptr);
|
||||||
|
|
||||||
|
ctx.remove_node(to_oak(math), to_oak(folder));
|
||||||
|
EXPECT_EQ(about_to_delete, item);
|
||||||
|
EXPECT_TRUE(ctx.get_items().isEmpty());
|
||||||
|
EXPECT_EQ(ctx.get_item(to_oak(math), to_oak(folder)), nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeParamViewTest, ContextRemoveNodesWithContextRemovesOnlyMatches)
|
||||||
|
{
|
||||||
|
auto *math_a = add_node<olive::MathNode>();
|
||||||
|
auto *math_b = add_node<olive::MathNode>();
|
||||||
|
auto *folder_a = add_node<olive::Folder>();
|
||||||
|
auto *folder_b = add_node<olive::Folder>();
|
||||||
|
|
||||||
|
olive::NodeParamViewContext ctx;
|
||||||
|
|
||||||
|
auto *item_a = new olive::NodeParamViewItem(to_oak(math_a),
|
||||||
|
olive::k_no_check_boxes, &ctx);
|
||||||
|
item_a->set_context(to_oak(folder_a));
|
||||||
|
auto *item_b = new olive::NodeParamViewItem(to_oak(math_b),
|
||||||
|
olive::k_no_check_boxes, &ctx);
|
||||||
|
item_b->set_context(to_oak(folder_b));
|
||||||
|
|
||||||
|
ctx.add_node(item_a);
|
||||||
|
ctx.add_node(item_b);
|
||||||
|
ASSERT_EQ(ctx.get_items().size(), 2);
|
||||||
|
|
||||||
|
QVector<olive::NodeParamViewItem *> deleted;
|
||||||
|
QObject::connect(&ctx, &olive::NodeParamViewContext::about_to_delete_item,
|
||||||
|
[&deleted](olive::NodeParamViewItem *i) {
|
||||||
|
deleted.append(i);
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.remove_nodes_with_context(to_oak(folder_a));
|
||||||
|
|
||||||
|
ASSERT_EQ(deleted.size(), 1);
|
||||||
|
EXPECT_EQ(deleted.first(), item_a);
|
||||||
|
|
||||||
|
ASSERT_EQ(ctx.get_items().size(), 1);
|
||||||
|
EXPECT_EQ(ctx.get_items().first(), item_b);
|
||||||
|
EXPECT_EQ(ctx.get_item(to_oak(math_a), to_oak(folder_a)), nullptr);
|
||||||
|
EXPECT_EQ(ctx.get_item(to_oak(math_b), to_oak(folder_b)), item_b);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,539 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include <QImage>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QSignalSpy>
|
||||||
|
#include <QSlider>
|
||||||
|
#include <QStandardItemModel>
|
||||||
|
#include <QStringListModel>
|
||||||
|
#include <QStyleOptionViewItem>
|
||||||
|
#include <QTest>
|
||||||
|
|
||||||
|
#include "core.h"
|
||||||
|
#include "node/color/colormanager/colormanager.h"
|
||||||
|
#include "node/project.h"
|
||||||
|
#include "node/project/footage/footage.h"
|
||||||
|
#include "node/project/folder/folder.h"
|
||||||
|
#include "oakutil/define.h"
|
||||||
|
#include "render/diskmanager.h"
|
||||||
|
#include "widget/projectexplorer/projectexplorericonview.h"
|
||||||
|
#include "widget/projectexplorer/projectexplorericonviewitemdelegate.h"
|
||||||
|
#include "widget/projectexplorer/projectexplorerlistview.h"
|
||||||
|
#include "widget/projectexplorer/projectexplorerlistviewbase.h"
|
||||||
|
#include "widget/projectexplorer/projectexplorerlistviewitemdelegate.h"
|
||||||
|
#include "widget/projectexplorer/projectexplorernavigation.h"
|
||||||
|
#include "widget/projectexplorer/projectexplorertreeview.h"
|
||||||
|
|
||||||
|
using namespace olive;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// Undo commands that rename/move items go through app-wide singletons
|
||||||
|
void ensure_app_singletons()
|
||||||
|
{
|
||||||
|
if (!olive::Core::instance()) {
|
||||||
|
new olive::Core(); // intentionally leaked
|
||||||
|
}
|
||||||
|
if (!olive::DiskManager::instance()) {
|
||||||
|
olive::DiskManager::create_instance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(ProjectExplorerNavigation, DefaultsMatchDocumentedState)
|
||||||
|
{
|
||||||
|
ProjectExplorerNavigation nav(nullptr);
|
||||||
|
|
||||||
|
auto *dir_up = nav.findChild<QPushButton *>();
|
||||||
|
auto *label = nav.findChild<QLabel *>();
|
||||||
|
auto *slider = nav.findChild<QSlider *>();
|
||||||
|
ASSERT_NE(dir_up, nullptr);
|
||||||
|
ASSERT_NE(label, nullptr);
|
||||||
|
ASSERT_NE(slider, nullptr);
|
||||||
|
|
||||||
|
// Root folder assumption: no parent to go up to, no folder name
|
||||||
|
EXPECT_FALSE(dir_up->isEnabled());
|
||||||
|
EXPECT_TRUE(label->text().isEmpty());
|
||||||
|
|
||||||
|
// Slider spans the project icon size constants, defaulting to the default
|
||||||
|
EXPECT_EQ(slider->minimum(), k_project_icon_size_minimum);
|
||||||
|
EXPECT_EQ(slider->maximum(), k_project_icon_size_maximum);
|
||||||
|
EXPECT_EQ(slider->value(), k_project_icon_size_default);
|
||||||
|
EXPECT_EQ(slider->orientation(), Qt::Horizontal);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ProjectExplorerNavigation, SetTextUpdatesLabel)
|
||||||
|
{
|
||||||
|
ProjectExplorerNavigation nav(nullptr);
|
||||||
|
auto *label = nav.findChild<QLabel *>();
|
||||||
|
ASSERT_NE(label, nullptr);
|
||||||
|
|
||||||
|
nav.set_text(QStringLiteral("Media"));
|
||||||
|
EXPECT_EQ(label->text(), QStringLiteral("Media"));
|
||||||
|
|
||||||
|
nav.set_text(QString());
|
||||||
|
EXPECT_TRUE(label->text().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ProjectExplorerNavigation, DirUpButtonOnlyEmitsWhenEnabled)
|
||||||
|
{
|
||||||
|
ProjectExplorerNavigation nav(nullptr);
|
||||||
|
auto *dir_up = nav.findChild<QPushButton *>();
|
||||||
|
ASSERT_NE(dir_up, nullptr);
|
||||||
|
|
||||||
|
QSignalSpy spy(&nav, &ProjectExplorerNavigation::directory_up_clicked);
|
||||||
|
|
||||||
|
// Disabled button swallows clicks
|
||||||
|
dir_up->click();
|
||||||
|
EXPECT_EQ(spy.count(), 0);
|
||||||
|
|
||||||
|
nav.set_dir_up_enabled(true);
|
||||||
|
EXPECT_TRUE(dir_up->isEnabled());
|
||||||
|
|
||||||
|
dir_up->click();
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
|
||||||
|
// Disabling again silences it
|
||||||
|
nav.set_dir_up_enabled(false);
|
||||||
|
dir_up->click();
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ProjectExplorerNavigation, SizeSliderEmitsSizeChanged)
|
||||||
|
{
|
||||||
|
ProjectExplorerNavigation nav(nullptr);
|
||||||
|
auto *slider = nav.findChild<QSlider *>();
|
||||||
|
ASSERT_NE(slider, nullptr);
|
||||||
|
|
||||||
|
QSignalSpy spy(&nav, &ProjectExplorerNavigation::size_changed);
|
||||||
|
|
||||||
|
slider->setValue(k_project_icon_size_minimum);
|
||||||
|
ASSERT_EQ(spy.count(), 1);
|
||||||
|
EXPECT_EQ(spy.first().first().toInt(), k_project_icon_size_minimum);
|
||||||
|
|
||||||
|
// The setter is implemented as a plain slider setValue, so despite the
|
||||||
|
// header comment claiming otherwise it does forward through the signal
|
||||||
|
nav.set_size_value(k_project_icon_size_maximum);
|
||||||
|
EXPECT_EQ(slider->value(), k_project_icon_size_maximum);
|
||||||
|
ASSERT_EQ(spy.count(), 2);
|
||||||
|
EXPECT_EQ(spy.at(1).first().toInt(), k_project_icon_size_maximum);
|
||||||
|
|
||||||
|
// Setting the same value again is a no-op, as QSlider only emits on change
|
||||||
|
nav.set_size_value(k_project_icon_size_maximum);
|
||||||
|
EXPECT_EQ(spy.count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ProjectExplorerListViewBase, ConstructionDefaults)
|
||||||
|
{
|
||||||
|
ProjectExplorerListViewBase view(nullptr);
|
||||||
|
|
||||||
|
EXPECT_EQ(view.movement(), QListView::Free);
|
||||||
|
EXPECT_EQ(view.selectionMode(), QAbstractItemView::ExtendedSelection);
|
||||||
|
EXPECT_EQ(view.resizeMode(), QListView::Adjust);
|
||||||
|
EXPECT_EQ(view.contextMenuPolicy(), Qt::CustomContextMenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ProjectExplorerListViewBase, DoubleClickEmptyAreaEmitsSignal)
|
||||||
|
{
|
||||||
|
QStringListModel model({ QStringLiteral("One"), QStringLiteral("Two"),
|
||||||
|
QStringLiteral("Three") });
|
||||||
|
|
||||||
|
ProjectExplorerListViewBase view(nullptr);
|
||||||
|
view.setModel(&model);
|
||||||
|
view.resize(400, 300);
|
||||||
|
view.show();
|
||||||
|
ASSERT_TRUE(QTest::qWaitForWindowExposed(&view));
|
||||||
|
|
||||||
|
const QModelIndex first = model.index(0, 0);
|
||||||
|
const QRect item_rect = view.visualRect(first);
|
||||||
|
if (!item_rect.isValid()) {
|
||||||
|
GTEST_SKIP() << "View did not lay out items under the offscreen platform";
|
||||||
|
}
|
||||||
|
|
||||||
|
int hits = 0;
|
||||||
|
QObject::connect(&view,
|
||||||
|
&ProjectExplorerListViewBase::double_clicked_empty_area,
|
||||||
|
[&hits] { ++hits; });
|
||||||
|
|
||||||
|
// Double clicking an item is not an empty-area click
|
||||||
|
QTest::mouseDClick(view.viewport(), Qt::LeftButton, Qt::NoModifier,
|
||||||
|
item_rect.center());
|
||||||
|
EXPECT_EQ(hits, 0);
|
||||||
|
|
||||||
|
// Double clicking below the last item is
|
||||||
|
const QPoint empty(view.viewport()->width() - 4,
|
||||||
|
view.viewport()->height() - 4);
|
||||||
|
if (view.indexAt(empty).isValid()) {
|
||||||
|
GTEST_SKIP() << "Could not find an item-free spot in the viewport";
|
||||||
|
}
|
||||||
|
QTest::mouseDClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, empty);
|
||||||
|
EXPECT_EQ(hits, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ProjectExplorerListViewBase, CtrlClickExtendsSelection)
|
||||||
|
{
|
||||||
|
QStringListModel model({ QStringLiteral("One"), QStringLiteral("Two"),
|
||||||
|
QStringLiteral("Three") });
|
||||||
|
|
||||||
|
ProjectExplorerListViewBase view(nullptr);
|
||||||
|
view.setModel(&model);
|
||||||
|
view.resize(400, 300);
|
||||||
|
view.show();
|
||||||
|
ASSERT_TRUE(QTest::qWaitForWindowExposed(&view));
|
||||||
|
|
||||||
|
const QModelIndex first = model.index(0, 0);
|
||||||
|
const QModelIndex third = model.index(2, 0);
|
||||||
|
const QRect first_rect = view.visualRect(first);
|
||||||
|
const QRect third_rect = view.visualRect(third);
|
||||||
|
if (!first_rect.isValid() || !third_rect.isValid()) {
|
||||||
|
GTEST_SKIP() << "View did not lay out items under the offscreen platform";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain click selects exactly one item and makes it current
|
||||||
|
QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier,
|
||||||
|
first_rect.center());
|
||||||
|
EXPECT_EQ(view.selectionModel()->currentIndex(), first);
|
||||||
|
EXPECT_EQ(view.selectionModel()->selectedIndexes().size(), 1);
|
||||||
|
|
||||||
|
// ExtendedSelection: Ctrl+click adds to the selection instead of replacing
|
||||||
|
QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::ControlModifier,
|
||||||
|
third_rect.center());
|
||||||
|
const QModelIndexList selected = view.selectionModel()->selectedIndexes();
|
||||||
|
EXPECT_EQ(selected.size(), 2);
|
||||||
|
EXPECT_TRUE(selected.contains(first));
|
||||||
|
EXPECT_TRUE(selected.contains(third));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ProjectExplorerListView, UsesListModeAndItemDelegate)
|
||||||
|
{
|
||||||
|
ProjectExplorerListView view(nullptr);
|
||||||
|
|
||||||
|
EXPECT_EQ(view.viewMode(), QListView::ListMode);
|
||||||
|
|
||||||
|
// The delegate classes carry no Q_OBJECT, so cast with RTTI
|
||||||
|
EXPECT_NE(dynamic_cast<ProjectExplorerListViewItemDelegate *>(
|
||||||
|
view.itemDelegate()),
|
||||||
|
nullptr);
|
||||||
|
|
||||||
|
// Inherits the base view behavior
|
||||||
|
EXPECT_EQ(view.selectionMode(), QAbstractItemView::ExtendedSelection);
|
||||||
|
EXPECT_EQ(view.contextMenuPolicy(), Qt::CustomContextMenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ProjectExplorerIconView, UsesIconModeAndItemDelegate)
|
||||||
|
{
|
||||||
|
ProjectExplorerIconView view(nullptr);
|
||||||
|
|
||||||
|
EXPECT_EQ(view.viewMode(), QListView::IconMode);
|
||||||
|
|
||||||
|
EXPECT_NE(dynamic_cast<ProjectExplorerIconViewItemDelegate *>(
|
||||||
|
view.itemDelegate()),
|
||||||
|
nullptr);
|
||||||
|
|
||||||
|
EXPECT_EQ(view.selectionMode(), QAbstractItemView::ExtendedSelection);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ProjectExplorerTreeView, ConstructionDefaultsEnableDragDrop)
|
||||||
|
{
|
||||||
|
ProjectExplorerTreeView view(nullptr);
|
||||||
|
|
||||||
|
EXPECT_EQ(view.selectionMode(), QAbstractItemView::ExtendedSelection);
|
||||||
|
EXPECT_EQ(view.dragDropMode(), QAbstractItemView::DragDrop);
|
||||||
|
EXPECT_TRUE(view.dragEnabled());
|
||||||
|
EXPECT_TRUE(view.acceptDrops());
|
||||||
|
EXPECT_EQ(view.contextMenuPolicy(), Qt::CustomContextMenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ProjectExplorerTreeView, DoubleClickEmptyAreaEmitsSignal)
|
||||||
|
{
|
||||||
|
QStringListModel model({ QStringLiteral("One"), QStringLiteral("Two"),
|
||||||
|
QStringLiteral("Three") });
|
||||||
|
|
||||||
|
ProjectExplorerTreeView view(nullptr);
|
||||||
|
view.setModel(&model);
|
||||||
|
view.resize(400, 300);
|
||||||
|
view.show();
|
||||||
|
ASSERT_TRUE(QTest::qWaitForWindowExposed(&view));
|
||||||
|
|
||||||
|
const QModelIndex first = model.index(0, 0);
|
||||||
|
const QRect item_rect = view.visualRect(first);
|
||||||
|
if (!item_rect.isValid()) {
|
||||||
|
GTEST_SKIP() << "View did not lay out items under the offscreen platform";
|
||||||
|
}
|
||||||
|
|
||||||
|
int hits = 0;
|
||||||
|
QObject::connect(&view, &ProjectExplorerTreeView::double_clicked_empty_area,
|
||||||
|
[&hits] { ++hits; });
|
||||||
|
|
||||||
|
QTest::mouseDClick(view.viewport(), Qt::LeftButton, Qt::NoModifier,
|
||||||
|
item_rect.center());
|
||||||
|
EXPECT_EQ(hits, 0);
|
||||||
|
|
||||||
|
const QPoint empty(view.viewport()->width() - 4,
|
||||||
|
view.viewport()->height() - 4);
|
||||||
|
if (view.indexAt(empty).isValid()) {
|
||||||
|
GTEST_SKIP() << "Could not find an item-free spot in the viewport";
|
||||||
|
}
|
||||||
|
QTest::mouseDClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, empty);
|
||||||
|
EXPECT_EQ(hits, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ProjectExplorerListViewItemDelegate, SizeHintIsSquareFromDecorationHeight)
|
||||||
|
{
|
||||||
|
ProjectExplorerListViewItemDelegate delegate;
|
||||||
|
|
||||||
|
QStyleOptionViewItem opt;
|
||||||
|
opt.decorationSize = QSize(10, 32);
|
||||||
|
EXPECT_EQ(delegate.sizeHint(opt, QModelIndex()), QSize(32, 32));
|
||||||
|
|
||||||
|
opt.decorationSize = QSize(64, 20);
|
||||||
|
EXPECT_EQ(delegate.sizeHint(opt, QModelIndex()), QSize(20, 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ProjectExplorerListViewItemDelegate, PaintFillsHighlightWhenSelected)
|
||||||
|
{
|
||||||
|
QStandardItemModel model;
|
||||||
|
model.appendRow(new QStandardItem(QStringLiteral("Clip")));
|
||||||
|
|
||||||
|
QStyleOptionViewItem opt;
|
||||||
|
opt.rect = QRect(0, 0, 200, 24);
|
||||||
|
opt.decorationSize = QSize(24, 24);
|
||||||
|
|
||||||
|
ProjectExplorerListViewItemDelegate delegate;
|
||||||
|
|
||||||
|
// Selected rows are filled with the palette highlight
|
||||||
|
QImage selected_img(200, 24, QImage::Format_ARGB32);
|
||||||
|
selected_img.fill(Qt::transparent);
|
||||||
|
opt.state = QStyle::State_Enabled | QStyle::State_Selected;
|
||||||
|
{
|
||||||
|
QPainter p(&selected_img);
|
||||||
|
delegate.paint(&p, opt, model.index(0, 0));
|
||||||
|
}
|
||||||
|
EXPECT_EQ(selected_img.pixelColor(0, 0).rgb(),
|
||||||
|
opt.palette.highlight().color().rgb());
|
||||||
|
|
||||||
|
// Unselected rows paint nothing into the icon area (no icon set)
|
||||||
|
QImage plain_img(200, 24, QImage::Format_ARGB32);
|
||||||
|
plain_img.fill(Qt::transparent);
|
||||||
|
opt.state = QStyle::State_Enabled;
|
||||||
|
{
|
||||||
|
QPainter p(&plain_img);
|
||||||
|
delegate.paint(&p, opt, model.index(0, 0));
|
||||||
|
}
|
||||||
|
EXPECT_EQ(plain_img.pixelColor(0, 0).alpha(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ProjectExplorerIconViewItemDelegate, SizeHintIsFixed)
|
||||||
|
{
|
||||||
|
ProjectExplorerIconViewItemDelegate delegate;
|
||||||
|
|
||||||
|
QStyleOptionViewItem opt;
|
||||||
|
opt.decorationSize = QSize(16, 16);
|
||||||
|
|
||||||
|
// Always 256x256 regardless of the option
|
||||||
|
EXPECT_EQ(delegate.sizeHint(opt, QModelIndex()), QSize(256, 256));
|
||||||
|
EXPECT_EQ(delegate.sizeHint(QStyleOptionViewItem(), QModelIndex()),
|
||||||
|
QSize(256, 256));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ProjectExplorerIconViewItemDelegate, PaintDrawsTextBand)
|
||||||
|
{
|
||||||
|
QStandardItemModel model;
|
||||||
|
model.appendRow(new QStandardItem(QStringLiteral("Clip")));
|
||||||
|
const QModelIndex index = model.index(0, 0);
|
||||||
|
model.setData(index, QStringLiteral("00:00:01:00"), Qt::UserRole);
|
||||||
|
|
||||||
|
ProjectExplorerIconViewItemDelegate delegate;
|
||||||
|
|
||||||
|
QStyleOptionViewItem opt;
|
||||||
|
opt.rect = QRect(0, 0, 256, 256);
|
||||||
|
|
||||||
|
// The text band occupies the bottom fm.height() rows of the cell; the
|
||||||
|
// painter over a bare QImage uses the default application font
|
||||||
|
const QFontMetrics fm((QFont()));
|
||||||
|
const int band_top = opt.rect.height() - fm.height();
|
||||||
|
ASSERT_GT(opt.rect.height() / 2, fm.height());
|
||||||
|
|
||||||
|
// Unselected: band background is white
|
||||||
|
QImage plain_img(256, 256, QImage::Format_ARGB32);
|
||||||
|
plain_img.fill(Qt::transparent);
|
||||||
|
opt.state = QStyle::State_Enabled;
|
||||||
|
{
|
||||||
|
QPainter p(&plain_img);
|
||||||
|
delegate.paint(&p, opt, index);
|
||||||
|
}
|
||||||
|
EXPECT_EQ(plain_img.pixelColor(opt.rect.width() / 2, band_top).rgb(),
|
||||||
|
QColor(Qt::white).rgb());
|
||||||
|
|
||||||
|
// Selected: band background follows the palette highlight
|
||||||
|
QImage selected_img(256, 256, QImage::Format_ARGB32);
|
||||||
|
selected_img.fill(Qt::transparent);
|
||||||
|
opt.state = QStyle::State_Enabled | QStyle::State_Selected;
|
||||||
|
{
|
||||||
|
QPainter p(&selected_img);
|
||||||
|
delegate.paint(&p, opt, index);
|
||||||
|
}
|
||||||
|
EXPECT_EQ(selected_img.pixelColor(opt.rect.width() / 2, band_top).rgb(),
|
||||||
|
opt.palette.highlight().color().rgb());
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProjectExplorerUndoTest : public ::testing::Test {
|
||||||
|
protected:
|
||||||
|
void SetUp() override
|
||||||
|
{
|
||||||
|
ColorManager::set_up_default_config();
|
||||||
|
ensure_app_singletons();
|
||||||
|
|
||||||
|
project_ = std::make_unique<Project>();
|
||||||
|
project_->initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename T> T *make_node()
|
||||||
|
{
|
||||||
|
auto *node = new T();
|
||||||
|
node->setParent(project_.get());
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<Project> project_;
|
||||||
|
};
|
||||||
|
|
||||||
|
TEST_F(ProjectExplorerUndoTest, FolderAddChildRedoUndoRoundTrips)
|
||||||
|
{
|
||||||
|
Folder *root = project_->root();
|
||||||
|
Footage *footage = make_node<Footage>();
|
||||||
|
ASSERT_EQ(root->item_child_count(), 0);
|
||||||
|
ASSERT_EQ(footage->folder(), nullptr);
|
||||||
|
|
||||||
|
FolderAddChild cmd(root, footage);
|
||||||
|
EXPECT_EQ(cmd.get_relevant_project(), project_.get());
|
||||||
|
|
||||||
|
cmd.redo_now();
|
||||||
|
EXPECT_EQ(root->item_child_count(), 1);
|
||||||
|
EXPECT_EQ(root->item_child(0), footage);
|
||||||
|
EXPECT_EQ(footage->folder(), root);
|
||||||
|
|
||||||
|
// UndoCommand guards on a done flag: a second redo is a no-op
|
||||||
|
cmd.redo_now();
|
||||||
|
EXPECT_EQ(root->item_child_count(), 1);
|
||||||
|
|
||||||
|
cmd.undo_now();
|
||||||
|
EXPECT_EQ(root->item_child_count(), 0);
|
||||||
|
EXPECT_EQ(footage->folder(), nullptr);
|
||||||
|
|
||||||
|
// Likewise a second undo does nothing
|
||||||
|
cmd.undo_now();
|
||||||
|
EXPECT_EQ(root->item_child_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(ProjectExplorerUndoTest, FolderAddChildEmitsFolderInsertRemoveSignals)
|
||||||
|
{
|
||||||
|
Folder *root = project_->root();
|
||||||
|
Footage *footage = make_node<Footage>();
|
||||||
|
|
||||||
|
// The signal payload carries Node*, which is not a registered metatype,
|
||||||
|
// so count with lambdas rather than QSignalSpy
|
||||||
|
int insert_began = 0, insert_ended = 0, remove_began = 0, remove_ended = 0;
|
||||||
|
Node *inserted_node = nullptr;
|
||||||
|
int inserted_index = -1;
|
||||||
|
QObject::connect(root, &Folder::begin_insert_item,
|
||||||
|
[&](Node *n, int index) {
|
||||||
|
++insert_began;
|
||||||
|
inserted_node = n;
|
||||||
|
inserted_index = index;
|
||||||
|
});
|
||||||
|
QObject::connect(root, &Folder::end_insert_item,
|
||||||
|
[&] { ++insert_ended; });
|
||||||
|
QObject::connect(root, &Folder::begin_remove_item,
|
||||||
|
[&](Node *, int) { ++remove_began; });
|
||||||
|
QObject::connect(root, &Folder::end_remove_item,
|
||||||
|
[&] { ++remove_ended; });
|
||||||
|
|
||||||
|
FolderAddChild cmd(root, footage);
|
||||||
|
cmd.redo_now();
|
||||||
|
EXPECT_EQ(insert_began, 1);
|
||||||
|
EXPECT_EQ(insert_ended, 1);
|
||||||
|
EXPECT_EQ(inserted_node, footage);
|
||||||
|
EXPECT_EQ(inserted_index, 0);
|
||||||
|
EXPECT_EQ(remove_began, 0);
|
||||||
|
|
||||||
|
cmd.undo_now();
|
||||||
|
EXPECT_EQ(remove_began, 1);
|
||||||
|
EXPECT_EQ(remove_ended, 1);
|
||||||
|
EXPECT_EQ(insert_began, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(ProjectExplorerUndoTest, RemoveElementCommandRedoUndoRoundTrips)
|
||||||
|
{
|
||||||
|
Folder *root = project_->root();
|
||||||
|
Footage *a = make_node<Footage>();
|
||||||
|
Footage *b = make_node<Footage>();
|
||||||
|
FolderAddChild(root, a).redo_now();
|
||||||
|
FolderAddChild(root, b).redo_now();
|
||||||
|
ASSERT_EQ(root->item_child_count(), 2);
|
||||||
|
|
||||||
|
Folder::RemoveElementCommand cmd(root, a);
|
||||||
|
EXPECT_EQ(cmd.get_relevant_project(), project_.get());
|
||||||
|
|
||||||
|
cmd.redo_now();
|
||||||
|
EXPECT_EQ(root->item_child_count(), 1);
|
||||||
|
EXPECT_EQ(root->index_of_child(a), -1);
|
||||||
|
EXPECT_EQ(a->folder(), nullptr);
|
||||||
|
EXPECT_EQ(b->folder(), root);
|
||||||
|
|
||||||
|
cmd.undo_now();
|
||||||
|
EXPECT_EQ(root->item_child_count(), 2);
|
||||||
|
EXPECT_NE(root->index_of_child(a), -1);
|
||||||
|
EXPECT_EQ(a->folder(), root);
|
||||||
|
EXPECT_EQ(b->folder(), root);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(ProjectExplorerUndoTest, RemoveElementCommandOnNonChildIsNoOp)
|
||||||
|
{
|
||||||
|
Folder *root = project_->root();
|
||||||
|
Footage *stray = make_node<Footage>();
|
||||||
|
ASSERT_EQ(root->item_child_count(), 0);
|
||||||
|
|
||||||
|
// The child was never connected to the folder, so the command finds no
|
||||||
|
// array index and both directions do nothing
|
||||||
|
Folder::RemoveElementCommand cmd(root, stray);
|
||||||
|
cmd.redo_now();
|
||||||
|
EXPECT_EQ(root->item_child_count(), 0);
|
||||||
|
EXPECT_EQ(stray->folder(), nullptr);
|
||||||
|
|
||||||
|
cmd.undo_now();
|
||||||
|
EXPECT_EQ(root->item_child_count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(ProjectExplorerUndoTest, NestedFolderAddChildTracksHierarchy)
|
||||||
|
{
|
||||||
|
Folder *root = project_->root();
|
||||||
|
Folder *folder = make_node<Folder>();
|
||||||
|
Footage *footage = make_node<Footage>();
|
||||||
|
|
||||||
|
FolderAddChild add_folder(root, folder);
|
||||||
|
add_folder.redo_now();
|
||||||
|
FolderAddChild add_footage(folder, footage);
|
||||||
|
add_footage.redo_now();
|
||||||
|
|
||||||
|
EXPECT_EQ(root->item_child_count(), 1);
|
||||||
|
EXPECT_EQ(folder->item_child_count(), 1);
|
||||||
|
EXPECT_TRUE(root->has_child_recursive(footage));
|
||||||
|
EXPECT_EQ(footage->folder(), folder);
|
||||||
|
|
||||||
|
// Undoing the inner add leaves the folder in place but empty
|
||||||
|
add_footage.undo_now();
|
||||||
|
EXPECT_EQ(folder->item_child_count(), 0);
|
||||||
|
EXPECT_EQ(footage->folder(), nullptr);
|
||||||
|
EXPECT_EQ(folder->folder(), root);
|
||||||
|
|
||||||
|
add_folder.undo_now();
|
||||||
|
EXPECT_EQ(root->item_child_count(), 0);
|
||||||
|
EXPECT_EQ(folder->folder(), nullptr);
|
||||||
|
}
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include <QAction>
|
||||||
|
#include <QSignalSpy>
|
||||||
|
#include <QTest>
|
||||||
|
|
||||||
|
#include "config/config.h"
|
||||||
|
#include "node/color/colormanager/colormanager.h"
|
||||||
|
#include "node/project.h"
|
||||||
|
#include "widget/colorwheel/colorswatchwidget.h"
|
||||||
|
#include "widget/manageddisplay/manageddisplay.h"
|
||||||
|
#include "widget/menu/menu.h"
|
||||||
|
#include "widget/scope/histogram/histogram.h"
|
||||||
|
#include "widget/scope/scopebase/scopebase.h"
|
||||||
|
#include "widget/scope/vectorscope/vectorscope.h"
|
||||||
|
#include "widget/scope/waveform/waveform.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// ManagedDisplayWidget is abstract (on_paint), so a minimal concrete probe is
|
||||||
|
// needed even for tests that only exercise the base-class behavior
|
||||||
|
class ProbeDisplayWidget : public olive::ManagedDisplayWidget {
|
||||||
|
public:
|
||||||
|
using olive::ManagedDisplayWidget::ManagedDisplayWidget;
|
||||||
|
|
||||||
|
void *pub_renderer() const
|
||||||
|
{
|
||||||
|
return renderer();
|
||||||
|
}
|
||||||
|
bool pub_backend_neutral() const
|
||||||
|
{
|
||||||
|
return is_backend_neutral();
|
||||||
|
}
|
||||||
|
|
||||||
|
int processor_changed_events = 0;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
virtual void on_paint() override
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual void color_processor_changed_event() override
|
||||||
|
{
|
||||||
|
processor_changed_events++;
|
||||||
|
olive::ManagedDisplayWidget::color_processor_changed_event();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Exposes the protected renderer state of the concrete scopes so tests can
|
||||||
|
// verify construction wiring without a GL context
|
||||||
|
class ProbeHistogramScope : public olive::HistogramScope {
|
||||||
|
public:
|
||||||
|
void *pub_renderer() const
|
||||||
|
{
|
||||||
|
return renderer();
|
||||||
|
}
|
||||||
|
bool pub_backend_neutral() const
|
||||||
|
{
|
||||||
|
return is_backend_neutral();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class ProbeWaveformScope : public olive::WaveformScope {
|
||||||
|
public:
|
||||||
|
void *pub_renderer() const
|
||||||
|
{
|
||||||
|
return renderer();
|
||||||
|
}
|
||||||
|
bool pub_backend_neutral() const
|
||||||
|
{
|
||||||
|
return is_backend_neutral();
|
||||||
|
}
|
||||||
|
oak_video_params pub_viewport_params() const
|
||||||
|
{
|
||||||
|
return get_viewport_params();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class ProbeVectorscopeScope : public olive::VectorscopeScope {
|
||||||
|
public:
|
||||||
|
void *pub_renderer() const
|
||||||
|
{
|
||||||
|
return renderer();
|
||||||
|
}
|
||||||
|
bool pub_backend_neutral() const
|
||||||
|
{
|
||||||
|
return is_backend_neutral();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ColorSwatchWidget is abstract (get_color_from_screen_pos); this probe maps
|
||||||
|
// screen position deterministically to a color and records the change events
|
||||||
|
class ProbeSwatchWidget : public olive::ColorSwatchWidget {
|
||||||
|
public:
|
||||||
|
olive::Color pub_managed_color(const olive::Color &c) const
|
||||||
|
{
|
||||||
|
return get_managed_color(c);
|
||||||
|
}
|
||||||
|
Qt::GlobalColor pub_selector_color() const
|
||||||
|
{
|
||||||
|
return get_ui_selector_color();
|
||||||
|
}
|
||||||
|
|
||||||
|
int changed_events = 0;
|
||||||
|
bool last_external = false;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
virtual olive::Color get_color_from_screen_pos(const QPoint &p) const override
|
||||||
|
{
|
||||||
|
return olive::Color(p.x() / 100.0, p.y() / 100.0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual void SelectedColorChangedEvent(const olive::Color &c,
|
||||||
|
bool external) override
|
||||||
|
{
|
||||||
|
changed_events++;
|
||||||
|
last_external = external;
|
||||||
|
olive::ColorSwatchWidget::SelectedColorChangedEvent(c, external);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Returns the index of the first action whose data differs from the current
|
||||||
|
// transform component, or -1 when the menu offers no alternative
|
||||||
|
int alternative_action_index(olive::Menu *menu, const QString ¤t)
|
||||||
|
{
|
||||||
|
const QList<QAction *> acts = menu->actions();
|
||||||
|
for (int i = 0; i < acts.size(); i++) {
|
||||||
|
if (acts.at(i)->data().toString() != current) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(WidgetManagedDisplay, DefaultConstructionState)
|
||||||
|
{
|
||||||
|
ProbeDisplayWidget w;
|
||||||
|
|
||||||
|
EXPECT_EQ(w.color_manager(), nullptr);
|
||||||
|
EXPECT_NE(w.pub_renderer(), nullptr);
|
||||||
|
|
||||||
|
// No transform has been chosen yet
|
||||||
|
const oak::ColorTransform &t = w.get_color_transform();
|
||||||
|
EXPECT_FALSE(t.is_display());
|
||||||
|
EXPECT_TRUE(t.output().isEmpty());
|
||||||
|
EXPECT_TRUE(t.view().isEmpty());
|
||||||
|
EXPECT_TRUE(t.look().isEmpty());
|
||||||
|
|
||||||
|
EXPECT_EQ(w.processor_changed_events, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetManagedDisplay, SetColorTransformWithoutManagerRoundTrips)
|
||||||
|
{
|
||||||
|
ProbeDisplayWidget w;
|
||||||
|
|
||||||
|
// ColorProcessorHandlePtr is not a registered metatype, so count the
|
||||||
|
// signal through a direct lambda connection instead of QSignalSpy
|
||||||
|
int signal_count = 0;
|
||||||
|
olive::ColorProcessorHandlePtr last_processor;
|
||||||
|
QObject::connect(&w, &olive::ManagedDisplayWidget::color_processor_changed,
|
||||||
|
[&signal_count, &last_processor](
|
||||||
|
olive::ColorProcessorHandlePtr p) {
|
||||||
|
signal_count++;
|
||||||
|
last_processor = p;
|
||||||
|
});
|
||||||
|
|
||||||
|
w.set_color_transform(
|
||||||
|
oak::ColorTransform(QStringLiteral("MyDisplay"),
|
||||||
|
QStringLiteral("MyView"), QStringLiteral("MyLook")));
|
||||||
|
|
||||||
|
const oak::ColorTransform &t = w.get_color_transform();
|
||||||
|
EXPECT_TRUE(t.is_display());
|
||||||
|
EXPECT_EQ(t.display(), QStringLiteral("MyDisplay"));
|
||||||
|
EXPECT_EQ(t.view(), QStringLiteral("MyView"));
|
||||||
|
EXPECT_EQ(t.look(), QStringLiteral("MyLook"));
|
||||||
|
|
||||||
|
// With no color manager connected, the processor is cleared and the
|
||||||
|
// change is signalled once
|
||||||
|
EXPECT_EQ(signal_count, 1);
|
||||||
|
EXPECT_EQ(last_processor, nullptr);
|
||||||
|
EXPECT_EQ(w.processor_changed_events, 1);
|
||||||
|
|
||||||
|
// A plain colorspace transform stores only the output name
|
||||||
|
w.set_color_transform(oak::ColorTransform(QStringLiteral("ACEScg")));
|
||||||
|
EXPECT_FALSE(w.get_color_transform().is_display());
|
||||||
|
EXPECT_EQ(w.get_color_transform().output(), QStringLiteral("ACEScg"));
|
||||||
|
EXPECT_EQ(signal_count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetManagedDisplay, ConnectColorManagerDefaultsToDisplayViewTransform)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
|
||||||
|
ProbeDisplayWidget w;
|
||||||
|
|
||||||
|
int manager_signals = 0;
|
||||||
|
OakEngineColorManager *last_manager = nullptr;
|
||||||
|
QObject::connect(&w, &olive::ManagedDisplayWidget::color_manager_changed,
|
||||||
|
[&manager_signals,
|
||||||
|
&last_manager](OakEngineColorManager *m) {
|
||||||
|
manager_signals++;
|
||||||
|
last_manager = m;
|
||||||
|
});
|
||||||
|
|
||||||
|
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
|
||||||
|
|
||||||
|
EXPECT_EQ(w.color_manager(),
|
||||||
|
olive::oak_color_manager(project.color_manager()));
|
||||||
|
EXPECT_EQ(manager_signals, 1);
|
||||||
|
EXPECT_EQ(last_manager, olive::oak_color_manager(project.color_manager()));
|
||||||
|
|
||||||
|
// An empty transform is conformed to the config's default display/view so
|
||||||
|
// the widget would show a sensible image
|
||||||
|
const oak::ColorTransform &t = w.get_color_transform();
|
||||||
|
EXPECT_TRUE(t.is_display());
|
||||||
|
EXPECT_FALSE(t.display().isEmpty());
|
||||||
|
EXPECT_FALSE(t.view().isEmpty());
|
||||||
|
|
||||||
|
// Connecting the same manager again is a no-op
|
||||||
|
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
|
||||||
|
EXPECT_EQ(manager_signals, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetManagedDisplay, DisconnectColorManagerClearsState)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
|
||||||
|
ProbeDisplayWidget w;
|
||||||
|
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
|
||||||
|
|
||||||
|
int manager_signals = 0;
|
||||||
|
OakEngineColorManager *last_manager =
|
||||||
|
olive::oak_color_manager(project.color_manager());
|
||||||
|
QObject::connect(&w, &olive::ManagedDisplayWidget::color_manager_changed,
|
||||||
|
[&manager_signals,
|
||||||
|
&last_manager](OakEngineColorManager *m) {
|
||||||
|
manager_signals++;
|
||||||
|
last_manager = m;
|
||||||
|
});
|
||||||
|
|
||||||
|
w.disconnect_color_manager();
|
||||||
|
|
||||||
|
EXPECT_EQ(w.color_manager(), nullptr);
|
||||||
|
EXPECT_EQ(manager_signals, 1);
|
||||||
|
EXPECT_EQ(last_manager, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetManagedDisplay, DisplayMenuReflectsCurrentTransform)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
|
||||||
|
ProbeDisplayWidget w;
|
||||||
|
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Menu> menu(w.get_display_menu(nullptr));
|
||||||
|
ASSERT_FALSE(menu->actions().isEmpty());
|
||||||
|
|
||||||
|
// Exactly the current display is checked
|
||||||
|
int checked = 0;
|
||||||
|
for (QAction *a : menu->actions()) {
|
||||||
|
EXPECT_TRUE(a->isCheckable());
|
||||||
|
EXPECT_EQ(a->data().toString(), a->text());
|
||||||
|
if (a->isChecked()) {
|
||||||
|
checked++;
|
||||||
|
EXPECT_EQ(a->data().toString(), w.get_color_transform().display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EXPECT_EQ(checked, 1);
|
||||||
|
|
||||||
|
// Picking another display (if the config has one) retargets the transform
|
||||||
|
const int alt =
|
||||||
|
alternative_action_index(menu.get(), w.get_color_transform().display());
|
||||||
|
if (alt >= 0) {
|
||||||
|
const QString target = menu->actions().at(alt)->data().toString();
|
||||||
|
menu->actions().at(alt)->trigger();
|
||||||
|
EXPECT_EQ(w.get_color_transform().display(), target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetManagedDisplay, ViewMenuReflectsCurrentTransform)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
|
||||||
|
ProbeDisplayWidget w;
|
||||||
|
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Menu> menu(w.get_view_menu(nullptr));
|
||||||
|
ASSERT_FALSE(menu->actions().isEmpty());
|
||||||
|
|
||||||
|
int checked = 0;
|
||||||
|
for (QAction *a : menu->actions()) {
|
||||||
|
EXPECT_TRUE(a->isCheckable());
|
||||||
|
if (a->isChecked()) {
|
||||||
|
checked++;
|
||||||
|
EXPECT_EQ(a->data().toString(), w.get_color_transform().view());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EXPECT_EQ(checked, 1);
|
||||||
|
|
||||||
|
const int alt =
|
||||||
|
alternative_action_index(menu.get(), w.get_color_transform().view());
|
||||||
|
if (alt >= 0) {
|
||||||
|
const QString target = menu->actions().at(alt)->data().toString();
|
||||||
|
menu->actions().at(alt)->trigger();
|
||||||
|
EXPECT_EQ(w.get_color_transform().view(), target);
|
||||||
|
// The display component survives a view change
|
||||||
|
EXPECT_FALSE(w.get_color_transform().display().isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetManagedDisplay, LookMenuStartsWithNoLookEntry)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
|
||||||
|
ProbeDisplayWidget w;
|
||||||
|
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Menu> menu(w.get_look_menu(nullptr));
|
||||||
|
ASSERT_FALSE(menu->actions().isEmpty());
|
||||||
|
|
||||||
|
// The first entry is always the "(None)" entry carrying empty data
|
||||||
|
QAction *none = menu->actions().first();
|
||||||
|
EXPECT_TRUE(none->isCheckable());
|
||||||
|
EXPECT_EQ(none->data().toString(), QString());
|
||||||
|
|
||||||
|
// The default transform has no look, so "(None)" is checked
|
||||||
|
EXPECT_TRUE(w.get_color_transform().look().isEmpty());
|
||||||
|
EXPECT_TRUE(none->isChecked());
|
||||||
|
|
||||||
|
for (int i = 1; i < menu->actions().size(); i++) {
|
||||||
|
EXPECT_TRUE(menu->actions().at(i)->isCheckable());
|
||||||
|
EXPECT_EQ(menu->actions().at(i)->data().toString(),
|
||||||
|
menu->actions().at(i)->text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetManagedDisplay, ColorSpaceMenuListsConfigSpaces)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
|
||||||
|
ProbeDisplayWidget w;
|
||||||
|
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
|
||||||
|
|
||||||
|
const QStringList spaces =
|
||||||
|
project.color_manager()->list_available_colorspaces();
|
||||||
|
ASSERT_GE(spaces.size(), 2);
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Menu> menu(w.get_color_space_menu(nullptr));
|
||||||
|
ASSERT_EQ(menu->actions().size(), spaces.size());
|
||||||
|
|
||||||
|
for (int i = 0; i < spaces.size(); i++) {
|
||||||
|
EXPECT_EQ(menu->actions().at(i)->data().toString(), spaces.at(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Selecting a colorspace switches to a non-display transform
|
||||||
|
const int alt =
|
||||||
|
alternative_action_index(menu.get(), w.get_color_transform().output());
|
||||||
|
if (alt >= 0) {
|
||||||
|
const QString target = menu->actions().at(alt)->data().toString();
|
||||||
|
menu->actions().at(alt)->trigger();
|
||||||
|
EXPECT_EQ(w.get_color_transform().output(), target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetScopeBase, ViewportParamsTrackWidgetGeometry)
|
||||||
|
{
|
||||||
|
ProbeWaveformScope w;
|
||||||
|
w.resize(640, 360);
|
||||||
|
|
||||||
|
const oak_video_params vp = w.pub_viewport_params();
|
||||||
|
EXPECT_EQ(vp.width, int(640 * w.devicePixelRatioF()));
|
||||||
|
EXPECT_EQ(vp.height, int(360 * w.devicePixelRatioF()));
|
||||||
|
EXPECT_EQ(vp.format,
|
||||||
|
olive::Config::current()[QStringLiteral("OfflinePixelFormat")]
|
||||||
|
.toInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetHistogramScope, ConstructionCreatesRendererWithoutTexture)
|
||||||
|
{
|
||||||
|
ProbeHistogramScope w;
|
||||||
|
|
||||||
|
// The renderer abstraction exists right after construction, but nothing
|
||||||
|
// color-related is connected yet
|
||||||
|
EXPECT_NE(w.pub_renderer(), nullptr);
|
||||||
|
EXPECT_EQ(w.color_manager(), nullptr);
|
||||||
|
EXPECT_TRUE(w.get_color_transform().output().isEmpty());
|
||||||
|
|
||||||
|
// A null buffer is accepted and simply schedules a repaint; there is no
|
||||||
|
// texture to retain
|
||||||
|
w.set_buffer(nullptr);
|
||||||
|
w.set_buffer(nullptr);
|
||||||
|
|
||||||
|
EXPECT_EQ(w.color_manager(), nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetHistogramScope, ConnectColorManagerDefaultsTransform)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
|
||||||
|
ProbeHistogramScope w;
|
||||||
|
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
|
||||||
|
|
||||||
|
EXPECT_EQ(w.color_manager(),
|
||||||
|
olive::oak_color_manager(project.color_manager()));
|
||||||
|
EXPECT_TRUE(w.get_color_transform().is_display());
|
||||||
|
EXPECT_FALSE(w.get_color_transform().display().isEmpty());
|
||||||
|
EXPECT_FALSE(w.get_color_transform().view().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetVectorscopeScope, ConstructionCreatesRenderer)
|
||||||
|
{
|
||||||
|
ProbeVectorscopeScope w;
|
||||||
|
|
||||||
|
EXPECT_NE(w.pub_renderer(), nullptr);
|
||||||
|
EXPECT_EQ(w.color_manager(), nullptr);
|
||||||
|
|
||||||
|
// Null buffers are tolerated without a connected manager
|
||||||
|
w.set_buffer(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetVectorscopeScope, ConnectColorManagerDefaultsTransform)
|
||||||
|
{
|
||||||
|
olive::ColorManager::set_up_default_config();
|
||||||
|
olive::Project project;
|
||||||
|
|
||||||
|
ProbeVectorscopeScope w;
|
||||||
|
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
|
||||||
|
|
||||||
|
EXPECT_TRUE(w.get_color_transform().is_display());
|
||||||
|
EXPECT_FALSE(w.get_color_transform().display().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetWaveformScope, ParadeModeFollowsConfig)
|
||||||
|
{
|
||||||
|
const QVariant old =
|
||||||
|
olive::Config::current()[QStringLiteral("WaveformRgbParade")];
|
||||||
|
|
||||||
|
olive::Config::current()[QStringLiteral("WaveformRgbParade")] = true;
|
||||||
|
{
|
||||||
|
ProbeWaveformScope w;
|
||||||
|
EXPECT_TRUE(w.parade_mode());
|
||||||
|
EXPECT_NE(w.pub_renderer(), nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
olive::Config::current()[QStringLiteral("WaveformRgbParade")] = false;
|
||||||
|
{
|
||||||
|
ProbeWaveformScope w;
|
||||||
|
EXPECT_FALSE(w.parade_mode());
|
||||||
|
}
|
||||||
|
|
||||||
|
olive::Config::current()[QStringLiteral("WaveformRgbParade")] = old;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetWaveformScope, SetParadeModePersistsToConfig)
|
||||||
|
{
|
||||||
|
const QVariant old =
|
||||||
|
olive::Config::current()[QStringLiteral("WaveformRgbParade")];
|
||||||
|
|
||||||
|
{
|
||||||
|
ProbeWaveformScope w;
|
||||||
|
|
||||||
|
w.set_parade_mode(true);
|
||||||
|
EXPECT_TRUE(w.parade_mode());
|
||||||
|
EXPECT_TRUE(
|
||||||
|
olive::Config::current()[QStringLiteral("WaveformRgbParade")]
|
||||||
|
.toBool());
|
||||||
|
|
||||||
|
w.set_parade_mode(false);
|
||||||
|
EXPECT_FALSE(w.parade_mode());
|
||||||
|
EXPECT_FALSE(
|
||||||
|
olive::Config::current()[QStringLiteral("WaveformRgbParade")]
|
||||||
|
.toBool());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A newly constructed scope picks the persisted value up
|
||||||
|
{
|
||||||
|
ProbeWaveformScope w;
|
||||||
|
EXPECT_FALSE(w.parade_mode());
|
||||||
|
}
|
||||||
|
|
||||||
|
olive::Config::current()[QStringLiteral("WaveformRgbParade")] = old;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetColorSwatchWidget, SetSelectedColorRoundTrips)
|
||||||
|
{
|
||||||
|
ProbeSwatchWidget w;
|
||||||
|
|
||||||
|
w.set_selected_color(olive::Color(0.2, 0.4, 0.6));
|
||||||
|
|
||||||
|
EXPECT_FLOAT_EQ(w.get_selected_color().red(), 0.2f);
|
||||||
|
EXPECT_FLOAT_EQ(w.get_selected_color().green(), 0.4f);
|
||||||
|
EXPECT_FLOAT_EQ(w.get_selected_color().blue(), 0.6f);
|
||||||
|
|
||||||
|
// Programmatic changes count as external
|
||||||
|
EXPECT_EQ(w.changed_events, 1);
|
||||||
|
EXPECT_TRUE(w.last_external);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetColorSwatchWidget, MousePressPicksColorAndEmits)
|
||||||
|
{
|
||||||
|
ProbeSwatchWidget w;
|
||||||
|
w.resize(100, 100);
|
||||||
|
|
||||||
|
// olive::Color is not a registered metatype in this harness, so capture
|
||||||
|
// the signal payload through a lambda
|
||||||
|
QVector<olive::Color> received;
|
||||||
|
QObject::connect(&w, &olive::ColorSwatchWidget::selected_color_changed,
|
||||||
|
[&received](const olive::Color &c) { received.append(c); });
|
||||||
|
|
||||||
|
QTest::mouseClick(&w, Qt::LeftButton, Qt::NoModifier, QPoint(10, 20));
|
||||||
|
|
||||||
|
ASSERT_EQ(received.size(), 1);
|
||||||
|
EXPECT_NEAR(received.first().red(), 0.10, 1e-6);
|
||||||
|
EXPECT_NEAR(received.first().green(), 0.20, 1e-6);
|
||||||
|
EXPECT_NEAR(received.first().blue(), 0.5, 1e-6);
|
||||||
|
|
||||||
|
// User interaction is reported as non-external
|
||||||
|
EXPECT_EQ(w.changed_events, 1);
|
||||||
|
EXPECT_FALSE(w.last_external);
|
||||||
|
|
||||||
|
EXPECT_NEAR(w.get_selected_color().red(), 0.10, 1e-6);
|
||||||
|
EXPECT_NEAR(w.get_selected_color().green(), 0.20, 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetColorSwatchWidget, DragEmitsForEachMove)
|
||||||
|
{
|
||||||
|
ProbeSwatchWidget w;
|
||||||
|
w.resize(100, 100);
|
||||||
|
|
||||||
|
QVector<olive::Color> received;
|
||||||
|
QObject::connect(&w, &olive::ColorSwatchWidget::selected_color_changed,
|
||||||
|
[&received](const olive::Color &c) { received.append(c); });
|
||||||
|
|
||||||
|
QTest::mousePress(&w, Qt::LeftButton, Qt::NoModifier, QPoint(10, 10));
|
||||||
|
ASSERT_EQ(received.size(), 1);
|
||||||
|
|
||||||
|
// A move while the left button is held updates the color...
|
||||||
|
QTest::mouseMove(&w, QPoint(30, 40));
|
||||||
|
ASSERT_EQ(received.size(), 2);
|
||||||
|
EXPECT_NEAR(received.at(1).red(), 0.30, 1e-6);
|
||||||
|
EXPECT_NEAR(received.at(1).green(), 0.40, 1e-6);
|
||||||
|
|
||||||
|
QTest::mouseRelease(&w, Qt::LeftButton, Qt::NoModifier, QPoint(30, 40));
|
||||||
|
EXPECT_EQ(received.size(), 2);
|
||||||
|
|
||||||
|
// ...but a bare move without a pressed button is ignored
|
||||||
|
QTest::mouseMove(&w, QPoint(50, 50));
|
||||||
|
EXPECT_EQ(received.size(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetColorSwatchWidget, ManagedColorPassesThroughWithoutProcessors)
|
||||||
|
{
|
||||||
|
ProbeSwatchWidget w;
|
||||||
|
|
||||||
|
// With no color processors installed, the managed color is the input
|
||||||
|
const olive::Color in(0.1, 0.2, 0.3, 1.0);
|
||||||
|
const olive::Color out = w.pub_managed_color(in);
|
||||||
|
EXPECT_FLOAT_EQ(out.red(), 0.1f);
|
||||||
|
EXPECT_FLOAT_EQ(out.green(), 0.2f);
|
||||||
|
EXPECT_FLOAT_EQ(out.blue(), 0.3f);
|
||||||
|
|
||||||
|
// Setting null processors still forces a full external refresh
|
||||||
|
w.set_color_processor(nullptr, nullptr);
|
||||||
|
EXPECT_TRUE(w.last_external);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetColorSwatchWidget, SelectorColorFollowsLuminance)
|
||||||
|
{
|
||||||
|
ProbeSwatchWidget w;
|
||||||
|
|
||||||
|
// Bright swatches get a black selector, dark swatches a white one
|
||||||
|
w.set_selected_color(olive::Color(1.0, 1.0, 1.0));
|
||||||
|
EXPECT_EQ(w.pub_selector_color(), Qt::black);
|
||||||
|
|
||||||
|
w.set_selected_color(olive::Color(0.0, 0.0, 0.0));
|
||||||
|
EXPECT_EQ(w.pub_selector_color(), Qt::white);
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QLabel>
|
||||||
|
#include <QSignalSpy>
|
||||||
|
|
||||||
|
#include "common/configwrapper.h"
|
||||||
|
#include "widget/focusablelineedit/focusablelineedit.h"
|
||||||
|
#include "widget/slider/base/numericsliderbase.h"
|
||||||
|
#include "widget/slider/base/sliderlabel.h"
|
||||||
|
#include "widget/slider/base/sliderladder.h"
|
||||||
|
#include "widget/slider/sliderdisplaytypeapp.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// NumericSliderBase is abstract (SliderBase's value_to_string /
|
||||||
|
// string_to_value / value_signal_event are pure), so test it through a
|
||||||
|
// minimal concrete probe that also exposes the protected entry points
|
||||||
|
class ProbeNumericSlider : public olive::NumericSliderBase {
|
||||||
|
public:
|
||||||
|
void set_value(const QVariant &v)
|
||||||
|
{
|
||||||
|
set_value_internal(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant get_value() const
|
||||||
|
{
|
||||||
|
return get_value_internal();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant get_offset_public() const
|
||||||
|
{
|
||||||
|
return get_offset();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant adjust_drag_public(const QVariant &start, const double &drag) const
|
||||||
|
{
|
||||||
|
return adjust_drag_distance_internal(start, drag);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool greater_public(const QVariant &lhs, const QVariant &rhs) const
|
||||||
|
{
|
||||||
|
return value_greater_than(lhs, rhs);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool less_public(const QVariant &lhs, const QVariant &rhs) const
|
||||||
|
{
|
||||||
|
return value_less_than(lhs, rhs);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool can_set_value_public() const
|
||||||
|
{
|
||||||
|
return can_set_value();
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_minimum_public(const QVariant &v)
|
||||||
|
{
|
||||||
|
set_minimum_internal(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_maximum_public(const QVariant &v)
|
||||||
|
{
|
||||||
|
set_maximum_internal(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<QVariant> signaled;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
virtual QString value_to_string(const QVariant &v) const override
|
||||||
|
{
|
||||||
|
return QString::number(v.toDouble());
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual QVariant string_to_value(const QString &s, bool *ok) const override
|
||||||
|
{
|
||||||
|
return s.toDouble(ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
virtual void value_signal_event(const QVariant &value) override
|
||||||
|
{
|
||||||
|
signaled.append(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Forces ladder mode on for the duration of a test. Ladder mode keeps the
|
||||||
|
// SliderLadder away from the macOS cursor-grab path
|
||||||
|
// (CGAssociateMouseAndMouseCursorPosition), which would otherwise decouple
|
||||||
|
// the host's real cursor while a ladder widget exists
|
||||||
|
struct LadderConfigGuard {
|
||||||
|
LadderConfigGuard()
|
||||||
|
: old_(OAK_CONFIG("UseSliderLadders").toBool())
|
||||||
|
{
|
||||||
|
OAK_CONFIG("UseSliderLadders") = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
~LadderConfigGuard()
|
||||||
|
{
|
||||||
|
OAK_CONFIG("UseSliderLadders") = old_;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool old_;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The drag ladder is an orphan top-level popup; dig it out of the
|
||||||
|
// application's top-level widget list
|
||||||
|
olive::SliderLadder *find_ladder()
|
||||||
|
{
|
||||||
|
const QWidgetList tops = QApplication::topLevelWidgets();
|
||||||
|
for (QWidget *w : tops) {
|
||||||
|
if (auto *l = qobject_cast<olive::SliderLadder *>(w)) {
|
||||||
|
return l;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Starts a ladder drag on the slider by emitting its label's press signal
|
||||||
|
void press_label(ProbeNumericSlider &s)
|
||||||
|
{
|
||||||
|
olive::SliderLabel *label = s.findChild<olive::SliderLabel *>();
|
||||||
|
ASSERT_NE(label, nullptr);
|
||||||
|
ASSERT_TRUE(QMetaObject::invokeMethod(label, "label_pressed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(SliderDisplayType, OrdinalValuesAreAbiStable)
|
||||||
|
{
|
||||||
|
// These cross the engine C ABI as ints inside node input properties; the
|
||||||
|
// ordinals must match engine node/sliderdisplaytype.h
|
||||||
|
EXPECT_EQ(int(olive::slider::k_normal), 0);
|
||||||
|
EXPECT_EQ(int(olive::slider::k_decibel), 1);
|
||||||
|
EXPECT_EQ(int(olive::slider::k_percentage), 2);
|
||||||
|
|
||||||
|
EXPECT_EQ(int(olive::slider::k_time), 0);
|
||||||
|
EXPECT_EQ(int(olive::slider::k_float), 1);
|
||||||
|
EXPECT_EQ(int(olive::slider::k_rational), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NumericSliderBase, DefaultStateIsIdle)
|
||||||
|
{
|
||||||
|
ProbeNumericSlider s;
|
||||||
|
EXPECT_FALSE(s.is_dragging());
|
||||||
|
EXPECT_TRUE(s.can_set_value_public());
|
||||||
|
EXPECT_EQ(s.cursor().shape(), Qt::SizeHorCursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NumericSliderBase, OffsetRoundTrips)
|
||||||
|
{
|
||||||
|
ProbeNumericSlider s;
|
||||||
|
EXPECT_FALSE(s.get_offset_public().isValid());
|
||||||
|
|
||||||
|
s.set_offset(7);
|
||||||
|
EXPECT_EQ(s.get_offset_public().toInt(), 7);
|
||||||
|
|
||||||
|
s.set_offset(-2.5);
|
||||||
|
EXPECT_DOUBLE_EQ(s.get_offset_public().toDouble(), -2.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NumericSliderBase, DragDistanceDefaultsToAddition)
|
||||||
|
{
|
||||||
|
ProbeNumericSlider s;
|
||||||
|
EXPECT_DOUBLE_EQ(s.adjust_drag_public(1.5, 2.25).toDouble(), 3.75);
|
||||||
|
EXPECT_DOUBLE_EQ(s.adjust_drag_public(1.5, -0.5).toDouble(), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NumericSliderBase, ValueComparisonUsesNumericValues)
|
||||||
|
{
|
||||||
|
ProbeNumericSlider s;
|
||||||
|
EXPECT_TRUE(s.greater_public(2.5, 2.4));
|
||||||
|
EXPECT_FALSE(s.greater_public(1.0, 1.0));
|
||||||
|
EXPECT_TRUE(s.less_public(1, 2));
|
||||||
|
|
||||||
|
// String variants compare by their numeric value, not lexicographically
|
||||||
|
EXPECT_TRUE(s.less_public(QStringLiteral("9"), QStringLiteral("10")));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NumericSliderBase, SetMinimumClampsExistingValue)
|
||||||
|
{
|
||||||
|
ProbeNumericSlider s;
|
||||||
|
|
||||||
|
s.set_value(5.0);
|
||||||
|
s.set_minimum_public(10.0);
|
||||||
|
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 10.0);
|
||||||
|
|
||||||
|
// New values below the minimum clamp on the way in; programmatic sets do
|
||||||
|
// not emit the value-changed signal
|
||||||
|
s.set_value(3.0);
|
||||||
|
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 10.0);
|
||||||
|
EXPECT_TRUE(s.signaled.isEmpty());
|
||||||
|
|
||||||
|
s.set_value(42.0);
|
||||||
|
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 42.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NumericSliderBase, SetMaximumClampsExistingValue)
|
||||||
|
{
|
||||||
|
ProbeNumericSlider s;
|
||||||
|
|
||||||
|
s.set_value(5.0);
|
||||||
|
s.set_maximum_public(2.0);
|
||||||
|
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 2.0);
|
||||||
|
|
||||||
|
s.set_value(99.0);
|
||||||
|
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 2.0);
|
||||||
|
|
||||||
|
s.set_value(-7.0);
|
||||||
|
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), -7.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NumericSliderBase, LadderDragUpdatesValueAndSignals)
|
||||||
|
{
|
||||||
|
LadderConfigGuard guard;
|
||||||
|
|
||||||
|
{
|
||||||
|
ProbeNumericSlider s;
|
||||||
|
s.set_ladder_element_count(2);
|
||||||
|
s.set_value(10.0);
|
||||||
|
|
||||||
|
press_label(s);
|
||||||
|
ASSERT_TRUE(s.is_dragging());
|
||||||
|
EXPECT_FALSE(s.can_set_value_public());
|
||||||
|
|
||||||
|
olive::SliderLadder *ladder = find_ladder();
|
||||||
|
ASSERT_NE(ladder, nullptr);
|
||||||
|
|
||||||
|
// Two outer values either side plus the center entry
|
||||||
|
EXPECT_EQ(ladder->findChildren<olive::SliderLadderElement *>().size(), 5);
|
||||||
|
|
||||||
|
// External sets are blocked while the drag owns the value
|
||||||
|
s.set_value(50.0);
|
||||||
|
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 10.0);
|
||||||
|
EXPECT_TRUE(s.signaled.isEmpty());
|
||||||
|
|
||||||
|
// value * multiplier accumulates onto the drag start value
|
||||||
|
ASSERT_TRUE(QMetaObject::invokeMethod(
|
||||||
|
ladder, "dragged_by_value", Q_ARG(int, 3), Q_ARG(double, 2.0)));
|
||||||
|
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 16.0);
|
||||||
|
ASSERT_EQ(s.signaled.size(), 1);
|
||||||
|
EXPECT_DOUBLE_EQ(s.signaled.last().toDouble(), 16.0);
|
||||||
|
|
||||||
|
ASSERT_TRUE(QMetaObject::invokeMethod(
|
||||||
|
ladder, "dragged_by_value", Q_ARG(int, 1), Q_ARG(double, 1.0)));
|
||||||
|
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 17.0);
|
||||||
|
ASSERT_EQ(s.signaled.size(), 2);
|
||||||
|
|
||||||
|
// Releasing after a drag re-signals the final value and ends the drag
|
||||||
|
ladder->close();
|
||||||
|
EXPECT_FALSE(s.is_dragging());
|
||||||
|
EXPECT_TRUE(s.can_set_value_public());
|
||||||
|
ASSERT_EQ(s.signaled.size(), 3);
|
||||||
|
EXPECT_DOUBLE_EQ(s.signaled.last().toDouble(), 17.0);
|
||||||
|
|
||||||
|
// Programmatic sets are accepted again
|
||||||
|
s.set_value(3.0);
|
||||||
|
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush the ladder's deleteLater and its queued drag-timer start only
|
||||||
|
// after the slider is gone, so a stray timer tick can't reach a ladder
|
||||||
|
// whose owner no longer exists
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NumericSliderBase, LadderDragClampsToRange)
|
||||||
|
{
|
||||||
|
LadderConfigGuard guard;
|
||||||
|
|
||||||
|
{
|
||||||
|
ProbeNumericSlider s;
|
||||||
|
s.set_ladder_element_count(2);
|
||||||
|
s.set_minimum_public(0.0);
|
||||||
|
s.set_maximum_public(8.0);
|
||||||
|
s.set_value(5.0);
|
||||||
|
|
||||||
|
press_label(s);
|
||||||
|
olive::SliderLadder *ladder = find_ladder();
|
||||||
|
ASSERT_NE(ladder, nullptr);
|
||||||
|
|
||||||
|
ASSERT_TRUE(QMetaObject::invokeMethod(
|
||||||
|
ladder, "dragged_by_value", Q_ARG(int, -10), Q_ARG(double, 1.0)));
|
||||||
|
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 0.0);
|
||||||
|
|
||||||
|
ASSERT_TRUE(QMetaObject::invokeMethod(
|
||||||
|
ladder, "dragged_by_value", Q_ARG(int, 100), Q_ARG(double, 1.0)));
|
||||||
|
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 8.0);
|
||||||
|
|
||||||
|
ladder->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NumericSliderBase, ClickWithoutDragShowsEditor)
|
||||||
|
{
|
||||||
|
LadderConfigGuard guard;
|
||||||
|
|
||||||
|
{
|
||||||
|
ProbeNumericSlider s;
|
||||||
|
s.set_ladder_element_count(2);
|
||||||
|
s.set_value(4.0);
|
||||||
|
|
||||||
|
press_label(s);
|
||||||
|
olive::SliderLadder *ladder = find_ladder();
|
||||||
|
ASSERT_NE(ladder, nullptr);
|
||||||
|
|
||||||
|
// A press/release with no drag in between is treated as a click and
|
||||||
|
// opens the line editor instead of changing the value
|
||||||
|
ladder->close();
|
||||||
|
EXPECT_FALSE(s.is_dragging());
|
||||||
|
EXPECT_TRUE(s.signaled.isEmpty());
|
||||||
|
|
||||||
|
olive::FocusableLineEdit *editor =
|
||||||
|
s.findChild<olive::FocusableLineEdit *>();
|
||||||
|
ASSERT_NE(editor, nullptr);
|
||||||
|
EXPECT_EQ(s.currentWidget(), editor);
|
||||||
|
EXPECT_EQ(editor->text(), QStringLiteral("4"));
|
||||||
|
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 4.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
QCoreApplication::processEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SliderLadder, BuildsElementPerPowerOfTen)
|
||||||
|
{
|
||||||
|
LadderConfigGuard guard;
|
||||||
|
|
||||||
|
olive::SliderLadder ladder(1.0, 2, QStringLiteral("9999"));
|
||||||
|
EXPECT_TRUE(ladder.windowFlags() & Qt::Popup);
|
||||||
|
|
||||||
|
const auto elements = ladder.findChildren<olive::SliderLadderElement *>();
|
||||||
|
ASSERT_EQ(elements.size(), 5);
|
||||||
|
|
||||||
|
// Coarse multipliers on top, the unit entry in the middle, fine below
|
||||||
|
EXPECT_DOUBLE_EQ(elements.at(0)->get_multiplier(), 100.0);
|
||||||
|
EXPECT_DOUBLE_EQ(elements.at(1)->get_multiplier(), 10.0);
|
||||||
|
EXPECT_DOUBLE_EQ(elements.at(2)->get_multiplier(), 1.0);
|
||||||
|
EXPECT_DOUBLE_EQ(elements.at(3)->get_multiplier(), 0.1);
|
||||||
|
EXPECT_DOUBLE_EQ(elements.at(4)->get_multiplier(), 0.01);
|
||||||
|
|
||||||
|
// Only the center entry starts highlighted
|
||||||
|
for (int i = 0; i < elements.size(); i++) {
|
||||||
|
const QPalette::ColorRole expected =
|
||||||
|
(i == 2) ? QPalette::Highlight : QPalette::Window;
|
||||||
|
EXPECT_EQ(elements.at(i)->backgroundRole(), expected) << i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SliderLadder, DragMultiplierScalesElements)
|
||||||
|
{
|
||||||
|
LadderConfigGuard guard;
|
||||||
|
|
||||||
|
olive::SliderLadder ladder(2.5, 1, QStringLiteral("9999"));
|
||||||
|
|
||||||
|
const auto elements = ladder.findChildren<olive::SliderLadderElement *>();
|
||||||
|
ASSERT_EQ(elements.size(), 3);
|
||||||
|
EXPECT_DOUBLE_EQ(elements.at(0)->get_multiplier(), 25.0);
|
||||||
|
EXPECT_DOUBLE_EQ(elements.at(1)->get_multiplier(), 2.5);
|
||||||
|
EXPECT_DOUBLE_EQ(elements.at(2)->get_multiplier(), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SliderLadder, SetValueShowsOnHighlightedElementOnly)
|
||||||
|
{
|
||||||
|
LadderConfigGuard guard;
|
||||||
|
|
||||||
|
olive::SliderLadder ladder(1.0, 1, QStringLiteral("9999"));
|
||||||
|
ladder.set_value(QStringLiteral("42"));
|
||||||
|
|
||||||
|
const auto elements = ladder.findChildren<olive::SliderLadderElement *>();
|
||||||
|
ASSERT_EQ(elements.size(), 3);
|
||||||
|
|
||||||
|
for (int i = 0; i < elements.size(); i++) {
|
||||||
|
QLabel *label = elements.at(i)->findChild<QLabel *>();
|
||||||
|
ASSERT_NE(label, nullptr);
|
||||||
|
if (i == 1) {
|
||||||
|
EXPECT_EQ(label->text(), QStringLiteral("1\n42"));
|
||||||
|
} else {
|
||||||
|
EXPECT_FALSE(label->text().contains(QStringLiteral("42")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SliderLadder, CloseEmitsReleased)
|
||||||
|
{
|
||||||
|
LadderConfigGuard guard;
|
||||||
|
|
||||||
|
olive::SliderLadder ladder(1.0, 1, QStringLiteral("9999"));
|
||||||
|
ladder.show();
|
||||||
|
|
||||||
|
QSignalSpy spy(&ladder, &olive::SliderLadder::released);
|
||||||
|
ladder.close();
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SliderLadder, SingleElementHidesMultiplier)
|
||||||
|
{
|
||||||
|
LadderConfigGuard guard;
|
||||||
|
|
||||||
|
// With no outer values the ladder is a single readout, and its multiplier
|
||||||
|
// is hidden since there's nothing to switch between
|
||||||
|
olive::SliderLadder ladder(1.0, 0, QStringLiteral("9999"));
|
||||||
|
|
||||||
|
const auto elements = ladder.findChildren<olive::SliderLadderElement *>();
|
||||||
|
ASSERT_EQ(elements.size(), 1);
|
||||||
|
|
||||||
|
ladder.set_value(QStringLiteral("7"));
|
||||||
|
|
||||||
|
QLabel *label = elements.first()->findChild<QLabel *>();
|
||||||
|
ASSERT_NE(label, nullptr);
|
||||||
|
EXPECT_EQ(label->text(), QStringLiteral("7"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SliderLadderElement, LabelReflectsHighlightAndMultiplierVisibility)
|
||||||
|
{
|
||||||
|
olive::SliderLadderElement e(2.5, QStringLiteral("9999"));
|
||||||
|
QLabel *label = e.findChild<QLabel *>();
|
||||||
|
ASSERT_NE(label, nullptr);
|
||||||
|
|
||||||
|
// Unhighlighted: only the multiplier shows, the value slot stays empty
|
||||||
|
EXPECT_EQ(label->text(), QStringLiteral("2.5\n"));
|
||||||
|
EXPECT_EQ(e.backgroundRole(), QPalette::Window);
|
||||||
|
|
||||||
|
e.set_value(QStringLiteral("9"));
|
||||||
|
EXPECT_EQ(label->text(), QStringLiteral("2.5\n"));
|
||||||
|
|
||||||
|
e.set_highlighted(true);
|
||||||
|
EXPECT_EQ(label->text(), QStringLiteral("2.5\n9"));
|
||||||
|
EXPECT_EQ(e.backgroundRole(), QPalette::Highlight);
|
||||||
|
|
||||||
|
e.set_highlighted(false);
|
||||||
|
EXPECT_EQ(e.backgroundRole(), QPalette::Window);
|
||||||
|
|
||||||
|
e.set_multiplier_visible(false);
|
||||||
|
EXPECT_EQ(label->text(), QStringLiteral("9"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QSignalSpy>
|
||||||
|
|
||||||
|
#include "render/videoparams.h"
|
||||||
|
#include "ui/humanstrings.h"
|
||||||
|
#include "widget/standardcombos/standardcombos.h"
|
||||||
|
|
||||||
|
// These tests complement widget_combos_test.cpp, which already covers item
|
||||||
|
// counts and basic value round-trips. Here we cover item text/data contents,
|
||||||
|
// signal emissions, and the stateful edge cases (custom entries, restore
|
||||||
|
// behavior, language changes).
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// QComboBox::currentIndexChanged is overloaded; spy on the int form
|
||||||
|
QSignalSpy *index_spy(QComboBox *combo)
|
||||||
|
{
|
||||||
|
return new QSignalSpy(combo,
|
||||||
|
static_cast<void (QComboBox::*)(int)>(
|
||||||
|
&QComboBox::currentIndexChanged));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, SampleRateTextsAndDataMatchHumanStrings)
|
||||||
|
{
|
||||||
|
olive::SampleRateComboBox combo;
|
||||||
|
|
||||||
|
ASSERT_EQ(combo.count(),
|
||||||
|
int(olive::AudioParams::k_supported_sample_rates.size()));
|
||||||
|
|
||||||
|
for (int i = 0; i < combo.count(); i++) {
|
||||||
|
const int rate = olive::AudioParams::k_supported_sample_rates.at(i);
|
||||||
|
EXPECT_EQ(combo.itemData(i).toInt(), rate);
|
||||||
|
EXPECT_EQ(combo.itemText(i),
|
||||||
|
olive::HumanStrings::sample_rate_to_string(rate));
|
||||||
|
EXPECT_FALSE(combo.itemText(i).isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, SampleRateSetEmitsIndexChanged)
|
||||||
|
{
|
||||||
|
olive::SampleRateComboBox combo;
|
||||||
|
combo.set_sample_rate(olive::AudioParams::k_supported_sample_rates.back());
|
||||||
|
|
||||||
|
QSignalSpy *spy = index_spy(&combo);
|
||||||
|
combo.set_sample_rate(olive::AudioParams::k_supported_sample_rates.front());
|
||||||
|
|
||||||
|
ASSERT_EQ(spy->count(), 1);
|
||||||
|
EXPECT_EQ(spy->first().first().toInt(), 0);
|
||||||
|
delete spy;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, SampleRateUnknownRateLeavesSelection)
|
||||||
|
{
|
||||||
|
olive::SampleRateComboBox combo;
|
||||||
|
combo.set_sample_rate(olive::AudioParams::k_supported_sample_rates.back());
|
||||||
|
|
||||||
|
int sentinel = 7;
|
||||||
|
while (combo.findData(sentinel) != -1) {
|
||||||
|
sentinel++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int old_index = combo.currentIndex();
|
||||||
|
combo.set_sample_rate(sentinel);
|
||||||
|
|
||||||
|
EXPECT_EQ(combo.currentIndex(), old_index);
|
||||||
|
EXPECT_EQ(combo.get_sample_rate(),
|
||||||
|
olive::AudioParams::k_supported_sample_rates.back());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, ChannelLayoutTextsAndDataMatchHumanStrings)
|
||||||
|
{
|
||||||
|
olive::ChannelLayoutComboBox combo;
|
||||||
|
|
||||||
|
ASSERT_EQ(combo.count(),
|
||||||
|
int(olive::AudioParams::k_supported_channel_layouts.size()));
|
||||||
|
|
||||||
|
for (int i = 0; i < combo.count(); i++) {
|
||||||
|
const uint64_t layout =
|
||||||
|
olive::AudioParams::k_supported_channel_layouts.at(i);
|
||||||
|
EXPECT_EQ(combo.itemData(i).toULongLong(), layout);
|
||||||
|
EXPECT_EQ(combo.itemText(i),
|
||||||
|
olive::HumanStrings::channel_layout_to_string(layout));
|
||||||
|
EXPECT_FALSE(combo.itemText(i).isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, ChannelLayoutSetEmitsIndexChanged)
|
||||||
|
{
|
||||||
|
olive::ChannelLayoutComboBox combo;
|
||||||
|
combo.set_channel_layout(
|
||||||
|
olive::AudioParams::k_supported_channel_layouts.back());
|
||||||
|
|
||||||
|
QSignalSpy *spy = index_spy(&combo);
|
||||||
|
combo.set_channel_layout(
|
||||||
|
olive::AudioParams::k_supported_channel_layouts.front());
|
||||||
|
|
||||||
|
ASSERT_EQ(spy->count(), 1);
|
||||||
|
EXPECT_EQ(spy->first().first().toInt(), 0);
|
||||||
|
delete spy;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, ChannelLayoutUnknownLayoutLeavesSelection)
|
||||||
|
{
|
||||||
|
olive::ChannelLayoutComboBox combo;
|
||||||
|
combo.set_channel_layout(
|
||||||
|
olive::AudioParams::k_supported_channel_layouts.back());
|
||||||
|
|
||||||
|
const uint64_t sentinel = ~uint64_t(0);
|
||||||
|
for (int i = 0; i < combo.count(); i++) {
|
||||||
|
ASSERT_NE(combo.itemData(i).toULongLong(), sentinel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const int old_index = combo.currentIndex();
|
||||||
|
combo.set_channel_layout(sentinel);
|
||||||
|
|
||||||
|
EXPECT_EQ(combo.currentIndex(), old_index);
|
||||||
|
EXPECT_EQ(combo.get_channel_layout(),
|
||||||
|
olive::AudioParams::k_supported_channel_layouts.back());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, InterlacedTextsMatchEnumOrder)
|
||||||
|
{
|
||||||
|
olive::InterlacedComboBox combo;
|
||||||
|
|
||||||
|
ASSERT_EQ(combo.count(), 3);
|
||||||
|
EXPECT_EQ(combo.itemText(int(olive::VideoParams::k_interlace_none)),
|
||||||
|
QStringLiteral("None (Progressive)"));
|
||||||
|
EXPECT_EQ(combo.itemText(int(olive::VideoParams::k_interlaced_top_first)),
|
||||||
|
QStringLiteral("Top-Field First"));
|
||||||
|
EXPECT_EQ(
|
||||||
|
combo.itemText(int(olive::VideoParams::k_interlaced_bottom_first)),
|
||||||
|
QStringLiteral("Bottom-Field First"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, InterlacedSetEmitsIndexChanged)
|
||||||
|
{
|
||||||
|
olive::InterlacedComboBox combo;
|
||||||
|
|
||||||
|
QSignalSpy *spy = index_spy(&combo);
|
||||||
|
combo.set_interlace_mode(olive::VideoParams::k_interlaced_bottom_first);
|
||||||
|
|
||||||
|
ASSERT_EQ(spy->count(), 1);
|
||||||
|
EXPECT_EQ(spy->first().first().toInt(),
|
||||||
|
int(olive::VideoParams::k_interlaced_bottom_first));
|
||||||
|
delete spy;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, PixelAspectRatioEntriesCarryNamesAndRatios)
|
||||||
|
{
|
||||||
|
olive::PixelAspectRatioComboBox combo;
|
||||||
|
|
||||||
|
const QVector<olive::Rational> &standards =
|
||||||
|
olive::VideoParams::k_standard_pixel_aspects;
|
||||||
|
const QStringList names =
|
||||||
|
olive::VideoParams::get_standard_pixel_aspect_ratio_names();
|
||||||
|
ASSERT_EQ(combo.count(), standards.size() + 1);
|
||||||
|
ASSERT_EQ(names.size(), standards.size());
|
||||||
|
|
||||||
|
for (int i = 0; i < standards.size(); i++) {
|
||||||
|
EXPECT_EQ(combo.itemData(i).value<olive::Rational>(), standards.at(i));
|
||||||
|
EXPECT_EQ(combo.itemText(i), names.at(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default selection is the first standard ratio
|
||||||
|
EXPECT_EQ(combo.currentIndex(), 0);
|
||||||
|
EXPECT_EQ(combo.get_pixel_aspect_ratio(), standards.first());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, PixelAspectRatioCustomEntryDefaultsToSquare)
|
||||||
|
{
|
||||||
|
olive::PixelAspectRatioComboBox combo;
|
||||||
|
|
||||||
|
const int last = combo.count() - 1;
|
||||||
|
EXPECT_EQ(combo.itemText(last), QStringLiteral("Custom..."));
|
||||||
|
|
||||||
|
// A null custom ratio is backed by 1:1 so it can never produce a 0 PAR
|
||||||
|
const olive::Rational data =
|
||||||
|
combo.itemData(last).value<olive::Rational>();
|
||||||
|
EXPECT_DOUBLE_EQ(data.to_double(), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, PixelAspectRatioCustomSetRenamesLastEntry)
|
||||||
|
{
|
||||||
|
olive::PixelAspectRatioComboBox combo;
|
||||||
|
|
||||||
|
const QVector<olive::Rational> &standards =
|
||||||
|
olive::VideoParams::k_standard_pixel_aspects;
|
||||||
|
const int last = combo.count() - 1;
|
||||||
|
|
||||||
|
const olive::Rational custom(17, 13);
|
||||||
|
combo.set_pixel_aspect_ratio(custom);
|
||||||
|
|
||||||
|
EXPECT_EQ(combo.currentIndex(), last);
|
||||||
|
EXPECT_TRUE(
|
||||||
|
combo.itemText(last).startsWith(QStringLiteral("Custom (")));
|
||||||
|
EXPECT_EQ(combo.itemData(last).value<olive::Rational>(), custom);
|
||||||
|
|
||||||
|
// Returning to a standard ratio selects it but keeps the custom label
|
||||||
|
combo.set_pixel_aspect_ratio(standards.first());
|
||||||
|
EXPECT_EQ(combo.currentIndex(), 0);
|
||||||
|
EXPECT_EQ(combo.get_pixel_aspect_ratio(), standards.first());
|
||||||
|
EXPECT_TRUE(
|
||||||
|
combo.itemText(last).startsWith(QStringLiteral("Custom (")));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, PixelAspectRatioSelectingStandardDoesNotPrompt)
|
||||||
|
{
|
||||||
|
olive::PixelAspectRatioComboBox combo;
|
||||||
|
|
||||||
|
const QVector<olive::Rational> &standards =
|
||||||
|
olive::VideoParams::k_standard_pixel_aspects;
|
||||||
|
ASSERT_GE(standards.size(), 2);
|
||||||
|
|
||||||
|
// A user-style index change to a standard entry must not open the
|
||||||
|
// custom-ratio dialog (selecting the last entry would, so it is
|
||||||
|
// intentionally not exercised under the offscreen platform)
|
||||||
|
combo.setCurrentIndex(1);
|
||||||
|
|
||||||
|
EXPECT_EQ(combo.get_pixel_aspect_ratio(), standards.at(1));
|
||||||
|
EXPECT_EQ(combo.itemText(combo.count() - 1),
|
||||||
|
QStringLiteral("Custom..."));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, PixelFormatEntriesOrderedWithNames)
|
||||||
|
{
|
||||||
|
olive::PixelFormatComboBox combo(false);
|
||||||
|
|
||||||
|
// Preview formats u8..f32 are added in enum order
|
||||||
|
ASSERT_EQ(combo.count(), int(olive::core::PixelFormat::count));
|
||||||
|
for (int i = 0; i < combo.count(); i++) {
|
||||||
|
EXPECT_EQ(combo.itemData(i).toInt(), i);
|
||||||
|
const auto fmt = static_cast<olive::core::PixelFormat::Format>(i);
|
||||||
|
EXPECT_EQ(combo.itemText(i),
|
||||||
|
olive::VideoParams::get_format_name(fmt));
|
||||||
|
EXPECT_FALSE(combo.itemText(i).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default selection is the first (u8) format
|
||||||
|
EXPECT_EQ(static_cast<olive::core::PixelFormat::Format>(
|
||||||
|
combo.get_pixel_format()),
|
||||||
|
olive::core::PixelFormat::u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, PixelFormatSetEmitsIndexChanged)
|
||||||
|
{
|
||||||
|
olive::PixelFormatComboBox combo(false);
|
||||||
|
|
||||||
|
QSignalSpy *spy = index_spy(&combo);
|
||||||
|
combo.set_pixel_format(olive::core::PixelFormat::f32);
|
||||||
|
|
||||||
|
ASSERT_EQ(spy->count(), 1);
|
||||||
|
EXPECT_EQ(spy->first().first().toInt(),
|
||||||
|
int(olive::core::PixelFormat::f32));
|
||||||
|
delete spy;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, PixelFormatFloatOnlyIgnoresIntegerFormat)
|
||||||
|
{
|
||||||
|
olive::PixelFormatComboBox combo(true);
|
||||||
|
ASSERT_GT(combo.count(), 0);
|
||||||
|
|
||||||
|
// u8 is not a float format, so it cannot be selected here
|
||||||
|
combo.set_pixel_format(olive::core::PixelFormat::u8);
|
||||||
|
|
||||||
|
olive::core::PixelFormat selected =
|
||||||
|
static_cast<olive::core::PixelFormat::Format>(
|
||||||
|
combo.get_pixel_format());
|
||||||
|
EXPECT_TRUE(selected.is_float());
|
||||||
|
EXPECT_EQ(combo.currentIndex(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, SampleFormatStartsEmpty)
|
||||||
|
{
|
||||||
|
olive::SampleFormatComboBox combo;
|
||||||
|
EXPECT_EQ(combo.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, SampleFormatAvailableFormatsPopulateInOrder)
|
||||||
|
{
|
||||||
|
using SampleFormat = olive::core::SampleFormat;
|
||||||
|
|
||||||
|
olive::SampleFormatComboBox combo;
|
||||||
|
|
||||||
|
const std::vector<SampleFormat> formats = {
|
||||||
|
SampleFormat::u8, SampleFormat::s16, SampleFormat::f32
|
||||||
|
};
|
||||||
|
combo.set_available_formats(formats);
|
||||||
|
|
||||||
|
ASSERT_EQ(combo.count(), int(formats.size()));
|
||||||
|
for (int i = 0; i < combo.count(); i++) {
|
||||||
|
EXPECT_EQ(combo.itemData(i).toInt(),
|
||||||
|
int(static_cast<SampleFormat::Format>(formats.at(i))));
|
||||||
|
EXPECT_EQ(combo.itemText(i),
|
||||||
|
olive::HumanStrings::format_to_string(formats.at(i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The first entry is selected by default
|
||||||
|
EXPECT_EQ(static_cast<SampleFormat::Format>(combo.get_sample_format()),
|
||||||
|
SampleFormat::u8);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, SampleFormatRestoreDisabledReselectsFirst)
|
||||||
|
{
|
||||||
|
using Format = olive::core::SampleFormat::Format;
|
||||||
|
|
||||||
|
olive::SampleFormatComboBox combo;
|
||||||
|
combo.set_attempt_to_restore_format(false);
|
||||||
|
|
||||||
|
combo.set_packed_formats();
|
||||||
|
combo.set_sample_format(olive::core::SampleFormat::f32);
|
||||||
|
ASSERT_EQ(static_cast<Format>(combo.get_sample_format()),
|
||||||
|
olive::core::SampleFormat::f32);
|
||||||
|
|
||||||
|
// With restore disabled, repopulating falls back to the first entry
|
||||||
|
combo.set_packed_formats();
|
||||||
|
EXPECT_EQ(combo.currentIndex(), 0);
|
||||||
|
EXPECT_EQ(static_cast<Format>(combo.get_sample_format()),
|
||||||
|
static_cast<Format>(olive::core::SampleFormat::packed_start));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, SampleFormatSetEmitsIndexChanged)
|
||||||
|
{
|
||||||
|
olive::SampleFormatComboBox combo;
|
||||||
|
combo.set_packed_formats();
|
||||||
|
|
||||||
|
QSignalSpy *spy = index_spy(&combo);
|
||||||
|
combo.set_sample_format(olive::core::SampleFormat::f32);
|
||||||
|
|
||||||
|
ASSERT_EQ(spy->count(), 1);
|
||||||
|
EXPECT_EQ(spy->first().first().toInt(),
|
||||||
|
int(olive::core::SampleFormat::f32) -
|
||||||
|
int(olive::core::SampleFormat::packed_start));
|
||||||
|
delete spy;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, VideoDividerTextsAndDataMatch)
|
||||||
|
{
|
||||||
|
olive::VideoDividerComboBox combo;
|
||||||
|
|
||||||
|
ASSERT_EQ(combo.count(), olive::VideoParams::k_supported_dividers.size());
|
||||||
|
|
||||||
|
for (int i = 0; i < combo.count(); i++) {
|
||||||
|
const int divider = olive::VideoParams::k_supported_dividers.at(i);
|
||||||
|
EXPECT_EQ(combo.itemData(i).toInt(), divider);
|
||||||
|
EXPECT_EQ(combo.itemText(i),
|
||||||
|
olive::VideoParams::get_name_for_divider(divider));
|
||||||
|
EXPECT_FALSE(combo.itemText(i).isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, VideoDividerSetEmitsIndexChanged)
|
||||||
|
{
|
||||||
|
olive::VideoDividerComboBox combo;
|
||||||
|
combo.set_divider(olive::VideoParams::k_supported_dividers.last());
|
||||||
|
|
||||||
|
QSignalSpy *spy = index_spy(&combo);
|
||||||
|
combo.set_divider(olive::VideoParams::k_supported_dividers.first());
|
||||||
|
|
||||||
|
ASSERT_EQ(spy->count(), 1);
|
||||||
|
EXPECT_EQ(spy->first().first().toInt(), 0);
|
||||||
|
delete spy;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, VideoDividerUnknownDividerLeavesSelection)
|
||||||
|
{
|
||||||
|
olive::VideoDividerComboBox combo;
|
||||||
|
combo.set_divider(olive::VideoParams::k_supported_dividers.last());
|
||||||
|
|
||||||
|
int sentinel = 3;
|
||||||
|
while (combo.findData(sentinel) != -1) {
|
||||||
|
sentinel += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int old_index = combo.currentIndex();
|
||||||
|
combo.set_divider(sentinel);
|
||||||
|
|
||||||
|
EXPECT_EQ(combo.currentIndex(), old_index);
|
||||||
|
EXPECT_EQ(combo.get_divider(),
|
||||||
|
olive::VideoParams::k_supported_dividers.last());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, FrameRateHasTrailingCustomEntry)
|
||||||
|
{
|
||||||
|
olive::FrameRateComboBox combo;
|
||||||
|
|
||||||
|
QComboBox *inner = combo.findChild<QComboBox *>();
|
||||||
|
ASSERT_NE(inner, nullptr);
|
||||||
|
|
||||||
|
const QVector<olive::Rational> &standards =
|
||||||
|
olive::VideoParams::k_supported_frame_rates;
|
||||||
|
ASSERT_EQ(inner->count(), standards.size() + 1);
|
||||||
|
|
||||||
|
for (int i = 0; i < standards.size(); i++) {
|
||||||
|
EXPECT_EQ(inner->itemData(i).value<olive::Rational>(),
|
||||||
|
standards.at(i));
|
||||||
|
EXPECT_EQ(inner->itemText(i),
|
||||||
|
olive::VideoParams::frame_rate_to_string(standards.at(i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_EQ(inner->itemText(standards.size()),
|
||||||
|
QStringLiteral("Custom..."));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, FrameRateProgrammaticSetDoesNotEmit)
|
||||||
|
{
|
||||||
|
olive::FrameRateComboBox combo;
|
||||||
|
|
||||||
|
QSignalSpy spy(&combo, &olive::FrameRateComboBox::frame_rate_changed);
|
||||||
|
|
||||||
|
combo.set_frame_rate(olive::VideoParams::k_supported_frame_rates.at(1));
|
||||||
|
combo.set_frame_rate(olive::Rational(27, 2));
|
||||||
|
combo.set_frame_rate(olive::VideoParams::k_supported_frame_rates.at(0));
|
||||||
|
|
||||||
|
EXPECT_EQ(spy.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, FrameRateUserSelectionEmitsFrameRateChanged)
|
||||||
|
{
|
||||||
|
olive::FrameRateComboBox combo;
|
||||||
|
|
||||||
|
QComboBox *inner = combo.findChild<QComboBox *>();
|
||||||
|
ASSERT_NE(inner, nullptr);
|
||||||
|
ASSERT_GE(olive::VideoParams::k_supported_frame_rates.size(), 3);
|
||||||
|
|
||||||
|
QSignalSpy spy(&combo, &olive::FrameRateComboBox::frame_rate_changed);
|
||||||
|
|
||||||
|
// Standard entries emit directly; only the last "Custom..." entry would
|
||||||
|
// open a modal input dialog, which is not exercised here
|
||||||
|
inner->setCurrentIndex(2);
|
||||||
|
|
||||||
|
ASSERT_EQ(spy.count(), 1);
|
||||||
|
EXPECT_EQ(spy.first().first().value<olive::Rational>(),
|
||||||
|
olive::VideoParams::k_supported_frame_rates.at(2));
|
||||||
|
EXPECT_EQ(combo.get_frame_rate(),
|
||||||
|
olive::VideoParams::k_supported_frame_rates.at(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, FrameRateCustomSetRenamesLastEntry)
|
||||||
|
{
|
||||||
|
olive::FrameRateComboBox combo;
|
||||||
|
|
||||||
|
QComboBox *inner = combo.findChild<QComboBox *>();
|
||||||
|
ASSERT_NE(inner, nullptr);
|
||||||
|
const int last = inner->count() - 1;
|
||||||
|
|
||||||
|
combo.set_frame_rate(olive::Rational(27, 2));
|
||||||
|
EXPECT_EQ(inner->currentIndex(), last);
|
||||||
|
EXPECT_TRUE(
|
||||||
|
inner->itemText(last).startsWith(QStringLiteral("Custom (")));
|
||||||
|
EXPECT_EQ(combo.get_frame_rate(), olive::Rational(27, 2));
|
||||||
|
|
||||||
|
// A second custom rate replaces the label's rate
|
||||||
|
combo.set_frame_rate(olive::Rational(29, 2));
|
||||||
|
EXPECT_EQ(inner->currentIndex(), last);
|
||||||
|
EXPECT_EQ(combo.get_frame_rate(), olive::Rational(29, 2));
|
||||||
|
|
||||||
|
// User-selecting a standard rate afterwards emits and returns it
|
||||||
|
QSignalSpy spy(&combo, &olive::FrameRateComboBox::frame_rate_changed);
|
||||||
|
inner->setCurrentIndex(0);
|
||||||
|
|
||||||
|
ASSERT_EQ(spy.count(), 1);
|
||||||
|
EXPECT_EQ(spy.first().first().value<olive::Rational>(),
|
||||||
|
olive::VideoParams::k_supported_frame_rates.first());
|
||||||
|
EXPECT_EQ(combo.get_frame_rate(),
|
||||||
|
olive::VideoParams::k_supported_frame_rates.first());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(WidgetStandardCombos, FrameRateLanguageChangePreservesCustom)
|
||||||
|
{
|
||||||
|
olive::FrameRateComboBox combo;
|
||||||
|
|
||||||
|
combo.set_frame_rate(olive::Rational(27, 2));
|
||||||
|
|
||||||
|
QEvent ev(QEvent::LanguageChange);
|
||||||
|
QApplication::sendEvent(&combo, &ev);
|
||||||
|
|
||||||
|
QComboBox *inner = combo.findChild<QComboBox *>();
|
||||||
|
ASSERT_NE(inner, nullptr);
|
||||||
|
|
||||||
|
const QVector<olive::Rational> &standards =
|
||||||
|
olive::VideoParams::k_supported_frame_rates;
|
||||||
|
ASSERT_EQ(inner->count(), standards.size() + 1);
|
||||||
|
EXPECT_EQ(inner->currentIndex(), standards.size());
|
||||||
|
EXPECT_EQ(combo.get_frame_rate(), olive::Rational(27, 2));
|
||||||
|
EXPECT_TRUE(inner->itemText(standards.size())
|
||||||
|
.startsWith(QStringLiteral("Custom (")));
|
||||||
|
}
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <QImage>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QSignalSpy>
|
||||||
|
#include <QTest>
|
||||||
|
#include <QWheelEvent>
|
||||||
|
|
||||||
|
#include "core.h"
|
||||||
|
#include "node/output/viewer/viewer.h"
|
||||||
|
#include "timeline/timelinemarker.h"
|
||||||
|
#include "timeline/timelineworkarea.h"
|
||||||
|
#include "widget/resizablescrollbar/resizabletimelinescrollbar.h"
|
||||||
|
#include "widget/timebased/timebasedview.h"
|
||||||
|
#include "widget/timebased/timebasedviewselectionmanager.h"
|
||||||
|
#include "widget/timebased/timescaledobject.h"
|
||||||
|
#include "widget/timetarget/timetarget.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// HandMovableView (base of TimeBasedView) connects to Core::instance() at
|
||||||
|
// construction, so the application singleton must exist
|
||||||
|
void ensure_core()
|
||||||
|
{
|
||||||
|
if (!olive::Core::instance()) {
|
||||||
|
new olive::Core(); // intentionally leaked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TimeScaledObject is not a QObject and its interesting hooks are protected,
|
||||||
|
// so expose them through a probe
|
||||||
|
class ProbeScaledObject : public olive::TimeScaledObject {
|
||||||
|
public:
|
||||||
|
int timebase_events = 0;
|
||||||
|
olive::Rational last_timebase;
|
||||||
|
int scale_events = 0;
|
||||||
|
double last_scale = 0.0;
|
||||||
|
|
||||||
|
void pub_set_minimum_scale(const double &min)
|
||||||
|
{
|
||||||
|
set_minimum_scale(min);
|
||||||
|
}
|
||||||
|
void pub_set_maximum_scale(const double &max)
|
||||||
|
{
|
||||||
|
set_maximum_scale(max);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void TimebaseChangedEvent(const olive::Rational &tb) override
|
||||||
|
{
|
||||||
|
timebase_events++;
|
||||||
|
last_timebase = tb;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ScaleChangedEvent(const double &scale) override
|
||||||
|
{
|
||||||
|
scale_events++;
|
||||||
|
last_scale = scale;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Records the y-axis hook and exposes the protected playhead/zoom entry
|
||||||
|
// points of TimeBasedView
|
||||||
|
class ProbeTimeBasedView : public olive::TimeBasedView {
|
||||||
|
public:
|
||||||
|
int y_scale_events = 0;
|
||||||
|
double last_y_scale = 0.0;
|
||||||
|
std::vector<void *> select_events;
|
||||||
|
std::vector<void *> deselect_events;
|
||||||
|
|
||||||
|
void pub_set_y_axis_enabled(bool e)
|
||||||
|
{
|
||||||
|
set_y_axis_enabled(e);
|
||||||
|
}
|
||||||
|
void pub_zoom(QWheelEvent *e, double multiplier, const QPointF &pos)
|
||||||
|
{
|
||||||
|
zoom_into_cursor_position(e, multiplier, pos);
|
||||||
|
}
|
||||||
|
bool pub_playhead_press(QMouseEvent *e)
|
||||||
|
{
|
||||||
|
return playhead_press(e);
|
||||||
|
}
|
||||||
|
bool pub_playhead_move(QMouseEvent *e)
|
||||||
|
{
|
||||||
|
return playhead_move(e);
|
||||||
|
}
|
||||||
|
bool pub_playhead_release(QMouseEvent *e)
|
||||||
|
{
|
||||||
|
return playhead_release(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SelectionManagerSelectEvent(void *obj) override
|
||||||
|
{
|
||||||
|
select_events.push_back(obj);
|
||||||
|
}
|
||||||
|
void SelectionManagerDeselectEvent(void *obj) override
|
||||||
|
{
|
||||||
|
deselect_events.push_back(obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void VerticalScaleChangedEvent(double scale) override
|
||||||
|
{
|
||||||
|
y_scale_events++;
|
||||||
|
last_y_scale = scale;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stand-in selectable object for the templated selection manager; the drag
|
||||||
|
// paths (which need engine keyframe/marker free functions) are never
|
||||||
|
// instantiated by these tests
|
||||||
|
struct FakeSelectable {
|
||||||
|
};
|
||||||
|
|
||||||
|
// Records the protected TimeTargetObject hooks
|
||||||
|
class ProbeTimeTarget : public olive::TimeTargetObject {
|
||||||
|
public:
|
||||||
|
std::vector<OakEngineNode *> connected;
|
||||||
|
std::vector<OakEngineNode *> disconnected;
|
||||||
|
std::vector<OakEngineNode *> changed;
|
||||||
|
|
||||||
|
protected:
|
||||||
|
void TimeTargetConnectEvent(OakEngineNode *n) override
|
||||||
|
{
|
||||||
|
connected.push_back(n);
|
||||||
|
}
|
||||||
|
void TimeTargetDisconnectEvent(OakEngineNode *n) override
|
||||||
|
{
|
||||||
|
disconnected.push_back(n);
|
||||||
|
}
|
||||||
|
void TimeTargetChangedEvent(OakEngineNode *n) override
|
||||||
|
{
|
||||||
|
changed.push_back(n);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
QMouseEvent make_press(const QPointF &pos, Qt::MouseButton button,
|
||||||
|
Qt::KeyboardModifiers mods = Qt::NoModifier)
|
||||||
|
{
|
||||||
|
return QMouseEvent(QEvent::MouseButtonPress, pos, pos, pos, button, button,
|
||||||
|
mods);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The selection manager hit-tests in unscaled scene coordinates: it maps the
|
||||||
|
// view-local click through mapToScene() and then unscale_point(). A hidden
|
||||||
|
// QGraphicsView centers its (zero-height) scene rect inside the viewport, so
|
||||||
|
// view-local coordinates are NOT scene coordinates; use the inverse mapping
|
||||||
|
// to aim a synthetic event at an unscaled scene point deterministically.
|
||||||
|
QPoint view_pos_for_unscaled_point(olive::TimeBasedView *view, const QPointF &p)
|
||||||
|
{
|
||||||
|
return view->mapFromScene(view->scale_point(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders a widget into a fresh image so two states can be compared
|
||||||
|
// pixel-for-pixel without needing the window to be exposed
|
||||||
|
QImage render_widget(QWidget *w)
|
||||||
|
{
|
||||||
|
QImage img(w->size(), QImage::Format_ARGB32);
|
||||||
|
img.fill(Qt::transparent);
|
||||||
|
w->render(&img);
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(TimeScaledObject, DefaultsAreUnitScaleAndNullTimebase)
|
||||||
|
{
|
||||||
|
olive::TimelineScaledWidget w;
|
||||||
|
EXPECT_DOUBLE_EQ(w.get_scale(), 1.0);
|
||||||
|
EXPECT_TRUE(w.timebase().isNull());
|
||||||
|
// NB: timebase_dbl() is only meaningful after set_timebase(); the
|
||||||
|
// constructor leaves it uninitialized, so it is not asserted here
|
||||||
|
EXPECT_GT(w.get_maximum_scale(), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeScaledObject, SetTimebaseCachesDoubleAndFiresEvent)
|
||||||
|
{
|
||||||
|
ProbeScaledObject o;
|
||||||
|
o.set_timebase(olive::Rational(1, 30));
|
||||||
|
|
||||||
|
EXPECT_EQ(o.timebase(), olive::Rational(1, 30));
|
||||||
|
EXPECT_NEAR(o.timebase_dbl(), 1.0 / 30.0, 1e-12);
|
||||||
|
EXPECT_EQ(o.timebase_events, 1);
|
||||||
|
EXPECT_EQ(o.last_timebase, olive::Rational(1, 30));
|
||||||
|
|
||||||
|
o.set_timebase(olive::Rational(1, 24));
|
||||||
|
EXPECT_EQ(o.timebase_events, 2);
|
||||||
|
EXPECT_NEAR(o.timebase_dbl(), 1.0 / 24.0, 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeScaledObject, ScaleClampsToMinimumAndMaximum)
|
||||||
|
{
|
||||||
|
ProbeScaledObject o;
|
||||||
|
o.pub_set_minimum_scale(0.5);
|
||||||
|
o.pub_set_maximum_scale(10.0);
|
||||||
|
|
||||||
|
// Default scale (1.0) is inside the new limits, so no clamp event yet
|
||||||
|
EXPECT_DOUBLE_EQ(o.get_scale(), 1.0);
|
||||||
|
|
||||||
|
o.set_scale(100.0);
|
||||||
|
EXPECT_DOUBLE_EQ(o.get_scale(), 10.0);
|
||||||
|
EXPECT_DOUBLE_EQ(o.last_scale, 10.0);
|
||||||
|
|
||||||
|
o.set_scale(0.01);
|
||||||
|
EXPECT_DOUBLE_EQ(o.get_scale(), 0.5);
|
||||||
|
EXPECT_DOUBLE_EQ(o.last_scale, 0.5);
|
||||||
|
|
||||||
|
o.set_scale(2.0);
|
||||||
|
EXPECT_DOUBLE_EQ(o.get_scale(), 2.0);
|
||||||
|
EXPECT_EQ(o.scale_events, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeScaledObject, ShrinkingLimitsPullScaleIntoRange)
|
||||||
|
{
|
||||||
|
ProbeScaledObject o;
|
||||||
|
o.set_scale(5.0);
|
||||||
|
|
||||||
|
// Lowering the maximum below the current scale clamps the scale down
|
||||||
|
o.pub_set_maximum_scale(2.0);
|
||||||
|
EXPECT_DOUBLE_EQ(o.get_scale(), 2.0);
|
||||||
|
|
||||||
|
// Raising the minimum above the current scale pushes the scale up
|
||||||
|
o.pub_set_maximum_scale(100.0);
|
||||||
|
o.pub_set_minimum_scale(4.0);
|
||||||
|
EXPECT_DOUBLE_EQ(o.get_scale(), 4.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeScaledObject, InvertedLimitsNeverBreakScaleClamp)
|
||||||
|
{
|
||||||
|
ProbeScaledObject o;
|
||||||
|
o.set_scale(5.0);
|
||||||
|
|
||||||
|
// Narrow the valid range to [2, 3]
|
||||||
|
o.pub_set_maximum_scale(3.0);
|
||||||
|
o.pub_set_minimum_scale(2.0);
|
||||||
|
EXPECT_DOUBLE_EQ(o.get_scale(), 3.0);
|
||||||
|
|
||||||
|
// Raising the minimum past the maximum pulls the maximum up with it, so
|
||||||
|
// the scale lands exactly on the new minimum instead of hitting an
|
||||||
|
// inverted std::clamp (undefined behavior)
|
||||||
|
o.pub_set_minimum_scale(10.0);
|
||||||
|
EXPECT_DOUBLE_EQ(o.get_maximum_scale(), 10.0);
|
||||||
|
EXPECT_DOUBLE_EQ(o.get_scale(), 10.0);
|
||||||
|
|
||||||
|
// Lowering the maximum past the minimum pushes the minimum down instead
|
||||||
|
o.pub_set_maximum_scale(4.0);
|
||||||
|
EXPECT_DOUBLE_EQ(o.get_maximum_scale(), 4.0);
|
||||||
|
EXPECT_DOUBLE_EQ(o.get_scale(), 4.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeScaledObject, TimeToSceneScalesTime)
|
||||||
|
{
|
||||||
|
olive::TimelineScaledWidget w;
|
||||||
|
|
||||||
|
// Null timebase maps everything to zero
|
||||||
|
EXPECT_DOUBLE_EQ(w.time_to_scene(olive::Rational(10)), 0.0);
|
||||||
|
|
||||||
|
w.set_timebase(olive::Rational(1, 30));
|
||||||
|
w.set_scale(30.0);
|
||||||
|
|
||||||
|
// time_to_scene is time in seconds multiplied by the scale
|
||||||
|
EXPECT_DOUBLE_EQ(w.time_to_scene(olive::Rational(1, 30)), 1.0);
|
||||||
|
EXPECT_DOUBLE_EQ(w.time_to_scene(olive::Rational(2)), 60.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeScaledObject, SceneToTimeFloorsCeilsAndRounds)
|
||||||
|
{
|
||||||
|
olive::TimelineScaledWidget w;
|
||||||
|
w.set_timebase(olive::Rational(1, 30));
|
||||||
|
w.set_scale(30.0);
|
||||||
|
|
||||||
|
// 45.5 px / 30 px-per-frame = 45.5 frames
|
||||||
|
// floor for positive, ceil for negative, nearest when rounding
|
||||||
|
EXPECT_EQ(w.scene_to_time(45.5), olive::Rational(45, 30));
|
||||||
|
EXPECT_EQ(w.scene_to_time(-10.5), olive::Rational(-10, 30));
|
||||||
|
EXPECT_EQ(w.scene_to_time(45.5, true), olive::Rational(46, 30));
|
||||||
|
|
||||||
|
// Exact grid points map exactly
|
||||||
|
EXPECT_EQ(w.scene_to_time(60.0), olive::Rational(2));
|
||||||
|
|
||||||
|
// Null timebase always yields a null time
|
||||||
|
olive::TimelineScaledWidget null_tb;
|
||||||
|
EXPECT_TRUE(null_tb.scene_to_time(123.0).isNull());
|
||||||
|
EXPECT_TRUE(
|
||||||
|
olive::TimeScaledObject::scene_to_time(10.0, 1.0, olive::Rational())
|
||||||
|
.isNull());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeScaledObject, SceneToTimeNoGridIgnoresTimebase)
|
||||||
|
{
|
||||||
|
// Static form is a pure scale division
|
||||||
|
const olive::Rational r =
|
||||||
|
olive::TimeScaledObject::scene_to_time_no_grid(3.0, 2.0);
|
||||||
|
EXPECT_NEAR(r.to_double(), 1.5, 1e-9);
|
||||||
|
|
||||||
|
// The instance form with a null timebase divides by the current scale
|
||||||
|
olive::TimelineScaledWidget w;
|
||||||
|
w.set_scale(2.0);
|
||||||
|
EXPECT_NEAR(w.scene_to_time_no_grid(3.0).to_double(), 1.5, 1e-9);
|
||||||
|
|
||||||
|
// A set timebase does not affect the no-grid conversion
|
||||||
|
w.set_timebase(olive::Rational(1, 30));
|
||||||
|
EXPECT_NEAR(w.scene_to_time_no_grid(3.0).to_double(), 1.5, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeScaledObject, ScaleFromDimensionsMath)
|
||||||
|
{
|
||||||
|
// (viewport / 10 * 9) / content
|
||||||
|
EXPECT_DOUBLE_EQ(
|
||||||
|
olive::TimeScaledObject::calculate_scale_from_dimensions(1000, 500),
|
||||||
|
1.8);
|
||||||
|
EXPECT_DOUBLE_EQ(
|
||||||
|
olive::TimeScaledObject::calculate_padding_from_dimension_scale(100),
|
||||||
|
5.0);
|
||||||
|
|
||||||
|
olive::TimelineScaledWidget w;
|
||||||
|
w.set_scale_from_dimensions(1000, 500);
|
||||||
|
EXPECT_DOUBLE_EQ(w.get_scale(), 1.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedView, ConstructionDefaults)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
olive::TimeBasedView view;
|
||||||
|
EXPECT_DOUBLE_EQ(view.get_scale(), 1.0);
|
||||||
|
EXPECT_DOUBLE_EQ(view.get_y_scale(), 1.0);
|
||||||
|
EXPECT_FALSE(view.is_snapped());
|
||||||
|
EXPECT_EQ(view.get_snap_service(), nullptr);
|
||||||
|
EXPECT_EQ(view.get_viewer_node(), nullptr);
|
||||||
|
EXPECT_FALSE(view.is_dragging_playhead());
|
||||||
|
EXPECT_EQ(view.dragMode(), QGraphicsView::NoDrag);
|
||||||
|
EXPECT_NE(view.scene(), nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedView, SnapEnableDisableTogglesState)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
olive::TimeBasedView view;
|
||||||
|
view.enable_snap({ olive::Rational(1), olive::Rational(2) });
|
||||||
|
EXPECT_TRUE(view.is_snapped());
|
||||||
|
|
||||||
|
view.disable_snap();
|
||||||
|
EXPECT_FALSE(view.is_snapped());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedView, YScaleEventOnlyFiresWhenAxisEnabled)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
ProbeTimeBasedView view;
|
||||||
|
|
||||||
|
// Y axis disabled: the value is stored but no event fires
|
||||||
|
view.set_y_scale(2.0);
|
||||||
|
EXPECT_DOUBLE_EQ(view.get_y_scale(), 2.0);
|
||||||
|
EXPECT_EQ(view.y_scale_events, 0);
|
||||||
|
|
||||||
|
view.pub_set_y_axis_enabled(true);
|
||||||
|
view.set_y_scale(3.0);
|
||||||
|
EXPECT_DOUBLE_EQ(view.get_y_scale(), 3.0);
|
||||||
|
EXPECT_EQ(view.y_scale_events, 1);
|
||||||
|
EXPECT_DOUBLE_EQ(view.last_y_scale, 3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedView, ScalePointAndUnscalePointAreInverses)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
olive::TimeBasedView view;
|
||||||
|
view.set_scale(2.0);
|
||||||
|
view.set_y_scale(4.0);
|
||||||
|
|
||||||
|
const QPointF scaled = view.scale_point(QPointF(1.5, 0.5));
|
||||||
|
EXPECT_DOUBLE_EQ(scaled.x(), 3.0);
|
||||||
|
EXPECT_DOUBLE_EQ(scaled.y(), 2.0);
|
||||||
|
|
||||||
|
const QPointF unscaled = view.unscale_point(scaled);
|
||||||
|
EXPECT_DOUBLE_EQ(unscaled.x(), 1.5);
|
||||||
|
EXPECT_DOUBLE_EQ(unscaled.y(), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedView, EndTimeDrivesSceneRectRightEdge)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
olive::TimeBasedView view;
|
||||||
|
view.resize(400, 300);
|
||||||
|
view.set_timebase(olive::Rational(1, 30));
|
||||||
|
view.set_scale(100.0);
|
||||||
|
view.set_end_time(olive::Rational(10));
|
||||||
|
|
||||||
|
// update_scene_rect pins the left edge to zero and puts the right edge
|
||||||
|
// one viewport width past the end of the content
|
||||||
|
QRectF rect = view.scene()->sceneRect();
|
||||||
|
EXPECT_DOUBLE_EQ(rect.left(), 0.0);
|
||||||
|
EXPECT_DOUBLE_EQ(rect.right(), 10.0 * 100.0 + view.width());
|
||||||
|
|
||||||
|
// Changing the scale re-runs update_scene_rect
|
||||||
|
view.set_scale(200.0);
|
||||||
|
rect = view.scene()->sceneRect();
|
||||||
|
EXPECT_DOUBLE_EQ(rect.right(), 10.0 * 200.0 + view.width());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedView, ZoomWithoutYAxisEmitsHorizontalScaleChange)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
ProbeTimeBasedView view;
|
||||||
|
view.resize(400, 300);
|
||||||
|
QSignalSpy spy(&view, &olive::TimeBasedView::scale_changed);
|
||||||
|
|
||||||
|
QWheelEvent wheel(QPointF(10, 10), QPointF(10, 10), QPoint(),
|
||||||
|
QPoint(0, 120), Qt::NoButton, Qt::NoModifier,
|
||||||
|
Qt::NoScrollPhase, false);
|
||||||
|
view.pub_zoom(&wheel, 2.0, QPointF(10, 10));
|
||||||
|
|
||||||
|
// The view only emits the requested scale; applying it is up to the
|
||||||
|
// parent widget, so the stored scale is unchanged
|
||||||
|
ASSERT_EQ(spy.count(), 1);
|
||||||
|
EXPECT_DOUBLE_EQ(spy.first().first().toDouble(), 2.0);
|
||||||
|
EXPECT_DOUBLE_EQ(view.get_scale(), 1.0);
|
||||||
|
EXPECT_DOUBLE_EQ(view.get_y_scale(), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedView, ZoomWithYAxisAndShiftOnlyScalesVertically)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
ProbeTimeBasedView view;
|
||||||
|
view.resize(400, 300);
|
||||||
|
view.pub_set_y_axis_enabled(true);
|
||||||
|
QSignalSpy spy(&view, &olive::TimeBasedView::scale_changed);
|
||||||
|
|
||||||
|
// Shift (without Alt) restricts the zoom to the vertical axis
|
||||||
|
QWheelEvent wheel(QPointF(10, 10), QPointF(10, 10), QPoint(),
|
||||||
|
QPoint(0, 120), Qt::NoButton, Qt::ShiftModifier,
|
||||||
|
Qt::NoScrollPhase, false);
|
||||||
|
view.pub_zoom(&wheel, 2.0, QPointF(10, 10));
|
||||||
|
|
||||||
|
EXPECT_EQ(spy.count(), 0);
|
||||||
|
EXPECT_DOUBLE_EQ(view.get_y_scale(), 2.0);
|
||||||
|
EXPECT_EQ(view.y_scale_events, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedView, ZoomWithYAxisShiftAndAltOnlyScalesHorizontally)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
ProbeTimeBasedView view;
|
||||||
|
view.resize(400, 300);
|
||||||
|
view.pub_set_y_axis_enabled(true);
|
||||||
|
QSignalSpy spy(&view, &olive::TimeBasedView::scale_changed);
|
||||||
|
|
||||||
|
QWheelEvent wheel(QPointF(10, 10), QPointF(10, 10), QPoint(),
|
||||||
|
QPoint(0, 120), Qt::NoButton,
|
||||||
|
Qt::ShiftModifier | Qt::AltModifier, Qt::NoScrollPhase,
|
||||||
|
false);
|
||||||
|
view.pub_zoom(&wheel, 2.0, QPointF(10, 10));
|
||||||
|
|
||||||
|
ASSERT_EQ(spy.count(), 1);
|
||||||
|
EXPECT_DOUBLE_EQ(spy.first().first().toDouble(), 2.0);
|
||||||
|
EXPECT_DOUBLE_EQ(view.get_y_scale(), 1.0);
|
||||||
|
EXPECT_EQ(view.y_scale_events, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedView, PlayheadDragRequiresPaintedPlayheadRect)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
ProbeTimeBasedView view;
|
||||||
|
view.resize(400, 300);
|
||||||
|
|
||||||
|
// The playhead hit rect is only populated by drawForeground; before any
|
||||||
|
// paint the rect is empty and nothing can grab the playhead
|
||||||
|
QMouseEvent press = make_press(QPointF(50, 50), Qt::LeftButton);
|
||||||
|
EXPECT_FALSE(view.pub_playhead_press(&press));
|
||||||
|
EXPECT_FALSE(view.is_dragging_playhead());
|
||||||
|
|
||||||
|
// Move/release are no-ops without an active drag
|
||||||
|
QMouseEvent move(QEvent::MouseMove, QPointF(60, 50), QPointF(60, 50),
|
||||||
|
QPointF(60, 50), Qt::NoButton, Qt::LeftButton,
|
||||||
|
Qt::NoModifier);
|
||||||
|
EXPECT_FALSE(view.pub_playhead_move(&move));
|
||||||
|
|
||||||
|
QMouseEvent release(QEvent::MouseButtonRelease, QPointF(60, 50),
|
||||||
|
QPointF(60, 50), QPointF(60, 50), Qt::LeftButton,
|
||||||
|
Qt::NoButton, Qt::NoModifier);
|
||||||
|
EXPECT_FALSE(view.pub_playhead_release(&release));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedView, SetViewerNodeStoresAndClears)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
olive::TimeBasedView view;
|
||||||
|
auto *viewer = new olive::ViewerOutput();
|
||||||
|
OakEngineNode *handle = reinterpret_cast<OakEngineNode *>(viewer);
|
||||||
|
|
||||||
|
view.set_viewer_node(handle);
|
||||||
|
EXPECT_EQ(view.get_viewer_node(), handle);
|
||||||
|
|
||||||
|
view.set_viewer_node(nullptr);
|
||||||
|
EXPECT_EQ(view.get_viewer_node(), nullptr);
|
||||||
|
|
||||||
|
delete viewer;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedViewSelectionManager, SelectDeselectSemantics)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
olive::TimeBasedView view;
|
||||||
|
olive::TimeBasedViewSelectionManager<FakeSelectable> mgr(&view);
|
||||||
|
|
||||||
|
FakeSelectable a, b;
|
||||||
|
EXPECT_TRUE(mgr.get_selected_objects().empty());
|
||||||
|
|
||||||
|
EXPECT_TRUE(mgr.select(&a));
|
||||||
|
EXPECT_TRUE(mgr.is_selected(&a));
|
||||||
|
EXPECT_FALSE(mgr.is_selected(&b));
|
||||||
|
|
||||||
|
// Re-selecting an already selected object is a no-op
|
||||||
|
EXPECT_FALSE(mgr.select(&a));
|
||||||
|
EXPECT_EQ(mgr.get_selected_objects().size(), 1u);
|
||||||
|
|
||||||
|
EXPECT_TRUE(mgr.select(&b));
|
||||||
|
EXPECT_EQ(mgr.get_selected_objects().size(), 2u);
|
||||||
|
|
||||||
|
EXPECT_TRUE(mgr.deselect(&a));
|
||||||
|
EXPECT_FALSE(mgr.is_selected(&a));
|
||||||
|
|
||||||
|
// Deselecting something not selected reports failure
|
||||||
|
EXPECT_FALSE(mgr.deselect(&a));
|
||||||
|
|
||||||
|
mgr.clear_selection();
|
||||||
|
EXPECT_TRUE(mgr.get_selected_objects().empty());
|
||||||
|
EXPECT_FALSE(mgr.is_selected(&b));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedViewSelectionManager, ObjectAtPointPrefersLastDrawn)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
olive::TimeBasedView view; // scale 1.0, y scale 1.0: scene == unscaled
|
||||||
|
olive::TimeBasedViewSelectionManager<FakeSelectable> mgr(&view);
|
||||||
|
|
||||||
|
FakeSelectable bottom, top;
|
||||||
|
mgr.declare_drawn_object(&bottom, QRectF(0, 0, 100, 100));
|
||||||
|
mgr.declare_drawn_object(&top, QRectF(50, 50, 100, 100));
|
||||||
|
|
||||||
|
// In the overlap the object drawn later (i.e. on top) wins
|
||||||
|
EXPECT_EQ(mgr.get_object_at_point(QPointF(75, 75)), &top);
|
||||||
|
// Outside the overlap the bottom object is hit
|
||||||
|
EXPECT_EQ(mgr.get_object_at_point(QPointF(25, 25)), &bottom);
|
||||||
|
// A complete miss returns nullptr
|
||||||
|
EXPECT_EQ(mgr.get_object_at_point(QPointF(500, 500)), nullptr);
|
||||||
|
|
||||||
|
mgr.clear_drawn_objects();
|
||||||
|
EXPECT_EQ(mgr.get_object_at_point(QPointF(25, 25)), nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedViewSelectionManager, ObjectAtPointMatchesDeclaredRectBounds)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
olive::TimeBasedView view; // scale 1.0, y scale 1.0: scene == unscaled
|
||||||
|
olive::TimeBasedViewSelectionManager<FakeSelectable> mgr(&view);
|
||||||
|
|
||||||
|
FakeSelectable item;
|
||||||
|
mgr.declare_drawn_object(&item, QRectF(10, 10, 30, 30));
|
||||||
|
|
||||||
|
// The rect spans scene coordinates [10, 40] on both axes
|
||||||
|
EXPECT_EQ(mgr.get_object_at_point(QPointF(10, 10)), &item);
|
||||||
|
EXPECT_EQ(mgr.get_object_at_point(QPointF(40, 40)), &item);
|
||||||
|
EXPECT_EQ(mgr.get_object_at_point(QPointF(41, 41)), nullptr);
|
||||||
|
EXPECT_EQ(mgr.get_object_at_point(QPointF(9, 20)), nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedViewSelectionManager, MousePressSelectsAndClears)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
ProbeTimeBasedView view;
|
||||||
|
olive::TimeBasedViewSelectionManager<FakeSelectable> mgr(&view);
|
||||||
|
|
||||||
|
FakeSelectable item;
|
||||||
|
mgr.declare_drawn_object(&item, QRectF(10, 10, 20, 20));
|
||||||
|
|
||||||
|
// Left click on the object selects it and notifies the view
|
||||||
|
QMouseEvent hit = make_press(
|
||||||
|
view_pos_for_unscaled_point(&view, QPointF(15, 15)), Qt::LeftButton);
|
||||||
|
EXPECT_EQ(mgr.mouse_press(&hit), &item);
|
||||||
|
EXPECT_TRUE(mgr.is_selected(&item));
|
||||||
|
ASSERT_EQ(view.select_events.size(), 1u);
|
||||||
|
EXPECT_EQ(view.select_events.at(0), &item);
|
||||||
|
|
||||||
|
// Left click on empty space clears the selection and returns nothing
|
||||||
|
QMouseEvent miss = make_press(
|
||||||
|
view_pos_for_unscaled_point(&view, QPointF(300, 300)), Qt::LeftButton);
|
||||||
|
EXPECT_EQ(mgr.mouse_press(&miss), nullptr);
|
||||||
|
EXPECT_TRUE(mgr.get_selected_objects().empty());
|
||||||
|
|
||||||
|
// Right click also selects
|
||||||
|
QMouseEvent right = make_press(
|
||||||
|
view_pos_for_unscaled_point(&view, QPointF(15, 15)), Qt::RightButton);
|
||||||
|
EXPECT_EQ(mgr.mouse_press(&right), &item);
|
||||||
|
EXPECT_TRUE(mgr.is_selected(&item));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedViewSelectionManager, ShiftClickTogglesWithoutClearing)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
ProbeTimeBasedView view;
|
||||||
|
olive::TimeBasedViewSelectionManager<FakeSelectable> mgr(&view);
|
||||||
|
|
||||||
|
FakeSelectable a, b;
|
||||||
|
mgr.declare_drawn_object(&a, QRectF(10, 10, 20, 20));
|
||||||
|
mgr.declare_drawn_object(&b, QRectF(100, 100, 20, 20));
|
||||||
|
|
||||||
|
// Plain click selects a
|
||||||
|
QMouseEvent press_a = make_press(
|
||||||
|
view_pos_for_unscaled_point(&view, QPointF(15, 15)), Qt::LeftButton);
|
||||||
|
mgr.mouse_press(&press_a);
|
||||||
|
ASSERT_TRUE(mgr.is_selected(&a));
|
||||||
|
|
||||||
|
// Shift-click on b adds it without clearing a
|
||||||
|
QMouseEvent shift_b =
|
||||||
|
make_press(view_pos_for_unscaled_point(&view, QPointF(105, 105)),
|
||||||
|
Qt::LeftButton, Qt::ShiftModifier);
|
||||||
|
EXPECT_EQ(mgr.mouse_press(&shift_b), &b);
|
||||||
|
EXPECT_TRUE(mgr.is_selected(&a));
|
||||||
|
EXPECT_TRUE(mgr.is_selected(&b));
|
||||||
|
|
||||||
|
// Shift-click on the already-selected a deselects it and reports no
|
||||||
|
// object under the cursor
|
||||||
|
QMouseEvent shift_a =
|
||||||
|
make_press(view_pos_for_unscaled_point(&view, QPointF(15, 15)),
|
||||||
|
Qt::LeftButton, Qt::ShiftModifier);
|
||||||
|
EXPECT_EQ(mgr.mouse_press(&shift_a), nullptr);
|
||||||
|
EXPECT_FALSE(mgr.is_selected(&a));
|
||||||
|
EXPECT_TRUE(mgr.is_selected(&b));
|
||||||
|
ASSERT_EQ(view.deselect_events.size(), 1u);
|
||||||
|
EXPECT_EQ(view.deselect_events.at(0), &a);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedViewSelectionManager, RubberBandSelectsIntersectingObjects)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
olive::TimeBasedView view;
|
||||||
|
view.resize(400, 300);
|
||||||
|
olive::TimeBasedViewSelectionManager<FakeSelectable> mgr(&view);
|
||||||
|
|
||||||
|
FakeSelectable inside, outside;
|
||||||
|
mgr.declare_drawn_object(&inside, QRectF(10, 10, 20, 20));
|
||||||
|
mgr.declare_drawn_object(&outside, QRectF(300, 300, 20, 20));
|
||||||
|
|
||||||
|
EXPECT_FALSE(mgr.is_rubber_banding());
|
||||||
|
|
||||||
|
QMouseEvent press = make_press(
|
||||||
|
view_pos_for_unscaled_point(&view, QPointF(0, 0)), Qt::LeftButton);
|
||||||
|
mgr.rubber_band_start(&press);
|
||||||
|
EXPECT_TRUE(mgr.is_rubber_banding());
|
||||||
|
|
||||||
|
mgr.rubber_band_move(view_pos_for_unscaled_point(&view, QPointF(100, 100)));
|
||||||
|
EXPECT_TRUE(mgr.is_selected(&inside));
|
||||||
|
EXPECT_FALSE(mgr.is_selected(&outside));
|
||||||
|
|
||||||
|
mgr.rubber_band_stop();
|
||||||
|
EXPECT_FALSE(mgr.is_rubber_banding());
|
||||||
|
|
||||||
|
// Stopping twice is harmless
|
||||||
|
mgr.rubber_band_stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeBasedViewSelectionManager, RubberBandKeepsPreselection)
|
||||||
|
{
|
||||||
|
ensure_core();
|
||||||
|
|
||||||
|
olive::TimeBasedView view;
|
||||||
|
view.resize(400, 300);
|
||||||
|
olive::TimeBasedViewSelectionManager<FakeSelectable> mgr(&view);
|
||||||
|
|
||||||
|
FakeSelectable pre, banded;
|
||||||
|
mgr.declare_drawn_object(&pre, QRectF(300, 300, 20, 20));
|
||||||
|
mgr.declare_drawn_object(&banded, QRectF(10, 10, 20, 20));
|
||||||
|
mgr.select(&pre);
|
||||||
|
|
||||||
|
QMouseEvent press = make_press(
|
||||||
|
view_pos_for_unscaled_point(&view, QPointF(0, 0)), Qt::LeftButton);
|
||||||
|
mgr.rubber_band_start(&press);
|
||||||
|
mgr.rubber_band_move(view_pos_for_unscaled_point(&view, QPointF(100, 100)));
|
||||||
|
|
||||||
|
// Objects selected before the band started stay selected
|
||||||
|
EXPECT_TRUE(mgr.is_selected(&pre));
|
||||||
|
EXPECT_TRUE(mgr.is_selected(&banded));
|
||||||
|
|
||||||
|
mgr.rubber_band_stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeTargetObject, DefaultsToNullTarget)
|
||||||
|
{
|
||||||
|
olive::TimeTargetObject target;
|
||||||
|
EXPECT_EQ(target.get_time_target(), nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeTargetObject, SetTimeTargetFiresEventSequence)
|
||||||
|
{
|
||||||
|
auto *node_a = new olive::ViewerOutput();
|
||||||
|
auto *node_b = new olive::ViewerOutput();
|
||||||
|
OakEngineNode *handle_a = reinterpret_cast<OakEngineNode *>(node_a);
|
||||||
|
OakEngineNode *handle_b = reinterpret_cast<OakEngineNode *>(node_b);
|
||||||
|
|
||||||
|
ProbeTimeTarget target;
|
||||||
|
|
||||||
|
// null -> a: changed + connect, no disconnect
|
||||||
|
target.set_time_target(handle_a);
|
||||||
|
EXPECT_EQ(target.get_time_target(), handle_a);
|
||||||
|
EXPECT_TRUE(target.disconnected.empty());
|
||||||
|
ASSERT_EQ(target.changed.size(), 1u);
|
||||||
|
EXPECT_EQ(target.changed.at(0), handle_a);
|
||||||
|
ASSERT_EQ(target.connected.size(), 1u);
|
||||||
|
EXPECT_EQ(target.connected.at(0), handle_a);
|
||||||
|
|
||||||
|
// a -> b: disconnect(a), changed(b), connect(b)
|
||||||
|
target.set_time_target(handle_b);
|
||||||
|
ASSERT_EQ(target.disconnected.size(), 1u);
|
||||||
|
EXPECT_EQ(target.disconnected.at(0), handle_a);
|
||||||
|
ASSERT_EQ(target.changed.size(), 2u);
|
||||||
|
EXPECT_EQ(target.changed.at(1), handle_b);
|
||||||
|
ASSERT_EQ(target.connected.size(), 2u);
|
||||||
|
EXPECT_EQ(target.connected.at(1), handle_b);
|
||||||
|
|
||||||
|
// b -> null: disconnect(b), changed(null), no connect
|
||||||
|
target.set_time_target(nullptr);
|
||||||
|
EXPECT_EQ(target.get_time_target(), nullptr);
|
||||||
|
ASSERT_EQ(target.disconnected.size(), 2u);
|
||||||
|
EXPECT_EQ(target.disconnected.at(1), handle_b);
|
||||||
|
ASSERT_EQ(target.changed.size(), 3u);
|
||||||
|
EXPECT_EQ(target.changed.at(2), nullptr);
|
||||||
|
EXPECT_EQ(target.connected.size(), 2u);
|
||||||
|
|
||||||
|
delete node_a;
|
||||||
|
delete node_b;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(TimeTargetObject, AdjustedTimePassesThroughWhenEitherNodeIsNull)
|
||||||
|
{
|
||||||
|
auto *node = new olive::ViewerOutput();
|
||||||
|
OakEngineNode *handle = reinterpret_cast<OakEngineNode *>(node);
|
||||||
|
|
||||||
|
olive::TimeTargetObject target;
|
||||||
|
target.set_path_index(1);
|
||||||
|
|
||||||
|
// Null source returns the input unchanged
|
||||||
|
EXPECT_EQ(target.get_adjusted_time(nullptr, handle, olive::Rational(5),
|
||||||
|
olive::k_transform_towards_output),
|
||||||
|
olive::Rational(5));
|
||||||
|
|
||||||
|
// Null target returns the input unchanged
|
||||||
|
EXPECT_EQ(target.get_adjusted_time(handle, nullptr, olive::Rational(7),
|
||||||
|
olive::k_transform_towards_input),
|
||||||
|
olive::Rational(7));
|
||||||
|
|
||||||
|
// Same for the TimeRange overload
|
||||||
|
const olive::TimeRange range(olive::Rational(2), olive::Rational(9));
|
||||||
|
const olive::TimeRange out = target.get_adjusted_time(
|
||||||
|
nullptr, nullptr, range, olive::k_transform_towards_output);
|
||||||
|
EXPECT_EQ(out.in(), olive::Rational(2));
|
||||||
|
EXPECT_EQ(out.out(), olive::Rational(9));
|
||||||
|
|
||||||
|
delete node;
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ResizableTimelineScrollBar, ConstructionDefaults)
|
||||||
|
{
|
||||||
|
olive::ResizableTimelineScrollBar bar;
|
||||||
|
EXPECT_EQ(bar.orientation(), Qt::Vertical);
|
||||||
|
EXPECT_EQ(bar.singleStep(), 20); // inherited from ResizableScrollBar
|
||||||
|
EXPECT_DOUBLE_EQ(bar.get_scale(), 1.0); // TimeScaledObject base
|
||||||
|
EXPECT_TRUE(bar.timebase().isNull());
|
||||||
|
|
||||||
|
olive::ResizableTimelineScrollBar hbar(Qt::Horizontal);
|
||||||
|
EXPECT_EQ(hbar.orientation(), Qt::Horizontal);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ResizableTimelineScrollBar, NullConnectionsAreSafe)
|
||||||
|
{
|
||||||
|
olive::ResizableTimelineScrollBar bar(Qt::Horizontal);
|
||||||
|
bar.connect_markers(nullptr);
|
||||||
|
bar.connect_work_area(nullptr);
|
||||||
|
bar.SetScale(2.0);
|
||||||
|
|
||||||
|
// Disconnecting when nothing was ever connected is equally safe
|
||||||
|
bar.connect_markers(nullptr);
|
||||||
|
bar.connect_work_area(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ResizableTimelineScrollBar, EnabledWorkAreaChangesPaintedPixels)
|
||||||
|
{
|
||||||
|
olive::ResizableTimelineScrollBar bar(Qt::Horizontal);
|
||||||
|
bar.resize(400, 20);
|
||||||
|
bar.set_timebase(olive::Rational(1, 30));
|
||||||
|
bar.set_scale(10.0);
|
||||||
|
|
||||||
|
olive::TimelineWorkArea workarea; // disabled by default
|
||||||
|
auto *handle = reinterpret_cast<OakEngineWorkarea *>(&workarea);
|
||||||
|
bar.connect_work_area(handle);
|
||||||
|
oakengine_workarea_set_range(handle, 1, 1, 10, 1);
|
||||||
|
|
||||||
|
const QImage disabled = render_widget(&bar);
|
||||||
|
|
||||||
|
oakengine_workarea_set_enabled(handle, 1);
|
||||||
|
const QImage enabled = render_widget(&bar);
|
||||||
|
EXPECT_NE(disabled, enabled);
|
||||||
|
|
||||||
|
// Disabling again restores the base scrollbar paint exactly
|
||||||
|
oakengine_workarea_set_enabled(handle, 0);
|
||||||
|
EXPECT_EQ(render_widget(&bar), disabled);
|
||||||
|
|
||||||
|
// Disconnecting the workarea also restores it while the workarea is on
|
||||||
|
oakengine_workarea_set_enabled(handle, 1);
|
||||||
|
bar.connect_work_area(nullptr);
|
||||||
|
EXPECT_EQ(render_widget(&bar), disabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ResizableTimelineScrollBar, MarkersChangePaintedPixels)
|
||||||
|
{
|
||||||
|
olive::ResizableTimelineScrollBar bar(Qt::Horizontal);
|
||||||
|
bar.resize(400, 20);
|
||||||
|
bar.set_timebase(olive::Rational(1, 30));
|
||||||
|
bar.set_scale(10.0);
|
||||||
|
|
||||||
|
const QImage clean = render_widget(&bar);
|
||||||
|
|
||||||
|
olive::TimelineMarkerList list;
|
||||||
|
auto *list_handle = reinterpret_cast<OakEngineMarkerList *>(&list);
|
||||||
|
bar.connect_markers(list_handle);
|
||||||
|
|
||||||
|
// Parenting a marker to the list registers it through the list's
|
||||||
|
// childEvent
|
||||||
|
auto *marker = new olive::TimelineMarker(
|
||||||
|
0, olive::TimeRange(olive::Rational(2), olive::Rational(5)),
|
||||||
|
QStringLiteral("m"), &list);
|
||||||
|
ASSERT_EQ(oakengine_marker_list_count(list_handle), 1);
|
||||||
|
|
||||||
|
const QImage with_marker = render_widget(&bar);
|
||||||
|
EXPECT_NE(clean, with_marker);
|
||||||
|
|
||||||
|
// Removing the marker restores the base paint. NB: the list only
|
||||||
|
// unregisters a marker on ChildRemoved, which must arrive while the
|
||||||
|
// marker is still fully constructed (its childEvent dynamic_casts the
|
||||||
|
// child to TimelineMarker), so reparent before deleting — the same
|
||||||
|
// order MarkerRemoveCommand uses.
|
||||||
|
marker->setParent(nullptr);
|
||||||
|
delete marker;
|
||||||
|
EXPECT_EQ(render_widget(&bar), clean);
|
||||||
|
|
||||||
|
// Disconnecting the list with a marker present also restores it
|
||||||
|
auto *marker2 = new olive::TimelineMarker(
|
||||||
|
0, olive::TimeRange(olive::Rational(2), olive::Rational(5)),
|
||||||
|
QStringLiteral("m2"), &list);
|
||||||
|
bar.connect_markers(nullptr);
|
||||||
|
EXPECT_EQ(render_widget(&bar), clean);
|
||||||
|
delete marker2;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,781 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QCloseEvent>
|
||||||
|
#include <QFontComboBox>
|
||||||
|
#include <QFontDatabase>
|
||||||
|
#include <QTextCursor>
|
||||||
|
#include <QImage>
|
||||||
|
#include <QMatrix4x4>
|
||||||
|
#include <QMouseEvent>
|
||||||
|
#include <QPainter>
|
||||||
|
#include <QPaintEvent>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QPushButton>
|
||||||
|
#include <QScrollBar>
|
||||||
|
#include <QSignalSpy>
|
||||||
|
#include <QTest>
|
||||||
|
|
||||||
|
#include "node/output/viewer/viewer.h"
|
||||||
|
#include "node/project/footage/footage.h"
|
||||||
|
#include "node/project/sequence/sequence.h"
|
||||||
|
#include "render/videoparams.h"
|
||||||
|
#include "widget/viewer/displaybuffer.h"
|
||||||
|
#include "widget/viewer/vieweroutpututils.h"
|
||||||
|
#include "widget/viewer/viewerplaybacktimer.h"
|
||||||
|
#include "widget/viewer/viewerpreventsleep.h"
|
||||||
|
#include "widget/viewer/viewersizer.h"
|
||||||
|
#include "widget/viewer/viewertexteditor.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// ViewerSizer's scrollbars are created in a fixed order (horizontal first),
|
||||||
|
// and findChildren preserves child creation order
|
||||||
|
QList<QScrollBar *> sizer_scrollbars(olive::ViewerSizer *sizer)
|
||||||
|
{
|
||||||
|
return sizer->findChildren<QScrollBar *>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ViewerTextEditorToolBar push buttons in creation order (see
|
||||||
|
// viewertexteditor.cpp): underline, strikethrough, color, align left/center/
|
||||||
|
// right/justify, align top/middle/bottom, small caps
|
||||||
|
QList<QPushButton *> toolbar_buttons(olive::ViewerTextEditorToolBar *toolbar)
|
||||||
|
{
|
||||||
|
return toolbar->findChildren<QPushButton *>();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ToolBarButton {
|
||||||
|
k_underline = 0,
|
||||||
|
k_strikethrough = 1,
|
||||||
|
k_color = 2,
|
||||||
|
k_align_left = 3,
|
||||||
|
k_align_center = 4,
|
||||||
|
k_align_right = 5,
|
||||||
|
k_align_justify = 6,
|
||||||
|
k_align_top = 7,
|
||||||
|
k_align_middle = 8,
|
||||||
|
k_align_bottom = 9,
|
||||||
|
k_small_caps = 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
QMatrix4x4 last_matrix(QSignalSpy &spy)
|
||||||
|
{
|
||||||
|
return spy.last().first().value<QMatrix4x4>();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(ViewerSizer, ScrollBarsStartHiddenAndZoomIsFit)
|
||||||
|
{
|
||||||
|
olive::ViewerSizer sizer;
|
||||||
|
const auto bars = sizer_scrollbars(&sizer);
|
||||||
|
ASSERT_EQ(bars.size(), 2);
|
||||||
|
EXPECT_EQ(bars.at(0)->orientation(), Qt::Horizontal);
|
||||||
|
EXPECT_EQ(bars.at(1)->orientation(), Qt::Vertical);
|
||||||
|
EXPECT_FALSE(bars.at(0)->isVisibleTo(&sizer));
|
||||||
|
EXPECT_FALSE(bars.at(1)->isVisibleTo(&sizer));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerSizer, ZeroChildSizeFillsContainerWithoutScaleRequest)
|
||||||
|
{
|
||||||
|
olive::ViewerSizer sizer;
|
||||||
|
sizer.resize(400, 300);
|
||||||
|
|
||||||
|
auto *child = new QWidget();
|
||||||
|
sizer.set_widget(child);
|
||||||
|
|
||||||
|
QSignalSpy scale_spy(&sizer, &olive::ViewerSizer::request_scale);
|
||||||
|
|
||||||
|
// width_/height_ default to 0, so the child simply fills the container
|
||||||
|
EXPECT_EQ(child->geometry(), QRect(0, 0, 400, 300));
|
||||||
|
EXPECT_EQ(scale_spy.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerSizer, SetWidgetDestroysPreviousWidget)
|
||||||
|
{
|
||||||
|
olive::ViewerSizer sizer;
|
||||||
|
sizer.resize(200, 200);
|
||||||
|
|
||||||
|
QPointer<QWidget> first = new QWidget();
|
||||||
|
sizer.set_widget(first);
|
||||||
|
ASSERT_FALSE(first.isNull());
|
||||||
|
EXPECT_EQ(first->parentWidget(), &sizer);
|
||||||
|
|
||||||
|
auto *second = new QWidget();
|
||||||
|
sizer.set_widget(second);
|
||||||
|
|
||||||
|
// set_widget() deletes the previously installed widget
|
||||||
|
EXPECT_TRUE(first.isNull());
|
||||||
|
EXPECT_EQ(second->parentWidget(), &sizer);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerSizer, WideContainerScalesByHeight)
|
||||||
|
{
|
||||||
|
olive::ViewerSizer sizer;
|
||||||
|
sizer.resize(400, 300);
|
||||||
|
sizer.set_widget(new QWidget());
|
||||||
|
|
||||||
|
QSignalSpy scale_spy(&sizer, &olive::ViewerSizer::request_scale);
|
||||||
|
|
||||||
|
// Square image in a 4:3 container: container is wider, so the matrix
|
||||||
|
// compresses X by sequence_aspect / container_aspect = 1 / (4/3)
|
||||||
|
sizer.set_child_size(100, 100);
|
||||||
|
|
||||||
|
ASSERT_GE(scale_spy.count(), 1);
|
||||||
|
const QMatrix4x4 m = last_matrix(scale_spy);
|
||||||
|
EXPECT_NEAR(m(0, 0), 0.75, 1e-9);
|
||||||
|
EXPECT_NEAR(m(1, 1), 1.0, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerSizer, TallContainerScalesByWidth)
|
||||||
|
{
|
||||||
|
olive::ViewerSizer sizer;
|
||||||
|
sizer.resize(300, 300);
|
||||||
|
sizer.set_widget(new QWidget());
|
||||||
|
|
||||||
|
QSignalSpy scale_spy(&sizer, &olive::ViewerSizer::request_scale);
|
||||||
|
|
||||||
|
// 2:1 image in a square container: container is taller, so the matrix
|
||||||
|
// compresses Y by container_aspect / sequence_aspect = 1 / 2
|
||||||
|
sizer.set_child_size(200, 100);
|
||||||
|
|
||||||
|
ASSERT_GE(scale_spy.count(), 1);
|
||||||
|
const QMatrix4x4 m = last_matrix(scale_spy);
|
||||||
|
EXPECT_NEAR(m(0, 0), 1.0, 1e-9);
|
||||||
|
EXPECT_NEAR(m(1, 1), 0.5, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerSizer, PixelAspectRatioFactorsIntoScale)
|
||||||
|
{
|
||||||
|
olive::ViewerSizer sizer;
|
||||||
|
sizer.resize(300, 300);
|
||||||
|
sizer.set_widget(new QWidget());
|
||||||
|
sizer.set_child_size(100, 100);
|
||||||
|
|
||||||
|
QSignalSpy scale_spy(&sizer, &olive::ViewerSizer::request_scale);
|
||||||
|
|
||||||
|
// 2:1 pixel aspect doubles the sequence aspect -> 2:1 in a square container
|
||||||
|
sizer.set_pixel_aspect_ratio(olive::Rational(2, 1));
|
||||||
|
ASSERT_GE(scale_spy.count(), 1);
|
||||||
|
QMatrix4x4 m = last_matrix(scale_spy);
|
||||||
|
EXPECT_NEAR(m(0, 0), 1.0, 1e-9);
|
||||||
|
EXPECT_NEAR(m(1, 1), 0.5, 1e-9);
|
||||||
|
|
||||||
|
// 1:2 pixel aspect halves it -> 1:2 image, container wider than image
|
||||||
|
sizer.set_pixel_aspect_ratio(olive::Rational(1, 2));
|
||||||
|
m = last_matrix(scale_spy);
|
||||||
|
EXPECT_NEAR(m(0, 0), 0.5, 1e-9);
|
||||||
|
EXPECT_NEAR(m(1, 1), 1.0, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerSizer, ExplicitZoomShowsScrollBars)
|
||||||
|
{
|
||||||
|
olive::ViewerSizer sizer;
|
||||||
|
sizer.resize(200, 200);
|
||||||
|
sizer.set_widget(new QWidget());
|
||||||
|
sizer.set_child_size(100, 100);
|
||||||
|
sizer.show();
|
||||||
|
ASSERT_TRUE(QTest::qWaitForWindowExposed(&sizer));
|
||||||
|
|
||||||
|
const auto bars = sizer_scrollbars(&sizer);
|
||||||
|
ASSERT_EQ(bars.size(), 2);
|
||||||
|
|
||||||
|
QSignalSpy scale_spy(&sizer, &olive::ViewerSizer::request_scale);
|
||||||
|
|
||||||
|
// 100px child at 400% is 400px > 200px container, so both bars appear
|
||||||
|
sizer.set_zoom(4.0);
|
||||||
|
EXPECT_TRUE(bars.at(0)->isVisible());
|
||||||
|
EXPECT_TRUE(bars.at(1)->isVisible());
|
||||||
|
|
||||||
|
// The zoom is folded into the requested scale on top of the fit scale
|
||||||
|
ASSERT_GE(scale_spy.count(), 1);
|
||||||
|
const QMatrix4x4 m = last_matrix(scale_spy);
|
||||||
|
EXPECT_GT(m(0, 0), 1.0);
|
||||||
|
EXPECT_GT(m(1, 1), 1.0);
|
||||||
|
|
||||||
|
// Back to fit: scrollbars disappear again
|
||||||
|
sizer.set_zoom(-1);
|
||||||
|
EXPECT_FALSE(bars.at(0)->isVisible());
|
||||||
|
EXPECT_FALSE(bars.at(1)->isVisible());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerSizer, ScrollBarMovementEmitsTranslate)
|
||||||
|
{
|
||||||
|
olive::ViewerSizer sizer;
|
||||||
|
sizer.resize(200, 200);
|
||||||
|
sizer.set_widget(new QWidget());
|
||||||
|
sizer.set_child_size(100, 100);
|
||||||
|
sizer.show();
|
||||||
|
ASSERT_TRUE(QTest::qWaitForWindowExposed(&sizer));
|
||||||
|
sizer.set_zoom(4.0);
|
||||||
|
|
||||||
|
const auto bars = sizer_scrollbars(&sizer);
|
||||||
|
ASSERT_EQ(bars.size(), 2);
|
||||||
|
QScrollBar *horiz = bars.at(0);
|
||||||
|
ASSERT_TRUE(horiz->isVisible());
|
||||||
|
ASSERT_GT(horiz->maximum(), 0);
|
||||||
|
|
||||||
|
QSignalSpy translate_spy(&sizer, &olive::ViewerSizer::request_translate);
|
||||||
|
|
||||||
|
// Scrolled to the far right the offset is negative. Go to the maximum
|
||||||
|
// first so the subsequent setValue(0) is a real change (QScrollBar only
|
||||||
|
// emits valueChanged, which drives scroll_bar_moved, on actual changes)
|
||||||
|
horiz->setValue(horiz->maximum());
|
||||||
|
ASSERT_GE(translate_spy.count(), 1);
|
||||||
|
const float at_max = last_matrix(translate_spy)(0, 3);
|
||||||
|
EXPECT_LT(at_max, 0.0f);
|
||||||
|
|
||||||
|
// Back at 0 the view centers on the left edge: positive X offset
|
||||||
|
translate_spy.clear();
|
||||||
|
horiz->setValue(0);
|
||||||
|
ASSERT_GE(translate_spy.count(), 1);
|
||||||
|
const float at_min = last_matrix(translate_spy)(0, 3);
|
||||||
|
EXPECT_GT(at_min, 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerSizer, HandDragMoveAdjustsVisibleScrollBars)
|
||||||
|
{
|
||||||
|
olive::ViewerSizer sizer;
|
||||||
|
sizer.resize(200, 200);
|
||||||
|
sizer.set_widget(new QWidget());
|
||||||
|
sizer.set_child_size(100, 100);
|
||||||
|
sizer.show();
|
||||||
|
ASSERT_TRUE(QTest::qWaitForWindowExposed(&sizer));
|
||||||
|
sizer.set_zoom(4.0);
|
||||||
|
|
||||||
|
const auto bars = sizer_scrollbars(&sizer);
|
||||||
|
ASSERT_EQ(bars.size(), 2);
|
||||||
|
bars.at(0)->setValue(50);
|
||||||
|
bars.at(1)->setValue(30);
|
||||||
|
|
||||||
|
// A drag of (+10, +5) pulls the content, decreasing both scroll values
|
||||||
|
sizer.hand_drag_move(10, 5);
|
||||||
|
EXPECT_EQ(bars.at(0)->value(), 40);
|
||||||
|
EXPECT_EQ(bars.at(1)->value(), 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerSizer, AnchoredZoomOutToFitResetsScrollPositions)
|
||||||
|
{
|
||||||
|
olive::ViewerSizer sizer;
|
||||||
|
sizer.resize(200, 200);
|
||||||
|
sizer.set_widget(new QWidget());
|
||||||
|
sizer.set_child_size(100, 100);
|
||||||
|
sizer.show();
|
||||||
|
ASSERT_TRUE(QTest::qWaitForWindowExposed(&sizer));
|
||||||
|
|
||||||
|
const auto bars = sizer_scrollbars(&sizer);
|
||||||
|
ASSERT_EQ(bars.size(), 2);
|
||||||
|
|
||||||
|
sizer.set_zoom_anchored(4.0, 10.0, 10.0);
|
||||||
|
ASSERT_TRUE(bars.at(0)->isVisible());
|
||||||
|
|
||||||
|
// A non-positive anchored zoom is the "fit" command: zoom clears and the
|
||||||
|
// scroll offsets reset
|
||||||
|
sizer.set_zoom_anchored(0.0, 0.0, 0.0);
|
||||||
|
EXPECT_FALSE(bars.at(0)->isVisible());
|
||||||
|
EXPECT_FALSE(bars.at(1)->isVisible());
|
||||||
|
EXPECT_EQ(bars.at(0)->value(), 0);
|
||||||
|
EXPECT_EQ(bars.at(1)->value(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerPlaybackTimer, ZeroSpeedKeepsStartTimestamp)
|
||||||
|
{
|
||||||
|
olive::ViewerPlaybackTimer timer;
|
||||||
|
timer.start(42, 0, 1.0 / 30.0);
|
||||||
|
|
||||||
|
// No elapsed real time can move the timestamp when the speed is zero
|
||||||
|
EXPECT_EQ(timer.get_timestamp_now(), 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerPlaybackTimer, HugeTimebaseFreezesFrameAdvance)
|
||||||
|
{
|
||||||
|
olive::ViewerPlaybackTimer timer;
|
||||||
|
// 1000-second frames: any sub-second test run still rounds to zero frames
|
||||||
|
timer.start(7, 3, 1000.0);
|
||||||
|
EXPECT_EQ(timer.get_timestamp_now(), 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerPlaybackTimer, NegativeSpeedAlsoStartsAtTimestamp)
|
||||||
|
{
|
||||||
|
olive::ViewerPlaybackTimer timer;
|
||||||
|
timer.start(100, -2, 1000.0);
|
||||||
|
EXPECT_EQ(timer.get_timestamp_now(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerPlaybackTimer, RestartReplacesAnchor)
|
||||||
|
{
|
||||||
|
olive::ViewerPlaybackTimer timer;
|
||||||
|
timer.start(1, 0, 1.0 / 30.0);
|
||||||
|
EXPECT_EQ(timer.get_timestamp_now(), 1);
|
||||||
|
|
||||||
|
timer.start(500, 0, 1.0 / 30.0);
|
||||||
|
EXPECT_EQ(timer.get_timestamp_now(), 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerPreventSleep, ToggleOnOffDoesNotCrash)
|
||||||
|
{
|
||||||
|
// No observable state is exposed; on macOS this creates/releases an IOPM
|
||||||
|
// assertion, elsewhere it talks to the OS sleep-inhibit service. Toggling
|
||||||
|
// twice exercises the released-assertion no-op path.
|
||||||
|
olive::prevent_sleep(true);
|
||||||
|
olive::prevent_sleep(false);
|
||||||
|
olive::prevent_sleep(false);
|
||||||
|
SUCCEED();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DisplayBuffer, NullHandlesAreSafeToWrapAndDestroy)
|
||||||
|
{
|
||||||
|
auto tex = olive::oak_make_shared_texture(nullptr);
|
||||||
|
EXPECT_EQ(tex->handle, nullptr);
|
||||||
|
EXPECT_EQ(tex->type, olive::OakSharedBuffer::k_texture);
|
||||||
|
|
||||||
|
auto frame = olive::oak_make_shared_frame(nullptr);
|
||||||
|
EXPECT_EQ(frame->handle, nullptr);
|
||||||
|
EXPECT_EQ(frame->type, olive::OakSharedBuffer::k_frame);
|
||||||
|
|
||||||
|
// Both facade free functions ignore nullptr, so destroying these is safe
|
||||||
|
tex.reset();
|
||||||
|
frame.reset();
|
||||||
|
SUCCEED();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DisplayBuffer, SharedCopiesExtendHandleLifetime)
|
||||||
|
{
|
||||||
|
void *raw = oakengine_codec_frame_create();
|
||||||
|
ASSERT_NE(raw, nullptr);
|
||||||
|
|
||||||
|
auto frame = olive::oak_make_shared_frame(raw);
|
||||||
|
EXPECT_EQ(frame->handle, raw);
|
||||||
|
EXPECT_EQ(frame.use_count(), 1);
|
||||||
|
|
||||||
|
{
|
||||||
|
auto copy = frame;
|
||||||
|
EXPECT_EQ(frame.use_count(), 2);
|
||||||
|
EXPECT_EQ(copy->handle, raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The copy is gone but the original still holds the handle
|
||||||
|
EXPECT_EQ(frame.use_count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(DisplayBuffer, RoundTripsThroughVariant)
|
||||||
|
{
|
||||||
|
auto frame = olive::oak_make_shared_frame(nullptr);
|
||||||
|
|
||||||
|
QVariant v = QVariant::fromValue(frame);
|
||||||
|
EXPECT_TRUE(v.isValid());
|
||||||
|
|
||||||
|
const olive::OakSharedBufferPtr out = v.value<olive::OakSharedBufferPtr>();
|
||||||
|
EXPECT_EQ(out, frame);
|
||||||
|
EXPECT_EQ(out.use_count(), frame.use_count());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerOutputUtils, NullViewerReturnsDefaults)
|
||||||
|
{
|
||||||
|
const oak::VideoParams vp = olive::viewer_output_video_params(nullptr);
|
||||||
|
EXPECT_EQ(vp.width(), 0);
|
||||||
|
EXPECT_EQ(vp.height(), 0);
|
||||||
|
EXPECT_EQ(vp, olive::empty_video_params());
|
||||||
|
|
||||||
|
const olive::AudioParams ap = olive::viewer_output_audio_params(nullptr);
|
||||||
|
EXPECT_EQ(ap.sample_rate(), 0);
|
||||||
|
EXPECT_FALSE(ap.is_valid());
|
||||||
|
|
||||||
|
EXPECT_DOUBLE_EQ(olive::viewer_output_playhead(nullptr).to_double(), 0.0);
|
||||||
|
EXPECT_DOUBLE_EQ(olive::viewer_output_length(nullptr).to_double(), 0.0);
|
||||||
|
EXPECT_DOUBLE_EQ(olive::viewer_output_video_length(nullptr).to_double(), 0.0);
|
||||||
|
EXPECT_DOUBLE_EQ(olive::viewer_output_audio_length(nullptr).to_double(), 0.0);
|
||||||
|
EXPECT_DOUBLE_EQ(olive::sequence_timebase(nullptr).to_double(), 0.0);
|
||||||
|
|
||||||
|
EXPECT_FALSE(olive::viewer_output_is_sequence(nullptr));
|
||||||
|
EXPECT_FALSE(olive::viewer_output_is_footage(nullptr));
|
||||||
|
EXPECT_FALSE(olive::viewer_output_node_type_is(nullptr, "anything"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerOutputUtils, VideoParamsRoundTripThroughFacade)
|
||||||
|
{
|
||||||
|
olive::ViewerOutput viewer;
|
||||||
|
viewer.set_video_params(olive::VideoParams(320, 240, olive::Rational(1, 25),
|
||||||
|
olive::PixelFormat::u8,
|
||||||
|
olive::VideoParams::k_rgba_channel_count));
|
||||||
|
|
||||||
|
const oak::VideoParams vp = olive::viewer_output_video_params(&viewer);
|
||||||
|
EXPECT_EQ(vp.width(), 320);
|
||||||
|
EXPECT_EQ(vp.height(), 240);
|
||||||
|
EXPECT_TRUE(vp.is_valid());
|
||||||
|
|
||||||
|
// sequence_timebase is the frame duration as a rational
|
||||||
|
const olive::Rational tb = olive::sequence_timebase(&viewer);
|
||||||
|
EXPECT_EQ(tb.numerator(), 1);
|
||||||
|
EXPECT_EQ(tb.denominator(), 25);
|
||||||
|
|
||||||
|
EXPECT_EQ(vp.time_base().numerator(), 1);
|
||||||
|
EXPECT_EQ(vp.time_base().denominator(), 25);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerOutputUtils, PlayheadRoundTripsAndLengthDefaultsToZero)
|
||||||
|
{
|
||||||
|
olive::ViewerOutput viewer;
|
||||||
|
|
||||||
|
viewer.set_playhead(olive::Rational(3, 2));
|
||||||
|
const olive::Rational ph = olive::viewer_output_playhead(&viewer);
|
||||||
|
EXPECT_EQ(ph.numerator(), 3);
|
||||||
|
EXPECT_EQ(ph.denominator(), 2);
|
||||||
|
|
||||||
|
// A fresh viewer has no content, so all lengths are zero
|
||||||
|
EXPECT_DOUBLE_EQ(olive::viewer_output_length(&viewer).to_double(), 0.0);
|
||||||
|
EXPECT_DOUBLE_EQ(olive::viewer_output_video_length(&viewer).to_double(), 0.0);
|
||||||
|
EXPECT_DOUBLE_EQ(olive::viewer_output_audio_length(&viewer).to_double(), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerOutputUtils, AudioParamsRoundTripThroughFacade)
|
||||||
|
{
|
||||||
|
olive::ViewerOutput viewer;
|
||||||
|
const uint64_t stereo_mask = 0x3; // front-left | front-right
|
||||||
|
viewer.set_audio_params(
|
||||||
|
olive::AudioParams(48000, stereo_mask, olive::SampleFormat::s16));
|
||||||
|
|
||||||
|
const olive::AudioParams ap = olive::viewer_output_audio_params(&viewer);
|
||||||
|
EXPECT_TRUE(ap.is_valid());
|
||||||
|
EXPECT_EQ(ap.sample_rate(), 48000);
|
||||||
|
EXPECT_EQ(ap.channel_layout(), stereo_mask);
|
||||||
|
EXPECT_EQ(olive::SampleFormat::Format(ap.format()), olive::SampleFormat::s16);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerOutputUtils, TypeProbesMatchNodeIds)
|
||||||
|
{
|
||||||
|
olive::Sequence sequence;
|
||||||
|
EXPECT_TRUE(olive::viewer_output_is_sequence(&sequence));
|
||||||
|
EXPECT_FALSE(olive::viewer_output_is_footage(&sequence));
|
||||||
|
EXPECT_TRUE(olive::viewer_output_node_type_is(
|
||||||
|
&sequence, "org.olivevideoeditor.Olive.sequence"));
|
||||||
|
EXPECT_FALSE(
|
||||||
|
olive::viewer_output_node_type_is(&sequence, "org.oak.test.nonexistent"));
|
||||||
|
|
||||||
|
olive::Footage footage;
|
||||||
|
EXPECT_TRUE(olive::viewer_output_is_footage(&footage));
|
||||||
|
EXPECT_FALSE(olive::viewer_output_is_sequence(&footage));
|
||||||
|
|
||||||
|
// A plain ViewerOutput is neither a Sequence nor Footage
|
||||||
|
olive::ViewerOutput viewer;
|
||||||
|
EXPECT_FALSE(olive::viewer_output_is_sequence(&viewer));
|
||||||
|
EXPECT_FALSE(olive::viewer_output_is_footage(&viewer));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerTextEditorToolBar, SettersUpdateButtonsWithoutEmitting)
|
||||||
|
{
|
||||||
|
olive::ViewerTextEditorToolBar toolbar;
|
||||||
|
const auto buttons = toolbar_buttons(&toolbar);
|
||||||
|
ASSERT_EQ(buttons.size(), 11);
|
||||||
|
|
||||||
|
QSignalSpy underline_spy(&toolbar,
|
||||||
|
&olive::ViewerTextEditorToolBar::underline_changed);
|
||||||
|
QSignalSpy strike_spy(
|
||||||
|
&toolbar, &olive::ViewerTextEditorToolBar::strikethrough_changed);
|
||||||
|
QSignalSpy caps_spy(&toolbar,
|
||||||
|
&olive::ViewerTextEditorToolBar::small_caps_changed);
|
||||||
|
|
||||||
|
toolbar.set_underline(true);
|
||||||
|
EXPECT_TRUE(buttons.at(k_underline)->isChecked());
|
||||||
|
toolbar.set_strikethrough(true);
|
||||||
|
EXPECT_TRUE(buttons.at(k_strikethrough)->isChecked());
|
||||||
|
toolbar.set_small_caps(true);
|
||||||
|
EXPECT_TRUE(buttons.at(k_small_caps)->isChecked());
|
||||||
|
|
||||||
|
// Programmatic sync from the editor must not bounce back as user edits
|
||||||
|
EXPECT_EQ(underline_spy.count(), 0);
|
||||||
|
EXPECT_EQ(strike_spy.count(), 0);
|
||||||
|
EXPECT_EQ(caps_spy.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerTextEditorToolBar, SetAlignmentChecksMatchingButtonOnly)
|
||||||
|
{
|
||||||
|
olive::ViewerTextEditorToolBar toolbar;
|
||||||
|
const auto buttons = toolbar_buttons(&toolbar);
|
||||||
|
ASSERT_EQ(buttons.size(), 11);
|
||||||
|
|
||||||
|
toolbar.set_alignment(Qt::AlignHCenter);
|
||||||
|
EXPECT_FALSE(buttons.at(k_align_left)->isChecked());
|
||||||
|
EXPECT_TRUE(buttons.at(k_align_center)->isChecked());
|
||||||
|
EXPECT_FALSE(buttons.at(k_align_right)->isChecked());
|
||||||
|
EXPECT_FALSE(buttons.at(k_align_justify)->isChecked());
|
||||||
|
|
||||||
|
toolbar.set_alignment(Qt::AlignRight);
|
||||||
|
EXPECT_FALSE(buttons.at(k_align_left)->isChecked());
|
||||||
|
EXPECT_FALSE(buttons.at(k_align_center)->isChecked());
|
||||||
|
EXPECT_TRUE(buttons.at(k_align_right)->isChecked());
|
||||||
|
EXPECT_FALSE(buttons.at(k_align_justify)->isChecked());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerTextEditorToolBar, SetVerticalAlignmentChecksMatchingButtonOnly)
|
||||||
|
{
|
||||||
|
olive::ViewerTextEditorToolBar toolbar;
|
||||||
|
const auto buttons = toolbar_buttons(&toolbar);
|
||||||
|
ASSERT_EQ(buttons.size(), 11);
|
||||||
|
|
||||||
|
toolbar.set_vertical_alignment(Qt::AlignVCenter);
|
||||||
|
EXPECT_FALSE(buttons.at(k_align_top)->isChecked());
|
||||||
|
EXPECT_TRUE(buttons.at(k_align_middle)->isChecked());
|
||||||
|
EXPECT_FALSE(buttons.at(k_align_bottom)->isChecked());
|
||||||
|
|
||||||
|
toolbar.set_vertical_alignment(Qt::AlignBottom);
|
||||||
|
EXPECT_FALSE(buttons.at(k_align_top)->isChecked());
|
||||||
|
EXPECT_FALSE(buttons.at(k_align_middle)->isChecked());
|
||||||
|
EXPECT_TRUE(buttons.at(k_align_bottom)->isChecked());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerTextEditorToolBar, AlignmentButtonsEmitTheirAlignment)
|
||||||
|
{
|
||||||
|
olive::ViewerTextEditorToolBar toolbar;
|
||||||
|
const auto buttons = toolbar_buttons(&toolbar);
|
||||||
|
ASSERT_EQ(buttons.size(), 11);
|
||||||
|
|
||||||
|
// Lambda capture instead of QSignalSpy: Qt::Alignment is not guaranteed
|
||||||
|
// to be a registered metatype for the spy on every platform
|
||||||
|
QVector<Qt::Alignment> h_received;
|
||||||
|
QObject::connect(&toolbar,
|
||||||
|
&olive::ViewerTextEditorToolBar::alignment_changed,
|
||||||
|
[&h_received](Qt::Alignment a) { h_received.append(a); });
|
||||||
|
QVector<Qt::Alignment> v_received;
|
||||||
|
QObject::connect(
|
||||||
|
&toolbar, &olive::ViewerTextEditorToolBar::vertical_alignment_changed,
|
||||||
|
[&v_received](Qt::Alignment a) { v_received.append(a); });
|
||||||
|
|
||||||
|
buttons.at(k_align_center)->click();
|
||||||
|
ASSERT_EQ(h_received.size(), 1);
|
||||||
|
EXPECT_EQ(h_received.first(), Qt::AlignHCenter);
|
||||||
|
|
||||||
|
buttons.at(k_align_justify)->click();
|
||||||
|
ASSERT_EQ(h_received.size(), 2);
|
||||||
|
EXPECT_EQ(h_received.last(), Qt::AlignJustify);
|
||||||
|
|
||||||
|
buttons.at(k_align_middle)->click();
|
||||||
|
ASSERT_EQ(v_received.size(), 1);
|
||||||
|
EXPECT_EQ(v_received.first(), Qt::AlignVCenter);
|
||||||
|
|
||||||
|
buttons.at(k_align_bottom)->click();
|
||||||
|
ASSERT_EQ(v_received.size(), 2);
|
||||||
|
EXPECT_EQ(v_received.last(), Qt::AlignBottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerTextEditorToolBar, ToggleButtonsEmitTheirState)
|
||||||
|
{
|
||||||
|
olive::ViewerTextEditorToolBar toolbar;
|
||||||
|
const auto buttons = toolbar_buttons(&toolbar);
|
||||||
|
ASSERT_EQ(buttons.size(), 11);
|
||||||
|
|
||||||
|
QSignalSpy underline_spy(&toolbar,
|
||||||
|
&olive::ViewerTextEditorToolBar::underline_changed);
|
||||||
|
QSignalSpy strike_spy(
|
||||||
|
&toolbar, &olive::ViewerTextEditorToolBar::strikethrough_changed);
|
||||||
|
QSignalSpy caps_spy(&toolbar,
|
||||||
|
&olive::ViewerTextEditorToolBar::small_caps_changed);
|
||||||
|
|
||||||
|
buttons.at(k_underline)->click();
|
||||||
|
ASSERT_EQ(underline_spy.count(), 1);
|
||||||
|
EXPECT_TRUE(underline_spy.first().first().toBool());
|
||||||
|
|
||||||
|
buttons.at(k_strikethrough)->click();
|
||||||
|
ASSERT_EQ(strike_spy.count(), 1);
|
||||||
|
EXPECT_TRUE(strike_spy.first().first().toBool());
|
||||||
|
|
||||||
|
buttons.at(k_small_caps)->click();
|
||||||
|
ASSERT_EQ(caps_spy.count(), 1);
|
||||||
|
EXPECT_TRUE(caps_spy.first().first().toBool());
|
||||||
|
|
||||||
|
// Unchecking emits false
|
||||||
|
buttons.at(k_underline)->click();
|
||||||
|
ASSERT_EQ(underline_spy.count(), 2);
|
||||||
|
EXPECT_FALSE(underline_spy.last().first().toBool());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerTextEditorToolBar, SetColorPaintsColorButton)
|
||||||
|
{
|
||||||
|
olive::ViewerTextEditorToolBar toolbar;
|
||||||
|
const auto buttons = toolbar_buttons(&toolbar);
|
||||||
|
ASSERT_EQ(buttons.size(), 11);
|
||||||
|
|
||||||
|
const QColor c(QStringLiteral("#FF0000"));
|
||||||
|
toolbar.set_color(c);
|
||||||
|
|
||||||
|
QPushButton *color_btn = buttons.at(k_color);
|
||||||
|
EXPECT_EQ(color_btn->property("color").value<QColor>(), c);
|
||||||
|
EXPECT_TRUE(color_btn->styleSheet().contains(c.name()));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerTextEditorToolBar, SetFontFamilyUpdatesComboWithoutEmitting)
|
||||||
|
{
|
||||||
|
olive::ViewerTextEditorToolBar toolbar;
|
||||||
|
|
||||||
|
QSignalSpy family_spy(&toolbar,
|
||||||
|
&olive::ViewerTextEditorToolBar::family_changed);
|
||||||
|
|
||||||
|
// Pick a family from the combo's own model: QFontDatabase also lists
|
||||||
|
// system fonts the QFontComboBox filters out (e.g. ".Apple Color Emoji"
|
||||||
|
// on macOS), which setCurrentFont can only approximate
|
||||||
|
auto *combo = toolbar.findChild<QFontComboBox *>();
|
||||||
|
ASSERT_NE(combo, nullptr);
|
||||||
|
ASSERT_GT(combo->count(), 1);
|
||||||
|
const QString family = combo->itemText(combo->count() / 2);
|
||||||
|
ASSERT_FALSE(family.isEmpty());
|
||||||
|
|
||||||
|
toolbar.set_font_family(family);
|
||||||
|
|
||||||
|
EXPECT_EQ(toolbar.get_font_family(), family);
|
||||||
|
// set_font_family blocks the combo's signals while syncing
|
||||||
|
EXPECT_EQ(family_spy.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerTextEditorToolBar, FirstPaintEmittedOnce)
|
||||||
|
{
|
||||||
|
olive::ViewerTextEditorToolBar toolbar;
|
||||||
|
QSignalSpy spy(&toolbar, &olive::ViewerTextEditorToolBar::first_paint);
|
||||||
|
|
||||||
|
// Deliver paint events directly: whether the offscreen QPA paints on
|
||||||
|
// expose is platform-dependent, but QWidget::event always dispatches a
|
||||||
|
// QPaintEvent to paintEvent
|
||||||
|
QPaintEvent first(toolbar.rect());
|
||||||
|
QApplication::sendEvent(&toolbar, &first);
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
|
||||||
|
// Later repaints don't re-emit
|
||||||
|
QPaintEvent second(toolbar.rect());
|
||||||
|
QApplication::sendEvent(&toolbar, &second);
|
||||||
|
EXPECT_EQ(spy.count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerTextEditorToolBar, CloseEventIsIgnored)
|
||||||
|
{
|
||||||
|
olive::ViewerTextEditorToolBar toolbar;
|
||||||
|
|
||||||
|
QCloseEvent ev;
|
||||||
|
QApplication::sendEvent(&toolbar, &ev);
|
||||||
|
|
||||||
|
// The toolbar is a floating child of the viewer; closing must never hide it
|
||||||
|
EXPECT_FALSE(ev.isAccepted());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerTextEditorToolBar, LeftDragMovesToolbarWithinParent)
|
||||||
|
{
|
||||||
|
QWidget parent;
|
||||||
|
parent.resize(600, 400);
|
||||||
|
|
||||||
|
olive::ViewerTextEditorToolBar toolbar(&parent);
|
||||||
|
toolbar.move(100, 100);
|
||||||
|
parent.show();
|
||||||
|
ASSERT_TRUE(QTest::qWaitForWindowExposed(&parent));
|
||||||
|
|
||||||
|
QTest::mousePress(&toolbar, Qt::LeftButton, Qt::NoModifier, QPoint(10, 10));
|
||||||
|
|
||||||
|
// QTest::mouseMove can't report held buttons, so deliver the move manually
|
||||||
|
QMouseEvent move(QEvent::MouseMove, QPointF(30, 25), QPointF(130, 125),
|
||||||
|
Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
|
||||||
|
QApplication::sendEvent(&toolbar, &move);
|
||||||
|
|
||||||
|
EXPECT_EQ(toolbar.pos(), QPoint(120, 115));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerTextEditor, CursorWidthScalesWithInverseZoom)
|
||||||
|
{
|
||||||
|
olive::ViewerTextEditor at_full(1.0);
|
||||||
|
EXPECT_EQ(at_full.cursorWidth(), 1);
|
||||||
|
|
||||||
|
olive::ViewerTextEditor zoomed_out(0.5);
|
||||||
|
EXPECT_EQ(zoomed_out.cursorWidth(), 2);
|
||||||
|
|
||||||
|
olive::ViewerTextEditor far_out(0.25);
|
||||||
|
EXPECT_EQ(far_out.cursorWidth(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerTextEditor, ConstructionDefaults)
|
||||||
|
{
|
||||||
|
olive::ViewerTextEditor editor(1.0);
|
||||||
|
|
||||||
|
// Rich text paste is disabled; colors default to white on transparent
|
||||||
|
EXPECT_FALSE(editor.acceptRichText());
|
||||||
|
EXPECT_EQ(editor.palette().color(QPalette::Text), QColor(Qt::white));
|
||||||
|
|
||||||
|
// Scrolling is handled by the viewer gizmo, not the text edit
|
||||||
|
EXPECT_EQ(editor.horizontalScrollBarPolicy(), Qt::ScrollBarAlwaysOff);
|
||||||
|
EXPECT_EQ(editor.verticalScrollBarPolicy(), Qt::ScrollBarAlwaysOff);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerTextEditor, ConnectToolBarSyncsCurrentFormat)
|
||||||
|
{
|
||||||
|
olive::ViewerTextEditor editor(1.0);
|
||||||
|
|
||||||
|
// Set a distinctive format before connecting; connect_tool_bar pushes the
|
||||||
|
// editor's current format into the toolbar exactly once
|
||||||
|
editor.setFontUnderline(true);
|
||||||
|
editor.setAlignment(Qt::AlignRight);
|
||||||
|
|
||||||
|
olive::ViewerTextEditorToolBar toolbar;
|
||||||
|
editor.connect_tool_bar(&toolbar);
|
||||||
|
|
||||||
|
const auto buttons = toolbar_buttons(&toolbar);
|
||||||
|
ASSERT_EQ(buttons.size(), 11);
|
||||||
|
EXPECT_TRUE(buttons.at(k_underline)->isChecked());
|
||||||
|
EXPECT_TRUE(buttons.at(k_align_right)->isChecked());
|
||||||
|
EXPECT_FALSE(buttons.at(k_align_left)->isChecked());
|
||||||
|
EXPECT_FALSE(buttons.at(k_align_center)->isChecked());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerTextEditor, ToolBarTogglesApplyToEditorFormat)
|
||||||
|
{
|
||||||
|
olive::ViewerTextEditor editor(1.0);
|
||||||
|
olive::ViewerTextEditorToolBar toolbar;
|
||||||
|
editor.connect_tool_bar(&toolbar);
|
||||||
|
|
||||||
|
const auto buttons = toolbar_buttons(&toolbar);
|
||||||
|
ASSERT_EQ(buttons.size(), 11);
|
||||||
|
|
||||||
|
buttons.at(k_underline)->click();
|
||||||
|
EXPECT_TRUE(editor.currentCharFormat().fontUnderline());
|
||||||
|
|
||||||
|
buttons.at(k_strikethrough)->click();
|
||||||
|
EXPECT_TRUE(editor.currentCharFormat().fontStrikeOut());
|
||||||
|
|
||||||
|
buttons.at(k_small_caps)->click();
|
||||||
|
EXPECT_EQ(editor.currentCharFormat().fontCapitalization(),
|
||||||
|
QFont::SmallCaps);
|
||||||
|
|
||||||
|
buttons.at(k_align_center)->click();
|
||||||
|
EXPECT_EQ(editor.alignment(), Qt::AlignHCenter);
|
||||||
|
|
||||||
|
// The toolbar follows the editor back (same alignment echo)
|
||||||
|
EXPECT_TRUE(buttons.at(k_align_center)->isChecked());
|
||||||
|
EXPECT_FALSE(buttons.at(k_align_left)->isChecked());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ViewerTextEditor, PaintNeverRendersTextByDesign)
|
||||||
|
{
|
||||||
|
olive::ViewerTextEditor editor(1.0);
|
||||||
|
editor.resize(200, 80);
|
||||||
|
editor.setPlainText(QStringLiteral("Hello"));
|
||||||
|
|
||||||
|
// document_changed() swaps in a clone whose text is fully transparent (the
|
||||||
|
// HACK in viewertexteditor.cpp: the gizmo underneath renders the text, the
|
||||||
|
// editor must not render it a second time). Verify that contract: nothing
|
||||||
|
// is painted, with or without a selection, at any vertical alignment.
|
||||||
|
QTextCursor c = editor.textCursor();
|
||||||
|
c.select(QTextCursor::Document);
|
||||||
|
editor.setTextCursor(c);
|
||||||
|
|
||||||
|
for (const Qt::Alignment valign :
|
||||||
|
{ Qt::AlignTop, Qt::AlignVCenter, Qt::AlignBottom }) {
|
||||||
|
QImage img(200, 80, QImage::Format_ARGB32);
|
||||||
|
img.fill(Qt::transparent);
|
||||||
|
{
|
||||||
|
QPainter p(&img);
|
||||||
|
editor.paint(&p, valign);
|
||||||
|
EXPECT_TRUE(p.isActive());
|
||||||
|
}
|
||||||
|
for (int y = 0; y < img.height(); y++) {
|
||||||
|
for (int x = 0; x < img.width(); x++) {
|
||||||
|
EXPECT_EQ(qAlpha(img.pixel(x, y)), 0)
|
||||||
|
<< "painted pixel at " << x << "," << y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user