Files
oak-editor/tests/gtest/render_projectcopier_test.cpp
Mike-Solar 5b68bbeec8 cli/worker/tests: facade-only linkage and updated test suites
oak-cli and oak-render-worker use the C ABI exclusively (0 olive::
symbols). Google Test suites updated for the migrated APIs, new engine
facade test coverage, CLI transcode verification.
2026-07-26 22:43:32 +08:00

866 lines
27 KiB
C++

#include <gtest/gtest.h>
#include <cstring>
#include <memory>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QImage>
#include <QPainter>
#include <QTemporaryDir>
#include <QUuid>
#include <QVector>
#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<olive::Project>();
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 <typename T> 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<olive::Project> project_;
std::unique_ptr<olive::ProjectCopier> copier_;
};
class RenderProjectCopierTest : public RenderCopierTestBase {
};
class RenderPlaybackCacheTest : public RenderCopierTestBase {
};
TEST_F(RenderProjectCopierTest, CopyIsSeparateProjectWithSameStructure)
{
auto *math_a = add_node<olive::MathNode>();
auto *math_b = add_node<olive::MathNode>();
add_node<olive::SolidGenerator>();
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<olive::ProjectCopier>();
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<olive::MathNode>();
copier_ = std::make_unique<olive::ProjectCopier>();
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<olive::MathNode>();
add_node<olive::SolidGenerator>();
copier_ = std::make_unique<olive::ProjectCopier>();
QVector<olive::Node *> 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<olive::ProjectCopier>();
copier_->set_project(project_.get());
QVector<olive::Node *> added;
QObject::connect(copier_.get(), &olive::ProjectCopier::added_node,
[&added](olive::Node *n) { added.append(n); });
auto *math = add_node<olive::MathNode>();
// 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<olive::MathNode>();
copier_ = std::make_unique<olive::ProjectCopier>();
copier_->set_project(project_.get());
olive::Node *copy = copier_->get_copy(math);
ASSERT_NE(copy, nullptr);
QVector<olive::Node *> 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<olive::MathNode>();
auto *dst = add_node<olive::MathNode>();
copier_ = std::make_unique<olive::ProjectCopier>();
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<olive::MathNode>();
copier_ = std::make_unique<olive::ProjectCopier>();
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<olive::MathNode>();
copier_ = std::make_unique<olive::ProjectCopier>();
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<olive::ProjectCopier>();
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<olive::ProjectCopier>();
copier_->set_project(project_.get());
const int copy_count_before =
copier_->get_copied_project()->nodes().size();
auto *group = add_node<olive::NodeGroup>();
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<olive::ProjectCopier>();
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<olive::MathNode>();
// 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<olive::Footage>();
copier_ = std::make_unique<olive::ProjectCopier>();
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<olive::MathNode>();
copier_ = std::make_unique<olive::ProjectCopier>();
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<olive::ProjectCopier>();
copier_->set_project(project_.get());
add_node<olive::MathNode>();
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<olive::SolidGenerator>();
EXPECT_FALSE(copier_->has_updates_in_queue());
}
TEST_F(RenderPlaybackCacheTest, UuidAndSavingFlagAccessors)
{
auto *node = add_node<olive::MathNode>();
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<olive::MathNode>();
TestPlaybackCache cache(node);
QVector<olive::TimeRange> validated_signals;
QVector<olive::TimeRange> 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<olive::MathNode>();
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<olive::MathNode>();
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<olive::MathNode>();
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<olive::MathNode>();
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<olive::MathNode>();
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<olive::MathNode>();
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<olive::TimeRange> 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<olive::MathNode>();
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<olive::MathNode>();
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<olive::MathNode>();
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::MathNode>();
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::MathNode>();
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);
}