Files
oak-editor/tests/gtest/plugin_node_test.cpp
Mike-Solar bb40b4923e style: unify identifier naming per updated conventions
Automated with clang-tidy readability-identifier-naming (config added to
.clang-tidy) plus scripted passes, per the updated rules now documented
in CONTRIBUTING.md:

- types (class/struct/enum/alias/template params): PascalCase
- functions, variables, members: snake_case (incl. rational -> Rational)
- private/protected members: trailing underscore; static member
  variables likewise (instance_, available_themes_)
- constants and enum values: snake_case (kLinear -> k_linear,
  F32P -> f32p); ALL_CAPS reserved for macros
- macros: OAK_ prefix (OLIVE_ADD_TEST/OLIVE_ASSERT/OLIVE_CONFIG ->
  OAK_ADD_TEST/OAK_ASSERT/OAK_CONFIG, GL_PREAMBLE -> OAK_GL_PREAMBLE,
  include guards -> OAK_*)
- file names: all lowercase (Current/Plugin/OliveHost/OliveClip/
  OlivePluginInstance -> current/plugin/olivehost/oliveclip/
  oliveplugininstance)
- getters share the member name sans underscore, setters set_foo()
- Qt and third-party (OpenFX) virtual overrides and framework callbacks
  keep their original names (exempt in .clang-tidy)

Manual follow-ups required where automation could not reach:
- string-based QMetaObject/SIGNAL/SLOT references updated to renamed
  methods (AddTask, CreatedFile, DeleteSpecificFile, moveSelectionUp, ...)
- macro bodies referencing renamed methods (OLIVE_CONFIG,
  NODE_DEFAULT_DESTRUCTOR, MANAGEDDISPLAYWIDGET_*)
- self-shadowing locals renamed where signals/methods became same-named
  (size_changed, worker_count, selected_items, import param, filters)
- third_party OFX member/namespace usages restored (OFX::Host::*,
  _created, _clipPrefsDirty, createInstance, clearPersistentMessage)
- STL protocol aliases restored (const_iterator) with .clang-tidy
  ignore rules; qHash overloads restored

Full build and test suite pass: ctest 4/4, ~1960 gtest cases green.
2026-07-19 16:10:54 +08:00

407 lines
14 KiB
C++

