From 7e55bd049b682e238255d1bf5d4589bdd8623327 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 17 Jul 2026 04:55:37 +0800 Subject: [PATCH] tests: coverage round 4 (footage, IPC, input immediate, project/factory) - footage_test: static describe/loop-mode helpers, stream mapping, Value() job generation incl. proxy attachment, data roles, reprobe via seeded metadata cache - render_workerpool_ipc_test: SharedMemoryRegion, FrameSlotPool cross-mapping handoff, IpcMessage NDJSON round trips, RenderWorkerPool rejection paths - node_inputimmediate_test: NodeInputImmediate raw API, SetValueAtTime, interpolation (linear/hold/bezier) for float/vec/color/rational - project_factory_test: Project settings/cache modes/save-load/signals, NodeFactory creation and menus Also fixes Project::cache_path() returning the default cache instead of a configured custom cache path (inverted branch, found by the tests) --- app/node/project.cpp | 2 +- tests/gtest/CMakeLists.txt | 4 + tests/gtest/footage_test.cpp | 809 +++++++++++++++++++ tests/gtest/node_inputimmediate_test.cpp | 733 +++++++++++++++++ tests/gtest/project_factory_test.cpp | 587 ++++++++++++++ tests/gtest/render_workerpool_ipc_test.cpp | 882 +++++++++++++++++++++ 6 files changed, 3016 insertions(+), 1 deletion(-) create mode 100644 tests/gtest/footage_test.cpp create mode 100644 tests/gtest/node_inputimmediate_test.cpp create mode 100644 tests/gtest/project_factory_test.cpp create mode 100644 tests/gtest/render_workerpool_ipc_test.cpp diff --git a/app/node/project.cpp b/app/node/project.cpp index 9dc140f0e..9084009b0 100644 --- a/app/node/project.cpp +++ b/app/node/project.cpp @@ -478,7 +478,7 @@ QString Project::cache_path() const break; case kCacheCustomPath: { QString cache_path = GetCustomCachePath(); - if (cache_path.isEmpty()) { + if (!cache_path.isEmpty()) { return cache_path; } break; diff --git a/tests/gtest/CMakeLists.txt b/tests/gtest/CMakeLists.txt index 942f957a3..70ba7b158 100644 --- a/tests/gtest/CMakeLists.txt +++ b/tests/gtest/CMakeLists.txt @@ -59,6 +59,10 @@ add_executable(olive-gtest node_core_test.cpp clip_traverser_test.cpp audio_manager_viewer_test.cpp + footage_test.cpp + render_workerpool_ipc_test.cpp + node_inputimmediate_test.cpp + project_factory_test.cpp timeline_marker_test.cpp undo_stack_test.cpp plugin_support_test.cpp diff --git a/tests/gtest/footage_test.cpp b/tests/gtest/footage_test.cpp new file mode 100644 index 000000000..a4b054544 --- /dev/null +++ b/tests/gtest/footage_test.cpp @@ -0,0 +1,809 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/filefunctions.h" +#include "core.h" +#include "node/color/colormanager/colormanager.h" +#include "node/globals.h" +#include "node/project.h" +#include "node/project/footage/footage.h" +#include "render/diskmanager.h" +#include "node/project/footage/footagedescription.h" +#include "render/job/footagejob.h" +#include "render/loopmode.h" +#include "render/texture.h" + +namespace +{ + +// Footage subclass that exposes the protected stream mutators of ViewerOutput +// so tests can populate streams without probing real media +class TestableFootage : public olive::Footage { +public: + using olive::ViewerOutput::AddStream; + using olive::ViewerOutput::SetStream; +}; + +// Temporarily overrides an environment variable, restoring the previous state +// on destruction. Used to sandbox the footage metadata cache inside a +// QTemporaryDir. +class ScopedEnvVar { +public: + ScopedEnvVar(const char *name, const QByteArray &value) + : name_(name) + , old_value_(qgetenv(name)) + , had_value_(qEnvironmentVariableIsSet(name)) + { + qputenv(name_, value); + } + + ~ScopedEnvVar() + { + if (had_value_) { + qputenv(name_, old_value_); + } else { + qunsetenv(name_); + } + } + +private: + const char *name_; + QByteArray old_value_; + bool had_value_; +}; + +olive::VideoParams MakeVideoStream(int stream_index) +{ + olive::VideoParams params(1920, 1080, olive::rational(1, 24), + olive::core::PixelFormat::U8, 4); + params.set_stream_index(stream_index); + params.set_duration(48); // 2 seconds at 24 fps + return params; +} + +olive::core::AudioParams MakeAudioStream(int stream_index) +{ + olive::core::AudioParams params(48000, olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P); + params.set_stream_index(stream_index); + params.set_duration(96000); // 2 seconds at 48 kHz + return params; +} + +olive::SubtitleParams MakeSubtitleStream(int stream_index) +{ + olive::SubtitleParams params; + params.set_stream_index(stream_index); + params.push_back(olive::Subtitle( + olive::TimeRange(olive::rational(0), olive::rational(3)), + QStringLiteral("subtitle text"))); + return params; +} + +// Two video streams (one with an explicit colorspace, one without) and one +// audio stream, reported as three source streams in total +olive::FootageDescription MakeStandardDescription() +{ + olive::FootageDescription desc(QStringLiteral("fakedecoder")); + + olive::VideoParams video0 = MakeVideoStream(0); + desc.AddVideoStream(video0); + + olive::VideoParams video1(1280, 720, olive::rational(1, 24), + olive::core::PixelFormat::U8, 4); + video1.set_stream_index(1); + video1.set_duration(48); + video1.set_colorspace(QStringLiteral("ExplicitSpace")); + desc.AddVideoStream(video1); + + desc.AddAudioStream(MakeAudioStream(2)); + + desc.SetStreamCount(3); + return desc; +} + +QString CreateFakeMediaFile(QTemporaryDir &dir, const QString &name) +{ + const QString path = QDir(dir.path()).filePath(name); + QFile file(path); + if (!file.open(QFile::WriteOnly)) { + return QString(); + } + file.write("OAK_FAKE_MEDIA"); + file.close(); + return path; +} + +// Seeds the footage metadata cache for media_path with desc, then points a +// project-parented Footage at the file so Reprobe() picks the cache up without +// running any real decoders. The caller is responsible for redirecting +// QStandardPaths::CacheLocation into a temporary directory first. Returns +// nullptr if the cache file could not be written. +TestableFootage *ProbeFootageFromCache(olive::Project *project, + const QString &media_path, + const olive::FootageDescription &desc) +{ + const QString cache_location = + QStandardPaths::writableLocation(QStandardPaths::CacheLocation); + if (cache_location.isEmpty() || !QDir().mkpath(cache_location)) { + return nullptr; + } + + const QString cache_file = + QDir(cache_location) + .filePath(olive::FileFunctions::GetUniqueFileIdentifier(media_path)); + if (!desc.Save(cache_file)) { + return nullptr; + } + + auto *footage = new TestableFootage(); + footage->setParent(project); + footage->set_filename(media_path); + return footage; +} + +olive::NodeGlobals MakeGlobals( + olive::LoopMode loop_mode = olive::LoopMode::kLoopModeOff, int divider = 1) +{ + olive::VideoParams vparams(64, 64, olive::rational(1, 24), + olive::core::PixelFormat::U8, 4); + vparams.set_divider(divider); + return olive::NodeGlobals(vparams, olive::core::AudioParams(), + olive::rational(0), loop_mode); +} + +} // namespace + +TEST(FootageStatic, DescribeVideoStreamFormatsVideoAndStillStreams) +{ + olive::VideoParams video = MakeVideoStream(0); + EXPECT_EQ(olive::Footage::DescribeVideoStream(video), + QStringLiteral("0: Video - 1920x1080")); + + video.set_video_type(olive::VideoParams::kVideoTypeStill); + video.set_stream_index(3); + EXPECT_EQ(olive::Footage::DescribeVideoStream(video), + QStringLiteral("3: Image - 1920x1080")); +} + +TEST(FootageStatic, DescribeAudioStreamContainsIndexAndRate) +{ + olive::core::AudioParams audio = MakeAudioStream(1); + + // The %n plural marker is only substituted when a translation is loaded, + // so assert on the stable parts of the description instead + const QString description = olive::Footage::DescribeAudioStream(audio); + EXPECT_TRUE(description.startsWith(QStringLiteral("1: Audio"))); + EXPECT_TRUE(description.contains(QStringLiteral("48000Hz"))); +} + +TEST(FootageStatic, DescribeSubtitleStreamContainsIndex) +{ + olive::SubtitleParams subs = MakeSubtitleStream(4); + EXPECT_EQ(olive::Footage::DescribeSubtitleStream(subs), + QStringLiteral("4: Subtitle")); +} + +TEST(FootageStatic, GetStreamTypeNameCoversAllTrackTypes) +{ + EXPECT_EQ(olive::Footage::GetStreamTypeName(olive::Track::kVideo), + QStringLiteral("Video")); + EXPECT_EQ(olive::Footage::GetStreamTypeName(olive::Track::kAudio), + QStringLiteral("Audio")); + EXPECT_EQ(olive::Footage::GetStreamTypeName(olive::Track::kSubtitle), + QStringLiteral("Subtitle")); + EXPECT_EQ(olive::Footage::GetStreamTypeName(olive::Track::kNone), + QStringLiteral("Unknown")); + EXPECT_EQ(olive::Footage::GetStreamTypeName(olive::Track::kCount), + QStringLiteral("Unknown")); +} + +TEST(FootageStatic, AdjustTimeByLoopModeReturnsZeroForStillImages) +{ + // Still images never loop, clamp, or drop: the adjusted time is always 0, + // even for in-bounds times + EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode( + olive::rational(3), olive::LoopMode::kLoopModeOff, + olive::rational(10), olive::VideoParams::kVideoTypeStill, + olive::rational(1, 24)), + olive::rational(0)); + EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode( + olive::rational(30), olive::LoopMode::kLoopModeLoop, + olive::rational(10), olive::VideoParams::kVideoTypeStill, + olive::rational(1, 24)), + olive::rational(0)); + EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode( + olive::rational(-1), olive::LoopMode::kLoopModeClamp, + olive::rational(10), olive::VideoParams::kVideoTypeStill, + olive::rational(1, 24)), + olive::rational(0)); +} + +TEST(FootageStatic, AdjustTimeByLoopModeKeepsInBoundsTime) +{ + for (olive::LoopMode mode : { olive::LoopMode::kLoopModeOff, + olive::LoopMode::kLoopModeClamp, + olive::LoopMode::kLoopModeLoop }) { + EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode( + olive::rational(3), mode, olive::rational(10), + olive::VideoParams::kVideoTypeVideo, + olive::rational(1, 24)), + olive::rational(3)); + } +} + +TEST(FootageStatic, AdjustTimeByLoopModeOffDropsOutOfBoundsTime) +{ + const olive::rational negative = olive::Footage::AdjustTimeByLoopMode( + olive::rational(-1), olive::LoopMode::kLoopModeOff, olive::rational(10), + olive::VideoParams::kVideoTypeVideo, olive::rational(1, 24)); + EXPECT_TRUE(negative.isNaN()); + + // The length itself is already out of bounds + const olive::rational at_length = olive::Footage::AdjustTimeByLoopMode( + olive::rational(10), olive::LoopMode::kLoopModeOff, olive::rational(10), + olive::VideoParams::kVideoTypeVideo, olive::rational(1, 24)); + EXPECT_TRUE(at_length.isNaN()); +} + +TEST(FootageStatic, AdjustTimeByLoopModeClampsToLength) +{ + // Beyond the end, clamp to the last frame (length - timebase) + EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode( + olive::rational(10), olive::LoopMode::kLoopModeClamp, + olive::rational(10), olive::VideoParams::kVideoTypeVideo, + olive::rational(1, 24)), + olive::rational(239, 24)); + + // Before the start, clamp to 0 + EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode( + olive::rational(-5), olive::LoopMode::kLoopModeClamp, + olive::rational(10), olive::VideoParams::kVideoTypeVideo, + olive::rational(1, 24)), + olive::rational(0)); +} + +TEST(FootageStatic, AdjustTimeByLoopModeLoopsAroundLength) +{ + // Single wrap past the end + EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode( + olive::rational(12), olive::LoopMode::kLoopModeLoop, + olive::rational(10), olive::VideoParams::kVideoTypeVideo, + olive::rational(1, 24)), + olive::rational(2)); + + // Multiple wraps past the end + EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode( + olive::rational(25), olive::LoopMode::kLoopModeLoop, + olive::rational(10), olive::VideoParams::kVideoTypeVideo, + olive::rational(1, 24)), + olive::rational(5)); + + // Wraps from before the start + EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode( + olive::rational(-3), olive::LoopMode::kLoopModeLoop, + olive::rational(10), olive::VideoParams::kVideoTypeVideo, + olive::rational(1, 24)), + olive::rational(7)); + EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode( + olive::rational(-25), olive::LoopMode::kLoopModeLoop, + olive::rational(10), olive::VideoParams::kVideoTypeVideo, + olive::rational(1, 24)), + olive::rational(5)); +} + +TEST(FootageStatic, RetranslateSetsInputNames) +{ + TestableFootage footage; + footage.Retranslate(); + + EXPECT_EQ(footage.GetInputName(olive::Footage::kFilenameInput), + QStringLiteral("Filename")); + EXPECT_EQ(footage.GetInputName(olive::ViewerOutput::kVideoParamsInput), + QStringLiteral("Video Parameters")); + EXPECT_EQ(footage.GetInputName(olive::ViewerOutput::kAudioParamsInput), + QStringLiteral("Audio Parameters")); + EXPECT_EQ(footage.GetInputName(olive::ViewerOutput::kSubtitleParamsInput), + QStringLiteral("Subtitle Parameters")); +} + +class FootageTest : public ::testing::Test { +protected: + void SetUp() override + { + if (!olive::Core::instance()) { + // Leaked intentionally: Core is process-wide and DiskManager + // touches it (matches render_diskcache_test). + new olive::Core(olive::Core::CoreParams()); + } + + // Footage::Value() resolves Project::cache_path(), which goes through + // the DiskManager singleton + olive::DiskManager::CreateInstance(); + + olive::ColorManager::SetUpDefaultConfig(); + + project_ = std::make_unique(); + project_->Initialize(); + } + + void TearDown() override + { + project_.reset(); + olive::DiskManager::DestroyInstance(); + } + + olive::Footage *AddFootage() + { + auto *footage = new olive::Footage(); + footage->setParent(project_.get()); + return footage; + } + + std::unique_ptr project_; +}; + +TEST_F(FootageTest, ManuallyAddedStreamsMapBetweenReferencesAndIndices) +{ + TestableFootage footage; + + EXPECT_EQ(footage.AddStream(olive::Track::kVideo, + QVariant::fromValue(MakeVideoStream(5))), + 0); + EXPECT_EQ(footage.AddStream(olive::Track::kAudio, + QVariant::fromValue(MakeAudioStream(2))), + 0); + EXPECT_EQ(footage.AddStream(olive::Track::kSubtitle, + QVariant::fromValue(MakeSubtitleStream(7))), + 0); + + EXPECT_EQ(footage.GetStreamIndex(olive::Track::kVideo, 0), 5); + EXPECT_EQ(footage.GetStreamIndex(olive::Track::kAudio, 0), 2); + EXPECT_EQ(footage.GetStreamIndex(olive::Track::kSubtitle, 0), 7); + EXPECT_EQ(footage.GetStreamIndex( + olive::Track::Reference(olive::Track::kVideo, 0)), + 5); + EXPECT_EQ(footage.GetStreamIndex(olive::Track::kNone, 0), -1); + EXPECT_EQ(footage.GetStreamIndex(olive::Track::kCount, 0), -1); + + EXPECT_EQ(footage.GetReferenceFromRealIndex(5), + olive::Track::Reference(olive::Track::kVideo, 0)); + EXPECT_EQ(footage.GetReferenceFromRealIndex(2), + olive::Track::Reference(olive::Track::kAudio, 0)); + EXPECT_EQ(footage.GetReferenceFromRealIndex(7), + olive::Track::Reference(olive::Track::kSubtitle, 0)); + + const olive::Track::Reference unknown = + footage.GetReferenceFromRealIndex(99); + EXPECT_EQ(unknown.type(), olive::Track::kNone); + EXPECT_EQ(unknown.index(), -1); +} + +TEST_F(FootageTest, ConnectedOutputsReflectStreamTypes) +{ + TestableFootage footage; + + EXPECT_EQ(footage.GetConnectedTextureOutput(), nullptr); + EXPECT_EQ(footage.GetConnectedSampleOutput(), nullptr); + + footage.AddStream(olive::Track::kVideo, + QVariant::fromValue(MakeVideoStream(0))); + EXPECT_EQ(footage.GetConnectedTextureOutput(), + static_cast(&footage)); + EXPECT_EQ(footage.GetConnectedSampleOutput(), nullptr); + + footage.AddStream(olive::Track::kAudio, + QVariant::fromValue(MakeAudioStream(1))); + EXPECT_EQ(footage.GetConnectedSampleOutput(), + static_cast(&footage)); +} + +TEST_F(FootageTest, DataRolesForInvalidFootage) +{ + TestableFootage footage; + + EXPECT_EQ(footage.data(olive::Node::TOOLTIP).toString(), + QStringLiteral("Invalid")); + EXPECT_TRUE(footage.data(olive::Node::ICON).canConvert()); + + // With no existing file behind the footage, the time roles fall through + // to the base class and stay invalid + EXPECT_FALSE(footage.data(olive::Node::CREATED_TIME).isValid()); + EXPECT_FALSE(footage.data(olive::Node::MODIFIED_TIME).isValid()); +} + +TEST_F(FootageTest, TooltipDescribesEnabledStreams) +{ + TestableFootage footage; + // The file does not exist, so the filename change clears the footage + // without probing anything + footage.set_filename(QStringLiteral("/nonexistent/media.mkv")); + footage.AddStream(olive::Track::kVideo, + QVariant::fromValue(MakeVideoStream(0))); + footage.AddStream(olive::Track::kAudio, + QVariant::fromValue(MakeAudioStream(1))); + footage.SetValid(); + + QString tip = footage.data(olive::Node::TOOLTIP).toString(); + EXPECT_TRUE( + tip.contains(QStringLiteral("Filename: /nonexistent/media.mkv"))); + EXPECT_TRUE(tip.contains(QStringLiteral("0: Video - 1920x1080"))); + EXPECT_TRUE(tip.contains(QStringLiteral("Audio"))); + + // Disabled streams are omitted from the tooltip + olive::VideoParams disabled = footage.GetVideoParams(0); + disabled.set_enabled(false); + footage.SetStream(olive::Track::kVideo, QVariant::fromValue(disabled), 0); + + tip = footage.data(olive::Node::TOOLTIP).toString(); + EXPECT_FALSE(tip.contains(QStringLiteral("0: Video"))); + EXPECT_TRUE(tip.contains(QStringLiteral("Audio"))); +} + +TEST_F(FootageTest, IconReflectsPrioritizedStreamType) +{ + // Invalid footage + TestableFootage footage; + EXPECT_TRUE(footage.data(olive::Node::ICON).canConvert()); + + // Real video streams take priority over audio + footage.AddStream(olive::Track::kVideo, + QVariant::fromValue(MakeVideoStream(0))); + footage.AddStream(olive::Track::kAudio, + QVariant::fromValue(MakeAudioStream(1))); + footage.SetValid(); + EXPECT_TRUE(footage.data(olive::Node::ICON).canConvert()); + + // A still image without audio hits the image branch + TestableFootage stills; + olive::VideoParams still = MakeVideoStream(0); + still.set_video_type(olive::VideoParams::kVideoTypeStill); + stills.AddStream(olive::Track::kVideo, QVariant::fromValue(still)); + stills.SetValid(); + EXPECT_TRUE(stills.data(olive::Node::ICON).canConvert()); + + // Audio-only footage + TestableFootage audio_only; + audio_only.AddStream(olive::Track::kAudio, + QVariant::fromValue(MakeAudioStream(0))); + audio_only.SetValid(); + EXPECT_TRUE(audio_only.data(olive::Node::ICON).canConvert()); + + // Subtitle-only footage + TestableFootage subs_only; + subs_only.AddStream(olive::Track::kSubtitle, + QVariant::fromValue(MakeSubtitleStream(0))); + subs_only.SetValid(); + EXPECT_TRUE(subs_only.data(olive::Node::ICON).canConvert()); +} + +TEST_F(FootageTest, ProxyChangesMarkProjectModifiedAndEmitSignal) +{ + olive::Footage *footage = AddFootage(); + ASSERT_FALSE(project_->is_modified()); + + int emissions = 0; + QObject::connect(footage, &olive::Footage::ProxySettingsChanged, + [&emissions]() { ++emissions; }); + + footage->SetProxy(QStringLiteral("/cache/proxy/example.mp4"), + olive::ProxyManager::kProxyReady, 0, 1, true); + EXPECT_EQ(emissions, 1); + EXPECT_TRUE(project_->is_modified()); + + // set_proxy_enabled only reacts to actual changes + project_->set_modified(false); + footage->set_proxy_enabled(true); + EXPECT_EQ(emissions, 1); + EXPECT_FALSE(project_->is_modified()); + + footage->set_proxy_enabled(false); + EXPECT_EQ(emissions, 2); + EXPECT_TRUE(project_->is_modified()); + EXPECT_FALSE(footage->proxy_enabled()); +} + +TEST_F(FootageTest, ReprobeRestoresStreamsFromMetadataCache) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const ScopedEnvVar xdg( + "XDG_CACHE_HOME", + QDir(dir.path()).filePath(QStringLiteral("xdg")).toUtf8()); + + const QString media = CreateFakeMediaFile(dir, QStringLiteral("fake.mkv")); + ASSERT_FALSE(media.isEmpty()); + + TestableFootage *footage = ProbeFootageFromCache( + project_.get(), media, MakeStandardDescription()); + ASSERT_NE(footage, nullptr); + + EXPECT_TRUE(footage->IsValid()); + EXPECT_EQ(footage->decoder(), QStringLiteral("fakedecoder")); + EXPECT_EQ(footage->GetTotalStreamCount(), 3); + EXPECT_EQ(footage->GetVideoStreamCount(), 2); + EXPECT_EQ(footage->GetAudioStreamCount(), 1); + EXPECT_EQ(footage->GetSubtitleStreamCount(), 0); + + EXPECT_EQ(footage->GetStreamIndex(olive::Track::kVideo, 0), 0); + EXPECT_EQ(footage->GetStreamIndex(olive::Track::kVideo, 1), 1); + EXPECT_EQ(footage->GetStreamIndex(olive::Track::kAudio, 0), 2); + EXPECT_EQ(footage->GetReferenceFromRealIndex(2), + olive::Track::Reference(olive::Track::kAudio, 0)); + + EXPECT_EQ(footage->GetConnectedTextureOutput(), + static_cast(footage)); + EXPECT_EQ(footage->GetConnectedSampleOutput(), + static_cast(footage)); + + // The file behind the footage exists, so both time roles are reported + const QVariant modified = footage->data(olive::Node::MODIFIED_TIME); + ASSERT_TRUE(modified.isValid()); + EXPECT_EQ(modified.toLongLong(), + QFileInfo(media).lastModified().toSecsSinceEpoch()); + EXPECT_TRUE(footage->data(olive::Node::CREATED_TIME).isValid()); +} + +TEST_F(FootageTest, VerifyLengthUsesStreamDurations) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const ScopedEnvVar xdg( + "XDG_CACHE_HOME", + QDir(dir.path()).filePath(QStringLiteral("xdg")).toUtf8()); + + const QString media = CreateFakeMediaFile(dir, QStringLiteral("fake.mkv")); + ASSERT_FALSE(media.isEmpty()); + + TestableFootage *footage = ProbeFootageFromCache( + project_.get(), media, MakeStandardDescription()); + ASSERT_NE(footage, nullptr); + + // Both streams describe two seconds of media + footage->VerifyLength(); + EXPECT_EQ(footage->GetVideoLength(), olive::rational(2)); + EXPECT_EQ(footage->GetAudioLength(), olive::rational(2)); + EXPECT_EQ(footage->GetLength(), olive::rational(2)); +} + +TEST_F(FootageTest, ValueSkipsMissingFiles) +{ + TestableFootage footage; + + olive::NodeValueRow row; + row.insert(olive::Footage::kFilenameInput, + olive::NodeValue(olive::NodeValue::kFile, + QStringLiteral("/nonexistent/media.mkv"))); + + olive::NodeValueTable table; + footage.Value(row, MakeGlobals(), &table); + + EXPECT_TRUE(table.isEmpty()); +} + +TEST_F(FootageTest, ValuePushesOnlyLengthWhenNoStreams) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString media = CreateFakeMediaFile(dir, QStringLiteral("empty.mkv")); + ASSERT_FALSE(media.isEmpty()); + + TestableFootage footage; + + olive::NodeValueRow row; + row.insert(olive::Footage::kFilenameInput, + olive::NodeValue(olive::NodeValue::kFile, media)); + + olive::NodeValueTable table; + footage.Value(row, MakeGlobals(), &table); + + // The file exists but no streams were ever probed, so only the (zero) + // length is pushed + ASSERT_EQ(table.Count(), 1); + const olive::NodeValue length = + table.Get(olive::NodeValue::kRational, QStringLiteral("length")); + EXPECT_EQ(length.type(), olive::NodeValue::kRational); + EXPECT_EQ(length.toRational(), olive::rational(0)); + EXPECT_EQ(length.source(), static_cast(&footage)); +} + +TEST_F(FootageTest, ValuePushesStreamJobs) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const ScopedEnvVar xdg( + "XDG_CACHE_HOME", + QDir(dir.path()).filePath(QStringLiteral("xdg")).toUtf8()); + + const QString media = CreateFakeMediaFile(dir, QStringLiteral("fake.mkv")); + ASSERT_FALSE(media.isEmpty()); + + TestableFootage *footage = ProbeFootageFromCache( + project_.get(), media, MakeStandardDescription()); + ASSERT_NE(footage, nullptr); + footage->VerifyLength(); + + // The colorspace fallback reads the project default, and the audio cache + // path comes from the project's cache settings + project_->SetDefaultInputColorSpace(QStringLiteral("TestInputSpace")); + project_->SetCacheLocationSetting(olive::Project::kCacheCustomPath); + const QString cache_path = + QDir(dir.path()).filePath(QStringLiteral("cache")); + project_->SetCustomCachePath(cache_path); + + olive::NodeValueRow row; + row.insert(olive::Footage::kFilenameInput, + olive::NodeValue(olive::NodeValue::kFile, media)); + + // A divider > 1 routes through the target-resolution divider calculation + const olive::NodeGlobals globals = + MakeGlobals(olive::LoopMode::kLoopModeLoop, 2); + + olive::NodeValueTable table; + footage->Value(row, globals, &table); + + // Length, two texture jobs, and one sample job + EXPECT_EQ(table.Count(), 4); + + const olive::NodeValue length = + table.Get(olive::NodeValue::kRational, QStringLiteral("length")); + EXPECT_EQ(length.toRational(), olive::rational(2)); + + // A stream without a colorspace falls back to the project default + const olive::TexturePtr tex0 = + table.Get(olive::NodeValue::kTexture, QStringLiteral("v:0")) + .toTexture(); + ASSERT_NE(tex0, nullptr); + EXPECT_EQ(tex0->params().colorspace(), QStringLiteral("TestInputSpace")); + // min(calculated divider for 32x32 from 1920x1080, requested 2) + EXPECT_EQ(tex0->params().divider(), 2); + + // An explicit colorspace survives + const olive::TexturePtr tex1 = + table.Get(olive::NodeValue::kTexture, QStringLiteral("v:1")) + .toTexture(); + ASSERT_NE(tex1, nullptr); + EXPECT_EQ(tex1->params().colorspace(), QStringLiteral("ExplicitSpace")); + + const olive::NodeValue samples = + table.Get(olive::NodeValue::kSamples, QStringLiteral("a:0")); + ASSERT_EQ(samples.type(), olive::NodeValue::kSamples); + const olive::FootageJob audio_job = + samples.data().value(); + EXPECT_EQ(audio_job.filename(), media); + EXPECT_EQ(audio_job.decoder(), QStringLiteral("fakedecoder")); + EXPECT_EQ(audio_job.type(), olive::Track::kAudio); + EXPECT_EQ(audio_job.audio_params().sample_rate(), 48000); + EXPECT_EQ(audio_job.length(), olive::rational(2)); + EXPECT_EQ(audio_job.loop_mode(), olive::LoopMode::kLoopModeLoop); + EXPECT_EQ(audio_job.time(), globals.time()); + EXPECT_EQ(audio_job.cache_path(), cache_path); + + // With a divider of 1, everything renders at full resolution + olive::NodeValueTable full_res; + footage->Value(row, MakeGlobals(), &full_res); + const olive::TexturePtr full_res_tex = + full_res.Get(olive::NodeValue::kTexture, QStringLiteral("v:0")) + .toTexture(); + ASSERT_NE(full_res_tex, nullptr); + EXPECT_EQ(full_res_tex->params().divider(), 1); +} + +TEST_F(FootageTest, ValueAttachesReadyProxyToJobs) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const ScopedEnvVar xdg( + "XDG_CACHE_HOME", + QDir(dir.path()).filePath(QStringLiteral("xdg")).toUtf8()); + + const QString media = CreateFakeMediaFile(dir, QStringLiteral("fake.mkv")); + ASSERT_FALSE(media.isEmpty()); + + TestableFootage *footage = ProbeFootageFromCache( + project_.get(), media, MakeStandardDescription()); + ASSERT_NE(footage, nullptr); + footage->VerifyLength(); + + // A ready proxy is simply an existing proxy file with no .working + // sibling; the .a1. marker declares that it contains audio + const QString proxy = QDir(dir.path()) + .filePath(QStringLiteral( + "proxy-0.1920x1080.v1.a1.mp4")); + { + QFile proxy_file(proxy); + ASSERT_TRUE(proxy_file.open(QFile::WriteOnly)); + } + footage->SetProxy(proxy, olive::ProxyManager::kProxyReady, 0, 1, true); + + olive::NodeValueRow row; + row.insert(olive::Footage::kFilenameInput, + olive::NodeValue(olive::NodeValue::kFile, media)); + + olive::NodeValueTable table; + footage->Value(row, MakeGlobals(), &table); + + // The proxied video stream (real index 0) gets the proxy at stream 0 + const olive::TexturePtr tex0 = + table.Get(olive::NodeValue::kTexture, QStringLiteral("v:0")) + .toTexture(); + ASSERT_NE(tex0, nullptr); + const auto *video0_job = + static_cast(tex0->job()); + ASSERT_NE(video0_job, nullptr); + EXPECT_TRUE(video0_job->has_proxy()); + EXPECT_EQ(video0_job->proxy_filename(), proxy); + EXPECT_EQ(video0_job->proxy_decoder(), QStringLiteral("ffmpeg")); + EXPECT_EQ(video0_job->proxy_stream_index(), 0); + + // Other video streams are unaffected + const olive::TexturePtr tex1 = + table.Get(olive::NodeValue::kTexture, QStringLiteral("v:1")) + .toTexture(); + ASSERT_NE(tex1, nullptr); + const auto *video1_job = + static_cast(tex1->job()); + ASSERT_NE(video1_job, nullptr); + EXPECT_FALSE(video1_job->has_proxy()); + + // The first audio stream follows the video stream inside the proxy file + const olive::FootageJob audio_job = + table.Get(olive::NodeValue::kSamples, QStringLiteral("a:0")) + .data() + .value(); + EXPECT_TRUE(audio_job.has_proxy()); + EXPECT_EQ(audio_job.proxy_filename(), proxy); + EXPECT_EQ(audio_job.proxy_stream_index(), 1); + + // Disabling the proxy detaches it from subsequent jobs + footage->set_proxy_enabled(false); + olive::NodeValueTable no_proxy; + footage->Value(row, MakeGlobals(), &no_proxy); + const olive::TexturePtr no_proxy_tex = + no_proxy.Get(olive::NodeValue::kTexture, QStringLiteral("v:0")) + .toTexture(); + ASSERT_NE(no_proxy_tex, nullptr); + const auto *no_proxy_job = + static_cast(no_proxy_tex->job()); + ASSERT_NE(no_proxy_job, nullptr); + EXPECT_FALSE(no_proxy_job->has_proxy()); +} + +TEST_F(FootageTest, SaveCustomPersistsSourceStartTime) +{ + olive::Footage footage; + footage.set_timestamp(7); + footage.SetSourceStartTime(olive::rational(3600), QStringLiteral("manual")); + + QString xml; + QXmlStreamWriter writer(&xml); + writer.writeStartDocument(); + writer.writeStartElement(QStringLiteral("custom")); + footage.SaveCustom(&writer); + writer.writeEndElement(); + writer.writeEndDocument(); + + EXPECT_TRUE(xml.contains(QStringLiteral("sourcestarttime"))); + EXPECT_TRUE(xml.contains(QStringLiteral("source=\"manual\""))); + EXPECT_TRUE(xml.contains(QStringLiteral("3600/1"))); + + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("custom")); + + olive::Footage loaded; + ASSERT_TRUE(loaded.LoadCustom(&reader, nullptr)); + ASSERT_TRUE(loaded.HasSourceStartTime()); + EXPECT_EQ(loaded.source_start_time(), olive::rational(3600)); + EXPECT_EQ(loaded.source_start_time_source(), QStringLiteral("manual")); + EXPECT_EQ(loaded.timestamp(), 7); +} diff --git a/tests/gtest/node_inputimmediate_test.cpp b/tests/gtest/node_inputimmediate_test.cpp new file mode 100644 index 000000000..60973bbf6 --- /dev/null +++ b/tests/gtest/node_inputimmediate_test.cpp @@ -0,0 +1,733 @@ +#include + +#include + +#include +#include +#include +#include + +#include "node/color/colormanager/colormanager.h" +#include "node/generator/matrix/matrix.h" +#include "node/generator/solid/solid.h" +#include "node/inputimmediate.h" +#include "node/keyframe.h" +#include "node/math/math/math.h" +#include "node/node.h" +#include "node/project.h" +#include "node/time/timeoffset/timeoffsetnode.h" +#include "olive/core/util/bezier.h" +#include "undo/undocommand.h" + +namespace +{ + +// NodeInputImmediate is not a QObject, so keyframes inserted directly into it +// (rather than through parenting to a Node) must be removed and deleted by +// hand to avoid leaks. +void ClearImmediate(olive::NodeInputImmediate *imm) +{ + for (int i = 0; i < imm->keyframe_tracks().size(); i++) { + const QVector keys = imm->keyframe_tracks().at(i); + for (olive::NodeKeyframe *key : keys) { + imm->remove_keyframe(key); + delete key; + } + } +} + +olive::NodeKeyframe *MakeKey(const olive::rational &time, const QVariant &value, + int track, + olive::NodeKeyframe::Type type = + olive::NodeKeyframe::kLinear) +{ + return new olive::NodeKeyframe(time, value, type, track, -1, + QStringLiteral("test_in")); +} + +} // namespace + +TEST(NodeInputImmediate, StandardValueRoundTrip) +{ + olive::NodeInputImmediate imm(olive::NodeValue::kFloat, { 1.5 }); + + EXPECT_FALSE(imm.is_keyframing()); + EXPECT_TRUE(imm.is_using_standard_value(0)); + + // The default value seeds the standard value + ASSERT_EQ(imm.get_split_standard_value().size(), 1); + EXPECT_DOUBLE_EQ(imm.get_split_standard_value().at(0).toDouble(), 1.5); + EXPECT_DOUBLE_EQ(imm.get_split_standard_value_on_track(0).toDouble(), 1.5); + + imm.set_split_standard_value({ 2.5 }); + EXPECT_DOUBLE_EQ(imm.get_split_standard_value().at(0).toDouble(), 2.5); + + imm.set_standard_value_on_track(3.5, 0); + EXPECT_DOUBLE_EQ(imm.get_split_standard_value_on_track(0).toDouble(), 3.5); +} + +TEST(NodeInputImmediate, SetSplitStandardValueCopiesOnlyOverlappingTracks) +{ + olive::NodeInputImmediate imm(olive::NodeValue::kVec2, { 0.0, 0.0 }); + ASSERT_EQ(imm.get_split_standard_value().size(), 2); + + // A shorter split only overwrites the tracks it covers + imm.set_split_standard_value({ 5.0 }); + EXPECT_DOUBLE_EQ(imm.get_split_standard_value_on_track(0).toDouble(), 5.0); + EXPECT_DOUBLE_EQ(imm.get_split_standard_value_on_track(1).toDouble(), 0.0); + + // A longer split is clamped to the existing track count + imm.set_split_standard_value({ 1.0, 2.0, 3.0 }); + ASSERT_EQ(imm.get_split_standard_value().size(), 2); + EXPECT_DOUBLE_EQ(imm.get_split_standard_value_on_track(0).toDouble(), 1.0); + EXPECT_DOUBLE_EQ(imm.get_split_standard_value_on_track(1).toDouble(), 2.0); +} + +TEST(NodeInputImmediate, SetDataTypeResizesTracksAndReappliesDefault) +{ + olive::NodeInputImmediate imm(olive::NodeValue::kFloat, { 7.0 }); + ASSERT_EQ(imm.keyframe_tracks().size(), 1); + ASSERT_EQ(imm.get_split_standard_value().size(), 1); + + // Growing to a four-track type keeps the default on the first track and + // leaves the new tracks null, since the default split only has one entry + imm.set_data_type(olive::NodeValue::kVec4); + EXPECT_EQ(imm.keyframe_tracks().size(), 4); + ASSERT_EQ(imm.get_split_standard_value().size(), 4); + EXPECT_DOUBLE_EQ(imm.get_split_standard_value_on_track(0).toDouble(), 7.0); + EXPECT_TRUE(imm.get_split_standard_value_on_track(1).isNull()); + EXPECT_TRUE(imm.get_split_standard_value_on_track(2).isNull()); + EXPECT_TRUE(imm.get_split_standard_value_on_track(3).isNull()); + + imm.set_data_type(olive::NodeValue::kFloat); + EXPECT_EQ(imm.keyframe_tracks().size(), 1); + ASSERT_EQ(imm.get_split_standard_value().size(), 1); + EXPECT_DOUBLE_EQ(imm.get_split_standard_value_on_track(0).toDouble(), 7.0); +} + +TEST(NodeInputImmediate, KeyframeLookupRequiresKeyframingEnabled) +{ + olive::NodeInputImmediate imm(olive::NodeValue::kFloat, { 0.0 }); + + olive::NodeKeyframe *key = MakeKey(olive::rational(2), 1.0, 0); + imm.insert_keyframe(key); + + // Without keyframing enabled the track reports that it uses the standard + // value and all keyframe lookups come back empty + EXPECT_TRUE(imm.is_using_standard_value(0)); + EXPECT_FALSE(imm.has_keyframe_at_time(olive::rational(2))); + EXPECT_EQ(imm.get_keyframe_at_time_on_track(olive::rational(2), 0), + nullptr); + EXPECT_TRUE(imm.get_keyframe_at_time(olive::rational(2)).isEmpty()); + EXPECT_EQ(imm.get_closest_keyframe_to_time_on_track(olive::rational(2), 0), + nullptr); + + imm.set_is_keyframing(true); + EXPECT_FALSE(imm.is_using_standard_value(0)); + EXPECT_TRUE(imm.has_keyframe_at_time(olive::rational(2))); + EXPECT_EQ(imm.get_keyframe_at_time_on_track(olive::rational(2), 0), key); + ASSERT_EQ(imm.get_keyframe_at_time(olive::rational(2)).size(), 1); + EXPECT_EQ(imm.get_keyframe_at_time(olive::rational(2)).first(), key); + EXPECT_EQ(imm.get_closest_keyframe_to_time_on_track(olive::rational(2), 0), + key); + EXPECT_FALSE(imm.has_keyframe_at_time(olive::rational(3))); + + ClearImmediate(&imm); +} + +TEST(NodeInputImmediate, InsertKeyframeSortsByTimeAndLinksSiblings) +{ + olive::NodeInputImmediate imm(olive::NodeValue::kFloat, { 0.0 }); + + // Insert out of order; the track must stay sorted by time + olive::NodeKeyframe *key_late = MakeKey(olive::rational(10), 10.0, 0); + olive::NodeKeyframe *key_early = MakeKey(olive::rational(0), 0.0, 0); + olive::NodeKeyframe *key_mid = MakeKey(olive::rational(5), 5.0, 0); + imm.insert_keyframe(key_late); + imm.insert_keyframe(key_early); + imm.insert_keyframe(key_mid); + + const olive::NodeKeyframeTrack &track = imm.keyframe_tracks().at(0); + ASSERT_EQ(track.size(), 3); + EXPECT_EQ(track.at(0), key_early); + EXPECT_EQ(track.at(1), key_mid); + EXPECT_EQ(track.at(2), key_late); + + // Sibling links follow the sorted order + EXPECT_EQ(key_early->previous(), nullptr); + EXPECT_EQ(key_early->next(), key_mid); + EXPECT_EQ(key_mid->previous(), key_early); + EXPECT_EQ(key_mid->next(), key_late); + EXPECT_EQ(key_late->previous(), key_mid); + EXPECT_EQ(key_late->next(), nullptr); + + EXPECT_EQ(imm.get_earliest_keyframe(), key_early); + EXPECT_EQ(imm.get_latest_keyframe(), key_late); + + // Removing the middle keyframe re-links its siblings + imm.remove_keyframe(key_mid); + EXPECT_EQ(key_early->next(), key_late); + EXPECT_EQ(key_late->previous(), key_early); + EXPECT_EQ(key_mid->previous(), nullptr); + EXPECT_EQ(key_mid->next(), nullptr); + ASSERT_EQ(track.size(), 2); + delete key_mid; + + ClearImmediate(&imm); +} + +TEST(NodeInputImmediate, ClosestKeyframeToTimeOnTrackClampsAndPicksNearest) +{ + olive::NodeInputImmediate imm(olive::NodeValue::kVec2, { 0.0, 0.0 }); + imm.set_is_keyframing(true); + + olive::NodeKeyframe *key_a = MakeKey(olive::rational(0), 0.0, 0); + olive::NodeKeyframe *key_b = MakeKey(olive::rational(10), 10.0, 0); + imm.insert_keyframe(key_a); + imm.insert_keyframe(key_b); + + // Outside the keyed range the closest keyframe clamps to the ends + EXPECT_EQ(imm.get_closest_keyframe_to_time_on_track(olive::rational(-3), 0), + key_a); + EXPECT_EQ(imm.get_closest_keyframe_to_time_on_track(olive::rational(20), 0), + key_b); + + // Between the keys the nearer one wins + EXPECT_EQ(imm.get_closest_keyframe_to_time_on_track(olive::rational(3), 0), + key_a); + EXPECT_EQ(imm.get_closest_keyframe_to_time_on_track(olive::rational(7), 0), + key_b); + + // Exactly halfway the earlier keyframe wins the tie + EXPECT_EQ(imm.get_closest_keyframe_to_time_on_track(olive::rational(5), 0), + key_a); + + // A track with no keyframes still counts as using the standard value + EXPECT_EQ(imm.get_closest_keyframe_to_time_on_track(olive::rational(5), 1), + nullptr); + + ClearImmediate(&imm); +} + +TEST(NodeInputImmediate, ClosestKeyframeBeforeAfterSpansAllTracks) +{ + olive::NodeInputImmediate imm(olive::NodeValue::kVec2, { 0.0, 0.0 }); + imm.set_is_keyframing(true); + + olive::NodeKeyframe *key_t0 = MakeKey(olive::rational(0), 0.0, 0); + olive::NodeKeyframe *key_t10 = MakeKey(olive::rational(10), 10.0, 0); + olive::NodeKeyframe *key_t4_track1 = MakeKey(olive::rational(4), 4.0, 1); + imm.insert_keyframe(key_t0); + imm.insert_keyframe(key_t10); + imm.insert_keyframe(key_t4_track1); + + // The closest keyframe before 5 is the one at 4 on the other track + EXPECT_EQ(imm.get_closest_keyframe_before_time(olive::rational(5)), + key_t4_track1); + EXPECT_EQ(imm.get_closest_keyframe_after_time(olive::rational(5)), key_t10); + + // Strictly before/after: nothing exists outside the keyed range + EXPECT_EQ(imm.get_closest_keyframe_before_time(olive::rational(0)), + nullptr); + EXPECT_EQ(imm.get_closest_keyframe_after_time(olive::rational(10)), + nullptr); + + EXPECT_EQ(imm.get_closest_keyframe_before_time(olive::rational(4)), key_t0); + EXPECT_EQ(imm.get_closest_keyframe_after_time(olive::rational(4)), key_t10); + + ClearImmediate(&imm); +} + +TEST(NodeInputImmediate, BestKeyframeTypeForTimeFollowsClosestKey) +{ + olive::NodeInputImmediate imm(olive::NodeValue::kFloat, { 0.0 }); + + // With no keyframes there is no reference, so the default type is used + EXPECT_EQ(int(imm.get_best_keyframe_type_for_time(olive::rational(5), 0)), + int(olive::NodeKeyframe::kDefaultType)); + + olive::NodeKeyframe *key_hold = + MakeKey(olive::rational(0), 0.0, 0, olive::NodeKeyframe::kHold); + olive::NodeKeyframe *key_linear = MakeKey(olive::rational(10), 10.0, 0); + imm.insert_keyframe(key_hold); + imm.insert_keyframe(key_linear); + imm.set_is_keyframing(true); + + EXPECT_EQ(int(imm.get_best_keyframe_type_for_time(olive::rational(2), 0)), + int(olive::NodeKeyframe::kHold)); + EXPECT_EQ(int(imm.get_best_keyframe_type_for_time(olive::rational(8), 0)), + int(olive::NodeKeyframe::kLinear)); + + ClearImmediate(&imm); +} + +TEST(NodeInputImmediate, GetKeyframeAtTimeAggregatesAcrossTracks) +{ + olive::NodeInputImmediate imm(olive::NodeValue::kVec2, { 0.0, 0.0 }); + imm.set_is_keyframing(true); + + olive::NodeKeyframe *key_track0 = MakeKey(olive::rational(3), 1.0, 0); + olive::NodeKeyframe *key_track1 = MakeKey(olive::rational(3), 2.0, 1); + olive::NodeKeyframe *key_later = MakeKey(olive::rational(7), 3.0, 0); + imm.insert_keyframe(key_track0); + imm.insert_keyframe(key_track1); + imm.insert_keyframe(key_later); + + // Both tracks have a keyframe at t=3 + QVector at_three = + imm.get_keyframe_at_time(olive::rational(3)); + ASSERT_EQ(at_three.size(), 2); + EXPECT_TRUE(at_three.contains(key_track0)); + EXPECT_TRUE(at_three.contains(key_track1)); + + // Only track 0 has one at t=7, and there is nothing at t=99 + EXPECT_EQ(imm.get_keyframe_at_time(olive::rational(7)).size(), 1); + EXPECT_TRUE(imm.get_keyframe_at_time(olive::rational(99)).isEmpty()); + + ClearImmediate(&imm); +} + +class NodeInputImmediateNodeTest : 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; + } + + olive::NodeKeyframe *AddKey(olive::Node *node, const QString &input, + const olive::rational &time, + const QVariant &value, int track, + olive::NodeKeyframe::Type type = + olive::NodeKeyframe::kLinear) + { + auto *key = new olive::NodeKeyframe(time, value, type, track, -1, input); + key->setParent(node); + return key; + } + + std::unique_ptr project_; +}; + +TEST_F(NodeInputImmediateNodeTest, SetValueAtTimeCreatesAndUpdatesKeyframes) +{ + auto *node = AddNode(); + const olive::NodeInput input(node, olive::MathNode::kParamAIn); + node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true); + + // A new keyframe is inserted where none exists yet + olive::MultiUndoCommand cmd; + olive::Node::SetValueAtTime(input, olive::rational(5), 42.0, 0, &cmd, true); + EXPECT_EQ(cmd.child_count(), 1); + cmd.redo_now(); + + olive::NodeKeyframe *key = node->GetKeyframeAtTimeOnTrack( + olive::MathNode::kParamAIn, olive::rational(5), 0); + ASSERT_NE(key, nullptr); + EXPECT_DOUBLE_EQ(key->value().toDouble(), 42.0); + EXPECT_DOUBLE_EQ(node->GetValueAtTime(olive::MathNode::kParamAIn, + olive::rational(5)) + .toDouble(), + 42.0); + + // Setting the same time again updates the existing keyframe in place + olive::MultiUndoCommand update_cmd; + olive::Node::SetValueAtTime(input, olive::rational(5), 43.0, 0, + &update_cmd, true); + EXPECT_EQ(update_cmd.child_count(), 1); + update_cmd.redo_now(); + + const QVector &tracks = + node->GetKeyframeTracks(olive::MathNode::kParamAIn, -1); + ASSERT_EQ(tracks.at(0).size(), 1); + EXPECT_EQ(tracks.at(0).first(), key); + EXPECT_DOUBLE_EQ(key->value().toDouble(), 43.0); +} + +TEST_F(NodeInputImmediateNodeTest, SetValueAtTimeWithoutKeyframingSetsStandardValue) +{ + auto *node = AddNode(); + const olive::NodeInput input(node, olive::MathNode::kParamAIn); + + olive::MultiUndoCommand cmd; + olive::Node::SetValueAtTime(input, olive::rational(5), 9.0, 0, &cmd, true); + EXPECT_EQ(cmd.child_count(), 1); + cmd.redo_now(); + + EXPECT_DOUBLE_EQ( + node->GetStandardValue(olive::MathNode::kParamAIn).toDouble(), 9.0); + EXPECT_TRUE(node->GetKeyframeTracks(olive::MathNode::kParamAIn, -1) + .at(0) + .isEmpty()); +} + +TEST_F(NodeInputImmediateNodeTest, SetValueAtTimeInsertsOnAllTracksOnlyWhenAsked) +{ + auto *solid = AddNode(); + solid->SetInputIsKeyframing(olive::SolidGenerator::kColorInput, true); + const olive::NodeInput input(solid, olive::SolidGenerator::kColorInput); + + // With insert_on_all_tracks_if_no_key set, keyframes are created on every + // track; sibling tracks capture the value they currently evaluate to (the + // standard value red = (1, 0, 0, 1) here) + olive::MultiUndoCommand cmd; + olive::Node::SetValueAtTime(input, olive::rational(5), 0.5, 2, &cmd, true); + EXPECT_EQ(cmd.child_count(), 4); + cmd.redo_now(); + + const QVector &tracks = + solid->GetKeyframeTracks(olive::SolidGenerator::kColorInput, -1); + ASSERT_EQ(tracks.size(), 4); + for (int i = 0; i < tracks.size(); i++) { + ASSERT_EQ(tracks.at(i).size(), 1); + EXPECT_EQ(tracks.at(i).first()->time(), olive::rational(5)); + } + EXPECT_DOUBLE_EQ(tracks.at(0).first()->value().toDouble(), 1.0); + EXPECT_DOUBLE_EQ(tracks.at(1).first()->value().toDouble(), 0.0); + EXPECT_DOUBLE_EQ(tracks.at(2).first()->value().toDouble(), 0.5); + EXPECT_DOUBLE_EQ(tracks.at(3).first()->value().toDouble(), 1.0); + + const olive::Color c = + solid->GetValueAtTime(olive::SolidGenerator::kColorInput, + olive::rational(5)) + .value(); + EXPECT_FLOAT_EQ(c.red(), 1.0f); + EXPECT_FLOAT_EQ(c.green(), 0.0f); + EXPECT_FLOAT_EQ(c.blue(), 0.5f); + EXPECT_FLOAT_EQ(c.alpha(), 1.0f); + + // Without the flag only the requested track receives a keyframe + auto *single = AddNode(); + single->SetInputIsKeyframing(olive::SolidGenerator::kColorInput, true); + const olive::NodeInput single_input(single, + olive::SolidGenerator::kColorInput); + + olive::MultiUndoCommand single_cmd; + olive::Node::SetValueAtTime(single_input, olive::rational(5), 0.5, 2, + &single_cmd, false); + EXPECT_EQ(single_cmd.child_count(), 1); + single_cmd.redo_now(); + + const QVector &single_tracks = + single->GetKeyframeTracks(olive::SolidGenerator::kColorInput, -1); + ASSERT_EQ(single_tracks.size(), 4); + EXPECT_TRUE(single_tracks.at(0).isEmpty()); + EXPECT_TRUE(single_tracks.at(1).isEmpty()); + ASSERT_EQ(single_tracks.at(2).size(), 1); + EXPECT_DOUBLE_EQ(single_tracks.at(2).first()->value().toDouble(), 0.5); + EXPECT_TRUE(single_tracks.at(3).isEmpty()); +} + +TEST_F(NodeInputImmediateNodeTest, GetValueAtTimeInterpolatesColorTracks) +{ + auto *solid = AddNode(); + solid->SetInputIsKeyframing(olive::SolidGenerator::kColorInput, true); + + // Black to white over ten seconds on all four tracks + for (int track = 0; track < 4; track++) { + AddKey(solid, olive::SolidGenerator::kColorInput, olive::rational(0), + 0.0, track); + AddKey(solid, olive::SolidGenerator::kColorInput, olive::rational(10), + 1.0, track); + } + + const olive::Color mid = + solid->GetValueAtTime(olive::SolidGenerator::kColorInput, + olive::rational(5)) + .value(); + EXPECT_FLOAT_EQ(mid.red(), 0.5f); + EXPECT_FLOAT_EQ(mid.green(), 0.5f); + EXPECT_FLOAT_EQ(mid.blue(), 0.5f); + EXPECT_FLOAT_EQ(mid.alpha(), 0.5f); + + const olive::SplitValue split = solid->GetSplitValueAtTime( + olive::SolidGenerator::kColorInput, olive::rational(5)); + ASSERT_EQ(split.size(), 4); + for (int i = 0; i < split.size(); i++) { + EXPECT_DOUBLE_EQ(split.at(i).toDouble(), 0.5); + } + + // Outside the keyed range the end values hold + const olive::Color before = + solid->GetValueAtTime(olive::SolidGenerator::kColorInput, + olive::rational(-2)) + .value(); + EXPECT_FLOAT_EQ(before.red(), 0.0f); + const olive::Color after = + solid->GetValueAtTime(olive::SolidGenerator::kColorInput, + olive::rational(20)) + .value(); + EXPECT_FLOAT_EQ(after.alpha(), 1.0f); +} + +TEST_F(NodeInputImmediateNodeTest, GetValueAtTimeInterpolatesVec2Tracks) +{ + auto *matrix = AddNode(); + matrix->SetInputIsKeyframing(olive::MatrixGenerator::kPositionInput, true); + + AddKey(matrix, olive::MatrixGenerator::kPositionInput, olive::rational(0), + 0.0, 0); + AddKey(matrix, olive::MatrixGenerator::kPositionInput, olive::rational(10), + 10.0, 0); + AddKey(matrix, olive::MatrixGenerator::kPositionInput, olive::rational(0), + 10.0, 1); + AddKey(matrix, olive::MatrixGenerator::kPositionInput, olive::rational(10), + 20.0, 1); + + const QVector2D mid = + matrix->GetValueAtTime(olive::MatrixGenerator::kPositionInput, + olive::rational(5)) + .value(); + EXPECT_FLOAT_EQ(mid.x(), 5.0f); + EXPECT_FLOAT_EQ(mid.y(), 15.0f); + + // Each track clamps to its own end keyframes + const QVector2D clamped_low = + matrix->GetValueAtTime(olive::MatrixGenerator::kPositionInput, + olive::rational(-5)) + .value(); + EXPECT_FLOAT_EQ(clamped_low.x(), 0.0f); + EXPECT_FLOAT_EQ(clamped_low.y(), 10.0f); + const QVector2D clamped_high = + matrix->GetValueAtTime(olive::MatrixGenerator::kPositionInput, + olive::rational(15)) + .value(); + EXPECT_FLOAT_EQ(clamped_high.x(), 10.0f); + EXPECT_FLOAT_EQ(clamped_high.y(), 20.0f); +} + +TEST_F(NodeInputImmediateNodeTest, GetValueAtTimeInterpolatesRationalAsRational) +{ + auto *offset = AddNode(); + offset->SetInputIsKeyframing(olive::TimeOffsetNode::kTimeInput, true); + + AddKey(offset, olive::TimeOffsetNode::kTimeInput, olive::rational(0), + QVariant::fromValue(olive::rational(0)), 0); + AddKey(offset, olive::TimeOffsetNode::kTimeInput, olive::rational(10), + QVariant::fromValue(olive::rational(10)), 0); + + // The interpolated value is converted back into a rational + const QVariant mid = offset->GetValueAtTime( + olive::TimeOffsetNode::kTimeInput, olive::rational(5)); + EXPECT_EQ(mid.value(), olive::rational(5)); + + const QVariant one_tenth_in = offset->GetValueAtTime( + olive::TimeOffsetNode::kTimeInput, olive::rational(1)); + EXPECT_DOUBLE_EQ(one_tenth_in.value().toDouble(), 1.0); +} + +TEST_F(NodeInputImmediateNodeTest, GetValueAtTimeHoldsRationalUntilNextKey) +{ + auto *offset = AddNode(); + offset->SetInputIsKeyframing(olive::TimeOffsetNode::kTimeInput, true); + + AddKey(offset, olive::TimeOffsetNode::kTimeInput, olive::rational(0), + QVariant::fromValue(olive::rational(2, 3)), 0, + olive::NodeKeyframe::kHold); + AddKey(offset, olive::TimeOffsetNode::kTimeInput, olive::rational(10), + QVariant::fromValue(olive::rational(4, 3)), 0); + + // A hold keyframe keeps its exact rational value until the next key + const QVariant held = offset->GetValueAtTime( + olive::TimeOffsetNode::kTimeInput, olive::rational(9)); + EXPECT_EQ(held.value(), olive::rational(2, 3)); + + const QVariant at_next = offset->GetValueAtTime( + olive::TimeOffsetNode::kTimeInput, olive::rational(10)); + EXPECT_EQ(at_next.value(), olive::rational(4, 3)); +} + +TEST_F(NodeInputImmediateNodeTest, GetValueAtTimeBezierHandlesBendCurve) +{ + auto *node = AddNode(); + node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true); + + olive::NodeKeyframe *before = + AddKey(node, olive::MathNode::kParamAIn, olive::rational(0), 0.0, 0, + olive::NodeKeyframe::kBezier); + olive::NodeKeyframe *after = + AddKey(node, olive::MathNode::kParamAIn, olive::rational(10), 10.0, 0, + olive::NodeKeyframe::kBezier); + + // Ease-in shape: the outgoing handle pulls the start of the curve flat + before->set_bezier_control_out(QPointF(2.5, 0.0)); + after->set_bezier_control_in(QPointF(0.0, 0.0)); + + const double interpolated = node->GetValueAtTime( + olive::MathNode::kParamAIn, olive::rational(5)).toDouble(); + const double expected = olive::core::Bezier::CubicXtoY( + 5.0, Imath::V2d(0.0, 0.0), Imath::V2d(2.5, 0.0), Imath::V2d(10.0, 10.0), + Imath::V2d(10.0, 10.0)); + EXPECT_DOUBLE_EQ(interpolated, expected); + + // The ease-in handle keeps the midpoint below the linear value of 5.0 + EXPECT_LT(interpolated, 5.0); +} + +TEST_F(NodeInputImmediateNodeTest, GetValueAtTimeQuadraticBezierWithOneHandle) +{ + auto *node = AddNode(); + node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true); + + // Bezier into linear uses a quadratic curve with a single control point + olive::NodeKeyframe *before = + AddKey(node, olive::MathNode::kParamAIn, olive::rational(0), 0.0, 0, + olive::NodeKeyframe::kBezier); + AddKey(node, olive::MathNode::kParamAIn, olive::rational(10), 10.0, 0, + olive::NodeKeyframe::kLinear); + before->set_bezier_control_out(QPointF(2.5, 0.0)); + + const double interpolated = node->GetValueAtTime( + olive::MathNode::kParamAIn, olive::rational(5)).toDouble(); + const double expected = olive::core::Bezier::QuadraticXtoY( + 5.0, Imath::V2d(0.0, 0.0), Imath::V2d(2.5, 0.0), + Imath::V2d(10.0, 10.0)); + EXPECT_DOUBLE_EQ(interpolated, expected); + EXPECT_LT(interpolated, 5.0); +} + +TEST_F(NodeInputImmediateNodeTest, IsUsingStandardValueTransitions) +{ + auto *node = AddNode(); + node->SetStandardValue(olive::MathNode::kParamAIn, 3.0); + + // Static input: standard value is always in use + EXPECT_TRUE(node->IsUsingStandardValue(olive::MathNode::kParamAIn, 0)); + + // Keyframing enabled but no keyframes yet: still the standard value + node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true); + EXPECT_TRUE(node->IsUsingStandardValue(olive::MathNode::kParamAIn, 0)); + + // With a keyframe present the track switches to the keyed value + olive::NodeKeyframe *key = + AddKey(node, olive::MathNode::kParamAIn, olive::rational(5), 7.0, 0); + EXPECT_FALSE(node->IsUsingStandardValue(olive::MathNode::kParamAIn, 0)); + + // Disabling keyframing hides the keyframes again + node->SetInputIsKeyframing(olive::MathNode::kParamAIn, false); + EXPECT_TRUE(node->IsUsingStandardValue(olive::MathNode::kParamAIn, 0)); + EXPECT_DOUBLE_EQ(node->GetValueAtTime(olive::MathNode::kParamAIn, + olive::rational(5)) + .toDouble(), + 3.0); + + // Removing the last keyframe returns the track to the standard value + node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true); + EXPECT_FALSE(node->IsUsingStandardValue(olive::MathNode::kParamAIn, 0)); + key->setParent(nullptr); + delete key; + EXPECT_TRUE(node->IsUsingStandardValue(olive::MathNode::kParamAIn, 0)); + EXPECT_DOUBLE_EQ(node->GetValueAtTime(olive::MathNode::kParamAIn, + olive::rational(5)) + .toDouble(), + 3.0); +} + +TEST_F(NodeInputImmediateNodeTest, PartiallyKeyedTrackFallsBackToStandardValue) +{ + auto *matrix = AddNode(); + matrix->SetStandardValue(olive::MatrixGenerator::kPositionInput, + QVector2D(1.0f, 2.0f)); + matrix->SetInputIsKeyframing(olive::MatrixGenerator::kPositionInput, true); + + // Only the X track is keyed; the Y track keeps its standard value + AddKey(matrix, olive::MatrixGenerator::kPositionInput, olive::rational(0), + 0.0, 0); + AddKey(matrix, olive::MatrixGenerator::kPositionInput, olive::rational(10), + 10.0, 0); + + EXPECT_FALSE( + matrix->IsUsingStandardValue(olive::MatrixGenerator::kPositionInput, + 0)); + EXPECT_TRUE( + matrix->IsUsingStandardValue(olive::MatrixGenerator::kPositionInput, + 1)); + + const QVector2D value = + matrix->GetValueAtTime(olive::MatrixGenerator::kPositionInput, + olive::rational(5)) + .value(); + EXPECT_FLOAT_EQ(value.x(), 5.0f); + EXPECT_FLOAT_EQ(value.y(), 2.0f); +} + +TEST_F(NodeInputImmediateNodeTest, StandardValueCombinationAcrossTracks) +{ + auto *solid = AddNode(); + + // The declared default is opaque red + olive::Color initial = + solid->GetStandardValue(olive::SolidGenerator::kColorInput) + .value(); + EXPECT_FLOAT_EQ(initial.red(), 1.0f); + EXPECT_FLOAT_EQ(initial.green(), 0.0f); + EXPECT_FLOAT_EQ(initial.blue(), 0.0f); + EXPECT_FLOAT_EQ(initial.alpha(), 1.0f); + + // Setting a normal value splits it across the four tracks + solid->SetStandardValue( + olive::SolidGenerator::kColorInput, + QVariant::fromValue(olive::Color(0.25f, 0.5f, 0.75f, 1.0f))); + const olive::SplitValue split = + solid->GetSplitStandardValue(olive::SolidGenerator::kColorInput); + ASSERT_EQ(split.size(), 4); + EXPECT_DOUBLE_EQ(split.at(0).toDouble(), 0.25); + EXPECT_DOUBLE_EQ(split.at(1).toDouble(), 0.5); + EXPECT_DOUBLE_EQ(split.at(2).toDouble(), 0.75); + EXPECT_DOUBLE_EQ(split.at(3).toDouble(), 1.0); + EXPECT_DOUBLE_EQ(solid->GetSplitStandardValueOnTrack( + olive::SolidGenerator::kColorInput, 2) + .toDouble(), + 0.75); + + // A partial split only overwrites the leading tracks + solid->SetSplitStandardValue(olive::SolidGenerator::kColorInput, + { 0.1, 0.2 }); + const olive::Color combined = + solid->GetStandardValue(olive::SolidGenerator::kColorInput) + .value(); + EXPECT_FLOAT_EQ(combined.red(), 0.1f); + EXPECT_FLOAT_EQ(combined.green(), 0.2f); + EXPECT_FLOAT_EQ(combined.blue(), 0.75f); + EXPECT_FLOAT_EQ(combined.alpha(), 1.0f); +} + +TEST_F(NodeInputImmediateNodeTest, DeleteAllKeyframesReparentsOrDeletes) +{ + auto *node = AddNode(); + node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true); + + olive::NodeKeyframe *key_a = + AddKey(node, olive::MathNode::kParamAIn, olive::rational(0), 1.0, 0); + olive::NodeKeyframe *key_b = + AddKey(node, olive::MathNode::kParamAIn, olive::rational(1), 2.0, 0); + + olive::NodeInputImmediate *imm = + node->GetImmediate(olive::MathNode::kParamAIn, -1); + ASSERT_NE(imm, nullptr); + ASSERT_EQ(imm->keyframe_tracks().at(0).size(), 2); + + // With a parent the keyframes are handed over rather than deleted + QObject guard; + imm->delete_all_keyframes(&guard); + EXPECT_TRUE(imm->keyframe_tracks().at(0).isEmpty()); + EXPECT_EQ(guard.children().size(), 2); + EXPECT_TRUE(guard.children().contains(key_a)); + EXPECT_TRUE(guard.children().contains(key_b)); + + // Without a parent the keyframes are deleted outright + key_a->setParent(node); + key_b->setParent(node); + ASSERT_EQ(imm->keyframe_tracks().at(0).size(), 2); + imm->delete_all_keyframes(); + EXPECT_TRUE(imm->keyframe_tracks().at(0).isEmpty()); +} diff --git a/tests/gtest/project_factory_test.cpp b/tests/gtest/project_factory_test.cpp new file mode 100644 index 000000000..fe42c350f --- /dev/null +++ b/tests/gtest/project_factory_test.cpp @@ -0,0 +1,587 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "node/block/clip/clip.h" +#include "node/block/gap/gap.h" +#include "node/color/colormanager/colormanager.h" +#include "node/factory.h" +#include "node/generator/solid/solid.h" +#include "node/generator/text/textv3.h" +#include "node/group/group.h" +#include "node/math/math/math.h" +#include "node/output/track/track.h" +#include "node/output/viewer/viewer.h" +#include "node/project.h" +#include "node/project/folder/folder.h" +#include "node/project/footage/footage.h" +#include "node/project/sequence/sequence.h" +#include "node/serializeddata.h" +#include "core.h" +#include "render/diskmanager.h" +#include "widget/menu/menu.h" + +namespace +{ + +void CollectLeafActions(QMenu *menu, QList *leaves) +{ + for (QAction *action : menu->actions()) { + if (action->menu()) { + CollectLeafActions(action->menu(), leaves); + } else if (!action->isSeparator()) { + leaves->append(action); + } + } +} + + +// Project save/load and cache paths go through the DiskManager singleton, +// which itself touches Core +void EnsureAppSingletons() +{ + if (!olive::Core::instance()) { + new olive::Core(olive::Core::CoreParams()); // intentionally leaked + } + if (!olive::DiskManager::instance()) { + olive::DiskManager::CreateInstance(); + } +} + +} // namespace + +TEST(Project, FilenameNamePrettyAndSignals) +{ + olive::ColorManager::SetUpDefaultConfig(); + EnsureAppSingletons(); + olive::Project project; + + int name_changes = 0; + QObject::connect(&project, &olive::Project::NameChanged, + [&name_changes]() { ++name_changes; }); + + const QString filename = QStringLiteral("/tmp/some/dir/my_edit.ove"); + project.set_filename(filename); + EXPECT_EQ(project.filename(), filename); + EXPECT_EQ(project.name(), QStringLiteral("my_edit")); + EXPECT_EQ(project.pretty_filename(), filename); + EXPECT_FALSE(project.is_new()); + EXPECT_EQ(name_changes, 1); + + // Each set_filename() call emits, even for the same value + project.set_filename(filename); + EXPECT_EQ(name_changes, 2); + + project.SetSavedURL(QStringLiteral("/tmp/some/dir")); + EXPECT_EQ(project.GetSavedURL(), QStringLiteral("/tmp/some/dir")); + + // A filename alone does not mark the project modified + EXPECT_FALSE(project.is_modified()); +} + +TEST(Project, ModifiedAndAutoRecoverySignals) +{ + olive::ColorManager::SetUpDefaultConfig(); + EnsureAppSingletons(); + olive::Project project; + + QVector states; + QObject::connect(&project, &olive::Project::ModifiedChanged, + [&states](bool e) { states.append(e); }); + + project.set_modified(true); + EXPECT_TRUE(project.is_modified()); + EXPECT_FALSE(project.has_autorecovery_been_saved()); + + project.set_modified(false); + EXPECT_FALSE(project.is_modified()); + EXPECT_TRUE(project.has_autorecovery_been_saved()); + + ASSERT_EQ(states.size(), 2); + EXPECT_TRUE(states.at(0)); + EXPECT_FALSE(states.at(1)); + + // The auto-recovery flag can also be controlled directly + project.set_autorecovery_saved(false); + EXPECT_FALSE(project.has_autorecovery_been_saved()); + project.set_autorecovery_saved(true); + EXPECT_TRUE(project.has_autorecovery_been_saved()); +} + +TEST(Project, SettingsEmitSignalsAndColorSideEffects) +{ + olive::ColorManager::SetUpDefaultConfig(); + EnsureAppSingletons(); + olive::Project project; + + QVector changed_keys; + QObject::connect(&project, &olive::Project::SettingChanged, + [&changed_keys](const QString &key, const QString &) { + changed_keys.append(key); + }); + + QString reference_space; + QObject::connect(project.color_manager(), + &olive::ColorManager::ReferenceSpaceChanged, + [&reference_space](const QString &s) { + reference_space = s; + }); + QString default_input; + QObject::connect(project.color_manager(), + &olive::ColorManager::DefaultInputChanged, + [&default_input](const QString &s) { default_input = s; }); + + project.SetSetting(QStringLiteral("plain"), QStringLiteral("value")); + EXPECT_EQ(project.GetSetting(QStringLiteral("plain")), + QStringLiteral("value")); + + project.SetColorReferenceSpace(QStringLiteral("ACES - ACEScg")); + EXPECT_EQ(project.GetColorReferenceSpace(), + QStringLiteral("ACES - ACEScg")); + EXPECT_EQ(reference_space, QStringLiteral("ACES - ACEScg")); + + project.SetDefaultInputColorSpace(QStringLiteral("Linear Rec.709")); + EXPECT_EQ(project.GetDefaultInputColorSpace(), + QStringLiteral("Linear Rec.709")); + EXPECT_EQ(default_input, QStringLiteral("Linear Rec.709")); + + // A nonexistent config filename is stored; the failed OCIO load inside + // ColorManager::UpdateConfigFromFilename() is swallowed + project.SetColorConfigFilename(QStringLiteral("/nonexistent/config.ocio")); + EXPECT_EQ(project.GetColorConfigFilename(), + QStringLiteral("/nonexistent/config.ocio")); + + EXPECT_TRUE(changed_keys.contains(QStringLiteral("plain"))); + EXPECT_TRUE(changed_keys.contains(olive::Project::kColorReferenceSpace)); + EXPECT_TRUE( + changed_keys.contains(olive::Project::kDefaultInputColorSpaceKey)); + EXPECT_TRUE(changed_keys.contains(olive::Project::kColorConfigFilename)); +} + +TEST(Project, CachePathModes) +{ + const bool created_disk_manager = + (olive::DiskManager::instance() == nullptr); + if (created_disk_manager) { + olive::DiskManager::CreateInstance(); + } + const QString default_path = + olive::DiskManager::instance()->GetDefaultCachePath(); + + olive::ColorManager::SetUpDefaultConfig(); + EnsureAppSingletons(); + + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString filename = + QDir(dir.path()).filePath(QStringLiteral("proj.ove")); + const QString alongside = + QDir(dir.path()).filePath(QStringLiteral("cache")); + + olive::Project project; + project.set_filename(filename); + + // Default mode always returns the application-wide cache path + project.SetCacheLocationSetting(olive::Project::kCacheUseDefaultLocation); + EXPECT_EQ(project.GetCacheLocationSetting(), + olive::Project::kCacheUseDefaultLocation); + EXPECT_EQ(project.cache_path(), default_path); + + // Alongside mode returns a "cache" directory next to the project file + project.SetCacheLocationSetting( + olive::Project::kCacheStoreAlongsideProject); + EXPECT_EQ(project.get_cache_alongside_project_path(), alongside); + EXPECT_EQ(project.cache_path(), alongside); + + // Without a filename there is no alongside location, so it falls back + olive::Project unsaved; + unsaved.SetCacheLocationSetting( + olive::Project::kCacheStoreAlongsideProject); + EXPECT_TRUE(unsaved.get_cache_alongside_project_path().isEmpty()); + EXPECT_EQ(unsaved.cache_path(), default_path); + + // A non-empty custom path is used verbatim; an empty one falls back to + // the default location (this branch used to be inverted) + olive::Project custom; + custom.SetCacheLocationSetting(olive::Project::kCacheCustomPath); + custom.SetCustomCachePath(QStringLiteral("/tmp/oak-custom-cache")); + EXPECT_EQ(custom.GetCustomCachePath(), + QStringLiteral("/tmp/oak-custom-cache")); + EXPECT_EQ(custom.cache_path(), QStringLiteral("/tmp/oak-custom-cache")); + + olive::Project custom_empty; + custom_empty.SetCacheLocationSetting(olive::Project::kCacheCustomPath); + EXPECT_EQ(custom_empty.cache_path(), default_path); + + if (created_disk_manager) { + olive::DiskManager::DestroyInstance(); + } +} + +TEST(Project, NodeManagementSignalsAndClear) +{ + olive::ColorManager::SetUpDefaultConfig(); + EnsureAppSingletons(); + olive::Project project; + project.Initialize(); + + // The root folder created by Initialize() is part of the graph + ASSERT_EQ(project.nodes().size(), 1); + EXPECT_EQ(project.nodes().first(), project.root()); + + int added = 0; + int removed = 0; + olive::Node *last_added = nullptr; + QObject::connect(&project, &olive::Project::NodeAdded, + [&added, &last_added](olive::Node *n) { + ++added; + last_added = n; + }); + QObject::connect(&project, &olive::Project::NodeRemoved, + [&removed](olive::Node *) { ++removed; }); + + auto *math = new olive::MathNode(); + math->setParent(&project); + EXPECT_EQ(added, 1); + EXPECT_EQ(last_added, math); + EXPECT_TRUE(project.nodes().contains(math)); + EXPECT_EQ(project.nodes().size(), 2); + + delete math; + EXPECT_EQ(removed, 1); + EXPECT_FALSE(project.nodes().contains(math)); + EXPECT_EQ(project.nodes().size(), 1); + + // Clear() destructively removes every node from the graph + auto *a = new olive::MathNode(); + a->setParent(&project); + auto *b = new olive::MathNode(); + b->setParent(&project); + ASSERT_EQ(project.nodes().size(), 3); + project.Clear(); + EXPECT_TRUE(project.nodes().isEmpty()); + // One removal from the earlier delete, plus root + 2 nodes from Clear() + EXPECT_EQ(removed, 4); +} + +TEST(Project, ContextCounting) +{ + olive::ColorManager::SetUpDefaultConfig(); + EnsureAppSingletons(); + olive::Project project; + project.Initialize(); + + auto *node = new olive::MathNode(); + node->setParent(&project); + auto *folder = new olive::Folder(); + folder->setParent(&project); + + EXPECT_EQ(project.GetNumberOfContextsNodeIsIn(node), 0); + + EXPECT_TRUE(folder->SetNodePositionInContext( + node, olive::Node::Position(QPointF(1.0, 2.0), true))); + EXPECT_EQ(project.GetNumberOfContextsNodeIsIn(node), 1); + EXPECT_EQ(project.GetNumberOfContextsNodeIsIn(node, true), 1); + + // except_itself only excludes the queried node when it acts as its own + // context + node->SetNodePositionInContext(node, + olive::Node::Position(QPointF(), true)); + EXPECT_EQ(project.GetNumberOfContextsNodeIsIn(node, false), 2); + EXPECT_EQ(project.GetNumberOfContextsNodeIsIn(node, true), 1); +} + +TEST(Project, CopySettingsCopiesEntireMap) +{ + olive::ColorManager::SetUpDefaultConfig(); + EnsureAppSingletons(); + olive::Project from; + olive::Project to; + + from.SetSetting(QStringLiteral("alpha"), QStringLiteral("1")); + from.SetCustomCachePath(QStringLiteral("/tmp/x")); + + EXPECT_TRUE(to.GetSetting(QStringLiteral("alpha")).isEmpty()); + + olive::Project::CopySettings(&from, &to); + EXPECT_EQ(to.GetSetting(QStringLiteral("alpha")), QStringLiteral("1")); + EXPECT_EQ(to.GetCustomCachePath(), QStringLiteral("/tmp/x")); +} + +TEST(Project, SaveLoadRoundTripPreservesUuidSettingsAndRoot) +{ + olive::ColorManager::SetUpDefaultConfig(); + EnsureAppSingletons(); + olive::NodeFactory::Initialize(); + + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = + QDir(dir.path()).filePath(QStringLiteral("roundtrip.ove")); + + const QUuid fixed_uuid( + QStringLiteral("{12345678-1234-1234-1234-1234567890ab}")); + + { + olive::Project project; + project.Initialize(); + project.SetUuid(fixed_uuid); + project.SetSetting(QStringLiteral("customkey"), + QStringLiteral("customvalue")); + + QFile file(path); + ASSERT_TRUE(file.open(QFile::WriteOnly)); + QXmlStreamWriter writer(&file); + writer.writeStartDocument(); + writer.writeStartElement(QStringLiteral("project")); + project.Save(&writer); + writer.writeEndElement(); + writer.writeEndDocument(); + file.close(); + } + + // The project being loaded into must not be Initialize()d: Load() + // re-resolves the root folder from the saved settings and asserts it + // does not exist yet + olive::Project loaded; + QFile in(path); + ASSERT_TRUE(in.open(QFile::ReadOnly)); + QXmlStreamReader reader(&in); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("project")); + olive::SerializedData data = loaded.Load(&reader); + in.close(); + + EXPECT_EQ(loaded.GetUuid(), fixed_uuid); + EXPECT_EQ(loaded.GetSetting(QStringLiteral("customkey")), + QStringLiteral("customvalue")); + + // The root folder was re-created as a new instance and the root setting + // now points at it + ASSERT_NE(loaded.root(), nullptr); + EXPECT_TRUE(loaded.nodes().contains(loaded.root())); + EXPECT_EQ(loaded.GetSetting(olive::Project::kRootKey), + QString::number(reinterpret_cast(loaded.root()))); + EXPECT_FALSE(data.node_ptrs.isEmpty()); + + olive::NodeFactory::Destroy(); +} + +TEST(Project, LoadSkipsUnknownAndEmptyNodeIds) +{ + olive::ColorManager::SetUpDefaultConfig(); + EnsureAppSingletons(); + olive::NodeFactory::Initialize(); + + const QString xml = QStringLiteral( + "" + "" + "" + "" + "" + "" + ""); + + olive::Project project; + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("project")); + project.Load(&reader); + + // Only the node with a known, non-empty id made it into the graph + ASSERT_EQ(project.nodes().size(), 1); + EXPECT_EQ(project.nodes().first()->id(), + QStringLiteral("org.olivevideoeditor.Olive.math")); + + olive::NodeFactory::Destroy(); +} + +TEST(NodeFactory, CreateFromFactoryIndexReturnsNonNullUniqueIds) +{ + QSet ids; + for (int i = 0; i < int(olive::NodeFactory::kInternalNodeCount); ++i) { + const olive::NodeFactory::InternalID factory_id = + static_cast(i); + olive::Node *node = + olive::NodeFactory::CreateFromFactoryIndex(factory_id); + ASSERT_NE(node, nullptr) << "factory index" << i << "returned null"; + EXPECT_FALSE(node->id().isEmpty()); + EXPECT_FALSE(ids.contains(node->id())) + << "duplicate id" << node->id().toStdString(); + ids.insert(node->id()); + delete node; + } + EXPECT_EQ(ids.size(), int(olive::NodeFactory::kInternalNodeCount)); +} + +TEST(NodeFactory, CreateFromFactoryIndexReturnsExpectedTypes) +{ + std::unique_ptr footage( + olive::NodeFactory::CreateFromFactoryIndex( + olive::NodeFactory::kProjectFootage)); + EXPECT_NE(dynamic_cast(footage.get()), nullptr); + + std::unique_ptr sequence( + olive::NodeFactory::CreateFromFactoryIndex( + olive::NodeFactory::kProjectSequence)); + EXPECT_NE(dynamic_cast(sequence.get()), nullptr); + + std::unique_ptr folder( + olive::NodeFactory::CreateFromFactoryIndex( + olive::NodeFactory::kProjectFolder)); + EXPECT_NE(dynamic_cast(folder.get()), nullptr); + + std::unique_ptr track( + olive::NodeFactory::CreateFromFactoryIndex( + olive::NodeFactory::kTrackOutput)); + EXPECT_NE(dynamic_cast(track.get()), nullptr); + + std::unique_ptr viewer( + olive::NodeFactory::CreateFromFactoryIndex( + olive::NodeFactory::kViewerOutput)); + EXPECT_NE(dynamic_cast(viewer.get()), nullptr); + + std::unique_ptr solid( + olive::NodeFactory::CreateFromFactoryIndex( + olive::NodeFactory::kSolidGenerator)); + EXPECT_NE(dynamic_cast(solid.get()), nullptr); + + std::unique_ptr math( + olive::NodeFactory::CreateFromFactoryIndex(olive::NodeFactory::kMath)); + EXPECT_NE(dynamic_cast(math.get()), nullptr); + + std::unique_ptr text( + olive::NodeFactory::CreateFromFactoryIndex( + olive::NodeFactory::kTextGeneratorV3)); + EXPECT_NE(dynamic_cast(text.get()), nullptr); + + std::unique_ptr clip( + olive::NodeFactory::CreateFromFactoryIndex( + olive::NodeFactory::kClipBlock)); + EXPECT_NE(dynamic_cast(clip.get()), nullptr); + + std::unique_ptr gap( + olive::NodeFactory::CreateFromFactoryIndex( + olive::NodeFactory::kGapBlock)); + EXPECT_NE(dynamic_cast(gap.get()), nullptr); + + std::unique_ptr group( + olive::NodeFactory::CreateFromFactoryIndex( + olive::NodeFactory::kGroupNode)); + EXPECT_NE(dynamic_cast(group.get()), nullptr); +} + +TEST(NodeFactory, CreateFromFactoryIndexCountReturnsNull) +{ + EXPECT_EQ(olive::NodeFactory::CreateFromFactoryIndex( + olive::NodeFactory::kInternalNodeCount), + nullptr); +} + +TEST(NodeFactory, LibraryRoundTripAfterInitialize) +{ + olive::NodeFactory::Initialize(); + + for (int i = 0; i < int(olive::NodeFactory::kInternalNodeCount); ++i) { + std::unique_ptr probe( + olive::NodeFactory::CreateFromFactoryIndex( + static_cast(i))); + ASSERT_NE(probe, nullptr); + + // Every internal type must be retrievable from the library by id + EXPECT_EQ(olive::NodeFactory::GetNameFromID(probe->id()), + probe->Name()) + << probe->id().toStdString(); + + std::unique_ptr copy( + olive::NodeFactory::CreateFromID(probe->id())); + ASSERT_NE(copy, nullptr) << probe->id().toStdString(); + EXPECT_EQ(copy->id(), probe->id()); + EXPECT_NE(copy.get(), probe.get()); + } + + // Unknown and empty ids fail gracefully + EXPECT_EQ(olive::NodeFactory::CreateFromID( + QStringLiteral("org.example.nonexistent")), + nullptr); + EXPECT_EQ(olive::NodeFactory::CreateFromID(QString()), nullptr); + EXPECT_TRUE(olive::NodeFactory::GetNameFromID( + QStringLiteral("org.example.nonexistent")) + .isEmpty()); + EXPECT_TRUE(olive::NodeFactory::GetNameFromID(QString()).isEmpty()); + + olive::NodeFactory::Destroy(); +} + +TEST(NodeFactory, CreateMenuWithNoneItem) +{ + olive::NodeFactory::Initialize(); + + std::unique_ptr menu( + olive::NodeFactory::CreateMenu(nullptr, true)); + ASSERT_NE(menu, nullptr); + ASSERT_FALSE(menu->actions().isEmpty()); + + // The "None" item is inserted at the very top and maps to nothing + QAction *none_item = menu->actions().first(); + EXPECT_EQ(none_item->data().toInt(), -1); + EXPECT_EQ(olive::NodeFactory::CreateFromMenuAction(none_item), nullptr); + EXPECT_TRUE(olive::NodeFactory::GetIDFromMenuAction(none_item).isEmpty()); + + // Leaf actions carry a library index that maps back to node ids + QList leaves; + CollectLeafActions(menu.get(), &leaves); + ASSERT_GT(leaves.size(), 1); + + int created = 0; + for (QAction *leaf : leaves) { + if (leaf->data().toInt() < 0) { + continue; + } + const QString id = olive::NodeFactory::GetIDFromMenuAction(leaf); + EXPECT_FALSE(id.isEmpty()); + std::unique_ptr node( + olive::NodeFactory::CreateFromMenuAction(leaf)); + ASSERT_NE(node, nullptr); + EXPECT_EQ(node->id(), id); + ++created; + } + EXPECT_GT(created, 0); + + olive::NodeFactory::Destroy(); +} + +TEST(NodeFactory, CreateMenuRestrictedToCategory) +{ + olive::NodeFactory::Initialize(); + + std::unique_ptr menu(olive::NodeFactory::CreateMenu( + nullptr, false, olive::Node::kCategoryMath)); + ASSERT_NE(menu, nullptr); + + QList leaves; + CollectLeafActions(menu.get(), &leaves); + ASSERT_FALSE(leaves.isEmpty()); + + for (QAction *leaf : leaves) { + std::unique_ptr node( + olive::NodeFactory::CreateFromMenuAction(leaf)); + ASSERT_NE(node, nullptr); + EXPECT_TRUE(node->Category().contains(olive::Node::kCategoryMath)) + << node->id().toStdString(); + } + + olive::NodeFactory::Destroy(); +} diff --git a/tests/gtest/render_workerpool_ipc_test.cpp b/tests/gtest/render_workerpool_ipc_test.cpp new file mode 100644 index 000000000..ca351068f --- /dev/null +++ b/tests/gtest/render_workerpool_ipc_test.cpp @@ -0,0 +1,882 @@ +/* + * Oak Video Editor - Render Worker Pool & IPC Coverage Tests + * Copyright (C) 2026 Oak Team + * + * CPU-only, headless coverage for the render worker IPC stack that + * render_ipc_test.cpp and render_worker_footage_test.cpp do not reach: + * - SharedMemoryRegion (named shared memory create/attach/lifetime) + * - FrameSlotPool (layout math, invalid attach, metadata fields, FIFO order) + * - NDJSON messages (color transform, legacy input_slot fallback, defaults, + * blank/non-object lines, closed-device writes) + * - RenderWorkerPool (early validation paths that need no worker process) + * - DecoderCache (DecoderPair defaults and insert/value round trip) + * + * No worker processes, GPU, audio devices, or network access are required; the + * shared-memory tests use real OS segments with per-run unique keys. + */ + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include + +#include "codec/decoder.h" +#include "node/output/track/track.h" +#include "render/ipc/frameslotpool.h" +#include "render/ipc/ipcmessage.h" +#include "render/ipc/sharedmemoryregion.h" +#include "render/rendercache.h" +#include "render/renderworkerpool.h" + +namespace +{ + +// Unique-per-run segment key so stale POSIX segments left by earlier runs can +// never collide with a test (MakeKey() bakes the pid into the key). +QString TestShmKey(const char *tag) +{ + return olive::ipc::SharedMemoryRegion::MakeKey( + QCoreApplication::applicationPid(), 99) + + QStringLiteral("-") + QLatin1String(tag); +} + +olive::RenderManager::RenderVideoParams +MakeVideoParams(olive::Node *node, const olive::VideoParams &video_params) +{ + return olive::RenderManager::RenderVideoParams( + node, video_params, olive::core::AudioParams(), + olive::core::rational(0), nullptr, olive::RenderMode::kOnline); +} + +} // namespace + +// ============================================================================ +// SharedMemoryRegion +// ============================================================================ + +TEST(SharedMemoryRegion, MakeKeyFormat) +{ + EXPECT_EQ(olive::ipc::SharedMemoryRegion::MakeKey(12345, 3), + QStringLiteral("olive-rw-12345-3")); + + EXPECT_NE(olive::ipc::SharedMemoryRegion::MakeKey(12345, 3), + olive::ipc::SharedMemoryRegion::MakeKey(12345, 4)); + EXPECT_NE(olive::ipc::SharedMemoryRegion::MakeKey(12345, 3), + olive::ipc::SharedMemoryRegion::MakeKey(12346, 3)); +} + +TEST(SharedMemoryRegion, CreateProvidesZeroedWritableMemory) +{ + const QString key = TestShmKey("zeroed"); + olive::ipc::SharedMemoryRegion region; + ASSERT_TRUE(region.Open(key, 4096, olive::ipc::SharedMemoryRegion::kCreate)) + << region.error().toStdString(); + + EXPECT_TRUE(region.IsValid()); + EXPECT_EQ(region.size(), size_t(4096)); + EXPECT_EQ(region.key(), key); + ASSERT_NE(region.data(), nullptr); + + // Freshly created segments are zero-filled. + const auto *bytes = static_cast(region.data()); + for (size_t i = 0; i < region.size(); i++) { + ASSERT_EQ(bytes[i], 0) << "byte " << i; + } + + // The mapping is readable and writable. + auto *writable = static_cast(region.data()); + for (size_t i = 0; i < region.size(); i++) { + writable[i] = uint8_t(i * 31 + 7); + } + EXPECT_EQ(writable[0], 7); + EXPECT_EQ(writable[4095], uint8_t(4095 * 31 + 7)); +} + +TEST(SharedMemoryRegion, AttachToMissingKeyFails) +{ + olive::ipc::SharedMemoryRegion region; + EXPECT_FALSE(region.Open(TestShmKey("missing"), 4096, + olive::ipc::SharedMemoryRegion::kAttach)); + EXPECT_FALSE(region.IsValid()); + EXPECT_EQ(region.data(), nullptr); + EXPECT_FALSE(region.error().isEmpty()); +} + +TEST(SharedMemoryRegion, CreateAttachRoundTrip) +{ + const QString key = TestShmKey("roundtrip"); + + olive::ipc::SharedMemoryRegion owner; + ASSERT_TRUE(owner.Open(key, 8192, olive::ipc::SharedMemoryRegion::kCreate)) + << owner.error().toStdString(); + + olive::ipc::SharedMemoryRegion peer; + ASSERT_TRUE(peer.Open(key, 8192, olive::ipc::SharedMemoryRegion::kAttach)) + << peer.error().toStdString(); + EXPECT_TRUE(peer.IsValid()); + EXPECT_EQ(peer.size(), size_t(8192)); + EXPECT_EQ(peer.key(), key); + + // Writes through the owner mapping are visible through the peer mapping. + auto *owner_bytes = static_cast(owner.data()); + const auto *peer_bytes = static_cast(peer.data()); + for (size_t i = 0; i < 8192; i += 257) { + owner_bytes[i] = uint8_t(i ^ 0x5A); + } + for (size_t i = 0; i < 8192; i += 257) { + EXPECT_EQ(peer_bytes[i], uint8_t(i ^ 0x5A)) << "offset " << i; + } + + // And vice versa: the peer maps the same segment read/write. + auto *peer_writable = static_cast(peer.data()); + peer_writable[123] = 0xA5; + EXPECT_EQ(owner_bytes[123], 0xA5); +} + +TEST(SharedMemoryRegion, ZeroSizeCreateFails) +{ + olive::ipc::SharedMemoryRegion region; + // A zero-length mapping is rejected (EINVAL from mmap on POSIX, invalid + // size for CreateFileMapping on Windows). + EXPECT_FALSE(region.Open(TestShmKey("zerosize"), 0, + olive::ipc::SharedMemoryRegion::kCreate)); + EXPECT_FALSE(region.IsValid()); + EXPECT_FALSE(region.error().isEmpty()); +} + +TEST(SharedMemoryRegion, CloseInvalidatesThenReopenWorks) +{ + olive::ipc::SharedMemoryRegion region; + ASSERT_TRUE(region.Open(TestShmKey("close1"), 4096, + olive::ipc::SharedMemoryRegion::kCreate)); + + region.Close(); + EXPECT_FALSE(region.IsValid()); + EXPECT_EQ(region.data(), nullptr); + EXPECT_EQ(region.size(), size_t(0)); + + // Close is idempotent. + region.Close(); + EXPECT_FALSE(region.IsValid()); + + // The same object can be reused for a new segment (Open() closes first). + ASSERT_TRUE(region.Open(TestShmKey("close2"), 2048, + olive::ipc::SharedMemoryRegion::kCreate)); + EXPECT_TRUE(region.IsValid()); + EXPECT_EQ(region.size(), size_t(2048)); +} + +TEST(SharedMemoryRegion, OwnerDestructionUnlinksSegment) +{ + const QString key = TestShmKey("unlink"); + { + olive::ipc::SharedMemoryRegion owner; + ASSERT_TRUE(owner.Open(key, 4096, + olive::ipc::SharedMemoryRegion::kCreate)); + + // While the owner lives, attaching works. + olive::ipc::SharedMemoryRegion peer; + ASSERT_TRUE( + peer.Open(key, 4096, olive::ipc::SharedMemoryRegion::kAttach)); + } + + // Once the owner is destroyed the name is unlinked; new attaches fail. + olive::ipc::SharedMemoryRegion late; + EXPECT_FALSE(late.Open(key, 4096, olive::ipc::SharedMemoryRegion::kAttach)); + EXPECT_FALSE(late.IsValid()); +} + +// ============================================================================ +// FrameSlotPool +// ============================================================================ + +TEST(FrameSlotPool, AttachRejectsBadMagic) +{ + // A region that was never initialized by Create() has no valid magic number. + std::vector mem(olive::ipc::FrameSlotPool::BytesNeeded(2, 64), 0xAB); + olive::ipc::FrameSlotPool pool = olive::ipc::FrameSlotPool::Attach(mem.data()); + + EXPECT_FALSE(pool.IsValid()); + EXPECT_EQ(pool.slot_count(), 0u); + EXPECT_EQ(pool.slot_data_bytes(), size_t(0)); +} + +TEST(FrameSlotPool, DefaultConstructedIsInvalid) +{ + olive::ipc::FrameSlotPool pool; + EXPECT_FALSE(pool.IsValid()); + EXPECT_EQ(pool.slot_count(), 0u); + EXPECT_EQ(pool.slot_data_bytes(), size_t(0)); +} + +TEST(FrameSlotPool, BytesNeededReflectsGeometry) +{ + // The total is a sum of 64-byte-aligned sub-regions, so it stays 64-aligned. + EXPECT_EQ(olive::ipc::FrameSlotPool::BytesNeeded(1, 64) % 64, 0u); + EXPECT_EQ(olive::ipc::FrameSlotPool::BytesNeeded(3, 1000) % 64, 0u); + + // More slots and bigger slots both need strictly more memory... + EXPECT_LT(olive::ipc::FrameSlotPool::BytesNeeded(1, 64), + olive::ipc::FrameSlotPool::BytesNeeded(2, 64)); + EXPECT_LT(olive::ipc::FrameSlotPool::BytesNeeded(2, 64), + olive::ipc::FrameSlotPool::BytesNeeded(3, 64)); + EXPECT_LT(olive::ipc::FrameSlotPool::BytesNeeded(2, 64), + olive::ipc::FrameSlotPool::BytesNeeded(2, 128)); + + // ...but sizes inside the same 64-byte alignment bucket collapse together. + EXPECT_EQ(olive::ipc::FrameSlotPool::BytesNeeded(2, 65), + olive::ipc::FrameSlotPool::BytesNeeded(2, 128)); +} + +TEST(FrameSlotPool, SlotDataBlocksAreAlignedAndDistinct) +{ + constexpr uint32_t kSlots = 2; + constexpr size_t kSlotBytes = 100; // deliberately not 64-aligned + + std::vector mem( + olive::ipc::FrameSlotPool::BytesNeeded(kSlots, kSlotBytes)); + olive::ipc::FrameSlotPool pool = + olive::ipc::FrameSlotPool::Create(mem.data(), kSlots, kSlotBytes); + ASSERT_TRUE(pool.IsValid()); + + auto *first = static_cast(pool.SlotData(0)); + auto *second = static_cast(pool.SlotData(1)); + + // Slot data blocks are padded out to 64-byte boundaries within the region. + EXPECT_EQ(second - first, ptrdiff_t(128)); + + // A full-size write to one slot never spills into the next. + std::memset(first, 0x11, kSlotBytes); + std::memset(second, 0x22, kSlotBytes); + EXPECT_EQ(first[kSlotBytes - 1], 0x11); + EXPECT_EQ(second[0], 0x22); + + // The const overload maps the same addresses. + const olive::ipc::FrameSlotPool &const_pool = pool; + EXPECT_EQ(static_cast(const_pool.SlotData(0)), first); + EXPECT_EQ(static_cast(const_pool.SlotData(1)), second); +} + +TEST(FrameSlotPool, MetadataFieldsRoundTrip) +{ + constexpr uint32_t kSlots = 2; + constexpr size_t kSlotBytes = 64; + + std::vector mem( + olive::ipc::FrameSlotPool::BytesNeeded(kSlots, kSlotBytes)); + olive::ipc::FrameSlotPool filler = + olive::ipc::FrameSlotPool::Create(mem.data(), kSlots, kSlotBytes); + olive::ipc::FrameSlotPool drainer = + olive::ipc::FrameSlotPool::Attach(mem.data()); + + uint32_t idx = 0; + ASSERT_TRUE(filler.Acquire(&idx)); + + // Freshly created pools zero the metadata array. + const olive::ipc::FrameSlotPool &const_drainer = drainer; + const olive::ipc::FrameSlotMeta *blank = const_drainer.Meta(idx); + EXPECT_EQ(blank->id, 0); + EXPECT_EQ(blank->time_num, 0); + EXPECT_EQ(blank->time_den, 0); + EXPECT_EQ(blank->width, 0); + EXPECT_EQ(blank->colorspace[0], '\0'); + + // Every field the producer writes survives the hand-off. + olive::ipc::FrameSlotMeta *meta = filler.Meta(idx); + meta->id = -99; + meta->time_num = 1001; + meta->time_den = 30000; + meta->width = 3840; + meta->height = 2160; + meta->format = int(olive::core::PixelFormat::F32); + meta->channel_count = 4; + meta->linesize = 3840 * 4 * 4; + meta->data_size = int32_t(kSlotBytes); + const char kColorspace[] = "acescg"; + std::strncpy(meta->colorspace, kColorspace, sizeof(meta->colorspace) - 1); + meta->colorspace[sizeof(meta->colorspace) - 1] = '\0'; + + ASSERT_TRUE(filler.Publish(idx)); + + uint32_t got = 0; + ASSERT_TRUE(drainer.Consume(&got)); + EXPECT_EQ(got, idx); + + const olive::ipc::FrameSlotMeta *out = const_drainer.Meta(got); + EXPECT_EQ(out->id, -99); + EXPECT_EQ(out->time_num, 1001); + EXPECT_EQ(out->time_den, 30000); + EXPECT_EQ(out->width, 3840); + EXPECT_EQ(out->height, 2160); + EXPECT_EQ(out->format, int(olive::core::PixelFormat::F32)); + EXPECT_EQ(out->channel_count, 4); + EXPECT_EQ(out->linesize, 3840 * 4 * 4); + EXPECT_EQ(out->data_size, int32_t(kSlotBytes)); + EXPECT_STREQ(out->colorspace, kColorspace); + + EXPECT_TRUE(drainer.Release(got)); +} + +TEST(FrameSlotPool, FreeSlotsAreIssuedInOrder) +{ + constexpr uint32_t kSlots = 4; + std::vector mem( + olive::ipc::FrameSlotPool::BytesNeeded(kSlots, 64)); + olive::ipc::FrameSlotPool pool = + olive::ipc::FrameSlotPool::Create(mem.data(), kSlots, 64); + + // Create() seeds the free ring FIFO with every slot index. + for (uint32_t expected = 0; expected < kSlots; expected++) { + uint32_t idx = 0; + ASSERT_TRUE(pool.Acquire(&idx)); + EXPECT_EQ(idx, expected); + } + + uint32_t overflow = 0; + EXPECT_FALSE(pool.Acquire(&overflow)); + + // Released slots are re-issued in the order they were released. + ASSERT_TRUE(pool.Release(2)); + ASSERT_TRUE(pool.Release(0)); + uint32_t idx = 0; + ASSERT_TRUE(pool.Acquire(&idx)); + EXPECT_EQ(idx, 2u); + ASSERT_TRUE(pool.Acquire(&idx)); + EXPECT_EQ(idx, 0u); +} + +TEST(FrameSlotPool, ReadyRingDeliversInPublishOrder) +{ + constexpr uint32_t kSlots = 3; + std::vector mem( + olive::ipc::FrameSlotPool::BytesNeeded(kSlots, 64)); + olive::ipc::FrameSlotPool pool = + olive::ipc::FrameSlotPool::Create(mem.data(), kSlots, 64); + + uint32_t a = 0, b = 0, c = 0; + ASSERT_TRUE(pool.Acquire(&a)); + ASSERT_TRUE(pool.Acquire(&b)); + ASSERT_TRUE(pool.Acquire(&c)); + + // Publish order, not slot order, determines consume order. + ASSERT_TRUE(pool.Publish(c)); + ASSERT_TRUE(pool.Publish(a)); + ASSERT_TRUE(pool.Publish(b)); + + const uint32_t expected[] = { c, a, b }; + for (uint32_t want : expected) { + uint32_t got = 0; + ASSERT_TRUE(pool.Consume(&got)); + EXPECT_EQ(got, want); + ASSERT_TRUE(pool.Release(got)); + } + + uint32_t empty = 0; + EXPECT_FALSE(pool.Consume(&empty)); +} + +TEST(FrameSlotPool, CrossMappingHandoff) +{ + constexpr uint32_t kSlots = 2; + constexpr size_t kSlotBytes = 128; + const QString key = TestShmKey("pool-handoff"); + + const size_t bytes = + olive::ipc::FrameSlotPool::BytesNeeded(kSlots, kSlotBytes); + + olive::ipc::SharedMemoryRegion owner_region; + ASSERT_TRUE(owner_region.Open(key, bytes, + olive::ipc::SharedMemoryRegion::kCreate)) + << owner_region.error().toStdString(); + olive::ipc::FrameSlotPool filler = olive::ipc::FrameSlotPool::Create( + owner_region.data(), kSlots, kSlotBytes); + ASSERT_TRUE(filler.IsValid()); + + // The peer maps the same segment separately and attaches to the pool header. + olive::ipc::SharedMemoryRegion peer_region; + ASSERT_TRUE(peer_region.Open(key, bytes, + olive::ipc::SharedMemoryRegion::kAttach)) + << peer_region.error().toStdString(); + olive::ipc::FrameSlotPool drainer = + olive::ipc::FrameSlotPool::Attach(peer_region.data()); + ASSERT_TRUE(drainer.IsValid()); + EXPECT_EQ(drainer.slot_count(), kSlots); + EXPECT_EQ(drainer.slot_data_bytes(), kSlotBytes); + + // Filler side: acquire a slot, stamp it, publish it. + uint32_t idx = 0; + ASSERT_TRUE(filler.Acquire(&idx)); + auto *data = static_cast(filler.SlotData(idx)); + for (size_t i = 0; i < kSlotBytes; i++) { + data[i] = uint8_t(0xC3 ^ i); + } + filler.Meta(idx)->id = 777; + ASSERT_TRUE(filler.Publish(idx)); + + // Drainer side (through the second mapping): same slot, meta and pixels. + uint32_t got = 0; + ASSERT_TRUE(drainer.Consume(&got)); + EXPECT_EQ(got, idx); + EXPECT_EQ(drainer.Meta(got)->id, 777); + const auto *peer_data = + static_cast(drainer.SlotData(got)); + for (size_t i = 0; i < kSlotBytes; i++) { + ASSERT_EQ(peer_data[i], uint8_t(0xC3 ^ i)) << "byte " << i; + } + ASSERT_TRUE(drainer.Release(got)); + + // The release crosses back to the owner's mapping. The free ring is FIFO: + // the next fresh slot comes first, then the released slot cycles back. + uint32_t reacquired = 0; + ASSERT_TRUE(filler.Acquire(&reacquired)); + EXPECT_EQ(reacquired, 1u); + ASSERT_TRUE(filler.Acquire(&reacquired)); + EXPECT_EQ(reacquired, got); +} + +// ============================================================================ +// NDJSON control messages (beyond render_ipc_test.cpp) +// ============================================================================ + +TEST(IpcMessage, LoadGraphRoundTrip) +{ + olive::ipc::LoadGraphMsg msg; + msg.path = QStringLiteral("/tmp/oak-render-graph-abc123.ove"); + + const QJsonObject obj = msg.ToJson(); + EXPECT_EQ(obj.value(QStringLiteral("type")).toString(), + QLatin1String(olive::ipc::msgtype::kLoadGraph)); + + olive::ipc::LoadGraphMsg back; + ASSERT_TRUE(olive::ipc::LoadGraphMsg::FromJson(obj, &back)); + EXPECT_EQ(back.path, msg.path); +} + +TEST(IpcMessage, RenderFrameColorTransformRoundTrip) +{ + olive::ipc::RenderFrameMsg msg; + msg.ticket_id = 123; + msg.node_uuid = QStringLiteral("{11111111-2222-3333-4444-555555555555}"); + msg.time_num = 1001; + msg.time_den = 24000; + msg.width = 1920; + msg.height = 1080; + msg.format = int(olive::core::PixelFormat::F32); + msg.channel_count = 4; + msg.mode = 1; + msg.input_slots = { 0, 2, 5 }; + msg.has_color_transform = true; + msg.color_is_display = true; + msg.color_output = QStringLiteral("sRGB - Display"); + msg.color_view = QStringLiteral("ACES 1.0 SDR-video"); + msg.color_look = QStringLiteral("None"); + + const QJsonObject obj = msg.ToJson(); + EXPECT_EQ(obj.value(QStringLiteral("type")).toString(), + QLatin1String(olive::ipc::msgtype::kRenderFrame)); + EXPECT_TRUE(obj.value(QStringLiteral("has_color_transform")).toBool()); + + olive::ipc::RenderFrameMsg back; + ASSERT_TRUE(olive::ipc::RenderFrameMsg::FromJson(obj, &back)); + EXPECT_EQ(back.ticket_id, msg.ticket_id); + EXPECT_EQ(back.node_uuid, msg.node_uuid); + EXPECT_EQ(back.time_num, msg.time_num); + EXPECT_EQ(back.time_den, msg.time_den); + EXPECT_EQ(back.width, msg.width); + EXPECT_EQ(back.height, msg.height); + EXPECT_EQ(back.format, msg.format); + EXPECT_EQ(back.channel_count, msg.channel_count); + EXPECT_EQ(back.mode, msg.mode); + EXPECT_EQ(back.input_slots, msg.input_slots); + EXPECT_TRUE(back.has_color_transform); + EXPECT_TRUE(back.color_is_display); + EXPECT_EQ(back.color_output, msg.color_output); + EXPECT_EQ(back.color_view, msg.color_view); + EXPECT_EQ(back.color_look, msg.color_look); +} + +TEST(IpcMessage, RenderFrameOmitsColorTransformWhenUnset) +{ + olive::ipc::RenderFrameMsg msg; // has_color_transform defaults to false + const QJsonObject obj = msg.ToJson(); + + EXPECT_FALSE(obj.contains(QStringLiteral("has_color_transform"))); + EXPECT_FALSE(obj.contains(QStringLiteral("color_output"))); + EXPECT_FALSE(obj.contains(QStringLiteral("color_view"))); + EXPECT_FALSE(obj.contains(QStringLiteral("color_look"))); + + olive::ipc::RenderFrameMsg back; + ASSERT_TRUE(olive::ipc::RenderFrameMsg::FromJson(obj, &back)); + EXPECT_FALSE(back.has_color_transform); + EXPECT_FALSE(back.color_is_display); + EXPECT_TRUE(back.color_output.isEmpty()); +} + +TEST(IpcMessage, RenderFrameLegacyInputSlotFallback) +{ + // Older peers only send the scalar "input_slot"; FromJson folds it into the + // input_slots array when the array is absent. + QJsonObject obj; + obj[QStringLiteral("type")] = + QLatin1String(olive::ipc::msgtype::kRenderFrame); + obj[QStringLiteral("ticket")] = 5.0; + obj[QStringLiteral("input_slot")] = 3; + + olive::ipc::RenderFrameMsg back; + ASSERT_TRUE(olive::ipc::RenderFrameMsg::FromJson(obj, &back)); + EXPECT_EQ(back.input_slot, 3); + ASSERT_EQ(back.input_slots.size(), 1); + EXPECT_EQ(back.input_slots.first(), 3); + + // When the array is present it wins and the scalar is not duplicated. + obj[QStringLiteral("input_slots")] = QJsonArray{ 7, 8 }; + olive::ipc::RenderFrameMsg back2; + ASSERT_TRUE(olive::ipc::RenderFrameMsg::FromJson(obj, &back2)); + ASSERT_EQ(back2.input_slots.size(), 2); + EXPECT_EQ(back2.input_slots.at(0), 7); + EXPECT_EQ(back2.input_slots.at(1), 8); +} + +TEST(IpcMessage, RenderFrameDefaultsFromSparseJson) +{ + // A message carrying only the type tag must still parse, with every field + // falling back to its documented default. + QJsonObject obj; + obj[QStringLiteral("type")] = + QLatin1String(olive::ipc::msgtype::kRenderFrame); + + olive::ipc::RenderFrameMsg back; + ASSERT_TRUE(olive::ipc::RenderFrameMsg::FromJson(obj, &back)); + EXPECT_EQ(back.ticket_id, 0); + EXPECT_TRUE(back.node_uuid.isEmpty()); + EXPECT_EQ(back.time_num, 0); + EXPECT_EQ(back.time_den, 1); + EXPECT_EQ(back.width, 0); + EXPECT_EQ(back.height, 0); + EXPECT_EQ(back.format, -1); + EXPECT_EQ(back.channel_count, 0); + EXPECT_EQ(back.mode, 0); + EXPECT_EQ(back.input_slot, -1); + EXPECT_TRUE(back.input_slots.isEmpty()); + EXPECT_FALSE(back.has_color_transform); +} + +TEST(IpcMessage, LargeIdentifiersSurviveRoundTrip) +{ + // 64-bit ids travel as JSON doubles, exact up to 2^53; ticket ids are + // pointer-derived and slot sizes are byte counts, both well inside that. + const qint64 ticket = (qint64(1) << 52) + 12345; + const qint64 slot_bytes = qint64(7680) * 4320 * 4 * 4; // 8K RGBA float + + olive::ipc::RenderFrameMsg rf; + rf.ticket_id = ticket; + rf.time_num = qint64(48000) * 123456789; + rf.time_den = qint64(1) << 40; + olive::ipc::RenderFrameMsg rf_back; + ASSERT_TRUE(olive::ipc::RenderFrameMsg::FromJson(rf.ToJson(), &rf_back)); + EXPECT_EQ(rf_back.ticket_id, ticket); + EXPECT_EQ(rf_back.time_num, rf.time_num); + EXPECT_EQ(rf_back.time_den, rf.time_den); + + olive::ipc::FrameReadyMsg fr; + fr.ticket_id = ticket; + olive::ipc::FrameReadyMsg fr_back; + ASSERT_TRUE(olive::ipc::FrameReadyMsg::FromJson(fr.ToJson(), &fr_back)); + EXPECT_EQ(fr_back.ticket_id, ticket); + + olive::ipc::CancelMsg cancel; + cancel.ticket_id = ticket; + olive::ipc::CancelMsg cancel_back; + ASSERT_TRUE(olive::ipc::CancelMsg::FromJson(cancel.ToJson(), &cancel_back)); + EXPECT_EQ(cancel_back.ticket_id, ticket); + + olive::ipc::HandshakeMsg hs; + hs.slot_data_bytes = slot_bytes; + hs.input_slot_data_bytes = slot_bytes / 2; + olive::ipc::HandshakeMsg hs_back; + ASSERT_TRUE(olive::ipc::HandshakeMsg::FromJson(hs.ToJson(), &hs_back)); + EXPECT_EQ(hs_back.slot_data_bytes, slot_bytes); + EXPECT_EQ(hs_back.input_slot_data_bytes, slot_bytes / 2); +} + +TEST(IpcMessage, TypedBuildersRejectMismatchedType) +{ + const QJsonObject hs_obj = olive::ipc::HandshakeMsg().ToJson(); + const QJsonObject rf_obj = olive::ipc::RenderFrameMsg().ToJson(); + const QJsonObject fr_obj = olive::ipc::FrameReadyMsg().ToJson(); + const QJsonObject cancel_obj = olive::ipc::CancelMsg().ToJson(); + const QJsonObject load_obj = olive::ipc::LoadGraphMsg().ToJson(); + + olive::ipc::HandshakeMsg hs_out; + EXPECT_FALSE(olive::ipc::HandshakeMsg::FromJson(rf_obj, &hs_out)); + olive::ipc::RenderFrameMsg rf_out; + EXPECT_FALSE(olive::ipc::RenderFrameMsg::FromJson(cancel_obj, &rf_out)); + olive::ipc::FrameReadyMsg fr_out; + EXPECT_FALSE(olive::ipc::FrameReadyMsg::FromJson(load_obj, &fr_out)); + olive::ipc::CancelMsg cancel_out; + EXPECT_FALSE(olive::ipc::CancelMsg::FromJson(fr_obj, &cancel_out)); + olive::ipc::LoadGraphMsg load_out; + EXPECT_FALSE(olive::ipc::LoadGraphMsg::FromJson(hs_obj, &load_out)); + + // An object with no "type" at all is rejected by every parser. + const QJsonObject empty; + EXPECT_FALSE(olive::ipc::HandshakeMsg::FromJson(empty, &hs_out)); + EXPECT_FALSE(olive::ipc::RenderFrameMsg::FromJson(empty, &rf_out)); + EXPECT_FALSE(olive::ipc::FrameReadyMsg::FromJson(empty, &fr_out)); + EXPECT_FALSE(olive::ipc::CancelMsg::FromJson(empty, &cancel_out)); + EXPECT_FALSE(olive::ipc::LoadGraphMsg::FromJson(empty, &load_out)); +} + +TEST(IpcMessage, ReadMessageSkipsBlankLines) +{ + olive::ipc::CancelMsg cancel; + cancel.ticket_id = 9; + const QByteArray line = + QJsonDocument(cancel.ToJson()).toJson(QJsonDocument::Compact); + + // A reader loop sees: blank line, whitespace-only line, then a real message. + QByteArray reader = QByteArray("\n \n") + line + '\n'; + + QJsonObject obj; + bool ok = true; + EXPECT_FALSE(olive::ipc::ReadMessage(&reader, &obj, &ok)); // blank + EXPECT_FALSE(ok); + EXPECT_FALSE(olive::ipc::ReadMessage(&reader, &obj, &ok)); // whitespace + EXPECT_FALSE(ok); + + ASSERT_TRUE(olive::ipc::ReadMessage(&reader, &obj, &ok)); + EXPECT_TRUE(ok); + olive::ipc::CancelMsg back; + ASSERT_TRUE(olive::ipc::CancelMsg::FromJson(obj, &back)); + EXPECT_EQ(back.ticket_id, 9); + EXPECT_TRUE(reader.isEmpty()); +} + +TEST(IpcMessage, ReadMessageRejectsNonObjectJson) +{ + // Valid JSON, but an array rather than an object: consumed, flagged not-ok. + QByteArray reader = QByteArray("[1,2,3]\n"); + QJsonObject obj; + bool ok = true; + EXPECT_FALSE(olive::ipc::ReadMessage(&reader, &obj, &ok)); + EXPECT_FALSE(ok); + EXPECT_TRUE(reader.isEmpty()); +} + +TEST(IpcMessage, ReadMessageWorksWithoutOkPointer) +{ + olive::ipc::CancelMsg cancel; + cancel.ticket_id = 4; + QByteArray reader = + QJsonDocument(cancel.ToJson()).toJson(QJsonDocument::Compact); + reader.append('\n'); + + QJsonObject obj; + EXPECT_TRUE(olive::ipc::ReadMessage(&reader, &obj)); // ok defaults to nullptr + + QByteArray bad = QByteArray("garbage\n"); + EXPECT_FALSE(olive::ipc::ReadMessage(&bad, &obj)); +} + +TEST(IpcMessage, WriteMessageProducesSingleTerminatedLine) +{ + QByteArray storage; + QBuffer device(&storage); + ASSERT_TRUE(device.open(QIODevice::WriteOnly)); + + olive::ipc::HandshakeMsg hs; + hs.protocol_version = 1; + hs.shm_key = QStringLiteral("olive-rw-1-0"); + ASSERT_TRUE(olive::ipc::WriteMessage(&device, hs.ToJson())); + device.close(); + + // NDJSON: exactly one compact line, newline-terminated. + EXPECT_TRUE(storage.startsWith('{')); + EXPECT_TRUE(storage.endsWith('\n')); + EXPECT_EQ(storage.count('\n'), 1); + + // And it parses back to an identical object. + QJsonObject obj; + bool ok = false; + ASSERT_TRUE(olive::ipc::ReadMessage(&storage, &obj, &ok)); + EXPECT_TRUE(ok); + EXPECT_EQ(obj, hs.ToJson()); +} + +TEST(IpcMessage, WriteMessageFailsOnClosedDevice) +{ + QByteArray storage; + QBuffer device(&storage); // never opened: writes fail + + olive::ipc::CancelMsg cancel; + EXPECT_FALSE(olive::ipc::WriteMessage(&device, cancel.ToJson())); + EXPECT_TRUE(storage.isEmpty()); +} + +TEST(IpcMessage, MessageTypeConstantsAreDistinct) +{ + const QSet types = { + QString::fromUtf8(olive::ipc::msgtype::kHandshake), + QString::fromUtf8(olive::ipc::msgtype::kLoadGraph), + QString::fromUtf8(olive::ipc::msgtype::kRenderFrame), + QString::fromUtf8(olive::ipc::msgtype::kFrameReady), + QString::fromUtf8(olive::ipc::msgtype::kCancel), + QString::fromUtf8(olive::ipc::msgtype::kGraphUpdate), + QString::fromUtf8(olive::ipc::msgtype::kShutdown), + QString::fromUtf8(olive::ipc::msgtype::kError), + }; + EXPECT_EQ(types.size(), 8); + + // Each builder stamps its own constant into the "type" field. + EXPECT_EQ(olive::ipc::HandshakeMsg() + .ToJson() + .value(QStringLiteral("type")) + .toString(), + QLatin1String(olive::ipc::msgtype::kHandshake)); + EXPECT_EQ(olive::ipc::RenderFrameMsg() + .ToJson() + .value(QStringLiteral("type")) + .toString(), + QLatin1String(olive::ipc::msgtype::kRenderFrame)); + EXPECT_EQ(olive::ipc::FrameReadyMsg() + .ToJson() + .value(QStringLiteral("type")) + .toString(), + QLatin1String(olive::ipc::msgtype::kFrameReady)); + EXPECT_EQ(olive::ipc::CancelMsg() + .ToJson() + .value(QStringLiteral("type")) + .toString(), + QLatin1String(olive::ipc::msgtype::kCancel)); + EXPECT_EQ(olive::ipc::LoadGraphMsg() + .ToJson() + .value(QStringLiteral("type")) + .toString(), + QLatin1String(olive::ipc::msgtype::kLoadGraph)); +} + +// ============================================================================ +// RenderWorkerPool (validation paths that never reach a worker process) +// ============================================================================ + +TEST(RenderWorkerPool, RemoveTicketRejectsNull) +{ + olive::DecoderCache cache; + olive::RenderWorkerPool pool(&cache, QStringLiteral("cpu")); + + EXPECT_FALSE(pool.RemoveTicket(nullptr)); +} + +TEST(RenderWorkerPool, RemoveTicketUnknownTicketReturnsFalse) +{ + olive::DecoderCache cache; + olive::RenderWorkerPool pool(&cache, QStringLiteral("cpu")); + + // The pool thread was never started, so the ticket can be neither queued + // nor active. + const olive::RenderTicketPtr ticket = std::make_shared(); + EXPECT_FALSE(pool.RemoveTicket(ticket)); +} + +TEST(RenderWorkerPool, ShutdownWithoutStartIsSafeAndIdempotent) +{ + olive::DecoderCache cache; + olive::RenderWorkerPool pool(&cache, QStringLiteral("cpu")); + + // Shutdown on a pool whose thread never ran must not block or crash; the + // destructor runs it once more when the pool goes out of scope. + pool.Shutdown(); + pool.Shutdown(); + EXPECT_FALSE(pool.isRunning()); +} + +TEST(RenderWorkerPool, SubmitFrameRejectsNullNode) +{ + olive::DecoderCache cache; + olive::RenderWorkerPool pool(&cache, QStringLiteral("cpu")); + + const olive::RenderTicketPtr ticket = std::make_shared(); + EXPECT_FALSE(pool.SubmitFrame( + ticket, + MakeVideoParams(nullptr, olive::VideoParams( + 64, 64, olive::core::PixelFormat::U8, 4)))); + + // A rejected submission must leave the ticket untouched. + EXPECT_FALSE(ticket->IsRunning()); + EXPECT_EQ(ticket->GetFinishCount(), 0); +} + +TEST(RenderWorkerPool, SubmitFrameRejectsInvalidVideoParams) +{ + olive::DecoderCache cache; + olive::RenderWorkerPool pool(&cache, QStringLiteral("cpu")); + + // A real node, but a default (zero-sized) VideoParams fails validation + // before any project resolution or decode work happens. + olive::Track track; + ASSERT_FALSE(olive::VideoParams().is_valid()); + + const olive::RenderTicketPtr ticket = std::make_shared(); + EXPECT_FALSE( + pool.SubmitFrame(ticket, MakeVideoParams(&track, olive::VideoParams()))); + EXPECT_FALSE(ticket->IsRunning()); +} + +TEST(RenderWorkerPool, SubmitFrameRejectsNonFrameReturnType) +{ + olive::DecoderCache cache; + olive::RenderWorkerPool pool(&cache, QStringLiteral("cpu")); + + olive::Track track; + olive::RenderManager::RenderVideoParams params = MakeVideoParams( + &track, olive::VideoParams(64, 64, olive::core::PixelFormat::U8, 4)); + params.return_type = olive::RenderManager::kTexture; + + const olive::RenderTicketPtr ticket = std::make_shared(); + EXPECT_FALSE(pool.SubmitFrame(ticket, params)); + EXPECT_FALSE(ticket->IsRunning()); +} + +// ============================================================================ +// DecoderCache +// ============================================================================ + +TEST(DecoderCache, DefaultPairAndInsertRoundTrip) +{ + olive::DecoderCache cache; + const olive::Decoder::CodecStream stream( + QStringLiteral("/nonexistent/source.mov"), 2, nullptr); + + // Missing entries yield a default DecoderPair. + const olive::DecoderPair missing = cache.value(stream); + EXPECT_EQ(missing.decoder, nullptr); + EXPECT_EQ(missing.last_modified, 0); + + olive::DecoderPair pair; + pair.last_modified = qint64(1700000000123); + cache.insert(stream, pair); + + const olive::DecoderPair fetched = cache.value(stream); + EXPECT_EQ(fetched.decoder, nullptr); + EXPECT_EQ(fetched.last_modified, qint64(1700000000123)); + + // The cache exposes its mutex for locked access (used by the worker pool). + EXPECT_NE(cache.mutex(), nullptr); + + // A different stream is an independent entry. + const olive::Decoder::CodecStream other( + QStringLiteral("/nonexistent/other.mov"), 2, nullptr); + EXPECT_EQ(cache.value(other).last_modified, 0); +}