#include #include #include #include #include #include #include #include #include #include #include #include "codec/proxymanager.h" #include "core.h" #include "node/color/colormanager/colormanager.h" #include "node/generator/solid/solid.h" #include "node/group/group.h" #include "node/math/math/math.h" #include "node/project.h" #include "node/project/footage/footage.h" #include "render/audioplaybackcache.h" #include "render/diskmanager.h" #include "render/playbackcache.h" #include "render/projectcopier.h" namespace { // Exposes the protected Validate() hook and records the virtual event // callbacks so the invalidate/persist paths can be observed from the tests. class TestPlaybackCache : public olive::PlaybackCache { public: explicit TestPlaybackCache(QObject *parent = nullptr) : olive::PlaybackCache(parent) { } using olive::PlaybackCache::validate; int invalidate_event_count = 0; int load_state_event_count = 0; int save_state_event_count = 0; protected: virtual void InvalidateEvent(const olive::TimeRange &) override { invalidate_event_count++; } virtual void LoadStateEvent(QDataStream &) override { load_state_event_count++; } virtual void SaveStateEvent(QDataStream &) override { save_state_event_count++; } }; } // namespace class RenderCopierTestBase : public ::testing::Test { protected: void SetUp() override { if (!temp_dir_.isValid()) { GTEST_FAIL() << "Failed to create temporary directory"; } olive::ColorManager::set_up_default_config(); if (!olive::Core::instance()) { // Leaked intentionally: Core is process-wide (matches // render_diskcache_test) new olive::Core(); } olive::DiskManager::create_instance(); // Point the project cache at a folder alongside the (unsaved) project // file so every cache read/write stays inside the temporary directory. project_ = std::make_unique(); project_->initialize(); project_->set_filename( QDir(temp_dir_.path()).filePath(QStringLiteral("test.ove"))); project_->set_cache_location_setting( olive::Project::k_cache_store_alongside_project); } void TearDown() override { copier_.reset(); project_.reset(); olive::DiskManager::destroy_instance(); } template T *add_node() { T *node = new T(); node->setParent(project_.get()); return node; } QString cache_root() const { return QDir(temp_dir_.path()).filePath(QStringLiteral("cache")); } QTemporaryDir temp_dir_; std::unique_ptr project_; std::unique_ptr copier_; }; class RenderProjectCopierTest : public RenderCopierTestBase { }; class RenderPlaybackCacheTest : public RenderCopierTestBase { }; TEST_F(RenderProjectCopierTest, CopyIsSeparateProjectWithSameStructure) { auto *math_a = add_node(); auto *math_b = add_node(); add_node(); olive::Node::connect_edge( math_a, olive::NodeInput(math_b, olive::MathNode::k_param_a_in)); project_->set_setting(QStringLiteral("copier_test_key"), QStringLiteral("copier_test_value")); copier_ = std::make_unique(); copier_->set_project(project_.get()); olive::Project *copy = copier_->get_copied_project(); ASSERT_NE(copy, nullptr); EXPECT_NE(copy, project_.get()); // The copy is marked as an in-memory render proxy EXPECT_TRUE(copy->property("_oak_render_proxy").toBool()); // Same number of nodes, same IDs, different objects ASSERT_EQ(copy->nodes().size(), project_->nodes().size()); EXPECT_EQ(copier_->get_node_map().size(), project_->nodes().size()); for (olive::Node *original : project_->nodes()) { olive::Node *cloned = copier_->get_copy(original); ASSERT_NE(cloned, nullptr); EXPECT_NE(cloned, original); EXPECT_EQ(cloned->id(), original->id()); EXPECT_EQ(copier_->get_original(cloned), original); EXPECT_TRUE(copy->nodes().contains(cloned)); } // Settings were copied EXPECT_EQ(copy->get_setting(QStringLiteral("copier_test_key")), QStringLiteral("copier_test_value")); // The pre-existing edge was recreated between the copies olive::Node *copy_a = copier_->get_copy(math_a); olive::Node *copy_b = copier_->get_copy(math_b); ASSERT_EQ(copy_b->input_connections().size(), 1); EXPECT_EQ(copy_b->input_connections().at( olive::NodeInput(copy_b, olive::MathNode::k_param_a_in)), copy_a); // SetProject applies the initial sync synchronously EXPECT_FALSE(copier_->has_updates_in_queue()); } TEST_F(RenderProjectCopierTest, CopiedNodesHaveDisabledCachesAndSharedUuids) { auto *math = add_node(); copier_ = std::make_unique(); copier_->set_project(project_.get()); olive::Node *copy = copier_->get_copy(math); ASSERT_NE(copy, nullptr); // Caches are disabled on the render-proxy copy but keep the same UUIDs so // the copy can read frames written by the original EXPECT_TRUE(math->are_caches_enabled()); EXPECT_FALSE(copy->are_caches_enabled()); EXPECT_EQ(copy->video_frame_cache()->get_uuid(), math->video_frame_cache()->get_uuid()); EXPECT_EQ(copy->audio_playback_cache()->get_uuid(), math->audio_playback_cache()->get_uuid()); } TEST_F(RenderProjectCopierTest, AddedNodeSignalFiresForEachCopiedNode) { add_node(); add_node(); copier_ = std::make_unique(); QVector added; QObject::connect(copier_.get(), &olive::ProjectCopier::added_node, [&added](olive::Node *n) { added.append(n); }); copier_->set_project(project_.get()); // One signal per node in the original project (color manager, root folder, // and the two nodes added above), carrying the *original* pointers EXPECT_EQ(added.size(), project_->nodes().size()); for (olive::Node *n : project_->nodes()) { EXPECT_TRUE(added.contains(n)); } } TEST_F(RenderProjectCopierTest, QueuedNodeAddIsAppliedByProcessUpdateQueue) { copier_ = std::make_unique(); copier_->set_project(project_.get()); QVector added; QObject::connect(copier_.get(), &olive::ProjectCopier::added_node, [&added](olive::Node *n) { added.append(n); }); auto *math = add_node(); // The change is queued, not applied immediately EXPECT_TRUE(copier_->has_updates_in_queue()); EXPECT_EQ(copier_->get_copy(math), nullptr); EXPECT_TRUE(added.isEmpty()); copier_->process_update_queue(); EXPECT_FALSE(copier_->has_updates_in_queue()); olive::Node *copy = copier_->get_copy(math); ASSERT_NE(copy, nullptr); EXPECT_EQ(copy->id(), math->id()); EXPECT_TRUE(copier_->get_copied_project()->nodes().contains(copy)); ASSERT_EQ(added.size(), 1); EXPECT_EQ(added.first(), math); // Processing the queue marked the copy as modified EXPECT_TRUE(copier_->get_copied_project()->is_modified()); } TEST_F(RenderProjectCopierTest, QueuedNodeRemoveDeletesCopy) { auto *math = add_node(); copier_ = std::make_unique(); copier_->set_project(project_.get()); olive::Node *copy = copier_->get_copy(math); ASSERT_NE(copy, nullptr); QVector removed; QObject::connect(copier_.get(), &olive::ProjectCopier::removed_node, [&removed](olive::Node *n) { removed.append(n); }); delete math; EXPECT_TRUE(copier_->has_updates_in_queue()); copier_->process_update_queue(); EXPECT_FALSE(copier_->has_updates_in_queue()); ASSERT_EQ(removed.size(), 1); EXPECT_EQ(removed.first(), math); EXPECT_EQ(copier_->get_copy(math), nullptr); EXPECT_FALSE(copier_->get_copied_project()->nodes().contains(copy)); } TEST_F(RenderProjectCopierTest, QueuedEdgeAddAndRemoveAreMirrored) { auto *src = add_node(); auto *dst = add_node(); copier_ = std::make_unique(); copier_->set_project(project_.get()); olive::Node *copy_src = copier_->get_copy(src); olive::Node *copy_dst = copier_->get_copy(dst); ASSERT_NE(copy_src, nullptr); ASSERT_NE(copy_dst, nullptr); olive::Node::connect_edge( src, olive::NodeInput(dst, olive::MathNode::k_param_a_in)); EXPECT_TRUE(copier_->has_updates_in_queue()); EXPECT_TRUE(copy_dst->input_connections().empty()); copier_->process_update_queue(); ASSERT_EQ(copy_dst->input_connections().size(), 1); EXPECT_EQ(copy_dst->input_connections().at( olive::NodeInput(copy_dst, olive::MathNode::k_param_a_in)), copy_src); olive::Node::disconnect_edge( src, olive::NodeInput(dst, olive::MathNode::k_param_a_in)); copier_->process_update_queue(); EXPECT_TRUE(copy_dst->input_connections().empty()); EXPECT_TRUE(copy_src->output_connections().empty()); } TEST_F(RenderProjectCopierTest, QueuedValueChangeKeepsCopyIndependentUntilProcessed) { auto *math = add_node(); copier_ = std::make_unique(); copier_->set_project(project_.get()); olive::Node *copy = copier_->get_copy(math); ASSERT_NE(copy, nullptr); const double before = math->get_standard_value(olive::MathNode::k_param_a_in).toDouble(); const double changed = before + 2.5; math->set_standard_value(olive::MathNode::k_param_a_in, changed); EXPECT_TRUE(copier_->has_updates_in_queue()); // The copy must not change until the queue is processed EXPECT_DOUBLE_EQ( copy->get_standard_value(olive::MathNode::k_param_a_in).toDouble(), before); copier_->process_update_queue(); EXPECT_DOUBLE_EQ( copy->get_standard_value(olive::MathNode::k_param_a_in).toDouble(), changed); EXPECT_DOUBLE_EQ( math->get_standard_value(olive::MathNode::k_param_a_in).toDouble(), changed); } TEST_F(RenderProjectCopierTest, QueuedValueHintChangeIsMirrored) { auto *math = add_node(); copier_ = std::make_unique(); copier_->set_project(project_.get()); olive::Node *copy = copier_->get_copy(math); ASSERT_NE(copy, nullptr); const olive::Node::ValueHint hint({ olive::NodeValue::k_float }, 7, QStringLiteral("copier_hint")); math->set_value_hint_for_input(olive::MathNode::k_param_a_in, hint); EXPECT_TRUE(copier_->has_updates_in_queue()); copier_->process_update_queue(); const olive::Node::ValueHint copied_hint = copy->get_value_hint_for_input(olive::MathNode::k_param_a_in); EXPECT_EQ(copied_hint.tag(), QStringLiteral("copier_hint")); EXPECT_EQ(copied_hint.index(), 7); ASSERT_EQ(copied_hint.types().size(), 1); EXPECT_EQ(copied_hint.types().first(), olive::NodeValue::k_float); } TEST_F(RenderProjectCopierTest, QueuedProjectSettingChangeIsMirrored) { copier_ = std::make_unique(); copier_->set_project(project_.get()); project_->set_setting(QStringLiteral("copier_late_key"), QStringLiteral("copier_late_value")); EXPECT_TRUE(copier_->has_updates_in_queue()); EXPECT_TRUE(copier_->get_copied_project() ->get_setting(QStringLiteral("copier_late_key")) .isEmpty()); copier_->process_update_queue(); EXPECT_EQ(copier_->get_copied_project()->get_setting( QStringLiteral("copier_late_key")), QStringLiteral("copier_late_value")); } TEST_F(RenderProjectCopierTest, GroupNodesAreNotCopied) { copier_ = std::make_unique(); copier_->set_project(project_.get()); const int copy_count_before = copier_->get_copied_project()->nodes().size(); auto *group = add_node(); copier_->process_update_queue(); // Group nodes are dummies for rendering and must not appear in the copy EXPECT_EQ(copier_->get_copy(group), nullptr); EXPECT_EQ(copier_->get_copied_project()->nodes().size(), copy_count_before); for (olive::Node *n : copier_->get_copied_project()->nodes()) { EXPECT_NE(n->id(), group->id()); } } TEST_F(RenderProjectCopierTest, GraphChangeTimeAdvancesAndSyncCatchesUp) { copier_ = std::make_unique(); copier_->set_project(project_.get()); // SetProject leaves the sync point at or after the graph change point EXPECT_GE(copier_->get_last_update_time().value(), copier_->get_graph_change_time().value()); add_node(); // A queued change moves the graph change point ahead of the sync point EXPECT_GT(copier_->get_graph_change_time().value(), copier_->get_last_update_time().value()); copier_->process_update_queue(); EXPECT_GE(copier_->get_last_update_time().value(), copier_->get_graph_change_time().value()); } TEST_F(RenderProjectCopierTest, FootageProxySettingsSyncToCopyImmediately) { auto *footage = add_node(); copier_ = std::make_unique(); copier_->set_project(project_.get()); olive::Footage *copy = copier_->get_copy(footage); ASSERT_NE(copy, nullptr); EXPECT_FALSE(copy->proxy_enabled()); // Proxy settings are not Node inputs, so the copier mirrors them through a // direct connection without involving the update queue footage->set_proxy(QStringLiteral("/cache/proxy/example.mp4"), olive::ProxyManager::k_proxy_ready, 2, 3, true); EXPECT_FALSE(copier_->has_updates_in_queue()); EXPECT_TRUE(copy->proxy_enabled()); EXPECT_EQ(copy->proxy_path(), QStringLiteral("/cache/proxy/example.mp4")); EXPECT_EQ(copy->proxy_state(), olive::ProxyManager::k_proxy_ready); EXPECT_EQ(copy->proxy_video_stream_index(), 2); EXPECT_EQ(copy->proxy_preset_version(), 3); footage->set_proxy(QString(), olive::ProxyManager::k_proxy_missing, -1, 0, false); EXPECT_FALSE(copy->proxy_enabled()); EXPECT_EQ(copy->proxy_state(), olive::ProxyManager::k_proxy_missing); } TEST_F(RenderProjectCopierTest, SetProjectTwiceReplacesCopyContents) { auto *first = add_node(); copier_ = std::make_unique(); copier_->set_project(project_.get()); ASSERT_NE(copier_->get_copy(first), nullptr); olive::Project second_project; second_project.initialize(); auto *second = new olive::SolidGenerator(); second->setParent(&second_project); copier_->set_project(&second_project); // Nodes from the first project are gone, nodes from the second are present EXPECT_EQ(copier_->get_copy(first), nullptr); EXPECT_NE(copier_->get_copy(second), nullptr); EXPECT_EQ(copier_->get_copied_project()->nodes().size(), second_project.nodes().size()); EXPECT_FALSE(copier_->has_updates_in_queue()); } TEST_F(RenderProjectCopierTest, SetProjectNullStopsTracking) { copier_ = std::make_unique(); copier_->set_project(project_.get()); add_node(); EXPECT_TRUE(copier_->has_updates_in_queue()); copier_->set_project(nullptr); // Pending changes are discarded and the original is no longer tracked EXPECT_FALSE(copier_->has_updates_in_queue()); EXPECT_NE(copier_->get_copied_project(), nullptr); add_node(); EXPECT_FALSE(copier_->has_updates_in_queue()); } TEST_F(RenderPlaybackCacheTest, UuidAndSavingFlagAccessors) { auto *node = add_node(); TestPlaybackCache cache(node); EXPECT_FALSE(cache.get_uuid().isNull()); EXPECT_EQ(cache.parent(), node); EXPECT_TRUE(cache.is_saving_enabled()); EXPECT_NE(cache.mutex(), nullptr); TestPlaybackCache other(node); EXPECT_NE(other.get_uuid(), cache.get_uuid()); const QUuid uuid = QUuid::createUuid(); cache.set_uuid(uuid); EXPECT_EQ(cache.get_uuid(), uuid); cache.set_saving_enabled(false); EXPECT_FALSE(cache.is_saving_enabled()); } TEST_F(RenderPlaybackCacheTest, ValidateAndInvalidateBookkeeping) { auto *node = add_node(); TestPlaybackCache cache(node); QVector validated_signals; QVector invalidated_signals; QObject::connect(&cache, &olive::PlaybackCache::validated, [&validated_signals](const olive::TimeRange &r) { validated_signals.append(r); }); QObject::connect(&cache, &olive::PlaybackCache::invalidated, [&invalidated_signals](const olive::TimeRange &r) { invalidated_signals.append(r); }); const olive::TimeRange whole(olive::Rational(0), olive::Rational(10)); EXPECT_TRUE(cache.has_invalidated_ranges(whole)); EXPECT_FALSE(cache.has_validated_ranges()); cache.validate(whole); EXPECT_TRUE(cache.has_validated_ranges()); EXPECT_TRUE(cache.get_validated_ranges().contains(whole)); EXPECT_FALSE(cache.has_invalidated_ranges(whole)); EXPECT_TRUE(cache.get_invalidated_ranges(olive::Rational(10)).isEmpty()); EXPECT_EQ(cache.invalidate_event_count, 0); ASSERT_EQ(validated_signals.size(), 1); EXPECT_EQ(validated_signals.first(), whole); // A larger query range still reports the remainder as invalidated EXPECT_TRUE(cache.has_invalidated_ranges( olive::TimeRange(olive::Rational(0), olive::Rational(11)))); const olive::TimeRange hole(olive::Rational(2), olive::Rational(5)); cache.invalidate(hole); EXPECT_EQ(cache.invalidate_event_count, 1); ASSERT_EQ(invalidated_signals.size(), 1); EXPECT_EQ(invalidated_signals.first(), hole); EXPECT_TRUE(cache.has_invalidated_ranges(whole)); const olive::TimeRangeList invalidated = cache.get_invalidated_ranges(olive::Rational(10)); ASSERT_EQ(invalidated.size(), 1); EXPECT_EQ(invalidated.first(), hole); } TEST_F(RenderPlaybackCacheTest, InvalidateZeroLengthRangeIsIgnored) { auto *node = add_node(); TestPlaybackCache cache(node); const olive::TimeRange whole(olive::Rational(0), olive::Rational(10)); cache.validate(whole); int invalidated_count = 0; QObject::connect(&cache, &olive::PlaybackCache::invalidated, [&invalidated_count](const olive::TimeRange &) { invalidated_count++; }); // Zero-length invalidations are rejected with a warning cache.invalidate(olive::TimeRange(olive::Rational(5), olive::Rational(5))); EXPECT_EQ(invalidated_count, 0); EXPECT_EQ(cache.invalidate_event_count, 0); EXPECT_TRUE(cache.get_validated_ranges().contains(whole)); } TEST_F(RenderPlaybackCacheTest, GetInvalidatedRangesClampsNegativeTimes) { auto *node = add_node(); TestPlaybackCache cache(node); // Nothing is validated, so the whole (clamped) range is invalidated const olive::TimeRangeList invalidated = cache.get_invalidated_ranges( olive::TimeRange(olive::Rational(-5), olive::Rational(10))); ASSERT_EQ(invalidated.size(), 1); EXPECT_EQ(invalidated.first().in(), olive::Rational(0)); EXPECT_EQ(invalidated.first().out(), olive::Rational(10)); } TEST_F(RenderPlaybackCacheTest, PassthroughCoversInvalidatedRanges) { auto *node = add_node(); TestPlaybackCache source(node); TestPlaybackCache dest(node); const olive::TimeRange whole(olive::Rational(0), olive::Rational(10)); source.validate(whole); dest.set_passthrough(&source); ASSERT_EQ(dest.get_passthroughs().size(), 1); EXPECT_EQ(dest.get_passthroughs().front().cache, source.get_uuid()); EXPECT_EQ(dest.get_passthroughs().front().in(), whole.in()); EXPECT_EQ(dest.get_passthroughs().front().out(), whole.out()); // GetInvalidatedRanges honors passthroughs ... EXPECT_TRUE(dest.get_invalidated_ranges(olive::Rational(10)).isEmpty()); // ... but HasInvalidatedRanges only looks at locally validated ranges EXPECT_TRUE(dest.has_invalidated_ranges(whole)); // Invalidating trims the passthrough too const olive::TimeRange hole(olive::Rational(2), olive::Rational(3)); dest.invalidate(hole); const olive::TimeRangeList invalidated = dest.get_invalidated_ranges(olive::Rational(10)); ASSERT_EQ(invalidated.size(), 1); EXPECT_EQ(invalidated.first(), hole); } TEST_F(RenderPlaybackCacheTest, PassthroughChainsAcrossCaches) { auto *node = add_node(); TestPlaybackCache a(node); TestPlaybackCache b(node); TestPlaybackCache c(node); a.validate(olive::TimeRange(olive::Rational(0), olive::Rational(10))); b.set_passthrough(&a); c.set_passthrough(&b); // c inherits b's passthrough of a ASSERT_EQ(c.get_passthroughs().size(), 1); EXPECT_EQ(c.get_passthroughs().front().cache, a.get_uuid()); EXPECT_TRUE(c.get_invalidated_ranges(olive::Rational(10)).isEmpty()); } TEST_F(RenderPlaybackCacheTest, RequestResignalAndClear) { auto *node = add_node(); TestPlaybackCache cache(node); int requested_count = 0; olive::TimeRange last_requested; QObject::connect(&cache, &olive::PlaybackCache::requested, [&requested_count, &last_requested]( olive::ViewerOutput *context, const olive::TimeRange &r) { EXPECT_EQ(context, nullptr); requested_count++; last_requested = r; }); const olive::TimeRange first(olive::Rational(0), olive::Rational(5)); const olive::TimeRange second(olive::Rational(10), olive::Rational(20)); cache.request(nullptr, first); EXPECT_EQ(requested_count, 1); EXPECT_EQ(last_requested, first); cache.request(nullptr, second); EXPECT_EQ(requested_count, 2); // Both pending ranges are re-signaled cache.resignal_requests(); EXPECT_EQ(requested_count, 4); // Clearing one range leaves the other pending cache.clear_request_range(first); cache.resignal_requests(); EXPECT_EQ(requested_count, 5); EXPECT_EQ(last_requested, second); } TEST_F(RenderPlaybackCacheTest, InvalidateAllClearsEverything) { auto *node = add_node(); TestPlaybackCache cache(node); TestPlaybackCache source(node); source.validate(olive::TimeRange(olive::Rational(0), olive::Rational(10))); cache.validate(olive::TimeRange(olive::Rational(0), olive::Rational(10))); cache.set_passthrough(&source); QVector invalidated_signals; QObject::connect(&cache, &olive::PlaybackCache::invalidated, [&invalidated_signals](const olive::TimeRange &r) { invalidated_signals.append(r); }); cache.invalidate_all(); EXPECT_FALSE(cache.has_validated_ranges()); EXPECT_TRUE(cache.get_passthroughs().empty()); ASSERT_EQ(invalidated_signals.size(), 1); EXPECT_EQ(invalidated_signals.first(), olive::TimeRange(olive::Rational(0), RATIONAL_MAX)); } TEST_F(RenderPlaybackCacheTest, StatePersistsAcrossCaches) { auto *node = add_node(); const QUuid uuid = QUuid::createUuid(); const QUuid source_uuid = QUuid::createUuid(); const olive::TimeRange valid(olive::Rational(5), olive::Rational(15)); const olive::TimeRange pass(olive::Rational(20), olive::Rational(30)); { TestPlaybackCache cache(node); cache.set_uuid(uuid); cache.validate(valid); TestPlaybackCache source(node); source.set_uuid(source_uuid); source.validate(pass); cache.set_passthrough(&source); EXPECT_GE(cache.save_state_event_count, 1); } const QString state_file = QDir(QDir(cache_root()).filePath(uuid.toString())) .filePath(QStringLiteral("state")); ASSERT_TRUE(QFileInfo::exists(state_file)); TestPlaybackCache restored(node); restored.set_uuid(uuid); EXPECT_EQ(restored.load_state_event_count, 1); EXPECT_TRUE(restored.get_validated_ranges().contains(valid)); ASSERT_EQ(restored.get_passthroughs().size(), 1); EXPECT_EQ(restored.get_passthroughs().front().cache, source_uuid); EXPECT_EQ(restored.get_passthroughs().front().in(), pass.in()); EXPECT_EQ(restored.get_passthroughs().front().out(), pass.out()); } TEST_F(RenderPlaybackCacheTest, CacheDirectoryHelpers) { auto *node = add_node(); TestPlaybackCache cache(node); const QUuid uuid = QUuid::createUuid(); cache.set_uuid(uuid); EXPECT_EQ(olive::PlaybackCache::get_this_cache_directory( QStringLiteral("/base/path"), uuid), QDir(QDir(QStringLiteral("/base/path")).filePath( uuid.toString()))); EXPECT_EQ(cache.get_this_cache_directory(), QDir(QDir(cache_root()).filePath(uuid.toString()))); EXPECT_GT(olive::PlaybackCache::get_cache_indicator_height(), 0); } TEST_F(RenderPlaybackCacheTest, DrawPaintsValidatedRangesGreen) { auto *node = add_node(); TestPlaybackCache cache(node); cache.validate(olive::TimeRange(olive::Rational(2), olive::Rational(5))); QImage image(100, 10, QImage::Format_RGB32); image.fill(Qt::black); { QPainter painter(&image); cache.draw(&painter, olive::Rational(0), 10.0, QRect(0, 0, 100, 10)); } // 10 px/s: validated seconds [2,5) cover pixels [20,50) EXPECT_EQ(image.pixelColor(30, 5), QColor(Qt::green)); EXPECT_EQ(image.pixelColor(10, 5), QColor(Qt::red)); EXPECT_EQ(image.pixelColor(60, 5), QColor(Qt::red)); } TEST_F(RenderPlaybackCacheTest, AudioParametersRoundTrip) { auto *node = add_node(); olive::AudioPlaybackCache cache(node); const olive::AudioParams params(48000, olive::k_channel_layout_stereo, olive::SampleFormat::f32_p); cache.set_parameters(params); EXPECT_TRUE(cache.get_parameters() == params); EXPECT_EQ(cache.get_parameters().sample_rate(), 48000); EXPECT_EQ(cache.get_parameters().channel_count(), 2); // Setting identical parameters again takes the no-op path cache.set_parameters(params); EXPECT_TRUE(cache.get_parameters() == params); const olive::AudioParams other(44100, olive::k_channel_layout_mono, olive::SampleFormat::f32_p); cache.set_parameters(other); EXPECT_EQ(cache.get_parameters().sample_rate(), 44100); EXPECT_EQ(cache.get_parameters().channel_count(), 1); } // NOTE: AudioPlaybackCache::WriteSilence() is intentionally not exercised: it // calls WritePCM() with an empty SampleBuffer, which makes // WritePartOfSampleBuffer() loop forever (the write cursor never advances when // the buffer provides no bytes). See the bug notes in the test report. TEST_F(RenderPlaybackCacheTest, WritePcmValidatesRangesAndWritesSegments) { auto *node = add_node(); olive::AudioPlaybackCache cache(node); const olive::AudioParams params(48000, olive::k_channel_layout_stereo, olive::SampleFormat::f32_p); cache.set_parameters(params); // Two adjacent 0.1s ranges at 48kHz stereo float const olive::TimeRange r1(olive::Rational(0), olive::Rational(1, 10)); const olive::TimeRange r2(olive::Rational(1, 10), olive::Rational(1, 5)); const olive::TimeRange whole(olive::Rational(0), olive::Rational(1, 5)); const qint64 total_bytes = params.time_to_bytes_per_channel(whole.length()); const qint64 range_bytes = params.time_to_bytes_per_channel(r1.length()); ASSERT_GT(total_bytes, 0); ASSERT_GT(range_bytes, 0); olive::SampleBuffer samples(params, size_t(params.time_to_samples(whole.length()))); ASSERT_TRUE(samples.is_allocated()); for (int ch = 0; ch < params.channel_count(); ch++) { for (int64_t i = 0; i < params.time_to_samples(whole.length()); i++) { samples.data(ch)[i] = 0.0001f * float(i) + float(ch); } } int validated_count = 0; QObject::connect(&cache, &olive::PlaybackCache::validated, [&validated_count](const olive::TimeRange &) { validated_count++; }); cache.write_pcm(whole, { r1, r2 }, samples); // Adjacent ranges merge into one validated block EXPECT_EQ(validated_count, 2); EXPECT_TRUE(cache.get_validated_ranges().contains(r1)); EXPECT_TRUE(cache.get_validated_ranges().contains(r2)); EXPECT_FALSE(cache.has_invalidated_ranges(whole)); // One segment file per channel in this cache's directory const QDir cache_dir = cache.get_this_cache_directory(); const QString seg_ch0 = cache_dir.filePath(QStringLiteral("0.0")); const QString seg_ch1 = cache_dir.filePath(QStringLiteral("0.1")); ASSERT_TRUE(QFileInfo::exists(seg_ch0)); ASSERT_TRUE(QFileInfo::exists(seg_ch1)); // The segment starts with exactly the PCM data that was written QFile f0(seg_ch0); ASSERT_TRUE(f0.open(QFile::ReadOnly)); const QByteArray raw0 = f0.read(total_bytes); ASSERT_EQ(raw0.size(), total_bytes); EXPECT_EQ(std::memcmp(raw0.constData(), samples.data(0), size_t(total_bytes)), 0); QFile f1(seg_ch1); ASSERT_TRUE(f1.open(QFile::ReadOnly)); const QByteArray raw1 = f1.read(total_bytes); ASSERT_EQ(raw1.size(), total_bytes); EXPECT_EQ(std::memcmp(raw1.constData(), samples.data(1), size_t(total_bytes)), 0); }