/*
* Oak Video Editor - Plugin Node / OFX Host Tests
* Copyright (C) 2025 Olive CE Team
*
* CPU-only tests for app/pluginSupport/OliveHost.cpp and the constructible
* surface of app/node/plugins/Plugin.h.
*
* PluginNode and OlivePluginInstance cannot be instantiated in a unit test:
* PluginNode's constructor dereferences an OFX::Host::ImageEffect::Instance,
* and the HostSupport Instance constructor requires a plugin binary that
* dlopen()s successfully (ofxhImageEffect.cpp calls
* plugin->getPluginHandle()->getOfxPlugin(), which needs the OfxGetPlugin
* symbol of a real .ofx bundle). The host-side surface (pluginSupported,
* descriptor factory, message routing, loadPlugins) is exercised here with a
* fake ImageEffectPlugin backed by a deliberately invalid PluginBinary.
*/
#include <gtest/gtest.h>
#include <cstdarg>
#include <memory>
#include <string>
#include <QDir>
#include <QFile>
#include <QGuiApplication>
#include <QTemporaryDir>
#include "ofxCore.h"
#include "ofxImageEffect.h"
#include "ofxMessage.h"
#include "ofxhImageEffect.h"
#include "ofxhPluginCache.h"
#include "common/current.h"
#include "node/plugins/plugin.h"
#include "pluginSupport/olivehost.h"
#include "version.h"
namespace
{
// Paths that never exist on disk: the PluginBinary stats the file, marks
// itself invalid, and every code path used below tolerates that without ever
// calling dlopen().
constexpr char k_fake_bundle_path[] = "/nonexistent/Fake.ofx.bundle";
constexpr char k_fake_binary_path[] =
"/nonexistent/Fake.ofx.bundle/Contents/Linux-x86-64/Fake.ofx";
constexpr char k_fake_plugin_id[] = "com.oak.test.FakePlugin";
// Builds an OliveHost, an ImageEffect::PluginCache bound to it (which sets
// the global gImageEffectHost), an invalid PluginBinary, and a fake
// ImageEffectPlugin whose construction runs through OliveHost::makeDescriptor.
// The saver member restores gImageEffectHost on destruction so other tests
// keep observing the global state they set up themselves.
struct FakePluginHarness {
struct HostGlobalSaver {
HostGlobalSaver()
: previous(OFX::Host::ImageEffect::gImageEffectHost)
{
}
~HostGlobalSaver()
{
OFX::Host::ImageEffect::gImageEffectHost = previous;
}
OFX::Host::ImageEffect::Host *previous;
};
HostGlobalSaver saver;
olive::plugin::OliveHost host;
OFX::Host::ImageEffect::PluginCache cache{ host };
OFX::Host::PluginBinary binary{ k_fake_binary_path, k_fake_bundle_path, 0, 0 };
OFX::Host::ImageEffect::ImageEffectPlugin plugin{ cache, &binary, 0,
kOfxImageEffectPluginApi,
1,
k_fake_plugin_id,
k_fake_plugin_id,
1,
0 };
};
OfxStatus call_v_message(olive::plugin::OliveHost &host, const char *type,
const char *id, const char *format, ...)
{
va_list args;
va_start(args, format);
const OfxStatus status = host.vmessage(type, id, format, args);
va_end(args);
return status;
}
OfxStatus call_set_persistent_message(olive::plugin::OliveHost &host,
const char *type, const char *id,
const char *format, ...)
{
va_list args;
va_start(args, format);
const OfxStatus status = host.setPersistentMessage(type, id, format, args);
va_end(args);
return status;
}
} // namespace
// ============================================================================
// OliveHost::pluginSupported
// ============================================================================
TEST(OliveHost, PluginSupportedRejectsNullPlugin)
{
FakePluginHarness harness;
std::string reason;
EXPECT_FALSE(harness.host.pluginSupported(nullptr, reason));
EXPECT_EQ(reason, "null plugin");
}
TEST(OliveHost, PluginSupportedRejectsPluginWithoutContexts)
{
FakePluginHarness harness;
// The fake plugin was never described, so it supports no contexts.
std::string reason;
EXPECT_FALSE(harness.host.pluginSupported(&harness.plugin, reason));
EXPECT_EQ(reason, "no supported contexts (describe failed)");
}
TEST(OliveHost, PluginSupportedAcceptsPluginWithKnownContext)
{
FakePluginHarness harness;
harness.plugin.addContext(kOfxImageEffectContextFilter);
std::string reason;
EXPECT_TRUE(harness.host.pluginSupported(&harness.plugin, reason));
EXPECT_TRUE(reason.empty());
}
TEST(OliveHost, FakePluginExposesConstructionMetadata)
{
FakePluginHarness harness;
EXPECT_EQ(harness.plugin.getIdentifier(), k_fake_plugin_id);
EXPECT_EQ(harness.plugin.getVersionMajor(), 1);
EXPECT_EQ(harness.plugin.getVersionMinor(), 0);
// Construction went through OliveHost::makeDescriptor(plugin), which
// stamps the descriptor with the binary's bundle path.
EXPECT_EQ(harness.plugin.getDescriptor().getProps().getStringProperty(
kOfxPluginPropFilePath),
k_fake_bundle_path);
}
// ============================================================================
// OliveHost::makeDescriptor (descriptor store)
// ============================================================================
TEST(OliveHost, MakeDescriptorFromBundlePathRecordsFilePath)
{
FakePluginHarness harness;
auto first = harness.host.makeDescriptor(
std::string("/nonexistent/One.ofx.bundle"), nullptr);
auto second = harness.host.makeDescriptor(
std::string("/nonexistent/Two.ofx.bundle"), nullptr);
ASSERT_NE(first, nullptr);
ASSERT_NE(second, nullptr);
EXPECT_NE(first, second);
EXPECT_EQ(first->getProps().getStringProperty(kOfxPluginPropFilePath),
"/nonexistent/One.ofx.bundle");
EXPECT_EQ(second->getProps().getStringProperty(kOfxPluginPropFilePath),
"/nonexistent/Two.ofx.bundle");
EXPECT_EQ(first->getProps().getStringProperty(kOfxPropType),
kOfxTypeImageEffect);
}
TEST(OliveHost, MakeDescriptorFromRootContextCopiesProperties)
{
FakePluginHarness harness;
auto root = harness.host.makeDescriptor(
std::string("/nonexistent/Root.ofx.bundle"), nullptr);
ASSERT_NE(root, nullptr);
root->getProps().setStringProperty(kOfxPropLabel, "Root Label");
auto desc = harness.host.makeDescriptor(*root, &harness.plugin);
ASSERT_NE(desc, nullptr);
// Properties are inherited from the root context ...
EXPECT_EQ(desc->getProps().getStringProperty(kOfxPropLabel),
"Root Label");
// ... while the file path is stamped from the plugin's own binary.
EXPECT_EQ(desc->getProps().getStringProperty(kOfxPluginPropFilePath),
k_fake_bundle_path);
}
// ============================================================================
// OliveHost::destroyInstance
// ============================================================================
TEST(OliveHost, DestroyInstanceIgnoresNull)
{
olive::plugin::OliveHost host;
EXPECT_NO_THROW(host.destroy_instance(nullptr));
}
// ============================================================================
// OliveHost message routing
//
// With a QApplication on a non-offscreen platform the successful
// vmessage/setPersistentMessage paths pop modal QMessageBox dialogs, which a
// headless test cannot dismiss; on the offscreen platform they log to stderr
// instead, so those paths are exercised here behind a platform check.
// ============================================================================
TEST(OliveHost, VMessageRejectsNullArguments)
{
olive::plugin::OliveHost host;
EXPECT_EQ(call_v_message(host, nullptr, "id", "%s", "x"), kOfxStatFailed);
EXPECT_EQ(call_v_message(host, kOfxMessageError, "id", nullptr),
kOfxStatFailed);
}
TEST(OliveHost, SetPersistentMessageRejectsNullArguments)
{
olive::plugin::OliveHost host;
EXPECT_EQ(call_set_persistent_message(host, nullptr, "id", "%s", "x"),
kOfxStatFailed);
EXPECT_EQ(call_set_persistent_message(host, kOfxMessageError, "id", nullptr),
kOfxStatFailed);
}
TEST(OliveHost, SetPersistentMessageRejectsUnknownType)
{
olive::plugin::OliveHost host;
// A type that is neither error, warning, nor message fails before any
// dialog would be shown.
EXPECT_EQ(call_set_persistent_message(host, "OfxMessageBogus", "id", "%s",
"hello"),
kOfxStatFailed);
}
TEST(OliveHost, VMessageOffscreenLogsInsteadOfDialog)
{
if (QGuiApplication::platformName() != QLatin1String("offscreen")) {
GTEST_SKIP() << "requires the offscreen QPA platform";
}
olive::plugin::OliveHost host;
// No modal dialog is shown on the offscreen platform; the message is
// logged to stderr and acknowledged.
EXPECT_EQ(call_v_message(host, kOfxMessageError, "id", "%s", "boom"),
kOfxStatOK);
EXPECT_EQ(call_v_message(host, kOfxMessageWarning, "id", "%s", "boom"),
kOfxStatOK);
EXPECT_EQ(call_v_message(host, kOfxMessageMessage, "id", "%s", "boom"),
kOfxStatOK);
// A question cannot be answered headlessly, so it is a "no".
EXPECT_EQ(call_v_message(host, kOfxMessageQuestion, "id", "%s", "boom"),
kOfxStatReplyNo);
}
TEST(OliveHost, SetPersistentMessageOffscreenSucceeds)
{
if (QGuiApplication::platformName() != QLatin1String("offscreen")) {
GTEST_SKIP() << "requires the offscreen QPA platform";
}
olive::plugin::OliveHost host;
EXPECT_EQ(call_set_persistent_message(host, kOfxMessageError, "id", "%s",
"boom"),
kOfxStatOK);
EXPECT_EQ(call_set_persistent_message(host, kOfxMessageWarning, "id", "%s",
"boom"),
kOfxStatOK);
EXPECT_EQ(call_set_persistent_message(host, kOfxMessageMessage, "id", "%s",
"boom"),
kOfxStatOK);
}
TEST(OliveHost, ClearPersistentMessageSucceeds)
{
olive::plugin::OliveHost host;
EXPECT_EQ(host.clearPersistentMessage(), kOfxStatOK);
}
// ============================================================================
// OliveHost suite/property surface (inherited from OFX::Host::ImageEffect::Host)
// ============================================================================
TEST(OliveHost, FetchSuiteProvidesExpectedSuites)
{
olive::plugin::OliveHost host;
EXPECT_NE(host.fetchSuite(kOfxImageEffectSuite, 1), nullptr);
EXPECT_EQ(host.fetchSuite(kOfxImageEffectSuite, 2), nullptr);
EXPECT_NE(host.fetchSuite(kOfxPropertySuite, 1), nullptr);
EXPECT_NE(host.fetchSuite(kOfxMemorySuite, 1), nullptr);
EXPECT_NE(host.fetchSuite(kOfxMessageSuite, 1), nullptr);
EXPECT_NE(host.fetchSuite(kOfxMessageSuite, 2), nullptr);
EXPECT_NE(host.fetchSuite(kOfxParameterSuite, 1), nullptr);
EXPECT_EQ(host.fetchSuite("com.oak.BogusSuite", 1), nullptr);
}
TEST(OliveHost, HostPropertiesIdentifyAsOfxHost)
{
// HostSupport seeds the host property set with the literal "Host"
// (ofxhHost.cpp hostStuffs), not kOfxTypeImageEffectHost.
olive::plugin::OliveHost host;
EXPECT_EQ(host.getProperties().getStringProperty(kOfxPropType), "Host");
}
TEST(OliveHost, HostPropertiesIdentifyApplication)
{
// The ctor stamps the app identity over HostSupport's "UNKNOWN" defaults
// so plugins querying the host description see real values.
olive::plugin::OliveHost host;
const auto &props = host.getProperties();
EXPECT_EQ(props.getStringProperty(kOfxPropName), "Oak Video Editor");
EXPECT_EQ(props.getStringProperty(kOfxPropLabel), "Oak Video Editor");
EXPECT_EQ(props.getStringProperty(kOfxPropVersionLabel),
olive::k_app_version.toStdString());
const QStringList version_parts =
olive::k_app_version.section(QLatin1Char('-'), 0, 0)
.split(QLatin1Char('.'));
EXPECT_EQ(props.getIntProperty(kOfxPropVersion, 0),
version_parts.value(0).toInt());
EXPECT_EQ(props.getIntProperty(kOfxPropVersion, 1),
version_parts.value(1).toInt());
EXPECT_EQ(props.getIntProperty(kOfxPropVersion, 2),
version_parts.value(2).toInt());
}
#ifdef OFX_SUPPORTS_OPENGLRENDER
TEST(OliveHost, FlushOpenGLResourcesReportsFailure)
{
// No GL context exists in a headless test; the host must report failure
// rather than crash.
olive::plugin::OliveHost host;
EXPECT_EQ(host.flushOpenGLResources(), kOfxStatFailed);
}
#endif
// ============================================================================
// olive::plugin::loadPlugins
// ============================================================================
TEST(OliveHost, LoadPluginsInitializesAndReusesCurrentHost)
{
olive::plugin::load_plugins(QString());
std::shared_ptr<olive::plugin::OliveHost> host =
Current::getInstance().plugin_host();
std::shared_ptr<OFX::Host::ImageEffect::PluginCache> cache =
Current::getInstance().plugin_cache();
ASSERT_NE(host, nullptr);
ASSERT_NE(cache, nullptr);
// A second call must reuse the already-created host and cache.
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
olive::plugin::load_plugins(dir.path());
EXPECT_EQ(Current::getInstance().plugin_host(), host);
EXPECT_EQ(Current::getInstance().plugin_cache(), cache);
}
TEST(OliveHost, LoadPluginsScansBundleWithoutValidBinary)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
// Minimal .ofx.bundle layout with a file that is not a loadable shared
// object: the scanner must mark it invalid and move on.
const QString arch_dir = dir.filePath(QStringLiteral(
"Fake.ofx.bundle/Contents/Linux-x86-64"));
ASSERT_TRUE(QDir().mkpath(arch_dir));
QFile fake_binary(dir.filePath(QStringLiteral(
"Fake.ofx.bundle/Contents/Linux-x86-64/Fake.ofx")));
ASSERT_TRUE(fake_binary.open(QIODevice::WriteOnly));
fake_binary.write("not a shared object");
fake_binary.close();
EXPECT_NO_THROW(olive::plugin::load_plugins(dir.path()));
EXPECT_NE(OFX::Host::PluginCache::getPluginCache(), nullptr);
// The invalid bundle must not have registered any plugin.
for (auto *plug : OFX::Host::PluginCache::getPluginCache()->getPlugins()) {
ASSERT_NE(plug, nullptr);
EXPECT_NE(plug->getIdentifier(), "Fake");
}
}
// ============================================================================
// PluginNode
//
// See the file header: PluginNode needs a real OFX instance and cannot be
// built without a plugin bundle. What remains constructible is the fallback
// input id the node uses when an effect declares no usable source clip.
// ============================================================================
TEST(PluginNode, TextureInputFallbackIdIsStable)
{
EXPECT_EQ(olive::plugin::k_texture_input, QStringLiteral("tex_in"));
}