From 576a843e4d14558d91f887142710c3df2216f1d1 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 17 Jul 2026 04:17:51 +0800 Subject: [PATCH] tests: major coverage round for node/render/audio/plugin subsystems Add 12 gtest files (~400 tests) covering previously untested or under-tested areas: - node_math_test: MathNode operations across number/rational/vector/ matrix/color/sample pairings, shader code generation - node_undo_test: all nodeundo command classes redo/undo - track_test: Track block management, lookup, references, Value() - render_diskcache_test: FrameHashCache EXR/JPEG round trips, DiskManager LRU eviction, state persistence - node_group_test: NodeGroup passthrough registration and serialization - plugin_paraminstance_test: OFX param instances and clip image logic - audio_waveform_test: AudioVisualWaveform + AudioProcessor - node_value_extended_test: NodeValue conversions, NodeValueTable ops, NodeKeyframe/bezier behavior - render_projectcopier_test: ProjectCopier sync, PlaybackCache, AudioPlaybackCache PCM segments - node_core_test: Node input arrays, flags, contexts, links, keyframe events, CopyInputs - clip_traverser_test: ClipBlock speed/reverse/loop time mapping, traverser time propagation - audio_manager_viewer_test: AudioManager device API, ViewerOutput params/streams/signals Also fixes two real bugs found by the new tests: - MathNode vec-vec divide crashed (debug) or produced NaN (release) on the zero padding components of vec2/vec3 operands - NodeKeyframe's default constructor left previous_/next_ and the bezier handles uninitialized --- app/node/keyframe.cpp | 8 +- app/node/math/math/mathbase.cpp | 25 +- tests/gtest/CMakeLists.txt | 12 + tests/gtest/audio_manager_viewer_test.cpp | 676 ++++++++++ tests/gtest/audio_waveform_test.cpp | 913 ++++++++++++++ tests/gtest/clip_traverser_test.cpp | 1012 +++++++++++++++ tests/gtest/node_core_test.cpp | 1136 +++++++++++++++++ tests/gtest/node_group_test.cpp | 624 ++++++++++ tests/gtest/node_math_test.cpp | 1362 +++++++++++++++++++++ tests/gtest/node_undo_test.cpp | 912 ++++++++++++++ tests/gtest/node_value_extended_test.cpp | 983 +++++++++++++++ tests/gtest/plugin_paraminstance_test.cpp | 889 ++++++++++++++ tests/gtest/render_diskcache_test.cpp | 652 ++++++++++ tests/gtest/render_projectcopier_test.cpp | 865 +++++++++++++ tests/gtest/track_test.cpp | 1167 ++++++++++++++++++ 15 files changed, 11232 insertions(+), 4 deletions(-) create mode 100644 tests/gtest/audio_manager_viewer_test.cpp create mode 100644 tests/gtest/audio_waveform_test.cpp create mode 100644 tests/gtest/clip_traverser_test.cpp create mode 100644 tests/gtest/node_core_test.cpp create mode 100644 tests/gtest/node_group_test.cpp create mode 100644 tests/gtest/node_math_test.cpp create mode 100644 tests/gtest/node_undo_test.cpp create mode 100644 tests/gtest/node_value_extended_test.cpp create mode 100644 tests/gtest/plugin_paraminstance_test.cpp create mode 100644 tests/gtest/render_diskcache_test.cpp create mode 100644 tests/gtest/render_projectcopier_test.cpp create mode 100644 tests/gtest/track_test.cpp diff --git a/app/node/keyframe.cpp b/app/node/keyframe.cpp index 37f71cf51..9eae2ca90 100644 --- a/app/node/keyframe.cpp +++ b/app/node/keyframe.cpp @@ -46,8 +46,14 @@ NodeKeyframe::NodeKeyframe(const rational &time, const QVariant &value, } NodeKeyframe::NodeKeyframe() + : type_(NodeKeyframe::kLinear) + , bezier_control_in_(QPointF(0.0, 0.0)) + , bezier_control_out_(QPointF(0.0, 0.0)) + , track_(-1) + , element_(-1) + , previous_(nullptr) + , next_(nullptr) { - type_ = NodeKeyframe::kLinear; } NodeKeyframe::~NodeKeyframe() diff --git a/app/node/math/math/mathbase.cpp b/app/node/math/math/mathbase.cpp index 0ad7cacf9..d29da7f8d 100644 --- a/app/node/math/math/mathbase.cpp +++ b/app/node/math/math/mathbase.cpp @@ -279,10 +279,29 @@ void MathNodeBase::ValueInternal( case kPairVecVec: { // We convert all vectors to QVector4D just for simplicity and exploit the fact that kVec4 is higher than kVec2 in // the enum to find the largest data type + QVector4D vec_a = RetrieveVector(val_a); + QVector4D vec_b = RetrieveVector(val_b); + + if (operation == kOpDivide) { + // Lower-dimensional vectors are padded with zeros; dividing the + // padding components would be 0/0 (assert in Qt debug builds, NaN + // otherwise). Force those components to 0/1 so the result is a + // well-defined zero, which is discarded by PushVector anyway. + const NodeValue::Type max_type = qMax(val_a.type(), val_b.type()); + if (max_type == NodeValue::kVec2) { + vec_a.setZ(0.0f); + vec_a.setW(0.0f); + vec_b.setZ(1.0f); + vec_b.setW(1.0f); + } else if (max_type == NodeValue::kVec3) { + vec_a.setW(0.0f); + vec_b.setW(1.0f); + } + } + PushVector(output, qMax(val_a.type(), val_b.type()), - PerformAddSubMultDiv( - operation, RetrieveVector(val_a), - RetrieveVector(val_b))); + PerformAddSubMultDiv(operation, vec_a, + vec_b)); break; } diff --git a/tests/gtest/CMakeLists.txt b/tests/gtest/CMakeLists.txt index a927d6e5a..942f957a3 100644 --- a/tests/gtest/CMakeLists.txt +++ b/tests/gtest/CMakeLists.txt @@ -47,6 +47,18 @@ add_executable(olive-gtest proxy_manager_test.cpp proxy_dialog_test.cpp lut_file_field_test.cpp + node_math_test.cpp + node_undo_test.cpp + track_test.cpp + render_diskcache_test.cpp + node_group_test.cpp + plugin_paraminstance_test.cpp + audio_waveform_test.cpp + node_value_extended_test.cpp + render_projectcopier_test.cpp + node_core_test.cpp + clip_traverser_test.cpp + audio_manager_viewer_test.cpp timeline_marker_test.cpp undo_stack_test.cpp plugin_support_test.cpp diff --git a/tests/gtest/audio_manager_viewer_test.cpp b/tests/gtest/audio_manager_viewer_test.cpp new file mode 100644 index 000000000..d378c8137 --- /dev/null +++ b/tests/gtest/audio_manager_viewer_test.cpp @@ -0,0 +1,676 @@ +/* + * Oak Video Editor - AudioManager + ViewerOutput headless tests + * + * Covers the CPU-safe surface of olive::AudioManager (PortAudio device + * bookkeeping that never opens a stream) and olive::ViewerOutput (parameter + * setters/getters, stream arrays, connected-output resolution, signals and + * XML (de)serialization). Anything that requires an actual audio output or + * input stream is intentionally not exercised here. + */ + +#include + +#include + +#include +#include + +#include "audio/audiomanager.h" +#include "node/block/clip/clip.h" +#include "node/color/colormanager/colormanager.h" +#include "node/generator/solid/solid.h" +#include "node/globals.h" +#include "node/output/viewer/viewer.h" +#include "node/project.h" +#include "olive/core/render/audioparams.h" +#include "olive/core/render/sampleformat.h" + +// ============================================================================ +// AudioManager (no audio hardware required: no stream is ever opened) +// ============================================================================ + +class AudioManagerTest : public ::testing::Test { +protected: + void SetUp() override + { + olive::AudioManager::CreateInstance(); + ASSERT_NE(olive::AudioManager::instance(), nullptr); + } + + void TearDown() override + { + olive::AudioManager::DestroyInstance(); + EXPECT_EQ(olive::AudioManager::instance(), nullptr); + } +}; + +TEST_F(AudioManagerTest, InstanceLifecycle) +{ + // The fixture already created the instance; creating again must be a no-op + olive::AudioManager *first = olive::AudioManager::instance(); + olive::AudioManager::CreateInstance(); + EXPECT_EQ(olive::AudioManager::instance(), first); + + // Whatever PortAudio reports, the stored indices are either a valid + // device index or paNoDevice + EXPECT_GE(olive::AudioManager::instance()->GetOutputDevice(), paNoDevice); + EXPECT_GE(olive::AudioManager::instance()->GetInputDevice(), paNoDevice); +} + +TEST_F(AudioManagerTest, SetAndGetNoDevice) +{ + olive::AudioManager::instance()->SetOutputDevice(paNoDevice); + EXPECT_EQ(olive::AudioManager::instance()->GetOutputDevice(), paNoDevice); + + olive::AudioManager::instance()->SetInputDevice(paNoDevice); + EXPECT_EQ(olive::AudioManager::instance()->GetInputDevice(), paNoDevice); +} + +TEST_F(AudioManagerTest, PushToOutputWithoutDeviceFails) +{ + olive::AudioManager::instance()->SetOutputDevice(paNoDevice); + + const olive::core::AudioParams params( + 48000, olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P); + const QByteArray samples(1024, 0); + + QString error; + EXPECT_FALSE( + olive::AudioManager::instance()->PushToOutput(params, samples, &error)); + EXPECT_EQ(error, QStringLiteral("No output device is set")); + + // A null error pointer must be tolerated too + EXPECT_FALSE( + olive::AudioManager::instance()->PushToOutput(params, samples, nullptr)); +} + +TEST_F(AudioManagerTest, StartRecordingWithoutInputDeviceFails) +{ + olive::AudioManager::instance()->SetInputDevice(paNoDevice); + + // Fails before any encoder or PortAudio stream is created + QString error; + EXPECT_FALSE( + olive::AudioManager::instance()->StartRecording(olive::EncodingParams(), + &error)); + + // Tearing down a recording that never started must be harmless + olive::AudioManager::instance()->StopRecording(); +} + +TEST_F(AudioManagerTest, OutputControlsWithoutStreamAreNoOps) +{ + // No output stream is open; all of these must be harmless no-ops + olive::AudioManager::instance()->StopOutput(); + olive::AudioManager::instance()->ClearBufferedOutput(); + olive::AudioManager::instance()->SetOutputNotifyInterval(64); + olive::AudioManager::instance()->SetOutputNotifyInterval(0); + + SUCCEED(); +} + +TEST_F(AudioManagerTest, HardResetKeepsManagerUsable) +{ + olive::AudioManager::instance()->HardReset(); + + // Device bookkeeping must survive a PortAudio terminate/init cycle + olive::AudioManager::instance()->SetOutputDevice(paNoDevice); + EXPECT_EQ(olive::AudioManager::instance()->GetOutputDevice(), paNoDevice); +} + +TEST_F(AudioManagerTest, FindDeviceByNameReturnsValidIndexOrNoDevice) +{ + const PaDeviceIndex bogus_output = olive::AudioManager::FindDeviceByName( + QStringLiteral("OakNoSuchAudioDevice12345"), true); + EXPECT_TRUE(bogus_output == paNoDevice || + (bogus_output >= 0 && bogus_output < Pa_GetDeviceCount())); + + const PaDeviceIndex bogus_input = olive::AudioManager::FindDeviceByName( + QStringLiteral("OakNoSuchAudioDevice12345"), false); + EXPECT_TRUE(bogus_input == paNoDevice || + (bogus_input >= 0 && bogus_input < Pa_GetDeviceCount())); + + // An empty name falls back to the default/preferred device + const PaDeviceIndex fallback = + olive::AudioManager::FindDeviceByName(QString(), true); + EXPECT_TRUE(fallback == paNoDevice || + (fallback >= 0 && fallback < Pa_GetDeviceCount())); +} + +TEST_F(AudioManagerTest, FindConfigDeviceByNameReturnsValidIndexOrNoDevice) +{ + const PaDeviceIndex output = + olive::AudioManager::FindConfigDeviceByName(true); + EXPECT_TRUE(output == paNoDevice || + (output >= 0 && output < Pa_GetDeviceCount())); + + const PaDeviceIndex input = + olive::AudioManager::FindConfigDeviceByName(false); + EXPECT_TRUE(input == paNoDevice || + (input >= 0 && input < Pa_GetDeviceCount())); +} + +TEST_F(AudioManagerTest, PortAudioParamsReflectAudioParams) +{ + if (Pa_GetDeviceCount() <= 0) { + GTEST_SKIP() << "No PortAudio devices available on this system"; + } + + const olive::core::AudioParams params( + 48000, olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32); + const PaStreamParameters p = + olive::AudioManager::GetPortAudioParams(params, 0); + + EXPECT_EQ(p.channelCount, 2); + EXPECT_EQ(p.device, 0); + EXPECT_EQ(p.sampleFormat, PaSampleFormat(paFloat32)); + EXPECT_EQ(p.hostApiSpecificStreamInfo, nullptr); + EXPECT_GE(p.suggestedLatency, 0.0); +} + +TEST_F(AudioManagerTest, PortAudioParamsMapsSampleFormats) +{ + if (Pa_GetDeviceCount() <= 0) { + GTEST_SKIP() << "No PortAudio devices available on this system"; + } + + const auto format_for = [](olive::core::SampleFormat f) { + const olive::core::AudioParams params( + 48000, olive::core::kChannelLayoutMono, f); + return olive::AudioManager::GetPortAudioParams(params, 0).sampleFormat; + }; + + // Packed and planar variants of the same depth map to the same flag + EXPECT_EQ(format_for(olive::core::SampleFormat::U8), + PaSampleFormat(paUInt8)); + EXPECT_EQ(format_for(olive::core::SampleFormat::U8P), + PaSampleFormat(paUInt8)); + EXPECT_EQ(format_for(olive::core::SampleFormat::S16), + PaSampleFormat(paInt16)); + EXPECT_EQ(format_for(olive::core::SampleFormat::S16P), + PaSampleFormat(paInt16)); + EXPECT_EQ(format_for(olive::core::SampleFormat::S32), + PaSampleFormat(paInt32)); + EXPECT_EQ(format_for(olive::core::SampleFormat::S32P), + PaSampleFormat(paInt32)); + EXPECT_EQ(format_for(olive::core::SampleFormat::F32), + PaSampleFormat(paFloat32)); + EXPECT_EQ(format_for(olive::core::SampleFormat::F32P), + PaSampleFormat(paFloat32)); + + // 64-bit depths have no PortAudio equivalent and map to paCustomFormat(0) + EXPECT_EQ(format_for(olive::core::SampleFormat::S64), PaSampleFormat(0)); + EXPECT_EQ(format_for(olive::core::SampleFormat::F64), PaSampleFormat(0)); + EXPECT_EQ(format_for(olive::core::SampleFormat::INVALID), + PaSampleFormat(0)); +} + +// ============================================================================ +// ViewerOutput +// ============================================================================ + +// AddStream/SetStream are protected on ViewerOutput (Sequence is the intended +// caller); expose them so the stream-array bookkeeping can be tested directly +class TestViewerOutput : public olive::ViewerOutput { +public: + using olive::ViewerOutput::ViewerOutput; + using olive::ViewerOutput::AddStream; + using olive::ViewerOutput::SetStream; +}; + +class ViewerOutputTest : public ::testing::Test { +protected: + void SetUp() override + { + olive::ColorManager::SetUpDefaultConfig(); + + project_ = std::make_unique(); + project_->Initialize(); + + viewer_ = new TestViewerOutput(); + viewer_->setParent(project_.get()); + } + + template T *AddNode() + { + T *node = new T(); + node->setParent(project_.get()); + return node; + } + + std::unique_ptr project_; + TestViewerOutput *viewer_; +}; + +TEST_F(ViewerOutputTest, DefaultConstruction) +{ + EXPECT_EQ(viewer_->Name(), QStringLiteral("Viewer")); + EXPECT_EQ(viewer_->id(), + QStringLiteral("org.olivevideoeditor.Olive.vieweroutput")); + EXPECT_TRUE(viewer_->Category().contains(olive::Node::kCategoryOutput)); + + // One default video and audio stream, no subtitle streams + EXPECT_EQ(viewer_->GetVideoStreamCount(), 1); + EXPECT_EQ(viewer_->GetAudioStreamCount(), 1); + EXPECT_EQ(viewer_->GetSubtitleStreamCount(), 0); + EXPECT_EQ(viewer_->GetTotalStreamCount(), 2); + + EXPECT_NE(viewer_->GetWorkArea(), nullptr); + EXPECT_NE(viewer_->GetMarkers(), nullptr); + + EXPECT_EQ(viewer_->GetPlayhead(), olive::rational(0)); + EXPECT_EQ(viewer_->GetLength(), olive::rational(0)); + EXPECT_EQ(viewer_->GetVideoLength(), olive::rational(0)); + EXPECT_EQ(viewer_->GetAudioLength(), olive::rational(0)); + + EXPECT_EQ(viewer_->GetConnectedTextureOutput(), nullptr); + EXPECT_EQ(viewer_->GetConnectedSampleOutput(), nullptr); + EXPECT_EQ(viewer_->GetConnectedWaveform(), nullptr); + + // The autocache API is currently a stub that always reports disabled + EXPECT_FALSE(viewer_->IsVideoAutoCacheEnabled()); +} + +TEST_F(ViewerOutputTest, SetAndGetVideoParams) +{ + const olive::VideoParams vp(1920, 1080, olive::rational(1, 30), + olive::PixelFormat::U8, 4); + viewer_->SetVideoParams(vp); + + EXPECT_EQ(viewer_->GetVideoParams(), vp); + EXPECT_EQ(viewer_->GetVideoParams().width(), 1920); + EXPECT_EQ(viewer_->GetVideoParams().height(), 1080); + + // Out-of-range indices return invalid params instead of garbage + EXPECT_FALSE(viewer_->GetVideoParams(5).is_valid()); +} + +TEST_F(ViewerOutputTest, SetAndGetAudioParams) +{ + const olive::core::AudioParams ap(48000, olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P); + viewer_->SetAudioParams(ap); + + EXPECT_EQ(viewer_->GetAudioParams(), ap); + + // The default-constructed stream uses the viewer's default sample format + const olive::core::SampleFormat default_format = + olive::ViewerOutput::kDefaultSampleFormat; + EXPECT_EQ(viewer_->GetAudioParams().format(), default_format); + + // Out-of-range indices return invalid params instead of garbage + EXPECT_FALSE(viewer_->GetAudioParams(9).is_valid()); +} + +TEST_F(ViewerOutputTest, SetAndGetSubtitleParams) +{ + olive::SubtitleParams subs; + subs.push_back(olive::Subtitle( + olive::TimeRange(olive::rational(0), olive::rational(2)), + QStringLiteral("hello"))); + + EXPECT_EQ(viewer_->AddStream(olive::Track::kSubtitle, + QVariant::fromValue(subs)), + 0); + EXPECT_EQ(viewer_->GetSubtitleStreamCount(), 1); + + ASSERT_TRUE(viewer_->GetSubtitleParams(0).is_valid()); + EXPECT_EQ(viewer_->GetSubtitleParams(0).duration(), olive::rational(2)); + EXPECT_TRUE(viewer_->HasEnabledSubtitleStreams()); + + // Out-of-range indices return invalid (empty) params + EXPECT_FALSE(viewer_->GetSubtitleParams(3).is_valid()); +} + +TEST_F(ViewerOutputTest, VideoParamSignals) +{ + int size_emissions = 0; + int frame_rate_emissions = 0; + int pixel_aspect_emissions = 0; + int interlacing_emissions = 0; + int params_emissions = 0; + olive::rational emitted_frame_rate; + QObject::connect(viewer_, &olive::ViewerOutput::SizeChanged, + [&size_emissions](int, int) { ++size_emissions; }); + QObject::connect(viewer_, &olive::ViewerOutput::FrameRateChanged, + [&frame_rate_emissions, &emitted_frame_rate]( + const olive::rational &r) { + ++frame_rate_emissions; + emitted_frame_rate = r; + }); + QObject::connect(viewer_, &olive::ViewerOutput::PixelAspectChanged, + [&pixel_aspect_emissions](const olive::rational &) { + ++pixel_aspect_emissions; + }); + QObject::connect(viewer_, &olive::ViewerOutput::InterlacingChanged, + [&interlacing_emissions](olive::VideoParams::Interlacing) { + ++interlacing_emissions; + }); + QObject::connect(viewer_, &olive::ViewerOutput::VideoParamsChanged, + [¶ms_emissions]() { ++params_emissions; }); + + // Every aspect differs from the cached defaults, so all signals fire once + const olive::VideoParams vp(1280, 720, olive::rational(1, 60), + olive::PixelFormat::U8, 4, olive::rational(2), + olive::VideoParams::kInterlacedTopFirst); + viewer_->SetVideoParams(vp); + + EXPECT_EQ(size_emissions, 1); + EXPECT_EQ(frame_rate_emissions, 1); + EXPECT_EQ(emitted_frame_rate, olive::rational(60, 1)); + EXPECT_EQ(pixel_aspect_emissions, 1); + EXPECT_EQ(interlacing_emissions, 1); + EXPECT_EQ(params_emissions, 1); + + // Setting identical params only re-emits the unconditional change signal + viewer_->SetVideoParams(vp); + + EXPECT_EQ(size_emissions, 1); + EXPECT_EQ(frame_rate_emissions, 1); + EXPECT_EQ(pixel_aspect_emissions, 1); + EXPECT_EQ(interlacing_emissions, 1); + EXPECT_EQ(params_emissions, 2); +} + +TEST_F(ViewerOutputTest, AudioParamSignals) +{ + int sample_rate_emissions = 0; + int params_emissions = 0; + int emitted_sample_rate = 0; + QObject::connect(viewer_, &olive::ViewerOutput::SampleRateChanged, + [&sample_rate_emissions, &emitted_sample_rate](int sr) { + ++sample_rate_emissions; + emitted_sample_rate = sr; + }); + QObject::connect(viewer_, &olive::ViewerOutput::AudioParamsChanged, + [¶ms_emissions]() { ++params_emissions; }); + + const olive::core::AudioParams ap(44100, olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P); + viewer_->SetAudioParams(ap); + + EXPECT_EQ(sample_rate_emissions, 1); + EXPECT_EQ(emitted_sample_rate, 44100); + EXPECT_EQ(params_emissions, 1); + + // Same sample rate again: no SampleRateChanged, but AudioParamsChanged + viewer_->SetAudioParams(ap); + + EXPECT_EQ(sample_rate_emissions, 1); + EXPECT_EQ(params_emissions, 2); +} + +TEST_F(ViewerOutputTest, SetPlayheadEmitsPlayheadChanged) +{ + int emissions = 0; + olive::rational emitted; + QObject::connect(viewer_, &olive::ViewerOutput::PlayheadChanged, + [&emissions, &emitted](const olive::rational &t) { + ++emissions; + emitted = t; + }); + + viewer_->SetPlayhead(olive::rational(3, 2)); + + EXPECT_EQ(emissions, 1); + EXPECT_EQ(emitted, olive::rational(3, 2)); + EXPECT_EQ(viewer_->GetPlayhead(), olive::rational(3, 2)); +} + +TEST_F(ViewerOutputTest, VerifyLengthWithoutConnectionsStaysZero) +{ + int length_emissions = 0; + QObject::connect(viewer_, &olive::ViewerOutput::LengthChanged, + [&length_emissions](const olive::rational &) { + ++length_emissions; + }); + + viewer_->VerifyLength(); + viewer_->VerifyLength(); + + // Nothing is connected, so all lengths stay zero and nothing is emitted + EXPECT_EQ(viewer_->GetLength(), olive::rational(0)); + EXPECT_EQ(viewer_->GetVideoLength(), olive::rational(0)); + EXPECT_EQ(viewer_->GetAudioLength(), olive::rational(0)); + EXPECT_EQ(length_emissions, 0); + + EXPECT_EQ(viewer_->GetVideoCacheRange(), + olive::TimeRange(olive::rational(0), olive::rational(0))); + EXPECT_EQ(viewer_->GetAudioCacheRange(), + olive::TimeRange(olive::rational(0), olive::rational(0))); +} + +TEST_F(ViewerOutputTest, StreamEnableDisable) +{ + ASSERT_TRUE(viewer_->HasEnabledVideoStreams()); + ASSERT_TRUE(viewer_->HasEnabledAudioStreams()); + + olive::VideoParams vp = viewer_->GetVideoParams(); + vp.set_enabled(false); + viewer_->SetVideoParams(vp); + + EXPECT_FALSE(viewer_->HasEnabledVideoStreams()); + EXPECT_FALSE(viewer_->GetFirstEnabledVideoStream().is_valid()); + EXPECT_TRUE(viewer_->GetEnabledVideoStreams().isEmpty()); + + olive::core::AudioParams ap = viewer_->GetAudioParams(); + ap.set_enabled(false); + viewer_->SetAudioParams(ap); + + EXPECT_FALSE(viewer_->HasEnabledAudioStreams()); + EXPECT_FALSE(viewer_->GetFirstEnabledAudioStream().is_valid()); + EXPECT_TRUE(viewer_->GetEnabledAudioStreams().isEmpty()); + EXPECT_TRUE(viewer_->GetEnabledStreamsAsReferences().isEmpty()); +} + +TEST_F(ViewerOutputTest, AddAndSetStreams) +{ + const olive::VideoParams vp2(640, 360, olive::rational(1, 25), + olive::PixelFormat::U8, 4); + + EXPECT_EQ(viewer_->AddStream(olive::Track::kVideo, + QVariant::fromValue(vp2)), + 1); + EXPECT_EQ(viewer_->GetVideoStreamCount(), 2); + EXPECT_EQ(viewer_->GetVideoParams(1), vp2); + EXPECT_EQ(viewer_->GetTotalStreamCount(), 3); + + // References enumerate the enabled streams in video/audio/subtitle order + const QVector refs = + viewer_->GetEnabledStreamsAsReferences(); + ASSERT_EQ(refs.size(), 3); + EXPECT_EQ(refs.at(0), olive::Track::Reference(olive::Track::kVideo, 0)); + EXPECT_EQ(refs.at(1), olive::Track::Reference(olive::Track::kVideo, 1)); + EXPECT_EQ(refs.at(2), olive::Track::Reference(olive::Track::kAudio, 0)); + + // SetStream replaces an existing element in place + const olive::core::AudioParams ap2( + 32000, olive::core::kChannelLayoutMono, olive::core::SampleFormat::S16); + EXPECT_EQ(viewer_->SetStream(olive::Track::kAudio, + QVariant::fromValue(ap2), 0), + 0); + EXPECT_EQ(viewer_->GetAudioStreamCount(), 1); + EXPECT_EQ(viewer_->GetAudioParams(0), ap2); + + // kNone is not a valid stream type + EXPECT_EQ(viewer_->AddStream(olive::Track::kNone, QVariant()), -1); +} + +TEST_F(ViewerOutputTest, ConnectTextureEmitsAndResolves) +{ + auto *solid = AddNode(); + + int emissions = 0; + QObject::connect(viewer_, &olive::ViewerOutput::TextureInputChanged, + [&emissions]() { ++emissions; }); + + olive::Node::ConnectEdge( + solid, olive::NodeInput(viewer_, olive::ViewerOutput::kTextureInput)); + + EXPECT_EQ(emissions, 1); + EXPECT_EQ(viewer_->GetConnectedTextureOutput(), solid); + + // Explicit value hints set on the input are reported through the getter + const olive::Node::ValueHint hint({ olive::NodeValue::kTexture }, 2, + QStringLiteral("tex")); + viewer_->SetValueHintForInput(olive::ViewerOutput::kTextureInput, hint); + const olive::Node::ValueHint got = + viewer_->GetConnectedTextureValueHint(); + ASSERT_EQ(got.types().size(), 1); + EXPECT_EQ(got.types().first(), olive::NodeValue::kTexture); + EXPECT_EQ(got.index(), 2); + EXPECT_EQ(got.tag(), QStringLiteral("tex")); + + olive::Node::DisconnectEdge( + solid, olive::NodeInput(viewer_, olive::ViewerOutput::kTextureInput)); + + EXPECT_EQ(emissions, 2); + EXPECT_EQ(viewer_->GetConnectedTextureOutput(), nullptr); +} + +TEST_F(ViewerOutputTest, ConnectSamplesResolves) +{ + auto *clip = AddNode(); + + olive::Node::ConnectEdge( + clip, olive::NodeInput(viewer_, olive::ViewerOutput::kSamplesInput)); + + EXPECT_EQ(viewer_->GetConnectedSampleOutput(), clip); + EXPECT_EQ(viewer_->GetConnectedWaveform(), clip->waveform_cache()); + + // Without an explicit hint the sample value hint is empty + EXPECT_TRUE(viewer_->GetConnectedSampleValueHint().types().isEmpty()); + + olive::Node::DisconnectEdge( + clip, olive::NodeInput(viewer_, olive::ViewerOutput::kSamplesInput)); + + EXPECT_EQ(viewer_->GetConnectedSampleOutput(), nullptr); + EXPECT_EQ(viewer_->GetConnectedWaveform(), nullptr); +} + +TEST_F(ViewerOutputTest, InvalidateCacheWithoutConnectionsIsSafe) +{ + // With nothing connected the request path is skipped entirely; this must + // neither crash nor produce a length change + viewer_->InvalidateCache(olive::TimeRange(olive::rational(0), + olive::rational(1)), + olive::ViewerOutput::kTextureInput, -1, + olive::Node::InvalidateCacheOptions()); + viewer_->InvalidateCache(olive::TimeRange(olive::rational(0), + olive::rational(1)), + olive::ViewerOutput::kSamplesInput, -1, + olive::Node::InvalidateCacheOptions()); + + EXPECT_EQ(viewer_->GetLength(), olive::rational(0)); +} + +TEST_F(ViewerOutputTest, ValueRepushTagsStreams) +{ + olive::NodeValueRow row; + row.insert(olive::ViewerOutput::kTextureInput, + olive::NodeValue(olive::NodeValue::kTexture, 0)); + row.insert(olive::ViewerOutput::kSamplesInput, + olive::NodeValue(olive::NodeValue::kSamples, 0)); + + olive::NodeValueTable table; + viewer_->Value(row, olive::NodeGlobals(), &table); + + // The texture value is re-pushed tagged as video stream 0 + const QString video_tag = + olive::Track::Reference(olive::Track::kVideo, 0).ToString(); + EXPECT_EQ(table.Get(olive::NodeValue::kTexture, video_tag).type(), + olive::NodeValue::kTexture); + + // The samples value is re-pushed and stays retrievable + EXPECT_EQ(table.Get(olive::NodeValue::kSamples).type(), + olive::NodeValue::kSamples); +} + +TEST_F(ViewerOutputTest, LastUsedEncodingParamsRoundTrip) +{ + olive::EncodingParams params; + params.SetFilename(QStringLiteral("/tmp/oak-export.mp4")); + + viewer_->SetLastUsedEncodingParams(params); + + EXPECT_EQ(viewer_->GetLastUsedEncodingParams().filename(), + QStringLiteral("/tmp/oak-export.mp4")); +} + +TEST_F(ViewerOutputTest, SaveLoadCustomRoundTrip) +{ + viewer_->GetWorkArea()->set_enabled(true); + viewer_->GetWorkArea()->set_range( + olive::TimeRange(olive::rational(1), olive::rational(5))); + + QString xml; + QXmlStreamWriter writer(&xml); + writer.writeStartDocument(); + writer.writeStartElement(QStringLiteral("custom")); + viewer_->SaveCustom(&writer); + writer.writeEndElement(); + writer.writeEndDocument(); + + EXPECT_TRUE(xml.contains(QStringLiteral("workarea"))); + EXPECT_TRUE(xml.contains(QStringLiteral("markers"))); + + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("custom")); + + olive::ViewerOutput loaded; + ASSERT_TRUE(loaded.LoadCustom(&reader, nullptr)); + EXPECT_TRUE(loaded.GetWorkArea()->enabled()); + EXPECT_EQ(loaded.GetWorkArea()->range(), viewer_->GetWorkArea()->range()); +} + +TEST_F(ViewerOutputTest, RetranslateSetsInputNames) +{ + viewer_->Retranslate(); + + EXPECT_EQ(viewer_->GetInputName(olive::ViewerOutput::kVideoParamsInput), + QStringLiteral("Video Parameters")); + EXPECT_EQ(viewer_->GetInputName(olive::ViewerOutput::kAudioParamsInput), + QStringLiteral("Audio Parameters")); + EXPECT_EQ(viewer_->GetInputName(olive::ViewerOutput::kSubtitleParamsInput), + QStringLiteral("Subtitle Parameters")); + EXPECT_EQ(viewer_->GetInputName(olive::ViewerOutput::kTextureInput), + QStringLiteral("Texture")); + EXPECT_EQ(viewer_->GetInputName(olive::ViewerOutput::kSamplesInput), + QStringLiteral("Samples")); +} + +TEST_F(ViewerOutputTest, AutoCacheStubsAlwaysReportDisabled) +{ + EXPECT_FALSE(viewer_->IsVideoAutoCacheEnabled()); + + // The setter is a stub and must not change the reported state + viewer_->SetVideoAutoCacheEnabled(true); + EXPECT_FALSE(viewer_->IsVideoAutoCacheEnabled()); +} + +TEST_F(ViewerOutputTest, SetWaveformEnabledWithoutConnectionIsSafe) +{ + // No samples input connected: enabling waveform requests must not crash + viewer_->SetWaveformEnabled(true); + EXPECT_EQ(viewer_->GetConnectedWaveform(), nullptr); + + viewer_->SetWaveformEnabled(false); +} + +TEST_F(ViewerOutputTest, FrequencyRateDataReflectsEnabledStreams) +{ + // A video stream takes priority and is reported as a frame rate + QString rate = viewer_->data(olive::Node::FREQUENCY_RATE).toString(); + EXPECT_TRUE(rate.endsWith(QStringLiteral(" FPS"))); + + // With the video stream disabled, the audio sample rate is reported + olive::VideoParams vp = viewer_->GetVideoParams(); + vp.set_enabled(false); + viewer_->SetVideoParams(vp); + + rate = viewer_->data(olive::Node::FREQUENCY_RATE).toString(); + EXPECT_TRUE(rate.endsWith(QStringLiteral(" Hz"))); +} diff --git a/tests/gtest/audio_waveform_test.cpp b/tests/gtest/audio_waveform_test.cpp new file mode 100644 index 000000000..fd7847e53 --- /dev/null +++ b/tests/gtest/audio_waveform_test.cpp @@ -0,0 +1,913 @@ +/*** + Oak Video Editor - Extended tests for AudioVisualWaveform and AudioProcessor + Copyright (C) 2026 Oak Team +***/ + +#include + +#include +#include +#include + +#include +#include +#include + +#include "audio/audioprocessor.h" +#include "audio/audiovisualwaveform.h" +#include "olive/core/render/audioparams.h" +#include "olive/core/render/samplebuffer.h" +#include "olive/core/render/sampleformat.h" + +namespace +{ + +constexpr int kSampleRate = 48000; + +olive::core::AudioParams MakeParams(uint64_t channel_layout) +{ + return olive::core::AudioParams(kSampleRate, channel_layout, + olive::core::SampleFormat::F32P); +} + +// Returns a buffer of `sample_count` samples per channel, every sample set to +// `value`. Constant buffers keep mipmap chunk boundaries irrelevant, so +// summaries can be checked with exact float comparisons. +olive::core::SampleBuffer MakeConstantBuffer( + const olive::core::AudioParams ¶ms, size_t sample_count, float value) +{ + olive::core::SampleBuffer buffer(params, sample_count); + for (int ch = 0; ch < buffer.channel_count(); ch++) { + float *data = buffer.data(ch); + for (size_t i = 0; i < buffer.sample_count(); i++) { + data[i] = value; + } + } + return buffer; +} + +// Returns one second of mono `first_value` followed by one second of mono +// `second_value`. The split lands exactly on a chunk boundary at every mipmap +// rate when kSampleRate is 48000, so per-range summaries stay exact. +olive::core::SampleBuffer MakeSplitMonoBuffer(float first_value, + float second_value) +{ + olive::core::SampleBuffer buffer( + MakeParams(olive::core::kChannelLayoutMono), size_t(kSampleRate * 2)); + float *data = buffer.data(0); + for (size_t i = 0; i < size_t(kSampleRate); i++) { + data[i] = first_value; + } + for (size_t i = size_t(kSampleRate); i < buffer.sample_count(); i++) { + data[i] = second_value; + } + return buffer; +} + +olive::core::SampleBuffer MakeMonoConstant(size_t sample_count, float value) +{ + return MakeConstantBuffer(MakeParams(olive::core::kChannelLayoutMono), + sample_count, value); +} + +void ExpectSummary(const olive::AudioVisualWaveform::Sample &summary, + size_t channel, float expected_min, float expected_max) +{ + ASSERT_LT(channel, summary.size()); + // ReSumSamples aggregates starting from a zero-initialized accumulator, so + // a summary always spans zero for single-signed data. + EXPECT_FLOAT_EQ(summary.at(channel).min, std::min(expected_min, 0.0f)); + EXPECT_FLOAT_EQ(summary.at(channel).max, std::max(expected_max, 0.0f)); +} + +// Pushes input through the processor, then flushes and drains everything the +// filter graph still holds, returning the accumulated per-plane output. +// Draining after a flush ends at EOF, which AudioProcessor reports as a +// negative return value, so the final Convert result is intentionally unused. +olive::AudioProcessor::Buffer ConvertAndDrain(olive::AudioProcessor &processor, + float **input, int nb_samples) +{ + olive::AudioProcessor::Buffer output; + EXPECT_GE(processor.Convert(input, nb_samples, &output), 0); + + processor.Flush(); + + olive::AudioProcessor::Buffer rest; + processor.Convert(nullptr, 0, &rest); + + if (output.size() < rest.size()) { + output.resize(rest.size()); + } + for (int i = 0; i < rest.size(); i++) { + output[i].append(rest.at(i)); + } + return output; +} + +} // namespace + +// --------------------------------------------------------------------------- +// AudioVisualWaveform::OverwriteSamples +// --------------------------------------------------------------------------- + +TEST(AudioVisualWaveform, OverwriteSamplesWithZeroChannelsIsIgnored) +{ + olive::AudioVisualWaveform waveform; // channel count defaults to zero + + olive::core::SampleBuffer buffer( + MakeParams(olive::core::kChannelLayoutStereo), size_t(100)); + waveform.OverwriteSamples(buffer, kSampleRate, olive::core::rational(0)); + + // Nothing is written and no length is recorded + EXPECT_EQ(waveform.length(), olive::core::rational(0)); + EXPECT_TRUE(waveform + .GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(1)) + .empty()); +} + +TEST(AudioVisualWaveform, OverwriteSamplesAtNonZeroStartSetsLength) +{ + olive::AudioVisualWaveform waveform; + waveform.set_channel_count(1); + + waveform.OverwriteSamples(MakeMonoConstant(size_t(kSampleRate), 0.75f), + kSampleRate, olive::core::rational(2)); + + // length() tracks the absolute end time of the written data + EXPECT_EQ(waveform.length(), olive::core::rational(3)); + + const olive::AudioVisualWaveform::Sample summary = + waveform.GetSummaryFromTime(olive::core::rational(2), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.75f, 0.75f); +} + +TEST(AudioVisualWaveform, OverwriteSamplesReplacesPreviousData) +{ + olive::AudioVisualWaveform waveform; + waveform.set_channel_count(1); + + waveform.OverwriteSamples(MakeMonoConstant(size_t(kSampleRate), 0.8f), + kSampleRate, olive::core::rational(0)); + waveform.OverwriteSamples(MakeMonoConstant(size_t(kSampleRate), 0.2f), + kSampleRate, olive::core::rational(0)); + + // Overwriting must replace, not mix or extend + EXPECT_EQ(waveform.length(), olive::core::rational(1)); + + const olive::AudioVisualWaveform::Sample summary = + waveform.GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.2f, 0.2f); +} + +TEST(AudioVisualWaveform, OverwriteSamplesAfterGapLeavesSilence) +{ + olive::AudioVisualWaveform waveform; + waveform.set_channel_count(1); + + waveform.OverwriteSamples(MakeMonoConstant(size_t(kSampleRate), 0.25f), + kSampleRate, olive::core::rational(0)); + waveform.OverwriteSamples(MakeMonoConstant(size_t(kSampleRate), 0.75f), + kSampleRate, olive::core::rational(2)); + + EXPECT_EQ(waveform.length(), olive::core::rational(3)); + + olive::AudioVisualWaveform::Sample summary = + waveform.GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.25f, 0.25f); + + // The unwritten gap between the two writes reads back as zeros + summary = waveform.GetSummaryFromTime(olive::core::rational(1), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.0f, 0.0f); + + summary = waveform.GetSummaryFromTime(olive::core::rational(2), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.75f, 0.75f); +} + +TEST(AudioVisualWaveform, OverwriteSamplesBeforeExistingDataPrependsZeros) +{ + olive::AudioVisualWaveform waveform; + waveform.set_channel_count(1); + + waveform.OverwriteSamples(MakeMonoConstant(size_t(kSampleRate), 0.75f), + kSampleRate, olive::core::rational(2)); + ASSERT_EQ(waveform.length(), olive::core::rational(3)); + + // Writing before the current virtual start pushes the existing data back + waveform.OverwriteSamples(MakeMonoConstant(size_t(kSampleRate), 0.25f), + kSampleRate, olive::core::rational(0)); + + olive::AudioVisualWaveform::Sample summary = + waveform.GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.25f, 0.25f); + + // Two seconds (one written, one gap) were prepended + summary = waveform.GetSummaryFromTime(olive::core::rational(1), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.0f, 0.0f); + + // The original data is still intact at its absolute position + summary = waveform.GetSummaryFromTime(olive::core::rational(2), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.75f, 0.75f); + + // BUG: the data now spans [0, 3), but prepending via a negative TrimIn + // subtracts the negated length from length_ instead of keeping the + // absolute end time, so length() reports 1 instead of 3 + EXPECT_EQ(waveform.length(), olive::core::rational(1)); +} + +// --------------------------------------------------------------------------- +// AudioVisualWaveform::GetSummaryFromTime +// --------------------------------------------------------------------------- + +TEST(AudioVisualWaveform, GetSummaryFromTimeReturnsExactMinMaxPerChannel) +{ + olive::AudioVisualWaveform waveform; + waveform.set_channel_count(2); + + olive::core::SampleBuffer buffer( + MakeParams(olive::core::kChannelLayoutStereo), size_t(kSampleRate)); + float *left = buffer.data(0); + float *right = buffer.data(1); + for (size_t i = 0; i < buffer.sample_count(); i++) { + left[i] = 0.5f; + right[i] = -0.25f; + } + waveform.OverwriteSamples(buffer, kSampleRate, olive::core::rational(0)); + + const olive::AudioVisualWaveform::Sample summary = + waveform.GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(1)); + + ASSERT_EQ(summary.size(), 2); + ExpectSummary(summary, 0, 0.5f, 0.5f); + ExpectSummary(summary, 1, -0.25f, -0.25f); +} + +TEST(AudioVisualWaveform, GetSummaryFromTimeResolvesDistinctRanges) +{ + olive::AudioVisualWaveform waveform; + waveform.set_channel_count(1); + + // One second of 0.25 followed by one second of 0.75 + waveform.OverwriteSamples(MakeSplitMonoBuffer(0.25f, 0.75f), kSampleRate, + olive::core::rational(0)); + ASSERT_EQ(waveform.length(), olive::core::rational(2)); + + olive::AudioVisualWaveform::Sample summary = + waveform.GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.25f, 0.25f); + + summary = waveform.GetSummaryFromTime(olive::core::rational(1), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.75f, 0.75f); + + // A range covering both seconds merges their extremes + summary = waveform.GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(2)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.25f, 0.75f); +} + +TEST(AudioVisualWaveform, GetSummaryFromTimeHandlesSurroundChannels) +{ + olive::AudioVisualWaveform waveform; + waveform.set_channel_count(6); + + olive::core::SampleBuffer buffer( + MakeParams(olive::core::kChannelLayout5Point1), size_t(kSampleRate)); + for (int ch = 0; ch < buffer.channel_count(); ch++) { + float *data = buffer.data(ch); + for (size_t i = 0; i < buffer.sample_count(); i++) { + data[i] = 0.1f * float(ch + 1); + } + } + waveform.OverwriteSamples(buffer, kSampleRate, olive::core::rational(0)); + + const olive::AudioVisualWaveform::Sample summary = + waveform.GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(1)); + + ASSERT_EQ(summary.size(), 6); + for (size_t ch = 0; ch < 6; ch++) { + const float expected = 0.1f * float(ch + 1); + ExpectSummary(summary, ch, expected, expected); + } +} + +TEST(AudioVisualWaveform, GetSummaryFromTimeOnEmptyWaveformReturnsNullSamples) +{ + olive::AudioVisualWaveform waveform; + waveform.set_channel_count(2); + + const olive::AudioVisualWaveform::Sample summary = + waveform.GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(1)); + + // No data written: one zeroed entry per channel + ASSERT_EQ(summary.size(), 2); + ExpectSummary(summary, 0, 0.0f, 0.0f); + ExpectSummary(summary, 1, 0.0f, 0.0f); +} + +TEST(AudioVisualWaveform, + GetSummaryFromTimeShorterThanOneSampleReturnsNullSamples) +{ + olive::AudioVisualWaveform waveform; + waveform.set_channel_count(1); + waveform.OverwriteSamples(MakeMonoConstant(size_t(kSampleRate), 0.5f), + kSampleRate, olive::core::rational(0)); + + // Shorter than a single frame even at the highest mipmap rate (1024 Hz), + // so the request quantizes down to zero frames + const olive::AudioVisualWaveform::Sample summary = + waveform.GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(1, 100000)); + + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.0f, 0.0f); +} + +// --------------------------------------------------------------------------- +// AudioVisualWaveform::OverwriteSums +// --------------------------------------------------------------------------- + +TEST(AudioVisualWaveform, OverwriteSumsCopiesEntireWaveform) +{ + olive::AudioVisualWaveform source; + source.set_channel_count(1); + source.OverwriteSamples(MakeSplitMonoBuffer(0.25f, 0.75f), kSampleRate, + olive::core::rational(0)); + + olive::AudioVisualWaveform dest; + dest.set_channel_count(1); + dest.OverwriteSums(source, olive::core::rational(0)); + + EXPECT_EQ(dest.length(), olive::core::rational(2)); + + olive::AudioVisualWaveform::Sample summary = + dest.GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.25f, 0.25f); + + summary = dest.GetSummaryFromTime(olive::core::rational(1), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.75f, 0.75f); +} + +TEST(AudioVisualWaveform, OverwriteSumsAtDestinationOffset) +{ + olive::AudioVisualWaveform source; + source.set_channel_count(1); + source.OverwriteSamples(MakeSplitMonoBuffer(0.25f, 0.75f), kSampleRate, + olive::core::rational(0)); + + olive::AudioVisualWaveform dest; + dest.set_channel_count(1); + dest.OverwriteSums(source, olive::core::rational(1)); + + EXPECT_EQ(dest.length(), olive::core::rational(3)); + + // The copied data lands one second later; because the destination's + // virtual start moved to the destination offset, only [1, 3) can be + // queried safely + olive::AudioVisualWaveform::Sample summary = + dest.GetSummaryFromTime(olive::core::rational(1), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.25f, 0.25f); + + summary = dest.GetSummaryFromTime(olive::core::rational(2), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.75f, 0.75f); +} + +TEST(AudioVisualWaveform, OverwriteSumsWithSourceOffset) +{ + olive::AudioVisualWaveform source; + source.set_channel_count(1); + source.OverwriteSamples(MakeSplitMonoBuffer(0.25f, 0.75f), kSampleRate, + olive::core::rational(0)); + + olive::AudioVisualWaveform dest; + dest.set_channel_count(1); + dest.OverwriteSums(source, olive::core::rational(0), + olive::core::rational(1)); + + // Only the source's second second is copied + EXPECT_EQ(dest.length(), olive::core::rational(1)); + + const olive::AudioVisualWaveform::Sample summary = + dest.GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.75f, 0.75f); +} + +TEST(AudioVisualWaveform, OverwriteSumsWithLengthLimit) +{ + olive::AudioVisualWaveform source; + source.set_channel_count(1); + source.OverwriteSamples(MakeSplitMonoBuffer(0.25f, 0.75f), kSampleRate, + olive::core::rational(0)); + + olive::AudioVisualWaveform dest; + dest.set_channel_count(1); + dest.OverwriteSums(source, olive::core::rational(0), + olive::core::rational(0), olive::core::rational(1)); + + // Only the source's first second is copied + EXPECT_EQ(dest.length(), olive::core::rational(1)); + + const olive::AudioVisualWaveform::Sample summary = + dest.GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.25f, 0.25f); +} + +TEST(AudioVisualWaveform, OverwriteSumsWithOffsetBeyondSourceIsIgnored) +{ + olive::AudioVisualWaveform source; + source.set_channel_count(1); + source.OverwriteSamples(MakeSplitMonoBuffer(0.25f, 0.75f), kSampleRate, + olive::core::rational(0)); + + olive::AudioVisualWaveform dest; + dest.set_channel_count(1); + dest.OverwriteSums(source, olive::core::rational(0), + olive::core::rational(10)); + + // The offset starts past the end of every source mipmap, so nothing is + // copied and the destination stays empty + EXPECT_EQ(dest.length(), olive::core::rational(0)); + + const olive::AudioVisualWaveform::Sample summary = + dest.GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.0f, 0.0f); +} + +// --------------------------------------------------------------------------- +// AudioVisualWaveform::OverwriteSilence +// --------------------------------------------------------------------------- + +TEST(AudioVisualWaveform, OverwriteSilenceZeroesRange) +{ + olive::AudioVisualWaveform waveform; + waveform.set_channel_count(2); + + waveform.OverwriteSamples( + MakeConstantBuffer(MakeParams(olive::core::kChannelLayoutStereo), + size_t(kSampleRate * 2), 0.8f), + kSampleRate, olive::core::rational(0)); + + // Silence [0.5, 1.5) + waveform.OverwriteSilence(olive::core::rational(1, 2), + olive::core::rational(1)); + + olive::AudioVisualWaveform::Sample summary = + waveform.GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(1, 2)); + ASSERT_EQ(summary.size(), 2); + ExpectSummary(summary, 0, 0.8f, 0.8f); + ExpectSummary(summary, 1, 0.8f, 0.8f); + + summary = waveform.GetSummaryFromTime(olive::core::rational(1, 2), + olive::core::rational(1, 2)); + ASSERT_EQ(summary.size(), 2); + ExpectSummary(summary, 0, 0.0f, 0.0f); + ExpectSummary(summary, 1, 0.0f, 0.0f); + + summary = waveform.GetSummaryFromTime(olive::core::rational(3, 2), + olive::core::rational(1, 2)); + ASSERT_EQ(summary.size(), 2); + ExpectSummary(summary, 0, 0.8f, 0.8f); + ExpectSummary(summary, 1, 0.8f, 0.8f); +} + +TEST(AudioVisualWaveform, OverwriteSilenceExtendsLength) +{ + olive::AudioVisualWaveform waveform; + waveform.set_channel_count(1); + waveform.OverwriteSamples(MakeMonoConstant(size_t(kSampleRate), 0.5f), + kSampleRate, olive::core::rational(0)); + ASSERT_EQ(waveform.length(), olive::core::rational(1)); + + // Silencing past the end grows the buffer with zeros + waveform.OverwriteSilence(olive::core::rational(2), + olive::core::rational(1)); + + EXPECT_EQ(waveform.length(), olive::core::rational(3)); + + const olive::AudioVisualWaveform::Sample summary = + waveform.GetSummaryFromTime(olive::core::rational(2), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.0f, 0.0f); +} + +// --------------------------------------------------------------------------- +// AudioVisualWaveform::Mid / Resize / TrimIn +// --------------------------------------------------------------------------- + +TEST(AudioVisualWaveform, MidFromOffsetReturnsTail) +{ + olive::AudioVisualWaveform waveform; + waveform.set_channel_count(1); + waveform.OverwriteSamples(MakeSplitMonoBuffer(0.25f, 0.75f), kSampleRate, + olive::core::rational(0)); + + const olive::AudioVisualWaveform mid = + waveform.Mid(olive::core::rational(1)); + + EXPECT_EQ(mid.length(), olive::core::rational(1)); + EXPECT_EQ(mid.channel_count(), 1); + + // The original is untouched + EXPECT_EQ(waveform.length(), olive::core::rational(2)); + + const olive::AudioVisualWaveform::Sample summary = + mid.GetSummaryFromTime(olive::core::rational(1), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.75f, 0.75f); +} + +TEST(AudioVisualWaveform, ResizeExtendPadsWithZeros) +{ + olive::AudioVisualWaveform waveform; + waveform.set_channel_count(1); + waveform.OverwriteSamples(MakeMonoConstant(size_t(kSampleRate), 0.5f), + kSampleRate, olive::core::rational(0)); + + waveform.Resize(olive::core::rational(3)); + + EXPECT_EQ(waveform.length(), olive::core::rational(3)); + + olive::AudioVisualWaveform::Sample summary = + waveform.GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.5f, 0.5f); + + // The extended region is zero-filled + summary = waveform.GetSummaryFromTime(olive::core::rational(2), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.0f, 0.0f); +} + +TEST(AudioVisualWaveform, TrimInZeroIsNoOp) +{ + olive::AudioVisualWaveform waveform; + waveform.set_channel_count(1); + waveform.OverwriteSamples(MakeMonoConstant(size_t(kSampleRate), 0.5f), + kSampleRate, olive::core::rational(0)); + + waveform.TrimIn(olive::core::rational(0)); + + EXPECT_EQ(waveform.length(), olive::core::rational(1)); + + const olive::AudioVisualWaveform::Sample summary = + waveform.GetSummaryFromTime(olive::core::rational(0), + olive::core::rational(1)); + ASSERT_EQ(summary.size(), 1); + ExpectSummary(summary, 0, 0.5f, 0.5f); +} + +// --------------------------------------------------------------------------- +// AudioVisualWaveform::SumSamples / ReSumSamples +// --------------------------------------------------------------------------- + +TEST(AudioVisualWaveform, SumSamplesHonorsStartOffsetAndChannels) +{ + olive::core::SampleBuffer buffer( + MakeParams(olive::core::kChannelLayoutStereo), size_t(100)); + float *left = buffer.data(0); + float *right = buffer.data(1); + for (size_t i = 0; i < buffer.sample_count(); i++) { + left[i] = float(i) / 100.0f; + right[i] = -float(i) / 100.0f; + } + + // Summarize samples [10, 30) only + const olive::AudioVisualWaveform::Sample summary = + olive::AudioVisualWaveform::SumSamples(buffer, 10, 20); + + ASSERT_EQ(summary.size(), 2); + // SumSamples (unlike ReSumSamples) reports the true extremes of the range + EXPECT_FLOAT_EQ(summary.at(0).min, float(10) / 100.0f); + EXPECT_FLOAT_EQ(summary.at(0).max, float(29) / 100.0f); + EXPECT_FLOAT_EQ(summary.at(1).min, -float(29) / 100.0f); + EXPECT_FLOAT_EQ(summary.at(1).max, -float(10) / 100.0f); +} + +TEST(AudioVisualWaveform, ReSumSamplesMergesExtremesAcrossFrames) +{ + std::vector frames(4); + frames[0] = { -0.2f, 0.3f }; // channel 0, narrow range + frames[1] = { 0.0f, 0.1f }; // channel 1 + frames[2] = { -0.9f, 0.1f }; // channel 0, wider minimum + frames[3] = { 0.05f, 0.08f }; // channel 1 + + const olive::AudioVisualWaveform::Sample summary = + olive::AudioVisualWaveform::ReSumSamples(frames.data(), 4, 2); + + ASSERT_EQ(summary.size(), 2); + ExpectSummary(summary, 0, -0.9f, 0.3f); + ExpectSummary(summary, 1, 0.0f, 0.1f); +} + +TEST(AudioVisualWaveform, ReSumSamplesSingleFrameIsIdentity) +{ + std::vector frames(2); + frames[0] = { -0.3f, 0.7f }; + frames[1] = { -0.1f, 0.2f }; + + const olive::AudioVisualWaveform::Sample summary = + olive::AudioVisualWaveform::ReSumSamples(frames.data(), 2, 2); + + ASSERT_EQ(summary.size(), 2); + ExpectSummary(summary, 0, -0.3f, 0.7f); + ExpectSummary(summary, 1, -0.1f, 0.2f); +} + +// --------------------------------------------------------------------------- +// AudioVisualWaveform::DrawSample +// --------------------------------------------------------------------------- + +TEST(AudioVisualWaveform, DrawSamplePaintsVerticalSpan) +{ + QImage image(4, 100, QImage::Format_ARGB32); + image.fill(Qt::transparent); + + { + QPainter painter(&image); + const olive::AudioVisualWaveform::Sample sample = { { -1.0f, 1.0f } }; + olive::AudioVisualWaveform::DrawSample(&painter, sample, 1, 0, 100, + false); + } + + // A full-scale sample spans the whole column + EXPECT_GT(image.pixelColor(1, 10).alpha(), 0); + EXPECT_GT(image.pixelColor(1, 90).alpha(), 0); +} + +TEST(AudioVisualWaveform, DrawSampleIgnoresEmptySample) +{ + QImage image(4, 100, QImage::Format_ARGB32); + image.fill(Qt::transparent); + + { + QPainter painter(&image); + olive::AudioVisualWaveform::DrawSample( + &painter, olive::AudioVisualWaveform::Sample(), 1, 0, 100, false); + } + + EXPECT_EQ(image.pixelColor(1, 10).alpha(), 0); +} + +// --------------------------------------------------------------------------- +// AudioProcessor +// --------------------------------------------------------------------------- + +TEST(AudioProcessor, ConvertPassthroughCopiesInputSamples) +{ + olive::AudioProcessor processor; + const olive::core::AudioParams params = + MakeParams(olive::core::kChannelLayoutStereo); + ASSERT_TRUE(processor.Open(params, params, 1.0)); + + constexpr int kSamples = 1024; + std::vector left(kSamples, 0.5f); + std::vector right(kSamples, -0.25f); + float *input[2] = { left.data(), right.data() }; + + olive::AudioProcessor::Buffer output; + EXPECT_EQ(processor.Convert(input, kSamples, &output), 0); + + // Planar output keeps one byte plane per channel + ASSERT_EQ(output.size(), 2); + ASSERT_EQ(output.at(0).size(), kSamples * int(sizeof(float))); + ASSERT_EQ(output.at(1).size(), kSamples * int(sizeof(float))); + + float value = 0.0f; + std::memcpy(&value, output.at(0).constData(), sizeof(float)); + EXPECT_FLOAT_EQ(value, 0.5f); + std::memcpy(&value, + output.at(0).constData() + (kSamples - 1) * sizeof(float), + sizeof(float)); + EXPECT_FLOAT_EQ(value, 0.5f); + std::memcpy(&value, output.at(1).constData(), sizeof(float)); + EXPECT_FLOAT_EQ(value, -0.25f); +} + +TEST(AudioProcessor, ConvertToPackedInterleavesChannels) +{ + olive::AudioProcessor processor; + const olive::core::AudioParams from = + MakeParams(olive::core::kChannelLayoutStereo); + const olive::core::AudioParams to(kSampleRate, + olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32); + ASSERT_TRUE(processor.Open(from, to, 1.0)); + + constexpr int kSamples = 1024; + std::vector left(kSamples, 0.5f); + std::vector right(kSamples, -0.25f); + float *input[2] = { left.data(), right.data() }; + + olive::AudioProcessor::Buffer output; + EXPECT_EQ(processor.Convert(input, kSamples, &output), 0); + + // Packed output folds both channels into a single interleaved plane + ASSERT_EQ(output.size(), 1); + ASSERT_EQ(output.at(0).size(), kSamples * 2 * int(sizeof(float))); + + float left_value = 0.0f; + float right_value = 0.0f; + std::memcpy(&left_value, output.at(0).constData(), sizeof(float)); + std::memcpy(&right_value, output.at(0).constData() + sizeof(float), + sizeof(float)); + EXPECT_FLOAT_EQ(left_value, 0.5f); + EXPECT_FLOAT_EQ(right_value, -0.25f); +} + +TEST(AudioProcessor, ConvertDownmixToMonoReducesPlaneCount) +{ + olive::AudioProcessor processor; + ASSERT_TRUE(processor.Open(MakeParams(olive::core::kChannelLayoutStereo), + MakeParams(olive::core::kChannelLayoutMono), + 1.0)); + + constexpr int kSamples = 1024; + std::vector left(kSamples, 0.5f); + std::vector right(kSamples, 0.5f); + float *input[2] = { left.data(), right.data() }; + + olive::AudioProcessor::Buffer output; + EXPECT_EQ(processor.Convert(input, kSamples, &output), 0); + + ASSERT_EQ(output.size(), 1); + ASSERT_EQ(output.at(0).size(), kSamples * int(sizeof(float))); + + // The downmix of two identical channels must stay audible regardless of + // the exact mixing coefficients + float value = 0.0f; + std::memcpy(&value, output.at(0).constData(), sizeof(float)); + EXPECT_GT(value, 0.0f); + EXPECT_LE(value, 1.0f); +} + +TEST(AudioProcessor, ConvertResampleDrainProducesExpectedSampleCount) +{ + olive::AudioProcessor processor; + const olive::core::AudioParams from = + MakeParams(olive::core::kChannelLayoutStereo); + const olive::core::AudioParams to(kSampleRate / 2, + olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P); + ASSERT_TRUE(processor.Open(from, to, 1.0)); + + // One second of input + constexpr int kSamples = 48000; + std::vector left(kSamples, 0.5f); + std::vector right(kSamples, 0.5f); + float *input[2] = { left.data(), right.data() }; + + const olive::AudioProcessor::Buffer output = + ConvertAndDrain(processor, input, kSamples); + + ASSERT_EQ(output.size(), 2); + EXPECT_EQ(output.at(0).size(), output.at(1).size()); + + // 48 kHz downsampled to 24 kHz must produce about half the samples; the + // resampler's filter delay makes the exact total version-dependent + const int converted = output.at(0).size() / int(sizeof(float)); + EXPECT_GE(converted, 23000); + EXPECT_LE(converted, 24500); +} + +TEST(AudioProcessor, ConvertTempoDrainReducesSampleCount) +{ + olive::AudioProcessor processor; + const olive::core::AudioParams params = + MakeParams(olive::core::kChannelLayoutStereo); + ASSERT_TRUE(processor.Open(params, params, 2.0)); + + // One second of input + constexpr int kSamples = 48000; + std::vector left(kSamples, 0.5f); + std::vector right(kSamples, 0.5f); + float *input[2] = { left.data(), right.data() }; + + const olive::AudioProcessor::Buffer output = + ConvertAndDrain(processor, input, kSamples); + + ASSERT_EQ(output.size(), 2); + + // 2x tempo must output roughly half the input; atempo works in windows, + // so allow generous margins + const int converted = output.at(0).size() / int(sizeof(float)); + EXPECT_GE(converted, 20000); + EXPECT_LE(converted, 28000); +} + +TEST(AudioProcessor, ConvertWithNullOutputOnlyPushes) +{ + olive::AudioProcessor processor; + const olive::core::AudioParams params = + MakeParams(olive::core::kChannelLayoutStereo); + ASSERT_TRUE(processor.Open(params, params, 1.0)); + + constexpr int kSamples = 1024; + std::vector left(kSamples, 0.5f); + std::vector right(kSamples, 0.5f); + float *input[2] = { left.data(), right.data() }; + + // A null output buffer means push-only and is not an error + EXPECT_EQ(processor.Convert(input, kSamples, nullptr), 0); +} + +TEST(AudioProcessor, ConvertWithNoInputReturnsZeroWithEmptyPlanes) +{ + olive::AudioProcessor processor; + const olive::core::AudioParams params = + MakeParams(olive::core::kChannelLayoutStereo); + ASSERT_TRUE(processor.Open(params, params, 1.0)); + + olive::AudioProcessor::Buffer output; + EXPECT_EQ(processor.Convert(nullptr, 0, &output), 0); + + // The output is still sized to the planar channel count, but empty + ASSERT_EQ(output.size(), 2); + EXPECT_TRUE(output.at(0).isEmpty()); + EXPECT_TRUE(output.at(1).isEmpty()); +} + +TEST(AudioProcessor, OpenFixesZeroChannelLayout) +{ + olive::AudioProcessor processor; + + // A zero layout mask is unusable by the filter graph and must be replaced + // with a default layout derived from the channel count + const olive::core::AudioParams from(kSampleRate, 0, + olive::core::SampleFormat::F32P); + const olive::core::AudioParams to = + MakeParams(olive::core::kChannelLayoutStereo); + + ASSERT_TRUE(processor.Open(from, to, 1.0)); + EXPECT_NE(processor.from().channel_layout(), uint64_t(0)); + EXPECT_EQ(processor.from().channel_count(), 2); +} + +TEST(AudioProcessor, CloseIsIdempotentAndReopenSucceeds) +{ + olive::AudioProcessor processor; + const olive::core::AudioParams params = + MakeParams(olive::core::kChannelLayoutStereo); + + // Closing an unopened processor must be safe + processor.Close(); + EXPECT_FALSE(processor.IsOpen()); + + ASSERT_TRUE(processor.Open(params, params, 1.0)); + processor.Close(); + processor.Close(); + EXPECT_FALSE(processor.IsOpen()); + + EXPECT_TRUE(processor.Open(params, params, 1.0)); + EXPECT_TRUE(processor.IsOpen()); +} + +TEST(AudioProcessor, FlushWithoutOpenDoesNotCrash) +{ + olive::AudioProcessor processor; + + // Logs an error but must not crash + processor.Flush(); + EXPECT_FALSE(processor.IsOpen()); +} diff --git a/tests/gtest/clip_traverser_test.cpp b/tests/gtest/clip_traverser_test.cpp new file mode 100644 index 000000000..69eb4c88e --- /dev/null +++ b/tests/gtest/clip_traverser_test.cpp @@ -0,0 +1,1012 @@ +#include + +#include +#include + +#include +#include + +#include "node/block/clip/clip.h" +#include "node/generator/solid/solid.h" +#include "node/globals.h" +#include "node/input/time/timeinput.h" +#include "node/output/track/track.h" +#include "node/output/viewer/viewer.h" +#include "node/color/colormanager/colormanager.h" +#include "node/project.h" +#include "node/traverser.h" +#include "olive/core/util/rational.h" +#include "olive/core/util/timerange.h" +#include "render/framehashcache.h" +#include "render/loopmode.h" +#include "render/playbackcache.h" + +namespace +{ + +// Records the loop mode seen in NodeGlobals so the traverser's clip loop +// mode pick-up can be observed. +class LoopModeProbeNode : public olive::Node { +public: + LoopModeProbeNode() = default; + + NODE_DEFAULT_FUNCTIONS(LoopModeProbeNode) + + virtual QString Name() const override + { + return QStringLiteral("Loop Mode Probe"); + } + + virtual QString id() const override + { + return QStringLiteral("org.oak.test.loopmodeprobe"); + } + + virtual QVector Category() const override + { + return {}; + } + + virtual void Value(const olive::NodeValueRow &value, + const olive::NodeGlobals &globals, + olive::NodeValueTable *table) const override + { + Q_UNUSED(value) + + last_loop_mode_ = globals.loop_mode(); + table->Push(olive::NodeValue::kFloat, 0.0, this); + } + + olive::LoopMode last_loop_mode() const + { + return last_loop_mode_; + } + +private: + mutable olive::LoopMode last_loop_mode_ = olive::LoopMode::kLoopModeOff; +}; + +// Node with two connectable inputs and a configurable gizmo transformation, +// used to verify which nodes the traverser accumulates transforms from. +class GizmoProbeNode : public olive::Node { +public: + GizmoProbeNode() + { + AddInput(QStringLiteral("a_in"), olive::NodeValue::kFloat, 0.0); + AddInput(QStringLiteral("b_in"), olive::NodeValue::kFloat, 0.0); + } + + NODE_DEFAULT_FUNCTIONS(GizmoProbeNode) + + virtual QString Name() const override + { + return QStringLiteral("Gizmo Probe"); + } + + virtual QString id() const override + { + return QStringLiteral("org.oak.test.gizmoprobe"); + } + + virtual QVector Category() const override + { + return {}; + } + + void SetGizmoTransform(const QTransform &t) + { + t_ = t; + } + + virtual QTransform + GizmoTransformation(const olive::NodeValueRow &row, + const olive::NodeGlobals &globals) const override + { + Q_UNUSED(row) + Q_UNUSED(globals) + + return t_; + } + + 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::kFloat, 0.0, this); + } + + static QString InputA() + { + return QStringLiteral("a_in"); + } + + static QString InputB() + { + return QStringLiteral("b_in"); + } + +private: + QTransform t_; +}; + +olive::ClipBlock *CreateClip(olive::Project *project, + const olive::core::rational &length) +{ + auto *clip = new olive::ClipBlock(); + clip->setParent(project); + clip->set_length_and_media_out(length); + return clip; +} + +olive::SolidGenerator *CreateSolid(olive::Project *project) +{ + auto *solid = new olive::SolidGenerator(); + solid->setParent(project); + return solid; +} + +// Generates the clip's output table at a single time with a fresh traverser +// (the traverser caches tables per node+range, so reusing one would return +// stale values after the clip's parameters change). +olive::NodeValueTable GenerateClipTable(const olive::ClipBlock *clip, + const olive::core::rational &time) +{ + olive::NodeTraverser traverser; + return traverser.GenerateTable( + clip, olive::TimeRange(time, time + olive::core::rational(1, 30))); +} + +double GenerateClipTimeValue(const olive::ClipBlock *clip, + const olive::core::rational &time) +{ + olive::NodeValueTable table = GenerateClipTable(clip, time); + olive::NodeValue v = table.Get(olive::NodeValue::kFloat); + if (v.type() != olive::NodeValue::kFloat) { + return std::numeric_limits::quiet_NaN(); + } + return v.toDouble(); +} + +} // namespace + +TEST(ClipBlock, DefaultState) +{ + olive::ClipBlock clip; + + EXPECT_DOUBLE_EQ(clip.speed(), 1.0); + EXPECT_FALSE(clip.reverse()); + EXPECT_EQ(clip.loop_mode(), olive::LoopMode::kLoopModeOff); + EXPECT_EQ(clip.media_in(), olive::core::rational(0)); + EXPECT_FALSE(clip.maintain_audio_pitch()); + EXPECT_FALSE(clip.IsAutocaching()); + EXPECT_EQ(clip.in_transition(), nullptr); + EXPECT_EQ(clip.out_transition(), nullptr); + EXPECT_EQ(clip.connected_viewer(), nullptr); + EXPECT_EQ(clip.GetTrackType(), olive::Track::kNone); + EXPECT_TRUE(clip.block_links().isEmpty()); + + EXPECT_EQ(clip.length(), olive::core::rational(0)); + EXPECT_EQ(clip.GetVideoCacheRange(), + olive::TimeRange(olive::core::rational(0), olive::core::rational(0))); + EXPECT_EQ(clip.GetAudioCacheRange(), + olive::TimeRange(olive::core::rational(0), olive::core::rational(0))); + + EXPECT_EQ(clip.id(), QStringLiteral("org.olivevideoeditor.Olive.clip")); + EXPECT_EQ(clip.Name(), QStringLiteral("Clip")); + EXPECT_FALSE(clip.Description().isEmpty()); + EXPECT_TRUE(clip.Category().contains(olive::Node::kCategoryTimeline)); +} + +TEST(ClipBlock, SpeedReverseLoopPitchAutocacheAccessors) +{ + olive::ClipBlock clip; + + clip.SetStandardValue(olive::ClipBlock::kSpeedInput, 2.5); + EXPECT_DOUBLE_EQ(clip.speed(), 2.5); + + clip.set_reverse(true); + EXPECT_TRUE(clip.reverse()); + clip.set_reverse(false); + EXPECT_FALSE(clip.reverse()); + + clip.set_loop_mode(olive::LoopMode::kLoopModeLoop); + EXPECT_EQ(clip.loop_mode(), olive::LoopMode::kLoopModeLoop); + clip.set_loop_mode(olive::LoopMode::kLoopModeClamp); + EXPECT_EQ(clip.loop_mode(), olive::LoopMode::kLoopModeClamp); + clip.set_loop_mode(olive::LoopMode::kLoopModeOff); + EXPECT_EQ(clip.loop_mode(), olive::LoopMode::kLoopModeOff); + + clip.set_maintain_audio_pitch(true); + EXPECT_TRUE(clip.maintain_audio_pitch()); + + clip.SetAutocache(true); + EXPECT_TRUE(clip.IsAutocaching()); + clip.SetAutocache(false); + EXPECT_FALSE(clip.IsAutocaching()); +} + +TEST(ClipBlock, LoopModeChangeEmitsPreviewChanged) +{ + olive::ClipBlock clip; + + int emissions = 0; + QObject::connect(&clip, &olive::Block::PreviewChanged, + [&emissions]() { ++emissions; }); + + clip.set_loop_mode(olive::LoopMode::kLoopModeLoop); + EXPECT_EQ(emissions, 1); + + clip.set_loop_mode(olive::LoopMode::kLoopModeClamp); + EXPECT_EQ(emissions, 2); +} + +TEST(ClipBlock, MediaInAccessor) +{ + olive::ClipBlock clip; + + clip.set_media_in(olive::core::rational(5)); + EXPECT_EQ(clip.media_in(), olive::core::rational(5)); + EXPECT_EQ(clip.GetStandardValue(olive::ClipBlock::kMediaInInput) + .value(), + olive::core::rational(5)); +} + +TEST(ClipBlock, InputTimeAdjustmentPassesThroughByDefault) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + + const olive::TimeRange range(olive::core::rational(2), + olive::core::rational(4)); + + // A default clip (speed 1, no reverse, no media in) maps times unchanged + EXPECT_EQ(clip->InputTimeAdjustment(olive::ClipBlock::kBufferIn, -1, range, + true), + range); + + // Non-buffer inputs never adjust time + EXPECT_EQ(clip->InputTimeAdjustment(olive::ClipBlock::kSpeedInput, -1, + range, true), + range); + EXPECT_EQ(clip->OutputTimeAdjustment(olive::ClipBlock::kSpeedInput, -1, + range), + range); +} + +TEST(ClipBlock, InputTimeAdjustmentAppliesSpeed) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + + clip->SetStandardValue(olive::ClipBlock::kSpeedInput, 2.0); + EXPECT_EQ(clip->InputTimeAdjustment( + olive::ClipBlock::kBufferIn, -1, + olive::TimeRange(olive::core::rational(2), + olive::core::rational(4)), + true), + olive::TimeRange(olive::core::rational(4), + olive::core::rational(8))); + + clip->SetStandardValue(olive::ClipBlock::kSpeedInput, 0.5); + EXPECT_EQ(clip->InputTimeAdjustment( + olive::ClipBlock::kBufferIn, -1, + olive::TimeRange(olive::core::rational(2), + olive::core::rational(4)), + true), + olive::TimeRange(olive::core::rational(1), + olive::core::rational(2))); + + // Media in is added after the speed multiplication + clip->SetStandardValue(olive::ClipBlock::kSpeedInput, 2.0); + clip->set_media_in(olive::core::rational(3)); + EXPECT_EQ(clip->InputTimeAdjustment( + olive::ClipBlock::kBufferIn, -1, + olive::TimeRange(olive::core::rational(2), + olive::core::rational(4)), + true), + olive::TimeRange(olive::core::rational(7), + olive::core::rational(11))); +} + +TEST(ClipBlock, InputTimeAdjustmentZeroSpeedHoldsAtMediaIn) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + + clip->SetStandardValue(olive::ClipBlock::kSpeedInput, 0.0); + clip->set_media_in(olive::core::rational(5)); + + // Zero speed collapses every sequence time onto the media in point + const olive::TimeRange adjusted = clip->InputTimeAdjustment( + olive::ClipBlock::kBufferIn, -1, + olive::TimeRange(olive::core::rational(2), olive::core::rational(4)), + true); + EXPECT_EQ(adjusted.in(), olive::core::rational(5)); + EXPECT_EQ(adjusted.out(), olive::core::rational(5)); +} + +TEST(ClipBlock, InputTimeAdjustmentAppliesReverse) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + + clip->set_reverse(true); + + // Reverse mirrors time around the clip length; TimeRange normalizes the + // resulting inverted range + EXPECT_EQ(clip->InputTimeAdjustment( + olive::ClipBlock::kBufferIn, -1, + olive::TimeRange(olive::core::rational(2), + olive::core::rational(3)), + true), + olive::TimeRange(olive::core::rational(7), + olive::core::rational(8))); +} + +TEST(ClipBlock, InputTimeAdjustmentCombinesReverseSpeedAndMediaIn) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + + clip->set_reverse(true); + clip->SetStandardValue(olive::ClipBlock::kSpeedInput, 2.0); + clip->set_media_in(olive::core::rational(5)); + + // (10 - 2) * 2 + 5 = 21, (10 - 3) * 2 + 5 = 19, normalized to [19, 21] + EXPECT_EQ(clip->InputTimeAdjustment( + olive::ClipBlock::kBufferIn, -1, + olive::TimeRange(olive::core::rational(2), + olive::core::rational(3)), + true), + olive::TimeRange(olive::core::rational(19), + olive::core::rational(21))); +} + +TEST(ClipBlock, InputTimeAdjustmentPassesThroughInfinities) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + + clip->set_reverse(true); + clip->SetStandardValue(olive::ClipBlock::kSpeedInput, 2.0); + clip->set_media_in(olive::core::rational(5)); + + const olive::core::rational kMin(INT_MIN); + const olive::core::rational kMax(INT_MAX); + const olive::TimeRange infinite(kMin, kMax); + + EXPECT_EQ(clip->InputTimeAdjustment(olive::ClipBlock::kBufferIn, -1, + infinite, true), + infinite); + EXPECT_EQ(clip->OutputTimeAdjustment(olive::ClipBlock::kBufferIn, -1, + infinite), + infinite); +} + +TEST(ClipBlock, OutputTimeAdjustmentInvertsInputAdjustment) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + + clip->SetStandardValue(olive::ClipBlock::kSpeedInput, 2.0); + clip->set_media_in(olive::core::rational(5)); + + // Media time is converted back by subtracting media in and dividing speed + EXPECT_EQ(clip->OutputTimeAdjustment( + olive::ClipBlock::kBufferIn, -1, + olive::TimeRange(olive::core::rational(5), + olive::core::rational(9))), + olive::TimeRange(olive::core::rational(0), + olive::core::rational(2))); + + // Round trip through both adjustments returns the original range + const olive::TimeRange range(olive::core::rational(1), + olive::core::rational(3)); + EXPECT_EQ(clip->OutputTimeAdjustment( + olive::ClipBlock::kBufferIn, -1, + clip->InputTimeAdjustment(olive::ClipBlock::kBufferIn, -1, + range, true)), + range); + + // Round trip also holds in reverse + clip->set_reverse(true); + EXPECT_EQ(clip->OutputTimeAdjustment( + olive::ClipBlock::kBufferIn, -1, + clip->InputTimeAdjustment(olive::ClipBlock::kBufferIn, -1, + range, true)), + range); +} + +TEST(ClipBlock, MediaRangeReflectsSpeedReverseAndMediaIn) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + + EXPECT_EQ(clip->media_range(), + olive::TimeRange(olive::core::rational(0), + olive::core::rational(10))); + + clip->set_media_in(olive::core::rational(5)); + EXPECT_EQ(clip->media_range(), + olive::TimeRange(olive::core::rational(5), + olive::core::rational(15))); + + // A 2x clip consumes twice its length in media time + clip->set_media_in(olive::core::rational(0)); + clip->SetStandardValue(olive::ClipBlock::kSpeedInput, 2.0); + EXPECT_EQ(clip->media_range(), + olive::TimeRange(olive::core::rational(0), + olive::core::rational(20))); + + // Reverse maps the same media extent (the range normalizes) + clip->SetStandardValue(olive::ClipBlock::kSpeedInput, 1.0); + clip->set_media_in(olive::core::rational(5)); + clip->set_reverse(true); + EXPECT_EQ(clip->media_range(), + olive::TimeRange(olive::core::rational(5), + olive::core::rational(15))); +} + +TEST(ClipBlock, SetLengthAndMediaOutInReversePreservesMediaOut) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + + clip->set_reverse(true); + + // Trimming the out point of a reversed clip moves the media in point so + // that the media out point is preserved + clip->set_length_and_media_out(olive::core::rational(4)); + + EXPECT_EQ(clip->length(), olive::core::rational(4)); + EXPECT_EQ(clip->media_in(), olive::core::rational(6)); + EXPECT_EQ(clip->media_range(), + olive::TimeRange(olive::core::rational(6), + olive::core::rational(10))); +} + +TEST(ClipBlock, SetLengthAndMediaInForwardAdjustsMediaIn) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + + // Trimming the in point of a forward clip pushes the media in point + // forward by the removed amount + clip->set_length_and_media_in(olive::core::rational(4)); + + EXPECT_EQ(clip->length(), olive::core::rational(4)); + EXPECT_EQ(clip->media_in(), olive::core::rational(6)); + EXPECT_EQ(clip->media_range(), + olive::TimeRange(olive::core::rational(6), + olive::core::rational(10))); +} + +TEST(ClipBlock, ConnectedCacheAccessors) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + + EXPECT_EQ(clip->connected_video_cache(), nullptr); + EXPECT_EQ(clip->connected_audio_cache(), nullptr); + EXPECT_EQ(clip->thumbnails(), nullptr); + EXPECT_EQ(clip->waveform(), nullptr); + + olive::SolidGenerator *solid = CreateSolid(&project); + olive::Node::ConnectEdge(solid, + olive::NodeInput(clip, olive::ClipBlock::kBufferIn)); + + EXPECT_EQ(clip->connected_video_cache(), solid->video_frame_cache()); + EXPECT_EQ(clip->connected_audio_cache(), solid->audio_playback_cache()); + EXPECT_EQ(clip->thumbnails(), solid->thumbnail_cache()); + EXPECT_EQ(clip->waveform(), solid->waveform_cache()); +} + +TEST(ClipBlock, InvalidateCacheTransformsMediaTimeToSequenceTime) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + clip->SetStandardValue(olive::ClipBlock::kSpeedInput, 2.0); + + olive::SolidGenerator *solid = CreateSolid(&project); + olive::Node::ConnectEdge(solid, + olive::NodeInput(clip, olive::ClipBlock::kBufferIn)); + + QVector invalidated; + QObject::connect(clip->video_frame_cache(), + &olive::PlaybackCache::Invalidated, + [&invalidated](const olive::TimeRange &r) { + invalidated.append(r); + }); + + // An invalidation in media time [4,8] covers sequence time [2,4] at 2x + solid->InvalidateCache(olive::TimeRange(olive::core::rational(4), + olive::core::rational(8)), + olive::SolidGenerator::kColorInput); + + ASSERT_EQ(invalidated.size(), 1); + EXPECT_EQ(invalidated.first(), + olive::TimeRange(olive::core::rational(2), + olive::core::rational(4))); +} + +TEST(ClipBlock, InvalidateCacheReverseTransformsRange) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + clip->set_reverse(true); + + olive::SolidGenerator *solid = CreateSolid(&project); + olive::Node::ConnectEdge(solid, + olive::NodeInput(clip, olive::ClipBlock::kBufferIn)); + + QVector invalidated; + QObject::connect(clip->video_frame_cache(), + &olive::PlaybackCache::Invalidated, + [&invalidated](const olive::TimeRange &r) { + invalidated.append(r); + }); + + // Media time [4,8] maps to sequence time [2,6] when reversed + solid->InvalidateCache(olive::TimeRange(olive::core::rational(4), + olive::core::rational(8)), + olive::SolidGenerator::kColorInput); + + ASSERT_EQ(invalidated.size(), 1); + EXPECT_EQ(invalidated.first(), + olive::TimeRange(olive::core::rational(2), + olive::core::rational(6))); +} + +TEST(ClipBlock, InvalidateCacheZeroSpeedInvalidatesWholeClip) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + clip->SetStandardValue(olive::ClipBlock::kSpeedInput, 0.0); + + olive::SolidGenerator *solid = CreateSolid(&project); + olive::Node::ConnectEdge(solid, + olive::NodeInput(clip, olive::ClipBlock::kBufferIn)); + + QVector invalidated; + QObject::connect(clip->video_frame_cache(), + &olive::PlaybackCache::Invalidated, + [&invalidated](const olive::TimeRange &r) { + invalidated.append(r); + }); + + // With zero speed any media invalidation affects the whole clip + solid->InvalidateCache(olive::TimeRange(olive::core::rational(4), + olive::core::rational(8)), + olive::SolidGenerator::kColorInput); + + ASSERT_EQ(invalidated.size(), 1); + EXPECT_EQ(invalidated.first(), + olive::TimeRange(olive::core::rational(0), + olive::core::rational(10))); +} + +TEST(ClipBlock, InvalidateCacheWithVideoTrackReachesConnectedCaches) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *track = new olive::Track(); + track->setParent(&project); + track->set_type(olive::Track::kVideo); + + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + track->AppendBlock(clip); + + olive::SolidGenerator *solid = CreateSolid(&project); + olive::Node::ConnectEdge(solid, + olive::NodeInput(clip, olive::ClipBlock::kBufferIn)); + + QVector invalidated; + QObject::connect(solid->video_frame_cache(), + &olive::PlaybackCache::Invalidated, + [&invalidated](const olive::TimeRange &r) { + invalidated.append(r); + }); + QVector requested; + QObject::connect(solid->video_frame_cache(), + &olive::PlaybackCache::Requested, + [&requested](olive::ViewerOutput *, const olive::TimeRange &r) { + requested.append(r); + }); + + // Without autocache the connected cache is invalidated but not requested + clip->InvalidateCache(olive::TimeRange(olive::core::rational(4), + olive::core::rational(8)), + olive::ClipBlock::kBufferIn, -1, + olive::Node::InvalidateCacheOptions()); + ASSERT_EQ(invalidated.size(), 1); + EXPECT_EQ(invalidated.first(), + olive::TimeRange(olive::core::rational(4), + olive::core::rational(8))); + EXPECT_TRUE(requested.isEmpty()); + + // Enabling autocache re-requests everything currently invalidated (a + // fresh cache has no validated ranges, so the full media range) + clip->SetAutocache(true); + ASSERT_GE(requested.size(), 1); + EXPECT_EQ(requested.first(), + olive::TimeRange(olive::core::rational(0), + olive::core::rational(10))); + + // With autocache on, invalidations are also requested + invalidated.clear(); + requested.clear(); + clip->InvalidateCache(olive::TimeRange(olive::core::rational(4), + olive::core::rational(8)), + olive::ClipBlock::kBufferIn, -1, + olive::Node::InvalidateCacheOptions()); + ASSERT_EQ(invalidated.size(), 1); + EXPECT_EQ(invalidated.first(), + olive::TimeRange(olive::core::rational(4), + olive::core::rational(8))); + ASSERT_EQ(requested.size(), 1); + EXPECT_EQ(requested.first(), + olive::TimeRange(olive::core::rational(4), + olive::core::rational(8))); +} + +TEST(ClipBlock, DiscardCacheInvalidatesConnectedNodeCache) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *track = new olive::Track(); + track->setParent(&project); + track->set_type(olive::Track::kVideo); + + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + track->AppendBlock(clip); + + olive::SolidGenerator *solid = CreateSolid(&project); + olive::Node::ConnectEdge(solid, + olive::NodeInput(clip, olive::ClipBlock::kBufferIn)); + + QVector invalidated; + QObject::connect(solid->video_frame_cache(), + &olive::PlaybackCache::Invalidated, + [&invalidated](const olive::TimeRange &r) { + invalidated.append(r); + }); + + clip->DiscardCache(); + + const olive::core::rational kMin(INT_MIN); + const olive::core::rational kMax(INT_MAX); + ASSERT_EQ(invalidated.size(), 1); + EXPECT_EQ(invalidated.first(), olive::TimeRange(kMin, kMax)); +} + +TEST(ClipBlock, AddCachePassthroughFromUnvalidatedSourceAddsNoPassthroughs) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(10)); + olive::ClipBlock *b = CreateClip(&project, olive::core::rational(10)); + + // Passthroughs are only added for validated ranges of the source caches, + // and a fresh cache has none + b->AddCachePassthroughFrom(a); + + EXPECT_TRUE(b->video_frame_cache()->GetPassthroughs().empty()); + EXPECT_TRUE(b->audio_playback_cache()->GetPassthroughs().empty()); +} + +TEST(ClipBlock, GetValueHintForBufferWithoutTrackHasNoPreference) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + + // With no track the clip cannot prefer texture or samples and falls back + // to the default (typeless) hint + EXPECT_TRUE(clip->GetValueHintForInput(olive::ClipBlock::kBufferIn) + .types() + .isEmpty()); +} + +TEST(ClipBlock, FindMulticamReturnsNullWithoutMulticam) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + + EXPECT_EQ(clip->FindMulticam(), nullptr); + + olive::SolidGenerator *solid = CreateSolid(&project); + olive::Node::ConnectEdge(solid, + olive::NodeInput(clip, olive::ClipBlock::kBufferIn)); + EXPECT_EQ(clip->FindMulticam(), nullptr); +} + +TEST(ClipTraverser, GenerateTablePropagatesSpeedAdjustedTime) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + clip->SetStandardValue(olive::ClipBlock::kSpeedInput, 2.0); + + auto *time = new olive::TimeInput(); + time->setParent(&project); + olive::Node::ConnectEdge(time, + olive::NodeInput(clip, olive::ClipBlock::kBufferIn)); + + // The connected node is evaluated in media time: sequence time 3 at 2x + // speed reaches the time node as 6 + EXPECT_DOUBLE_EQ(GenerateClipTimeValue(clip, olive::core::rational(3)), + 6.0); +} + +TEST(ClipTraverser, GenerateTablePropagatesMediaInOffset) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + clip->set_media_in(olive::core::rational(5)); + + auto *time = new olive::TimeInput(); + time->setParent(&project); + olive::Node::ConnectEdge(time, + olive::NodeInput(clip, olive::ClipBlock::kBufferIn)); + + EXPECT_DOUBLE_EQ(GenerateClipTimeValue(clip, olive::core::rational(3)), + 8.0); +} + +TEST(ClipTraverser, GenerateTablePropagatesReverseTime) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + clip->set_reverse(true); + + auto *time = new olive::TimeInput(); + time->setParent(&project); + olive::Node::ConnectEdge(time, + olive::NodeInput(clip, olive::ClipBlock::kBufferIn)); + + // Reverse maps the frame range [t, t+1/30) onto media [7, 7-1/30), so the + // connected node is evaluated at 7 - 1/30 + EXPECT_DOUBLE_EQ(GenerateClipTimeValue(clip, olive::core::rational(3)), + 7.0 - 1.0 / 30.0); +} + +TEST(ClipTraverser, GenerateTableCombinesReverseSpeedAndMediaIn) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + clip->set_reverse(true); + clip->SetStandardValue(olive::ClipBlock::kSpeedInput, 2.0); + clip->set_media_in(olive::core::rational(5)); + + auto *time = new olive::TimeInput(); + time->setParent(&project); + olive::Node::ConnectEdge(time, + olive::NodeInput(clip, olive::ClipBlock::kBufferIn)); + + // (10 - 1/30 - 3) * 2 + 5 = 19 - 2/30 + EXPECT_DOUBLE_EQ(GenerateClipTimeValue(clip, olive::core::rational(3)), + 19.0 - 2.0 / 30.0); +} + +TEST(ClipTraverser, GenerateTablePicksUpClipLoopMode) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::ClipBlock *clip = CreateClip(&project, olive::core::rational(10)); + + auto *probe = new LoopModeProbeNode(); + probe->setParent(&project); + olive::Node::ConnectEdge(probe, + olive::NodeInput(clip, olive::ClipBlock::kBufferIn)); + + GenerateClipTable(clip, olive::core::rational(0)); + EXPECT_EQ(probe->last_loop_mode(), olive::LoopMode::kLoopModeOff); + + clip->set_loop_mode(olive::LoopMode::kLoopModeLoop); + GenerateClipTable(clip, olive::core::rational(0)); + EXPECT_EQ(probe->last_loop_mode(), olive::LoopMode::kLoopModeLoop); + + clip->set_loop_mode(olive::LoopMode::kLoopModeClamp); + GenerateClipTable(clip, olive::core::rational(0)); + EXPECT_EQ(probe->last_loop_mode(), olive::LoopMode::kLoopModeClamp); +} + +TEST(ClipTraverser, GenerateTableDefaultsToLoopModeOff) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *probe = new LoopModeProbeNode(); + probe->setParent(&project); + + olive::NodeTraverser traverser; + traverser.GenerateTable( + probe, olive::TimeRange(olive::core::rational(0), + olive::core::rational(1, 30))); + EXPECT_EQ(probe->last_loop_mode(), olive::LoopMode::kLoopModeOff); +} + +TEST(ClipTraverser, TransformAccumulatesGizmosAlongPath) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *start = new GizmoProbeNode(); + start->setParent(&project); + start->SetGizmoTransform(QTransform().translate(1, 0)); + + auto *middle = new GizmoProbeNode(); + middle->setParent(&project); + middle->SetGizmoTransform(QTransform().translate(2, 0)); + + auto *end = new GizmoProbeNode(); + end->setParent(&project); + end->SetGizmoTransform(QTransform().translate(4, 0)); + + olive::Node::ConnectEdge(start, + olive::NodeInput(middle, GizmoProbeNode::InputA())); + olive::Node::ConnectEdge(middle, + olive::NodeInput(end, GizmoProbeNode::InputA())); + + const olive::TimeRange range(olive::core::rational(0), + olive::core::rational(1, 30)); + + // The start node defines the reference frame, so only the gizmos of the + // nodes between start and end (inclusive of end) are accumulated + olive::NodeTraverser traverser; + QTransform t; + traverser.Transform(&t, start, end, range); + EXPECT_DOUBLE_EQ(t.dx(), 6.0); + EXPECT_DOUBLE_EQ(t.dy(), 0.0); + + // Stopping at the middle node accumulates only its gizmo + olive::NodeTraverser traverser2; + QTransform t2; + traverser2.Transform(&t2, start, middle, range); + EXPECT_DOUBLE_EQ(t2.dx(), 2.0); + EXPECT_DOUBLE_EQ(t2.dy(), 0.0); +} + +TEST(ClipTraverser, TransformIgnoresNodesOffThePath) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *start = new GizmoProbeNode(); + start->setParent(&project); + start->SetGizmoTransform(QTransform().translate(1, 0)); + + auto *end = new GizmoProbeNode(); + end->setParent(&project); + end->SetGizmoTransform(QTransform().translate(4, 0)); + + // Connected to the end node but not on the start->end path + auto *off_path = new GizmoProbeNode(); + off_path->setParent(&project); + off_path->SetGizmoTransform(QTransform().translate(100, 0)); + + olive::Node::ConnectEdge(start, + olive::NodeInput(end, GizmoProbeNode::InputA())); + olive::Node::ConnectEdge(off_path, + olive::NodeInput(end, GizmoProbeNode::InputB())); + + const olive::TimeRange range(olive::core::rational(0), + olive::core::rational(1, 30)); + + olive::NodeTraverser traverser; + QTransform t; + traverser.Transform(&t, start, end, range); + EXPECT_DOUBLE_EQ(t.dx(), 4.0); + EXPECT_DOUBLE_EQ(t.dy(), 0.0); +} + +TEST(ClipTraverser, TransformWithSameStartAndEndIsIdentity) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = new GizmoProbeNode(); + node->setParent(&project); + node->SetGizmoTransform(QTransform().translate(4, 0)); + + const olive::TimeRange range(olive::core::rational(0), + olive::core::rational(1, 30)); + + olive::NodeTraverser traverser; + QTransform t; + traverser.Transform(&t, node, node, range); + EXPECT_TRUE(t.isIdentity()); +} + +TEST(ClipTraverser, ViewerConnectedOutputsResolveThroughGraph) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *viewer = new olive::ViewerOutput(); + viewer->setParent(&project); + + EXPECT_EQ(viewer->GetConnectedTextureOutput(), nullptr); + EXPECT_EQ(viewer->GetConnectedSampleOutput(), nullptr); + + auto *texture_source = new GizmoProbeNode(); + texture_source->setParent(&project); + auto *sample_source = new GizmoProbeNode(); + sample_source->setParent(&project); + + olive::Node::ConnectEdge( + texture_source, + olive::NodeInput(viewer, olive::ViewerOutput::kTextureInput)); + olive::Node::ConnectEdge( + sample_source, + olive::NodeInput(viewer, olive::ViewerOutput::kSamplesInput)); + + EXPECT_EQ(viewer->GetConnectedTextureOutput(), texture_source); + EXPECT_EQ(viewer->GetConnectedSampleOutput(), sample_source); + + // The value hint getters delegate to the corresponding input hints + EXPECT_EQ(viewer->GetConnectedTextureValueHint().types(), + viewer->GetValueHintForInput(olive::ViewerOutput::kTextureInput) + .types()); + EXPECT_EQ(viewer->GetConnectedSampleValueHint().types(), + viewer->GetValueHintForInput(olive::ViewerOutput::kSamplesInput) + .types()); + + olive::Node::DisconnectEdge( + texture_source, + olive::NodeInput(viewer, olive::ViewerOutput::kTextureInput)); + EXPECT_EQ(viewer->GetConnectedTextureOutput(), nullptr); + EXPECT_EQ(viewer->GetConnectedSampleOutput(), sample_source); +} diff --git a/tests/gtest/node_core_test.cpp b/tests/gtest/node_core_test.cpp new file mode 100644 index 000000000..6ef2f6425 --- /dev/null +++ b/tests/gtest/node_core_test.cpp @@ -0,0 +1,1136 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include + +#include "node/color/colormanager/colormanager.h" +#include "node/generator/solid/solid.h" +#include "node/generator/text/textv3.h" +#include "node/keyframe.h" +#include "node/math/math/math.h" +#include "node/node.h" +#include "node/project.h" +#include "node/project/folder/folder.h" + +namespace +{ + +// Node that records every InvalidateCache() call so the invalidation flow +// through the graph can be asserted without any rendering. +class RecordingNode : public olive::Node { +public: + RecordingNode() + { + AddInput(kTestInput, olive::NodeValue::kFloat, 0.0); + } + + NODE_DEFAULT_FUNCTIONS(RecordingNode) + + virtual QString Name() const override + { + return QStringLiteral("Recording"); + } + + virtual QString id() const override + { + return QStringLiteral("org.oak.test.recording"); + } + + virtual QVector Category() const override + { + return { kCategoryMath }; + } + + virtual void InvalidateCache( + const olive::TimeRange &range, const QString &from, int element, + olive::Node::InvalidateCacheOptions options) override + { + invalidations.append({ range, from, element }); + olive::Node::InvalidateCache(range, from, element, options); + } + + struct Invalidation { + olive::TimeRange range; + QString from; + int element; + }; + + QVector invalidations; + + static const QString kTestInput; +}; + +const QString RecordingNode::kTestInput = QStringLiteral("test_in"); + +} // namespace + +class NodeCoreTest : public ::testing::Test { +protected: + void SetUp() override + { + olive::ColorManager::SetUpDefaultConfig(); + + project_ = std::make_unique(); + project_->Initialize(); + } + + template T *AddNode() + { + T *node = new T(); + node->setParent(project_.get()); + return node; + } + + std::unique_ptr project_; +}; + +TEST_F(NodeCoreTest, InputArrayResizeEmitsSignals) +{ + auto *node = AddNode(); + const int base = node->InputArraySize(olive::TextGeneratorV3::kArgsInput); + + struct ResizeEvent { + QString input; + int old_size; + int new_size; + }; + QVector resizes; + QObject::connect(node, &olive::Node::InputArraySizeChanged, + [&resizes](const QString &input, int old_size, + int new_size) { + resizes.append({ input, old_size, new_size }); + }); + int value_changed = 0; + QObject::connect(node, &olive::Node::ValueChanged, + [&value_changed](const olive::NodeInput &, + const olive::TimeRange &) { + ++value_changed; + }); + + node->InputArrayAppend(olive::TextGeneratorV3::kArgsInput); + EXPECT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), + base + 1); + ASSERT_EQ(resizes.size(), 1); + EXPECT_EQ(resizes.first().input, olive::TextGeneratorV3::kArgsInput); + EXPECT_EQ(resizes.first().old_size, base); + EXPECT_EQ(resizes.first().new_size, base + 1); + EXPECT_EQ(value_changed, 1); + + // Resizing to the current size is a no-op + node->InputArrayResize(olive::TextGeneratorV3::kArgsInput, base + 1); + EXPECT_EQ(resizes.size(), 1); + EXPECT_EQ(value_changed, 1); + + node->InputArrayResize(olive::TextGeneratorV3::kArgsInput, base + 3); + EXPECT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), + base + 3); + ASSERT_EQ(resizes.size(), 2); + EXPECT_EQ(resizes.at(1).old_size, base + 1); + EXPECT_EQ(resizes.at(1).new_size, base + 3); + + node->InputArrayRemoveLast(olive::TextGeneratorV3::kArgsInput); + EXPECT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), + base + 2); + + node->InputArrayPrepend(olive::TextGeneratorV3::kArgsInput); + EXPECT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), + base + 3); +} + +TEST_F(NodeCoreTest, InputArrayInsertShiftsConnectionsAndValues) +{ + auto *node = AddNode(); + auto *output = AddNode(); + + node->InputArrayResize(olive::TextGeneratorV3::kArgsInput, 2); + node->SetStandardValue( + olive::NodeInput(node, olive::TextGeneratorV3::kArgsInput, 0), + QStringLiteral("zero")); + node->SetStandardValue( + olive::NodeInput(node, olive::TextGeneratorV3::kArgsInput, 1), + QStringLiteral("one")); + olive::Node::ConnectEdge( + output, olive::NodeInput(node, olive::TextGeneratorV3::kArgsInput, 0)); + + node->InputArrayInsert(olive::TextGeneratorV3::kArgsInput, 0); + + ASSERT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), 3); + + // The connection moved down along with its element + EXPECT_EQ(node->GetConnectedOutput(olive::TextGeneratorV3::kArgsInput, 0), + nullptr); + EXPECT_EQ(node->GetConnectedOutput(olive::TextGeneratorV3::kArgsInput, 1), + output); + ASSERT_EQ(output->output_connections().size(), 1); + EXPECT_EQ(output->output_connections().front().second, + olive::NodeInput(node, olive::TextGeneratorV3::kArgsInput, 1)); + + // Values moved down too; the inserted element holds the default value + EXPECT_TRUE(node->GetSplitStandardValue(olive::TextGeneratorV3::kArgsInput, + 0) + .at(0) + .toString() + .isEmpty()); + EXPECT_EQ(node->GetSplitStandardValue(olive::TextGeneratorV3::kArgsInput, + 1) + .at(0) + .toString(), + QStringLiteral("zero")); + EXPECT_EQ(node->GetSplitStandardValue(olive::TextGeneratorV3::kArgsInput, + 2) + .at(0) + .toString(), + QStringLiteral("one")); +} + +TEST_F(NodeCoreTest, InputArrayRemoveShiftsConnectionsAndValues) +{ + auto *node = AddNode(); + auto *output = AddNode(); + + node->InputArrayResize(olive::TextGeneratorV3::kArgsInput, 3); + node->SetStandardValue( + olive::NodeInput(node, olive::TextGeneratorV3::kArgsInput, 0), + QStringLiteral("zero")); + node->SetStandardValue( + olive::NodeInput(node, olive::TextGeneratorV3::kArgsInput, 1), + QStringLiteral("one")); + node->SetStandardValue( + olive::NodeInput(node, olive::TextGeneratorV3::kArgsInput, 2), + QStringLiteral("two")); + olive::Node::ConnectEdge( + output, olive::NodeInput(node, olive::TextGeneratorV3::kArgsInput, 1)); + + node->InputArrayRemove(olive::TextGeneratorV3::kArgsInput, 0); + + ASSERT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), 2); + + // The connection moved up along with its element + EXPECT_EQ(node->GetConnectedOutput(olive::TextGeneratorV3::kArgsInput, 0), + output); + EXPECT_EQ(node->GetConnectedOutput(olive::TextGeneratorV3::kArgsInput, 1), + nullptr); + + // Values moved up too + EXPECT_EQ(node->GetSplitStandardValue(olive::TextGeneratorV3::kArgsInput, + 0) + .at(0) + .toString(), + QStringLiteral("one")); + EXPECT_EQ(node->GetSplitStandardValue(olive::TextGeneratorV3::kArgsInput, + 1) + .at(0) + .toString(), + QStringLiteral("two")); + + // Removing the element an edge points at drops the edge entirely + node->InputArrayRemove(olive::TextGeneratorV3::kArgsInput, 0); + EXPECT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), 1); + EXPECT_EQ(node->GetConnectedOutput(olive::TextGeneratorV3::kArgsInput, 0), + nullptr); + EXPECT_TRUE(output->output_connections().empty()); + EXPECT_EQ(node->GetSplitStandardValue(olive::TextGeneratorV3::kArgsInput, + 0) + .at(0) + .toString(), + QStringLiteral("two")); +} + +TEST_F(NodeCoreTest, InputFlagsReflectDeclaration) +{ + auto *math = AddNode(); + EXPECT_TRUE(math->IsInputConnectable(olive::MathNode::kParamAIn)); + EXPECT_TRUE(math->IsInputKeyframable(olive::MathNode::kParamAIn)); + EXPECT_FALSE(math->IsInputHidden(olive::MathNode::kParamAIn)); + EXPECT_FALSE(math->InputIsArray(olive::MathNode::kParamAIn)); + + // kMethodIn is declared not-connectable and not-keyframable + EXPECT_FALSE(math->IsInputConnectable(olive::MathNode::kMethodIn)); + EXPECT_FALSE(math->IsInputKeyframable(olive::MathNode::kMethodIn)); + + auto *text = AddNode(); + EXPECT_TRUE( + text->IsInputHidden(olive::TextGeneratorV3::kVerticalAlignmentInput)); + EXPECT_FALSE(text->IsInputConnectable( + olive::TextGeneratorV3::kVerticalAlignmentInput)); + EXPECT_TRUE(text->InputIsArray(olive::TextGeneratorV3::kArgsInput)); + EXPECT_FALSE(text->InputIsArray(olive::TextGeneratorV3::kTextInput)); +} + +TEST_F(NodeCoreTest, SetInputFlagTogglesAndEmits) +{ + auto *node = AddNode(); + + int emissions = 0; + QString last_input; + uint64_t last_flags = 0; + QObject::connect(node, &olive::Node::InputFlagsChanged, + [&emissions, &last_input, + &last_flags](const QString &input, + const olive::InputFlags &flags) { + ++emissions; + last_input = input; + last_flags = flags.value(); + }); + + node->SetInputFlag(olive::MathNode::kParamAIn, olive::kInputFlagHidden); + EXPECT_TRUE(node->IsInputHidden(olive::MathNode::kParamAIn)); + EXPECT_EQ(emissions, 1); + EXPECT_EQ(last_input, olive::MathNode::kParamAIn); + EXPECT_TRUE(last_flags & olive::kInputFlagHidden); + + // Setting another flag preserves the flags already set + node->SetInputFlag(olive::MathNode::kParamAIn, + olive::kInputFlagNotConnectable); + EXPECT_FALSE(node->IsInputConnectable(olive::MathNode::kParamAIn)); + EXPECT_TRUE(node->IsInputHidden(olive::MathNode::kParamAIn)); + EXPECT_EQ(emissions, 2); + + node->SetInputFlag(olive::MathNode::kParamAIn, olive::kInputFlagHidden, + false); + EXPECT_FALSE(node->IsInputHidden(olive::MathNode::kParamAIn)); + EXPECT_EQ(emissions, 3); +} + +TEST_F(NodeCoreTest, SetKeyframingOnNonKeyframableInputIsIgnored) +{ + auto *node = AddNode(); + ASSERT_FALSE(node->IsInputKeyframable(olive::MathNode::kMethodIn)); + + node->SetInputIsKeyframing(olive::MathNode::kMethodIn, true); + EXPECT_FALSE(node->IsInputKeyframing(olive::MathNode::kMethodIn)); +} + +TEST_F(NodeCoreTest, UnknownInputAccessorsFallBackSafely) +{ + olive::MathNode node; + const QString bogus = QStringLiteral("bogus_in"); + + // All of these go through GetInternalInputData(), which must handle the + // input not existing without crashing + EXPECT_EQ(node.GetInputFlags(bogus).value(), + static_cast(olive::kInputFlagNormal)); + EXPECT_EQ(node.GetInputDataType(bogus), olive::NodeValue::kNone); + EXPECT_TRUE(node.GetInputName(bogus).isEmpty()); + EXPECT_EQ(node.GetImmediate(bogus, -1), nullptr); + EXPECT_EQ(node.GetConnectedOutput(bogus), nullptr); + EXPECT_FALSE(node.IsInputKeyframing(bogus)); + EXPECT_FALSE(node.HasInputProperty(bogus, QStringLiteral("x"))); + EXPECT_TRUE(node.GetInputProperties(bogus).isEmpty()); + EXPECT_TRUE(node.GetSplitStandardValue(bogus).isEmpty()); + EXPECT_TRUE(node.GetSplitDefaultValue(bogus).isEmpty()); + EXPECT_EQ(node.InputArraySize(bogus), 0); +} + +TEST_F(NodeCoreTest, InputNameTypePropertiesAndDefaults) +{ + auto *node = AddNode(); + + // Default values round-trip through the split representation + EXPECT_DOUBLE_EQ( + node->GetDefaultValue(olive::MathNode::kParamAIn).toDouble(), 0.0); + node->SetDefaultValue(olive::MathNode::kParamAIn, 1.5); + EXPECT_DOUBLE_EQ( + node->GetDefaultValue(olive::MathNode::kParamAIn).toDouble(), 1.5); + EXPECT_DOUBLE_EQ(node->GetSplitDefaultValue(olive::MathNode::kParamAIn) + .at(0) + .toDouble(), + 1.5); + + // Declared data type, keyframe track count and input properties + EXPECT_EQ(node->GetInputDataType(olive::MathNode::kParamAIn), + olive::NodeValue::kFloat); + EXPECT_EQ(node->GetNumberOfKeyframeTracks(olive::MathNode::kParamAIn), 1); + EXPECT_EQ(node->GetInputProperty(olive::MathNode::kParamAIn, + QStringLiteral("decimalplaces")) + .toInt(), + 8); + + auto *solid = AddNode(); + EXPECT_EQ( + solid->GetNumberOfKeyframeTracks(olive::SolidGenerator::kColorInput), + 4); + + int name_emissions = 0; + int type_emissions = 0; + int property_emissions = 0; + QObject::connect(node, &olive::Node::InputNameChanged, + [&name_emissions](const QString &, const QString &) { + ++name_emissions; + }); + QObject::connect(node, &olive::Node::InputDataTypeChanged, + [&type_emissions](const QString &, olive::NodeValue::Type) { + ++type_emissions; + }); + QObject::connect(node, &olive::Node::InputPropertyChanged, + [&property_emissions](const QString &, const QString &, + const QVariant &) { + ++property_emissions; + }); + + node->SetInputName(olive::MathNode::kParamAIn, QStringLiteral("Custom")); + EXPECT_EQ(node->GetInputName(olive::MathNode::kParamAIn), + QStringLiteral("Custom")); + EXPECT_EQ(name_emissions, 1); + + node->SetInputDataType(olive::MathNode::kParamAIn, olive::NodeValue::kInt); + EXPECT_EQ(node->GetInputDataType(olive::MathNode::kParamAIn), + olive::NodeValue::kInt); + EXPECT_EQ(type_emissions, 1); + + node->SetInputProperty(olive::MathNode::kParamAIn, QStringLiteral("mykey"), + 42); + EXPECT_TRUE(node->HasInputProperty(olive::MathNode::kParamAIn, + QStringLiteral("mykey"))); + EXPECT_EQ(node->GetInputProperty(olive::MathNode::kParamAIn, + QStringLiteral("mykey")) + .toInt(), + 42); + EXPECT_TRUE(node->GetInputProperties(olive::MathNode::kParamAIn) + .contains(QStringLiteral("decimalplaces"))); + EXPECT_EQ(property_emissions, 1); +} + +TEST_F(NodeCoreTest, ContextPositionLifecycleEmitsSignals) +{ + auto *node = AddNode(); + auto *context = AddNode(); + + int added_count = 0; + int removed_count = 0; + QVector positions; + QObject::connect(context, &olive::Node::NodeAddedToContext, + [&added_count](olive::Node *) { ++added_count; }); + QObject::connect(context, &olive::Node::NodeRemovedFromContext, + [&removed_count](olive::Node *) { ++removed_count; }); + QObject::connect(context, &olive::Node::NodePositionInContextChanged, + [&positions](olive::Node *, const QPointF &pos) { + positions.append(pos); + }); + + EXPECT_FALSE(context->ContextContainsNode(node)); + EXPECT_TRUE(context->GetContextPositions().isEmpty()); + + // First insertion reports that the node was added + EXPECT_TRUE(context->SetNodePositionInContext( + node, olive::Node::Position(QPointF(3.0, 4.0), true))); + EXPECT_TRUE(context->ContextContainsNode(node)); + EXPECT_EQ(context->GetNodePositionInContext(node), QPointF(3.0, 4.0)); + EXPECT_TRUE(context->IsNodeExpandedInContext(node)); + EXPECT_EQ(added_count, 1); + ASSERT_EQ(positions.size(), 1); + EXPECT_EQ(positions.first(), QPointF(3.0, 4.0)); + + // Updating an existing entry reports no addition and keeps the expanded + // state + EXPECT_FALSE(context->SetNodePositionInContext(node, QPointF(5.0, 6.0))); + EXPECT_EQ(context->GetNodePositionInContext(node), QPointF(5.0, 6.0)); + EXPECT_TRUE(context->IsNodeExpandedInContext(node)); + EXPECT_EQ(added_count, 1); + ASSERT_EQ(positions.size(), 2); + EXPECT_EQ(positions.at(1), QPointF(5.0, 6.0)); + + context->SetNodeExpandedInContext(node, false); + EXPECT_FALSE(context->IsNodeExpandedInContext(node)); + + EXPECT_TRUE(context->RemoveNodeFromContext(node)); + EXPECT_FALSE(context->ContextContainsNode(node)); + EXPECT_EQ(removed_count, 1); + + // Removing a node that is not in the context is a no-op + EXPECT_FALSE(context->RemoveNodeFromContext(node)); + EXPECT_EQ(removed_count, 1); +} + +TEST_F(NodeCoreTest, ContextPositionSaveLoadAndOperators) +{ + const olive::Node::Position pos(QPointF(1.5, -2.5), true); + + QString xml; + QXmlStreamWriter writer(&xml); + writer.writeStartDocument(); + writer.writeStartElement(QStringLiteral("position")); + pos.save(&writer); + writer.writeEndElement(); + writer.writeEndDocument(); + + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("position")); + + olive::Node::Position loaded; + ASSERT_TRUE(loaded.load(&reader)); + EXPECT_EQ(loaded.position, QPointF(1.5, -2.5)); + EXPECT_TRUE(loaded.expanded); + + // A position missing a coordinate fails to load + QXmlStreamReader bad_reader( + QStringLiteral("1")); + ASSERT_TRUE(bad_reader.readNextStartElement()); + olive::Node::Position incomplete; + EXPECT_FALSE(incomplete.load(&bad_reader)); + + // Position arithmetic only affects the point, not the expanded state + const olive::Node::Position a(QPointF(1.0, 2.0)); + const olive::Node::Position b(QPointF(3.0, 4.0)); + EXPECT_EQ((a + b).position, QPointF(4.0, 6.0)); + EXPECT_EQ((b - a).position, QPointF(2.0, 2.0)); + + olive::Node::Position c(QPointF(1.0, 1.0)); + c += a; + EXPECT_EQ(c.position, QPointF(2.0, 3.0)); + c -= b; + EXPECT_EQ(c.position, QPointF(-1.0, -1.0)); +} + +TEST_F(NodeCoreTest, LinkAndUnlinkLifecycle) +{ + auto *a = AddNode(); + auto *b = AddNode(); + + int a_changes = 0; + int b_changes = 0; + QObject::connect(a, &olive::Node::LinksChanged, + [&a_changes]() { ++a_changes; }); + QObject::connect(b, &olive::Node::LinksChanged, + [&b_changes]() { ++b_changes; }); + + EXPECT_FALSE(olive::Node::AreLinked(a, b)); + EXPECT_FALSE(a->HasLinks()); + EXPECT_FALSE(b->HasLinks()); + + // Invalid pairs are rejected + EXPECT_FALSE(olive::Node::Link(a, a)); + EXPECT_FALSE(olive::Node::Link(a, nullptr)); + EXPECT_FALSE(olive::Node::Link(nullptr, b)); + + EXPECT_TRUE(olive::Node::Link(a, b)); + EXPECT_TRUE(olive::Node::AreLinked(a, b)); + EXPECT_TRUE(olive::Node::AreLinked(b, a)); + EXPECT_TRUE(a->HasLinks()); + ASSERT_EQ(a->links().size(), 1); + EXPECT_EQ(a->links().first(), b); + EXPECT_EQ(b->links().first(), a); + EXPECT_EQ(a_changes, 1); + EXPECT_EQ(b_changes, 1); + + // Linking an already-linked pair does nothing + EXPECT_FALSE(olive::Node::Link(a, b)); + EXPECT_EQ(a_changes, 1); + EXPECT_EQ(b_changes, 1); + + EXPECT_TRUE(olive::Node::Unlink(a, b)); + EXPECT_FALSE(olive::Node::AreLinked(a, b)); + EXPECT_FALSE(a->HasLinks()); + EXPECT_EQ(a_changes, 2); + EXPECT_EQ(b_changes, 2); + + // Unlinking an unlinked pair does nothing + EXPECT_FALSE(olive::Node::Unlink(a, b)); + EXPECT_EQ(a_changes, 2); + EXPECT_EQ(b_changes, 2); +} + +TEST_F(NodeCoreTest, OverrideColorEmitsOnlyOnChange) +{ + auto *node = AddNode(); + ASSERT_EQ(node->GetOverrideColor(), -1); + + int emissions = 0; + QObject::connect(node, &olive::Node::ColorChanged, + [&emissions]() { ++emissions; }); + + node->SetOverrideColor(4); + EXPECT_EQ(node->GetOverrideColor(), 4); + EXPECT_EQ(emissions, 1); + + // Setting the same color again is not a change + node->SetOverrideColor(4); + EXPECT_EQ(emissions, 1); + + node->SetOverrideColor(-1); + EXPECT_EQ(node->GetOverrideColor(), -1); + EXPECT_EQ(emissions, 2); +} + +TEST_F(NodeCoreTest, ValueHintAccessorsEmitSignal) +{ + auto *node = AddNode(); + + // The default hint is empty + const olive::Node::ValueHint def = + node->GetValueHintForInput(olive::MathNode::kParamAIn); + EXPECT_TRUE(def.types().isEmpty()); + EXPECT_EQ(def.index(), -1); + EXPECT_TRUE(def.tag().isEmpty()); + + QVector hinted; + QObject::connect(node, &olive::Node::InputValueHintChanged, + [&hinted](const olive::NodeInput &input) { + hinted.append(input); + }); + + const olive::Node::ValueHint hint({ olive::NodeValue::kVec2 }, 3, + QStringLiteral("tag")); + node->SetValueHintForInput(olive::MathNode::kParamAIn, hint); + + const olive::Node::ValueHint stored = + node->GetValueHintForInput(olive::MathNode::kParamAIn); + ASSERT_EQ(stored.types().size(), 1); + EXPECT_EQ(stored.types().first(), olive::NodeValue::kVec2); + EXPECT_EQ(stored.index(), 3); + EXPECT_EQ(stored.tag(), QStringLiteral("tag")); + + ASSERT_EQ(hinted.size(), 1); + EXPECT_EQ(hinted.first(), + olive::NodeInput(node, olive::MathNode::kParamAIn, -1)); + EXPECT_FALSE(node->GetValueHints().isEmpty()); + + // Hints are tracked per element + node->SetValueHintForInput(olive::MathNode::kParamAIn, + olive::Node::ValueHint(QStringLiteral("elem")), + 2); + EXPECT_EQ(node->GetValueHintForInput(olive::MathNode::kParamAIn, 2).tag(), + QStringLiteral("elem")); + EXPECT_EQ(node->GetValueHintForInput(olive::MathNode::kParamAIn, 1).index(), + -1); +} + +TEST_F(NodeCoreTest, ValueHintSaveLoadRoundTrip) +{ + const olive::Node::ValueHint hint( + { olive::NodeValue::kVec2, olive::NodeValue::kTexture }, 7, + QStringLiteral("mytag")); + + QString xml; + QXmlStreamWriter writer(&xml); + writer.writeStartDocument(); + writer.writeStartElement(QStringLiteral("hint")); + hint.save(&writer); + writer.writeEndElement(); + writer.writeEndDocument(); + + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("hint")); + + olive::Node::ValueHint loaded; + ASSERT_TRUE(loaded.load(&reader)); + ASSERT_EQ(loaded.types().size(), 2); + EXPECT_EQ(loaded.types().at(0), olive::NodeValue::kVec2); + EXPECT_EQ(loaded.types().at(1), olive::NodeValue::kTexture); + EXPECT_EQ(loaded.index(), 7); + EXPECT_EQ(loaded.tag(), QStringLiteral("mytag")); + + // A default-constructed hint round-trips to an empty hint + QString empty_xml; + QXmlStreamWriter empty_writer(&empty_xml); + empty_writer.writeStartDocument(); + empty_writer.writeStartElement(QStringLiteral("hint")); + olive::Node::ValueHint().save(&empty_writer); + empty_writer.writeEndElement(); + empty_writer.writeEndDocument(); + + QXmlStreamReader empty_reader(empty_xml); + ASSERT_TRUE(empty_reader.readNextStartElement()); + olive::Node::ValueHint empty_loaded; + ASSERT_TRUE(empty_loaded.load(&empty_reader)); + EXPECT_TRUE(empty_loaded.types().isEmpty()); + EXPECT_EQ(empty_loaded.index(), -1); + EXPECT_TRUE(empty_loaded.tag().isEmpty()); +} + +TEST_F(NodeCoreTest, EdgeAndValueChangesPropagateInvalidateCache) +{ + auto *src = AddNode(); + auto *dst = AddNode(); + const olive::NodeInput dst_input(dst, RecordingNode::kTestInput); + + const olive::rational kMin(INT_MIN); + const olive::rational kMax(INT_MAX); + + QVector value_changed_inputs; + QObject::connect(src, &olive::Node::ValueChanged, + [&value_changed_inputs](const olive::NodeInput &input, + const olive::TimeRange &) { + value_changed_inputs.append(input); + }); + + // Connecting an edge invalidates the destination over the full range + olive::Node::ConnectEdge(src, dst_input); + ASSERT_EQ(dst->invalidations.size(), 1); + EXPECT_EQ(dst->invalidations.first().from, RecordingNode::kTestInput); + EXPECT_EQ(dst->invalidations.first().element, -1); + EXPECT_EQ(dst->invalidations.first().range.in(), kMin); + EXPECT_EQ(dst->invalidations.first().range.out(), kMax); + + // Changing a value upstream emits ValueChanged and invalidates downstream + src->SetStandardValue(olive::MathNode::kParamAIn, 2.0); + ASSERT_EQ(value_changed_inputs.size(), 1); + EXPECT_EQ(value_changed_inputs.first(), + olive::NodeInput(src, olive::MathNode::kParamAIn)); + ASSERT_EQ(dst->invalidations.size(), 2); + EXPECT_EQ(dst->invalidations.at(1).range.in(), kMin); + EXPECT_EQ(dst->invalidations.at(1).range.out(), kMax); + + // Disconnecting the edge invalidates the destination again + olive::Node::DisconnectEdge(src, dst_input); + ASSERT_EQ(dst->invalidations.size(), 3); + EXPECT_TRUE(dst->input_connections().empty()); + EXPECT_TRUE(src->output_connections().empty()); +} + +TEST_F(NodeCoreTest, IgnoreInvalidationsFlagSuppressesInvalidation) +{ + auto *src = AddNode(); + auto *dst = AddNode(); + const olive::NodeInput dst_input(dst, RecordingNode::kTestInput); + + // The flag on the destination input suppresses connect/disconnect + // invalidation + dst->SetInputFlag(RecordingNode::kTestInput, + olive::kInputFlagIgnoreInvalidations); + olive::Node::ConnectEdge(src, dst_input); + EXPECT_TRUE(dst->invalidations.isEmpty()); + + // The flag on the source input suppresses value-change propagation, but + // the ValueChanged signal is still emitted + int value_changed_count = 0; + QObject::connect(src, &olive::Node::ValueChanged, + [&value_changed_count](const olive::NodeInput &, + const olive::TimeRange &) { + ++value_changed_count; + }); + src->SetInputFlag(olive::MathNode::kParamAIn, + olive::kInputFlagIgnoreInvalidations); + src->SetStandardValue(olive::MathNode::kParamAIn, 5.0); + EXPECT_EQ(value_changed_count, 1); + EXPECT_TRUE(dst->invalidations.isEmpty()); + + olive::Node::DisconnectEdge(src, dst_input); + EXPECT_TRUE(dst->invalidations.isEmpty()); +} + +TEST_F(NodeCoreTest, InvalidateAllRelaysThroughGraph) +{ + auto *src = AddNode(); + auto *mid = AddNode(); + auto *dst = AddNode(); + olive::Node::ConnectEdge( + src, olive::NodeInput(mid, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge( + mid, olive::NodeInput(dst, RecordingNode::kTestInput)); + dst->invalidations.clear(); + + const olive::rational kMin(INT_MIN); + const olive::rational kMax(INT_MAX); + + // InvalidateAll propagates the full time range across multiple hops + src->InvalidateAll(olive::MathNode::kParamAIn); + ASSERT_EQ(dst->invalidations.size(), 1); + EXPECT_EQ(dst->invalidations.first().from, RecordingNode::kTestInput); + EXPECT_EQ(dst->invalidations.first().element, -1); + EXPECT_EQ(dst->invalidations.first().range.in(), kMin); + EXPECT_EQ(dst->invalidations.first().range.out(), kMax); + + // A zero-length range touches no caches but is still relayed + src->InvalidateCache( + olive::TimeRange(olive::rational(3), olive::rational(3)), + olive::MathNode::kParamAIn, -1); + ASSERT_EQ(dst->invalidations.size(), 2); + EXPECT_EQ(dst->invalidations.at(1).range.in(), olive::rational(3)); + EXPECT_EQ(dst->invalidations.at(1).range.out(), olive::rational(3)); + + // Disabled caches skip cache invalidation, but propagation continues + EXPECT_TRUE(src->AreCachesEnabled()); + src->SetCachesEnabled(false); + EXPECT_FALSE(src->AreCachesEnabled()); + src->InvalidateAll(olive::MathNode::kParamAIn); + ASSERT_EQ(dst->invalidations.size(), 3); + EXPECT_EQ(dst->invalidations.at(2).range.in(), kMin); +} + +TEST_F(NodeCoreTest, KeyframeAddAndRemovalEmitSignals) +{ + auto *node = AddNode(); + + const olive::rational kMin(INT_MIN); + const olive::rational kMax(INT_MAX); + + int enable_changed = 0; + bool last_enabled = false; + QObject::connect(node, &olive::Node::KeyframeEnableChanged, + [&enable_changed, &last_enabled](const olive::NodeInput &, + bool enabled) { + ++enable_changed; + last_enabled = enabled; + }); + int added = 0; + int removed = 0; + QObject::connect(node, &olive::Node::KeyframeAdded, + [&added](olive::NodeKeyframe *) { ++added; }); + QObject::connect(node, &olive::Node::KeyframeRemoved, + [&removed](olive::NodeKeyframe *) { ++removed; }); + QVector changed_ranges; + QObject::connect(node, &olive::Node::ValueChanged, + [&changed_ranges](const olive::NodeInput &, + const olive::TimeRange &range) { + changed_ranges.append(range); + }); + + node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true); + EXPECT_EQ(enable_changed, 1); + EXPECT_TRUE(last_enabled); + + // The first keyframe on a track invalidates the whole range + auto *first = new olive::NodeKeyframe( + olive::rational(5), 1.0, olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + first->setParent(node); + EXPECT_EQ(added, 1); + ASSERT_EQ(changed_ranges.size(), 1); + EXPECT_EQ(changed_ranges.first().in(), kMin); + EXPECT_EQ(changed_ranges.first().out(), kMax); + + // A later keyframe only invalidates from the previous keyframe onward + auto *second = new olive::NodeKeyframe( + olive::rational(10), 2.0, olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + second->setParent(node); + EXPECT_EQ(added, 2); + ASSERT_EQ(changed_ranges.size(), 2); + EXPECT_EQ(changed_ranges.at(1).in(), olive::rational(5)); + EXPECT_EQ(changed_ranges.at(1).out(), kMax); + + // Removing the later keyframe invalidates from the remaining one onward + second->setParent(nullptr); + EXPECT_EQ(removed, 1); + ASSERT_EQ(changed_ranges.size(), 3); + EXPECT_EQ(changed_ranges.at(2).in(), olive::rational(5)); + EXPECT_EQ(changed_ranges.at(2).out(), kMax); + delete second; + + // Removing the last keyframe invalidates everything again + first->setParent(nullptr); + EXPECT_EQ(removed, 2); + ASSERT_EQ(changed_ranges.size(), 4); + EXPECT_EQ(changed_ranges.at(3).in(), kMin); + EXPECT_EQ(changed_ranges.at(3).out(), kMax); + delete first; + + EXPECT_TRUE(node->GetKeyframeTracks(olive::MathNode::kParamAIn, -1) + .at(0) + .isEmpty()); +} + +TEST_F(NodeCoreTest, KeyframeTimeChangeResortsTrackAndEmits) +{ + auto *node = AddNode(); + node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true); + + auto *first = new olive::NodeKeyframe( + olive::rational(0), 0.0, olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + first->setParent(node); + auto *second = new olive::NodeKeyframe( + olive::rational(10), 10.0, olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + second->setParent(node); + + const QVector &tracks = + node->GetKeyframeTracks(olive::MathNode::kParamAIn, -1); + ASSERT_EQ(tracks.at(0).size(), 2); + EXPECT_EQ(tracks.at(0).first(), first); + EXPECT_EQ(node->GetEarliestKeyframe(olive::MathNode::kParamAIn), first); + EXPECT_EQ(node->GetLatestKeyframe(olive::MathNode::kParamAIn), second); + + int time_changed = 0; + olive::NodeKeyframe *last_changed = nullptr; + QObject::connect(node, &olive::Node::KeyframeTimeChanged, + [&time_changed, + &last_changed](olive::NodeKeyframe *key) { + ++time_changed; + last_changed = key; + }); + + // Moving the first keyframe past the second resorts the track + first->set_time(olive::rational(20)); + EXPECT_EQ(time_changed, 1); + EXPECT_EQ(last_changed, first); + ASSERT_EQ(tracks.at(0).size(), 2); + EXPECT_EQ(tracks.at(0).first(), second); + EXPECT_EQ(tracks.at(0).last(), first); + + EXPECT_EQ(node->GetEarliestKeyframe(olive::MathNode::kParamAIn), second); + EXPECT_EQ(node->GetLatestKeyframe(olive::MathNode::kParamAIn), first); + EXPECT_EQ(node->GetClosestKeyframeBeforeTime(olive::MathNode::kParamAIn, + olive::rational(15)), + second); + EXPECT_EQ(node->GetClosestKeyframeAfterTime(olive::MathNode::kParamAIn, + olive::rational(15)), + first); + EXPECT_TRUE(node->HasKeyframeAtTime(olive::MathNode::kParamAIn, + olive::rational(20))); + EXPECT_EQ(node->GetKeyframeAtTimeOnTrack(olive::MathNode::kParamAIn, + olive::rational(10), 0), + second); + EXPECT_EQ(node->GetKeyframesAtTime(olive::MathNode::kParamAIn, + olive::rational(20)) + .size(), + 1); +} + +TEST_F(NodeCoreTest, GetValueAtTimeUsesStandardValueWhenStatic) +{ + auto *node = AddNode(); + node->SetStandardValue(olive::MathNode::kParamAIn, 2.5); + + // A static input returns its standard value at any time + EXPECT_DOUBLE_EQ(node->GetValueAtTime(olive::MathNode::kParamAIn, + olive::rational(-100)) + .toDouble(), + 2.5); + EXPECT_DOUBLE_EQ( + node->GetValueAtTime(olive::MathNode::kParamAIn, olive::rational(0)) + .toDouble(), + 2.5); + EXPECT_DOUBLE_EQ(node->GetValueAtTime(olive::MathNode::kParamAIn, + olive::rational(100)) + .toDouble(), + 2.5); + + // Same result through the NodeInput convenience overload + EXPECT_DOUBLE_EQ(node->GetValueAtTime( + olive::NodeInput(node, olive::MathNode::kParamAIn), + olive::rational(3)) + .toDouble(), + 2.5); +} + +TEST_F(NodeCoreTest, GetValueAtTimeInterpolatesLinearKeyframes) +{ + auto *node = AddNode(); + node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true); + + auto *first = new olive::NodeKeyframe( + olive::rational(0), 0.0, olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + first->setParent(node); + auto *second = new olive::NodeKeyframe( + olive::rational(10), 10.0, olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + second->setParent(node); + + // Outside the keyed range the nearest keyframe value holds + EXPECT_DOUBLE_EQ(node->GetValueAtTime(olive::MathNode::kParamAIn, + olive::rational(-5)) + .toDouble(), + 0.0); + EXPECT_DOUBLE_EQ(node->GetValueAtTime(olive::MathNode::kParamAIn, + olive::rational(15)) + .toDouble(), + 10.0); + + // Exactly on a keyframe the value is exact + EXPECT_DOUBLE_EQ( + node->GetValueAtTime(olive::MathNode::kParamAIn, olive::rational(0)) + .toDouble(), + 0.0); + EXPECT_DOUBLE_EQ( + node->GetValueAtTime(olive::MathNode::kParamAIn, olive::rational(10)) + .toDouble(), + 10.0); + + // Between two linear keys the value interpolates linearly + EXPECT_DOUBLE_EQ( + node->GetValueAtTime(olive::MathNode::kParamAIn, olive::rational(5)) + .toDouble(), + 5.0); + + const olive::SplitValue split = + node->GetSplitValueAtTime(olive::MathNode::kParamAIn, + olive::rational(5)); + ASSERT_EQ(split.size(), 1); + EXPECT_DOUBLE_EQ(split.at(0).toDouble(), 5.0); +} + +TEST_F(NodeCoreTest, GetValueAtTimeRespectsHoldKeyframes) +{ + auto *node = AddNode(); + node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true); + + auto *hold = new olive::NodeKeyframe( + olive::rational(0), 1.0, olive::NodeKeyframe::kHold, 0, -1, + olive::MathNode::kParamAIn); + hold->setParent(node); + auto *linear = new olive::NodeKeyframe( + olive::rational(10), 3.0, olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + linear->setParent(node); + + // A hold keyframe keeps its value until the next keyframe's time + EXPECT_DOUBLE_EQ( + node->GetValueAtTime(olive::MathNode::kParamAIn, olive::rational(0)) + .toDouble(), + 1.0); + EXPECT_DOUBLE_EQ( + node->GetValueAtTime(olive::MathNode::kParamAIn, olive::rational(5)) + .toDouble(), + 1.0); + EXPECT_DOUBLE_EQ( + node->GetValueAtTime(olive::MathNode::kParamAIn, olive::rational(9)) + .toDouble(), + 1.0); + EXPECT_DOUBLE_EQ( + node->GetValueAtTime(olive::MathNode::kParamAIn, olive::rational(10)) + .toDouble(), + 3.0); +} + +TEST_F(NodeCoreTest, CopyInputsCopiesValuesKeyframesLabelAndColor) +{ + auto *src = AddNode(); + auto *dst = AddNode(); + + src->SetStandardValue(olive::MathNode::kParamAIn, 3.5); + src->SetOperation(olive::MathNode::kOpMultiply); + src->SetLabel(QStringLiteral("source label")); + src->SetOverrideColor(2); + src->SetValueHintForInput( + olive::MathNode::kParamAIn, + olive::Node::ValueHint({ olive::NodeValue::kVec2 }, 1, + QStringLiteral("hint"))); + + src->SetInputIsKeyframing(olive::MathNode::kParamBIn, true); + auto *key = new olive::NodeKeyframe( + olive::rational(4), 8.0, olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamBIn); + key->setParent(src); + + olive::Node::CopyInputs(src, dst, false); + + EXPECT_DOUBLE_EQ( + dst->GetStandardValue(olive::MathNode::kParamAIn).toDouble(), 3.5); + EXPECT_EQ(dst->GetOperation(), olive::MathNode::kOpMultiply); + EXPECT_EQ(dst->GetLabel(), QStringLiteral("source label")); + EXPECT_EQ(dst->GetOverrideColor(), 2); + EXPECT_EQ(dst->GetValueHintForInput(olive::MathNode::kParamAIn).tag(), + QStringLiteral("hint")); + EXPECT_TRUE(dst->IsInputKeyframing(olive::MathNode::kParamBIn)); + + const QVector &tracks = + dst->GetKeyframeTracks(olive::MathNode::kParamBIn, -1); + ASSERT_EQ(tracks.at(0).size(), 1); + EXPECT_EQ(tracks.at(0).first()->time(), olive::rational(4)); + EXPECT_DOUBLE_EQ(tracks.at(0).first()->value().toDouble(), 8.0); + // The copied keyframe belongs to the destination, not the source + EXPECT_EQ(tracks.at(0).first()->parent(), dst); + EXPECT_NE(tracks.at(0).first(), key); + + // Copied values are independent of the source + src->SetStandardValue(olive::MathNode::kParamAIn, 9.0); + EXPECT_DOUBLE_EQ( + dst->GetStandardValue(olive::MathNode::kParamAIn).toDouble(), 3.5); +} + +TEST_F(NodeCoreTest, CopyInputsCopiesConnectionsWhenRequested) +{ + auto *output = AddNode(); + auto *src = AddNode(); + auto *dst = AddNode(); + olive::Node::ConnectEdge( + output, olive::NodeInput(src, olive::MathNode::kParamAIn)); + + // Without connections requested, the destination stays unconnected + olive::Node::CopyInputs(src, dst, false); + EXPECT_EQ(dst->GetConnectedOutput(olive::MathNode::kParamAIn), nullptr); + + // With connections requested, the destination connects to the same output + olive::Node::CopyInputs(src, dst, true); + EXPECT_EQ(dst->GetConnectedOutput(olive::MathNode::kParamAIn), output); +} + +TEST_F(NodeCoreTest, CopyInputsCopiesArrayElements) +{ + auto *src = AddNode(); + auto *dst = AddNode(); + + src->InputArrayResize(olive::TextGeneratorV3::kArgsInput, 2); + src->SetStandardValue( + olive::NodeInput(src, olive::TextGeneratorV3::kArgsInput, 0), + QStringLiteral("first")); + src->SetStandardValue( + olive::NodeInput(src, olive::TextGeneratorV3::kArgsInput, 1), + QStringLiteral("second")); + src->SetInputIsKeyframing(olive::TextGeneratorV3::kArgsInput, true, 1); + auto *key = new olive::NodeKeyframe( + olive::rational(2), QStringLiteral("keyed"), olive::NodeKeyframe::kLinear, + 0, 1, olive::TextGeneratorV3::kArgsInput); + key->setParent(src); + + olive::Node::CopyInputs(src, dst, false); + + EXPECT_EQ(dst->InputArraySize(olive::TextGeneratorV3::kArgsInput), 2); + EXPECT_EQ(dst->GetSplitStandardValue(olive::TextGeneratorV3::kArgsInput, 0) + .at(0) + .toString(), + QStringLiteral("first")); + EXPECT_EQ(dst->GetSplitStandardValue(olive::TextGeneratorV3::kArgsInput, 1) + .at(0) + .toString(), + QStringLiteral("second")); + EXPECT_TRUE(dst->IsInputKeyframing(olive::TextGeneratorV3::kArgsInput, 1)); + EXPECT_FALSE(dst->IsInputKeyframing(olive::TextGeneratorV3::kArgsInput, 0)); + ASSERT_EQ(dst->GetKeyframeTracks(olive::TextGeneratorV3::kArgsInput, 1) + .at(0) + .size(), + 1); + EXPECT_EQ(dst->GetKeyframeTracks(olive::TextGeneratorV3::kArgsInput, 1) + .at(0) + .first() + ->value() + .toString(), + QStringLiteral("keyed")); +} + +TEST_F(NodeCoreTest, CopyDependencyGraphClonesAndReconnects) +{ + auto *solid = AddNode(); + auto *math = AddNode(); + math->SetStandardValue(olive::MathNode::kParamAIn, 6.0); + olive::Node::ConnectEdge( + solid, olive::NodeInput(math, olive::MathNode::kParamAIn)); + math->SetValueHintForInput(olive::MathNode::kParamAIn, + olive::Node::ValueHint(QStringLiteral("tagged"))); + + const QVector copies = + olive::Node::CopyDependencyGraph({ solid, math }, nullptr); + ASSERT_EQ(copies.size(), 2); + + olive::Node *solid_copy = copies.at(0); + olive::Node *math_copy = copies.at(1); + EXPECT_NE(solid_copy, solid); + EXPECT_NE(math_copy, math); + EXPECT_EQ(solid_copy->id(), solid->id()); + EXPECT_EQ(math_copy->id(), math->id()); + EXPECT_EQ(solid_copy->project(), project_.get()); + EXPECT_EQ(math_copy->project(), project_.get()); + + // The clone is wired to the cloned upstream node, not the original + EXPECT_EQ(math_copy->GetConnectedOutput(olive::MathNode::kParamAIn), + solid_copy); + EXPECT_EQ(math->GetConnectedOutput(olive::MathNode::kParamAIn), solid); + + // ...and carries the source's values and hints + EXPECT_DOUBLE_EQ( + math_copy->GetStandardValue(olive::MathNode::kParamAIn).toDouble(), + 6.0); + EXPECT_EQ(math_copy->GetValueHintForInput(olive::MathNode::kParamAIn).tag(), + QStringLiteral("tagged")); +} diff --git a/tests/gtest/node_group_test.cpp b/tests/gtest/node_group_test.cpp new file mode 100644 index 000000000..3efa232c9 --- /dev/null +++ b/tests/gtest/node_group_test.cpp @@ -0,0 +1,624 @@ +#include + +#include + +#include +#include +#include + +#include "node/group/group.h" +#include "node/math/math/math.h" +#include "node/color/colormanager/colormanager.h" +#include "node/project.h" +#include "node/serializeddata.h" +#include "node/value.h" + +class NodeGroupTest : public ::testing::Test { +protected: + void SetUp() override + { + olive::ColorManager::SetUpDefaultConfig(); + project_ = std::make_unique(); + project_->Initialize(); + } + + template T *AddNode() + { + T *node = new T(); + node->setParent(project_.get()); + return node; + } + + // AddInputPassthrough()/SetOutputPassthrough() assert that the inner node + // is part of the group's context, so tests always place it there first + olive::NodeGroup *AddGroupWithInnerMath(olive::MathNode **math) + { + olive::NodeGroup *group = AddNode(); + *math = AddNode(); + group->SetNodePositionInContext(*math, olive::Node::Position()); + return group; + } + + std::unique_ptr project_; +}; + +TEST_F(NodeGroupTest, MetadataIsCorrect) +{ + olive::NodeGroup group; + + EXPECT_EQ(group.id(), QStringLiteral("org.olivevideoeditor.Olive.group")); + EXPECT_EQ(group.Name(), QStringLiteral("Group")); + EXPECT_TRUE(group.Category().contains(olive::Node::kCategoryUnknown)); + EXPECT_FALSE(group.Description().isEmpty()); + EXPECT_TRUE(group.GetFlags() & olive::Node::kDontShowInCreateMenu); + + // A fresh group has no passthroughs of either kind + EXPECT_EQ(group.GetOutputPassthrough(), nullptr); + EXPECT_TRUE(group.GetInputPassthroughs().isEmpty()); +} + +TEST_F(NodeGroupTest, AddInputPassthroughRegistersMirroredInput) +{ + olive::MathNode *math; + olive::NodeGroup *group = AddGroupWithInnerMath(&math); + + QSignalSpy added_spy(group, &olive::NodeGroup::InputPassthroughAdded); + ASSERT_TRUE(added_spy.isValid()); + + const olive::NodeInput input(math, olive::MathNode::kParamAIn); + const QString id = group->AddInputPassthrough(input); + + // The first passthrough of an input reuses the inner input's ID + EXPECT_EQ(id, olive::MathNode::kParamAIn); + EXPECT_TRUE(group->HasInputWithID(id)); + + // The group input mirrors the inner input's type, default and flags + EXPECT_EQ(group->GetInputDataType(id), olive::NodeValue::kFloat); + EXPECT_DOUBLE_EQ(group->GetDefaultValue(id).toDouble(), 0.0); + EXPECT_EQ(group->GetInputFlags(id).value(), + math->GetInputFlags(olive::MathNode::kParamAIn).value()); + + // The passthrough is registered for lookup in both directions + ASSERT_EQ(group->GetInputPassthroughs().size(), 1); + EXPECT_EQ(group->GetInputPassthroughs().first().first, id); + EXPECT_EQ(group->GetInputPassthroughs().first().second, input); + EXPECT_TRUE(group->ContainsInputPassthrough(input)); + EXPECT_FALSE(group->ContainsInputPassthrough( + olive::NodeInput(math, olive::MathNode::kParamBIn))); + EXPECT_EQ(group->GetIDOfPassthrough(input), id); + EXPECT_EQ(group->GetInputFromID(id), input); + + // Unknown lookups return empty/invalid results + EXPECT_TRUE(group->GetIDOfPassthrough( + olive::NodeInput(math, olive::MathNode::kParamBIn)) + .isEmpty()); + EXPECT_FALSE( + group->GetInputFromID(QStringLiteral("no_such_input")).IsValid()); + + ASSERT_EQ(added_spy.count(), 1); + EXPECT_EQ(added_spy.takeFirst().at(1).value(), input); +} + +TEST_F(NodeGroupTest, AddInputPassthroughIsIdempotentForSameInput) +{ + olive::MathNode *math; + olive::NodeGroup *group = AddGroupWithInnerMath(&math); + + const olive::NodeInput input(math, olive::MathNode::kParamAIn); + const QString first = group->AddInputPassthrough(input); + + QSignalSpy added_spy(group, &olive::NodeGroup::InputPassthroughAdded); + const QString second = group->AddInputPassthrough(input); + + // Passing the same input through twice returns the existing ID + EXPECT_EQ(first, second); + EXPECT_EQ(group->GetInputPassthroughs().size(), 1); + EXPECT_EQ(added_spy.count(), 0); +} + +TEST_F(NodeGroupTest, AddInputPassthroughGeneratesUniqueIdForDuplicateInputId) +{ + auto *math_a = AddNode(); + auto *math_b = AddNode(); + auto *group = AddNode(); + group->SetNodePositionInContext(math_a, olive::Node::Position()); + group->SetNodePositionInContext(math_b, olive::Node::Position()); + + const QString id_a = group->AddInputPassthrough( + olive::NodeInput(math_a, olive::MathNode::kParamAIn)); + EXPECT_EQ(id_a, olive::MathNode::kParamAIn); + + // A second passthrough of the same input ID (on a different node) must + // not collide with the first + math_b->Retranslate(); + const QString id_b = group->AddInputPassthrough( + olive::NodeInput(math_b, olive::MathNode::kParamAIn)); + + // NOTE: the suffix is derived from the input's display name rather than + // its ID, so a retranslated MathNode param becomes "Value_2" + EXPECT_NE(id_a, id_b); + EXPECT_EQ(id_b, QStringLiteral("Value_2")); + + ASSERT_EQ(group->GetInputPassthroughs().size(), 2); + EXPECT_TRUE(group->HasInputWithID(id_b)); + EXPECT_EQ(group->GetInputFromID(id_b), + olive::NodeInput(math_b, olive::MathNode::kParamAIn)); + EXPECT_EQ(group->GetIDOfPassthrough( + olive::NodeInput(math_a, olive::MathNode::kParamAIn)), + id_a); +} + +TEST_F(NodeGroupTest, AddInputPassthroughHonorsForcedIdAndMirrorsFlags) +{ + olive::MathNode *math; + olive::NodeGroup *group = AddGroupWithInnerMath(&math); + + // kMethodIn is declared with kInputFlagNotConnectable | + // kInputFlagNotKeyframable, exercising the flag mirroring + const QString id = group->AddInputPassthrough( + olive::NodeInput(math, olive::MathNode::kMethodIn), + QStringLiteral("forced_method")); + + EXPECT_EQ(id, QStringLiteral("forced_method")); + EXPECT_TRUE(group->HasInputWithID(id)); + EXPECT_EQ(group->GetInputDataType(id), olive::NodeValue::kCombo); + EXPECT_EQ(group->GetInputFlags(id).value(), + math->GetInputFlags(olive::MathNode::kMethodIn).value()); + EXPECT_FALSE(group->IsInputConnectable(id)); + EXPECT_FALSE(group->IsInputKeyframable(id)); + EXPECT_EQ(group->GetInputFromID(id), + olive::NodeInput(math, olive::MathNode::kMethodIn)); +} + +TEST_F(NodeGroupTest, RemoveInputPassthroughMirrorsInputDeletion) +{ + olive::MathNode *math; + olive::NodeGroup *group = AddGroupWithInnerMath(&math); + + const olive::NodeInput input(math, olive::MathNode::kParamAIn); + const QString id = group->AddInputPassthrough(input); + + QSignalSpy removed_spy(group, &olive::NodeGroup::InputPassthroughRemoved); + ASSERT_TRUE(removed_spy.isValid()); + + group->RemoveInputPassthrough(input); + + EXPECT_EQ(removed_spy.count(), 1); + EXPECT_TRUE(group->GetInputPassthroughs().isEmpty()); + EXPECT_FALSE(group->ContainsInputPassthrough(input)); + EXPECT_FALSE(group->HasInputWithID(id)); + EXPECT_TRUE(group->GetIDOfPassthrough(input).isEmpty()); + EXPECT_FALSE(group->GetInputFromID(id).IsValid()); +} + +TEST_F(NodeGroupTest, RemoveInputPassthroughIgnoresUnknownInput) +{ + olive::MathNode *math; + olive::NodeGroup *group = AddGroupWithInnerMath(&math); + + const QString id = group->AddInputPassthrough( + olive::NodeInput(math, olive::MathNode::kParamAIn)); + + QSignalSpy removed_spy(group, &olive::NodeGroup::InputPassthroughRemoved); + + // An input that was never passed through must be a harmless no-op + group->RemoveInputPassthrough( + olive::NodeInput(math, olive::MathNode::kParamBIn)); + group->RemoveInputPassthrough(olive::NodeInput()); + + EXPECT_EQ(removed_spy.count(), 0); + EXPECT_EQ(group->GetInputPassthroughs().size(), 1); + EXPECT_TRUE(group->HasInputWithID(id)); +} + +TEST_F(NodeGroupTest, SetOutputPassthroughUpdatesAndEmits) +{ + olive::MathNode *math; + olive::NodeGroup *group = AddGroupWithInnerMath(&math); + ASSERT_EQ(group->GetOutputPassthrough(), nullptr); + + QSignalSpy output_spy(group, &olive::NodeGroup::OutputPassthroughChanged); + ASSERT_TRUE(output_spy.isValid()); + + group->SetOutputPassthrough(math); + EXPECT_EQ(group->GetOutputPassthrough(), math); + EXPECT_EQ(output_spy.count(), 1); + + // Clearing the passthrough is allowed + group->SetOutputPassthrough(nullptr); + EXPECT_EQ(group->GetOutputPassthrough(), nullptr); + EXPECT_EQ(output_spy.count(), 2); +} + +TEST_F(NodeGroupTest, PassthroughInputAcceptsExternalEdges) +{ + olive::MathNode *math; + olive::NodeGroup *group = AddGroupWithInnerMath(&math); + const QString id = group->AddInputPassthrough( + olive::NodeInput(math, olive::MathNode::kParamAIn)); + + // The mirrored input is a real input on the group and can be connected + // from nodes outside the group + auto *external = AddNode(); + const olive::NodeInput group_input(group, id); + EXPECT_FALSE(group_input.IsConnected()); + + olive::Node::ConnectEdge(external, group_input); + EXPECT_TRUE(group_input.IsConnected()); + EXPECT_EQ(group_input.GetConnectedOutput(), external); + EXPECT_EQ(external->output_connections().size(), 1); + + olive::Node::DisconnectEdge(external, group_input); + EXPECT_FALSE(group_input.IsConnected()); + EXPECT_TRUE(external->output_connections().empty()); +} + +TEST_F(NodeGroupTest, GetInputNameFallsThroughToInnerNode) +{ + olive::MathNode *math; + olive::NodeGroup *group = AddGroupWithInnerMath(&math); + math->Retranslate(); + + const QString id = group->AddInputPassthrough( + olive::NodeInput(math, olive::MathNode::kParamAIn)); + + // Without an override the name comes from the inner node's input + EXPECT_EQ(group->GetInputName(id), QStringLiteral("Value")); + + // An explicit override takes precedence + group->SetInputName(id, QStringLiteral("Custom Name")); + EXPECT_EQ(group->GetInputName(id), QStringLiteral("Custom Name")); + + // Clearing the override restores the fall-through + group->SetInputName(id, QString()); + EXPECT_EQ(group->GetInputName(id), QStringLiteral("Value")); +} + +TEST_F(NodeGroupTest, GetInputNameResolvesThroughNestedGroups) +{ + auto *math = AddNode(); + math->Retranslate(); + + auto *inner = AddNode(); + inner->SetNodePositionInContext(math, olive::Node::Position()); + const QString inner_id = inner->AddInputPassthrough( + olive::NodeInput(math, olive::MathNode::kParamAIn)); + + auto *outer = AddNode(); + outer->SetNodePositionInContext(inner, olive::Node::Position()); + const QString outer_id = outer->AddInputPassthrough( + olive::NodeInput(inner, inner_id)); + + // The outer group asks the inner group, which asks the math node + EXPECT_EQ(outer->GetInputName(outer_id), QStringLiteral("Value")); +} + +TEST_F(NodeGroupTest, ResolveInputUnwrapsNestedGroups) +{ + auto *math = AddNode(); + + auto *inner = AddNode(); + inner->SetNodePositionInContext(math, olive::Node::Position()); + const QString inner_id = inner->AddInputPassthrough( + olive::NodeInput(math, olive::MathNode::kParamAIn)); + + auto *outer = AddNode(); + outer->SetNodePositionInContext(inner, olive::Node::Position()); + const QString outer_id = outer->AddInputPassthrough( + olive::NodeInput(inner, inner_id)); + + // An input on the outer group resolves to the innermost real input + const olive::NodeInput resolved = olive::NodeGroup::ResolveInput( + olive::NodeInput(outer, outer_id)); + EXPECT_EQ(resolved.node(), math); + EXPECT_EQ(resolved.input(), olive::MathNode::kParamAIn); + EXPECT_EQ(resolved.element(), -1); + + // Inputs on regular nodes are returned unchanged + const olive::NodeInput plain(math, olive::MathNode::kParamBIn); + EXPECT_EQ(olive::NodeGroup::ResolveInput(plain), plain); +} + +TEST_F(NodeGroupTest, GetInnerRejectsNonGroupAndUnknownInputs) +{ + auto *math = AddNode(); + + auto *group = AddNode(); + group->SetNodePositionInContext(math, olive::Node::Position()); + const QString id = group->AddInputPassthrough( + olive::NodeInput(math, olive::MathNode::kParamAIn)); + + // A node that is not a group has no inner input + olive::NodeInput non_group(math, olive::MathNode::kParamAIn); + EXPECT_FALSE(olive::NodeGroup::GetInner(&non_group)); + EXPECT_EQ(non_group.node(), math); + + // An input ID that is not a passthrough is left unchanged + olive::NodeInput unknown(group, QStringLiteral("does_not_exist")); + EXPECT_FALSE(olive::NodeGroup::GetInner(&unknown)); + EXPECT_EQ(unknown.node(), group); + + // One level of passthrough resolves to the inner node's input + olive::NodeInput passthrough(group, id); + EXPECT_TRUE(olive::NodeGroup::GetInner(&passthrough)); + EXPECT_EQ(passthrough.node(), math); + EXPECT_EQ(passthrough.input(), olive::MathNode::kParamAIn); +} + +TEST_F(NodeGroupTest, RetranslateRetranslatesContextNodes) +{ + olive::MathNode *math; + olive::NodeGroup *group = AddGroupWithInnerMath(&math); + ASSERT_TRUE(math->GetInputName(olive::MathNode::kParamAIn).isEmpty()); + + group->Retranslate(); + + // The group retranslates itself and every node in its context + EXPECT_EQ(group->GetInputName(olive::Node::kEnabledInput), + QStringLiteral("Enabled")); + EXPECT_EQ(math->GetInputName(olive::MathNode::kParamAIn), + QStringLiteral("Value")); +} + +TEST_F(NodeGroupTest, SaveCustomWritesInputPassthroughs) +{ + olive::MathNode *math; + olive::NodeGroup *group = AddGroupWithInnerMath(&math); + + const QString id = group->AddInputPassthrough( + olive::NodeInput(math, olive::MathNode::kParamAIn), + QStringLiteral("pt_float")); + group->SetInputName(id, QStringLiteral("Custom Name")); + group->SetDefaultValue(id, 2.5); + group->SetInputFlag(id, olive::kInputFlagHidden); + group->SetInputProperty(id, QStringLiteral("mykey"), + QStringLiteral("myvalue")); + group->SetOutputPassthrough(math); + + QString xml; + QXmlStreamWriter writer(&xml); + writer.writeStartDocument(); + writer.writeStartElement(QStringLiteral("custom")); + group->SaveCustom(&writer); + writer.writeEndElement(); + writer.writeEndDocument(); + + const QString ptr = QString::number(reinterpret_cast(math)); + + EXPECT_TRUE(xml.contains(QStringLiteral(""))); + EXPECT_TRUE(xml.contains(QStringLiteral("%1").arg(ptr))); + EXPECT_TRUE(xml.contains( + QStringLiteral("%1").arg(olive::MathNode::kParamAIn))); + EXPECT_TRUE(xml.contains(QStringLiteral("-1"))); + EXPECT_TRUE(xml.contains(QStringLiteral("pt_float"))); + EXPECT_TRUE(xml.contains(QStringLiteral("Custom Name"))); + // 8 == kInputFlagHidden; only flags differing from the inner input are + // saved, and the inner input has kInputFlagNormal + EXPECT_TRUE(xml.contains(QStringLiteral("8"))); + EXPECT_TRUE(xml.contains(QStringLiteral("float"))); + EXPECT_TRUE(xml.contains(QStringLiteral("2.5"))); + EXPECT_TRUE(xml.contains(QStringLiteral("mykey"))); + EXPECT_TRUE(xml.contains(QStringLiteral("myvalue"))); + EXPECT_TRUE(xml.contains( + QStringLiteral("%1") + .arg(ptr))); +} + +TEST_F(NodeGroupTest, LoadCustomCollectsGroupLinks) +{ + auto *group = AddNode(); + + const QString xml = QStringLiteral( + "" + "" + "" + "12345" + "param_a_in" + "-1" + "pt_a" + "Custom A" + "8" + "float" + "2.5" + "" + "" + "mykey" + "myvalue" + "" + "" + "" + "" + "12345" + ""); + + olive::SerializedData data; + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("custom")); + EXPECT_TRUE(group->LoadCustom(&reader, &data)); + + // Loading only records the links; resolution happens in PostLoadEvent + ASSERT_EQ(data.group_input_links.size(), 1); + const olive::SerializedData::GroupLink &link = + data.group_input_links.first(); + EXPECT_EQ(link.group, group); + EXPECT_EQ(link.passthrough_id, QStringLiteral("pt_a")); + EXPECT_EQ(link.input_node, static_cast(12345)); + EXPECT_EQ(link.input_id, QStringLiteral("param_a_in")); + EXPECT_EQ(link.input_element, -1); + EXPECT_EQ(link.custom_name, QStringLiteral("Custom A")); + EXPECT_EQ(link.custom_flags.value(), uint64_t(8)); + EXPECT_EQ(link.data_type, olive::NodeValue::kFloat); + EXPECT_DOUBLE_EQ(link.default_val.toDouble(), 2.5); + EXPECT_EQ(link.custom_properties.value(QStringLiteral("mykey")) + .toString(), + QStringLiteral("myvalue")); + + ASSERT_EQ(data.group_output_links.size(), 1); + EXPECT_EQ(data.group_output_links.value(group), + static_cast(12345)); + + // Nothing has been applied to the group yet + EXPECT_TRUE(group->GetInputPassthroughs().isEmpty()); + EXPECT_EQ(group->GetOutputPassthrough(), nullptr); +} + +TEST_F(NodeGroupTest, PostLoadEventRecreatesPassthroughs) +{ + olive::MathNode *math; + olive::NodeGroup *group = AddGroupWithInnerMath(&math); + + const QString xml = QStringLiteral( + "" + "" + "" + "12345" + "param_a_in" + "-1" + "pt_a" + "Custom A" + "8" + "float" + "2.5" + "" + "" + "mykey" + "myvalue" + "" + "" + "" + "" + "12345" + ""); + + olive::SerializedData data; + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_TRUE(group->LoadCustom(&reader, &data)); + + // Point the serialized node references at the real inner node + data.node_ptrs.insert(static_cast(12345), math); + group->PostLoadEvent(&data); + + // The passthrough input is recreated with all its serialized overrides + ASSERT_TRUE(group->HasInputWithID(QStringLiteral("pt_a"))); + EXPECT_TRUE(group->ContainsInputPassthrough( + olive::NodeInput(math, olive::MathNode::kParamAIn))); + EXPECT_EQ(group->GetInputDataType(QStringLiteral("pt_a")), + olive::NodeValue::kFloat); + EXPECT_EQ(group->GetInputName(QStringLiteral("pt_a")), + QStringLiteral("Custom A")); + EXPECT_TRUE(group->IsInputHidden(QStringLiteral("pt_a"))); + EXPECT_DOUBLE_EQ( + group->GetDefaultValue(QStringLiteral("pt_a")).toDouble(), 2.5); + EXPECT_EQ(group + ->GetInputProperty(QStringLiteral("pt_a"), + QStringLiteral("mykey")) + .toString(), + QStringLiteral("myvalue")); + + EXPECT_EQ(group->GetOutputPassthrough(), math); +} + +TEST_F(NodeGroupTest, SaveLoadRoundTripPreservesPassthroughs) +{ + olive::MathNode *math_a; + olive::NodeGroup *group_a = AddGroupWithInnerMath(&math_a); + + const QString id = group_a->AddInputPassthrough( + olive::NodeInput(math_a, olive::MathNode::kParamAIn), + QStringLiteral("pt_roundtrip")); + group_a->SetInputName(id, QStringLiteral("Original Name")); + group_a->SetOutputPassthrough(math_a); + + QString xml; + QXmlStreamWriter writer(&xml); + writer.writeStartDocument(); + writer.writeStartElement(QStringLiteral("custom")); + group_a->SaveCustom(&writer); + writer.writeEndElement(); + writer.writeEndDocument(); + + // Load into a fresh group whose inner node replaces the original one + olive::MathNode *math_b; + olive::NodeGroup *group_b = AddGroupWithInnerMath(&math_b); + + olive::SerializedData data; + data.node_ptrs.insert(reinterpret_cast(math_a), math_b); + + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("custom")); + ASSERT_TRUE(group_b->LoadCustom(&reader, &data)); + group_b->PostLoadEvent(&data); + + ASSERT_EQ(group_b->GetInputPassthroughs().size(), 1); + EXPECT_EQ(group_b->GetInputPassthroughs().first().first, id); + EXPECT_EQ(group_b->GetInputPassthroughs().first().second.node(), math_b); + EXPECT_EQ(group_b->GetInputPassthroughs().first().second.input(), + olive::MathNode::kParamAIn); + EXPECT_EQ(group_b->GetInputName(id), QStringLiteral("Original Name")); + EXPECT_EQ(group_b->GetOutputPassthrough(), math_b); +} + +TEST_F(NodeGroupTest, AddInputPassthroughCommandAddsAndRemoves) +{ + olive::MathNode *math; + olive::NodeGroup *group = AddGroupWithInnerMath(&math); + const olive::NodeInput input(math, olive::MathNode::kParamAIn); + + olive::NodeGroupAddInputPassthrough cmd(group, input); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + ASSERT_EQ(group->GetInputPassthroughs().size(), 1); + EXPECT_TRUE(group->ContainsInputPassthrough(input)); + + cmd.undo_now(); + EXPECT_TRUE(group->GetInputPassthroughs().isEmpty()); + EXPECT_FALSE(group->HasInputWithID(olive::MathNode::kParamAIn)); +} + +TEST_F(NodeGroupTest, AddInputPassthroughCommandNoOpWhenAlreadyPresent) +{ + olive::MathNode *math; + olive::NodeGroup *group = AddGroupWithInnerMath(&math); + const olive::NodeInput input(math, olive::MathNode::kParamAIn); + group->AddInputPassthrough(input); + ASSERT_EQ(group->GetInputPassthroughs().size(), 1); + + olive::NodeGroupAddInputPassthrough cmd(group, input); + + // Redo must not add a duplicate when the passthrough already exists + cmd.redo_now(); + EXPECT_EQ(group->GetInputPassthroughs().size(), 1); + + // And undo must not remove the pre-existing passthrough + cmd.undo_now(); + EXPECT_EQ(group->GetInputPassthroughs().size(), 1); + EXPECT_TRUE(group->ContainsInputPassthrough(input)); +} + +TEST_F(NodeGroupTest, SetOutputPassthroughCommandRestoresPreviousOutput) +{ + olive::MathNode *math; + olive::NodeGroup *group = AddGroupWithInnerMath(&math); + auto *other = AddNode(); + group->SetNodePositionInContext(other, olive::Node::Position()); + + olive::NodeGroupSetOutputPassthrough cmd(group, math); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(group->GetOutputPassthrough(), math); + + // Replacing the output passthrough restores the previous one on undo + olive::NodeGroupSetOutputPassthrough replace_cmd(group, other); + replace_cmd.redo_now(); + EXPECT_EQ(group->GetOutputPassthrough(), other); + replace_cmd.undo_now(); + EXPECT_EQ(group->GetOutputPassthrough(), math); + + cmd.undo_now(); + EXPECT_EQ(group->GetOutputPassthrough(), nullptr); +} diff --git a/tests/gtest/node_math_test.cpp b/tests/gtest/node_math_test.cpp new file mode 100644 index 000000000..ee38ee218 --- /dev/null +++ b/tests/gtest/node_math_test.cpp @@ -0,0 +1,1362 @@ +#include + +#include +#include + +#include +#include +#include +#include + +#include "common/qtutils.h" +#include "node/globals.h" +#include "node/math/math/math.h" +#include "node/color/colormanager/colormanager.h" +#include "node/project.h" +#include "node/traverser.h" +#include "olive/core/render/audioparams.h" +#include "olive/core/render/samplebuffer.h" +#include "olive/core/util/color.h" +#include "olive/core/util/rational.h" +#include "render/job/samplejob.h" + +namespace +{ + +// Minimal node that emits a single configurable value. MathNode's inputs are +// declared as kFloat, so standard values always arrive as kFloat-typed +// NodeValues; feeding vectors, matrices, colors, rationals or sample buffers +// requires a connected node that pushes the real type. +class ConstantValueNode : public olive::Node { +public: + ConstantValueNode() = default; + + NODE_DEFAULT_FUNCTIONS(ConstantValueNode) + + virtual QString Name() const override + { + return QStringLiteral("Test Constant"); + } + + virtual QString id() const override + { + return QStringLiteral("org.oak.test.constant"); + } + + virtual QVector Category() const override + { + return { kCategoryMath }; + } + + void SetOutput(const olive::NodeValue &value) + { + output_ = value; + } + + virtual void Value(const olive::NodeValueRow &value, + const olive::NodeGlobals &globals, + olive::NodeValueTable *table) const override + { + Q_UNUSED(value) + Q_UNUSED(globals) + + table->Push(output_); + } + +private: + olive::NodeValue output_; +}; + +// Traverser that resolves SampleJobs on the CPU (no audio hardware) so the +// non-static number path of MathNode can be verified end to end. +class SampleResolvingTraverser : public olive::NodeTraverser { +public: + void Resolve(olive::NodeValue &value) + { + ResolveJobs(value); + } + +protected: + virtual olive::core::SampleBuffer + CreateSampleBuffer(const olive::core::AudioParams ¶ms, + int sample_count) override + { + return olive::core::SampleBuffer(params, size_t(sample_count)); + } + + virtual void ProcessSamples(olive::core::SampleBuffer &destination, + const olive::Node *node, + const olive::TimeRange &range, + const olive::SampleJob &job) override + { + Q_UNUSED(range) + + for (size_t i = 0; i < destination.sample_count(); i++) { + node->ProcessSamples(job.GetValues(), job.samples(), destination, + int(i)); + } + } +}; + +olive::MathNode *CreateMathNode(olive::Project *project) +{ + auto *math = new olive::MathNode(); + math->setParent(project); + return math; +} + +ConstantValueNode *CreateConstant(olive::Project *project, + const olive::NodeValue &value) +{ + auto *node = new ConstantValueNode(); + node->setParent(project); + node->SetOutput(value); + return node; +} + +olive::NodeValueTable GenerateMathTable(olive::MathNode *math) +{ + olive::NodeTraverser traverser; + return traverser.GenerateTable( + math, olive::TimeRange(olive::core::rational(0), + olive::core::rational(1, 30))); +} + +olive::core::AudioParams TestAudioParams() +{ + return olive::core::AudioParams(48000, olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P); +} + +// Creates a stereo buffer with the given per-channel samples. Both channels +// must have the same number of samples. +olive::core::SampleBuffer MakeSampleBuffer(const std::vector &channel0, + const std::vector &channel1) +{ + olive::core::SampleBuffer buffer(TestAudioParams(), channel0.size()); + for (size_t i = 0; i < channel0.size(); i++) { + buffer.data(0)[i] = channel0[i]; + } + for (size_t i = 0; i < channel1.size(); i++) { + buffer.data(1)[i] = channel1[i]; + } + return buffer; +} + +olive::NodeValue SampleValue(const olive::core::SampleBuffer &buffer) +{ + return olive::NodeValue(olive::NodeValue::kSamples, + QVariant::fromValue(buffer)); +} + +} // namespace + +TEST(MathNode, MetadataIsCorrect) +{ + olive::MathNode unparented; + EXPECT_EQ(unparented.id(), + QStringLiteral("org.olivevideoeditor.Olive.math")); + EXPECT_FALSE(unparented.Description().isEmpty()); + EXPECT_TRUE( + unparented.Category().contains(olive::Node::kCategoryMath)); + EXPECT_EQ(unparented.GetOperation(), olive::MathNode::kOpAdd); + + // Without a parent the node is just called "Math" + EXPECT_EQ(unparented.Name(), QStringLiteral("Math")); + + // Parented nodes are named after their operation + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + EXPECT_EQ(math->Name(), QStringLiteral("Add")); + + math->SetOperation(olive::MathNode::kOpSubtract); + EXPECT_EQ(math->GetOperation(), olive::MathNode::kOpSubtract); + EXPECT_EQ(math->Name(), QStringLiteral("Subtract")); +} + +TEST(MathNode, OperationNames) +{ + EXPECT_EQ(olive::MathNodeBase::GetOperationName(olive::MathNode::kOpAdd), + QStringLiteral("Add")); + EXPECT_EQ( + olive::MathNodeBase::GetOperationName(olive::MathNode::kOpSubtract), + QStringLiteral("Subtract")); + EXPECT_EQ( + olive::MathNodeBase::GetOperationName(olive::MathNode::kOpMultiply), + QStringLiteral("Multiply")); + EXPECT_EQ(olive::MathNodeBase::GetOperationName(olive::MathNode::kOpDivide), + QStringLiteral("Divide")); + EXPECT_EQ(olive::MathNodeBase::GetOperationName(olive::MathNode::kOpPower), + QStringLiteral("Power")); + + // Out-of-range operations produce an empty name + EXPECT_TRUE(olive::MathNodeBase::GetOperationName( + static_cast(-1)) + .isEmpty()); +} + +TEST(MathNode, RetranslateSetsInputNamesAndComboStrings) +{ + olive::MathNode math; + math.Retranslate(); + + EXPECT_EQ(math.GetInputName(olive::MathNode::kMethodIn), + QStringLiteral("Method")); + EXPECT_EQ(math.GetInputName(olive::MathNode::kParamAIn), + QStringLiteral("Value")); + EXPECT_EQ(math.GetInputName(olive::MathNode::kParamBIn), + QStringLiteral("Value")); + + const QStringList operations = + math.GetInputProperty(olive::MathNode::kMethodIn, + QStringLiteral("combo_str")) + .toStringList(); + ASSERT_EQ(operations.size(), 6); + EXPECT_EQ(operations.at(0), QStringLiteral("Add")); + EXPECT_EQ(operations.at(1), QStringLiteral("Subtract")); + EXPECT_EQ(operations.at(2), QStringLiteral("Multiply")); + EXPECT_EQ(operations.at(3), QStringLiteral("Divide")); + // NOTE: kOpPower == 4, but the combo list has an empty string at index 4 + // and "Power" at index 5, so the combo box is misaligned with the + // Operation enum (suspected bug, documented here). + EXPECT_TRUE(operations.at(4).isEmpty()); + EXPECT_EQ(operations.at(5), QStringLiteral("Power")); +} + +TEST(MathNode, AddNumbers) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpAdd); + math->SetStandardValue(olive::MathNode::kParamAIn, 2.0); + math->SetStandardValue(olive::MathNode::kParamBIn, 3.0); + + olive::NodeValueTable table = GenerateMathTable(math); + EXPECT_FLOAT_EQ(table.Get(olive::NodeValue::kFloat).toDouble(), 5.0); +} + +TEST(MathNode, SubtractNumbers) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpSubtract); + math->SetStandardValue(olive::MathNode::kParamAIn, 7.0); + math->SetStandardValue(olive::MathNode::kParamBIn, 10.0); + + olive::NodeValueTable table = GenerateMathTable(math); + EXPECT_FLOAT_EQ(table.Get(olive::NodeValue::kFloat).toDouble(), -3.0); +} + +TEST(MathNode, MultiplyNumbers) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpMultiply); + math->SetStandardValue(olive::MathNode::kParamAIn, 2.5); + math->SetStandardValue(olive::MathNode::kParamBIn, 4.0); + + olive::NodeValueTable table = GenerateMathTable(math); + EXPECT_FLOAT_EQ(table.Get(olive::NodeValue::kFloat).toDouble(), 10.0); +} + +TEST(MathNode, DivideNumbers) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpDivide); + math->SetStandardValue(olive::MathNode::kParamAIn, 7.0); + math->SetStandardValue(olive::MathNode::kParamBIn, 2.0); + + olive::NodeValueTable table = GenerateMathTable(math); + EXPECT_FLOAT_EQ(table.Get(olive::NodeValue::kFloat).toDouble(), 3.5); +} + +TEST(MathNode, PowerNumbers) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpPower); + math->SetStandardValue(olive::MathNode::kParamAIn, 2.0); + math->SetStandardValue(olive::MathNode::kParamBIn, 10.0); + + olive::NodeValueTable table = GenerateMathTable(math); + EXPECT_FLOAT_EQ(table.Get(olive::NodeValue::kFloat).toDouble(), 1024.0); +} + +TEST(MathNode, DivideByZeroProducesInfinity) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpDivide); + math->SetStandardValue(olive::MathNode::kParamAIn, 1.0); + math->SetStandardValue(olive::MathNode::kParamBIn, 0.0); + + olive::NodeValueTable table = GenerateMathTable(math); + const double result = table.Get(olive::NodeValue::kFloat).toDouble(); + EXPECT_TRUE(std::isinf(result)); + EXPECT_GT(result, 0.0); + + // 0 / 0 yields NaN + math->SetStandardValue(olive::MathNode::kParamAIn, 0.0); + table = GenerateMathTable(math); + EXPECT_TRUE(std::isnan(table.Get(olive::NodeValue::kFloat).toDouble())); +} + +TEST(MathNode, ChangingOperationChangesResult) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetStandardValue(olive::MathNode::kParamAIn, 2.0); + math->SetStandardValue(olive::MathNode::kParamBIn, 3.0); + + const olive::MathNode::Operation ops[] = { + olive::MathNode::kOpAdd, olive::MathNode::kOpSubtract, + olive::MathNode::kOpMultiply, olive::MathNode::kOpDivide, + olive::MathNode::kOpPower + }; + const double expected[] = { 5.0, -1.0, 6.0, 2.0 / 3.0, 8.0 }; + + for (int i = 0; i < 5; i++) { + math->SetOperation(ops[i]); + olive::NodeValueTable table = GenerateMathTable(math); + EXPECT_NEAR(table.Get(olive::NodeValue::kFloat).toDouble(), expected[i], + 1e-6) + << "Failed at operation index " << i; + } +} + +TEST(MathNode, AddSubtractRationalsPreserveRationalType) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + + ConstantValueNode *a = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kRational, + QVariant::fromValue(olive::core::rational(1, 2)))); + ConstantValueNode *b = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kRational, + QVariant::fromValue(olive::core::rational(1, 4)))); + + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + math->SetOperation(olive::MathNode::kOpAdd); + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kRational); + ASSERT_EQ(result.type(), olive::NodeValue::kRational); + EXPECT_EQ(result.toRational(), olive::core::rational(3, 4)); + + math->SetOperation(olive::MathNode::kOpSubtract); + table = GenerateMathTable(math); + result = table.Get(olive::NodeValue::kRational); + ASSERT_EQ(result.type(), olive::NodeValue::kRational); + EXPECT_EQ(result.toRational(), olive::core::rational(1, 4)); +} + +TEST(MathNode, MultiplyDivideRationalsPreserveRationalType) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + + ConstantValueNode *a = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kRational, + QVariant::fromValue(olive::core::rational(1, 2)))); + ConstantValueNode *b = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kRational, + QVariant::fromValue(olive::core::rational(1, 4)))); + + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + math->SetOperation(olive::MathNode::kOpMultiply); + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kRational); + ASSERT_EQ(result.type(), olive::NodeValue::kRational); + EXPECT_EQ(result.toRational(), olive::core::rational(1, 8)); + + math->SetOperation(olive::MathNode::kOpDivide); + table = GenerateMathTable(math); + result = table.Get(olive::NodeValue::kRational); + ASSERT_EQ(result.type(), olive::NodeValue::kRational); + EXPECT_EQ(result.toRational(), olive::core::rational(2)); +} + +TEST(MathNode, PowerOnRationalsProducesFloat) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpPower); + + ConstantValueNode *a = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kRational, + QVariant::fromValue(olive::core::rational(1, 2)))); + ConstantValueNode *b = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kRational, + QVariant::fromValue(olive::core::rational(2)))); + + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + // Power is not supported on rationals, so the result falls back to float + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kFloat); + ASSERT_EQ(result.type(), olive::NodeValue::kFloat); + EXPECT_FLOAT_EQ(result.toDouble(), 0.25); +} + +TEST(MathNode, RationalDividedByZeroProducesNaN) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpDivide); + + ConstantValueNode *a = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kRational, + QVariant::fromValue(olive::core::rational(1, 2)))); + ConstantValueNode *b = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kRational, + QVariant::fromValue(olive::core::rational(0)))); + + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kRational); + ASSERT_EQ(result.type(), olive::NodeValue::kRational); + EXPECT_TRUE(result.toRational().isNaN()); +} + +TEST(MathNode, MixedRationalAndFloatProducesFloat) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpAdd); + math->SetStandardValue(olive::MathNode::kParamBIn, 0.5); + + ConstantValueNode *a = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kRational, + QVariant::fromValue(olive::core::rational(1, 2)))); + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + + // Only rational+rational preserves the rational type + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kFloat); + ASSERT_EQ(result.type(), olive::NodeValue::kFloat); + EXPECT_FLOAT_EQ(result.toDouble(), 1.0); +} + +TEST(MathNode, IntegerInputsProduceFloatResult) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + + ConstantValueNode *a = CreateConstant( + &project, olive::NodeValue(olive::NodeValue::kInt, int64_t(7))); + ConstantValueNode *b = CreateConstant( + &project, olive::NodeValue(olive::NodeValue::kInt, int64_t(6))); + + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + math->SetOperation(olive::MathNode::kOpMultiply); + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kFloat); + ASSERT_EQ(result.type(), olive::NodeValue::kFloat); + EXPECT_FLOAT_EQ(result.toDouble(), 42.0); + + // Mixed int and float + olive::Node::DisconnectEdge(b, + olive::NodeInput(math, olive::MathNode::kParamBIn)); + math->SetStandardValue(olive::MathNode::kParamBIn, 0.5); + math->SetOperation(olive::MathNode::kOpAdd); + table = GenerateMathTable(math); + EXPECT_FLOAT_EQ(table.Get(olive::NodeValue::kFloat).toDouble(), 7.5); +} + +TEST(MathNode, AddVectorsPromotesToLargerType) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpAdd); + + ConstantValueNode *a = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kVec2, QVector2D(1.0f, 2.0f))); + ConstantValueNode *b = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kVec3, + QVector3D(10.0f, 20.0f, 30.0f))); + + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kVec3); + ASSERT_EQ(result.type(), olive::NodeValue::kVec3); + const QVector3D vec = result.toVec3(); + EXPECT_FLOAT_EQ(vec.x(), 11.0f); + EXPECT_FLOAT_EQ(vec.y(), 22.0f); + EXPECT_FLOAT_EQ(vec.z(), 30.0f); +} + +TEST(MathNode, SubtractVec4) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpSubtract); + + ConstantValueNode *a = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kVec4, + QVector4D(5.0f, 7.0f, 9.0f, 11.0f))); + ConstantValueNode *b = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kVec4, + QVector4D(1.0f, 2.0f, 3.0f, 4.0f))); + + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kVec4); + ASSERT_EQ(result.type(), olive::NodeValue::kVec4); + const QVector4D vec = result.toVec4(); + EXPECT_FLOAT_EQ(vec.x(), 4.0f); + EXPECT_FLOAT_EQ(vec.y(), 5.0f); + EXPECT_FLOAT_EQ(vec.z(), 6.0f); + EXPECT_FLOAT_EQ(vec.w(), 7.0f); +} + +TEST(MathNode, MultiplyDivideVectorsComponentwise) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + + ConstantValueNode *a = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kVec2, QVector2D(2.0f, 3.0f))); + ConstantValueNode *b = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kVec2, QVector2D(4.0f, 5.0f))); + + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + math->SetOperation(olive::MathNode::kOpMultiply); + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kVec2); + ASSERT_EQ(result.type(), olive::NodeValue::kVec2); + EXPECT_FLOAT_EQ(result.toVec2().x(), 8.0f); + EXPECT_FLOAT_EQ(result.toVec2().y(), 15.0f); + + math->SetOperation(olive::MathNode::kOpDivide); + table = GenerateMathTable(math); + result = table.Get(olive::NodeValue::kVec2); + ASSERT_EQ(result.type(), olive::NodeValue::kVec2); + EXPECT_FLOAT_EQ(result.toVec2().x(), 0.5f); + EXPECT_FLOAT_EQ(result.toVec2().y(), 0.6f); +} + +TEST(MathNode, VectorPowerReturnsFirstInputUnchanged) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpPower); + + ConstantValueNode *a = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kVec2, QVector2D(2.0f, 3.0f))); + ConstantValueNode *b = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kVec2, QVector2D(4.0f, 5.0f))); + + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + // Power is not implemented for vector/vector, the first vector is + // returned unchanged + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kVec2); + ASSERT_EQ(result.type(), olive::NodeValue::kVec2); + EXPECT_FLOAT_EQ(result.toVec2().x(), 2.0f); + EXPECT_FLOAT_EQ(result.toVec2().y(), 3.0f); +} + +TEST(MathNode, MultiplyVectorByNumber) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpMultiply); + math->SetStandardValue(olive::MathNode::kParamBIn, 2.0); + + ConstantValueNode *a = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kVec4, + QVector4D(1.0f, 2.0f, 3.0f, 4.0f))); + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kVec4); + ASSERT_EQ(result.type(), olive::NodeValue::kVec4); + const QVector4D vec = result.toVec4(); + EXPECT_FLOAT_EQ(vec.x(), 2.0f); + EXPECT_FLOAT_EQ(vec.y(), 4.0f); + EXPECT_FLOAT_EQ(vec.z(), 6.0f); + EXPECT_FLOAT_EQ(vec.w(), 8.0f); +} + +TEST(MathNode, DivideVectorByNumber) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpDivide); + math->SetStandardValue(olive::MathNode::kParamBIn, 2.0); + + ConstantValueNode *a = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kVec3, + QVector3D(2.0f, 4.0f, 6.0f))); + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kVec3); + ASSERT_EQ(result.type(), olive::NodeValue::kVec3); + const QVector3D vec = result.toVec3(); + EXPECT_FLOAT_EQ(vec.x(), 1.0f); + EXPECT_FLOAT_EQ(vec.y(), 2.0f); + EXPECT_FLOAT_EQ(vec.z(), 3.0f); +} + +TEST(MathNode, AddVectorAndNumberIsNoOp) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpAdd); + math->SetStandardValue(olive::MathNode::kParamBIn, 5.0); + + ConstantValueNode *a = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kVec2, QVector2D(1.0f, 2.0f))); + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + + // Only multiply/divide are implemented for vector+number; add returns + // the vector unchanged + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kVec2); + ASSERT_EQ(result.type(), olive::NodeValue::kVec2); + EXPECT_FLOAT_EQ(result.toVec2().x(), 1.0f); + EXPECT_FLOAT_EQ(result.toVec2().y(), 2.0f); +} + +TEST(MathNode, NumberTimesVectorYieldsNoComputedValue) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpMultiply); + math->SetStandardValue(olive::MathNode::kParamAIn, 2.0); + + ConstantValueNode *b = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kVec4, + QVector4D(1.0f, 2.0f, 3.0f, 4.0f))); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + // NOTE: With the number in parameter A and the vector in parameter B, + // ValueInternal() picks the *vector* as the number operand (the + // `val_a.type() & NodeValue::kMatrix` check is true for kFloat) and then + // pushes the result with type kFloat, which PushVector() drops. No + // computed value is produced, and nothing passes through into the output + // table either (suspected bug, documented here). + olive::NodeValueTable table = GenerateMathTable(math); + + EXPECT_EQ(table.Get(olive::NodeValue::kVec4).type(), + olive::NodeValue::kNone); + EXPECT_EQ(table.Get(olive::NodeValue::kFloat).type(), + olive::NodeValue::kNone); +} + +TEST(MathNode, MultiplyMatrixByVector) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpMultiply); + + QMatrix4x4 matrix; + matrix.scale(2.0f, 3.0f, 4.0f); + + ConstantValueNode *a = CreateConstant( + &project, olive::NodeValue(olive::NodeValue::kMatrix, matrix)); + ConstantValueNode *b = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kVec4, + QVector4D(1.0f, 2.0f, 3.0f, 1.0f))); + + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kVec4); + ASSERT_EQ(result.type(), olive::NodeValue::kVec4); + const QVector4D vec = result.toVec4(); + EXPECT_FLOAT_EQ(vec.x(), 2.0f); + EXPECT_FLOAT_EQ(vec.y(), 6.0f); + EXPECT_FLOAT_EQ(vec.z(), 12.0f); + EXPECT_FLOAT_EQ(vec.w(), 1.0f); +} + +TEST(MathNode, MatrixMatrixAddAndMultiply) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + + QMatrix4x4 mat_a; + mat_a.scale(2.0f, 3.0f, 4.0f); + QMatrix4x4 mat_b; + mat_b.scale(3.0f, 4.0f, 5.0f); + + ConstantValueNode *a = CreateConstant( + &project, olive::NodeValue(olive::NodeValue::kMatrix, mat_a)); + ConstantValueNode *b = CreateConstant( + &project, olive::NodeValue(olive::NodeValue::kMatrix, mat_b)); + + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + math->SetOperation(olive::MathNode::kOpMultiply); + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kMatrix); + ASSERT_EQ(result.type(), olive::NodeValue::kMatrix); + const QMatrix4x4 product = result.toMatrix(); + EXPECT_FLOAT_EQ(product(0, 0), 6.0f); + EXPECT_FLOAT_EQ(product(1, 1), 12.0f); + EXPECT_FLOAT_EQ(product(2, 2), 20.0f); + EXPECT_FLOAT_EQ(product(3, 3), 1.0f); + + math->SetOperation(olive::MathNode::kOpAdd); + table = GenerateMathTable(math); + result = table.Get(olive::NodeValue::kMatrix); + ASSERT_EQ(result.type(), olive::NodeValue::kMatrix); + const QMatrix4x4 sum = result.toMatrix(); + EXPECT_FLOAT_EQ(sum(0, 0), 5.0f); + EXPECT_FLOAT_EQ(sum(1, 1), 7.0f); + EXPECT_FLOAT_EQ(sum(2, 2), 9.0f); + EXPECT_FLOAT_EQ(sum(3, 3), 2.0f); +} + +TEST(MathNode, MatrixDivideReturnsFirstInputUnchanged) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpDivide); + + QMatrix4x4 mat_a; + mat_a.scale(2.0f, 3.0f, 4.0f); + QMatrix4x4 mat_b; + mat_b.scale(9.0f, 9.0f, 9.0f); + + ConstantValueNode *a = CreateConstant( + &project, olive::NodeValue(olive::NodeValue::kMatrix, mat_a)); + ConstantValueNode *b = CreateConstant( + &project, olive::NodeValue(olive::NodeValue::kMatrix, mat_b)); + + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + // Divide is not implemented for matrices, the first matrix is returned + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kMatrix); + ASSERT_EQ(result.type(), olive::NodeValue::kMatrix); + const QMatrix4x4 out = result.toMatrix(); + EXPECT_FLOAT_EQ(out(0, 0), 2.0f); + EXPECT_FLOAT_EQ(out(1, 1), 3.0f); + EXPECT_FLOAT_EQ(out(2, 2), 4.0f); +} + +TEST(MathNode, AddAndSubtractColors) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + + ConstantValueNode *a = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kColor, + QVariant::fromValue( + olive::core::Color(0.1f, 0.2f, 0.3f, 0.4f)))); + ConstantValueNode *b = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kColor, + QVariant::fromValue( + olive::core::Color(0.4f, 0.3f, 0.2f, 0.1f)))); + + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + math->SetOperation(olive::MathNode::kOpAdd); + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kColor); + ASSERT_EQ(result.type(), olive::NodeValue::kColor); + const olive::core::Color sum = result.toColor(); + EXPECT_FLOAT_EQ(sum.red(), 0.5f); + EXPECT_FLOAT_EQ(sum.green(), 0.5f); + EXPECT_FLOAT_EQ(sum.blue(), 0.5f); + EXPECT_FLOAT_EQ(sum.alpha(), 0.5f); + + math->SetOperation(olive::MathNode::kOpSubtract); + table = GenerateMathTable(math); + result = table.Get(olive::NodeValue::kColor); + ASSERT_EQ(result.type(), olive::NodeValue::kColor); + const olive::core::Color diff = result.toColor(); + EXPECT_FLOAT_EQ(diff.red(), -0.3f); + EXPECT_FLOAT_EQ(diff.green(), -0.1f); + EXPECT_FLOAT_EQ(diff.blue(), 0.1f); + EXPECT_FLOAT_EQ(diff.alpha(), 0.3f); +} + +TEST(MathNode, MultiplyColorsReturnsFirstInputUnchanged) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpMultiply); + + ConstantValueNode *a = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kColor, + QVariant::fromValue( + olive::core::Color(0.1f, 0.2f, 0.3f, 1.0f)))); + ConstantValueNode *b = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kColor, + QVariant::fromValue( + olive::core::Color(0.4f, 0.5f, 0.6f, 1.0f)))); + + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + // Only add/subtract are implemented for colors, the first color is + // returned unchanged + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kColor); + ASSERT_EQ(result.type(), olive::NodeValue::kColor); + const olive::core::Color out = result.toColor(); + EXPECT_FLOAT_EQ(out.red(), 0.1f); + EXPECT_FLOAT_EQ(out.green(), 0.2f); + EXPECT_FLOAT_EQ(out.blue(), 0.3f); + EXPECT_FLOAT_EQ(out.alpha(), 1.0f); +} + +TEST(MathNode, MultiplyColorByNumber) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpMultiply); + math->SetStandardValue(olive::MathNode::kParamBIn, 4.0); + + ConstantValueNode *a = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kColor, + QVariant::fromValue( + olive::core::Color(0.25f, 0.5f, 0.75f, 1.0f)))); + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kColor); + ASSERT_EQ(result.type(), olive::NodeValue::kColor); + const olive::core::Color out = result.toColor(); + EXPECT_FLOAT_EQ(out.red(), 1.0f); + EXPECT_FLOAT_EQ(out.green(), 2.0f); + EXPECT_FLOAT_EQ(out.blue(), 3.0f); + EXPECT_FLOAT_EQ(out.alpha(), 4.0f); +} + +TEST(MathNode, DivideColorByNumberIsNoOp) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpDivide); + math->SetStandardValue(olive::MathNode::kParamBIn, 4.0); + + ConstantValueNode *a = CreateConstant( + &project, + olive::NodeValue(olive::NodeValue::kColor, + QVariant::fromValue( + olive::core::Color(0.25f, 0.5f, 0.75f, 1.0f)))); + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + + // Only multiply is implemented for color+number, the color is returned + // unchanged + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kColor); + ASSERT_EQ(result.type(), olive::NodeValue::kColor); + const olive::core::Color out = result.toColor(); + EXPECT_FLOAT_EQ(out.red(), 0.25f); + EXPECT_FLOAT_EQ(out.green(), 0.5f); + EXPECT_FLOAT_EQ(out.blue(), 0.75f); + EXPECT_FLOAT_EQ(out.alpha(), 1.0f); +} + +TEST(MathNode, NoPairingForNoneInputLeavesInputsUntouched) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpAdd); + math->SetStandardValue(olive::MathNode::kParamAIn, 42.0); + + // A kNone value has no valid pairing, so Value() returns without pushing + // a result; the inputs do not leak into the output table either + ConstantValueNode *b = CreateConstant(&project, olive::NodeValue()); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + olive::NodeValueTable table = GenerateMathTable(math); + EXPECT_EQ(table.Get(olive::NodeValue::kFloat).type(), + olive::NodeValue::kNone); + EXPECT_EQ(table.Get(olive::NodeValue::kVec4).type(), + olive::NodeValue::kNone); +} + +TEST(MathNode, DisabledNodeDoesNotComputeResult) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpAdd); + math->SetStandardValue(olive::MathNode::kParamAIn, 2.0); + math->SetStandardValue(olive::MathNode::kParamBIn, 3.0); + math->SetStandardValue(olive::Node::kEnabledInput, false); + + // The node is bypassed, so the result is one of the inputs rather than + // their sum (merged input order is unspecified) + olive::NodeValueTable table = GenerateMathTable(math); + const double result = table.Get(olive::NodeValue::kFloat).toDouble(); + EXPECT_TRUE(result == 2.0 || result == 3.0); +} + +TEST(MathNode, MultiplySamplesByStaticNumber) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpMultiply); + math->SetStandardValue(olive::MathNode::kParamBIn, 2.0); + + ConstantValueNode *a = CreateConstant( + &project, + SampleValue(MakeSampleBuffer({ 1.0f, 2.0f, 3.0f, 4.0f }, + { 5.0f, 6.0f, 7.0f, 8.0f }))); + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kSamples); + ASSERT_EQ(result.type(), olive::NodeValue::kSamples); + const olive::core::SampleBuffer out = result.toSamples(); + ASSERT_TRUE(out.is_allocated()); + ASSERT_EQ(out.sample_count(), 4u); + for (int i = 0; i < 4; i++) { + EXPECT_FLOAT_EQ(out.data(0)[i], float(i + 1) * 2.0f); + EXPECT_FLOAT_EQ(out.data(1)[i], float(i + 5) * 2.0f); + } +} + +TEST(MathNode, AddZeroToSamplesIsNoOpButStillPushesBuffer) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpAdd); + math->SetStandardValue(olive::MathNode::kParamBIn, 0.0); + + ConstantValueNode *a = CreateConstant( + &project, + SampleValue(MakeSampleBuffer({ 1.0f, 2.0f, 3.0f, 4.0f }, + { 5.0f, 6.0f, 7.0f, 8.0f }))); + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + + // Adding 0 is a no-op, but the (unmodified) buffer is still pushed + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kSamples); + ASSERT_EQ(result.type(), olive::NodeValue::kSamples); + const olive::core::SampleBuffer out = result.toSamples(); + ASSERT_TRUE(out.is_allocated()); + for (int i = 0; i < 4; i++) { + EXPECT_FLOAT_EQ(out.data(0)[i], float(i + 1)); + EXPECT_FLOAT_EQ(out.data(1)[i], float(i + 5)); + } +} + +TEST(MathNode, DivideSamplesByZeroProducesInfinity) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpDivide); + math->SetStandardValue(olive::MathNode::kParamBIn, 0.0); + + ConstantValueNode *a = CreateConstant( + &project, + SampleValue(MakeSampleBuffer({ 1.0f, -1.0f, 0.0f, 2.0f }, + { 1.0f, -1.0f, 0.0f, 2.0f }))); + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + + olive::NodeValueTable table = GenerateMathTable(math); + const olive::core::SampleBuffer out = + table.Get(olive::NodeValue::kSamples).toSamples(); + ASSERT_TRUE(out.is_allocated()); + for (int channel = 0; channel < 2; channel++) { + EXPECT_TRUE(std::isinf(out.data(channel)[0])); + EXPECT_TRUE(std::isinf(out.data(channel)[1])); + EXPECT_TRUE(std::isnan(out.data(channel)[2])); + EXPECT_TRUE(std::isinf(out.data(channel)[3])); + } +} + +TEST(MathNode, PowerSamplesByStaticNumber) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpPower); + math->SetStandardValue(olive::MathNode::kParamBIn, 2.0); + + ConstantValueNode *a = CreateConstant( + &project, + SampleValue(MakeSampleBuffer({ 1.0f, 2.0f, 3.0f, 4.0f }, + { 5.0f, 6.0f, 7.0f, 8.0f }))); + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + + olive::NodeValueTable table = GenerateMathTable(math); + const olive::core::SampleBuffer out = + table.Get(olive::NodeValue::kSamples).toSamples(); + ASSERT_TRUE(out.is_allocated()); + for (int i = 0; i < 4; i++) { + EXPECT_FLOAT_EQ(out.data(0)[i], std::pow(float(i + 1), 2.0f)); + EXPECT_FLOAT_EQ(out.data(1)[i], std::pow(float(i + 5), 2.0f)); + } +} + +TEST(MathNode, AddSampleBuffersMixesAndKeepsRemainder) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpAdd); + + ConstantValueNode *a = CreateConstant( + &project, + SampleValue(MakeSampleBuffer({ 1.0f, 2.0f, 3.0f, 4.0f }, + { 5.0f, 6.0f, 7.0f, 8.0f }))); + ConstantValueNode *b = CreateConstant( + &project, + SampleValue(MakeSampleBuffer({ 10.0f, 20.0f }, { 50.0f, 60.0f }))); + + olive::Node::ConnectEdge(a, olive::NodeInput(math, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge(b, olive::NodeInput(math, olive::MathNode::kParamBIn)); + + // The output is as long as the longer buffer: overlapping samples are + // mixed, the remainder is copied from the longer buffer + olive::NodeValueTable table = GenerateMathTable(math); + const olive::core::SampleBuffer out = + table.Get(olive::NodeValue::kSamples).toSamples(); + ASSERT_TRUE(out.is_allocated()); + ASSERT_EQ(out.sample_count(), 4u); + EXPECT_FLOAT_EQ(out.data(0)[0], 11.0f); + EXPECT_FLOAT_EQ(out.data(0)[1], 22.0f); + EXPECT_FLOAT_EQ(out.data(0)[2], 3.0f); + EXPECT_FLOAT_EQ(out.data(0)[3], 4.0f); + EXPECT_FLOAT_EQ(out.data(1)[0], 55.0f); + EXPECT_FLOAT_EQ(out.data(1)[1], 66.0f); + EXPECT_FLOAT_EQ(out.data(1)[2], 7.0f); + EXPECT_FLOAT_EQ(out.data(1)[3], 8.0f); +} + +TEST(MathNode, ConnectedNumberProducesSampleJob) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::MathNode *math = CreateMathNode(&project); + math->SetOperation(olive::MathNode::kOpMultiply); + + // A connected (non-static) number produces a SampleJob instead of + // processing the buffer immediately + ConstantValueNode *number = CreateConstant( + &project, olive::NodeValue(olive::NodeValue::kFloat, 3.0)); + ConstantValueNode *samples = CreateConstant( + &project, + SampleValue(MakeSampleBuffer({ 1.0f, 2.0f, 3.0f, 4.0f }, + { 5.0f, 6.0f, 7.0f, 8.0f }))); + + olive::Node::ConnectEdge(number, + olive::NodeInput(math, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge(samples, + olive::NodeInput(math, olive::MathNode::kParamBIn)); + + olive::NodeValueTable table = GenerateMathTable(math); + olive::NodeValue result = table.Get(olive::NodeValue::kSamples); + ASSERT_EQ(result.type(), olive::NodeValue::kSamples); + ASSERT_TRUE(result.canConvert()); + + // Resolve the job on the CPU and verify the processed samples + SampleResolvingTraverser resolver; + resolver.Resolve(result); + + const olive::core::SampleBuffer out = result.toSamples(); + ASSERT_TRUE(out.is_allocated()); + ASSERT_EQ(out.sample_count(), 4u); + for (int i = 0; i < 4; i++) { + EXPECT_FLOAT_EQ(out.data(0)[i], float(i + 1) * 3.0f); + EXPECT_FLOAT_EQ(out.data(1)[i], float(i + 5) * 3.0f); + } +} + +TEST(MathNode, ProcessSamplesAppliesOperationPerSample) +{ + olive::MathNode math; + math.SetOperation(olive::MathNode::kOpMultiply); + + olive::NodeValueRow row; + row.insert(olive::MathNode::kParamAIn, + olive::NodeValue(olive::NodeValue::kFloat, 3.0)); + + olive::core::SampleBuffer input(TestAudioParams(), 2); + olive::core::SampleBuffer output(TestAudioParams(), 2); + input.data(0)[0] = 1.5f; + input.data(0)[1] = -2.0f; + input.data(1)[0] = 0.25f; + input.data(1)[1] = 8.0f; + + math.ProcessSamples(row, input, output, 0); + math.ProcessSamples(row, input, output, 1); + + EXPECT_FLOAT_EQ(output.data(0)[0], 4.5f); + EXPECT_FLOAT_EQ(output.data(0)[1], -6.0f); + EXPECT_FLOAT_EQ(output.data(1)[0], 0.75f); + EXPECT_FLOAT_EQ(output.data(1)[1], 24.0f); +} + +TEST(MathNode, ProcessSamplesUsesSecondParamWhenFirstMissing) +{ + olive::MathNode math; + math.SetOperation(olive::MathNode::kOpSubtract); + + olive::NodeValueRow row; + row.insert(olive::MathNode::kParamBIn, + olive::NodeValue(olive::NodeValue::kFloat, 4.0)); + + olive::core::SampleBuffer input(TestAudioParams(), 1); + olive::core::SampleBuffer output(TestAudioParams(), 1); + input.data(0)[0] = 10.0f; + input.data(1)[0] = 1.0f; + + math.ProcessSamples(row, input, output, 0); + + EXPECT_FLOAT_EQ(output.data(0)[0], 6.0f); + EXPECT_FLOAT_EQ(output.data(1)[0], -3.0f); +} + +TEST(MathNode, ProcessSamplesWithoutNumberLeavesOutputUntouched) +{ + olive::MathNode math; + math.SetOperation(olive::MathNode::kOpMultiply); + + // Neither parameter carries a number: output must not be written + olive::NodeValueRow row; + + olive::core::SampleBuffer input(TestAudioParams(), 1); + olive::core::SampleBuffer output(TestAudioParams(), 1); + input.data(0)[0] = 10.0f; + output.data(0)[0] = 123.0f; + output.data(1)[0] = 45.0f; + + math.ProcessSamples(row, input, output, 0); + + EXPECT_FLOAT_EQ(output.data(0)[0], 123.0f); + EXPECT_FLOAT_EQ(output.data(1)[0], 45.0f); +} + +TEST(MathNode, ShaderCodeForNumberAdd) +{ + olive::MathNode math; + + const olive::ShaderCode code = + math.GetShaderCode(olive::Node::ShaderRequest(QStringLiteral("0.0.2.2"))); + + EXPECT_TRUE(code.vert_code().isEmpty()); + EXPECT_TRUE(code.frag_code().contains( + QStringLiteral("uniform float param_a_in;"))); + EXPECT_TRUE(code.frag_code().contains( + QStringLiteral("uniform float param_b_in;"))); + EXPECT_TRUE(code.frag_code().contains( + QStringLiteral("vec4 c = param_a_in + param_b_in;"))); +} + +TEST(MathNode, ShaderCodeForTextureNumberPower) +{ + olive::MathNode math; + + // kOpPower / kPairTextureNumber / kTexture / kFloat + const olive::ShaderCode code = + math.GetShaderCode(olive::Node::ShaderRequest(QStringLiteral("4.8.10.2"))); + + EXPECT_TRUE(code.vert_code().isEmpty()); + EXPECT_TRUE(code.frag_code().contains( + QStringLiteral("uniform sampler2D param_a_in;"))); + EXPECT_TRUE(code.frag_code().contains( + QStringLiteral("uniform float param_b_in;"))); + EXPECT_TRUE(code.frag_code().contains( + QStringLiteral("pow(texture(param_a_in, ove_texcoord), vec4(param_b_in))"))); +} + +TEST(MathNode, ShaderCodeForColorPairUsesVec4Uniforms) +{ + olive::MathNode math; + + // kOpAdd / kPairColorColor / kColor / kColor + const olive::ShaderCode code = + math.GetShaderCode(olive::Node::ShaderRequest(QStringLiteral("0.7.5.5"))); + + EXPECT_TRUE(code.vert_code().isEmpty()); + EXPECT_TRUE(code.frag_code().contains( + QStringLiteral("uniform vec4 param_a_in;"))); + EXPECT_TRUE(code.frag_code().contains( + QStringLiteral("uniform vec4 param_b_in;"))); + EXPECT_TRUE(code.frag_code().contains( + QStringLiteral("vec4 c = param_a_in + param_b_in;"))); +} + +TEST(MathNode, ShaderCodeForTextureMatrixMultiplyHasVertexShader) +{ + olive::MathNode math; + + // kOpMultiply / kPairTextureMatrix / kTexture / kMatrix + olive::ShaderCode code = + math.GetShaderCode(olive::Node::ShaderRequest(QStringLiteral("2.10.10.6"))); + + EXPECT_TRUE(code.frag_code().contains( + QStringLiteral("texture(param_a_in, ove_texcoord)"))); + EXPECT_TRUE(code.vert_code().contains( + QStringLiteral("uniform mat4 param_b_in;"))); + EXPECT_TRUE(code.vert_code().contains( + QStringLiteral("gl_Position = param_b_in * a_position;"))); + + // Reversed operand order swaps the roles of the parameters + code = math.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("2.10.6.10"))); + + EXPECT_TRUE(code.frag_code().contains( + QStringLiteral("texture(param_b_in, ove_texcoord)"))); + EXPECT_TRUE(code.vert_code().contains( + QStringLiteral("uniform mat4 param_a_in;"))); + EXPECT_TRUE(code.vert_code().contains( + QStringLiteral("gl_Position = param_a_in * a_position;"))); +} diff --git a/tests/gtest/node_undo_test.cpp b/tests/gtest/node_undo_test.cpp new file mode 100644 index 000000000..66e6c815a --- /dev/null +++ b/tests/gtest/node_undo_test.cpp @@ -0,0 +1,912 @@ +#include + +#include + +#include +#include +#include + +#include "node/color/colormanager/colormanager.h" +#include "node/generator/solid/solid.h" +#include "node/generator/text/textv3.h" +#include "node/inputimmediate.h" +#include "node/keyframe.h" +#include "node/math/math/math.h" +#include "node/nodeundo.h" +#include "node/project.h" +#include "node/project/folder/folder.h" + +class NodeUndoTest : public ::testing::Test { +protected: + void SetUp() override + { + olive::ColorManager::SetUpDefaultConfig(); + + project_ = std::make_unique(); + project_->Initialize(); + } + + template T *AddNode() + { + T *node = new T(); + node->setParent(project_.get()); + return node; + } + + std::unique_ptr project_; +}; + +TEST_F(NodeUndoTest, AddCommandAddsAndRemovesNodeFromProject) +{ + auto *node = new olive::MathNode(); // Intentionally parentless + + olive::NodeAddCommand cmd(project_.get(), node); + + // The constructor takes ownership of the node without adding it to the graph + EXPECT_FALSE(project_->nodes().contains(node)); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.PushToThread(QCoreApplication::instance()->thread()); + + cmd.redo_now(); + EXPECT_TRUE(project_->nodes().contains(node)); + EXPECT_EQ(node->project(), project_.get()); + + cmd.undo_now(); + EXPECT_FALSE(project_->nodes().contains(node)); + EXPECT_EQ(node->project(), nullptr); + // cmd's destructor disposes of the unparented node +} + +TEST_F(NodeUndoTest, RemoveAndDisconnectCommandRestoresGraphState) +{ + auto *src = AddNode(); + auto *mid = AddNode(); + auto *dst = AddNode(); + auto *link_peer = AddNode(); + auto *context = AddNode(); + + olive::Node::ConnectEdge( + src, olive::NodeInput(mid, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge( + mid, olive::NodeInput(dst, olive::MathNode::kParamBIn)); + olive::Node::Link(mid, link_peer); + context->SetNodePositionInContext( + mid, olive::Node::Position(QPointF(3.0, 4.0), true)); + + olive::NodeRemoveAndDisconnectCommand cmd(mid); + cmd.redo_now(); + + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + EXPECT_EQ(mid->project(), nullptr); + EXPECT_FALSE(project_->nodes().contains(mid)); + EXPECT_TRUE(mid->input_connections().empty()); + EXPECT_TRUE(mid->output_connections().empty()); + EXPECT_TRUE(src->output_connections().empty()); + EXPECT_TRUE(dst->input_connections().empty()); + EXPECT_FALSE(context->ContextContainsNode(mid)); + EXPECT_FALSE(mid->HasLinks()); + + cmd.undo_now(); + + EXPECT_EQ(mid->project(), project_.get()); + EXPECT_TRUE(project_->nodes().contains(mid)); + ASSERT_EQ(src->output_connections().size(), 1); + EXPECT_EQ(src->output_connections().front().second, + olive::NodeInput(mid, olive::MathNode::kParamAIn)); + EXPECT_EQ(dst->input_connections().at( + olive::NodeInput(dst, olive::MathNode::kParamBIn)), + mid); + ASSERT_TRUE(context->ContextContainsNode(mid)); + EXPECT_EQ(context->GetNodePositionInContext(mid), QPointF(3.0, 4.0)); + EXPECT_TRUE(olive::Node::AreLinked(mid, link_peer)); +} + +TEST_F(NodeUndoTest, RemoveWithExclusiveDependenciesRemovesUpstreamChain) +{ + auto *src = AddNode(); + auto *dep = AddNode(); + auto *node = AddNode(); + + olive::Node::ConnectEdge( + src, olive::NodeInput(dep, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge( + dep, olive::NodeInput(node, olive::MathNode::kParamAIn)); + + olive::NodeRemoveWithExclusiveDependenciesAndDisconnect cmd(node); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + + // The node and all of its non-item upstream dependencies are removed + EXPECT_EQ(node->project(), nullptr); + EXPECT_EQ(dep->project(), nullptr); + EXPECT_EQ(src->project(), nullptr); + EXPECT_TRUE(node->input_connections().empty()); + EXPECT_TRUE(dep->input_connections().empty()); + EXPECT_TRUE(src->output_connections().empty()); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.undo_now(); + + EXPECT_EQ(node->project(), project_.get()); + EXPECT_EQ(dep->project(), project_.get()); + EXPECT_EQ(src->project(), project_.get()); + EXPECT_EQ(node->input_connections().at( + olive::NodeInput(node, olive::MathNode::kParamAIn)), + dep); + EXPECT_EQ(dep->input_connections().at( + olive::NodeInput(dep, olive::MathNode::kParamAIn)), + src); +} + +TEST_F(NodeUndoTest, EdgeAddCommandConnectsAndDisconnects) +{ + auto *output = AddNode(); + auto *input_node = AddNode(); + const olive::NodeInput input(input_node, olive::MathNode::kParamAIn); + + olive::NodeEdgeAddCommand cmd(output, input); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_TRUE(input.IsConnected()); + EXPECT_EQ(input.GetConnectedOutput(), output); + EXPECT_EQ(output->output_connections().size(), 1); + + cmd.undo_now(); + EXPECT_FALSE(input.IsConnected()); + EXPECT_TRUE(output->output_connections().empty()); +} + +TEST_F(NodeUndoTest, EdgeAddCommandReplacesExistingConnection) +{ + auto *first = AddNode(); + auto *second = AddNode(); + auto *input_node = AddNode(); + const olive::NodeInput input(input_node, olive::MathNode::kParamAIn); + + olive::Node::ConnectEdge(first, input); + ASSERT_EQ(input.GetConnectedOutput(), first); + + olive::NodeEdgeAddCommand cmd(second, input); + cmd.redo_now(); + + // The previous edge must be disconnected before the new one is made + EXPECT_EQ(input.GetConnectedOutput(), second); + EXPECT_TRUE(first->output_connections().empty()); + + cmd.undo_now(); + + // Undoing must restore the connection that was replaced + EXPECT_EQ(input.GetConnectedOutput(), first); + EXPECT_TRUE(second->output_connections().empty()); +} + +TEST_F(NodeUndoTest, EdgeRemoveCommandDisconnectsAndReconnects) +{ + auto *output = AddNode(); + auto *input_node = AddNode(); + const olive::NodeInput input(input_node, olive::MathNode::kParamBIn); + + olive::Node::ConnectEdge(output, input); + ASSERT_TRUE(input.IsConnected()); + + olive::NodeEdgeRemoveCommand cmd(output, input); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_FALSE(input.IsConnected()); + EXPECT_TRUE(output->output_connections().empty()); + + cmd.undo_now(); + EXPECT_TRUE(input.IsConnected()); + EXPECT_EQ(input.GetConnectedOutput(), output); +} + +TEST_F(NodeUndoTest, SetPositionCommandAddsNodeToContext) +{ + auto *node = AddNode(); + auto *context = AddNode(); + ASSERT_FALSE(context->ContextContainsNode(node)); + + olive::NodeSetPositionCommand cmd( + node, context, olive::Node::Position(QPointF(10.0, 20.0), true)); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + ASSERT_TRUE(context->ContextContainsNode(node)); + EXPECT_EQ(context->GetNodePositionInContext(node), QPointF(10.0, 20.0)); + EXPECT_TRUE(context->IsNodeExpandedInContext(node)); + + cmd.undo_now(); + EXPECT_FALSE(context->ContextContainsNode(node)); +} + +TEST_F(NodeUndoTest, SetPositionCommandRestoresPreviousPosition) +{ + auto *node = AddNode(); + auto *context = AddNode(); + context->SetNodePositionInContext( + node, olive::Node::Position(QPointF(1.0, 2.0))); + + olive::NodeSetPositionCommand cmd( + node, context, olive::Node::Position(QPointF(30.0, 40.0))); + + cmd.redo_now(); + EXPECT_EQ(context->GetNodePositionInContext(node), QPointF(30.0, 40.0)); + + cmd.undo_now(); + ASSERT_TRUE(context->ContextContainsNode(node)); + EXPECT_EQ(context->GetNodePositionInContext(node), QPointF(1.0, 2.0)); +} + +TEST_F(NodeUndoTest, SetPositionAndDependenciesRecursivelyMovesNode) +{ + auto *node = AddNode(); + auto *context = AddNode(); + context->SetNodePositionInContext( + node, olive::Node::Position(QPointF(2.0, 3.0))); + + olive::NodeSetPositionAndDependenciesRecursivelyCommand cmd( + node, context, olive::Node::Position(QPointF(8.0, 9.0))); + + cmd.redo_now(); + EXPECT_EQ(context->GetNodePositionInContext(node), QPointF(8.0, 9.0)); + + cmd.undo_now(); + EXPECT_EQ(context->GetNodePositionInContext(node), QPointF(2.0, 3.0)); +} + +TEST_F(NodeUndoTest, RemovePositionFromContextCommandRestoresPosition) +{ + auto *node = AddNode(); + auto *context = AddNode(); + context->SetNodePositionInContext( + node, olive::Node::Position(QPointF(5.0, 6.0))); + + olive::NodeRemovePositionFromContextCommand cmd(node, context); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_FALSE(context->ContextContainsNode(node)); + + cmd.undo_now(); + ASSERT_TRUE(context->ContextContainsNode(node)); + EXPECT_EQ(context->GetNodePositionInContext(node), QPointF(5.0, 6.0)); +} + +TEST_F(NodeUndoTest, RemovePositionFromContextCommandNoOpWhenAbsent) +{ + auto *node = AddNode(); + auto *context = AddNode(); + + olive::NodeRemovePositionFromContextCommand cmd(node, context); + + cmd.redo_now(); + EXPECT_FALSE(context->ContextContainsNode(node)); + + cmd.undo_now(); + EXPECT_FALSE(context->ContextContainsNode(node)); +} + +TEST_F(NodeUndoTest, RemovePositionFromAllContextsCommandRestoresAll) +{ + auto *node = AddNode(); + auto *ctx_a = AddNode(); + auto *ctx_b = AddNode(); + ctx_a->SetNodePositionInContext( + node, olive::Node::Position(QPointF(1.0, 1.0))); + ctx_b->SetNodePositionInContext( + node, olive::Node::Position(QPointF(2.0, 2.0))); + + olive::NodeRemovePositionFromAllContextsCommand cmd(node); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_FALSE(ctx_a->ContextContainsNode(node)); + EXPECT_FALSE(ctx_b->ContextContainsNode(node)); + + cmd.undo_now(); + EXPECT_EQ(ctx_a->GetNodePositionInContext(node), QPointF(1.0, 1.0)); + EXPECT_EQ(ctx_b->GetNodePositionInContext(node), QPointF(2.0, 2.0)); +} + +TEST_F(NodeUndoTest, RenameCommandSetsAndRestoresLabels) +{ + auto *a = AddNode(); + auto *b = AddNode(); + a->SetLabel(QStringLiteral("old_a")); + b->SetLabel(QStringLiteral("old_b")); + + olive::NodeRenameCommand cmd(a, QStringLiteral("new_a")); + cmd.AddNode(b, QStringLiteral("new_b")); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(a->GetLabel(), QStringLiteral("new_a")); + EXPECT_EQ(b->GetLabel(), QStringLiteral("new_b")); + + cmd.undo_now(); + EXPECT_EQ(a->GetLabel(), QStringLiteral("old_a")); + EXPECT_EQ(b->GetLabel(), QStringLiteral("old_b")); +} + +TEST_F(NodeUndoTest, RenameCommandEmptyHasNoProject) +{ + olive::NodeRenameCommand cmd; + EXPECT_EQ(cmd.GetRelevantProject(), nullptr); + + // redo/undo on an empty command must be harmless no-ops + cmd.redo_now(); + cmd.undo_now(); +} + +TEST_F(NodeUndoTest, OverrideColorCommandSetsAndRestoresColor) +{ + auto *node = AddNode(); + ASSERT_EQ(node->GetOverrideColor(), -1); + + olive::NodeOverrideColorCommand cmd(node, 5); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(node->GetOverrideColor(), 5); + + cmd.undo_now(); + EXPECT_EQ(node->GetOverrideColor(), -1); +} + +TEST_F(NodeUndoTest, LinkCommandLinksAndUnlinks) +{ + auto *a = AddNode(); + auto *b = AddNode(); + + olive::NodeLinkCommand link_cmd(a, b, true); + EXPECT_EQ(link_cmd.GetRelevantProject(), project_.get()); + + link_cmd.redo_now(); + EXPECT_TRUE(olive::Node::AreLinked(a, b)); + + link_cmd.undo_now(); + EXPECT_FALSE(olive::Node::AreLinked(a, b)); + + olive::Node::Link(a, b); + olive::NodeLinkCommand unlink_cmd(a, b, false); + + unlink_cmd.redo_now(); + EXPECT_FALSE(olive::Node::AreLinked(a, b)); + + unlink_cmd.undo_now(); + EXPECT_TRUE(olive::Node::AreLinked(a, b)); +} + +TEST_F(NodeUndoTest, LinkCommandIgnoresAlreadyLinkedPair) +{ + auto *a = AddNode(); + auto *b = AddNode(); + olive::Node::Link(a, b); + + olive::NodeLinkCommand cmd(a, b, true); + cmd.redo_now(); + EXPECT_TRUE(olive::Node::AreLinked(a, b)); + + // Undo must not unlink a pair that redo did not link + cmd.undo_now(); + EXPECT_TRUE(olive::Node::AreLinked(a, b)); +} + +TEST_F(NodeUndoTest, UnlinkAllCommandRestoresAllLinks) +{ + auto *node = AddNode(); + auto *a = AddNode(); + auto *b = AddNode(); + olive::Node::Link(node, a); + olive::Node::Link(node, b); + ASSERT_EQ(node->links().size(), 2); + + olive::NodeUnlinkAllCommand cmd(node); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_TRUE(node->links().isEmpty()); + EXPECT_FALSE(olive::Node::AreLinked(node, a)); + EXPECT_FALSE(olive::Node::AreLinked(node, b)); + + cmd.undo_now(); + EXPECT_EQ(node->links().size(), 2); + EXPECT_TRUE(olive::Node::AreLinked(node, a)); + EXPECT_TRUE(olive::Node::AreLinked(node, b)); +} + +TEST_F(NodeUndoTest, LinkManyCommandLinksAndUnlinksAllPairs) +{ + auto *a = AddNode(); + auto *b = AddNode(); + auto *c = AddNode(); + + olive::NodeLinkManyCommand cmd({ a, b, c }, true); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_TRUE(olive::Node::AreLinked(a, b)); + EXPECT_TRUE(olive::Node::AreLinked(a, c)); + EXPECT_TRUE(olive::Node::AreLinked(b, c)); + + cmd.undo_now(); + EXPECT_FALSE(olive::Node::AreLinked(a, b)); + EXPECT_FALSE(olive::Node::AreLinked(a, c)); + EXPECT_FALSE(olive::Node::AreLinked(b, c)); +} + +TEST_F(NodeUndoTest, ViewDeleteCommandRemovesNodeAndEdges) +{ + auto *context = AddNode(); + auto *a = AddNode(); + auto *b = AddNode(); + + olive::Node::ConnectEdge( + a, olive::NodeInput(b, olive::MathNode::kParamAIn)); + context->SetNodePositionInContext( + a, olive::Node::Position(QPointF(1.0, 2.0))); + context->SetNodePositionInContext( + b, olive::Node::Position(QPointF(3.0, 4.0))); + + olive::NodeViewDeleteCommand cmd; + EXPECT_EQ(cmd.GetRelevantProject(), nullptr); + + cmd.AddNode(b, context); + EXPECT_TRUE(cmd.ContainsNode(b, context)); + EXPECT_FALSE(cmd.ContainsNode(a, context)); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + + // b was only in this context and became fully disconnected, so it is + // removed from the graph entirely + EXPECT_FALSE(context->ContextContainsNode(b)); + EXPECT_EQ(b->project(), nullptr); + EXPECT_TRUE(b->input_connections().empty()); + EXPECT_TRUE(a->output_connections().empty()); + + cmd.undo_now(); + + EXPECT_EQ(b->project(), project_.get()); + ASSERT_TRUE(context->ContextContainsNode(b)); + EXPECT_EQ(context->GetNodePositionInContext(b), QPointF(3.0, 4.0)); + ASSERT_EQ(a->output_connections().size(), 1); + EXPECT_EQ(a->output_connections().front().second, + olive::NodeInput(b, olive::MathNode::kParamAIn)); +} + +TEST_F(NodeUndoTest, ViewDeleteCommandKeepsNodeConnectedOutsideContext) +{ + auto *context = AddNode(); + auto *a = AddNode(); + auto *outside = AddNode(); + + // "outside" is not in the context, so its edge keeps "a" in the graph + olive::Node::ConnectEdge( + a, olive::NodeInput(outside, olive::MathNode::kParamAIn)); + context->SetNodePositionInContext( + a, olive::Node::Position(QPointF(7.0, 8.0))); + + olive::NodeViewDeleteCommand cmd; + cmd.AddNode(a, context); + + cmd.redo_now(); + + EXPECT_FALSE(context->ContextContainsNode(a)); + EXPECT_EQ(a->project(), project_.get()); + EXPECT_EQ(outside->input_connections().at( + olive::NodeInput(outside, olive::MathNode::kParamAIn)), + a); + + cmd.undo_now(); + + ASSERT_TRUE(context->ContextContainsNode(a)); + EXPECT_EQ(context->GetNodePositionInContext(a), QPointF(7.0, 8.0)); +} + +TEST_F(NodeUndoTest, ParamSetKeyframingCommandTogglesKeyframing) +{ + auto *node = AddNode(); + const olive::NodeInput input(node, olive::MathNode::kParamAIn); + ASSERT_FALSE(input.IsKeyframing()); + + olive::NodeParamSetKeyframingCommand cmd(input, true); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_TRUE(input.IsKeyframing()); + + cmd.undo_now(); + EXPECT_FALSE(input.IsKeyframing()); +} + +TEST_F(NodeUndoTest, ParamInsertKeyframeCommandReparentsKeyframe) +{ + auto *node = AddNode(); + + auto *key = new olive::NodeKeyframe(olive::rational(0), 1.0, + olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + + olive::NodeParamInsertKeyframeCommand cmd(node, key); + + // The constructor takes ownership of the keyframe without inserting it + EXPECT_NE(key->parent(), node); + EXPECT_TRUE(node->GetKeyframeTracks(olive::MathNode::kParamAIn, -1) + .at(0) + .isEmpty()); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(key->parent(), node); + EXPECT_TRUE(node->GetKeyframeTracks(olive::MathNode::kParamAIn, -1) + .at(0) + .contains(key)); + + cmd.undo_now(); + EXPECT_NE(key->parent(), node); + EXPECT_TRUE(node->GetKeyframeTracks(olive::MathNode::kParamAIn, -1) + .at(0) + .isEmpty()); +} + +TEST_F(NodeUndoTest, ParamRemoveKeyframeCommandRestoresKeyframe) +{ + auto *node = AddNode(); + + auto *key = new olive::NodeKeyframe(olive::rational(0), 1.0, + olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + key->setParent(node); + ASSERT_TRUE(node->GetKeyframeTracks(olive::MathNode::kParamAIn, -1) + .at(0) + .contains(key)); + + olive::NodeParamRemoveKeyframeCommand cmd(key); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_NE(key->parent(), node); + EXPECT_TRUE(node->GetKeyframeTracks(olive::MathNode::kParamAIn, -1) + .at(0) + .isEmpty()); + + cmd.undo_now(); + EXPECT_EQ(key->parent(), node); + EXPECT_TRUE(node->GetKeyframeTracks(olive::MathNode::kParamAIn, -1) + .at(0) + .contains(key)); +} + +TEST_F(NodeUndoTest, ParamSetKeyframeTimeCommandChangesTime) +{ + auto *node = AddNode(); + auto *key = new olive::NodeKeyframe(olive::rational(0), 1.0, + olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + key->setParent(node); + + olive::NodeParamSetKeyframeTimeCommand cmd(key, olive::rational(1, 2)); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(key->time(), olive::rational(1, 2)); + + cmd.undo_now(); + EXPECT_EQ(key->time(), olive::rational(0)); +} + +TEST_F(NodeUndoTest, ParamSetKeyframeTimeCommandExplicitTimes) +{ + auto *node = AddNode(); + auto *key = new olive::NodeKeyframe(olive::rational(0), 1.0, + olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + key->setParent(node); + + olive::NodeParamSetKeyframeTimeCommand cmd(key, olive::rational(3, 4), + olive::rational(1, 4)); + + cmd.redo_now(); + EXPECT_EQ(key->time(), olive::rational(3, 4)); + + cmd.undo_now(); + EXPECT_EQ(key->time(), olive::rational(1, 4)); +} + +TEST_F(NodeUndoTest, ParamSetKeyframeValueCommandChangesValue) +{ + auto *node = AddNode(); + auto *key = new olive::NodeKeyframe(olive::rational(0), 1.0, + olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + key->setParent(node); + + olive::NodeParamSetKeyframeValueCommand cmd(key, 5.0); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_DOUBLE_EQ(key->value().toDouble(), 5.0); + + cmd.undo_now(); + EXPECT_DOUBLE_EQ(key->value().toDouble(), 1.0); +} + +TEST_F(NodeUndoTest, ParamSetKeyframeValueCommandExplicitValues) +{ + auto *node = AddNode(); + auto *key = new olive::NodeKeyframe(olive::rational(0), 1.0, + olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + key->setParent(node); + + olive::NodeParamSetKeyframeValueCommand cmd(key, 7.5, 2.5); + + cmd.redo_now(); + EXPECT_DOUBLE_EQ(key->value().toDouble(), 7.5); + + cmd.undo_now(); + EXPECT_DOUBLE_EQ(key->value().toDouble(), 2.5); +} + +TEST_F(NodeUndoTest, ParamSetStandardValueCommandSetsAndRestoresValue) +{ + auto *node = AddNode(); + const olive::NodeKeyframeTrackReference ref( + olive::NodeInput(node, olive::MathNode::kParamAIn), 0); + ASSERT_DOUBLE_EQ( + node->GetStandardValue(olive::MathNode::kParamAIn).toDouble(), 0.0); + + olive::NodeParamSetStandardValueCommand cmd(ref, 2.5); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_DOUBLE_EQ( + node->GetStandardValue(olive::MathNode::kParamAIn).toDouble(), 2.5); + + cmd.undo_now(); + EXPECT_DOUBLE_EQ( + node->GetStandardValue(olive::MathNode::kParamAIn).toDouble(), 0.0); +} + +TEST_F(NodeUndoTest, ParamSetStandardValueCommandExplicitOldValue) +{ + auto *node = AddNode(); + node->SetStandardValue(olive::MathNode::kParamAIn, 10.0); + const olive::NodeKeyframeTrackReference ref( + olive::NodeInput(node, olive::MathNode::kParamAIn), 0); + + // Three-argument form with an explicit old value, as used by + // SpeedDurationDialog + olive::NodeParamSetStandardValueCommand cmd(ref, 20.0, 10.0); + + cmd.redo_now(); + EXPECT_DOUBLE_EQ( + node->GetStandardValue(olive::MathNode::kParamAIn).toDouble(), 20.0); + + cmd.undo_now(); + EXPECT_DOUBLE_EQ( + node->GetStandardValue(olive::MathNode::kParamAIn).toDouble(), 10.0); +} + +TEST_F(NodeUndoTest, ParamSetSplitStandardValueCommandSetsAndRestoresSplit) +{ + auto *node = AddNode(); + const olive::NodeInput input(node, olive::SolidGenerator::kColorInput); + + const olive::SplitValue old_split = node->GetSplitStandardValue(input); + ASSERT_EQ(old_split.size(), 4); + + const olive::SplitValue new_split = { 0.25, 0.5, 0.75, 1.0 }; + + olive::NodeParamSetSplitStandardValueCommand cmd(input, new_split); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + const olive::SplitValue after = node->GetSplitStandardValue(input); + ASSERT_EQ(after.size(), 4); + EXPECT_DOUBLE_EQ(after.at(0).toDouble(), 0.25); + EXPECT_DOUBLE_EQ(after.at(1).toDouble(), 0.5); + EXPECT_DOUBLE_EQ(after.at(2).toDouble(), 0.75); + EXPECT_DOUBLE_EQ(after.at(3).toDouble(), 1.0); + + cmd.undo_now(); + const olive::SplitValue restored = node->GetSplitStandardValue(input); + ASSERT_EQ(restored.size(), 4); + for (int i = 0; i < restored.size(); ++i) { + EXPECT_DOUBLE_EQ(restored.at(i).toDouble(), + old_split.at(i).toDouble()); + } + + // The three-argument form takes the old value explicitly + const olive::SplitValue explicit_new = { 1.0, 1.0, 1.0, 1.0 }; + olive::NodeParamSetSplitStandardValueCommand explicit_cmd(input, + explicit_new, + restored); + explicit_cmd.redo_now(); + EXPECT_DOUBLE_EQ(node->GetSplitStandardValue(input).at(0).toDouble(), 1.0); + + explicit_cmd.undo_now(); + EXPECT_DOUBLE_EQ(node->GetSplitStandardValue(input).at(0).toDouble(), + restored.at(0).toDouble()); +} + +TEST_F(NodeUndoTest, ParamArrayAppendCommandAppendsAndRemoves) +{ + auto *node = AddNode(); + const int base = node->InputArraySize(olive::TextGeneratorV3::kArgsInput); + + olive::NodeParamArrayAppendCommand cmd(node, + olive::TextGeneratorV3::kArgsInput); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), + base + 1); + + cmd.undo_now(); + EXPECT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), base); +} + +TEST_F(NodeUndoTest, ArrayInsertCommandInsertsAndRemovesElement) +{ + auto *node = AddNode(); + node->InputArrayAppend(olive::TextGeneratorV3::kArgsInput); + const int base = node->InputArraySize(olive::TextGeneratorV3::kArgsInput); + + olive::NodeArrayInsertCommand cmd(node, olive::TextGeneratorV3::kArgsInput, + 0); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), + base + 1); + + cmd.undo_now(); + EXPECT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), base); +} + +TEST_F(NodeUndoTest, ArrayResizeCommandGrowAndShrink) +{ + auto *node = AddNode(); + const int base = node->InputArraySize(olive::TextGeneratorV3::kArgsInput); + + olive::NodeArrayResizeCommand cmd(node, olive::TextGeneratorV3::kArgsInput, + base + 3); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), + base + 3); + + cmd.undo_now(); + EXPECT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), base); +} + +TEST_F(NodeUndoTest, ArrayResizeCommandShrinkDisconnectsAndRestoresEdges) +{ + auto *node = AddNode(); + auto *output = AddNode(); + + node->InputArrayResize(olive::TextGeneratorV3::kArgsInput, 3); + const olive::NodeInput connected(node, + olive::TextGeneratorV3::kArgsInput, 2); + olive::Node::ConnectEdge(output, connected); + ASSERT_TRUE(connected.IsConnected()); + + olive::NodeArrayResizeCommand cmd(node, olive::TextGeneratorV3::kArgsInput, + 1); + cmd.redo_now(); + + // Shrinking removed elements 1 and 2; the edge into element 2 is dropped + EXPECT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), 1); + EXPECT_FALSE(connected.IsConnected()); + EXPECT_TRUE(output->output_connections().empty()); + + cmd.undo_now(); + + // Undo restores both the array size and the removed connection + EXPECT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), 3); + EXPECT_TRUE(connected.IsConnected()); + EXPECT_EQ(connected.GetConnectedOutput(), output); +} + +TEST_F(NodeUndoTest, ArrayRemoveCommandPreservesKeyframesAndValues) +{ + auto *node = AddNode(); + node->InputArrayResize(olive::TextGeneratorV3::kArgsInput, 2); + + const olive::NodeInput element(node, olive::TextGeneratorV3::kArgsInput, + 1); + node->SetStandardValue(element, QStringLiteral("hello")); + node->SetInputIsKeyframing(element, true); + + auto *key = new olive::NodeKeyframe( + olive::rational(0), QStringLiteral("key"), olive::NodeKeyframe::kLinear, + 0, 1, olive::TextGeneratorV3::kArgsInput); + key->setParent(node); + ASSERT_TRUE(node->GetKeyframeTracks(olive::TextGeneratorV3::kArgsInput, 1) + .at(0) + .contains(key)); + + olive::NodeArrayRemoveCommand cmd(node, olive::TextGeneratorV3::kArgsInput, + 1); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), 1); + EXPECT_NE(key->parent(), node); + + cmd.undo_now(); + EXPECT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), 2); + EXPECT_EQ(key->parent(), node); + EXPECT_TRUE(node->GetKeyframeTracks(olive::TextGeneratorV3::kArgsInput, 1) + .at(0) + .contains(key)); + EXPECT_TRUE(node->IsInputKeyframing(olive::TextGeneratorV3::kArgsInput, 1)); + EXPECT_EQ(node->GetSplitStandardValue(olive::TextGeneratorV3::kArgsInput, 1) + .at(0) + .toString(), + QStringLiteral("hello")); +} + +TEST_F(NodeUndoTest, SetValueHintCommandSetsAndRestoresHint) +{ + auto *node = AddNode(); + + const olive::Node::ValueHint old_hint = + node->GetValueHintForInput(olive::MathNode::kParamAIn, -1); + + const olive::Node::ValueHint new_hint({ olive::NodeValue::kVec2 }, 3, + QStringLiteral("tag")); + olive::NodeSetValueHintCommand cmd(node, olive::MathNode::kParamAIn, -1, + new_hint); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + const olive::Node::ValueHint after = + node->GetValueHintForInput(olive::MathNode::kParamAIn, -1); + ASSERT_EQ(after.types().size(), 1); + EXPECT_EQ(after.types().first(), olive::NodeValue::kVec2); + EXPECT_EQ(after.index(), 3); + EXPECT_EQ(after.tag(), QStringLiteral("tag")); + + cmd.undo_now(); + const olive::Node::ValueHint restored = + node->GetValueHintForInput(olive::MathNode::kParamAIn, -1); + EXPECT_EQ(restored.types().size(), old_hint.types().size()); + EXPECT_EQ(restored.index(), old_hint.index()); + EXPECT_EQ(restored.tag(), old_hint.tag()); +} + +TEST_F(NodeUndoTest, ImmediateRemoveAllKeyframesCommandRemovesKeys) +{ + auto *node = AddNode(); + node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true); + + auto *key_a = new olive::NodeKeyframe(olive::rational(0), 1.0, + olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + key_a->setParent(node); + auto *key_b = new olive::NodeKeyframe(olive::rational(1), 2.0, + olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + key_b->setParent(node); + + olive::NodeInputImmediate *immediate = + node->GetImmediate(olive::MathNode::kParamAIn, -1); + ASSERT_NE(immediate, nullptr); + ASSERT_EQ(immediate->keyframe_tracks().at(0).size(), 2); + + olive::NodeImmediateRemoveAllKeyframesCommand cmd(immediate); + EXPECT_EQ(cmd.GetRelevantProject(), nullptr); + + cmd.redo_now(); + EXPECT_TRUE(immediate->keyframe_tracks().at(0).isEmpty()); + EXPECT_NE(key_a->parent(), node); + EXPECT_NE(key_b->parent(), node); +} diff --git a/tests/gtest/node_value_extended_test.cpp b/tests/gtest/node_value_extended_test.cpp new file mode 100644 index 000000000..67b064de0 --- /dev/null +++ b/tests/gtest/node_value_extended_test.cpp @@ -0,0 +1,983 @@ +#include + +#include + +#include +#include +#include +#include +#include + +#include "node/keyframe.h" +#include "node/math/math/math.h" +#include "node/color/colormanager/colormanager.h" +#include "node/project.h" +#include "node/value.h" +#include "olive/core/render/audioparams.h" +#include "olive/core/render/samplebuffer.h" +#include "olive/core/render/sampleformat.h" + +TEST(NodeValueExtended, VectorAccessors) +{ + olive::NodeValue v2(olive::NodeValue::kVec2, QVector2D(1.5f, -2.5f)); + EXPECT_FLOAT_EQ(v2.toVec2().x(), 1.5f); + EXPECT_FLOAT_EQ(v2.toVec2().y(), -2.5f); + + olive::NodeValue v3(olive::NodeValue::kVec3, QVector3D(1.0f, 2.0f, 3.0f)); + EXPECT_FLOAT_EQ(v3.toVec3().x(), 1.0f); + EXPECT_FLOAT_EQ(v3.toVec3().y(), 2.0f); + EXPECT_FLOAT_EQ(v3.toVec3().z(), 3.0f); + + olive::NodeValue v4(olive::NodeValue::kVec4, + QVector4D(1.0f, 2.0f, 3.0f, 4.0f)); + EXPECT_FLOAT_EQ(v4.toVec4().x(), 1.0f); + EXPECT_FLOAT_EQ(v4.toVec4().y(), 2.0f); + EXPECT_FLOAT_EQ(v4.toVec4().z(), 3.0f); + EXPECT_FLOAT_EQ(v4.toVec4().w(), 4.0f); +} + +TEST(NodeValueExtended, ColorMatrixBezierAccessors) +{ + olive::NodeValue color(olive::NodeValue::kColor, + olive::core::Color(0.1f, 0.2f, 0.3f, 0.4f)); + EXPECT_FLOAT_EQ(color.toColor().red(), 0.1f); + EXPECT_FLOAT_EQ(color.toColor().green(), 0.2f); + EXPECT_FLOAT_EQ(color.toColor().blue(), 0.3f); + EXPECT_FLOAT_EQ(color.toColor().alpha(), 0.4f); + + QMatrix4x4 matrix; + matrix.scale(2.0f, 3.0f, 4.0f); + olive::NodeValue mat(olive::NodeValue::kMatrix, matrix); + EXPECT_FLOAT_EQ(mat.toMatrix()(0, 0), 2.0f); + EXPECT_FLOAT_EQ(mat.toMatrix()(1, 1), 3.0f); + EXPECT_FLOAT_EQ(mat.toMatrix()(2, 2), 4.0f); + + olive::NodeValue bezier(olive::NodeValue::kBezier, + olive::core::Bezier(1.0, 2.0, 3.0, 4.0, 5.0, 6.0)); + EXPECT_DOUBLE_EQ(bezier.toBezier().x(), 1.0); + EXPECT_DOUBLE_EQ(bezier.toBezier().y(), 2.0); + EXPECT_DOUBLE_EQ(bezier.toBezier().cp1_x(), 3.0); + EXPECT_DOUBLE_EQ(bezier.toBezier().cp1_y(), 4.0); + EXPECT_DOUBLE_EQ(bezier.toBezier().cp2_x(), 5.0); + EXPECT_DOUBLE_EQ(bezier.toBezier().cp2_y(), 6.0); +} + +TEST(NodeValueExtended, ScalarAccessors) +{ + olive::NodeValue boolean(olive::NodeValue::kBoolean, true); + EXPECT_TRUE(boolean.toBool()); + + olive::NodeValue floating(olive::NodeValue::kFloat, 2.75); + EXPECT_DOUBLE_EQ(floating.toDouble(), 2.75); + + olive::NodeValue text(olive::NodeValue::kText, QStringLiteral("oak")); + EXPECT_EQ(text.toString(), QStringLiteral("oak")); + + olive::NodeValue rational_value(olive::NodeValue::kRational, + olive::core::rational(3, 4)); + EXPECT_EQ(rational_value.toRational(), olive::core::rational(3, 4)); + + olive::NodeValue audio( + olive::NodeValue::kAudioParams, + olive::core::AudioParams(48000, olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P)); + EXPECT_EQ(audio.toAudioParams().sample_rate(), 48000); +} + +TEST(NodeValueExtended, SamplesAccessorRoundTripsBuffer) +{ + olive::core::AudioParams params(48000, olive::core::kChannelLayoutMono, + olive::core::SampleFormat::F32P); + olive::core::SampleBuffer buffer(params, size_t(4)); + for (int i = 0; i < 4; i++) { + buffer.data(0)[i] = 0.25f * float(i + 1); + } + + olive::NodeValue value(olive::NodeValue::kSamples, buffer); + olive::core::SampleBuffer out = value.toSamples(); + ASSERT_EQ(out.sample_count(), size_t(4)); + EXPECT_FLOAT_EQ(out.data(0)[0], 0.25f); + EXPECT_FLOAT_EQ(out.data(0)[1], 0.5f); + EXPECT_FLOAT_EQ(out.data(0)[2], 0.75f); + EXPECT_FLOAT_EQ(out.data(0)[3], 1.0f); +} + +TEST(NodeValueExtended, MismatchedTypeAccessorsReturnDefaults) +{ + olive::NodeValue text(olive::NodeValue::kText, QStringLiteral("hello")); + + // Accessors do not validate the stored type; failed QVariant conversions + // produce default-constructed values + EXPECT_EQ(text.toTexture(), nullptr); + EXPECT_FALSE(text.toSamples().is_allocated()); + EXPECT_TRUE(text.toVec4().isNull()); + EXPECT_TRUE(text.toMatrix().isIdentity()); + + const olive::core::Color c = text.toColor(); + EXPECT_FLOAT_EQ(c.red(), 0.0f); + EXPECT_FLOAT_EQ(c.green(), 0.0f); + EXPECT_FLOAT_EQ(c.blue(), 0.0f); + EXPECT_FLOAT_EQ(c.alpha(), 0.0f); +} + +TEST(NodeValueExtended, SourceArrayFlagAndEquality) +{ + olive::MathNode node; // any Node works; only the pointer value is observed + olive::NodeValue value(olive::NodeValue::kFloat, 1.5, &node, true, + QStringLiteral("tag")); + EXPECT_EQ(value.source(), static_cast(&node)); + EXPECT_TRUE(value.array()); + EXPECT_EQ(value.tag(), QStringLiteral("tag")); + EXPECT_EQ(value.type(), olive::NodeValue::kFloat); + EXPECT_TRUE(value); + + // A default-constructed value carries no data + olive::NodeValue empty; + EXPECT_EQ(empty.type(), olive::NodeValue::kNone); + EXPECT_EQ(empty.source(), nullptr); + EXPECT_FALSE(empty.array()); + EXPECT_TRUE(empty.data().isNull()); + EXPECT_FALSE(empty); + + // Equality compares type, tag, and data; the source pointer and the array + // flag are ignored + olive::NodeValue same(olive::NodeValue::kFloat, 1.5, nullptr, false, + QStringLiteral("tag")); + EXPECT_TRUE(value == same); + + olive::NodeValue different_tag(olive::NodeValue::kFloat, 1.5, &node, true, + QStringLiteral("other")); + EXPECT_FALSE(value == different_tag); + + olive::NodeValue different_data(olive::NodeValue::kFloat, 2.5, &node, true, + QStringLiteral("tag")); + EXPECT_FALSE(value == different_data); + + olive::NodeValue different_type(olive::NodeValue::kInt, int64_t(1), &node, + true, QStringLiteral("tag")); + EXPECT_FALSE(value == different_type); +} + +TEST(NodeValueExtended, CanConvertReflectsStoredData) +{ + olive::NodeValue integer(olive::NodeValue::kInt, int64_t(7)); + EXPECT_TRUE(integer.canConvert()); + + olive::NodeValue text(olive::NodeValue::kText, QStringLiteral("hello")); + EXPECT_TRUE(text.canConvert()); + + olive::NodeValue vec(olive::NodeValue::kVec2, QVector2D(1.0f, 2.0f)); + EXPECT_TRUE(vec.canConvert()); + EXPECT_FALSE(vec.canConvert()); +} + +TEST(NodeValueExtended, ColorStringRoundTrip) +{ + const olive::core::Color c(0.25f, 0.5f, 0.75f, 1.0f); + QString encoded = olive::NodeValue::ValueToString( + olive::NodeValue::kColor, QVariant::fromValue(c), false); + QVariant decoded = olive::NodeValue::StringToValue( + olive::NodeValue::kColor, encoded, false); + const olive::core::Color out = decoded.value(); + EXPECT_FLOAT_EQ(out.red(), c.red()); + EXPECT_FLOAT_EQ(out.green(), c.green()); + EXPECT_FLOAT_EQ(out.blue(), c.blue()); + EXPECT_FLOAT_EQ(out.alpha(), c.alpha()); +} + +TEST(NodeValueExtended, BezierStringRoundTrip) +{ + const olive::core::Bezier b(1.0, 2.0, 3.0, 4.0, 5.0, 6.0); + QString encoded = olive::NodeValue::ValueToString( + olive::NodeValue::kBezier, QVariant::fromValue(b), false); + QVariant decoded = olive::NodeValue::StringToValue( + olive::NodeValue::kBezier, encoded, false); + const olive::core::Bezier out = decoded.value(); + EXPECT_DOUBLE_EQ(out.x(), b.x()); + EXPECT_DOUBLE_EQ(out.y(), b.y()); + EXPECT_DOUBLE_EQ(out.cp1_x(), b.cp1_x()); + EXPECT_DOUBLE_EQ(out.cp1_y(), b.cp1_y()); + EXPECT_DOUBLE_EQ(out.cp2_x(), b.cp2_x()); + EXPECT_DOUBLE_EQ(out.cp2_y(), b.cp2_y()); +} + +TEST(NodeValueExtended, RationalStringRoundTrip) +{ + const olive::core::rational r(1, 24); + QString encoded = olive::NodeValue::ValueToString( + olive::NodeValue::kRational, QVariant::fromValue(r), false); + EXPECT_EQ(encoded, QStringLiteral("1/24")); + QVariant decoded = olive::NodeValue::StringToValue( + olive::NodeValue::kRational, encoded, false); + EXPECT_EQ(decoded.value(), r); + + // The rational path applies to key track values too + EXPECT_EQ(olive::NodeValue::ValueToString(olive::NodeValue::kRational, + QVariant::fromValue(r), true), + QStringLiteral("1/24")); +} + +TEST(NodeValueExtended, IntStringRoundTrip) +{ + const int64_t big = INT64_C(9223372036854775807); + QString encoded = olive::NodeValue::ValueToString( + olive::NodeValue::kInt, QVariant::fromValue(big), false); + EXPECT_EQ(encoded, QStringLiteral("9223372036854775807")); + QVariant decoded = olive::NodeValue::StringToValue( + olive::NodeValue::kInt, encoded, false); + EXPECT_EQ(decoded.value(), big); + + const int64_t small = -big - 1; + encoded = olive::NodeValue::ValueToString( + olive::NodeValue::kInt, QVariant::fromValue(small), false); + decoded = olive::NodeValue::StringToValue(olive::NodeValue::kInt, encoded, + false); + EXPECT_EQ(decoded.value(), small); +} + +TEST(NodeValueExtended, BufferAndNoneTypesSerializeToEmptyString) +{ + // Textures, samples, and empty values have no XML representation + EXPECT_TRUE(olive::NodeValue::ValueToString( + olive::NodeValue::kTexture, + QVariant::fromValue(olive::TexturePtr()), false) + .isEmpty()); + EXPECT_TRUE(olive::NodeValue::ValueToString( + olive::NodeValue::kSamples, + QVariant::fromValue(olive::core::SampleBuffer()), false) + .isEmpty()); + EXPECT_TRUE(olive::NodeValue::ValueToString(olive::NodeValue::kNone, + QVariant(), false) + .isEmpty()); +} + +TEST(NodeValueExtended, KeyTrackFlagFallsBackToPlainString) +{ + // With the key-track flag set, values without a dedicated serialization + // fall back to plain string conversion + EXPECT_EQ(olive::NodeValue::ValueToString(olive::NodeValue::kText, + QStringLiteral("hello"), true), + QStringLiteral("hello")); + EXPECT_EQ(olive::NodeValue::ValueToString(olive::NodeValue::kFloat, 2.5, + true), + QStringLiteral("2.5")); + + // StringToValue() likewise leaves key-track values as raw strings + QVariant decoded = olive::NodeValue::StringToValue( + olive::NodeValue::kFloat, QStringLiteral("2.5"), true); + EXPECT_EQ(decoded.toString(), QStringLiteral("2.5")); +} + +TEST(NodeValueExtended, ShortVectorStringIsZeroPadded) +{ + QVariant decoded = olive::NodeValue::StringToValue( + olive::NodeValue::kVec3, QStringLiteral("5:7"), false); + const QVector3D vec = decoded.value(); + EXPECT_FLOAT_EQ(vec.x(), 5.0f); + EXPECT_FLOAT_EQ(vec.y(), 7.0f); + EXPECT_FLOAT_EQ(vec.z(), 0.0f); + + // Even an empty string yields a zero vector rather than crashing + decoded = olive::NodeValue::StringToValue(olive::NodeValue::kVec2, + QString(), false); + const QVector2D vec2 = decoded.value(); + EXPECT_FLOAT_EQ(vec2.x(), 0.0f); + EXPECT_FLOAT_EQ(vec2.y(), 0.0f); +} + +TEST(NodeValueExtended, KeyframeTrackCounts) +{ + EXPECT_EQ(olive::NodeValue::get_number_of_keyframe_tracks( + olive::NodeValue::kVec2), + 2); + EXPECT_EQ(olive::NodeValue::get_number_of_keyframe_tracks( + olive::NodeValue::kVec3), + 3); + EXPECT_EQ(olive::NodeValue::get_number_of_keyframe_tracks( + olive::NodeValue::kVec4), + 4); + EXPECT_EQ(olive::NodeValue::get_number_of_keyframe_tracks( + olive::NodeValue::kColor), + 4); + EXPECT_EQ(olive::NodeValue::get_number_of_keyframe_tracks( + olive::NodeValue::kBezier), + 6); + + // All scalar types live on a single track + EXPECT_EQ(olive::NodeValue::get_number_of_keyframe_tracks( + olive::NodeValue::kFloat), + 1); + EXPECT_EQ(olive::NodeValue::get_number_of_keyframe_tracks( + olive::NodeValue::kInt), + 1); + EXPECT_EQ(olive::NodeValue::get_number_of_keyframe_tracks( + olive::NodeValue::kText), + 1); + EXPECT_EQ(olive::NodeValue::get_number_of_keyframe_tracks( + olive::NodeValue::kRational), + 1); + EXPECT_EQ(olive::NodeValue::get_number_of_keyframe_tracks( + olive::NodeValue::kNone), + 1); +} + +TEST(NodeValueExtended, SplitVectorIntoTrackValues) +{ + olive::NodeValue value(olive::NodeValue::kVec3, + QVector3D(1.0f, 2.0f, 3.0f)); + const olive::SplitValue split = value.to_split_value(); + ASSERT_EQ(split.size(), 3); + EXPECT_FLOAT_EQ(split.at(0).toFloat(), 1.0f); + EXPECT_FLOAT_EQ(split.at(1).toFloat(), 2.0f); + EXPECT_FLOAT_EQ(split.at(2).toFloat(), 3.0f); + + // to_split_value() matches the underlying static helper + const QVector manual = + olive::NodeValue::split_normal_value_into_track_values( + olive::NodeValue::kVec3, + QVariant::fromValue(QVector3D(1.0f, 2.0f, 3.0f))); + ASSERT_EQ(manual.size(), 3); + EXPECT_FLOAT_EQ(manual.at(2).toFloat(), 3.0f); +} + +TEST(NodeValueExtended, SplitColorAndBezierIntoTrackValues) +{ + olive::NodeValue color(olive::NodeValue::kColor, + olive::core::Color(0.1f, 0.2f, 0.3f, 0.4f)); + olive::SplitValue split = color.to_split_value(); + ASSERT_EQ(split.size(), 4); + EXPECT_FLOAT_EQ(split.at(0).toFloat(), 0.1f); + EXPECT_FLOAT_EQ(split.at(1).toFloat(), 0.2f); + EXPECT_FLOAT_EQ(split.at(2).toFloat(), 0.3f); + EXPECT_FLOAT_EQ(split.at(3).toFloat(), 0.4f); + + olive::NodeValue bezier(olive::NodeValue::kBezier, + olive::core::Bezier(1.0, 2.0, 3.0, 4.0, 5.0, 6.0)); + split = bezier.to_split_value(); + ASSERT_EQ(split.size(), 6); + EXPECT_DOUBLE_EQ(split.at(0).toDouble(), 1.0); + EXPECT_DOUBLE_EQ(split.at(1).toDouble(), 2.0); + EXPECT_DOUBLE_EQ(split.at(2).toDouble(), 3.0); + EXPECT_DOUBLE_EQ(split.at(3).toDouble(), 4.0); + EXPECT_DOUBLE_EQ(split.at(4).toDouble(), 5.0); + EXPECT_DOUBLE_EQ(split.at(5).toDouble(), 6.0); +} + +TEST(NodeValueExtended, SplitScalarStaysSingleValue) +{ + olive::NodeValue value(olive::NodeValue::kFloat, 4.75); + const olive::SplitValue split = value.to_split_value(); + ASSERT_EQ(split.size(), 1); + EXPECT_DOUBLE_EQ(split.at(0).toDouble(), 4.75); +} + +TEST(NodeValueExtended, CombineTrackValuesRebuildsValue) +{ + // Round trip through split/combine restores the original value + const olive::core::Color c(0.25f, 0.5f, 0.75f, 1.0f); + olive::SplitValue split = + olive::NodeValue(olive::NodeValue::kColor, c).to_split_value(); + QVariant combined = olive::NodeValue::combine_track_values_into_normal_value( + olive::NodeValue::kColor, split); + const olive::core::Color color_out = combined.value(); + EXPECT_FLOAT_EQ(color_out.red(), c.red()); + EXPECT_FLOAT_EQ(color_out.green(), c.green()); + EXPECT_FLOAT_EQ(color_out.blue(), c.blue()); + EXPECT_FLOAT_EQ(color_out.alpha(), c.alpha()); + + const QVector2D vec(1.5f, -2.5f); + split = olive::NodeValue(olive::NodeValue::kVec2, vec).to_split_value(); + combined = olive::NodeValue::combine_track_values_into_normal_value( + olive::NodeValue::kVec2, split); + const QVector2D vec_out = combined.value(); + EXPECT_FLOAT_EQ(vec_out.x(), vec.x()); + EXPECT_FLOAT_EQ(vec_out.y(), vec.y()); + + // Scalar types return the first (only) track value + QVariant scalar = olive::NodeValue::combine_track_values_into_normal_value( + olive::NodeValue::kFloat, { 4.5 }); + EXPECT_DOUBLE_EQ(scalar.toDouble(), 4.5); + + // An empty split combines to a null variant + EXPECT_TRUE(olive::NodeValue::combine_track_values_into_normal_value( + olive::NodeValue::kVec2, {}) + .isNull()); +} + +TEST(NodeValueExtended, PrettyDataTypeNames) +{ + for (int i = olive::NodeValue::kNone; i < olive::NodeValue::kDataTypeCount; + i++) { + const auto type = static_cast(i); + EXPECT_FALSE(olive::NodeValue::GetPrettyDataTypeName(type).isEmpty()) + << "type " << i; + } + EXPECT_EQ(olive::NodeValue::GetPrettyDataTypeName( + olive::NodeValue::kDataTypeCount), + QStringLiteral("Unknown")); + + // NOTE: kStrCombo and kPushButton have no dedicated pretty name and fall + // through to "Unknown" (naming gap, documented here). + EXPECT_EQ( + olive::NodeValue::GetPrettyDataTypeName(olive::NodeValue::kStrCombo), + QStringLiteral("Unknown")); + EXPECT_EQ( + olive::NodeValue::GetPrettyDataTypeName(olive::NodeValue::kPushButton), + QStringLiteral("Unknown")); +} + +TEST(NodeValueExtended, TypeClassificationRemainingCases) +{ + EXPECT_TRUE( + olive::NodeValue::type_can_be_interpolated(olive::NodeValue::kVec2)); + EXPECT_TRUE( + olive::NodeValue::type_can_be_interpolated(olive::NodeValue::kVec3)); + EXPECT_TRUE( + olive::NodeValue::type_can_be_interpolated(olive::NodeValue::kVec4)); + EXPECT_TRUE( + olive::NodeValue::type_can_be_interpolated(olive::NodeValue::kBezier)); + EXPECT_TRUE( + olive::NodeValue::type_can_be_interpolated(olive::NodeValue::kRational)); + EXPECT_FALSE( + olive::NodeValue::type_can_be_interpolated(olive::NodeValue::kNone)); + EXPECT_FALSE( + olive::NodeValue::type_can_be_interpolated(olive::NodeValue::kText)); + EXPECT_FALSE( + olive::NodeValue::type_can_be_interpolated(olive::NodeValue::kTexture)); + EXPECT_FALSE( + olive::NodeValue::type_can_be_interpolated(olive::NodeValue::kSamples)); + EXPECT_FALSE( + olive::NodeValue::type_can_be_interpolated(olive::NodeValue::kBoolean)); + + EXPECT_TRUE(olive::NodeValue::type_is_numeric(olive::NodeValue::kRational)); + EXPECT_FALSE(olive::NodeValue::type_is_numeric(olive::NodeValue::kVec2)); + EXPECT_FALSE(olive::NodeValue::type_is_numeric(olive::NodeValue::kColor)); + EXPECT_FALSE(olive::NodeValue::type_is_numeric(olive::NodeValue::kBoolean)); + EXPECT_FALSE(olive::NodeValue::type_is_numeric(olive::NodeValue::kNone)); + + EXPECT_TRUE(olive::NodeValue::type_is_vector(olive::NodeValue::kVec4)); + EXPECT_FALSE(olive::NodeValue::type_is_vector(olive::NodeValue::kColor)); + EXPECT_FALSE(olive::NodeValue::type_is_vector(olive::NodeValue::kText)); + + EXPECT_FALSE(olive::NodeValue::type_is_buffer(olive::NodeValue::kNone)); + EXPECT_FALSE(olive::NodeValue::type_is_buffer(olive::NodeValue::kFloat)); +} + +TEST(NodeValueExtended, UnnamedTypesHaveEmptyDataTypeNames) +{ + EXPECT_TRUE( + olive::NodeValue::GetDataTypeName(olive::NodeValue::kStrCombo) + .isEmpty()); + EXPECT_TRUE( + olive::NodeValue::GetDataTypeName(olive::NodeValue::kPushButton) + .isEmpty()); + EXPECT_TRUE( + olive::NodeValue::GetDataTypeName(olive::NodeValue::kDataTypeCount) + .isEmpty()); + + EXPECT_EQ(olive::NodeValue::GetDataTypeFromName( + QStringLiteral("not-a-type")), + olive::NodeValue::kNone); + + // NOTE: an empty name matches the first type with an empty serialized + // name (kStrCombo) rather than producing kNone (suspected bug, documented + // here). + EXPECT_EQ(olive::NodeValue::GetDataTypeFromName(QString()), + olive::NodeValue::kStrCombo); +} + +TEST(NodeValueExtended, ArrayValuesRoundTrip) +{ + olive::NodeValueArray array; + array[0] = olive::NodeValue(olive::NodeValue::kInt, int64_t(4)); + array[5] = olive::NodeValue(olive::NodeValue::kText, + QStringLiteral("five")); + + olive::NodeValue value(olive::NodeValue::kInt, array, nullptr, true); + EXPECT_TRUE(value.array()); + + const olive::NodeValueArray round_trip = value.toArray(); + ASSERT_EQ(round_trip.size(), size_t(2)); + EXPECT_EQ(round_trip.at(0).toInt(), 4); + EXPECT_EQ(round_trip.at(5).toString(), QStringLiteral("five")); +} + +TEST(NodeValueTableExtended, GetReturnsNewestMatchingValue) +{ + olive::NodeValueTable table; + table.Push(olive::NodeValue(olive::NodeValue::kFloat, 1.0)); + table.Push(olive::NodeValue(olive::NodeValue::kFloat, 2.0)); + + // Get() scans from the back, so the newest value wins and nothing is + // removed + EXPECT_DOUBLE_EQ(table.Get(olive::NodeValue::kFloat).toDouble(), 2.0); + EXPECT_EQ(table.Count(), 2); +} + +TEST(NodeValueTableExtended, GetWithTagSelectsMatchingValue) +{ + olive::NodeValueTable table; + table.Push(olive::NodeValue(olive::NodeValue::kFloat, 1.0, nullptr, + QStringLiteral("a"))); + table.Push(olive::NodeValue(olive::NodeValue::kFloat, 2.0, nullptr, + QStringLiteral("b"))); + table.Push(olive::NodeValue(olive::NodeValue::kFloat, 3.0)); + + EXPECT_DOUBLE_EQ( + table.Get(olive::NodeValue::kFloat, QStringLiteral("a")).toDouble(), + 1.0); + EXPECT_DOUBLE_EQ( + table.Get(olive::NodeValue::kFloat, QStringLiteral("b")).toDouble(), + 2.0); + + // Without a tag the newest value wins + EXPECT_DOUBLE_EQ(table.Get(olive::NodeValue::kFloat).toDouble(), 3.0); + EXPECT_EQ(table.GetValueIndex({ olive::NodeValue::kFloat }, + QStringLiteral("b")), + 1); + + // NOTE: an unknown tag does not yield an empty value; the search keeps + // scanning and returns the oldest value of the type instead (suspected + // bug, documented here). + EXPECT_DOUBLE_EQ( + table.Get(olive::NodeValue::kFloat, QStringLiteral("missing")) + .toDouble(), + 1.0); +} + +TEST(NodeValueTableExtended, GetWithMultipleTypes) +{ + olive::NodeValueTable table; + table.Push( + olive::NodeValue(olive::NodeValue::kText, QStringLiteral("s"))); + table.Push(olive::NodeValue(olive::NodeValue::kFloat, 2.5)); + + olive::NodeValue newest = + table.Get({ olive::NodeValue::kVec2, olive::NodeValue::kFloat }); + EXPECT_DOUBLE_EQ(newest.toDouble(), 2.5); + + olive::NodeValue text = + table.Get({ olive::NodeValue::kVec2, olive::NodeValue::kText }); + EXPECT_EQ(text.toString(), QStringLiteral("s")); + + // A type that was never pushed produces an empty (kNone) value + olive::NodeValue missing = table.Get(olive::NodeValue::kColor); + EXPECT_EQ(missing.type(), olive::NodeValue::kNone); + EXPECT_FALSE(missing); +} + +TEST(NodeValueTableExtended, PrependAddsValueToFront) +{ + olive::NodeValueTable table; + table.Push(olive::NodeValue(olive::NodeValue::kFloat, 1.0)); + table.Prepend(olive::NodeValue(olive::NodeValue::kFloat, 2.0)); + table.Prepend(olive::NodeValue::kText, QStringLiteral("t"), nullptr, + QStringLiteral("tag")); + + ASSERT_EQ(table.Count(), 3); + EXPECT_EQ(table.at(0).type(), olive::NodeValue::kText); + EXPECT_EQ(table.at(0).tag(), QStringLiteral("tag")); + EXPECT_DOUBLE_EQ(table.at(1).toDouble(), 2.0); + EXPECT_DOUBLE_EQ(table.at(2).toDouble(), 1.0); + + // Get() scans from the back, so prepended values are the lowest priority + EXPECT_DOUBLE_EQ(table.Get(olive::NodeValue::kFloat).toDouble(), 1.0); +} + +TEST(NodeValueTableExtended, TakeWithTagAndMissingType) +{ + olive::NodeValueTable table; + table.Push(olive::NodeValue(olive::NodeValue::kText, QStringLiteral("a"), + nullptr, QStringLiteral("x"))); + table.Push(olive::NodeValue(olive::NodeValue::kText, QStringLiteral("b"), + nullptr, QStringLiteral("y"))); + + olive::NodeValue taken = + table.Take(olive::NodeValue::kText, QStringLiteral("x")); + EXPECT_EQ(taken.toString(), QStringLiteral("a")); + EXPECT_EQ(table.Count(), 1); + + // Taking a type that is not present returns an empty value and leaves the + // table unchanged + olive::NodeValue absent = table.Take(olive::NodeValue::kColor); + EXPECT_EQ(absent.type(), olive::NodeValue::kNone); + EXPECT_EQ(table.Count(), 1); + + // NOTE: like Get(), an unmatched tag falls back to the oldest value of + // the type (suspected bug, documented here). + olive::NodeValue fallback = + table.Take(olive::NodeValue::kText, QStringLiteral("missing")); + EXPECT_EQ(fallback.toString(), QStringLiteral("b")); + EXPECT_TRUE(table.isEmpty()); +} + +TEST(NodeValueTableExtended, TakeWithMultipleTypes) +{ + olive::NodeValueTable table; + table.Push( + olive::NodeValue(olive::NodeValue::kText, QStringLiteral("s"))); + table.Push(olive::NodeValue(olive::NodeValue::kFloat, 1.5)); + + olive::NodeValue taken = + table.Take({ olive::NodeValue::kVec2, olive::NodeValue::kFloat }); + EXPECT_DOUBLE_EQ(taken.toDouble(), 1.5); + ASSERT_EQ(table.Count(), 1); + EXPECT_EQ(table.at(0).type(), olive::NodeValue::kText); +} + +TEST(NodeValueTableExtended, TakeAtRemovesByIndex) +{ + olive::NodeValueTable table; + table.Push(olive::NodeValue(olive::NodeValue::kInt, int64_t(1))); + table.Push(olive::NodeValue(olive::NodeValue::kInt, int64_t(2))); + table.Push(olive::NodeValue(olive::NodeValue::kInt, int64_t(3))); + + olive::NodeValue taken = table.TakeAt(1); + EXPECT_EQ(taken.toInt(), 2); + ASSERT_EQ(table.Count(), 2); + EXPECT_EQ(table.at(0).toInt(), 1); + EXPECT_EQ(table.at(1).toInt(), 3); +} + +TEST(NodeValueTableExtended, RemoveDeletesNewestEqualValue) +{ + olive::NodeValueTable table; + table.Push(olive::NodeValue(olive::NodeValue::kInt, int64_t(1))); + table.Push(olive::NodeValue(olive::NodeValue::kInt, int64_t(2))); + table.Push(olive::NodeValue(olive::NodeValue::kInt, int64_t(1))); + + // Remove() scans from the back and drops the newest equal value + table.Remove(olive::NodeValue(olive::NodeValue::kInt, int64_t(1))); + ASSERT_EQ(table.Count(), 2); + EXPECT_EQ(table.at(0).toInt(), 1); + EXPECT_EQ(table.at(1).toInt(), 2); + + // Removing a value that is not present is a no-op + table.Remove(olive::NodeValue(olive::NodeValue::kInt, int64_t(99))); + EXPECT_EQ(table.Count(), 2); +} + +TEST(NodeValueTableExtended, HasUsesBitmaskComparison) +{ + olive::NodeValueTable table; + table.Push(olive::NodeValue(olive::NodeValue::kFloat, 1.0)); + EXPECT_TRUE(table.Has(olive::NodeValue::kFloat)); + EXPECT_FALSE(table.Has(olive::NodeValue::kInt)); + + // NOTE: Has() compares types with a bitwise AND even though Type is a + // sequential enum, so unrelated types alias: kFloat (2) also satisfies + // kRational (3) and kText (7) because 2 & 3 != 0 and 2 & 7 != 0 + // (suspected bug, documented here). + EXPECT_TRUE(table.Has(olive::NodeValue::kRational)); + EXPECT_TRUE(table.Has(olive::NodeValue::kText)); + + // kNone is zero, so a table holding a kNone value never reports it + olive::NodeValueTable none_table; + none_table.Push(olive::NodeValue()); + EXPECT_FALSE(none_table.Has(olive::NodeValue::kNone)); +} + +TEST(NodeValueTableExtended, PushTableAppendsAllValues) +{ + olive::NodeValueTable first; + first.Push(olive::NodeValue(olive::NodeValue::kFloat, 1.0)); + first.Push( + olive::NodeValue(olive::NodeValue::kText, QStringLiteral("a"))); + + olive::NodeValueTable second; + second.Push(olive::NodeValue(olive::NodeValue::kInt, int64_t(7))); + + first.Push(second); + ASSERT_EQ(first.Count(), 3); + EXPECT_EQ(first.at(2).toInt(), 7); +} + +TEST(NodeValueTableExtended, MergeSlipstreamsTables) +{ + // A single table is returned as-is + olive::NodeValueTable single; + single.Push(olive::NodeValue(olive::NodeValue::kInt, int64_t(9))); + olive::NodeValueTable merged_single = + olive::NodeValueTable::Merge({ single }); + ASSERT_EQ(merged_single.Count(), 1); + EXPECT_EQ(merged_single.at(0).toInt(), 9); + + // Merging no tables yields an empty table + EXPECT_TRUE(olive::NodeValueTable::Merge({}).isEmpty()); + + // Rows are slipstreamed together from the back of each input table + olive::NodeValueTable a; + a.Push(olive::NodeValue(olive::NodeValue::kInt, int64_t(1))); + a.Push(olive::NodeValue(olive::NodeValue::kInt, int64_t(3))); + olive::NodeValueTable b; + b.Push(olive::NodeValue(olive::NodeValue::kInt, int64_t(2))); + b.Push(olive::NodeValue(olive::NodeValue::kInt, int64_t(4))); + + olive::NodeValueTable merged = olive::NodeValueTable::Merge({ a, b }); + ASSERT_EQ(merged.Count(), 4); + EXPECT_EQ(merged.at(0).toInt(), 2); + EXPECT_EQ(merged.at(1).toInt(), 1); + EXPECT_EQ(merged.at(2).toInt(), 4); + EXPECT_EQ(merged.at(3).toInt(), 3); + + // A longer table's excess rows end up at the front + olive::NodeValueTable c; + c.Push(olive::NodeValue(olive::NodeValue::kInt, int64_t(5))); + c.Push(olive::NodeValue(olive::NodeValue::kInt, int64_t(6))); + c.Push(olive::NodeValue(olive::NodeValue::kInt, int64_t(7))); + + olive::NodeValueTable merged_long = olive::NodeValueTable::Merge({ a, c }); + ASSERT_EQ(merged_long.Count(), 5); + EXPECT_EQ(merged_long.at(0).toInt(), 5); + EXPECT_EQ(merged_long.at(1).toInt(), 6); + EXPECT_EQ(merged_long.at(2).toInt(), 1); + EXPECT_EQ(merged_long.at(3).toInt(), 7); + EXPECT_EQ(merged_long.at(4).toInt(), 3); +} + +TEST(NodeKeyframeExtended, FullConstructorInitializesFields) +{ + olive::NodeKeyframe key(olive::core::rational(1, 24), 42.0, + olive::NodeKeyframe::kBezier, 2, 3, + QStringLiteral("input_name")); + EXPECT_EQ(key.time(), olive::core::rational(1, 24)); + EXPECT_DOUBLE_EQ(key.value().toDouble(), 42.0); + EXPECT_EQ(key.type(), olive::NodeKeyframe::kBezier); + EXPECT_EQ(key.track(), 2); + EXPECT_EQ(key.element(), 3); + EXPECT_EQ(key.input(), QStringLiteral("input_name")); + EXPECT_TRUE(key.bezier_control_in().isNull()); + EXPECT_TRUE(key.bezier_control_out().isNull()); + EXPECT_EQ(key.previous(), nullptr); + EXPECT_EQ(key.next(), nullptr); + EXPECT_EQ(key.parent(), nullptr); + + EXPECT_EQ(olive::NodeKeyframe::kDefaultType, olive::NodeKeyframe::kLinear); +} + +TEST(NodeKeyframeExtended, CopyDuplicatesAllFields) +{ + olive::NodeKeyframe key(olive::core::rational(1, 24), 3.5, + olive::NodeKeyframe::kBezier, 1, 2, + QStringLiteral("in")); + key.set_bezier_control_in(QPointF(0.1, 0.2)); + key.set_bezier_control_out(QPointF(0.3, 0.4)); + + std::unique_ptr copy(key.copy()); + EXPECT_EQ(copy->time(), key.time()); + EXPECT_DOUBLE_EQ(copy->value().toDouble(), 3.5); + EXPECT_EQ(copy->type(), olive::NodeKeyframe::kBezier); + EXPECT_EQ(copy->track(), 1); + EXPECT_EQ(copy->element(), 2); + EXPECT_EQ(copy->input(), QStringLiteral("in")); + EXPECT_EQ(copy->bezier_control_in(), QPointF(0.1, 0.2)); + EXPECT_EQ(copy->bezier_control_out(), QPointF(0.3, 0.4)); + EXPECT_EQ(copy->parent(), nullptr); + + // copy(element) overrides the element + std::unique_ptr moved(key.copy(7)); + EXPECT_EQ(moved->element(), 7); + + // The copy is independent of the original + key.set_value(9.0); + key.set_bezier_control_in(QPointF(1.0, 1.0)); + EXPECT_DOUBLE_EQ(copy->value().toDouble(), 3.5); + EXPECT_EQ(copy->bezier_control_in(), QPointF(0.1, 0.2)); +} + +TEST(NodeKeyframeExtended, SettersEmitSignals) +{ + // QSignalSpy resolves signal argument types at runtime; the app normally + // registers these in Core::Start(), which the test harness does not call + qRegisterMetaType(); + qRegisterMetaType(); + + olive::NodeKeyframe key; + + QSignalSpy time_spy(&key, &olive::NodeKeyframe::TimeChanged); + key.set_time(olive::core::rational(1, 2)); + ASSERT_EQ(time_spy.count(), 1); + EXPECT_EQ(time_spy.first().at(0).value(), + olive::core::rational(1, 2)); + + QSignalSpy value_spy(&key, &olive::NodeKeyframe::ValueChanged); + key.set_value(3.5); + ASSERT_EQ(value_spy.count(), 1); + EXPECT_DOUBLE_EQ(value_spy.first().at(0).toDouble(), 3.5); + + QSignalSpy type_spy(&key, &olive::NodeKeyframe::TypeChanged); + key.set_type(olive::NodeKeyframe::kHold); + ASSERT_EQ(type_spy.count(), 1); + EXPECT_EQ(type_spy.first().at(0).value(), + olive::NodeKeyframe::kHold); + + // Setting the same type again does not re-emit + key.set_type(olive::NodeKeyframe::kHold); + EXPECT_EQ(type_spy.count(), 1); + + QSignalSpy in_spy(&key, &olive::NodeKeyframe::BezierControlInChanged); + key.set_bezier_control_in(QPointF(0.25, -0.5)); + ASSERT_EQ(in_spy.count(), 1); + EXPECT_EQ(in_spy.first().at(0).toPointF(), QPointF(0.25, -0.5)); + + QSignalSpy out_spy(&key, &olive::NodeKeyframe::BezierControlOutChanged); + key.set_bezier_control_out(QPointF(-0.25, 0.5)); + ASSERT_EQ(out_spy.count(), 1); + EXPECT_EQ(out_spy.first().at(0).toPointF(), QPointF(-0.25, 0.5)); +} + +TEST(NodeKeyframeExtended, SetTypeToBezierInitializesHandles) +{ + // Without neighbors the handles default to one second either way + olive::NodeKeyframe lone; + lone.set_time(olive::core::rational(2)); + lone.set_type(olive::NodeKeyframe::kBezier); + EXPECT_DOUBLE_EQ(lone.bezier_control_in().x(), -1.0); + EXPECT_DOUBLE_EQ(lone.bezier_control_in().y(), 0.0); + EXPECT_DOUBLE_EQ(lone.bezier_control_out().x(), 1.0); + EXPECT_DOUBLE_EQ(lone.bezier_control_out().y(), 0.0); + + // With neighbors the handles default to halfway to each neighbor's time + olive::NodeKeyframe previous; + previous.set_time(olive::core::rational(-4)); + olive::NodeKeyframe next; + next.set_time(olive::core::rational(8)); + olive::NodeKeyframe key; + key.set_time(olive::core::rational(2)); + key.set_previous(&previous); + key.set_next(&next); + key.set_type(olive::NodeKeyframe::kBezier); + EXPECT_DOUBLE_EQ(key.bezier_control_in().x(), -3.0); + EXPECT_DOUBLE_EQ(key.bezier_control_in().y(), 0.0); + EXPECT_DOUBLE_EQ(key.bezier_control_out().x(), 3.0); + EXPECT_DOUBLE_EQ(key.bezier_control_out().y(), 0.0); + + // Handles that are already set are preserved + olive::NodeKeyframe preset; + preset.set_bezier_control_in(QPointF(-0.25, 0.5)); + preset.set_bezier_control_out(QPointF(0.75, -0.5)); + preset.set_type(olive::NodeKeyframe::kBezier); + EXPECT_EQ(preset.bezier_control_in(), QPointF(-0.25, 0.5)); + EXPECT_EQ(preset.bezier_control_out(), QPointF(0.75, -0.5)); +} + +TEST(NodeKeyframeExtended, SetTypeNoBezierAdjLeavesHandlesUntouched) +{ + olive::NodeKeyframe key; + key.set_bezier_control_in(QPointF(0.5, 0.5)); + key.set_bezier_control_out(QPointF(-0.5, -0.5)); + key.set_type_no_bezier_adj(olive::NodeKeyframe::kBezier); + EXPECT_EQ(key.type(), olive::NodeKeyframe::kBezier); + EXPECT_EQ(key.bezier_control_in(), QPointF(0.5, 0.5)); + EXPECT_EQ(key.bezier_control_out(), QPointF(-0.5, -0.5)); + + // Handles stay null when none were set + olive::NodeKeyframe other; + other.set_type_no_bezier_adj(olive::NodeKeyframe::kBezier); + EXPECT_TRUE(other.bezier_control_in().isNull()); + EXPECT_TRUE(other.bezier_control_out().isNull()); +} + +TEST(NodeKeyframeExtended, BezierControlAccessorsByHandleType) +{ + olive::NodeKeyframe key; + key.set_bezier_control(olive::NodeKeyframe::kInHandle, + QPointF(-0.5, 0.25)); + key.set_bezier_control(olive::NodeKeyframe::kOutHandle, + QPointF(0.5, -0.25)); + + EXPECT_EQ(key.bezier_control_in(), QPointF(-0.5, 0.25)); + EXPECT_EQ(key.bezier_control_out(), QPointF(0.5, -0.25)); + EXPECT_EQ(key.bezier_control(olive::NodeKeyframe::kInHandle), + key.bezier_control_in()); + EXPECT_EQ(key.bezier_control(olive::NodeKeyframe::kOutHandle), + key.bezier_control_out()); + + EXPECT_EQ(olive::NodeKeyframe::get_opposing_bezier_type( + olive::NodeKeyframe::kInHandle), + olive::NodeKeyframe::kOutHandle); + EXPECT_EQ(olive::NodeKeyframe::get_opposing_bezier_type( + olive::NodeKeyframe::kOutHandle), + olive::NodeKeyframe::kInHandle); +} + +TEST(NodeKeyframeExtended, ValidBezierControlsClampToNeighbors) +{ + olive::NodeKeyframe key; + key.set_time(olive::core::rational(2)); + key.set_bezier_control_in(QPointF(-5.0, 0.5)); + key.set_bezier_control_out(QPointF(5.0, -0.25)); + + // Without neighbors the handles pass through unchanged + EXPECT_EQ(key.valid_bezier_control_in(), QPointF(-5.0, 0.5)); + EXPECT_EQ(key.valid_bezier_control_out(), QPointF(5.0, -0.25)); + + olive::NodeKeyframe previous; + previous.set_time(olive::core::rational(1)); + olive::NodeKeyframe next; + next.set_time(olive::core::rational(3)); + key.set_previous(&previous); + key.set_next(&next); + + EXPECT_EQ(key.previous(), &previous); + EXPECT_EQ(key.next(), &next); + + // The clamped handles may not cross the neighboring keyframe's time + EXPECT_EQ(key.valid_bezier_control_in(), QPointF(-1.0, 0.5)); + EXPECT_EQ(key.valid_bezier_control_out(), QPointF(1.0, -0.25)); +} + +TEST(NodeKeyframeExtended, KeyTrackRefReflectsInputTrackElement) +{ + olive::NodeKeyframe key(olive::core::rational(1, 24), 2.0, + olive::NodeKeyframe::kLinear, 2, 3, + QStringLiteral("my_input")); + + const olive::NodeKeyframeTrackReference ref = key.key_track_ref(); + EXPECT_EQ(ref.track(), 2); + EXPECT_EQ(ref.input().input(), QStringLiteral("my_input")); + EXPECT_EQ(ref.input().element(), 3); + EXPECT_EQ(ref.input().node(), nullptr); + EXPECT_FALSE(ref.IsValid()); +} + +TEST(NodeKeyframeExtended, HasSiblingAtTimeDetectsOtherKeyframes) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *math = new olive::MathNode(); + math->setParent(&project); + + // Keyframe lookups only apply to tracks with keyframing enabled + math->SetInputIsKeyframing(olive::MathNode::kParamAIn, true); + + auto *first = new olive::NodeKeyframe( + olive::core::rational(0), 1.0, olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn, math); + auto *second = new olive::NodeKeyframe( + olive::core::rational(1), 2.0, olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn, math); + + // Parenting inserts the keyframes in time order and links the track + EXPECT_EQ(first->next(), second); + EXPECT_EQ(second->previous(), first); + EXPECT_EQ(first->previous(), nullptr); + EXPECT_EQ(second->next(), nullptr); + + // A sibling exists wherever another keyframe holds the time + EXPECT_TRUE(second->has_sibling_at_time(olive::core::rational(0))); + EXPECT_FALSE(second->has_sibling_at_time(olive::core::rational(1))); + EXPECT_FALSE(first->has_sibling_at_time(olive::core::rational(2))); + + // Inserting out of order keeps the track sorted and relinks neighbors + auto *middle = new olive::NodeKeyframe( + olive::core::rational(1, 2), 1.5, olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn, math); + EXPECT_EQ(first->next(), middle); + EXPECT_EQ(middle->previous(), first); + EXPECT_EQ(middle->next(), second); + EXPECT_EQ(second->previous(), middle); + EXPECT_TRUE(middle->has_sibling_at_time(olive::core::rational(0))); +} diff --git a/tests/gtest/plugin_paraminstance_test.cpp b/tests/gtest/plugin_paraminstance_test.cpp new file mode 100644 index 000000000..903e387d3 --- /dev/null +++ b/tests/gtest/plugin_paraminstance_test.cpp @@ -0,0 +1,889 @@ +#include + +#include + +#include "ofxImageEffect.h" +#include "ofxParam.h" +#include "ofxhClip.h" +#include "ofxhParam.h" +#include "common/avframeptr.h" +#include "common/ffmpegutils.h" +#include "pluginSupport/OliveClip.h" +#include "pluginSupport/image.h" +#include "pluginSupport/paraminstance.h" +#include "render/texture.h" + +namespace +{ +olive::VideoParams MakeParams(int width, int height, + olive::core::PixelFormat format, int channels, + bool premultiplied) +{ + olive::VideoParams params; + params.set_width(width); + params.set_height(height); + params.set_format(format); + params.set_channel_count(channels); + params.set_premultiplied_alpha(premultiplied); + return params; +} + +olive::AVFramePtr CreateFrame(const olive::VideoParams ¶ms) +{ + olive::AVFramePtr frame = olive::CreateAVFramePtr(); + frame->set_format(olive::FFmpegUtils::GetFFmpegPixelFormat( + params.format(), params.channel_count())); + frame->set_width(params.width()); + frame->set_height(params.height()); + if (frame->get_buffer(0) < 0) { + return nullptr; + } + return frame; +} +} + +// ============================================================================ +// app/pluginSupport/paraminstance.h +// +// The node-bound code paths require a PluginNode, which dereferences a real +// OFX ImageEffect::Instance in its constructor and therefore cannot be built +// without a plugin bundle. These tests cover the host-side fallback surface: +// descriptor defaults and the cached-value behavior when no node is bound. +// ============================================================================ + +TEST(PluginParamInstance, CoordinateSystemHelpers) +{ + OFX::Host::Param::Descriptor descriptor(kOfxParamTypeDouble, + "TestCoordinate"); + // A bare descriptor has no coordinate-system property; the OFX property + // suite reports an empty string which is treated as canonical. + EXPECT_FALSE(olive::plugin::IsNormalisedCoordinateSystem(descriptor)); + + descriptor.addStandardParamProps(kOfxParamTypeDouble); + EXPECT_FALSE(olive::plugin::IsNormalisedCoordinateSystem(descriptor)); + + descriptor.getProperties().setStringProperty( + kOfxParamPropDefaultCoordinateSystem, kOfxParamCoordinatesNormalised); + EXPECT_TRUE(olive::plugin::IsNormalisedCoordinateSystem(descriptor)); + + EXPECT_DOUBLE_EQ(olive::plugin::ToNormalised(960.0, 1920.0), 0.5); + EXPECT_DOUBLE_EQ(olive::plugin::ToCanonical(0.5, 1920.0), 960.0); + // A non-positive extent passes the value through unchanged. + EXPECT_DOUBLE_EQ(olive::plugin::ToNormalised(7.5, 0.0), 7.5); + EXPECT_DOUBLE_EQ(olive::plugin::ToCanonical(7.5, 0.0), 7.5); +} + +TEST(PluginParamInstance, ParamChangeLabelContainsParamName) +{ + OFX::Host::Param::Descriptor descriptor(kOfxParamTypeDouble, "Gain"); + EXPECT_EQ(olive::plugin::ParamChangeLabel(descriptor), + QStringLiteral("Change Gain")); +} + +TEST(PluginParamInstance, SubmitUndoCommandIgnoresNullCommand) +{ + EXPECT_NO_THROW(olive::plugin::SubmitUndoCommand( + nullptr, nullptr, QStringLiteral("Ignored"))); +} + +TEST(PluginParamInstance, IntegerInstanceUsesDescriptorDefault) +{ + OFX::Host::Param::Descriptor descriptor(kOfxParamTypeInteger, + "TestIntegerDefault"); + descriptor.addStandardParamProps(kOfxParamTypeInteger); + descriptor.getProperties().setIntProperty(kOfxParamPropDefault, 42); + + olive::plugin::IntegerInstance instance(nullptr, descriptor); + + int value = -1; + EXPECT_EQ(instance.get(value), kOfxStatOK); + EXPECT_EQ(value, 42); + + int time_value = -1; + EXPECT_EQ(instance.get(1.5, time_value), kOfxStatOK); + EXPECT_EQ(time_value, 42); + + // Rebinding to the same (null) node keeps the cached value. + instance.SetNode(nullptr); + EXPECT_EQ(instance.get(value), kOfxStatOK); + EXPECT_EQ(value, 42); +} + +TEST(PluginParamInstance, DoubleInstanceNullNodeRoundTrip) +{ + OFX::Host::Param::Descriptor bare(kOfxParamTypeDouble, "TestDoubleBare"); + olive::plugin::DoubleInstance bare_instance(nullptr, "TestDoubleBare", + bare); + double value = -1.0; + EXPECT_EQ(bare_instance.get(value), kOfxStatOK); + EXPECT_DOUBLE_EQ(value, 0.0); + + OFX::Host::Param::Descriptor descriptor(kOfxParamTypeDouble, "TestDouble"); + descriptor.addStandardParamProps(kOfxParamTypeDouble); + descriptor.getProperties().setDoubleProperty(kOfxParamPropDefault, 3.5); + olive::plugin::DoubleInstance instance(nullptr, "TestDouble", descriptor); + + EXPECT_EQ(instance.get(value), kOfxStatOK); + EXPECT_DOUBLE_EQ(value, 3.5); + + EXPECT_EQ(instance.set(7.25), kOfxStatOK); + EXPECT_EQ(instance.get(value), kOfxStatOK); + EXPECT_DOUBLE_EQ(value, 7.25); + + double time_value = 0.0; + EXPECT_EQ(instance.get(1.5, time_value), kOfxStatOK); + EXPECT_DOUBLE_EQ(time_value, 7.25); + + EXPECT_EQ(instance.set(0.5, -2.5), kOfxStatOK); + EXPECT_EQ(instance.get(value), kOfxStatOK); + EXPECT_DOUBLE_EQ(value, -2.5); + + double derived = 1.0; + EXPECT_EQ(instance.derive(0.0, derived), kOfxStatErrUnsupported); + double integrated = 1.0; + EXPECT_EQ(instance.integrate(0.0, 1.0, integrated), + kOfxStatErrUnsupported); +} + +TEST(PluginParamInstance, BooleanInstanceNullNodeRoundTrip) +{ + OFX::Host::Param::Descriptor bare(kOfxParamTypeBoolean, "TestBooleanBare"); + olive::plugin::BooleanInstance bare_instance(nullptr, "TestBooleanBare", + bare); + bool value = true; + EXPECT_EQ(bare_instance.get(value), kOfxStatOK); + EXPECT_FALSE(value); + + OFX::Host::Param::Descriptor descriptor(kOfxParamTypeBoolean, + "TestBoolean"); + descriptor.addStandardParamProps(kOfxParamTypeBoolean); + descriptor.getProperties().setIntProperty(kOfxParamPropDefault, 1); + olive::plugin::BooleanInstance instance(nullptr, "TestBoolean", descriptor); + + EXPECT_EQ(instance.get(value), kOfxStatOK); + EXPECT_TRUE(value); + + EXPECT_EQ(instance.set(false), kOfxStatOK); + EXPECT_EQ(instance.get(value), kOfxStatOK); + EXPECT_FALSE(value); + + EXPECT_EQ(instance.set(1.0, true), kOfxStatOK); + bool time_value = false; + EXPECT_EQ(instance.get(1.0, time_value), kOfxStatOK); + EXPECT_TRUE(time_value); +} + +TEST(PluginParamInstance, ChoiceInstanceNullNodeRoundTrip) +{ + OFX::Host::Param::Descriptor descriptor(kOfxParamTypeChoice, "TestChoice"); + descriptor.addStandardParamProps(kOfxParamTypeChoice); + descriptor.getProperties().setIntProperty(kOfxParamPropDefault, 2); + olive::plugin::ChoiceInstance instance(nullptr, "TestChoice", descriptor); + + int value = -1; + EXPECT_EQ(instance.get(value), kOfxStatOK); + EXPECT_EQ(value, 2); + + EXPECT_EQ(instance.set(4), kOfxStatOK); + EXPECT_EQ(instance.get(value), kOfxStatOK); + EXPECT_EQ(value, 4); + + int time_value = -1; + EXPECT_EQ(instance.get(2.0, time_value), kOfxStatOK); + EXPECT_EQ(time_value, 4); +} + +TEST(PluginParamInstance, StringInstanceNullNodeRoundTrip) +{ + OFX::Host::Param::Descriptor descriptor(kOfxParamTypeString, "TestString"); + descriptor.addStandardParamProps(kOfxParamTypeString); + descriptor.getProperties().setStringProperty(kOfxParamPropDefault, + "default_text"); + olive::plugin::StringInstance instance(nullptr, "TestString", descriptor); + + std::string value; + EXPECT_EQ(instance.get(value), kOfxStatOK); + EXPECT_EQ(value, "default_text"); + + EXPECT_EQ(instance.set("hello world"), kOfxStatOK); + EXPECT_EQ(instance.get(value), kOfxStatOK); + EXPECT_EQ(value, "hello world"); + + std::string time_value; + EXPECT_EQ(instance.get(3.0, time_value), kOfxStatOK); + EXPECT_EQ(time_value, "hello world"); + + // A null C-string is stored as an empty string. + EXPECT_EQ(instance.set(nullptr), kOfxStatOK); + EXPECT_EQ(instance.get(value), kOfxStatOK); + EXPECT_TRUE(value.empty()); +} + +TEST(PluginParamInstance, CustomInstanceNullNodeRoundTrip) +{ + OFX::Host::Param::Descriptor descriptor(kOfxParamTypeCustom, "TestCustom"); + olive::plugin::CustomInstance instance(nullptr, "TestCustom", descriptor); + + std::string value = "sentinel"; + EXPECT_EQ(instance.get(value), kOfxStatOK); + EXPECT_TRUE(value.empty()); + + EXPECT_EQ(instance.set("binary_blob"), kOfxStatOK); + EXPECT_EQ(instance.get(value), kOfxStatOK); + EXPECT_EQ(value, "binary_blob"); + + std::string time_value; + EXPECT_EQ(instance.get(0.25, time_value), kOfxStatOK); + EXPECT_EQ(time_value, "binary_blob"); +} + +TEST(PluginParamInstance, RGBAInstanceNullNodeRoundTrip) +{ + OFX::Host::Param::Descriptor descriptor(kOfxParamTypeRGBA, "TestRGBA"); + olive::plugin::RGBAInstance instance(nullptr, "TestRGBA", descriptor); + + double r = -1.0, g = -1.0, b = -1.0, a = -1.0; + EXPECT_EQ(instance.get(r, g, b, a), kOfxStatOK); + EXPECT_DOUBLE_EQ(r, 0.0); + EXPECT_DOUBLE_EQ(g, 0.0); + EXPECT_DOUBLE_EQ(b, 0.0); + EXPECT_DOUBLE_EQ(a, 0.0); + + EXPECT_EQ(instance.set(0.1, 0.2, 0.3, 0.4), kOfxStatOK); + EXPECT_EQ(instance.get(r, g, b, a), kOfxStatOK); + EXPECT_DOUBLE_EQ(r, 0.1); + EXPECT_DOUBLE_EQ(g, 0.2); + EXPECT_DOUBLE_EQ(b, 0.3); + EXPECT_DOUBLE_EQ(a, 0.4); + + EXPECT_EQ(instance.set(2.0, 0.5, 0.6, 0.7, 0.8), kOfxStatOK); + EXPECT_EQ(instance.get(2.0, r, g, b, a), kOfxStatOK); + EXPECT_DOUBLE_EQ(r, 0.5); + EXPECT_DOUBLE_EQ(g, 0.6); + EXPECT_DOUBLE_EQ(b, 0.7); + EXPECT_DOUBLE_EQ(a, 0.8); +} + +TEST(PluginParamInstance, RGBInstanceNullNodeRoundTrip) +{ + OFX::Host::Param::Descriptor descriptor(kOfxParamTypeRGB, "TestRGB"); + olive::plugin::RGBInstance instance(nullptr, "TestRGB", descriptor); + + double r = -1.0, g = -1.0, b = -1.0; + EXPECT_EQ(instance.get(r, g, b), kOfxStatOK); + EXPECT_DOUBLE_EQ(r, 0.0); + EXPECT_DOUBLE_EQ(g, 0.0); + EXPECT_DOUBLE_EQ(b, 0.0); + + EXPECT_EQ(instance.set(1.0, 0.5, 0.25), kOfxStatOK); + EXPECT_EQ(instance.get(r, g, b), kOfxStatOK); + EXPECT_DOUBLE_EQ(r, 1.0); + EXPECT_DOUBLE_EQ(g, 0.5); + EXPECT_DOUBLE_EQ(b, 0.25); + + EXPECT_EQ(instance.set(1.5, 0.75, 0.5, 0.125), kOfxStatOK); + EXPECT_EQ(instance.get(1.5, r, g, b), kOfxStatOK); + EXPECT_DOUBLE_EQ(r, 0.75); + EXPECT_DOUBLE_EQ(g, 0.5); + EXPECT_DOUBLE_EQ(b, 0.125); +} + +TEST(PluginParamInstance, Double2DInstanceNullNodeRoundTrip) +{ + OFX::Host::Param::Descriptor descriptor(kOfxParamTypeDouble2D, + "TestDouble2D"); + olive::plugin::Double2DInstance instance(nullptr, "TestDouble2D", + descriptor); + + double x = -1.0, y = -1.0; + EXPECT_EQ(instance.get(x, y), kOfxStatOK); + EXPECT_DOUBLE_EQ(x, 0.0); + EXPECT_DOUBLE_EQ(y, 0.0); + + EXPECT_EQ(instance.set(1.5, -2.5), kOfxStatOK); + EXPECT_EQ(instance.get(x, y), kOfxStatOK); + EXPECT_DOUBLE_EQ(x, 1.5); + EXPECT_DOUBLE_EQ(y, -2.5); + + EXPECT_EQ(instance.set(0.5, 3.0, 4.0), kOfxStatOK); + EXPECT_EQ(instance.get(0.5, x, y), kOfxStatOK); + EXPECT_DOUBLE_EQ(x, 3.0); + EXPECT_DOUBLE_EQ(y, 4.0); +} + +TEST(PluginParamInstance, Integer2DInstanceNullNodeRoundTrip) +{ + OFX::Host::Param::Descriptor descriptor(kOfxParamTypeInteger2D, + "TestInteger2D"); + olive::plugin::Integer2DInstance instance(nullptr, "TestInteger2D", + descriptor); + + int x = -1, y = -1; + EXPECT_EQ(instance.get(x, y), kOfxStatOK); + EXPECT_EQ(x, 0); + EXPECT_EQ(y, 0); + + EXPECT_EQ(instance.set(3, -7), kOfxStatOK); + EXPECT_EQ(instance.get(x, y), kOfxStatOK); + EXPECT_EQ(x, 3); + EXPECT_EQ(y, -7); + + EXPECT_EQ(instance.set(2.0, 10, 20), kOfxStatOK); + EXPECT_EQ(instance.get(2.0, x, y), kOfxStatOK); + EXPECT_EQ(x, 10); + EXPECT_EQ(y, 20); +} + +TEST(PluginParamInstance, Double3DInstanceNullNodeRoundTrip) +{ + OFX::Host::Param::Descriptor descriptor(kOfxParamTypeDouble3D, + "TestDouble3D"); + olive::plugin::Double3DInstance instance(nullptr, "TestDouble3D", + descriptor); + + double x = -1.0, y = -1.0, z = -1.0; + EXPECT_EQ(instance.get(x, y, z), kOfxStatOK); + EXPECT_DOUBLE_EQ(x, 0.0); + EXPECT_DOUBLE_EQ(y, 0.0); + EXPECT_DOUBLE_EQ(z, 0.0); + + EXPECT_EQ(instance.set(1.0, 2.0, 3.0), kOfxStatOK); + EXPECT_EQ(instance.get(x, y, z), kOfxStatOK); + EXPECT_DOUBLE_EQ(x, 1.0); + EXPECT_DOUBLE_EQ(y, 2.0); + EXPECT_DOUBLE_EQ(z, 3.0); + + EXPECT_EQ(instance.set(4.0, -1.0, -2.0, -3.0), kOfxStatOK); + EXPECT_EQ(instance.get(4.0, x, y, z), kOfxStatOK); + EXPECT_DOUBLE_EQ(x, -1.0); + EXPECT_DOUBLE_EQ(y, -2.0); + EXPECT_DOUBLE_EQ(z, -3.0); +} + +TEST(PluginParamInstance, Integer3DInstanceNullNodeRoundTrip) +{ + OFX::Host::Param::Descriptor descriptor(kOfxParamTypeInteger3D, + "TestInteger3D"); + olive::plugin::Integer3DInstance instance(nullptr, "TestInteger3D", + descriptor); + + int x = -1, y = -1, z = -1; + EXPECT_EQ(instance.get(x, y, z), kOfxStatOK); + EXPECT_EQ(x, 0); + EXPECT_EQ(y, 0); + EXPECT_EQ(z, 0); + + EXPECT_EQ(instance.set(-1, 0, 5), kOfxStatOK); + EXPECT_EQ(instance.get(x, y, z), kOfxStatOK); + EXPECT_EQ(x, -1); + EXPECT_EQ(y, 0); + EXPECT_EQ(z, 5); + + EXPECT_EQ(instance.set(3.0, 7, 8, 9), kOfxStatOK); + EXPECT_EQ(instance.get(3.0, x, y, z), kOfxStatOK); + EXPECT_EQ(x, 7); + EXPECT_EQ(y, 8); + EXPECT_EQ(z, 9); +} + +TEST(PluginParamInstance, PushbuttonGroupAndPageInstancesExposeNames) +{ + OFX::Host::Param::Descriptor button_desc(kOfxParamTypePushButton, + "TestButton"); + olive::plugin::PushbuttonInstance button(nullptr, "TestButton", + button_desc); + button.SetNode(nullptr); + EXPECT_EQ(button.getName(), "TestButton"); + + OFX::Host::Param::Descriptor group_desc(kOfxParamTypeGroup, "TestGroup"); + olive::plugin::GroupInstance group(group_desc); + EXPECT_EQ(group.getName(), "TestGroup"); + + OFX::Host::Param::Descriptor page_desc(kOfxParamTypePage, "TestPage"); + olive::plugin::PageInstance page(page_desc); + EXPECT_EQ(page.getName(), "TestPage"); +} + +// ============================================================================ +// app/pluginSupport/OliveClip.cpp +// ============================================================================ + +TEST(PluginClipInstance, UnmappedBitDepthFallsBackToParams) +{ + // U16 -> kOfxBitDepthShort is covered by PluginSupportClip.PropertyGetters. + struct Case { + olive::core::PixelFormat format; + const char *expected; + }; + const Case cases[] = { + { olive::core::PixelFormat::U8, kOfxBitDepthByte }, + { olive::core::PixelFormat::U10, kOfxBitDepthNone }, + { olive::core::PixelFormat::F16, kOfxBitDepthHalf }, + { olive::core::PixelFormat::F32, kOfxBitDepthFloat }, + { olive::core::PixelFormat::INVALID, kOfxBitDepthNone }, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + SCOPED_TRACE(i); + OFX::Host::ImageEffect::ClipDescriptor desc( + kOfxImageEffectOutputClipName); + olive::VideoParams params = + MakeParams(16, 16, cases[i].format, 4, false); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + EXPECT_EQ(clip.getUnmappedBitDepth(), cases[i].expected); + } +} + +TEST(PluginClipInstance, UnmappedBitDepthPrefersPluginChoice) +{ + OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName); + olive::VideoParams params = + MakeParams(16, 16, olive::core::PixelFormat::U8, 4, false); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + + clip.setPixelDepth(kOfxBitDepthFloat); + EXPECT_EQ(clip.getUnmappedBitDepth(), kOfxBitDepthFloat); + + // kOfxBitDepthNone means "no preference" and falls back to the params. + clip.setPixelDepth(kOfxBitDepthNone); + EXPECT_EQ(clip.getUnmappedBitDepth(), kOfxBitDepthByte); +} + +TEST(PluginClipInstance, UnmappedComponentsFallsBackToParams) +{ + // 3 channels -> kOfxImageComponentRGB is covered by + // PluginSupportClip.PropertyGetters. + struct Case { + int channels; + const char *expected; + }; + const Case cases[] = { + { 1, kOfxImageComponentAlpha }, + { 4, kOfxImageComponentRGBA }, + { 2, kOfxImageComponentNone }, + { 0, kOfxImageComponentNone }, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + SCOPED_TRACE(i); + OFX::Host::ImageEffect::ClipDescriptor desc( + kOfxImageEffectOutputClipName); + olive::VideoParams params = MakeParams( + 16, 16, olive::core::PixelFormat::U8, cases[i].channels, false); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + EXPECT_EQ(clip.getUnmappedComponents(), cases[i].expected); + } +} + +TEST(PluginClipInstance, UnmappedComponentsPrefersPluginChoice) +{ + OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName); + olive::VideoParams params = + MakeParams(16, 16, olive::core::PixelFormat::U8, 4, false); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + + clip.setComponents(kOfxImageComponentAlpha); + EXPECT_EQ(clip.getUnmappedComponents(), kOfxImageComponentAlpha); + + clip.setComponents(kOfxImageComponentNone); + EXPECT_EQ(clip.getUnmappedComponents(), kOfxImageComponentRGBA); +} + +TEST(PluginClipInstance, PremultReflectsPremultipliedParams) +{ + OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName); + olive::VideoParams params = + MakeParams(16, 16, olive::core::PixelFormat::U8, 4, true); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + + EXPECT_EQ(clip.getPremult(), kOfxImagePreMultiplied); +} + +TEST(PluginClipInstance, AspectRatioDefaultsToOneForZeroPar) +{ + OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName); + olive::VideoParams params = + MakeParams(16, 16, olive::core::PixelFormat::U8, 4, false); + params.set_pixel_aspect_ratio(olive::core::rational(0, 1)); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + + EXPECT_DOUBLE_EQ(clip.getAspectRatio(), 1.0); +} + +TEST(PluginClipInstance, FieldOrderNoneAndLower) +{ + // kInterlacedTopFirst -> kOfxImageFieldUpper is covered by + // PluginSupportClip.PropertyGetters. + OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName); + olive::VideoParams progressive = + MakeParams(16, 16, olive::core::PixelFormat::U8, 4, false); + progressive.set_interlacing(olive::VideoParams::kInterlaceNone); + olive::plugin::OliveClipInstance progressive_clip(nullptr, desc, + progressive); + EXPECT_EQ(progressive_clip.getFieldOrder(), kOfxImageFieldNone); + + olive::VideoParams lower = + MakeParams(16, 16, olive::core::PixelFormat::U8, 4, false); + lower.set_interlacing(olive::VideoParams::kInterlacedBottomFirst); + olive::plugin::OliveClipInstance lower_clip(nullptr, desc, lower); + EXPECT_EQ(lower_clip.getFieldOrder(), kOfxImageFieldLower); +} + +TEST(PluginClipInstance, RegionOfDefinitionDefaultsToScaledFrame) +{ + OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName); + olive::VideoParams params = + MakeParams(100, 80, olive::core::PixelFormat::U8, 4, false); + params.set_pixel_aspect_ratio(olive::core::rational(2, 1)); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + + OfxRectD rod = clip.getRegionOfDefinition(0.0); + EXPECT_DOUBLE_EQ(rod.x1, 0.0); + EXPECT_DOUBLE_EQ(rod.y1, 0.0); + EXPECT_DOUBLE_EQ(rod.x2, 200.0); + EXPECT_DOUBLE_EQ(rod.y2, 80.0); +} + +TEST(PluginClipInstance, RegionOfDefinitionPerTimeOverride) +{ + OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName); + olive::VideoParams params = + MakeParams(100, 80, olive::core::PixelFormat::U8, 4, false); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + + OfxRectD custom = { 1.5, 2.5, 51.5, 21.0 }; + clip.setRegionOfDefinition(custom, 3.0); + + OfxRectD at_three = clip.getRegionOfDefinition(3.0); + EXPECT_DOUBLE_EQ(at_three.x1, 1.5); + EXPECT_DOUBLE_EQ(at_three.y1, 2.5); + EXPECT_DOUBLE_EQ(at_three.x2, 51.5); + EXPECT_DOUBLE_EQ(at_three.y2, 21.0); + + // Other times still fall back to the params-derived region. + OfxRectD at_four = clip.getRegionOfDefinition(4.0); + EXPECT_DOUBLE_EQ(at_four.x1, 0.0); + EXPECT_DOUBLE_EQ(at_four.y1, 0.0); + EXPECT_DOUBLE_EQ(at_four.x2, 100.0); + EXPECT_DOUBLE_EQ(at_four.y2, 80.0); +} + +TEST(PluginClipInstance, OutputImageBoundsFollowRegionOfDefinition) +{ + OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName); + olive::VideoParams params = + MakeParams(100, 80, olive::core::PixelFormat::U8, 4, false); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + + OfxRectD custom = { 1.5, 2.5, 51.5, 21.0 }; + clip.setRegionOfDefinition(custom, 3.0); + + auto *image = + static_cast(clip.getImage(3.0, nullptr)); + ASSERT_NE(image, nullptr); + // Integer bounds are floor(min) to ceil(max). + EXPECT_EQ(image->width(), 51); + EXPECT_EQ(image->height(), 19); +} + +TEST(PluginClipInstance, OutputImageCacheIsPerTime) +{ + OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName); + olive::VideoParams params = + MakeParams(16, 16, olive::core::PixelFormat::U8, 4, false); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + + OFX::Host::ImageEffect::Image *first = clip.getImage(1.0, nullptr); + OFX::Host::ImageEffect::Image *second = clip.getImage(2.0, nullptr); + ASSERT_NE(first, nullptr); + ASSERT_NE(second, nullptr); + EXPECT_NE(first, second); +} + +TEST(PluginClipInstance, GetOutputImageUsesCache) +{ + OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName); + olive::VideoParams params = + MakeParams(16, 16, olive::core::PixelFormat::U8, 4, false); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + + OFX::Host::ImageEffect::Image *created = clip.getOutputImage(1.0); + ASSERT_NE(created, nullptr); + EXPECT_EQ(clip.getOutputImage(1.0), created); + + OFX::Host::ImageEffect::Image *fetched = clip.getImage(2.0, nullptr); + ASSERT_NE(fetched, nullptr); + EXPECT_EQ(clip.getOutputImage(2.0), fetched); +} + +TEST(PluginClipInstance, InputImageNullForInvalidParams) +{ + struct Case { + int width; + int height; + olive::core::PixelFormat format; + int channels; + }; + const Case cases[] = { + { 0, 10, olive::core::PixelFormat::U8, 4 }, + { 10, 0, olive::core::PixelFormat::U8, 4 }, + { 10, 10, olive::core::PixelFormat::INVALID, 4 }, + { 10, 10, olive::core::PixelFormat::U8, 0 }, + }; + for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); ++i) { + SCOPED_TRACE(i); + OFX::Host::ImageEffect::ClipDescriptor desc("Source"); + olive::VideoParams params = MakeParams(cases[i].width, cases[i].height, + cases[i].format, + cases[i].channels, false); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + EXPECT_EQ(clip.getImage(0.0, nullptr), nullptr); + } +} + +TEST(PluginClipInstance, ConnectedAfterImageBecomesAvailable) +{ + OFX::Host::ImageEffect::ClipDescriptor out_desc( + kOfxImageEffectOutputClipName); + olive::VideoParams out_params = + MakeParams(16, 16, olive::core::PixelFormat::U8, 4, false); + olive::plugin::OliveClipInstance out_clip(nullptr, out_desc, out_params); + EXPECT_FALSE(out_clip.getConnected()); + ASSERT_NE(out_clip.getImage(0.0, nullptr), nullptr); + EXPECT_TRUE(out_clip.getConnected()); + + OFX::Host::ImageEffect::ClipDescriptor src_desc("Source"); + olive::VideoParams src_params = + MakeParams(16, 16, olive::core::PixelFormat::U8, 4, false); + olive::plugin::OliveClipInstance src_clip(nullptr, src_desc, src_params); + EXPECT_FALSE(src_clip.getConnected()); + auto texture = std::make_shared(src_params); + src_clip.setInputTexture(texture, 1.0, true); + EXPECT_TRUE(src_clip.getConnected()); +} + +TEST(PluginClipInstance, SetInputTextureCopiesPixels) +{ + OFX::Host::ImageEffect::ClipDescriptor desc("Source"); + olive::VideoParams params = + MakeParams(4, 2, olive::core::PixelFormat::U8, 4, false); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + + olive::AVFramePtr frame = CreateFrame(params); + ASSERT_NE(frame, nullptr); + ASSERT_NE(frame->data(0), nullptr); + + const int values_per_row = params.width() * params.channel_count(); + for (int y = 0; y < params.height(); ++y) { + uint8_t *row = frame->data(0) + y * frame->linesize(0); + for (int x = 0; x < values_per_row; ++x) { + row[x] = static_cast(y * values_per_row + x + 1); + } + } + + auto texture = std::make_shared(params); + texture->handleFrame(frame); + clip.setInputTexture(texture, 1.0, true); + + auto *image = + static_cast(clip.getImage(1.0, nullptr)); + ASSERT_NE(image, nullptr); + ASSERT_NE(image->data(), nullptr); + ASSERT_EQ(image->width(), params.width()); + ASSERT_EQ(image->height(), params.height()); + for (int y = 0; y < params.height(); ++y) { + for (int x = 0; x < values_per_row; ++x) { + EXPECT_EQ(image->data()[y * image->row_bytes() + x], + static_cast(y * values_per_row + x + 1)); + } + } + image->releaseReference(); +} + +TEST(PluginClipInstance, SetInputTextureCopiesFloatPixels) +{ + OFX::Host::ImageEffect::ClipDescriptor desc("Source"); + olive::VideoParams params = + MakeParams(2, 1, olive::core::PixelFormat::F32, 4, false); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + + olive::AVFramePtr frame = CreateFrame(params); + ASSERT_NE(frame, nullptr); + ASSERT_NE(frame->data(0), nullptr); + + const float expected[] = { 0.125f, 0.25f, 0.375f, 0.5f, + 0.625f, 0.75f, 0.875f, 1.0f }; + auto *dst = reinterpret_cast(frame->data(0)); + for (size_t i = 0; i < sizeof(expected) / sizeof(expected[0]); ++i) { + dst[i] = expected[i]; + } + + auto texture = std::make_shared(params); + texture->handleFrame(frame); + clip.setInputTexture(texture, 1.0, true); + + auto *image = + static_cast(clip.getImage(1.0, nullptr)); + ASSERT_NE(image, nullptr); + ASSERT_NE(image->data(), nullptr); + const auto *pixels = reinterpret_cast(image->data()); + for (size_t i = 0; i < sizeof(expected) / sizeof(expected[0]); ++i) { + EXPECT_FLOAT_EQ(pixels[i], expected[i]); + } + image->releaseReference(); +} + +TEST(PluginClipInstance, SetInputTextureScrubsNaNToBlack) +{ + OFX::Host::ImageEffect::ClipDescriptor desc("Source"); + olive::VideoParams params = + MakeParams(2, 2, olive::core::PixelFormat::F32, 4, false); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + + olive::AVFramePtr frame = CreateFrame(params); + ASSERT_NE(frame, nullptr); + ASSERT_NE(frame->data(0), nullptr); + + const int stride = + frame->linesize(0) / static_cast(sizeof(float)); + for (int y = 0; y < params.height(); ++y) { + float *row = reinterpret_cast(frame->data(0)) + y * stride; + for (int x = 0; x < params.width() * params.channel_count(); ++x) { + row[x] = 0.5f; + } + } + reinterpret_cast(frame->data(0))[3] = + std::numeric_limits::quiet_NaN(); + + auto texture = std::make_shared(params); + texture->handleFrame(frame); + clip.setInputTexture(texture, 1.0, true); + ASSERT_TRUE(clip.getConnected()); + + // A frame containing NaN/Inf is replaced with black rather than being + // passed to the plugin. + auto *image = + static_cast(clip.getImage(1.0, nullptr)); + ASSERT_NE(image, nullptr); + ASSERT_NE(image->data(), nullptr); + const auto *pixels = reinterpret_cast(image->data()); + const int float_count = + params.width() * params.height() * params.channel_count(); + for (int i = 0; i < float_count; ++i) { + EXPECT_FLOAT_EQ(pixels[i], 0.0f); + } + image->releaseReference(); +} + +TEST(PluginClipInstance, PruneImagesCacheEvictsOldestInputImages) +{ + OFX::Host::ImageEffect::ClipDescriptor desc("Source"); + olive::VideoParams params = + MakeParams(16, 16, olive::core::PixelFormat::U8, 4, false); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + + for (int t = 1; + t <= olive::plugin::OliveClipInstance::kMaxInputImageCache + 1; ++t) { + auto texture = std::make_shared(params); + clip.setInputTexture(texture, static_cast(t), true); + } + clip.pruneImagesCache(); + + // The oldest entry (time 1) was evicted and is recreated on demand, + // while later entries remain cached. + OFX::Host::ImageEffect::Image *evicted_a = clip.getImage(1.0, nullptr); + OFX::Host::ImageEffect::Image *evicted_b = clip.getImage(1.0, nullptr); + EXPECT_NE(evicted_a, evicted_b); + + OFX::Host::ImageEffect::Image *cached_a = clip.getImage(2.0, nullptr); + OFX::Host::ImageEffect::Image *cached_b = clip.getImage(2.0, nullptr); + EXPECT_EQ(cached_a, cached_b); + + evicted_a->releaseReference(); + evicted_b->releaseReference(); + cached_a->releaseReference(); + cached_b->releaseReference(); +} + +TEST(PluginClipInstance, PruneImagesCacheKeepsOutputImages) +{ + OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName); + olive::VideoParams params = + MakeParams(16, 16, olive::core::PixelFormat::U8, 4, false); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + + for (int t = 1; + t <= olive::plugin::OliveClipInstance::kMaxInputImageCache + 1; ++t) { + clip.getImage(static_cast(t), nullptr); + } + OFX::Host::ImageEffect::Image *before = clip.getImage(1.0, nullptr); + ASSERT_NE(before, nullptr); + + // Pruning is a no-op for the output clip. + clip.pruneImagesCache(); + EXPECT_EQ(clip.getImage(1.0, nullptr), before); +} + +#ifdef OFX_SUPPORTS_OPENGLRENDER +TEST(PluginClipInstance, LoadTextureReturnsNullWithoutGpuTexture) +{ + OFX::Host::ImageEffect::ClipDescriptor src_desc("Source"); + olive::VideoParams params = + MakeParams(16, 16, olive::core::PixelFormat::U8, 4, false); + olive::plugin::OliveClipInstance src_clip(nullptr, src_desc, params); + + // No texture was supplied at all. + EXPECT_EQ(src_clip.loadTexture(1.0, nullptr, nullptr), nullptr); + + // A dummy (CPU-only) texture has no GL id, so no OFX texture is made. + auto texture = std::make_shared(params); + src_clip.setInputTexture(texture, 1.0, false); + EXPECT_EQ(src_clip.loadTexture(1.0, nullptr, nullptr), nullptr); + + OFX::Host::ImageEffect::ClipDescriptor out_desc( + kOfxImageEffectOutputClipName); + olive::plugin::OliveClipInstance out_clip(nullptr, out_desc, params); + out_clip.setOutputTexture(texture, 2.0); + EXPECT_TRUE(out_clip.getConnected()); + EXPECT_EQ(out_clip.loadTexture(2.0, nullptr, nullptr), nullptr); +} +#endif + +TEST(PluginClipInstance, SetParamsUpdatesClipAndPluginPreferences) +{ + OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName); + olive::VideoParams params = + MakeParams(16, 16, olive::core::PixelFormat::U8, 4, false); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + + olive::VideoParams updated = + MakeParams(32, 24, olive::core::PixelFormat::F32, 4, false); + updated.set_frame_rate(olive::core::rational(60, 1)); + clip.setParams(updated); + + EXPECT_DOUBLE_EQ(clip.getFrameRate(), 60.0); + EXPECT_EQ(clip.getUnmappedBitDepth(), kOfxBitDepthFloat); + + olive::VideoParams preferred = clip.getPluginPreferredParams(); + EXPECT_EQ(preferred.format(), olive::core::PixelFormat::F32); + EXPECT_EQ(preferred.channel_count(), 4); +} + +TEST(PluginClipInstance, PluginPreferredParamsDefaultsToClipParams) +{ + OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName); + olive::VideoParams params = + MakeParams(64, 32, olive::core::PixelFormat::U16, 3, false); + olive::plugin::OliveClipInstance clip(nullptr, desc, params); + + olive::VideoParams preferred = clip.getPluginPreferredParams(); + EXPECT_EQ(preferred.format(), olive::core::PixelFormat::U16); + EXPECT_EQ(preferred.channel_count(), 3); + EXPECT_EQ(preferred.width(), 64); + EXPECT_EQ(preferred.height(), 32); + + clip.setPixelDepth(kOfxBitDepthByte); + clip.setComponents(kOfxImageComponentRGBA); + preferred = clip.getPluginPreferredParams(); + EXPECT_EQ(preferred.format(), olive::core::PixelFormat::U8); + EXPECT_EQ(preferred.channel_count(), 4); + EXPECT_EQ(preferred.width(), 64); +} diff --git a/tests/gtest/render_diskcache_test.cpp b/tests/gtest/render_diskcache_test.cpp new file mode 100644 index 000000000..c59a9c698 --- /dev/null +++ b/tests/gtest/render_diskcache_test.cpp @@ -0,0 +1,652 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "codec/frame.h" +#include "core.h" +#include "node/color/colormanager/colormanager.h" +#include "node/project.h" +#include "render/diskmanager.h" +#include "render/framehashcache.h" + +namespace +{ + +bool WriteFile(const QString &path, qint64 size) +{ + QFile file(path); + if (!file.open(QFile::WriteOnly)) { + return false; + } + file.write(QByteArray(static_cast(size), 'x')); + file.close(); + return true; +} + +// Mirrors PlaybackCache::GetThisCacheDirectory + FrameHashCache::CachePathName: +// cached frames are stored as // with no extension. +QString ExpectedFrameFile(const QString &cache_root, const QUuid &uuid, + qint64 timestamp) +{ + return QDir(QDir(cache_root).filePath(uuid.toString())) + .filePath(QString::number(timestamp)); +} + +olive::FramePtr MakeSolidFrame(int width, int height, + olive::core::PixelFormat format, + int channel_count, + const olive::core::Color &color) +{ + olive::FramePtr frame = olive::Frame::Create(); + frame->set_video_params( + olive::VideoParams(width, height, format, channel_count)); + frame->allocate(); + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + frame->set_pixel(x, y, color); + } + } + return frame; +} + +} // namespace + +class RenderDiskCacheTest : public ::testing::Test { +protected: + void SetUp() override + { + if (!temp_dir_.isValid()) { + GTEST_FAIL() << "Failed to create temporary directory"; + } + + if (!olive::Core::instance()) { + // Leaked intentionally: Core is process-wide and DiskCacheFolder + // eviction calls Core::instance()->WarnCacheFull() (matches + // viewer_display_repro_test). + new olive::Core(olive::Core::CoreParams()); + } + + olive::DiskManager::CreateInstance(); + + // Point the project cache at a folder alongside the (unsaved) project + // file so every cache read/write stays inside the temporary directory. + olive::ColorManager::SetUpDefaultConfig(); + project_ = std::make_unique(); + project_->Initialize(); + project_->set_filename( + QDir(temp_dir_.path()).filePath(QStringLiteral("test.ove"))); + project_->SetCacheLocationSetting( + olive::Project::kCacheStoreAlongsideProject); + } + + void TearDown() override + { + project_.reset(); + olive::DiskManager::DestroyInstance(); + } + + QString CacheRoot() const + { + return QDir(temp_dir_.path()).filePath(QStringLiteral("cache")); + } + + QString MakeSubDir(const QString &name) const + { + QDir root(temp_dir_.path()); + if (!root.mkpath(name)) { + return QString(); + } + return root.filePath(name); + } + + QTemporaryDir temp_dir_; + std::unique_ptr project_; +}; + +TEST_F(RenderDiskCacheTest, ValidateTimestampCachesSingleFrame) +{ + olive::FrameHashCache cache(project_.get()); + cache.SetTimebase(olive::core::rational(1, 30)); + EXPECT_EQ(cache.GetTimebase(), olive::core::rational(1, 30)); + + EXPECT_FALSE(cache.IsFrameCached(olive::core::rational(15, 30))); + + cache.ValidateTimestamp(15); + + // Validated range is [15/30, 16/30): in inclusive, out exclusive + EXPECT_TRUE(cache.IsFrameCached(olive::core::rational(15, 30))); + EXPECT_TRUE(cache.IsFrameCached(olive::core::rational(31, 60))); + EXPECT_FALSE(cache.IsFrameCached(olive::core::rational(16, 30))); + EXPECT_FALSE(cache.IsFrameCached(olive::core::rational(14, 30))); +} + +TEST_F(RenderDiskCacheTest, ValidateTimeCachesOneTimebaseRange) +{ + olive::FrameHashCache cache(project_.get()); + cache.SetTimebase(olive::core::rational(1, 10)); + + // Validates [0.5, 0.6) + cache.ValidateTime(olive::core::rational(1, 2)); + + EXPECT_TRUE(cache.IsFrameCached(olive::core::rational(1, 2))); + EXPECT_TRUE(cache.IsFrameCached(olive::core::rational(59, 100))); + EXPECT_FALSE(cache.IsFrameCached(olive::core::rational(6, 10))); + EXPECT_FALSE(cache.IsFrameCached(olive::core::rational(4, 10))); +} + +TEST_F(RenderDiskCacheTest, SaveAndLoadFloatFrameRoundTrips) +{ + const QString sub = MakeSubDir(QStringLiteral("exr_f32")); + ASSERT_FALSE(sub.isEmpty()); + const QUuid uuid = QUuid::createUuid(); + + olive::FramePtr frame = + MakeSolidFrame(8, 6, olive::core::PixelFormat::F32, + olive::VideoParams::kRGBAChannelCount, + olive::core::Color(0.25f, 0.5f, 0.75f, 1.0f)); + + ASSERT_TRUE(olive::FrameHashCache::SaveCacheFrame(sub, uuid, 12345, frame)); + + const QString fn = ExpectedFrameFile(sub, uuid, 12345); + ASSERT_TRUE(QFileInfo::exists(fn)); + + olive::FramePtr loaded = + olive::FrameHashCache::LoadCacheFrame(sub, uuid, 12345); + ASSERT_NE(loaded, nullptr); + EXPECT_EQ(loaded->width(), 8); + EXPECT_EQ(loaded->height(), 6); + EXPECT_EQ(loaded->format(), olive::core::PixelFormat::F32); + EXPECT_EQ(loaded->channel_count(), int(olive::VideoParams::kRGBAChannelCount)); + + // EXR storage uses lossy DWAA compression; a solid color survives it well + const olive::core::Color px = loaded->get_pixel(4, 3); + EXPECT_NEAR(px.red(), 0.25f, 0.05); + EXPECT_NEAR(px.green(), 0.5f, 0.05); + EXPECT_NEAR(px.blue(), 0.75f, 0.05); +} + +TEST_F(RenderDiskCacheTest, SaveAndLoadHalfFloatRgbFrameRoundTrips) +{ + const QString sub = MakeSubDir(QStringLiteral("exr_f16")); + ASSERT_FALSE(sub.isEmpty()); + const QUuid uuid = QUuid::createUuid(); + + olive::FramePtr frame = + MakeSolidFrame(8, 6, olive::core::PixelFormat::F16, + olive::VideoParams::kRGBChannelCount, + olive::core::Color(0.5f, 0.5f, 0.5f, 1.0f)); + + ASSERT_TRUE(olive::FrameHashCache::SaveCacheFrame(sub, uuid, 7, frame)); + ASSERT_TRUE(QFileInfo::exists(ExpectedFrameFile(sub, uuid, 7))); + + // RGB-only frames are stored without an alpha channel + olive::FramePtr loaded = olive::FrameHashCache::LoadCacheFrame(sub, uuid, 7); + ASSERT_NE(loaded, nullptr); + EXPECT_EQ(loaded->width(), 8); + EXPECT_EQ(loaded->height(), 6); + EXPECT_EQ(loaded->format(), olive::core::PixelFormat::F16); + EXPECT_EQ(loaded->channel_count(), int(olive::VideoParams::kRGBChannelCount)); + + const olive::core::Color px = loaded->get_pixel(2, 2); + EXPECT_NEAR(px.red(), 0.5f, 0.05); +} + +TEST_F(RenderDiskCacheTest, SaveAndLoadU8FrameRoundTripsThroughJpeg) +{ + const QString sub = MakeSubDir(QStringLiteral("jpg_u8")); + ASSERT_FALSE(sub.isEmpty()); + const QUuid uuid = QUuid::createUuid(); + + olive::FramePtr frame = + MakeSolidFrame(16, 16, olive::core::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount, + olive::core::Color(0.5f, 0.5f, 0.5f, 1.0f)); + + ASSERT_TRUE(olive::FrameHashCache::SaveCacheFrame(sub, uuid, 3, frame)); + ASSERT_TRUE(QFileInfo::exists(ExpectedFrameFile(sub, uuid, 3))); + + // Integer formats fall back to JPEG; the loader hardcodes 4 channels + olive::FramePtr loaded = olive::FrameHashCache::LoadCacheFrame(sub, uuid, 3); + ASSERT_NE(loaded, nullptr); + EXPECT_EQ(loaded->width(), 16); + EXPECT_EQ(loaded->height(), 16); + EXPECT_EQ(loaded->format(), olive::core::PixelFormat::U8); + EXPECT_EQ(loaded->channel_count(), 4); + + // Gray is unaffected by channel order and survives JPEG nearly intact + const olive::core::Color px = loaded->get_pixel(8, 8); + EXPECT_NEAR(px.red(), 0.5f, 0.05); +} + +TEST_F(RenderDiskCacheTest, SaveAndLoadWithEmptyCachePathFail) +{ + olive::FramePtr frame = + MakeSolidFrame(4, 4, olive::core::PixelFormat::F32, + olive::VideoParams::kRGBAChannelCount, + olive::core::Color(1.0f, 1.0f, 1.0f, 1.0f)); + + EXPECT_FALSE(olive::FrameHashCache::SaveCacheFrame( + QString(), QUuid::createUuid(), 1, frame)); + EXPECT_EQ(olive::FrameHashCache::LoadCacheFrame( + QString(), QUuid::createUuid(), 1), + nullptr); +} + +TEST_F(RenderDiskCacheTest, LoadOfMissingFileReturnsNull) +{ + const QString sub = MakeSubDir(QStringLiteral("missing")); + ASSERT_FALSE(sub.isEmpty()); + + EXPECT_EQ(olive::FrameHashCache::LoadCacheFrame(sub, QUuid::createUuid(), + 555), + nullptr); +} + +TEST_F(RenderDiskCacheTest, LoadingCorruptFileReturnsNullAndDeletesIt) +{ + const QString sub = MakeSubDir(QStringLiteral("corrupt")); + ASSERT_FALSE(sub.isEmpty()); + const QUuid uuid = QUuid::createUuid(); + + const QString fn = ExpectedFrameFile(sub, uuid, 999); + ASSERT_TRUE(QDir().mkpath(QFileInfo(fn).absolutePath())); + ASSERT_TRUE(WriteFile(fn, 64)); // neither EXR nor JPEG + + // The corrupt frame must be registered for the disk manager to delete it + olive::DiskManager::instance()->CreatedFile(sub, fn); + + EXPECT_EQ(olive::FrameHashCache::LoadCacheFrame(sub, uuid, 999), nullptr); + EXPECT_FALSE(QFileInfo::exists(fn)); +} + +TEST_F(RenderDiskCacheTest, SavingUnsupportedPixelFormatFails) +{ + const QString sub = MakeSubDir(QStringLiteral("unsupported")); + ASSERT_FALSE(sub.isEmpty()); + + // U10 is a packed format with no EXR/QImage writer in FrameHashCache + olive::FramePtr frame = olive::Frame::Create(); + frame->set_video_params( + olive::VideoParams(8, 8, olive::core::PixelFormat::U10, + olive::VideoParams::kRGBAChannelCount)); + frame->allocate(); + + const QString fn = QDir(sub).filePath(QStringLiteral("u10_frame")); + EXPECT_FALSE(olive::FrameHashCache::SaveCacheFrame(fn, frame)); + EXPECT_FALSE(QFileInfo::exists(fn)); +} + +TEST_F(RenderDiskCacheTest, SaveCacheFrameRegistersFolderWithDiskManager) +{ + const QString sub = MakeSubDir(QStringLiteral("registered")); + ASSERT_FALSE(sub.isEmpty()); + const QUuid uuid = QUuid::createUuid(); + + olive::FramePtr frame = + MakeSolidFrame(4, 4, olive::core::PixelFormat::F32, + olive::VideoParams::kRGBAChannelCount, + olive::core::Color(0.0f, 0.0f, 0.0f, 1.0f)); + + olive::DiskManager *dm = olive::DiskManager::instance(); + const int folder_count_before = dm->GetOpenFolders().size(); + + ASSERT_TRUE(olive::FrameHashCache::SaveCacheFrame(sub, uuid, 42, frame)); + + EXPECT_EQ(dm->GetOpenFolders().size(), folder_count_before + 1); + + // Registration means the folder now tracks the file for deletion + olive::DiskCacheFolder *folder = dm->GetOpenFolder(sub); + ASSERT_NE(folder, nullptr); + EXPECT_TRUE(folder->DeleteSpecificFile(ExpectedFrameFile(sub, uuid, 42))); +} + +TEST_F(RenderDiskCacheTest, GetValidCacheFilenameRequiresValidatedFrame) +{ + olive::FrameHashCache cache(project_.get()); + cache.SetTimebase(olive::core::rational(1, 30)); + + const olive::core::rational t(15, 30); + EXPECT_TRUE(cache.GetValidCacheFilename(t).isEmpty()); + + cache.ValidateTimestamp(15); + + const QString fn = cache.GetValidCacheFilename(t); + EXPECT_EQ(fn, ExpectedFrameFile(CacheRoot(), cache.GetUuid(), 15)); +} + +TEST_F(RenderDiskCacheTest, DeletingFrameThroughDiskManagerInvalidatesRange) +{ + olive::FrameHashCache cache(project_.get()); + cache.SetTimebase(olive::core::rational(1, 30)); + + olive::FramePtr frame = + MakeSolidFrame(4, 4, olive::core::PixelFormat::F32, + olive::VideoParams::kRGBAChannelCount, + olive::core::Color(1.0f, 0.0f, 0.0f, 1.0f)); + + // Instance overloads resolve the cache dir/uuid from the parent project + ASSERT_TRUE(cache.SaveCacheFrame(15, frame)); + + const QString fn = ExpectedFrameFile(CacheRoot(), cache.GetUuid(), 15); + ASSERT_TRUE(QFileInfo::exists(fn)); + ASSERT_NE(cache.LoadCacheFrame(15), nullptr); + + const olive::core::rational t(15, 30); + cache.ValidateTimestamp(15); + ASSERT_TRUE(cache.IsFrameCached(t)); + + // Deletion must propagate through DiskManager::DeletedFrame into HashDeleted + olive::DiskManager::instance()->DeleteSpecificFile(fn); + + EXPECT_FALSE(QFileInfo::exists(fn)); + EXPECT_FALSE(cache.IsFrameCached(t)); +} + +TEST_F(RenderDiskCacheTest, DiskDeletedSignalFromForeignCacheDoesNotInvalidate) +{ + olive::FrameHashCache cache(project_.get()); + cache.SetTimebase(olive::core::rational(1, 30)); + cache.ValidateTimestamp(15); + + const olive::core::rational t(15, 30); + ASSERT_TRUE(cache.IsFrameCached(t)); + + // Different cache directory: must be ignored + emit olive::DiskManager::instance() + ->DeletedFrame(QStringLiteral("/some/other/cache"), + QStringLiteral("/some/other/cache/15")); + EXPECT_TRUE(cache.IsFrameCached(t)); + + // Same directory but a different cache UUID: must be ignored + emit olive::DiskManager::instance() + ->DeletedFrame(CacheRoot(), + ExpectedFrameFile(CacheRoot(), QUuid::createUuid(), 15)); + EXPECT_TRUE(cache.IsFrameCached(t)); +} + +TEST_F(RenderDiskCacheTest, InvalidateProjectSignalClearsValidatedRanges) +{ + olive::FrameHashCache cache(project_.get()); + cache.SetTimebase(olive::core::rational(1, 30)); + + const olive::core::rational t(15, 30); + cache.ValidateTimestamp(15); + ASSERT_TRUE(cache.IsFrameCached(t)); + + // An unrelated project must not invalidate this cache + olive::ColorManager::SetUpDefaultConfig(); + olive::Project other; + emit olive::DiskManager::instance()->InvalidateProject(&other); + EXPECT_TRUE(cache.IsFrameCached(t)); + + emit olive::DiskManager::instance()->InvalidateProject(project_.get()); + EXPECT_FALSE(cache.IsFrameCached(t)); +} + +TEST_F(RenderDiskCacheTest, ValidatedStatePersistsAcrossCaches) +{ + const QUuid uuid = QUuid::createUuid(); + + { + olive::FrameHashCache cache(project_.get()); + cache.SetUuid(uuid); + cache.SetTimebase(olive::core::rational(1, 30)); + cache.ValidateTimestamp(15); + } + + const QString state_file = + QDir(QDir(CacheRoot()).filePath(uuid.toString())) + .filePath(QStringLiteral("state")); + ASSERT_TRUE(QFileInfo::exists(state_file)); + + // SetUuid triggers LoadState, restoring timebase and validated ranges + olive::FrameHashCache restored(project_.get()); + restored.SetUuid(uuid); + + EXPECT_EQ(restored.GetTimebase(), olive::core::rational(1, 30)); + EXPECT_TRUE(restored.IsFrameCached(olive::core::rational(15, 30))); + EXPECT_FALSE(restored.IsFrameCached(olive::core::rational(16, 30))); +} + +TEST_F(RenderDiskCacheTest, PassthroughProvidesFilenameForUnvalidatedFrame) +{ + const olive::core::rational tb(1, 30); + + olive::FrameHashCache source(project_.get()); + source.SetTimebase(tb); + source.ValidateTimestamp(15); + + olive::FrameHashCache dest(project_.get()); + dest.SetPassthrough(&source); + + // SetPassthrough adopts the source cache's timebase + EXPECT_EQ(dest.GetTimebase(), tb); + + // The frame is not validated locally but the passthrough covers it + const olive::core::rational t(15, 30); + EXPECT_FALSE(dest.IsFrameCached(t)); + EXPECT_EQ(dest.GetValidCacheFilename(t), + ExpectedFrameFile(CacheRoot(), source.GetUuid(), 15)); +} + +TEST_F(RenderDiskCacheTest, ThumbnailCacheUsesFixedTimebase) +{ + olive::ThumbnailCache cache(project_.get()); + EXPECT_EQ(cache.GetTimebase(), olive::core::rational(1, 10)); +} + +TEST_F(RenderDiskCacheTest, FolderDefaultsToTwentyGbLimit) +{ + const QString sub = MakeSubDir(QStringLiteral("folder_defaults")); + ASSERT_FALSE(sub.isEmpty()); + + olive::DiskCacheFolder folder(sub); + EXPECT_EQ(folder.GetPath(), sub); + EXPECT_EQ(folder.GetLimit(), 21474836480LL); // 20 GB + EXPECT_FALSE(folder.GetClearOnClose()); +} + +TEST_F(RenderDiskCacheTest, CreatedFileCanBeDeletedSpecifically) +{ + const QString sub = MakeSubDir(QStringLiteral("delete_specific")); + ASSERT_FALSE(sub.isEmpty()); + + olive::DiskCacheFolder folder(sub); + + const QString fn = QDir(sub).filePath(QStringLiteral("frame1")); + ASSERT_TRUE(WriteFile(fn, 128)); + folder.CreatedFile(fn); + + QSignalSpy spy(&folder, &olive::DiskCacheFolder::DeletedFrame); + + EXPECT_TRUE(folder.DeleteSpecificFile(fn)); + EXPECT_FALSE(QFileInfo::exists(fn)); + + ASSERT_EQ(spy.count(), 1); + const QList args = spy.takeFirst(); + EXPECT_EQ(args.at(0).toString(), sub); + EXPECT_EQ(args.at(1).toString(), fn); + + // A second deletion attempt fails, as does deleting an unknown file + EXPECT_FALSE(folder.DeleteSpecificFile(fn)); + EXPECT_FALSE(folder.DeleteSpecificFile( + QDir(sub).filePath(QStringLiteral("never_registered")))); +} + +TEST_F(RenderDiskCacheTest, ClearCacheRemovesAllRegisteredFiles) +{ + const QString sub = MakeSubDir(QStringLiteral("clear_cache")); + ASSERT_FALSE(sub.isEmpty()); + + olive::DiskCacheFolder folder(sub); + + const QString f1 = QDir(sub).filePath(QStringLiteral("a")); + const QString f2 = QDir(sub).filePath(QStringLiteral("b")); + ASSERT_TRUE(WriteFile(f1, 32)); + ASSERT_TRUE(WriteFile(f2, 32)); + folder.CreatedFile(f1); + folder.CreatedFile(f2); + + QSignalSpy spy(&folder, &olive::DiskCacheFolder::DeletedFrame); + + EXPECT_TRUE(folder.ClearCache()); + EXPECT_FALSE(QFileInfo::exists(f1)); + EXPECT_FALSE(QFileInfo::exists(f2)); + EXPECT_EQ(spy.count(), 2); +} + +TEST_F(RenderDiskCacheTest, ClearCacheToleratesExternallyRemovedFiles) +{ + const QString sub = MakeSubDir(QStringLiteral("clear_missing")); + ASSERT_FALSE(sub.isEmpty()); + + olive::DiskCacheFolder folder(sub); + + const QString fn = QDir(sub).filePath(QStringLiteral("gone")); + ASSERT_TRUE(WriteFile(fn, 32)); + folder.CreatedFile(fn); + + ASSERT_TRUE(QFile::remove(fn)); + + // Already-missing files count as successfully cleared + EXPECT_TRUE(folder.ClearCache()); +} + +TEST_F(RenderDiskCacheTest, FolderStatePersistsAcrossInstances) +{ + const QString sub = MakeSubDir(QStringLiteral("persist")); + ASSERT_FALSE(sub.isEmpty()); + + const QString fn = QDir(sub).filePath(QStringLiteral("persisted_frame")); + ASSERT_TRUE(WriteFile(fn, 64)); + + { + olive::DiskCacheFolder folder(sub); + folder.SetLimit(12345); + folder.CreatedFile(fn); + // Destruction writes the index file into the cache folder + } + + { + olive::DiskCacheFolder reopened(sub); + EXPECT_EQ(reopened.GetLimit(), 12345); + EXPECT_FALSE(reopened.GetClearOnClose()); + + // The persisted entry is only known if the index was reloaded + EXPECT_TRUE(reopened.DeleteSpecificFile(fn)); + EXPECT_FALSE(QFileInfo::exists(fn)); + } +} + +TEST_F(RenderDiskCacheTest, ExceedingLimitEvictsLeastRecentlyUsedFile) +{ + const QString sub = MakeSubDir(QStringLiteral("eviction")); + ASSERT_FALSE(sub.isEmpty()); + + olive::DiskCacheFolder folder(sub); + folder.SetLimit(250); + EXPECT_EQ(folder.GetLimit(), 250); + + // Names are ordered so that even identical timestamps evict "aaa_evict" + const QString keep = QDir(sub).filePath(QStringLiteral("zzz_keep")); + const QString evict = QDir(sub).filePath(QStringLiteral("aaa_evict")); + const QString newest = QDir(sub).filePath(QStringLiteral("bbb_newest")); + + ASSERT_TRUE(WriteFile(keep, 100)); + folder.CreatedFile(keep); + + QThread::msleep(20); + + ASSERT_TRUE(WriteFile(evict, 100)); + folder.CreatedFile(evict); + + // Both files fit within the limit + ASSERT_TRUE(QFileInfo::exists(keep)); + ASSERT_TRUE(QFileInfo::exists(evict)); + + // Unknown filenames are ignored by Accessed + folder.Accessed(QDir(sub).filePath(QStringLiteral("unknown"))); + + QThread::msleep(20); + + // "keep" becomes the most recently used file + folder.Accessed(keep); + + QSignalSpy spy(&folder, &olive::DiskCacheFolder::DeletedFrame); + + ASSERT_TRUE(WriteFile(newest, 100)); + folder.CreatedFile(newest); // 300 > 250, one eviction required + + EXPECT_TRUE(QFileInfo::exists(keep)); + EXPECT_FALSE(QFileInfo::exists(evict)); + EXPECT_TRUE(QFileInfo::exists(newest)); + + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.first().at(1).toString(), evict); +} + +TEST_F(RenderDiskCacheTest, ClearOnCloseDeletesFilesWhenFolderCloses) +{ + const QString sub = MakeSubDir(QStringLiteral("clear_on_close")); + ASSERT_FALSE(sub.isEmpty()); + + const QString fn = QDir(sub).filePath(QStringLiteral("closing_frame")); + ASSERT_TRUE(WriteFile(fn, 32)); + + { + olive::DiskCacheFolder folder(sub); + folder.SetClearOnClose(true); + EXPECT_TRUE(folder.GetClearOnClose()); + folder.CreatedFile(fn); + } + + EXPECT_FALSE(QFileInfo::exists(fn)); +} + +TEST_F(RenderDiskCacheTest, DiskManagerOpenFolderDeduplicates) +{ + olive::DiskManager *dm = olive::DiskManager::instance(); + ASSERT_NE(dm, nullptr); + + const QString sub = MakeSubDir(QStringLiteral("dedupe")); + ASSERT_FALSE(sub.isEmpty()); + + const int folder_count_before = dm->GetOpenFolders().size(); + + olive::DiskCacheFolder *first = dm->GetOpenFolder(sub); + olive::DiskCacheFolder *second = dm->GetOpenFolder(sub); + + ASSERT_NE(first, nullptr); + EXPECT_EQ(first, second); + EXPECT_EQ(first->GetPath(), sub); + EXPECT_EQ(dm->GetOpenFolders().size(), folder_count_before + 1); + + // An empty path resolves to the default cache folder + EXPECT_EQ(dm->GetOpenFolder(QString()), dm->GetDefaultCacheFolder()); + EXPECT_FALSE(dm->GetDefaultCachePath().isEmpty()); +} + +TEST_F(RenderDiskCacheTest, DiskManagerClearDiskCacheRemovesFiles) +{ + olive::DiskManager *dm = olive::DiskManager::instance(); + ASSERT_NE(dm, nullptr); + + const QString sub = MakeSubDir(QStringLiteral("managed_clear")); + ASSERT_FALSE(sub.isEmpty()); + + const QString fn = QDir(sub).filePath(QStringLiteral("managed_frame")); + ASSERT_TRUE(WriteFile(fn, 32)); + dm->CreatedFile(sub, fn); + ASSERT_TRUE(QFileInfo::exists(fn)); + + EXPECT_TRUE(dm->ClearDiskCache(sub)); + EXPECT_FALSE(QFileInfo::exists(fn)); +} diff --git a/tests/gtest/render_projectcopier_test.cpp b/tests/gtest/render_projectcopier_test.cpp new file mode 100644 index 000000000..d46ee5d52 --- /dev/null +++ b/tests/gtest/render_projectcopier_test.cpp @@ -0,0 +1,865 @@ +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "codec/proxymanager.h" +#include "core.h" +#include "node/color/colormanager/colormanager.h" +#include "node/generator/solid/solid.h" +#include "node/group/group.h" +#include "node/math/math/math.h" +#include "node/project.h" +#include "node/project/footage/footage.h" +#include "render/audioplaybackcache.h" +#include "render/diskmanager.h" +#include "render/playbackcache.h" +#include "render/projectcopier.h" + +namespace +{ + +// Exposes the protected Validate() hook and records the virtual event +// callbacks so the invalidate/persist paths can be observed from the tests. +class TestPlaybackCache : public olive::PlaybackCache { +public: + explicit TestPlaybackCache(QObject *parent = nullptr) + : olive::PlaybackCache(parent) + { + } + + using olive::PlaybackCache::Validate; + + int invalidate_event_count = 0; + int load_state_event_count = 0; + int save_state_event_count = 0; + +protected: + virtual void InvalidateEvent(const olive::TimeRange &) override + { + invalidate_event_count++; + } + + virtual void LoadStateEvent(QDataStream &) override + { + load_state_event_count++; + } + + virtual void SaveStateEvent(QDataStream &) override + { + save_state_event_count++; + } +}; + +} // namespace + +class RenderCopierTestBase : public ::testing::Test { +protected: + void SetUp() override + { + if (!temp_dir_.isValid()) { + GTEST_FAIL() << "Failed to create temporary directory"; + } + + olive::ColorManager::SetUpDefaultConfig(); + + if (!olive::Core::instance()) { + // Leaked intentionally: Core is process-wide (matches + // render_diskcache_test) + new olive::Core(olive::Core::CoreParams()); + } + + olive::DiskManager::CreateInstance(); + + // Point the project cache at a folder alongside the (unsaved) project + // file so every cache read/write stays inside the temporary directory. + project_ = std::make_unique(); + project_->Initialize(); + project_->set_filename( + QDir(temp_dir_.path()).filePath(QStringLiteral("test.ove"))); + project_->SetCacheLocationSetting( + olive::Project::kCacheStoreAlongsideProject); + } + + void TearDown() override + { + copier_.reset(); + project_.reset(); + olive::DiskManager::DestroyInstance(); + } + + template T *AddNode() + { + T *node = new T(); + node->setParent(project_.get()); + return node; + } + + QString CacheRoot() const + { + return QDir(temp_dir_.path()).filePath(QStringLiteral("cache")); + } + + QTemporaryDir temp_dir_; + std::unique_ptr project_; + std::unique_ptr copier_; +}; + +class RenderProjectCopierTest : public RenderCopierTestBase { +}; + +class RenderPlaybackCacheTest : public RenderCopierTestBase { +}; + +TEST_F(RenderProjectCopierTest, CopyIsSeparateProjectWithSameStructure) +{ + auto *math_a = AddNode(); + auto *math_b = AddNode(); + AddNode(); + + olive::Node::ConnectEdge( + math_a, olive::NodeInput(math_b, olive::MathNode::kParamAIn)); + + project_->SetSetting(QStringLiteral("copier_test_key"), + QStringLiteral("copier_test_value")); + + copier_ = std::make_unique(); + copier_->SetProject(project_.get()); + + olive::Project *copy = copier_->GetCopiedProject(); + ASSERT_NE(copy, nullptr); + EXPECT_NE(copy, project_.get()); + + // The copy is marked as an in-memory render proxy + EXPECT_TRUE(copy->property("_oak_render_proxy").toBool()); + + // Same number of nodes, same IDs, different objects + ASSERT_EQ(copy->nodes().size(), project_->nodes().size()); + EXPECT_EQ(copier_->GetNodeMap().size(), project_->nodes().size()); + for (olive::Node *original : project_->nodes()) { + olive::Node *cloned = copier_->GetCopy(original); + ASSERT_NE(cloned, nullptr); + EXPECT_NE(cloned, original); + EXPECT_EQ(cloned->id(), original->id()); + EXPECT_EQ(copier_->GetOriginal(cloned), original); + EXPECT_TRUE(copy->nodes().contains(cloned)); + } + + // Settings were copied + EXPECT_EQ(copy->GetSetting(QStringLiteral("copier_test_key")), + QStringLiteral("copier_test_value")); + + // The pre-existing edge was recreated between the copies + olive::Node *copy_a = copier_->GetCopy(math_a); + olive::Node *copy_b = copier_->GetCopy(math_b); + ASSERT_EQ(copy_b->input_connections().size(), 1); + EXPECT_EQ(copy_b->input_connections().at( + olive::NodeInput(copy_b, olive::MathNode::kParamAIn)), + copy_a); + + // SetProject applies the initial sync synchronously + EXPECT_FALSE(copier_->HasUpdatesInQueue()); +} + +TEST_F(RenderProjectCopierTest, CopiedNodesHaveDisabledCachesAndSharedUuids) +{ + auto *math = AddNode(); + + copier_ = std::make_unique(); + copier_->SetProject(project_.get()); + + olive::Node *copy = copier_->GetCopy(math); + ASSERT_NE(copy, nullptr); + + // Caches are disabled on the render-proxy copy but keep the same UUIDs so + // the copy can read frames written by the original + EXPECT_TRUE(math->AreCachesEnabled()); + EXPECT_FALSE(copy->AreCachesEnabled()); + EXPECT_EQ(copy->video_frame_cache()->GetUuid(), + math->video_frame_cache()->GetUuid()); + EXPECT_EQ(copy->audio_playback_cache()->GetUuid(), + math->audio_playback_cache()->GetUuid()); +} + +TEST_F(RenderProjectCopierTest, AddedNodeSignalFiresForEachCopiedNode) +{ + AddNode(); + AddNode(); + + copier_ = std::make_unique(); + + QVector added; + QObject::connect(copier_.get(), &olive::ProjectCopier::AddedNode, + [&added](olive::Node *n) { added.append(n); }); + + copier_->SetProject(project_.get()); + + // One signal per node in the original project (color manager, root folder, + // and the two nodes added above), carrying the *original* pointers + EXPECT_EQ(added.size(), project_->nodes().size()); + for (olive::Node *n : project_->nodes()) { + EXPECT_TRUE(added.contains(n)); + } +} + +TEST_F(RenderProjectCopierTest, QueuedNodeAddIsAppliedByProcessUpdateQueue) +{ + copier_ = std::make_unique(); + copier_->SetProject(project_.get()); + + QVector added; + QObject::connect(copier_.get(), &olive::ProjectCopier::AddedNode, + [&added](olive::Node *n) { added.append(n); }); + + auto *math = AddNode(); + + // The change is queued, not applied immediately + EXPECT_TRUE(copier_->HasUpdatesInQueue()); + EXPECT_EQ(copier_->GetCopy(math), nullptr); + EXPECT_TRUE(added.isEmpty()); + + copier_->ProcessUpdateQueue(); + + EXPECT_FALSE(copier_->HasUpdatesInQueue()); + olive::Node *copy = copier_->GetCopy(math); + ASSERT_NE(copy, nullptr); + EXPECT_EQ(copy->id(), math->id()); + EXPECT_TRUE(copier_->GetCopiedProject()->nodes().contains(copy)); + ASSERT_EQ(added.size(), 1); + EXPECT_EQ(added.first(), math); + + // Processing the queue marked the copy as modified + EXPECT_TRUE(copier_->GetCopiedProject()->is_modified()); +} + +TEST_F(RenderProjectCopierTest, QueuedNodeRemoveDeletesCopy) +{ + auto *math = AddNode(); + + copier_ = std::make_unique(); + copier_->SetProject(project_.get()); + + olive::Node *copy = copier_->GetCopy(math); + ASSERT_NE(copy, nullptr); + + QVector removed; + QObject::connect(copier_.get(), &olive::ProjectCopier::RemovedNode, + [&removed](olive::Node *n) { removed.append(n); }); + + delete math; + EXPECT_TRUE(copier_->HasUpdatesInQueue()); + + copier_->ProcessUpdateQueue(); + + EXPECT_FALSE(copier_->HasUpdatesInQueue()); + ASSERT_EQ(removed.size(), 1); + EXPECT_EQ(removed.first(), math); + EXPECT_EQ(copier_->GetCopy(math), nullptr); + EXPECT_FALSE(copier_->GetCopiedProject()->nodes().contains(copy)); +} + +TEST_F(RenderProjectCopierTest, QueuedEdgeAddAndRemoveAreMirrored) +{ + auto *src = AddNode(); + auto *dst = AddNode(); + + copier_ = std::make_unique(); + copier_->SetProject(project_.get()); + + olive::Node *copy_src = copier_->GetCopy(src); + olive::Node *copy_dst = copier_->GetCopy(dst); + ASSERT_NE(copy_src, nullptr); + ASSERT_NE(copy_dst, nullptr); + + olive::Node::ConnectEdge( + src, olive::NodeInput(dst, olive::MathNode::kParamAIn)); + EXPECT_TRUE(copier_->HasUpdatesInQueue()); + EXPECT_TRUE(copy_dst->input_connections().empty()); + + copier_->ProcessUpdateQueue(); + + ASSERT_EQ(copy_dst->input_connections().size(), 1); + EXPECT_EQ(copy_dst->input_connections().at( + olive::NodeInput(copy_dst, olive::MathNode::kParamAIn)), + copy_src); + + olive::Node::DisconnectEdge( + src, olive::NodeInput(dst, olive::MathNode::kParamAIn)); + copier_->ProcessUpdateQueue(); + + EXPECT_TRUE(copy_dst->input_connections().empty()); + EXPECT_TRUE(copy_src->output_connections().empty()); +} + +TEST_F(RenderProjectCopierTest, QueuedValueChangeKeepsCopyIndependentUntilProcessed) +{ + auto *math = AddNode(); + + copier_ = std::make_unique(); + copier_->SetProject(project_.get()); + + olive::Node *copy = copier_->GetCopy(math); + ASSERT_NE(copy, nullptr); + + const double before = + math->GetStandardValue(olive::MathNode::kParamAIn).toDouble(); + const double changed = before + 2.5; + math->SetStandardValue(olive::MathNode::kParamAIn, changed); + + EXPECT_TRUE(copier_->HasUpdatesInQueue()); + // The copy must not change until the queue is processed + EXPECT_DOUBLE_EQ( + copy->GetStandardValue(olive::MathNode::kParamAIn).toDouble(), before); + + copier_->ProcessUpdateQueue(); + + EXPECT_DOUBLE_EQ( + copy->GetStandardValue(olive::MathNode::kParamAIn).toDouble(), changed); + EXPECT_DOUBLE_EQ( + math->GetStandardValue(olive::MathNode::kParamAIn).toDouble(), changed); +} + +TEST_F(RenderProjectCopierTest, QueuedValueHintChangeIsMirrored) +{ + auto *math = AddNode(); + + copier_ = std::make_unique(); + copier_->SetProject(project_.get()); + + olive::Node *copy = copier_->GetCopy(math); + ASSERT_NE(copy, nullptr); + + const olive::Node::ValueHint hint({ olive::NodeValue::kFloat }, 7, + QStringLiteral("copier_hint")); + math->SetValueHintForInput(olive::MathNode::kParamAIn, hint); + EXPECT_TRUE(copier_->HasUpdatesInQueue()); + + copier_->ProcessUpdateQueue(); + + const olive::Node::ValueHint copied_hint = + copy->GetValueHintForInput(olive::MathNode::kParamAIn); + EXPECT_EQ(copied_hint.tag(), QStringLiteral("copier_hint")); + EXPECT_EQ(copied_hint.index(), 7); + ASSERT_EQ(copied_hint.types().size(), 1); + EXPECT_EQ(copied_hint.types().first(), olive::NodeValue::kFloat); +} + +TEST_F(RenderProjectCopierTest, QueuedProjectSettingChangeIsMirrored) +{ + copier_ = std::make_unique(); + copier_->SetProject(project_.get()); + + project_->SetSetting(QStringLiteral("copier_late_key"), + QStringLiteral("copier_late_value")); + EXPECT_TRUE(copier_->HasUpdatesInQueue()); + EXPECT_TRUE(copier_->GetCopiedProject() + ->GetSetting(QStringLiteral("copier_late_key")) + .isEmpty()); + + copier_->ProcessUpdateQueue(); + + EXPECT_EQ(copier_->GetCopiedProject()->GetSetting( + QStringLiteral("copier_late_key")), + QStringLiteral("copier_late_value")); +} + +TEST_F(RenderProjectCopierTest, GroupNodesAreNotCopied) +{ + copier_ = std::make_unique(); + copier_->SetProject(project_.get()); + + const int copy_count_before = + copier_->GetCopiedProject()->nodes().size(); + + auto *group = AddNode(); + copier_->ProcessUpdateQueue(); + + // Group nodes are dummies for rendering and must not appear in the copy + EXPECT_EQ(copier_->GetCopy(group), nullptr); + EXPECT_EQ(copier_->GetCopiedProject()->nodes().size(), copy_count_before); + for (olive::Node *n : copier_->GetCopiedProject()->nodes()) { + EXPECT_NE(n->id(), group->id()); + } +} + +TEST_F(RenderProjectCopierTest, GraphChangeTimeAdvancesAndSyncCatchesUp) +{ + copier_ = std::make_unique(); + copier_->SetProject(project_.get()); + + // SetProject leaves the sync point at or after the graph change point + EXPECT_GE(copier_->GetLastUpdateTime().value(), + copier_->GetGraphChangeTime().value()); + + AddNode(); + + // A queued change moves the graph change point ahead of the sync point + EXPECT_GT(copier_->GetGraphChangeTime().value(), + copier_->GetLastUpdateTime().value()); + + copier_->ProcessUpdateQueue(); + + EXPECT_GE(copier_->GetLastUpdateTime().value(), + copier_->GetGraphChangeTime().value()); +} + +TEST_F(RenderProjectCopierTest, FootageProxySettingsSyncToCopyImmediately) +{ + auto *footage = AddNode(); + + copier_ = std::make_unique(); + copier_->SetProject(project_.get()); + + olive::Footage *copy = copier_->GetCopy(footage); + ASSERT_NE(copy, nullptr); + EXPECT_FALSE(copy->proxy_enabled()); + + // Proxy settings are not Node inputs, so the copier mirrors them through a + // direct connection without involving the update queue + footage->SetProxy(QStringLiteral("/cache/proxy/example.mp4"), + olive::ProxyManager::kProxyReady, 2, 3, true); + + EXPECT_FALSE(copier_->HasUpdatesInQueue()); + EXPECT_TRUE(copy->proxy_enabled()); + EXPECT_EQ(copy->proxy_path(), QStringLiteral("/cache/proxy/example.mp4")); + EXPECT_EQ(copy->proxy_state(), olive::ProxyManager::kProxyReady); + EXPECT_EQ(copy->proxy_video_stream_index(), 2); + EXPECT_EQ(copy->proxy_preset_version(), 3); + + footage->SetProxy(QString(), olive::ProxyManager::kProxyMissing, -1, 0, + false); + EXPECT_FALSE(copy->proxy_enabled()); + EXPECT_EQ(copy->proxy_state(), olive::ProxyManager::kProxyMissing); +} + +TEST_F(RenderProjectCopierTest, SetProjectTwiceReplacesCopyContents) +{ + auto *first = AddNode(); + + copier_ = std::make_unique(); + copier_->SetProject(project_.get()); + ASSERT_NE(copier_->GetCopy(first), nullptr); + + olive::Project second_project; + second_project.Initialize(); + auto *second = new olive::SolidGenerator(); + second->setParent(&second_project); + + copier_->SetProject(&second_project); + + // Nodes from the first project are gone, nodes from the second are present + EXPECT_EQ(copier_->GetCopy(first), nullptr); + EXPECT_NE(copier_->GetCopy(second), nullptr); + EXPECT_EQ(copier_->GetCopiedProject()->nodes().size(), + second_project.nodes().size()); + EXPECT_FALSE(copier_->HasUpdatesInQueue()); +} + +TEST_F(RenderProjectCopierTest, SetProjectNullStopsTracking) +{ + copier_ = std::make_unique(); + copier_->SetProject(project_.get()); + + AddNode(); + EXPECT_TRUE(copier_->HasUpdatesInQueue()); + + copier_->SetProject(nullptr); + + // Pending changes are discarded and the original is no longer tracked + EXPECT_FALSE(copier_->HasUpdatesInQueue()); + EXPECT_NE(copier_->GetCopiedProject(), nullptr); + + AddNode(); + EXPECT_FALSE(copier_->HasUpdatesInQueue()); +} + +TEST_F(RenderPlaybackCacheTest, UuidAndSavingFlagAccessors) +{ + auto *node = AddNode(); + TestPlaybackCache cache(node); + + EXPECT_FALSE(cache.GetUuid().isNull()); + EXPECT_EQ(cache.parent(), node); + EXPECT_TRUE(cache.IsSavingEnabled()); + EXPECT_NE(cache.mutex(), nullptr); + + TestPlaybackCache other(node); + EXPECT_NE(other.GetUuid(), cache.GetUuid()); + + const QUuid uuid = QUuid::createUuid(); + cache.SetUuid(uuid); + EXPECT_EQ(cache.GetUuid(), uuid); + + cache.SetSavingEnabled(false); + EXPECT_FALSE(cache.IsSavingEnabled()); +} + +TEST_F(RenderPlaybackCacheTest, ValidateAndInvalidateBookkeeping) +{ + auto *node = AddNode(); + TestPlaybackCache cache(node); + + QVector validated_signals; + QVector invalidated_signals; + QObject::connect(&cache, &olive::PlaybackCache::Validated, + [&validated_signals](const olive::TimeRange &r) { + validated_signals.append(r); + }); + QObject::connect(&cache, &olive::PlaybackCache::Invalidated, + [&invalidated_signals](const olive::TimeRange &r) { + invalidated_signals.append(r); + }); + + const olive::TimeRange whole(olive::rational(0), olive::rational(10)); + EXPECT_TRUE(cache.HasInvalidatedRanges(whole)); + EXPECT_FALSE(cache.HasValidatedRanges()); + + cache.Validate(whole); + + EXPECT_TRUE(cache.HasValidatedRanges()); + EXPECT_TRUE(cache.GetValidatedRanges().contains(whole)); + EXPECT_FALSE(cache.HasInvalidatedRanges(whole)); + EXPECT_TRUE(cache.GetInvalidatedRanges(olive::rational(10)).isEmpty()); + EXPECT_EQ(cache.invalidate_event_count, 0); + ASSERT_EQ(validated_signals.size(), 1); + EXPECT_EQ(validated_signals.first(), whole); + + // A larger query range still reports the remainder as invalidated + EXPECT_TRUE(cache.HasInvalidatedRanges( + olive::TimeRange(olive::rational(0), olive::rational(11)))); + + const olive::TimeRange hole(olive::rational(2), olive::rational(5)); + cache.Invalidate(hole); + + EXPECT_EQ(cache.invalidate_event_count, 1); + ASSERT_EQ(invalidated_signals.size(), 1); + EXPECT_EQ(invalidated_signals.first(), hole); + EXPECT_TRUE(cache.HasInvalidatedRanges(whole)); + + const olive::TimeRangeList invalidated = + cache.GetInvalidatedRanges(olive::rational(10)); + ASSERT_EQ(invalidated.size(), 1); + EXPECT_EQ(invalidated.first(), hole); +} + +TEST_F(RenderPlaybackCacheTest, InvalidateZeroLengthRangeIsIgnored) +{ + auto *node = AddNode(); + TestPlaybackCache cache(node); + + const olive::TimeRange whole(olive::rational(0), olive::rational(10)); + cache.Validate(whole); + + int invalidated_count = 0; + QObject::connect(&cache, &olive::PlaybackCache::Invalidated, + [&invalidated_count](const olive::TimeRange &) { + invalidated_count++; + }); + + // Zero-length invalidations are rejected with a warning + cache.Invalidate(olive::TimeRange(olive::rational(5), olive::rational(5))); + + EXPECT_EQ(invalidated_count, 0); + EXPECT_EQ(cache.invalidate_event_count, 0); + EXPECT_TRUE(cache.GetValidatedRanges().contains(whole)); +} + +TEST_F(RenderPlaybackCacheTest, GetInvalidatedRangesClampsNegativeTimes) +{ + auto *node = AddNode(); + TestPlaybackCache cache(node); + + // Nothing is validated, so the whole (clamped) range is invalidated + const olive::TimeRangeList invalidated = cache.GetInvalidatedRanges( + olive::TimeRange(olive::rational(-5), olive::rational(10))); + + ASSERT_EQ(invalidated.size(), 1); + EXPECT_EQ(invalidated.first().in(), olive::rational(0)); + EXPECT_EQ(invalidated.first().out(), olive::rational(10)); +} + +TEST_F(RenderPlaybackCacheTest, PassthroughCoversInvalidatedRanges) +{ + auto *node = AddNode(); + TestPlaybackCache source(node); + TestPlaybackCache dest(node); + + const olive::TimeRange whole(olive::rational(0), olive::rational(10)); + source.Validate(whole); + + dest.SetPassthrough(&source); + + ASSERT_EQ(dest.GetPassthroughs().size(), 1); + EXPECT_EQ(dest.GetPassthroughs().front().cache, source.GetUuid()); + EXPECT_EQ(dest.GetPassthroughs().front().in(), whole.in()); + EXPECT_EQ(dest.GetPassthroughs().front().out(), whole.out()); + + // GetInvalidatedRanges honors passthroughs ... + EXPECT_TRUE(dest.GetInvalidatedRanges(olive::rational(10)).isEmpty()); + // ... but HasInvalidatedRanges only looks at locally validated ranges + EXPECT_TRUE(dest.HasInvalidatedRanges(whole)); + + // Invalidating trims the passthrough too + const olive::TimeRange hole(olive::rational(2), olive::rational(3)); + dest.Invalidate(hole); + const olive::TimeRangeList invalidated = + dest.GetInvalidatedRanges(olive::rational(10)); + ASSERT_EQ(invalidated.size(), 1); + EXPECT_EQ(invalidated.first(), hole); +} + +TEST_F(RenderPlaybackCacheTest, PassthroughChainsAcrossCaches) +{ + auto *node = AddNode(); + TestPlaybackCache a(node); + TestPlaybackCache b(node); + TestPlaybackCache c(node); + + a.Validate(olive::TimeRange(olive::rational(0), olive::rational(10))); + b.SetPassthrough(&a); + c.SetPassthrough(&b); + + // c inherits b's passthrough of a + ASSERT_EQ(c.GetPassthroughs().size(), 1); + EXPECT_EQ(c.GetPassthroughs().front().cache, a.GetUuid()); + EXPECT_TRUE(c.GetInvalidatedRanges(olive::rational(10)).isEmpty()); +} + +TEST_F(RenderPlaybackCacheTest, RequestResignalAndClear) +{ + auto *node = AddNode(); + TestPlaybackCache cache(node); + + int requested_count = 0; + olive::TimeRange last_requested; + QObject::connect(&cache, &olive::PlaybackCache::Requested, + [&requested_count, &last_requested]( + olive::ViewerOutput *context, + const olive::TimeRange &r) { + EXPECT_EQ(context, nullptr); + requested_count++; + last_requested = r; + }); + + const olive::TimeRange first(olive::rational(0), olive::rational(5)); + const olive::TimeRange second(olive::rational(10), olive::rational(20)); + + cache.Request(nullptr, first); + EXPECT_EQ(requested_count, 1); + EXPECT_EQ(last_requested, first); + + cache.Request(nullptr, second); + EXPECT_EQ(requested_count, 2); + + // Both pending ranges are re-signaled + cache.ResignalRequests(); + EXPECT_EQ(requested_count, 4); + + // Clearing one range leaves the other pending + cache.ClearRequestRange(first); + cache.ResignalRequests(); + EXPECT_EQ(requested_count, 5); + EXPECT_EQ(last_requested, second); +} + +TEST_F(RenderPlaybackCacheTest, InvalidateAllClearsEverything) +{ + auto *node = AddNode(); + TestPlaybackCache cache(node); + TestPlaybackCache source(node); + + source.Validate(olive::TimeRange(olive::rational(0), olive::rational(10))); + cache.Validate(olive::TimeRange(olive::rational(0), olive::rational(10))); + cache.SetPassthrough(&source); + + QVector invalidated_signals; + QObject::connect(&cache, &olive::PlaybackCache::Invalidated, + [&invalidated_signals](const olive::TimeRange &r) { + invalidated_signals.append(r); + }); + + cache.InvalidateAll(); + + EXPECT_FALSE(cache.HasValidatedRanges()); + EXPECT_TRUE(cache.GetPassthroughs().empty()); + ASSERT_EQ(invalidated_signals.size(), 1); + EXPECT_EQ(invalidated_signals.first(), + olive::TimeRange(olive::rational(0), RATIONAL_MAX)); +} + +TEST_F(RenderPlaybackCacheTest, StatePersistsAcrossCaches) +{ + auto *node = AddNode(); + const QUuid uuid = QUuid::createUuid(); + const QUuid source_uuid = QUuid::createUuid(); + + const olive::TimeRange valid(olive::rational(5), olive::rational(15)); + const olive::TimeRange pass(olive::rational(20), olive::rational(30)); + + { + TestPlaybackCache cache(node); + cache.SetUuid(uuid); + cache.Validate(valid); + + TestPlaybackCache source(node); + source.SetUuid(source_uuid); + source.Validate(pass); + cache.SetPassthrough(&source); + + EXPECT_GE(cache.save_state_event_count, 1); + } + + const QString state_file = + QDir(QDir(CacheRoot()).filePath(uuid.toString())) + .filePath(QStringLiteral("state")); + ASSERT_TRUE(QFileInfo::exists(state_file)); + + TestPlaybackCache restored(node); + restored.SetUuid(uuid); + + EXPECT_EQ(restored.load_state_event_count, 1); + EXPECT_TRUE(restored.GetValidatedRanges().contains(valid)); + ASSERT_EQ(restored.GetPassthroughs().size(), 1); + EXPECT_EQ(restored.GetPassthroughs().front().cache, source_uuid); + EXPECT_EQ(restored.GetPassthroughs().front().in(), pass.in()); + EXPECT_EQ(restored.GetPassthroughs().front().out(), pass.out()); +} + +TEST_F(RenderPlaybackCacheTest, CacheDirectoryHelpers) +{ + auto *node = AddNode(); + TestPlaybackCache cache(node); + + const QUuid uuid = QUuid::createUuid(); + cache.SetUuid(uuid); + + EXPECT_EQ(olive::PlaybackCache::GetThisCacheDirectory( + QStringLiteral("/base/path"), uuid), + QDir(QDir(QStringLiteral("/base/path")).filePath( + uuid.toString()))); + + EXPECT_EQ(cache.GetThisCacheDirectory(), + QDir(QDir(CacheRoot()).filePath(uuid.toString()))); + + EXPECT_GT(olive::PlaybackCache::GetCacheIndicatorHeight(), 0); +} + +TEST_F(RenderPlaybackCacheTest, DrawPaintsValidatedRangesGreen) +{ + auto *node = AddNode(); + TestPlaybackCache cache(node); + cache.Validate(olive::TimeRange(olive::rational(2), olive::rational(5))); + + QImage image(100, 10, QImage::Format_RGB32); + image.fill(Qt::black); + { + QPainter painter(&image); + cache.Draw(&painter, olive::rational(0), 10.0, QRect(0, 0, 100, 10)); + } + + // 10 px/s: validated seconds [2,5) cover pixels [20,50) + EXPECT_EQ(image.pixelColor(30, 5), QColor(Qt::green)); + EXPECT_EQ(image.pixelColor(10, 5), QColor(Qt::red)); + EXPECT_EQ(image.pixelColor(60, 5), QColor(Qt::red)); +} + +TEST_F(RenderPlaybackCacheTest, AudioParametersRoundTrip) +{ + auto *node = AddNode(); + olive::AudioPlaybackCache cache(node); + + const olive::AudioParams params(48000, olive::kChannelLayoutStereo, + olive::SampleFormat::F32P); + cache.SetParameters(params); + EXPECT_TRUE(cache.GetParameters() == params); + EXPECT_EQ(cache.GetParameters().sample_rate(), 48000); + EXPECT_EQ(cache.GetParameters().channel_count(), 2); + + // Setting identical parameters again takes the no-op path + cache.SetParameters(params); + EXPECT_TRUE(cache.GetParameters() == params); + + const olive::AudioParams other(44100, olive::kChannelLayoutMono, + olive::SampleFormat::F32P); + cache.SetParameters(other); + EXPECT_EQ(cache.GetParameters().sample_rate(), 44100); + EXPECT_EQ(cache.GetParameters().channel_count(), 1); +} + +// NOTE: AudioPlaybackCache::WriteSilence() is intentionally not exercised: it +// calls WritePCM() with an empty SampleBuffer, which makes +// WritePartOfSampleBuffer() loop forever (the write cursor never advances when +// the buffer provides no bytes). See the bug notes in the test report. +TEST_F(RenderPlaybackCacheTest, WritePcmValidatesRangesAndWritesSegments) +{ + auto *node = AddNode(); + olive::AudioPlaybackCache cache(node); + + const olive::AudioParams params(48000, olive::kChannelLayoutStereo, + olive::SampleFormat::F32P); + cache.SetParameters(params); + + // Two adjacent 0.1s ranges at 48kHz stereo float + const olive::TimeRange r1(olive::rational(0), olive::rational(1, 10)); + const olive::TimeRange r2(olive::rational(1, 10), olive::rational(1, 5)); + const olive::TimeRange whole(olive::rational(0), olive::rational(1, 5)); + + const qint64 total_bytes = params.time_to_bytes_per_channel(whole.length()); + const qint64 range_bytes = params.time_to_bytes_per_channel(r1.length()); + ASSERT_GT(total_bytes, 0); + ASSERT_GT(range_bytes, 0); + + olive::SampleBuffer samples(params, + size_t(params.time_to_samples(whole.length()))); + ASSERT_TRUE(samples.is_allocated()); + for (int ch = 0; ch < params.channel_count(); ch++) { + for (int64_t i = 0; i < params.time_to_samples(whole.length()); i++) { + samples.data(ch)[i] = 0.0001f * float(i) + float(ch); + } + } + + int validated_count = 0; + QObject::connect(&cache, &olive::PlaybackCache::Validated, + [&validated_count](const olive::TimeRange &) { + validated_count++; + }); + + cache.WritePCM(whole, { r1, r2 }, samples); + + // Adjacent ranges merge into one validated block + EXPECT_EQ(validated_count, 2); + EXPECT_TRUE(cache.GetValidatedRanges().contains(r1)); + EXPECT_TRUE(cache.GetValidatedRanges().contains(r2)); + EXPECT_FALSE(cache.HasInvalidatedRanges(whole)); + + // One segment file per channel in this cache's directory + const QDir cache_dir = cache.GetThisCacheDirectory(); + const QString seg_ch0 = cache_dir.filePath(QStringLiteral("0.0")); + const QString seg_ch1 = cache_dir.filePath(QStringLiteral("0.1")); + ASSERT_TRUE(QFileInfo::exists(seg_ch0)); + ASSERT_TRUE(QFileInfo::exists(seg_ch1)); + + // The segment starts with exactly the PCM data that was written + QFile f0(seg_ch0); + ASSERT_TRUE(f0.open(QFile::ReadOnly)); + const QByteArray raw0 = f0.read(total_bytes); + ASSERT_EQ(raw0.size(), total_bytes); + EXPECT_EQ(std::memcmp(raw0.constData(), samples.data(0), size_t(total_bytes)), + 0); + + QFile f1(seg_ch1); + ASSERT_TRUE(f1.open(QFile::ReadOnly)); + const QByteArray raw1 = f1.read(total_bytes); + ASSERT_EQ(raw1.size(), total_bytes); + EXPECT_EQ(std::memcmp(raw1.constData(), samples.data(1), size_t(total_bytes)), + 0); +} diff --git a/tests/gtest/track_test.cpp b/tests/gtest/track_test.cpp new file mode 100644 index 000000000..6a957065c --- /dev/null +++ b/tests/gtest/track_test.cpp @@ -0,0 +1,1167 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include + +#include "common/qtutils.h" +#include "node/block/clip/clip.h" +#include "node/block/gap/gap.h" +#include "node/globals.h" +#include "node/output/track/track.h" +#include "node/color/colormanager/colormanager.h" +#include "node/project.h" +#include "olive/core/render/samplebuffer.h" +#include "render/loopmode.h" + +namespace +{ + +olive::Track *CreateTrack(olive::Project *project, + olive::Track::Type type = olive::Track::kVideo) +{ + auto *track = new olive::Track(); + track->setParent(project); + track->set_type(type); + return track; +} + +olive::ClipBlock *CreateClip(olive::Project *project, + const olive::core::rational &length) +{ + auto *clip = new olive::ClipBlock(); + clip->setParent(project); + clip->set_length_and_media_out(length); + return clip; +} + +olive::GapBlock *CreateGap(olive::Project *project, + const olive::core::rational &length) +{ + auto *gap = new olive::GapBlock(); + gap->setParent(project); + gap->set_length_and_media_out(length); + return gap; +} + +float SumOfAbsoluteSamples(const olive::core::SampleBuffer &buffer) +{ + float sum = 0.0f; + for (int ch = 0; ch < buffer.channel_count(); ++ch) { + const float *data = buffer.data(ch); + for (size_t i = 0; i < buffer.sample_count(); ++i) { + sum += std::abs(data[i]); + } + } + return sum; +} + +} // namespace + +TEST(Track, DefaultState) +{ + olive::Track track; + + EXPECT_EQ(track.type(), olive::Track::kNone); + EXPECT_EQ(track.Index(), -1); + EXPECT_TRUE(track.Blocks().isEmpty()); + EXPECT_EQ(track.track_length(), olive::core::rational(0)); + EXPECT_FALSE(track.IsMuted()); + EXPECT_FALSE(track.IsLocked()); + EXPECT_DOUBLE_EQ(track.GetTrackHeight(), olive::Track::kTrackHeightDefault); + EXPECT_EQ(track.sequence(), nullptr); + EXPECT_EQ(track.Name(), QStringLiteral("Track")); + EXPECT_EQ(track.id(), QStringLiteral("org.olivevideoeditor.Olive.track")); + EXPECT_TRUE(track.Category().contains(olive::Node::kCategoryTimeline)); +} + +TEST(Track, NameReflectsTypeAndIndex) +{ + olive::Track track; + + track.set_type(olive::Track::kVideo); + track.SetIndex(2); + EXPECT_EQ(track.Name(), QStringLiteral("Video Track 2")); + + track.set_type(olive::Track::kAudio); + track.SetIndex(0); + EXPECT_EQ(track.Name(), QStringLiteral("Audio Track 0")); + + track.set_type(olive::Track::kSubtitle); + track.SetIndex(1); + EXPECT_EQ(track.Name(), QStringLiteral("Subtitle Track 1")); + + track.set_type(olive::Track::kNone); + EXPECT_EQ(track.Name(), QStringLiteral("Track")); +} + +TEST(Track, SetIndexEmitsIndexChanged) +{ + olive::Track track; + + int emissions = 0; + int old_index = 0; + int new_index = 0; + QObject::connect(&track, &olive::Track::IndexChanged, + [&emissions, &old_index, &new_index](int old_i, int now_i) { + ++emissions; + old_index = old_i; + new_index = now_i; + }); + + track.SetIndex(3); + + EXPECT_EQ(track.Index(), 3); + EXPECT_EQ(emissions, 1); + EXPECT_EQ(old_index, -1); + EXPECT_EQ(new_index, 3); +} + +TEST(Track, MuteAndLockToggle) +{ + olive::Track track; + + int emissions = 0; + bool last_muted = false; + QObject::connect(&track, &olive::Track::MutedChanged, + [&emissions, &last_muted](bool e) { + ++emissions; + last_muted = e; + }); + + track.SetMuted(true); + EXPECT_TRUE(track.IsMuted()); + EXPECT_EQ(emissions, 1); + EXPECT_TRUE(last_muted); + + track.SetMuted(false); + EXPECT_FALSE(track.IsMuted()); + EXPECT_EQ(emissions, 2); + EXPECT_FALSE(last_muted); + + EXPECT_FALSE(track.IsLocked()); + track.SetLocked(true); + EXPECT_TRUE(track.IsLocked()); + track.SetLocked(false); + EXPECT_FALSE(track.IsLocked()); +} + +TEST(Track, TrackHeightAccessors) +{ + olive::Track track; + + int emissions = 0; + qreal last_height = 0.0; + QObject::connect(&track, &olive::Track::TrackHeightChanged, + [&emissions, &last_height](qreal h) { + ++emissions; + last_height = h; + }); + + track.SetTrackHeight(2.5); + EXPECT_DOUBLE_EQ(track.GetTrackHeight(), 2.5); + EXPECT_EQ(emissions, 1); + EXPECT_DOUBLE_EQ(last_height, 2.5); + + EXPECT_DOUBLE_EQ(olive::Track::kTrackHeightDefault, 3.0); + EXPECT_DOUBLE_EQ(olive::Track::kTrackHeightMinimum, 1.5); + EXPECT_DOUBLE_EQ(olive::Track::kTrackHeightInterval, 0.5); + EXPECT_LT(olive::Track::GetMinimumTrackHeightInPixels(), + olive::Track::GetDefaultTrackHeightInPixels()); +} + +TEST(Track, TrackHeightPixelRoundTrip) +{ + olive::Track track; + + track.SetTrackHeightInPixels(77); + EXPECT_EQ(track.GetTrackHeightInPixels(), 77); + + olive::Track default_track; + EXPECT_EQ(default_track.GetTrackHeightInPixels(), + olive::Track::GetDefaultTrackHeightInPixels()); +} + +TEST(Track, HeightSaveLoadRoundTrip) +{ + olive::Track track; + track.SetTrackHeight(1.75); + + QString xml; + QXmlStreamWriter writer(&xml); + writer.writeStartDocument(); + writer.writeStartElement(QStringLiteral("custom")); + track.SaveCustom(&writer); + writer.writeEndElement(); + writer.writeEndDocument(); + + EXPECT_TRUE(xml.contains(QStringLiteral("1.75"))); + + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("custom")); + + olive::Track loaded; + ASSERT_TRUE(loaded.LoadCustom(&reader, nullptr)); + EXPECT_DOUBLE_EQ(loaded.GetTrackHeight(), 1.75); +} + +TEST(Track, ReferenceStringsRoundTrip) +{ + const olive::Track::Reference video(olive::Track::kVideo, 2); + EXPECT_EQ(video.ToString(), QStringLiteral("v:2")); + EXPECT_TRUE(video.IsValid()); + EXPECT_EQ(olive::Track::Reference::FromString(QStringLiteral("v:2")), video); + + const olive::Track::Reference audio(olive::Track::kAudio, 10); + EXPECT_EQ(audio.ToString(), QStringLiteral("a:10")); + EXPECT_EQ(olive::Track::Reference::FromString(QStringLiteral("a:10")), + audio); + + const olive::Track::Reference subtitle(olive::Track::kSubtitle, 0); + EXPECT_EQ(subtitle.ToString(), QStringLiteral("s:0")); + EXPECT_EQ(olive::Track::Reference::FromString(QStringLiteral("s:0")), + subtitle); + + EXPECT_EQ(olive::Track::Reference::TypeFromString(QStringLiteral("v:2")), + olive::Track::kVideo); + EXPECT_EQ(olive::Track::Reference::TypeFromString(QStringLiteral("a:3")), + olive::Track::kAudio); + EXPECT_EQ(olive::Track::Reference::TypeFromString(QStringLiteral("s:0")), + olive::Track::kSubtitle); + + EXPECT_TRUE( + olive::Track::Reference::TypeToString(olive::Track::kNone).isEmpty()); + EXPECT_TRUE(olive::Track::Reference().ToString().isEmpty()); +} + +TEST(Track, ReferenceInvalidStrings) +{ + EXPECT_FALSE( + olive::Track::Reference::FromString(QString()).IsValid()); + EXPECT_FALSE( + olive::Track::Reference::FromString(QStringLiteral("x:1")).IsValid()); + // Too short to contain a type prefix and separator + EXPECT_FALSE( + olive::Track::Reference::FromString(QStringLiteral("v")).IsValid()); + // Non-numeric index fails to parse + EXPECT_FALSE( + olive::Track::Reference::FromString(QStringLiteral("v:x")).IsValid()); + EXPECT_EQ( + olive::Track::Reference::TypeFromString(QStringLiteral("q:0")), + olive::Track::kNone); + + EXPECT_FALSE(olive::Track::Reference().IsValid()); + EXPECT_FALSE( + olive::Track::Reference(olive::Track::kCount, 0).IsValid()); + EXPECT_FALSE( + olive::Track::Reference(olive::Track::kVideo, -1).IsValid()); +} + +TEST(Track, ReferenceComparisonAndHash) +{ + const olive::Track::Reference v1(olive::Track::kVideo, 1); + const olive::Track::Reference v1_copy(olive::Track::kVideo, 1); + const olive::Track::Reference v2(olive::Track::kVideo, 2); + const olive::Track::Reference a1(olive::Track::kAudio, 1); + + EXPECT_EQ(v1, v1_copy); + EXPECT_NE(v1, v2); + EXPECT_NE(v1, a1); + + // Ordering is by type first, then index + EXPECT_LT(v1, v2); + EXPECT_LT(v2, a1); + EXPECT_FALSE(a1 < v2); + + EXPECT_EQ(olive::qHash(v1), olive::qHash(v1_copy)); +} + +TEST(Track, ReferenceDataStreamRoundTrip) +{ + const olive::Track::Reference ref(olive::Track::kAudio, 5); + + QByteArray bytes; + QDataStream out(&bytes, QIODevice::WriteOnly); + out << ref; + + QDataStream in(bytes); + olive::Track::Reference loaded; + in >> loaded; + + EXPECT_EQ(loaded, ref); +} + +TEST(Track, ToReferenceMatchesTypeAndIndex) +{ + olive::Track track; + track.set_type(olive::Track::kAudio); + track.SetIndex(7); + + const olive::Track::Reference ref = track.ToReference(); + EXPECT_EQ(ref, olive::Track::Reference(olive::Track::kAudio, 7)); + EXPECT_EQ(ref.ToString(), QStringLiteral("a:7")); +} + +TEST(Track, AppendBlocksSetsInOutLengthAndLinks) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project); + + int added_count = 0; + olive::Block *last_added = nullptr; + QObject::connect(track, &olive::Track::BlockAdded, + [&added_count, &last_added](olive::Block *b) { + ++added_count; + last_added = b; + }); + int length_changed_count = 0; + QObject::connect(track, &olive::Track::TrackLengthChanged, + [&length_changed_count]() { ++length_changed_count; }); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(&project, olive::core::rational(3)); + track->AppendBlock(a); + track->AppendBlock(b); + + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(track->Blocks().at(1), b); + + EXPECT_EQ(a->in(), olive::core::rational(0)); + EXPECT_EQ(a->out(), olive::core::rational(2)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); + EXPECT_EQ(track->track_length(), olive::core::rational(5)); + + EXPECT_EQ(a->track(), track); + EXPECT_EQ(b->track(), track); + EXPECT_EQ(a->previous(), nullptr); + EXPECT_EQ(a->next(), b); + EXPECT_EQ(b->previous(), a); + EXPECT_EQ(b->next(), nullptr); + + EXPECT_EQ(added_count, 2); + EXPECT_EQ(last_added, b); + EXPECT_EQ(length_changed_count, 2); +} + +TEST(Track, PrependBlockShiftsExistingBlocks) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project); + + olive::ClipBlock *b = CreateClip(&project, olive::core::rational(2)); + track->AppendBlock(b); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(1)); + track->PrependBlock(a); + + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(track->Blocks().at(1), b); + + EXPECT_EQ(a->in(), olive::core::rational(0)); + EXPECT_EQ(a->out(), olive::core::rational(1)); + EXPECT_EQ(b->in(), olive::core::rational(1)); + EXPECT_EQ(b->out(), olive::core::rational(3)); + EXPECT_EQ(track->track_length(), olive::core::rational(3)); + + EXPECT_EQ(a->next(), b); + EXPECT_EQ(b->previous(), a); +} + +TEST(Track, InsertBlockAtIndexMiddle) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(1)); + olive::ClipBlock *c = CreateClip(&project, olive::core::rational(1)); + track->AppendBlock(a); + track->AppendBlock(c); + + olive::ClipBlock *b = CreateClip(&project, olive::core::rational(2)); + track->InsertBlockAtIndex(b, 1); + + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(track->Blocks().at(2), c); + + EXPECT_EQ(a->in(), olive::core::rational(0)); + EXPECT_EQ(a->out(), olive::core::rational(1)); + EXPECT_EQ(b->in(), olive::core::rational(1)); + EXPECT_EQ(b->out(), olive::core::rational(3)); + EXPECT_EQ(c->in(), olive::core::rational(3)); + EXPECT_EQ(c->out(), olive::core::rational(4)); + + EXPECT_EQ(a->next(), b); + EXPECT_EQ(b->previous(), a); + EXPECT_EQ(b->next(), c); + EXPECT_EQ(c->previous(), b); +} + +TEST(Track, InsertBlockAfterAndBefore) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(1)); + olive::ClipBlock *c = CreateClip(&project, olive::core::rational(1)); + track->AppendBlock(a); + track->AppendBlock(c); + + olive::ClipBlock *b = CreateClip(&project, olive::core::rational(2)); + track->InsertBlockAfter(b, a); + + olive::ClipBlock *d = CreateClip(&project, olive::core::rational(1)); + track->InsertBlockBefore(d, c); + + // A null "before" block prepends, a null "after" block appends + olive::ClipBlock *e = CreateClip(&project, olive::core::rational(1)); + track->InsertBlockAfter(e, nullptr); + + olive::ClipBlock *f = CreateClip(&project, olive::core::rational(1)); + track->InsertBlockBefore(f, nullptr); + + const QVector expected = { e, a, b, d, c, f }; + ASSERT_EQ(track->Blocks().size(), expected.size()); + for (int i = 0; i < expected.size(); ++i) { + EXPECT_EQ(track->Blocks().at(i), expected.at(i)); + } + + EXPECT_EQ(e->in(), olive::core::rational(0)); + EXPECT_EQ(a->in(), olive::core::rational(1)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(d->in(), olive::core::rational(4)); + EXPECT_EQ(c->in(), olive::core::rational(5)); + EXPECT_EQ(f->in(), olive::core::rational(6)); + EXPECT_EQ(f->out(), olive::core::rational(7)); + EXPECT_EQ(track->track_length(), olive::core::rational(7)); +} + +TEST(Track, RippleRemoveBlockShiftsAndDetaches) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(&project, olive::core::rational(3)); + olive::ClipBlock *c = CreateClip(&project, olive::core::rational(1)); + track->AppendBlock(a); + track->AppendBlock(b); + track->AppendBlock(c); + ASSERT_EQ(track->track_length(), olive::core::rational(6)); + + int removed_count = 0; + olive::Block *last_removed = nullptr; + QObject::connect(track, &olive::Track::BlockRemoved, + [&removed_count, &last_removed](olive::Block *blk) { + ++removed_count; + last_removed = blk; + }); + + track->RippleRemoveBlock(b); + + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(track->Blocks().at(1), c); + + // Subsequent blocks move earlier to fill the space + EXPECT_EQ(a->in(), olive::core::rational(0)); + EXPECT_EQ(a->out(), olive::core::rational(2)); + EXPECT_EQ(c->in(), olive::core::rational(2)); + EXPECT_EQ(c->out(), olive::core::rational(3)); + EXPECT_EQ(track->track_length(), olive::core::rational(3)); + + // The removed block is detached and reset to zero-based in/out + EXPECT_EQ(b->track(), nullptr); + EXPECT_EQ(b->previous(), nullptr); + EXPECT_EQ(b->next(), nullptr); + EXPECT_EQ(b->in(), olive::core::rational(0)); + EXPECT_EQ(b->out(), b->length()); + + EXPECT_EQ(a->next(), c); + EXPECT_EQ(c->previous(), a); + + EXPECT_EQ(removed_count, 1); + EXPECT_EQ(last_removed, b); +} + +TEST(Track, RippleRemoveFirstAndLastBlock) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(&project, olive::core::rational(3)); + olive::ClipBlock *c = CreateClip(&project, olive::core::rational(1)); + track->AppendBlock(a); + track->AppendBlock(b); + track->AppendBlock(c); + + track->RippleRemoveBlock(a); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(b->in(), olive::core::rational(0)); + EXPECT_EQ(b->out(), olive::core::rational(3)); + EXPECT_EQ(c->in(), olive::core::rational(3)); + EXPECT_EQ(track->track_length(), olive::core::rational(4)); + EXPECT_EQ(b->previous(), nullptr); + + track->RippleRemoveBlock(c); + ASSERT_EQ(track->Blocks().size(), 1); + EXPECT_EQ(track->track_length(), olive::core::rational(3)); + EXPECT_EQ(b->next(), nullptr); + + track->RippleRemoveBlock(b); + EXPECT_TRUE(track->Blocks().isEmpty()); + EXPECT_EQ(track->track_length(), olive::core::rational(0)); +} + +TEST(Track, ReplaceBlockSameLength) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(&project, olive::core::rational(3)); + track->AppendBlock(a); + track->AppendBlock(b); + + int removed_count = 0; + int added_count = 0; + QObject::connect(track, &olive::Track::BlockRemoved, + [&removed_count](olive::Block *) { ++removed_count; }); + QObject::connect(track, &olive::Track::BlockAdded, + [&added_count](olive::Block *) { ++added_count; }); + + olive::ClipBlock *r = CreateClip(&project, olive::core::rational(3)); + track->ReplaceBlock(b, r); + + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(track->Blocks().at(1), r); + + // Same-length replacement keeps in/out points identical + EXPECT_EQ(r->in(), olive::core::rational(2)); + EXPECT_EQ(r->out(), olive::core::rational(5)); + EXPECT_EQ(track->track_length(), olive::core::rational(5)); + + EXPECT_EQ(b->track(), nullptr); + EXPECT_EQ(b->previous(), nullptr); + EXPECT_EQ(b->next(), nullptr); + EXPECT_EQ(r->track(), track); + EXPECT_EQ(r->previous(), a); + EXPECT_EQ(r->next(), nullptr); + EXPECT_EQ(a->next(), r); + + EXPECT_EQ(removed_count, 1); + EXPECT_EQ(added_count, 1); +} + +TEST(Track, ReplaceBlockDifferentLengthRipples) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(&project, olive::core::rational(2)); + olive::ClipBlock *c = CreateClip(&project, olive::core::rational(1)); + track->AppendBlock(a); + track->AppendBlock(b); + track->AppendBlock(c); + ASSERT_EQ(track->track_length(), olive::core::rational(5)); + + olive::ClipBlock *r = CreateClip(&project, olive::core::rational(4)); + track->ReplaceBlock(b, r); + + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(r->in(), olive::core::rational(2)); + EXPECT_EQ(r->out(), olive::core::rational(6)); + // The longer replacement pushes subsequent blocks later + EXPECT_EQ(c->in(), olive::core::rational(6)); + EXPECT_EQ(c->out(), olive::core::rational(7)); + EXPECT_EQ(track->track_length(), olive::core::rational(7)); +} + +TEST(Track, BlockLookupFunctions) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(2)); + olive::GapBlock *g = CreateGap(&project, olive::core::rational(3)); + olive::ClipBlock *b = CreateClip(&project, olive::core::rational(4)); + track->AppendBlock(a); + track->AppendBlock(g); + track->AppendBlock(b); + // Layout: a [0,2], g [2,5], b [5,9] + + // BlockContainingTime: strictly inside only, never at edges + EXPECT_EQ(track->BlockContainingTime(olive::core::rational(1)), a); + EXPECT_EQ(track->BlockContainingTime(olive::core::rational(0)), nullptr); + EXPECT_EQ(track->BlockContainingTime(olive::core::rational(2)), nullptr); + EXPECT_EQ(track->BlockContainingTime(olive::core::rational(3)), g); + EXPECT_EQ(track->BlockContainingTime(olive::core::rational(5)), nullptr); + EXPECT_EQ(track->BlockContainingTime(olive::core::rational(8)), b); + EXPECT_EQ(track->BlockContainingTime(olive::core::rational(9)), nullptr); + EXPECT_EQ(track->BlockContainingTime(olive::core::rational(100)), nullptr); + + // NearestBlockBefore: block starting before and ending at/after the time + EXPECT_EQ(track->NearestBlockBefore(olive::core::rational(2)), a); + EXPECT_EQ(track->NearestBlockBefore(olive::core::rational(5)), g); + EXPECT_EQ(track->NearestBlockBefore(olive::core::rational(9)), b); + EXPECT_EQ(track->NearestBlockBefore(olive::core::rational(10)), nullptr); + + // NearestBlockBeforeOrAt: first block ending after the time + EXPECT_EQ(track->NearestBlockBeforeOrAt(olive::core::rational(0)), a); + EXPECT_EQ(track->NearestBlockBeforeOrAt(olive::core::rational(2)), g); + EXPECT_EQ(track->NearestBlockBeforeOrAt(olive::core::rational(5)), b); + EXPECT_EQ(track->NearestBlockBeforeOrAt(olive::core::rational(9)), nullptr); + + // NearestBlockAfterOrAt: first block starting at or after the time + EXPECT_EQ(track->NearestBlockAfterOrAt(olive::core::rational(0)), a); + EXPECT_EQ(track->NearestBlockAfterOrAt(olive::core::rational(2)), g); + EXPECT_EQ(track->NearestBlockAfterOrAt(olive::core::rational(5)), b); + EXPECT_EQ(track->NearestBlockAfterOrAt(olive::core::rational(9)), nullptr); + + // NearestBlockAfter: first block starting strictly after the time + EXPECT_EQ(track->NearestBlockAfter(olive::core::rational(0)), g); + EXPECT_EQ(track->NearestBlockAfter(olive::core::rational(2)), b); + EXPECT_EQ(track->NearestBlockAfter(olive::core::rational(4)), b); + EXPECT_EQ(track->NearestBlockAfter(olive::core::rational(5)), nullptr); + + // VisibleBlockAtTime: half-open [in, out) containment via binary search + EXPECT_EQ(track->VisibleBlockAtTime(olive::core::rational(0)), a); + EXPECT_EQ(track->VisibleBlockAtTime(olive::core::rational(1)), a); + EXPECT_EQ(track->VisibleBlockAtTime(olive::core::rational(2)), g); + EXPECT_EQ(track->VisibleBlockAtTime(olive::core::rational(4)), g); + EXPECT_EQ(track->VisibleBlockAtTime(olive::core::rational(5)), b); + EXPECT_EQ(track->VisibleBlockAtTime(olive::core::rational(8)), b); + EXPECT_EQ(track->VisibleBlockAtTime(olive::core::rational(9)), nullptr); + EXPECT_EQ(track->VisibleBlockAtTime(olive::core::rational(-1)), nullptr); + EXPECT_EQ(track->VisibleBlockAtTime(olive::core::rational(100)), nullptr); +} + +TEST(Track, BlockLookupOnEmptyTrack) +{ + olive::Track track; + + EXPECT_EQ(track.BlockContainingTime(olive::core::rational(0)), nullptr); + EXPECT_EQ(track.NearestBlockBefore(olive::core::rational(0)), nullptr); + EXPECT_EQ(track.NearestBlockBeforeOrAt(olive::core::rational(0)), nullptr); + EXPECT_EQ(track.NearestBlockAfterOrAt(olive::core::rational(0)), nullptr); + EXPECT_EQ(track.NearestBlockAfter(olive::core::rational(0)), nullptr); + EXPECT_EQ(track.VisibleBlockAtTime(olive::core::rational(0)), nullptr); + EXPECT_EQ(track.track_length(), olive::core::rational(0)); +} + +TEST(Track, IsRangeFree) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(2)); + olive::GapBlock *g = CreateGap(&project, olive::core::rational(3)); + olive::ClipBlock *b = CreateClip(&project, olive::core::rational(4)); + track->AppendBlock(a); + track->AppendBlock(g); + track->AppendBlock(b); + // Layout: clip a [0,2], gap g [2,5], clip b [5,9] + + // A range covering only the gap (or part of it) is free + EXPECT_TRUE(track->IsRangeFree(olive::core::TimeRange( + olive::core::rational(2), olive::core::rational(5)))); + EXPECT_TRUE(track->IsRangeFree(olive::core::TimeRange( + olive::core::rational(3), olive::core::rational(4)))); + + // Ranges touching clips are not free + EXPECT_FALSE(track->IsRangeFree(olive::core::TimeRange( + olive::core::rational(0), olive::core::rational(2)))); + EXPECT_FALSE(track->IsRangeFree(olive::core::TimeRange( + olive::core::rational(1), olive::core::rational(3)))); + EXPECT_FALSE(track->IsRangeFree(olive::core::TimeRange( + olive::core::rational(4), olive::core::rational(9)))); + + // Past the end of the track there is nothing in the way + EXPECT_TRUE(track->IsRangeFree(olive::core::TimeRange( + olive::core::rational(9), olive::core::rational(12)))); + + olive::Track empty_track; + EXPECT_TRUE(empty_track.IsRangeFree(olive::core::TimeRange( + olive::core::rational(0), olive::core::rational(10)))); +} + +TEST(Track, ArrayIndexesAreReusedAfterRemoval) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(1)); + olive::ClipBlock *b = CreateClip(&project, olive::core::rational(1)); + olive::ClipBlock *c = CreateClip(&project, olive::core::rational(1)); + track->AppendBlock(a); + track->AppendBlock(b); + track->AppendBlock(c); + + EXPECT_EQ(track->GetArrayIndexFromBlock(a), 0); + EXPECT_EQ(track->GetArrayIndexFromBlock(b), 1); + EXPECT_EQ(track->GetArrayIndexFromBlock(c), 2); + + // Removing B frees its input array index; the next block reuses it + track->RippleRemoveBlock(b); + + olive::ClipBlock *d = CreateClip(&project, olive::core::rational(1)); + track->AppendBlock(d); + + EXPECT_EQ(track->GetArrayIndexFromBlock(a), 0); + EXPECT_EQ(track->GetArrayIndexFromBlock(c), 2); + EXPECT_EQ(track->GetArrayIndexFromBlock(d), 1); + + // The array map standard value mirrors the cache order [a, c, d] + const QByteArray bytes = + track->GetStandardValue(olive::Track::kArrayMapInput).toByteArray(); + ASSERT_EQ(bytes.size(), 3 * int(sizeof(uint32_t))); + uint32_t values[3]; + std::memcpy(values, bytes.constData(), sizeof(values)); + EXPECT_EQ(values[0], 0u); + EXPECT_EQ(values[1], 2u); + EXPECT_EQ(values[2], 1u); +} + +TEST(Track, BlockLengthChangeRipplesSubsequentBlocks) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(&project, olive::core::rational(2)); + track->AppendBlock(a); + track->AppendBlock(b); + ASSERT_EQ(track->track_length(), olive::core::rational(4)); + + int refreshed_count = 0; + QObject::connect(track, &olive::Track::BlocksRefreshed, + [&refreshed_count]() { ++refreshed_count; }); + + a->set_length_and_media_out(olive::core::rational(5)); + EXPECT_EQ(a->out(), olive::core::rational(5)); + EXPECT_EQ(b->in(), olive::core::rational(5)); + EXPECT_EQ(b->out(), olive::core::rational(7)); + EXPECT_EQ(track->track_length(), olive::core::rational(7)); + + b->set_length_and_media_out(olive::core::rational(1)); + EXPECT_EQ(b->in(), olive::core::rational(5)); + EXPECT_EQ(b->out(), olive::core::rational(6)); + EXPECT_EQ(track->track_length(), olive::core::rational(6)); + + EXPECT_EQ(refreshed_count, 2); +} + +TEST(Track, GetActiveElementsAtTime) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project); + + // An empty track has no active elements + EXPECT_EQ(track->GetActiveElementsAtTime( + olive::Track::kBlockInput, + olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(1))) + .mode(), + olive::Node::ActiveElements::kNoElements); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(2)); + olive::GapBlock *g = CreateGap(&project, olive::core::rational(2)); + track->AppendBlock(a); + track->AppendBlock(g); + // Layout: clip a [0,2], gap g [2,4] + + // The clip is the only active element in [0,1] + const olive::Node::ActiveElements active = + track->GetActiveElementsAtTime( + olive::Track::kBlockInput, + olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(1))); + EXPECT_EQ(active.mode(), olive::Node::ActiveElements::kSpecified); + ASSERT_EQ(active.elements().size(), 1); + EXPECT_EQ(active.elements().front(), track->GetArrayIndexFromBlock(a)); + + // Gaps never count as active elements + EXPECT_EQ(track->GetActiveElementsAtTime( + olive::Track::kBlockInput, + olive::core::TimeRange(olive::core::rational(2), + olive::core::rational(4))) + .mode(), + olive::Node::ActiveElements::kNoElements); + + // A muted track reports no active elements + track->SetMuted(true); + EXPECT_EQ(track->GetActiveElementsAtTime( + olive::Track::kBlockInput, + olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(1))) + .mode(), + olive::Node::ActiveElements::kNoElements); + track->SetMuted(false); + + // A disabled clip is not an active element + a->set_enabled(false); + EXPECT_EQ(track->GetActiveElementsAtTime( + olive::Track::kBlockInput, + olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(1))) + .mode(), + olive::Node::ActiveElements::kNoElements); + + // Ranges outside the track length have no active elements + EXPECT_EQ(track->GetActiveElementsAtTime( + olive::Track::kBlockInput, + olive::core::TimeRange(olive::core::rational(4), + olive::core::rational(8))) + .mode(), + olive::Node::ActiveElements::kNoElements); + EXPECT_EQ(track->GetActiveElementsAtTime( + olive::Track::kBlockInput, + olive::core::TimeRange(olive::core::rational(-1), + olive::core::rational(0))) + .mode(), + olive::Node::ActiveElements::kNoElements); + + // Non-block inputs fall back to the default (all elements) + EXPECT_EQ(track->GetActiveElementsAtTime( + olive::Track::kMutedInput, + olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(1))) + .mode(), + olive::Node::ActiveElements::kAllElements); +} + +TEST(Track, TimeAdjustmentTransformsAroundBlock) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(&project, olive::core::rational(3)); + track->AppendBlock(a); + track->AppendBlock(b); + // Layout: a [0,2], b [2,5] + + const int b_index = track->GetArrayIndexFromBlock(b); + + // Without clamping, the range is simply shifted by the block's in point + EXPECT_EQ(track->InputTimeAdjustment( + olive::Track::kBlockInput, b_index, + olive::core::TimeRange(olive::core::rational(2), + olive::core::rational(5)), + false), + olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(3))); + + // Clamping limits the range to the block before shifting + EXPECT_EQ(track->InputTimeAdjustment( + olive::Track::kBlockInput, b_index, + olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(10)), + true), + olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(3))); + EXPECT_EQ(track->InputTimeAdjustment( + olive::Track::kBlockInput, b_index, + olive::core::TimeRange(olive::core::rational(1), + olive::core::rational(4)), + true), + olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(2))); + + // Output adjustment shifts block-local time back into track time + EXPECT_EQ(track->OutputTimeAdjustment( + olive::Track::kBlockInput, b_index, + olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(3))), + olive::core::TimeRange(olive::core::rational(2), + olive::core::rational(5))); + + // Unknown elements and inputs pass the range through unchanged + const olive::core::TimeRange unchanged(olive::core::rational(1), + olive::core::rational(2)); + EXPECT_EQ(track->InputTimeAdjustment(olive::Track::kBlockInput, 99, + unchanged, true), + unchanged); + EXPECT_EQ(track->OutputTimeAdjustment(olive::Track::kBlockInput, 99, + unchanged), + unchanged); + EXPECT_EQ(track->InputTimeAdjustment(olive::Track::kMutedInput, -1, + unchanged, true), + unchanged); +} + +TEST(Track, TransformTimeHelpersRespectInfinities) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(&project, olive::core::rational(3)); + track->AppendBlock(a); + track->AppendBlock(b); + // b occupies [2,5] + + const olive::core::rational kMax(INT_MAX); + const olive::core::rational kMin(INT_MIN); + + EXPECT_EQ(olive::Track::TransformTimeForBlock(b, kMax), kMax); + EXPECT_EQ(olive::Track::TransformTimeForBlock(b, kMin), kMin); + EXPECT_EQ(olive::Track::TransformTimeFromBlock(b, kMax), kMax); + EXPECT_EQ(olive::Track::TransformTimeFromBlock(b, kMin), kMin); + + EXPECT_EQ(olive::Track::TransformTimeForBlock(b, olive::core::rational(3)), + olive::core::rational(1)); + EXPECT_EQ( + olive::Track::TransformTimeFromBlock(b, olive::core::rational(1)), + olive::core::rational(3)); + + EXPECT_EQ(olive::Track::TransformRangeForBlock( + b, olive::core::TimeRange(olive::core::rational(2), + olive::core::rational(5))), + olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(3))); + EXPECT_EQ(olive::Track::TransformRangeFromBlock( + b, olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(3))), + olive::core::TimeRange(olive::core::rational(2), + olive::core::rational(5))); +} + +TEST(Track, VideoValuePassesThroughFirstArrayElement) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project, olive::Track::kVideo); + + olive::NodeValueArray arr; + arr.emplace(0, olive::NodeValue(olive::NodeValue::kFloat, 42.0)); + + olive::NodeValueRow row; + row.insert(olive::Track::kBlockInput, + olive::NodeValue(olive::NodeValue::kNone, arr, nullptr, true)); + + olive::NodeValueTable table; + track->Value(row, olive::NodeGlobals(), &table); + + const olive::NodeValue out = table.Get(olive::NodeValue::kFloat); + ASSERT_EQ(out.type(), olive::NodeValue::kFloat); + EXPECT_DOUBLE_EQ(out.toDouble(), 42.0); + + // An empty block array pushes nothing + olive::NodeValueRow empty_row; + empty_row.insert(olive::Track::kBlockInput, + olive::NodeValue(olive::NodeValue::kNone, + olive::NodeValueArray(), nullptr, true)); + + olive::NodeValueTable empty_table; + track->Value(empty_row, olive::NodeGlobals(), &empty_table); + EXPECT_EQ(empty_table.Get(olive::NodeValue::kFloat).type(), + olive::NodeValue::kNone); +} + +TEST(Track, AudioValueProducesSilentBufferWithoutBlocks) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project, olive::Track::kAudio); + + const olive::core::AudioParams aparams(48000, + olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P); + + olive::NodeValueRow row; + row.insert(olive::Track::kBlockInput, + olive::NodeValue(olive::NodeValue::kNone, + olive::NodeValueArray(), nullptr, true)); + + const olive::NodeGlobals globals( + olive::VideoParams(), aparams, + olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(1, 2)), + olive::LoopMode::kLoopModeOff); + + olive::NodeValueTable table; + track->Value(row, globals, &table); + + const olive::NodeValue out = table.Get(olive::NodeValue::kSamples); + ASSERT_EQ(out.type(), olive::NodeValue::kSamples); + + const olive::core::SampleBuffer samples = out.toSamples(); + ASSERT_TRUE(samples.is_allocated()); + EXPECT_EQ(samples.channel_count(), 2); + EXPECT_EQ(samples.sample_count(), 24000); + EXPECT_FLOAT_EQ(SumOfAbsoluteSamples(samples), 0.0f); +} + +TEST(Track, AudioValueMixesBlockSamples) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project, olive::Track::kAudio); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(1)); + olive::ClipBlock *b = CreateClip(&project, olive::core::rational(1)); + track->AppendBlock(a); + track->AppendBlock(b); + // Layout: a [0,1], b [1,2] + + const olive::core::AudioParams aparams(48000, + olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P); + + olive::core::SampleBuffer a_samples(aparams, olive::core::rational(1)); + olive::core::SampleBuffer b_samples(aparams, olive::core::rational(1)); + ASSERT_TRUE(a_samples.is_allocated()); + ASSERT_TRUE(b_samples.is_allocated()); + for (int ch = 0; ch < a_samples.channel_count(); ++ch) { + std::fill(a_samples.data(ch), a_samples.data(ch) + 48000, 1.0f); + std::fill(b_samples.data(ch), b_samples.data(ch) + 48000, 0.5f); + } + + olive::NodeValueArray arr; + arr.emplace(track->GetArrayIndexFromBlock(a), + olive::NodeValue(olive::NodeValue::kSamples, a_samples)); + arr.emplace(track->GetArrayIndexFromBlock(b), + olive::NodeValue(olive::NodeValue::kSamples, b_samples)); + + olive::NodeValueRow row; + row.insert(olive::Track::kBlockInput, + olive::NodeValue(olive::NodeValue::kNone, arr, nullptr, true)); + + const olive::NodeGlobals globals( + olive::VideoParams(), aparams, + olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(2)), + olive::LoopMode::kLoopModeOff); + + olive::NodeValueTable table; + track->Value(row, globals, &table); + + const olive::NodeValue out = table.Get(olive::NodeValue::kSamples); + ASSERT_EQ(out.type(), olive::NodeValue::kSamples); + + const olive::core::SampleBuffer mixed = out.toSamples(); + ASSERT_TRUE(mixed.is_allocated()); + ASSERT_EQ(mixed.sample_count(), 96000); + + // Each block's samples land at their track position + EXPECT_FLOAT_EQ(mixed.data(0)[0], 1.0f); + EXPECT_FLOAT_EQ(mixed.data(0)[47999], 1.0f); + EXPECT_FLOAT_EQ(mixed.data(0)[48000], 0.5f); + EXPECT_FLOAT_EQ(mixed.data(0)[95999], 0.5f); + EXPECT_FLOAT_EQ(mixed.data(1)[24000], 1.0f); + EXPECT_FLOAT_EQ(mixed.data(1)[72000], 0.5f); +} + +TEST(Track, AudioValueSilencesZeroSpeedClip) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project, olive::Track::kAudio); + + olive::ClipBlock *a = CreateClip(&project, olive::core::rational(1)); + track->AppendBlock(a); + a->SetStandardValue(olive::ClipBlock::kSpeedInput, 0.0); + ASSERT_DOUBLE_EQ(a->speed(), 0.0); + + const olive::core::AudioParams aparams(48000, + olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P); + + olive::core::SampleBuffer a_samples(aparams, olive::core::rational(1)); + ASSERT_TRUE(a_samples.is_allocated()); + for (int ch = 0; ch < a_samples.channel_count(); ++ch) { + std::fill(a_samples.data(ch), a_samples.data(ch) + 48000, 1.0f); + } + + olive::NodeValueArray arr; + arr.emplace(track->GetArrayIndexFromBlock(a), + olive::NodeValue(olive::NodeValue::kSamples, a_samples)); + + olive::NodeValueRow row; + row.insert(olive::Track::kBlockInput, + olive::NodeValue(olive::NodeValue::kNone, arr, nullptr, true)); + + const olive::NodeGlobals globals( + olive::VideoParams(), aparams, + olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(1)), + olive::LoopMode::kLoopModeOff); + + olive::NodeValueTable table; + track->Value(row, globals, &table); + + const olive::NodeValue out = table.Get(olive::NodeValue::kSamples); + ASSERT_EQ(out.type(), olive::NodeValue::kSamples); + + // Zero-speed audio is defined to come out as silence + const olive::core::SampleBuffer samples = out.toSamples(); + ASSERT_TRUE(samples.is_allocated()); + EXPECT_FLOAT_EQ(SumOfAbsoluteSamples(samples), 0.0f); +} + +TEST(Track, SubtitleValuePushesNothing) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Track *track = CreateTrack(&project, olive::Track::kSubtitle); + + olive::NodeValueArray arr; + arr.emplace(0, olive::NodeValue(olive::NodeValue::kFloat, 42.0)); + + olive::NodeValueRow row; + row.insert(olive::Track::kBlockInput, + olive::NodeValue(olive::NodeValue::kNone, arr, nullptr, true)); + + olive::NodeValueTable table; + track->Value(row, olive::NodeGlobals(), &table); + EXPECT_EQ(table.Count(), 0); +}