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:
2026-08-05 14:53:26 +08:00
parent 7b6042168a
commit 82162580ef
15 changed files with 9761 additions and 0 deletions
+14
View File
@@ -142,6 +142,20 @@ add_executable(olive-gtest
mainwindow_test.cpp
serialized_layout_xml_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)
+802
View File
@@ -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);
}
}
+672
View File
@@ -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
+569
View File
@@ -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);
}
+396
View File
@@ -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);
}
}
+846
View File
@@ -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);
}
+589
View File
@@ -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 &current)
{
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);
}
+436
View File
@@ -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"));
}
+496
View File
@@ -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 (")));
}
+870
View File
@@ -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;
}
+781
View File
@@ -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;
}
}
}
}