test: replace fake and duplicate tests with real assertions

Fake tests rewritten to assert real behavior:
- audio_smoke: conversion tests now actually Convert() samples and verify
  output; waveform length/summary assertions tightened to exact values
- plugin_format_conversion: RowBytes/U8ToU16/LoadImageFile now call real
  production code (VideoParams::GetBytesPerPixel, sws scaler, OIIO decode
  of tests/img.png with known pixel values)
- core_color: HSV round trip now verifies fromHsv(toHsv(c)) == c instead
  of comparing toHsv against its own accessors
- core_bezier/node_inputimmediate: expected values replaced with
  independently derived constants instead of re-running the code under test
- common_commandlineparser/common_debug/common_jobtime: capture
  stdout/stderr/qDebug and assert actual output content
- proxy_manager: ProxyFinished test now drives a real proxy job instead of
  emitting the signal itself; proxy_dialog/panel/proxy/preferences/timeruler
  tests assert real widget state
- viewer_smoke/preview_autocacher/render_misc: zero-assertion tests given
  observable-state assertions or removed where nothing is observable

Duplicates removed:
- plugin_smoke_test.cpp: 18 tests duplicated from plugin_paraminstance /
  plugin_support_* / plugin_renderer_readback (751 -> 180 lines)
- module_smoke HumanStrings tests covered precisely by ui_humanstrings_test
- render_misc duplicate kDefaultInterpolation constant check

Removed by policy (skip allowed, never disabled):
- all DISABLED_ prefixes: re-enabled as real offscreen tests or deleted
- ffmpeg_decoder_hw: hardcoded personal path replaced with
  OAK_TEST_HW_DECODE_FILE env var, GTEST_SKIP when unset

Also:
- config_test: restore Config defaults after run (cross-test pollution)
- render_worker_footage: drop /tmp debug-output scaffolding
- previewaudiodevice construction test asserts the real bugfix
This commit is contained in:
2026-07-19 13:58:31 +08:00
parent b0aa683499
commit cb1718a103
37 changed files with 1560 additions and 1038 deletions
+99 -21
View File
@@ -16,6 +16,7 @@
#include <QXmlStreamWriter>
#include "audio/audiomanager.h"
#include "config/config.h"
#include "node/block/clip/clip.h"
#include "node/color/colormanager/colormanager.h"
#include "node/generator/solid/solid.h"
@@ -51,10 +52,28 @@ TEST_F(AudioManagerTest, InstanceLifecycle)
olive::AudioManager::CreateInstance();
EXPECT_EQ(olive::AudioManager::instance(), first);
// Whatever PortAudio reports, the stored indices are either a valid
// device index or paNoDevice
EXPECT_GE(olive::AudioManager::instance()->GetOutputDevice(), paNoDevice);
EXPECT_GE(olive::AudioManager::instance()->GetInputDevice(), paNoDevice);
// The stored indices are either paNoDevice or a valid device index with
// channels in the appropriate direction
const PaDeviceIndex output =
olive::AudioManager::instance()->GetOutputDevice();
const PaDeviceIndex input = olive::AudioManager::instance()->GetInputDevice();
if (Pa_GetDeviceCount() == 0) {
// No devices exist, so nothing could have been selected
EXPECT_EQ(output, paNoDevice);
EXPECT_EQ(input, paNoDevice);
} else {
if (output != paNoDevice) {
ASSERT_GE(output, 0);
ASSERT_LT(output, Pa_GetDeviceCount());
EXPECT_GT(Pa_GetDeviceInfo(output)->maxOutputChannels, 0);
}
if (input != paNoDevice) {
ASSERT_GE(input, 0);
ASSERT_LT(input, Pa_GetDeviceCount());
EXPECT_GT(Pa_GetDeviceInfo(input)->maxInputChannels, 0);
}
}
}
TEST_F(AudioManagerTest, SetAndGetNoDevice)
@@ -101,13 +120,16 @@ TEST_F(AudioManagerTest, StartRecordingWithoutInputDeviceFails)
TEST_F(AudioManagerTest, OutputControlsWithoutStreamAreNoOps)
{
olive::AudioManager::instance()->SetOutputDevice(paNoDevice);
// No output stream is open; all of these must be harmless no-ops
olive::AudioManager::instance()->StopOutput();
olive::AudioManager::instance()->ClearBufferedOutput();
olive::AudioManager::instance()->SetOutputNotifyInterval(64);
olive::AudioManager::instance()->SetOutputNotifyInterval(0);
SUCCEED();
// ...and they must not disturb the device bookkeeping
EXPECT_EQ(olive::AudioManager::instance()->GetOutputDevice(), paNoDevice);
}
TEST_F(AudioManagerTest, HardResetKeepsManagerUsable)
@@ -119,36 +141,92 @@ TEST_F(AudioManagerTest, HardResetKeepsManagerUsable)
EXPECT_EQ(olive::AudioManager::instance()->GetOutputDevice(), paNoDevice);
}
TEST_F(AudioManagerTest, FindDeviceByNameReturnsValidIndexOrNoDevice)
TEST_F(AudioManagerTest, FindDeviceByNameFallsBackForUnknownName)
{
// A name that matches nothing must never produce a garbage index. When no
// devices exist there is nothing to fall back to and the result must be
// exactly paNoDevice; otherwise the fallback is a preferred/default
// device, i.e. a valid index or paNoDevice.
const PaDeviceIndex bogus_output = olive::AudioManager::FindDeviceByName(
QStringLiteral("OakNoSuchAudioDevice12345"), true);
EXPECT_TRUE(bogus_output == paNoDevice ||
(bogus_output >= 0 && bogus_output < Pa_GetDeviceCount()));
const PaDeviceIndex bogus_input = olive::AudioManager::FindDeviceByName(
QStringLiteral("OakNoSuchAudioDevice12345"), false);
if (Pa_GetDeviceCount() == 0) {
EXPECT_EQ(bogus_output, paNoDevice);
EXPECT_EQ(bogus_input, paNoDevice);
} else {
EXPECT_TRUE(bogus_output == paNoDevice ||
(bogus_output >= 0 && bogus_output < Pa_GetDeviceCount()));
EXPECT_TRUE(bogus_input == paNoDevice ||
(bogus_input >= 0 && bogus_input < Pa_GetDeviceCount()));
// An empty name falls back to the default/preferred device
const PaDeviceIndex fallback =
olive::AudioManager::FindDeviceByName(QString(), true);
EXPECT_TRUE(fallback == paNoDevice ||
(fallback >= 0 && fallback < Pa_GetDeviceCount()));
}
}
TEST_F(AudioManagerTest, FindConfigDeviceByNameReturnsValidIndexOrNoDevice)
TEST_F(AudioManagerTest, FindDeviceByNameFindsExactMatch)
{
if (Pa_GetDeviceCount() == 0) {
GTEST_SKIP() << "No PortAudio devices available on this system";
}
// Searching the exact name of a device must return that device's index.
// On Linux a match on a non-preferred backend (e.g. ALSA) may legitimately
// be upgraded to a preferred device, so only a device that already sits on
// a preferred host API (PipeWire/JACK/PulseAudio) gives an exact contract.
for (PaDeviceIndex i = 0, end = Pa_GetDeviceCount(); i < end; i++) {
const PaDeviceInfo *info = Pa_GetDeviceInfo(i);
if (!info || !info->maxOutputChannels) {
continue;
}
#ifdef Q_OS_LINUX
const PaHostApiInfo *api = Pa_GetHostApiInfo(info->hostApi);
if (!api) {
continue;
}
const QString api_name = QString::fromLatin1(api->name);
if (!api_name.contains(QStringLiteral("PipeWire"),
Qt::CaseInsensitive) &&
!api_name.contains(QStringLiteral("JACK"), Qt::CaseInsensitive) &&
!api_name.contains(QStringLiteral("PulseAudio"),
Qt::CaseInsensitive)) {
continue;
}
#endif
EXPECT_EQ(olive::AudioManager::FindDeviceByName(
QString::fromLatin1(info->name), true),
i);
return;
}
GTEST_SKIP() << "No output device on a preferred host API on this system";
}
TEST_F(AudioManagerTest, FindConfigDeviceByNameMatchesConfiguredLookup)
{
// The config-driven lookup must be exactly FindDeviceByName applied to the
// configured name, and must never return a garbage index
const PaDeviceIndex output =
olive::AudioManager::FindConfigDeviceByName(true);
EXPECT_TRUE(output == paNoDevice ||
(output >= 0 && output < Pa_GetDeviceCount()));
const PaDeviceIndex input =
olive::AudioManager::FindConfigDeviceByName(false);
EXPECT_TRUE(input == paNoDevice ||
(input >= 0 && input < Pa_GetDeviceCount()));
EXPECT_EQ(output,
olive::AudioManager::FindDeviceByName(
olive::Config::Current()[QStringLiteral("AudioOutput")]
.toString(),
true));
EXPECT_EQ(input,
olive::AudioManager::FindDeviceByName(
olive::Config::Current()[QStringLiteral("AudioInput")]
.toString(),
false));
if (Pa_GetDeviceCount() == 0) {
EXPECT_EQ(output, paNoDevice);
EXPECT_EQ(input, paNoDevice);
}
}
TEST_F(AudioManagerTest, PortAudioParamsReflectAudioParams)
+251 -49
View File
@@ -12,6 +12,10 @@
#include <gtest/gtest.h>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <QCoreApplication>
#include <QThread>
#include <QPainter>
@@ -56,6 +60,30 @@ static void FillSampleBuffer(SampleBuffer &buffer, float value)
}
}
// Pushes input through the processor, then flushes and drains everything the
// filter graph still holds, returning the accumulated per-plane output.
// Draining after a flush ends at EOF, which AudioProcessor reports as a
// negative return value, so the final Convert result is intentionally unused.
static AudioProcessor::Buffer ConvertAndDrain(AudioProcessor &processor,
float **input, int nb_samples)
{
AudioProcessor::Buffer output;
EXPECT_GE(processor.Convert(input, nb_samples, &output), 0);
processor.Flush();
AudioProcessor::Buffer rest;
processor.Convert(nullptr, 0, &rest);
if (output.size() < rest.size()) {
output.resize(rest.size());
}
for (int i = 0; i < rest.size(); i++) {
output[i].append(rest.at(i));
}
return output;
}
// ============================================================================
// Smoke Test: AudioParams
// ============================================================================
@@ -362,7 +390,8 @@ TEST(AudioSmokeWaveform, OverwriteSamples)
// Write samples to waveform
waveform.OverwriteSamples(buffer, 48000, rational(0));
EXPECT_GT(waveform.length(), rational(0));
// 4800 samples at 48000 Hz is exactly 0.1 seconds
EXPECT_EQ(waveform.length(), rational(1, 10));
}
TEST(AudioSmokeWaveform, OverwriteSilence)
@@ -376,13 +405,20 @@ TEST(AudioSmokeWaveform, OverwriteSilence)
FillSampleBuffer(buffer, 0.5f);
waveform.OverwriteSamples(buffer, 48000, rational(0));
rational original_length = waveform.length();
// Overwrite with silence
waveform.OverwriteSilence(rational(0), rational(1, 10)); // 0.1 seconds
// Length should be at least as long as original
EXPECT_GE(waveform.length(), original_length);
// The silence covers exactly the written region, so the length is
// unchanged at exactly 0.1 seconds
EXPECT_EQ(waveform.length(), rational(1, 10));
// ...and the overwritten region is actually silent
auto summary = waveform.GetSummaryFromTime(rational(0), rational(1, 10));
ASSERT_EQ(summary.size(), 2);
EXPECT_FLOAT_EQ(summary[0].min, 0.0f);
EXPECT_FLOAT_EQ(summary[0].max, 0.0f);
EXPECT_FLOAT_EQ(summary[1].min, 0.0f);
EXPECT_FLOAT_EQ(summary[1].max, 0.0f);
}
TEST(AudioSmokeWaveform, TrimIn)
@@ -479,10 +515,12 @@ TEST(AudioSmokeWaveform, GetSummaryFromTime)
// Get summary for first half
auto summary = waveform.GetSummaryFromTime(rational(0), rational(1, 20));
EXPECT_EQ(summary.size(), 2); // 2 channels
// Summary should reflect the min/max of the samples
EXPECT_LE(summary[0].min, 0.0f);
EXPECT_GE(summary[0].max, 0.0f);
ASSERT_EQ(summary.size(), 2); // 2 channels
// Samples alternate between +0.8 and -0.8, so the summary is exactly that
EXPECT_FLOAT_EQ(summary[0].min, -0.8f);
EXPECT_FLOAT_EQ(summary[0].max, 0.8f);
EXPECT_FLOAT_EQ(summary[1].min, -0.8f);
EXPECT_FLOAT_EQ(summary[1].max, 0.8f);
}
TEST(AudioSmokeWaveform, SumSamples)
@@ -556,10 +594,35 @@ TEST(AudioSmokeProcessor, SampleRateConversion)
AudioParams from(48000, kChannelLayoutStereo, SampleFormat::F32P);
AudioParams to(44100, kChannelLayoutStereo, SampleFormat::F32P);
EXPECT_TRUE(processor.Open(from, to, 1.0));
EXPECT_TRUE(processor.IsOpen());
ASSERT_TRUE(processor.Open(from, to, 1.0));
ASSERT_TRUE(processor.IsOpen());
EXPECT_EQ(processor.from().sample_rate(), 48000);
EXPECT_EQ(processor.to().sample_rate(), 44100);
// Push one second of a constant signal
constexpr int kSamples = 48000;
std::vector<float> left(kSamples, 0.5f);
std::vector<float> right(kSamples, 0.5f);
float *input[2] = { left.data(), right.data() };
const AudioProcessor::Buffer output =
ConvertAndDrain(processor, input, kSamples);
ASSERT_EQ(output.size(), 2);
ASSERT_EQ(output.at(0).size(), output.at(1).size());
// 48000 -> 44100 must produce ~44100 samples; the resampler's filter
// delay makes the exact total version-dependent
const int converted = output.at(0).size() / int(sizeof(float));
EXPECT_GE(converted, 43500);
EXPECT_LE(converted, 44600);
// A constant signal stays constant through resampling
float value = 0.0f;
std::memcpy(&value,
output.at(0).constData() + (converted / 2) * sizeof(float),
sizeof(float));
EXPECT_NEAR(value, 0.5f, 0.01f);
}
TEST(AudioSmokeProcessor, ChannelLayoutConversion)
@@ -569,10 +632,29 @@ TEST(AudioSmokeProcessor, ChannelLayoutConversion)
AudioParams from(48000, kChannelLayoutStereo, SampleFormat::F32P);
AudioParams to(48000, kChannelLayoutMono, SampleFormat::F32P);
EXPECT_TRUE(processor.Open(from, to, 1.0));
EXPECT_TRUE(processor.IsOpen());
ASSERT_TRUE(processor.Open(from, to, 1.0));
ASSERT_TRUE(processor.IsOpen());
EXPECT_EQ(processor.from().channel_count(), 2);
EXPECT_EQ(processor.to().channel_count(), 1);
constexpr int kSamples = 1024;
std::vector<float> left(kSamples, 0.5f);
std::vector<float> right(kSamples, 0.5f);
float *input[2] = { left.data(), right.data() };
AudioProcessor::Buffer output;
ASSERT_EQ(processor.Convert(input, kSamples, &output), 0);
// Downmixing folds both channels into a single mono plane
ASSERT_EQ(output.size(), 1);
ASSERT_EQ(output.at(0).size(), kSamples * int(sizeof(float)));
// The downmix of two identical channels must stay audible regardless of
// the exact mixing coefficients
float value = 0.0f;
std::memcpy(&value, output.at(0).constData(), sizeof(float));
EXPECT_GT(value, 0.0f);
EXPECT_LE(value, 1.0f);
}
TEST(AudioSmokeProcessor, FormatConversion)
@@ -582,8 +664,28 @@ TEST(AudioSmokeProcessor, FormatConversion)
AudioParams from(48000, kChannelLayoutStereo, SampleFormat::F32P);
AudioParams to(48000, kChannelLayoutStereo, SampleFormat::S16P);
EXPECT_TRUE(processor.Open(from, to, 1.0));
EXPECT_TRUE(processor.IsOpen());
ASSERT_TRUE(processor.Open(from, to, 1.0));
ASSERT_TRUE(processor.IsOpen());
constexpr int kSamples = 1024;
std::vector<float> left(kSamples, 0.5f);
std::vector<float> right(kSamples, -0.25f);
float *input[2] = { left.data(), right.data() };
AudioProcessor::Buffer output;
ASSERT_EQ(processor.Convert(input, kSamples, &output), 0);
// Planar 16-bit output keeps one plane per channel at 2 bytes per sample
ASSERT_EQ(output.size(), 2);
ASSERT_EQ(output.at(0).size(), kSamples * int(sizeof(int16_t)));
ASSERT_EQ(output.at(1).size(), kSamples * int(sizeof(int16_t)));
// Known float values land on the expected 16-bit codes
int16_t value = 0;
std::memcpy(&value, output.at(0).constData(), sizeof(value));
EXPECT_NEAR(value, 16384, 1); // 0.5 * 32768
std::memcpy(&value, output.at(1).constData(), sizeof(value));
EXPECT_NEAR(value, -8192, 1); // -0.25 * 32768
}
TEST(AudioSmokeProcessor, TempoChange)
@@ -594,8 +696,33 @@ TEST(AudioSmokeProcessor, TempoChange)
AudioParams to(48000, kChannelLayoutStereo, SampleFormat::F32P);
// Open with 2x tempo
EXPECT_TRUE(processor.Open(from, to, 2.0));
EXPECT_TRUE(processor.IsOpen());
ASSERT_TRUE(processor.Open(from, to, 2.0));
ASSERT_TRUE(processor.IsOpen());
// One second of input
constexpr int kSamples = 48000;
std::vector<float> left(kSamples, 0.5f);
std::vector<float> right(kSamples, 0.5f);
float *input[2] = { left.data(), right.data() };
const AudioProcessor::Buffer output =
ConvertAndDrain(processor, input, kSamples);
ASSERT_EQ(output.size(), 2);
ASSERT_EQ(output.at(0).size(), output.at(1).size());
// 2x tempo must output roughly half the input; atempo works in windows,
// so allow generous margins
const int converted = output.at(0).size() / int(sizeof(float));
EXPECT_GE(converted, 20000);
EXPECT_LE(converted, 28000);
// Tempo changes timing, not sample values
float value = 0.0f;
std::memcpy(&value,
output.at(0).constData() + (converted / 2) * sizeof(float),
sizeof(float));
EXPECT_NEAR(value, 0.5f, 0.05f);
}
TEST(AudioSmokeProcessor, InvalidOpen)
@@ -636,8 +763,17 @@ TEST(AudioSmokePreviewDevice, Construction)
{
PreviewAudioDevice device;
EXPECT_TRUE(device.isSequential());
EXPECT_EQ(device.bytes_per_frame(),
0); // BUG: Should be initialized properly
// Without params the frame size is unknown and reported as zero
EXPECT_EQ(device.bytes_per_frame(), 0);
// SetParams derives the frame size from the audio format:
// bytes per sample per channel * channel count
device.SetParams(AudioParams(48000, kChannelLayoutStereo, SampleFormat::F32P));
EXPECT_EQ(device.bytes_per_frame(), 8);
device.SetParams(AudioParams(48000, kChannelLayoutMono, SampleFormat::S16));
EXPECT_EQ(device.bytes_per_frame(), 2);
}
TEST(AudioSmokePreviewDevice, BytesPerFrame)
@@ -654,9 +790,48 @@ TEST(AudioSmokePreviewDevice, BytesPerFrame)
TEST(AudioSmokePreviewDevice, NotifyInterval)
{
PreviewAudioDevice device;
device.open(QIODevice::ReadWrite);
device.set_notify_interval(100); // 100 frames
// Cannot directly verify, but should not crash
// The notify interval is measured in bytes: Notify fires when the total
// number of bytes read crosses a multiple of the interval. readData() is
// called directly to bypass QIODevice's read-ahead buffer, which would
// otherwise coalesce the reads and hide the per-read transitions.
device.set_notify_interval(64);
int notify_count = 0;
QObject::connect(&device, &PreviewAudioDevice::Notify, &device,
[&notify_count]() { ++notify_count; });
QByteArray data(256, 0x01);
ASSERT_EQ(device.write(data), 256);
// Nothing read yet, so no notification
EXPECT_EQ(notify_count, 0);
char buf[128];
ASSERT_EQ(device.readData(buf, 64), 64);
EXPECT_EQ(notify_count, 1); // crossed the 64-byte mark
ASSERT_EQ(device.readData(buf, 64), 64);
EXPECT_EQ(notify_count, 2); // crossed the 128-byte mark
// Crossing two intervals in one read emits a single notification
ASSERT_EQ(device.readData(buf, 128), 128);
EXPECT_EQ(notify_count, 3);
// Buffer drained: no more reads, no more notifications
EXPECT_EQ(device.readData(buf, 64), 0);
EXPECT_EQ(notify_count, 3);
// An interval of zero disables notifications entirely
PreviewAudioDevice quiet_device;
quiet_device.open(QIODevice::ReadWrite);
int quiet_count = 0;
QObject::connect(&quiet_device, &PreviewAudioDevice::Notify, &quiet_device,
[&quiet_count]() { ++quiet_count; });
ASSERT_EQ(quiet_device.write(data), 256);
EXPECT_EQ(quiet_device.readData(buf, 128), 128);
EXPECT_EQ(quiet_count, 0);
}
TEST(AudioSmokePreviewDevice, Clear)
@@ -664,18 +839,33 @@ TEST(AudioSmokePreviewDevice, Clear)
PreviewAudioDevice device;
device.open(QIODevice::ReadWrite);
// Write some data
QByteArray data(1000, 0xAB);
device.write(data);
device.set_notify_interval(64);
int notify_count = 0;
QObject::connect(&device, &PreviewAudioDevice::Notify, &device,
[&notify_count]() { ++notify_count; });
// Clear
// Write some data and read it back (readData() is called directly to
// bypass QIODevice's read-ahead buffer)
QByteArray data(128, 0xAB);
ASSERT_EQ(device.write(data), 128);
char buf[128];
ASSERT_EQ(device.readData(buf, sizeof(buf)), 128);
EXPECT_EQ(notify_count, 1);
// Queue new data, then clear it
ASSERT_EQ(device.write(data), 128);
device.clear();
// Device should be empty now (next read should return 0 or silence)
char buf[100];
qint64 read = device.readData(buf, sizeof(buf));
// After clear, read should return 0 or the buffer should be zeroed
EXPECT_TRUE(read >= 0);
// After clear the device holds no data: a read returns 0 bytes, which is
// how the output callback knows to fill the stream with silence
EXPECT_EQ(device.readData(buf, sizeof(buf)), 0);
// clear() also resets the read counter, so notifications start over, and
// the device keeps working: data written after the clear reads back intact
ASSERT_EQ(device.write(data), 128);
ASSERT_EQ(device.readData(buf, sizeof(buf)), 128);
EXPECT_EQ(QByteArray(buf, data.size()), data);
EXPECT_EQ(notify_count, 2);
}
// ============================================================================
@@ -785,40 +975,39 @@ TEST(AudioSmokeThread, ConcurrentWaveformAccess)
TEST(AudioSmokeThread, ConcurrentSampleBufferOperations)
{
// SampleBuffer instances are independent value objects with no shared
// state, so operating on separate instances from multiple threads is
// race-free and must produce deterministic results
const int num_threads = 4;
AudioParams params(48000, kChannelLayoutStereo, SampleFormat::F32P);
SampleBuffer buffer(params, size_t(1000));
FillSampleBuffer(buffer, 0.5f);
std::vector<SampleBuffer> buffers;
buffers.reserve(num_threads);
for (int t = 0; t < num_threads; ++t) {
buffers.emplace_back(params, size_t(1000));
FillSampleBuffer(buffers.back(), 0.5f);
}
std::vector<std::thread> threads;
std::atomic<int> success_count{ 0 };
for (int t = 0; t < num_threads; ++t) {
threads.emplace_back([&buffer, &success_count, t]() {
// Each thread applies different operations
threads.emplace_back([&buffers, t]() {
SampleBuffer &buffer = buffers[static_cast<size_t>(t)];
switch (t % 4) {
case 0:
buffer.transform_volume(0.8f);
success_count++;
break;
case 1:
buffer.transform_volume(4.0f);
buffer.clamp();
success_count++;
break;
case 2: {
auto ripped = buffer.rip_channel(0);
if (ripped.channel_count() == 1)
success_count++;
case 2:
buffer.silence();
break;
}
case 3: {
auto ptrs = buffer.to_raw_ptrs();
if (!ptrs.empty())
success_count++;
case 3:
buffer.transform_volume_for_channel(1, 0.0f);
break;
}
}
});
}
@@ -826,7 +1015,20 @@ TEST(AudioSmokeThread, ConcurrentSampleBufferOperations)
t.join();
}
EXPECT_EQ(success_count.load(), num_threads);
// Each buffer must hold the exact deterministic outcome of its operation
for (size_t i = 0; i < buffers[0].sample_count(); ++i) {
EXPECT_FLOAT_EQ(buffers[0].data(0)[i], 0.4f); // 0.5 * 0.8
EXPECT_FLOAT_EQ(buffers[0].data(1)[i], 0.4f);
EXPECT_FLOAT_EQ(buffers[1].data(0)[i], 1.0f); // 0.5 * 4 clamped
EXPECT_FLOAT_EQ(buffers[1].data(1)[i], 1.0f);
EXPECT_FLOAT_EQ(buffers[2].data(0)[i], 0.0f); // silenced
EXPECT_FLOAT_EQ(buffers[2].data(1)[i], 0.0f);
EXPECT_FLOAT_EQ(buffers[3].data(0)[i], 0.5f); // untouched channel
EXPECT_FLOAT_EQ(buffers[3].data(1)[i], 0.0f); // zeroed channel
}
}
} // namespace test
+3 -3
View File
@@ -117,10 +117,10 @@ TEST(AudioWaveformSync, MaskedEstimationIgnoresInvalidWindows)
EXPECT_GT(masked.confidence, 0.99);
// Ignoring the uncached placeholder windows must not make the estimate
// worse than treating them as silence
if (unmasked.valid) {
// worse than treating them as silence. Both estimations are deterministic
// on this fixed input, so the unmasked result must be valid too.
ASSERT_TRUE(unmasked.valid);
EXPECT_GE(masked.confidence, unmasked.confidence);
}
}
TEST(AudioWaveformSync, EstimatesStretchAndOffset)
+6 -30
View File
@@ -25,15 +25,6 @@ namespace OCIO = OCIO_NAMESPACE;
namespace
{
bool IsOakSupportedLutExtension(QString suffix)
{
if (suffix.startsWith(QLatin1Char('.'))) {
suffix.remove(0, 1);
}
const QString lower = suffix.toLower();
return lower == QStringLiteral("cube") || lower == QStringLiteral("3dl");
}
QString WriteTestCube(QTemporaryDir *dir)
{
const QString path =
@@ -168,16 +159,7 @@ QString WriteAsymmetricCube(QTemporaryDir *dir)
} // namespace
TEST(ColorLut, OcioSupportsCubeAnd3dlExtensions)
{
EXPECT_TRUE(IsOakSupportedLutExtension("cube"));
EXPECT_TRUE(IsOakSupportedLutExtension(".cube"));
EXPECT_TRUE(IsOakSupportedLutExtension("3dl"));
EXPECT_TRUE(IsOakSupportedLutExtension(".3dl"));
EXPECT_FALSE(IsOakSupportedLutExtension("txt"));
}
TEST(ColorProcessor, CreateFromInvalidTransformReturnsNull)
TEST(ColorProcessor, CreateFromInvalidTransformThrows)
{
olive::ColorManager::SetUpDefaultConfig();
@@ -186,18 +168,12 @@ TEST(ColorProcessor, CreateFromInvalidTransformReturnsNull)
transform->setInterpolation(OCIO::INTERP_LINEAR);
transform->setDirection(OCIO::TRANSFORM_DIR_FORWARD);
olive::ColorProcessorPtr processor;
EXPECT_NO_THROW({
try {
processor = olive::ColorProcessor::Create(
// A FileTransform pointing at a missing LUT makes OCIO throw while
// resolving the processor, before ColorProcessor is even constructed.
EXPECT_THROW(olive::ColorProcessor::Create(
olive::ColorManager::GetDefaultConfig()->getProcessor(
transform));
} catch (const std::exception &e) {
processor = nullptr;
}
});
EXPECT_EQ(processor, nullptr);
transform)),
OCIO::Exception);
}
TEST(ColorProcessor, ConvertColorWithIdentityProcessor)
+48 -3
View File
@@ -1,7 +1,23 @@
#include <gtest/gtest.h>
#include <QDebug>
#include "common/commandlineparser.h"
namespace
{
// Collects qDebug/qWarning/qCritical output for inspection
QStringList g_captured_messages;
void CaptureMessageHandler(QtMsgType, const QMessageLogContext &,
const QString &msg)
{
g_captured_messages.append(msg);
}
} // namespace
TEST(CommonCommandLineParser, OptionWithoutArgument)
{
CommandLineParser parser;
@@ -57,16 +73,34 @@ TEST(CommonCommandLineParser, UnknownOptionWarning)
CommandLineParser parser;
parser.AddOption({ QStringLiteral("known") }, QStringLiteral("Known"));
// Should not crash; unknown option is logged
g_captured_messages.clear();
QtMessageHandler old = qInstallMessageHandler(CaptureMessageHandler);
parser.Process({ QStringLiteral("app"), QStringLiteral("-unknown") });
qInstallMessageHandler(old);
// The warning must name the offending option
ASSERT_EQ(g_captured_messages.size(), 1);
EXPECT_TRUE(g_captured_messages.first().contains(
QStringLiteral("Unknown parameter:")));
EXPECT_TRUE(
g_captured_messages.first().contains(QStringLiteral("-unknown")));
}
TEST(CommonCommandLineParser, UnknownPositionalWarning)
{
CommandLineParser parser;
// Should not crash; unknown positional is logged
g_captured_messages.clear();
QtMessageHandler old = qInstallMessageHandler(CaptureMessageHandler);
parser.Process({ QStringLiteral("app"), QStringLiteral("extra") });
qInstallMessageHandler(old);
// The warning must name the offending positional argument
ASSERT_EQ(g_captured_messages.size(), 1);
EXPECT_TRUE(g_captured_messages.first().contains(
QStringLiteral("Unknown parameter:")));
EXPECT_TRUE(
g_captured_messages.first().contains(QStringLiteral("extra")));
}
TEST(CommonCommandLineParser, HiddenOptionExcludedFromHelp)
@@ -78,6 +112,17 @@ TEST(CommonCommandLineParser, HiddenOptionExcludedFromHelp)
parser.AddPositionalArgument(QStringLiteral("file"),
QStringLiteral("Input file"));
// Should not crash; hidden option should be skipped during help output
// PrintHelp writes to stdout via printf
testing::internal::CaptureStdout();
parser.PrintHelp("/usr/bin/app");
std::string help = testing::internal::GetCapturedStdout();
// Visible option and positional argument must be listed
EXPECT_NE(help.find("-visible"), std::string::npos);
EXPECT_NE(help.find("Visible"), std::string::npos);
EXPECT_NE(help.find("[file]"), std::string::npos);
// Hidden option must not appear anywhere in the help text
EXPECT_EQ(help.find("-hidden"), std::string::npos);
EXPECT_EQ(help.find("Hidden"), std::string::npos);
}
+21 -1
View File
@@ -4,7 +4,8 @@
TEST(CommonDebug, DebugHandlerFormatsAllLevels)
{
// Install handler and restore after test
// DebugHandler writes "[LEVEL] message" lines to stderr
testing::internal::CaptureStderr();
QtMessageHandler old = qInstallMessageHandler(olive::DebugHandler);
qDebug() << "debug message";
@@ -13,4 +14,23 @@ TEST(CommonDebug, DebugHandlerFormatsAllLevels)
qCritical() << "critical message";
qInstallMessageHandler(old);
std::string out = testing::internal::GetCapturedStderr();
// Each level must get its own tag, paired with its message on one line
QString output = QString::fromStdString(out);
const QStringList lines = output.split('\n');
auto has_line = [&lines](const char *tag, const char *text) {
for (const QString &line : lines) {
if (line.contains(QString::fromLatin1(tag)) &&
line.contains(QString::fromLatin1(text))) {
return true;
}
}
return false;
};
EXPECT_TRUE(has_line("[DEBUG]", "debug message"));
EXPECT_TRUE(has_line("[INFO]", "info message"));
EXPECT_TRUE(has_line("[WARNING]", "warning message"));
EXPECT_TRUE(has_line("[ERROR]", "critical message"));
}
+28 -1
View File
@@ -1,10 +1,25 @@
#include <gtest/gtest.h>
#include <QDebug>
#include <QTemporaryDir>
#include <QTemporaryFile>
#include "common/filefunctions.h"
namespace
{
// Collects qDebug/qWarning/qCritical output for inspection
QStringList g_captured_messages;
void CaptureMessageHandler(QtMsgType, const QMessageLogContext &,
const QString &msg)
{
g_captured_messages.append(msg);
}
} // namespace
TEST(CommonFileFunctions, EnsureFilenameExtension)
{
EXPECT_EQ(olive::FileFunctions::EnsureFilenameExtension(
@@ -215,7 +230,19 @@ TEST(CommonFileFunctions, CopyDirectorySourceMissing)
QTemporaryDir dst;
ASSERT_TRUE(dst.isValid());
// Should not crash even if source doesn't exist
// A missing source must log a critical error naming the source and
// leave the destination untouched
g_captured_messages.clear();
QtMessageHandler old = qInstallMessageHandler(CaptureMessageHandler);
olive::FileFunctions::CopyDirectory(QStringLiteral("/nonexistent/path"),
dst.path(), false);
qInstallMessageHandler(old);
ASSERT_EQ(g_captured_messages.size(), 1);
EXPECT_TRUE(g_captured_messages.first().contains(
QStringLiteral("Failed to copy directory")));
EXPECT_TRUE(g_captured_messages.first().contains(
QStringLiteral("/nonexistent/path")));
EXPECT_TRUE(
QDir(dst.path()).entryList(QDir::NoDotAndDotDot).isEmpty());
}
+2 -1
View File
@@ -246,5 +246,6 @@ TEST(CommonHtml, NestedInlineTagsMergeFormats)
const QTextFragment frag = OnlyFragment(&doc);
EXPECT_TRUE(frag.charFormat().fontItalic());
EXPECT_EQ(frag.charFormat().fontWeight(), 600 / 8);
// CSS font-weight 600 maps to 75 on the legacy 0-99 Qt weight scale
EXPECT_EQ(frag.charFormat().fontWeight(), 75);
}
+34 -3
View File
@@ -2,6 +2,20 @@
#include "common/jobtime.h"
namespace
{
// Collects qDebug output for inspection
QStringList g_captured_messages;
void CaptureMessageHandler(QtMsgType, const QMessageLogContext &,
const QString &msg)
{
g_captured_messages.append(msg);
}
} // namespace
TEST(CommonJobTime, ConstructorAcquiresValue)
{
olive::JobTime a;
@@ -26,17 +40,34 @@ TEST(CommonJobTime, ComparisonOperators)
olive::JobTime a;
olive::JobTime b;
// `a` was acquired first, so its value is strictly lower than `b`'s
EXPECT_LT(a, b);
EXPECT_GT(b, a);
EXPECT_LE(a, a);
EXPECT_GE(b, b);
EXPECT_EQ(a, a);
EXPECT_LE(a, b);
EXPECT_GE(b, a);
EXPECT_NE(a, b);
// A copy holds the same value and must compare equal
olive::JobTime c = a;
EXPECT_EQ(a, c);
EXPECT_LE(a, c);
EXPECT_GE(a, c);
}
TEST(CommonJobTime, DebugStream)
{
olive::JobTime a;
g_captured_messages.clear();
QtMessageHandler old = qInstallMessageHandler(CaptureMessageHandler);
{
QDebug debug(QtDebugMsg);
debug << a;
}
qInstallMessageHandler(old);
// operator<< streams the raw value, so the message must contain it
ASSERT_EQ(g_captured_messages.size(), 1);
EXPECT_TRUE(
g_captured_messages.first().contains(QString::number(a.value())));
}
+41 -11
View File
@@ -83,8 +83,11 @@ TEST(CommonQtUtils, QFontMetricsWidth)
{
QFont font;
QFontMetrics fm(font);
int width = olive::QtUtils::QFontMetricsWidth(fm, QStringLiteral("Olive"));
EXPECT_GT(width, 0);
QString text = QStringLiteral("Olive");
// Thin wrapper: must forward to QFontMetrics::horizontalAdvance exactly
EXPECT_EQ(olive::QtUtils::QFontMetricsWidth(fm, text),
fm.horizontalAdvance(text));
}
TEST(CommonQtUtils, CreateHorizontalLine)
@@ -117,8 +120,10 @@ TEST(CommonQtUtils, GetFormattedDateTime)
{
QDateTime dt = QDateTime::fromString(QStringLiteral("2025-01-15T10:30:00"),
Qt::ISODate);
QString s = olive::QtUtils::GetFormattedDateTime(dt);
EXPECT_FALSE(s.isEmpty());
// Qt::TextDate renders "ddd MMM d HH:mm:ss yyyy" in the C locale
EXPECT_EQ(olive::QtUtils::GetFormattedDateTime(dt),
QStringLiteral("Wed Jan 15 10:30:00 2025"));
}
TEST(CommonQtUtils, WordWrapString)
@@ -126,15 +131,24 @@ TEST(CommonQtUtils, WordWrapString)
QFont font;
QFontMetrics fm(font);
// Use a moderate width; long words may not split cleanly, so just verify no crash
// A string wider than the bounding width must be split into
// multiple lines
QStringList wrapped = olive::QtUtils::WordWrapString(
QStringLiteral("hello world foo bar"), fm, 40);
EXPECT_GE(wrapped.size(), 1u);
EXPECT_GT(wrapped.size(), 1);
// A string that fits stays on a single line, untouched
wrapped = olive::QtUtils::WordWrapString(
QStringLiteral("hello world foo bar"), fm, 100000);
EXPECT_EQ(wrapped.size(), 1);
EXPECT_EQ(wrapped.first(), QStringLiteral("hello world foo bar"));
// Should preserve manual newlines
wrapped = olive::QtUtils::WordWrapString(QStringLiteral("line1\nline2"), fm,
1000);
EXPECT_EQ(wrapped.size(), 2);
EXPECT_EQ(wrapped.at(0), QStringLiteral("line1"));
EXPECT_EQ(wrapped.at(1), QStringLiteral("line2"));
}
TEST(CommonQtUtils, ToQColorClampsValues)
@@ -149,13 +163,29 @@ TEST(CommonQtUtils, ToQColorClampsValues)
TEST(CommonQtUtils, qHashRational)
{
olive::core::rational r(3, 4);
EXPECT_NO_THROW(qHash(r));
using olive::core::rational;
// Hash contract: equal rationals must hash equally
EXPECT_EQ(qHash(rational(3, 4)), qHash(rational(3, 4)));
EXPECT_EQ(qHash(rational(3, 4)), qHash(rational(6, 8)));
// Distinct values must hash differently
EXPECT_NE(qHash(rational(3, 4)), qHash(rational(1, 2)));
EXPECT_NE(qHash(rational(1, 3)), qHash(rational(2, 3)));
}
TEST(CommonQtUtils, qHashTimeRange)
{
olive::core::TimeRange tr(olive::core::rational(1),
olive::core::rational(5));
EXPECT_NO_THROW(qHash(tr));
using olive::core::rational;
using olive::core::TimeRange;
// Hash contract: equal ranges must hash equally
EXPECT_EQ(qHash(TimeRange(rational(1), rational(5))),
qHash(TimeRange(rational(1), rational(5))));
// Ranges differing in their in- or out-point must hash differently
EXPECT_NE(qHash(TimeRange(rational(1), rational(5))),
qHash(TimeRange(rational(2), rational(5))));
EXPECT_NE(qHash(TimeRange(rational(1), rational(5))),
qHash(TimeRange(rational(1), rational(6))));
}
+8
View File
@@ -30,6 +30,14 @@ TEST(Config, SetAndGetValues)
cfg[QStringLiteral("UnitTestDouble")] = 3.14;
EXPECT_NEAR(cfg[QStringLiteral("UnitTestDouble")].toDouble(), 3.14, 0.001);
// Config offers no key-removal API, so reset the singleton to its
// default state to avoid leaking the UnitTest* keys into later tests
cfg.SetDefaults();
EXPECT_FALSE(cfg[QStringLiteral("UnitTestValue")].isValid());
EXPECT_FALSE(cfg[QStringLiteral("UnitTestString")].isValid());
EXPECT_FALSE(cfg[QStringLiteral("UnitTestBool")].isValid());
EXPECT_FALSE(cfg[QStringLiteral("UnitTestDouble")].isValid());
}
TEST(Config, MissingKeyReturnsInvalidVariant)
+12 -3
View File
@@ -81,8 +81,13 @@ TEST(CoreBezier, QuadraticXtoY)
TEST(CoreBezier, CubicXtoT)
{
// Independent expectation from the Bernstein basis: with x control
// values 0, 0.33, 0.66, 1.0 the curve expands to x(t) = 0.99t + 0.01t^3,
// and x(t) = 0.5 is solved by t = 0.5037592 (Newton-Raphson). The
// implementation bisects until |x(t) - x| < 1e-6 and dx/dt >= 0.99 on
// [0,1], so the returned t is well within 1e-5 of the true root.
double t = Bezier::CubicXtoT(0.5, 0.0, 0.33, 0.66, 1.0);
EXPECT_NEAR(t, 0.5, 0.01);
EXPECT_NEAR(t, 0.5037592, 1e-5);
}
TEST(CoreBezier, CubicTtoY)
@@ -98,9 +103,13 @@ TEST(CoreBezier, CubicXtoY)
Imath::V2d c(0.66, 1.0);
Imath::V2d d(1.0, 1.0);
// Independent expectation from the Bernstein basis: the x curve is
// x(t) = 0.99t + 0.01t^3, so x = 0.5 gives t = 0.5037592 (Newton-Raphson);
// the y curve is y(t) = 3(1-t)t^2 + t^3 = 3t^2 - 2t^3, which then yields
// y = 0.5056392. The implementation's 1e-6 bisection tolerance in x is
// amplified by dy/dt < 1.5, keeping the y error well under 1e-5.
double y = Bezier::CubicXtoY(0.5, a, b, c, d);
EXPECT_GE(y, 0.0);
EXPECT_LE(y, 1.0);
EXPECT_NEAR(y, 0.5056392, 1e-5);
}
TEST(CoreBezier, VectorConverters)
+18 -6
View File
@@ -66,9 +66,17 @@ TEST(CoreColor, HsvRoundTrip)
float h, s, v;
original.toHsv(&h, &s, &v);
EXPECT_NEAR(original.hsv_hue(), h, 0.001f);
EXPECT_NEAR(original.hsv_saturation(), s, 0.001f);
EXPECT_NEAR(original.value(), v, 0.001f);
// Independently derived expectation: max=0.8 (red), delta=0.6, so
// h = 60*(g-b)/delta = 20, s = delta/max = 0.75, v = max = 0.8
EXPECT_NEAR(h, 20.0f, 0.0001f);
EXPECT_NEAR(s, 0.75f, 0.0001f);
EXPECT_NEAR(v, 0.8f, 0.0001f);
// True round trip: converting the HSV values back must restore the color
Color restored = Color::fromHsv(h, s, v);
EXPECT_NEAR(restored.red(), original.red(), 0.0001f);
EXPECT_NEAR(restored.green(), original.green(), 0.0001f);
EXPECT_NEAR(restored.blue(), original.blue(), 0.0001f);
}
TEST(CoreColor, HslRoundTrip)
@@ -77,9 +85,13 @@ TEST(CoreColor, HslRoundTrip)
float h, s, l;
original.toHsl(&h, &s, &l);
EXPECT_NEAR(original.hsl_hue(), h, 0.001f);
EXPECT_NEAR(original.hsl_saturation(), s, 0.001f);
EXPECT_NEAR(original.lightness(), l, 0.001f);
// Color has no fromHsl(), so instead of a round trip check the HSL
// values against independently derived expectations: min=0.2, max=0.8,
// l = (min+max)/2 = 0.5, s = (max-min)/(2-max-min) = 0.6,
// h = 60*(r-g)/(max-min) + 240 = 210 (blue is max)
EXPECT_NEAR(h, 210.0f, 0.0001f);
EXPECT_NEAR(s, 0.6f, 0.0001f);
EXPECT_NEAR(l, 0.5f, 0.0001f);
}
TEST(CoreColor, ArithmeticOperators)
+5
View File
@@ -259,4 +259,9 @@ TEST(CoreSampleBuffer, UnallocatedOperationsNoCrash)
b.silence();
b.set(0, nullptr, 0, 0);
b.destroy();
// Operations on an unallocated buffer must be no-ops
EXPECT_FALSE(b.is_allocated());
EXPECT_EQ(b.channel_count(), 0);
EXPECT_EQ(b.sample_count(), 0u);
}
+7
View File
@@ -84,8 +84,15 @@ TEST(CoreTimecode, TimeToTimestamp)
{
rational tb(1, 25);
EXPECT_EQ(Timecode::time_to_timestamp(rational(2, 1), tb), 50);
// 0.08s @ 25fps lands exactly on frame 2, so the rounding mode
// must not matter
EXPECT_EQ(Timecode::time_to_timestamp(0.08, tb, Timecode::kFloor), 2);
EXPECT_EQ(Timecode::time_to_timestamp(0.08, tb, Timecode::kCeil), 2);
// 0.1s @ 25fps is 2.5 frames: floor and ceil must differ
EXPECT_EQ(Timecode::time_to_timestamp(0.1, tb, Timecode::kFloor), 2);
EXPECT_EQ(Timecode::time_to_timestamp(0.1, tb, Timecode::kCeil), 3);
}
TEST(CoreTimecode, TimestampToTime)
+6 -2
View File
@@ -12,8 +12,12 @@ using namespace olive;
TEST(FFmpegDecoderHW, H264_422_10bit_CPUFrame_IsNotBlack)
{
const QString path =
QStringLiteral("/home/mikesolar/Videos/dual_system_video.MOV");
const QString path = qEnvironmentVariable("OAK_TEST_HW_DECODE_FILE");
if (path.isEmpty()) {
GTEST_SKIP() << "Set OAK_TEST_HW_DECODE_FILE to a 10-bit 4:2:2 H.264 "
"sample file to run this test";
}
if (!QFileInfo::exists(path)) {
GTEST_SKIP() << "Test footage not available: " << path.toStdString();
+79 -25
View File
@@ -21,6 +21,7 @@
#include "render/job/footagejob.h"
#include "render/loopmode.h"
#include "render/texture.h"
#include "ui/icons/icons.h"
namespace
{
@@ -474,39 +475,92 @@ TEST_F(FootageTest, TooltipDescribesEnabledStreams)
TEST_F(FootageTest, IconReflectsPrioritizedStreamType)
{
// Invalid footage
TestableFootage footage;
EXPECT_TRUE(footage.data(olive::Node::ICON).canConvert<QIcon>());
// The icon globals must be loaded for the returned icons to be
// distinguishable (null icons all share the same cache key)
olive::icon::LoadAll(QStringLiteral(":/style/olive-dark"));
ASSERT_FALSE(olive::icon::Video.isNull());
ASSERT_FALSE(olive::icon::Audio.isNull());
ASSERT_FALSE(olive::icon::Image.isNull());
ASSERT_FALSE(olive::icon::Subtitles.isNull());
ASSERT_FALSE(olive::icon::Error.isNull());
// Footage::data(ICON) only inspects streams once the footage has been
// probed (total_stream_count_ is set by Reprobe), so each variant is
// seeded through the metadata cache like a real probe would leave it
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const ScopedEnvVar xdg(
"XDG_CACHE_HOME",
QDir(dir.path()).filePath(QStringLiteral("xdg")).toUtf8());
auto probe = [&](const QString &name,
const olive::FootageDescription &desc) {
const QString media = CreateFakeMediaFile(dir, name);
EXPECT_FALSE(media.isEmpty());
TestableFootage *footage =
ProbeFootageFromCache(project_.get(), media, desc);
EXPECT_NE(footage, nullptr);
return footage;
};
// Invalid footage gets the error icon
TestableFootage invalid;
EXPECT_EQ(invalid.data(olive::Node::ICON).value<QIcon>().cacheKey(),
olive::icon::Error.cacheKey());
// 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<QIcon>());
olive::FootageDescription video_audio(QStringLiteral("fakedecoder"));
video_audio.AddVideoStream(MakeVideoStream(0));
video_audio.AddAudioStream(MakeAudioStream(1));
video_audio.SetStreamCount(2);
TestableFootage *footage = probe(QStringLiteral("video-audio.mkv"), video_audio);
ASSERT_NE(footage, nullptr);
const QIcon video_icon = footage->data(olive::Node::ICON).value<QIcon>();
EXPECT_EQ(video_icon.cacheKey(), olive::icon::Video.cacheKey());
EXPECT_NE(video_icon.cacheKey(), olive::icon::Audio.cacheKey());
EXPECT_NE(video_icon.cacheKey(), olive::icon::Error.cacheKey());
// Audio still takes priority over a still image stream
olive::VideoParams still_stream = MakeVideoStream(0);
still_stream.set_video_type(olive::VideoParams::kVideoTypeStill);
olive::FootageDescription still_audio(QStringLiteral("fakedecoder"));
still_audio.AddVideoStream(still_stream);
still_audio.AddAudioStream(MakeAudioStream(1));
still_audio.SetStreamCount(2);
TestableFootage *still_and_audio =
probe(QStringLiteral("still-audio.mkv"), still_audio);
ASSERT_NE(still_and_audio, nullptr);
const QIcon still_audio_icon =
still_and_audio->data(olive::Node::ICON).value<QIcon>();
EXPECT_EQ(still_audio_icon.cacheKey(), olive::icon::Audio.cacheKey());
EXPECT_NE(still_audio_icon.cacheKey(), olive::icon::Image.cacheKey());
// 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<QIcon>());
olive::FootageDescription stills(QStringLiteral("fakedecoder"));
stills.AddVideoStream(still_stream);
stills.SetStreamCount(1);
TestableFootage *still_only = probe(QStringLiteral("still.mkv"), stills);
ASSERT_NE(still_only, nullptr);
EXPECT_EQ(still_only->data(olive::Node::ICON).value<QIcon>().cacheKey(),
olive::icon::Image.cacheKey());
// 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<QIcon>());
olive::FootageDescription audio(QStringLiteral("fakedecoder"));
audio.AddAudioStream(MakeAudioStream(0));
audio.SetStreamCount(1);
TestableFootage *audio_only = probe(QStringLiteral("audio.mkv"), audio);
ASSERT_NE(audio_only, nullptr);
EXPECT_EQ(audio_only->data(olive::Node::ICON).value<QIcon>().cacheKey(),
olive::icon::Audio.cacheKey());
// 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<QIcon>());
olive::FootageDescription subs(QStringLiteral("fakedecoder"));
subs.AddSubtitleStream(MakeSubtitleStream(0));
subs.SetStreamCount(1);
TestableFootage *subs_only = probe(QStringLiteral("subs.mkv"), subs);
ASSERT_NE(subs_only, nullptr);
EXPECT_EQ(subs_only->data(olive::Node::ICON).value<QIcon>().cacheKey(),
olive::icon::Subtitles.cacheKey());
}
TEST_F(FootageTest, ProxyChangesMarkProjectModifiedAndEmitSignal)
-23
View File
@@ -1,7 +1,6 @@
#include <gtest/gtest.h>
#include "tool/tool.h"
#include "ui/humanstrings.h"
TEST(ModuleSmoke, ToolAddableObjectNames)
{
@@ -39,25 +38,3 @@ TEST(ModuleSmoke, ToolAddableObjectIds)
EXPECT_EQ(olive::Tool::GetAddableObjectID(olive::Tool::kAddableSubtitle),
QStringLiteral("subtitle"));
}
TEST(ModuleSmoke, HumanStringsSampleRate)
{
EXPECT_FALSE(olive::HumanStrings::SampleRateToString(48000).isEmpty());
EXPECT_FALSE(olive::HumanStrings::SampleRateToString(44100).isEmpty());
}
TEST(ModuleSmoke, HumanStringsChannelLayout)
{
EXPECT_FALSE(
olive::HumanStrings::ChannelLayoutToString(olive::core::kChannelLayoutMono).isEmpty());
EXPECT_FALSE(olive::HumanStrings::ChannelLayoutToString(olive::core::kChannelLayoutStereo)
.isEmpty());
}
TEST(ModuleSmoke, HumanStringsFormat)
{
EXPECT_FALSE(
olive::HumanStrings::FormatToString(olive::SampleFormat::U8).isEmpty());
EXPECT_FALSE(
olive::HumanStrings::FormatToString(olive::SampleFormat::F32).isEmpty());
}
+18 -10
View File
@@ -2,7 +2,6 @@
#include <memory>
#include <Imath/ImathVec.h>
#include <QObject>
#include <QPointF>
#include <QVector2D>
@@ -16,7 +15,6 @@
#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
@@ -564,10 +562,16 @@ TEST_F(NodeInputImmediateNodeTest, GetValueAtTimeBezierHandlesBendCurve)
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);
// Independent expectation, derived by hand from the control points
// P0=(0,0), P1=(2.5,0), P2=(10,10), P3=(10,10):
// x(t) = 7.5*(1-t)^2*t + 30*(1-t)*t^2 + 10*t^3
// = -12.5*t^3 + 15*t^2 + 7.5*t
// x(t) = 5 => 5*t^3 - 6*t^2 - 3*t + 2 = 0 => t = 0.4296537740156094
// y(t) = 30*(1-t)*t^2 + 10*t^3 = 30*t^2 - 20*t^3
// = 3.9517689049678255
// (the production bisection solves x(t) to 1e-6, so allow 1e-4 slack)
EXPECT_NEAR(interpolated, 3.9517689049678255, 1e-4);
// The ease-in handle keeps the midpoint below the linear value of 5.0
EXPECT_LT(interpolated, 5.0);
@@ -588,10 +592,14 @@ TEST_F(NodeInputImmediateNodeTest, GetValueAtTimeQuadraticBezierWithOneHandle)
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);
// Independent expectation, derived by hand from the single control point
// CP=(2.5,0) between P0=(0,0) and P2=(10,10):
// x(t) = 2*(1-t)*t*2.5 + t^2*10 = 5*t + 5*t^2
// x(t) = 5 => t = (sqrt(5)-1)/2 = 0.6180339887498949
// y(t) = t^2*10 = 5*(3 - sqrt(5)) = 3.819660112501051
// (the production bisection solves x(t) to 1e-6, so allow 1e-4 slack)
EXPECT_NEAR(interpolated, 3.819660112501051, 1e-4);
EXPECT_LT(interpolated, 5.0);
}
+69 -1
View File
@@ -1,5 +1,9 @@
#include <gtest/gtest.h>
#include <QUuid>
#include <QXmlStreamReader>
#include <QXmlStreamWriter>
#include "node/project.h"
TEST(NodeProject, DefaultsAfterConstruction)
@@ -108,5 +112,69 @@ TEST(NodeProject, SaveProducesXml)
writer.writeEndDocument();
EXPECT_FALSE(xml.isEmpty());
EXPECT_TRUE(xml.contains("uuid"));
// Parse the document and assert the serialized structure instead of
// relying on substring matching
QXmlStreamReader reader(xml);
ASSERT_TRUE(reader.readNextStartElement());
EXPECT_EQ(reader.name(), QStringLiteral("project"));
EXPECT_EQ(reader.attributes().value(QStringLiteral("version")).toString(),
QStringLiteral("1"));
bool saw_uuid = false;
QString uuid_text;
bool saw_nodes = false;
bool in_nodes = false;
int node_count = 0;
QString first_node_id;
bool in_settings = false;
bool saw_settings_root = false;
while (!reader.atEnd()) {
const QXmlStreamReader::TokenType token = reader.readNext();
if (token == QXmlStreamReader::StartElement) {
const QStringView name = reader.name();
if (name == QStringLiteral("uuid")) {
saw_uuid = true;
uuid_text = reader.readElementText();
} else if (name == QStringLiteral("nodes")) {
saw_nodes = true;
in_nodes = true;
} else if (in_nodes && name == QStringLiteral("node")) {
++node_count;
if (first_node_id.isEmpty()) {
first_node_id =
reader.attributes().value(QStringLiteral("id")).toString();
}
} else if (name == QStringLiteral("settings")) {
in_settings = true;
} else if (in_settings && name == QStringLiteral("root")) {
saw_settings_root = true;
}
} else if (token == QXmlStreamReader::EndElement) {
if (reader.name() == QStringLiteral("nodes")) {
in_nodes = false;
} else if (reader.name() == QStringLiteral("settings")) {
in_settings = false;
} else if (reader.name() == QStringLiteral("project")) {
break;
}
}
}
ASSERT_FALSE(reader.hasError()) << reader.errorString().toStdString();
// The project uuid round-trips as a valid uuid
EXPECT_TRUE(saw_uuid);
EXPECT_EQ(uuid_text, project.GetUuid().toString());
EXPECT_FALSE(QUuid(uuid_text).isNull());
// Initialize() created a root folder, so at least one node is serialized
// and the first one is that root
EXPECT_TRUE(saw_nodes);
EXPECT_GE(node_count, 1);
EXPECT_EQ(first_node_id, project.root()->id());
// The root pointer is persisted in the settings block
EXPECT_TRUE(saw_settings_root);
}
+8 -1
View File
@@ -47,14 +47,21 @@ TEST_F(NodeViewTest, ShowSelectedNodeInParamEditorNoSelectionIsNoOp)
QSignalSpy changed_with_ctx_spy(
&view, &NodeView::NodeSelectionChangedWithContexts);
// The action must exist; silently skipping the trigger would make this
// test pass without exercising anything
QAction *show_params_action = nullptr;
foreach (QAction *action, view.actions()) {
if (action->property("id").toString() ==
QStringLiteral("shownodeparams")) {
action->trigger();
show_params_action = action;
break;
}
}
ASSERT_NE(show_params_action, nullptr)
<< "NodeView has no action with id 'shownodeparams'";
// With nothing selected, triggering it must not emit a selection change
show_params_action->trigger();
EXPECT_EQ(changed_with_ctx_spy.count(), 0);
}
+84 -14
View File
@@ -1,7 +1,9 @@
#include <gtest/gtest.h>
#include <QLabel>
#include <QProgressBar>
#include <QSignalSpy>
#include <QSplitter>
#include <kddockwidgets/KDDockWidgets.h>
@@ -39,6 +41,9 @@
#include "undo/undostack.h"
#include "widget/curvewidget/curvewidget.h"
#include "widget/history/historywidget.h"
#include "widget/nodetableview/nodetableview.h"
#include "widget/nodetreeview/nodetreeview.h"
#include "widget/pixelsampler/pixelsampler.h"
#include "widget/taskview/taskview.h"
#include "widget/taskview/taskviewitem.h"
#include "widget/timebased/timebasedwidget.h"
@@ -229,14 +234,20 @@ TEST_F(PanelTest, PanelWidgetBaseCloseBehavior)
EXPECT_TRUE(panel.isVisible());
}
TEST_F(PanelTest, PanelWidgetBaseDefaultActionsAreNoOps)
TEST_F(PanelTest, PanelWidgetBaseDefaultActionsLeaveStateUnchanged)
{
TestPanel panel(QStringLiteral("NoOpTestPanel"));
panel.SetTitle(QStringLiteral("NoOp"));
panel.show();
ASSERT_TRUE(panel.isVisible());
// Default SaveData is empty and LoadData accepts anything
EXPECT_TRUE(panel.SaveData().empty());
panel.LoadData(PanelWidget::Info());
// None of the default actions may request a close
QSignalSpy close_spy(&panel, &PanelWidget::CloseRequested);
// All default actions are no-ops and must not crash
panel.ZoomIn();
panel.ZoomOut();
@@ -286,7 +297,11 @@ TEST_F(PanelTest, PanelWidgetBaseDefaultActionsAreNoOps)
panel.MoveInToPlayhead();
panel.MoveOutToPlayhead();
SUCCEED();
// The sweep must not have altered any observable panel state
EXPECT_EQ(panel.title(), QStringLiteral("NoOp"));
EXPECT_TRUE(panel.isVisible());
EXPECT_TRUE(panel.SaveData().empty());
EXPECT_EQ(close_spy.count(), 0);
}
TEST_F(PanelTest, PanelWidgetBaseBorderAndFocus)
@@ -310,11 +325,21 @@ TEST_F(PanelTest, PixelSamplerPanelConstruction)
EXPECT_EQ(panel.objectName(), QStringLiteral("PixelSamplerPanel"));
EXPECT_EQ(panel.title(), QStringLiteral("Pixel Sampler"));
// Feeding values through the slot must not crash
// The panel hosts the two sampler views of a ManagedPixelSamplerWidget
const auto samplers = panel.findChildren<PixelSamplerWidget *>();
ASSERT_EQ(samplers.size(), 2);
// Feeding values through the slot updates the displayed components
Color red(1.0, 0.0, 0.0, 1.0);
Color green(0.0, 1.0, 0.0, 1.0);
panel.SetValues(red, green);
// First child is the display view, second the reference view
EXPECT_TRUE(samplers.at(0)->findChild<QLabel *>()->text().contains(
QStringLiteral("G: 1 (255)")));
EXPECT_TRUE(samplers.at(1)->findChild<QLabel *>()->text().contains(
QStringLiteral("R: 1 (255)")));
// The panel hooks its visibility into Core's pixel sampling requests
emit panel.shown(Qt::OtherFocusReason);
emit panel.hidden();
@@ -369,12 +394,27 @@ TEST_F(PanelTest, CurvePanelSetNodes)
auto *math = AddNode<MathNode>(&project);
CurvePanel panel;
panel.SetNode(math);
panel.SetNode(nullptr);
panel.SetNodes({ math });
panel.SetNodes({});
auto *tree = panel.findChild<NodeTreeView *>();
ASSERT_NE(tree, nullptr);
EXPECT_EQ(tree->topLevelItemCount(), 0);
SUCCEED();
// A single node appears as one top-level item listing its keyframable
// inputs (MathNode has three: the base "enabled" input and parameters
// A and B)
panel.SetNode(math);
ASSERT_EQ(tree->topLevelItemCount(), 1);
EXPECT_EQ(tree->topLevelItem(0)->text(0), math->Name());
EXPECT_EQ(tree->topLevelItem(0)->childCount(), 3);
// A null node clears the tree again
panel.SetNode(nullptr);
EXPECT_EQ(tree->topLevelItemCount(), 0);
// Same through the multi-node slot
panel.SetNodes({ math });
EXPECT_EQ(tree->topLevelItemCount(), 1);
panel.SetNodes({});
EXPECT_EQ(tree->topLevelItemCount(), 0);
}
TEST_F(PanelTest, ParamPanelConstructionAndContexts)
@@ -527,10 +567,20 @@ TEST_F(PanelTest, NodeTablePanelConstruction)
EXPECT_EQ(panel.title(), QStringLiteral("Table View"));
ASSERT_NE(panel.GetTimeBasedWidget(), nullptr);
panel.SelectNodes({ math });
panel.DeselectNodes({ math });
auto *view = panel.findChild<NodeTableView *>();
ASSERT_NE(view, nullptr);
EXPECT_EQ(view->columnCount(), 6);
EXPECT_EQ(view->headerItem()->text(0), QStringLiteral("Type"));
EXPECT_EQ(view->topLevelItemCount(), 0);
SUCCEED();
// Selecting a node adds a top-level row labeled with the node
panel.SelectNodes({ math });
ASSERT_EQ(view->topLevelItemCount(), 1);
EXPECT_EQ(view->topLevelItem(0)->text(0), math->GetLabelAndName());
// Deselecting removes it again
panel.DeselectNodes({ math });
EXPECT_EQ(view->topLevelItemCount(), 0);
}
TEST_F(PanelTest, MulticamPanelConstruction)
@@ -643,17 +693,37 @@ TEST_F(PanelTest, TimelinePanelSaveLoadDataRoundTrip)
{
TimelinePanel panel(QStringLiteral("TimelinePanelDataTest"));
// The vertical view splitter (video/audio/subtitle views) is a direct
// child of the timeline widget
const QList<QSplitter *> splitters =
panel.timeline_widget()->findChildren<QSplitter *>(
QString(), Qt::FindDirectChildrenOnly);
ASSERT_EQ(splitters.size(), 1);
QSplitter *splitter = splitters.first();
ASSERT_EQ(splitter->count(), 3);
// Save a known layout
splitter->setSizes({ 200, 400, 100 });
const QList<int> saved_sizes = splitter->sizes();
PanelWidget::Info info = panel.SaveData();
ASSERT_EQ(info.size(), 1);
EXPECT_TRUE(info.count(QStringLiteral("splitter")));
// Loading the saved state back must not throw or crash
// Disturb the layout so the restore has something to undo
splitter->setSizes({ 500, 100, 100 });
ASSERT_NE(splitter->sizes(), saved_sizes);
// LoadData must restore the splitter layout captured by SaveData
panel.LoadData(info);
EXPECT_EQ(splitter->sizes(), saved_sizes);
EXPECT_EQ(panel.timeline_widget()->SaveSplitterState(),
QByteArray::fromBase64(
info.at(QStringLiteral("splitter")).toUtf8()));
// Loading twice is idempotent
panel.LoadData(info);
SUCCEED();
EXPECT_EQ(splitter->sizes(), saved_sizes);
}
TEST_F(PanelTest, ToolPanelReflectsCoreToolState)
+74 -58
View File
@@ -6,7 +6,9 @@
#include <QtGlobal>
#include <QDir>
#include <QDebug>
#include <QFileInfo>
#include "codec/decoder.h"
#include "common/ffmpegutils.h"
#include "render/videoparams.h"
#include "render/texture.h"
@@ -67,37 +69,53 @@ static AVFramePtr CreateTestFrame(int width, int height, int fmt,
return frame;
}
// Test U8 to U16 conversion
// Test U8 to U16 conversion through the bridge scaler
TEST(FormatConversion, U8ToU16)
{
const int width = 10;
const int height = 10;
const uint32_t test_color = 0xFF804020; // ARGB: A=255, R=128, G=64, B=32
const uint32_t test_color = 0xFF804020; // R=255, G=128, B=64, A=32
// Create U8 frame
// Create U8 source frame
AVFramePtr u8_frame =
CreateTestFrame(width, height, FB_PIX_FMT_RGBA, test_color);
ASSERT_NE(u8_frame, nullptr);
// Verify U8 values
uint8_t *first_pixel_u8 = u8_frame->data(0);
EXPECT_EQ(first_pixel_u8[0], 0xFF); // R
EXPECT_EQ(first_pixel_u8[1], 0x80); // G
EXPECT_EQ(first_pixel_u8[2], 0x40); // B
EXPECT_EQ(first_pixel_u8[3], 0x20); // A
// Create U16 frame
// Create U16 destination frame
AVFramePtr u16_frame =
CreateTestFrame(width, height, FB_PIX_FMT_RGBA64LE, test_color);
CreateTestFrame(width, height, FB_PIX_FMT_RGBA64LE, 0);
ASSERT_NE(u16_frame, nullptr);
// Verify U16 values (should be U8 value repeated: 0xFF -> 0xFFFF, 0x80 -> 0x8080)
uint16_t *first_pixel_u16 =
reinterpret_cast<uint16_t *>(u16_frame->data(0));
EXPECT_EQ(first_pixel_u16[0], 0xFFFF); // R
EXPECT_EQ(first_pixel_u16[1], 0x8080); // G
EXPECT_EQ(first_pixel_u16[2], 0x4040); // B
EXPECT_EQ(first_pixel_u16[3], 0x2020); // A
// Use the bridge scaler to convert
FBScaler *sws_ctx = fb_scaler_create(width, height, FB_PIX_FMT_RGBA, width,
height, FB_PIX_FMT_RGBA64LE,
FB_SCALER_POINT);
ASSERT_NE(sws_ctx, nullptr);
uint8_t *src_data[4];
int src_linesize[4];
uint8_t *dst_data[4];
int dst_linesize[4];
for (int i = 0; i < 4; ++i) {
src_data[i] = u8_frame->data(i);
src_linesize[i] = u8_frame->linesize(i);
dst_data[i] = u16_frame->data(i);
dst_linesize[i] = u16_frame->linesize(i);
}
fb_scaler_scale_slices(sws_ctx, src_data, src_linesize, height, dst_data,
dst_linesize);
fb_scaler_free(&sws_ctx);
// Verify conversion: each 16-bit channel should hold the 8-bit value
// scaled up (v * 257, i.e. (v << 8) | v); allow two 8-bit LSBs of
// rounding like the U16 -> U8 test below.
const uint16_t *first_pixel =
reinterpret_cast<const uint16_t *>(u16_frame->data(0));
EXPECT_NEAR(first_pixel[0], 0xFFFF, 512); // R
EXPECT_NEAR(first_pixel[1], 0x8080, 512); // G
EXPECT_NEAR(first_pixel[2], 0x4040, 512); // B
EXPECT_NEAR(first_pixel[3], 0x2020, 512); // A
}
// Test FFmpeg sws_scale for U16 to U8 conversion
@@ -174,22 +192,22 @@ TEST(FormatConversion, VideoParamsToAVFormat)
EXPECT_EQ(fmt_u16_rgb, FB_PIX_FMT_RGB48LE);
}
// Test row bytes calculation
// Test row bytes calculation via VideoParams::GetBytesPerPixel
TEST(FormatConversion, RowBytes)
{
const int width = 320;
// U8 RGBA: 4 bytes per pixel
EXPECT_EQ(width * 4, 1280);
EXPECT_EQ(width * VideoParams::GetBytesPerPixel(PixelFormat::U8, 4), 1280);
// U16 RGBA: 8 bytes per pixel
EXPECT_EQ(width * 8, 2560);
EXPECT_EQ(width * VideoParams::GetBytesPerPixel(PixelFormat::U16, 4), 2560);
// U8 RGB: 3 bytes per pixel
EXPECT_EQ(width * 3, 960);
EXPECT_EQ(width * VideoParams::GetBytesPerPixel(PixelFormat::U8, 3), 960);
// U16 RGB: 6 bytes per pixel
EXPECT_EQ(width * 6, 1920);
EXPECT_EQ(width * VideoParams::GetBytesPerPixel(PixelFormat::U16, 3), 1920);
}
// Test that linesize may differ from width * bpp due to alignment
@@ -216,46 +234,44 @@ TEST(FormatConversion, LinesizeAlignment)
// Test loading actual image file
TEST(FormatConversion, LoadImageFile)
{
// Load the test image
QString img_path =
QStringLiteral("%1/../tests/img.png").arg(QDir::currentPath());
const QString img_path = QDir(QStringLiteral(OAK_TEST_SOURCE_DIR))
.filePath(QStringLiteral("tests/img.png"));
ASSERT_TRUE(QFileInfo::exists(img_path));
AVFramePtr frame = CreateAVFramePtr();
// Just create a simple test frame instead of loading an image
frame->set_width(1920);
frame->set_height(1080);
frame->set_format(FB_PIX_FMT_RGBA);
if (frame->get_buffer(0) < 0) {
return;
}
// Fill with orange color (sunrise sky)
for (int y = 0; y < frame->height(); ++y) {
uint8_t *row = frame->data(0) + y * frame->linesize(0);
uint8_t r = 255;
uint8_t g = 128 + (y * 127) / frame->height(); // Gradient from 128 to 255
uint8_t b = 64;
uint8_t a = 255;
for (int x = 0; x < frame->width(); ++x) {
row[x * 4 + 0] = r;
row[x * 4 + 1] = g;
row[x * 4 + 2] = b;
row[x * 4 + 3] = a;
}
}
ASSERT_NE(frame->data(0), nullptr) << "Failed to create test frame";
DecoderPtr decoder = Decoder::CreateFromID(QStringLiteral("oiio"));
ASSERT_TRUE(decoder);
ASSERT_TRUE(decoder->Open(Decoder::CodecStream(img_path, 0, nullptr)));
Decoder::RetrieveVideoParams params;
params.time = rational(0);
params.divider = 1;
FramePtr frame = decoder->RetrieveVideoFrame(params);
decoder->Close();
ASSERT_TRUE(frame);
ASSERT_TRUE(frame->is_allocated());
EXPECT_EQ(frame->width(), 1920);
EXPECT_EQ(frame->height(), 1080);
// Check first pixel (top-left corner of the sunrise image)
// Based on the image, it should have some orange/pink color in the sky area
uint8_t *first_pixel = frame->data(0);
qDebug() << "First pixel RGBA:" << first_pixel[0] << first_pixel[1]
<< first_pixel[2] << first_pixel[3];
// Still images are decoded to F32 RGBA (channel values scaled by 1/255)
EXPECT_EQ(frame->format(), PixelFormat::F32);
EXPECT_EQ(frame->channel_count(), 4);
// The image is RGB, so we expect 3 channels
// First pixel should be non-black (sky area)
EXPECT_GT(first_pixel[0] + first_pixel[1] + first_pixel[2], 0);
// Spot-check decoded pixels against the known PNG content
const float eps = 1.0f / 255.0f + 0.001f;
const Color top_left = frame->get_pixel(0, 0);
EXPECT_NEAR(top_left.red(), 75.0f / 255.0f, eps);
EXPECT_NEAR(top_left.green(), 124.0f / 255.0f, eps);
EXPECT_NEAR(top_left.blue(), 127.0f / 255.0f, eps);
EXPECT_NEAR(top_left.alpha(), 1.0f, eps);
const Color center = frame->get_pixel(960, 540);
EXPECT_NEAR(center.red(), 131.0f / 255.0f, eps);
EXPECT_NEAR(center.green(), 108.0f / 255.0f, eps);
EXPECT_NEAR(center.blue(), 111.0f / 255.0f, eps);
EXPECT_NEAR(center.alpha(), 1.0f, eps);
}
// Tests are registered with gtest, no main needed
+7 -555
View File
@@ -2,19 +2,20 @@
* Oak Video Editor - Plugin Subsystem Smoke Tests
* Copyright (C) 2025 Olive CE Team
*
* Comprehensive smoke tests for the OFX plugin subsystem including:
* Smoke tests for the OFX plugin subsystem covering:
* - Plugin host initialization
* - Clip and image management
* - Parameter instances (all types)
* - Plugin node lifecycle
* - Plugin job execution
* - Renderer integration
* - Concurrent image allocation
*
* Parameter instance, clip, image and renderer coverage lives in the
* dedicated plugin_paraminstance_test.cpp, plugin_support_param_test.cpp,
* plugin_support_clip_test.cpp, plugin_support_image_test.cpp and
* plugin_renderer_readback_test.cpp files.
*/
#include <gtest/gtest.h>
#include <atomic>
#include <mutex>
#include <thread>
#include <QCoreApplication>
@@ -31,12 +32,9 @@
#include "pluginSupport/OliveClip.h"
#include "pluginSupport/OlivePluginInstance.h"
#include "pluginSupport/image.h"
#include "pluginSupport/paraminstance.h"
// Node and render headers
#include "node/plugins/Plugin.h"
#include "render/job/pluginjob.h"
#include "render/plugin/pluginrenderer.h"
#include "render/videoparams.h"
#include "render/texture.h"
@@ -121,419 +119,6 @@ TEST(PluginSmoke, LoadPluginsNonExistentPathNoCrash)
{ loadPlugins(QStringLiteral("/nonexistent/path/to/plugins")); });
}
// ============================================================================
// Smoke Test: OliveClipInstance
// ============================================================================
TEST(PluginSmokeClip, OutputClipProperties)
{
OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName);
VideoParams params =
MakeVideoParams(1920, 1080, core::PixelFormat::U8, 4, true);
params.set_pixel_aspect_ratio(core::rational(16, 9));
params.set_frame_rate(core::rational(24, 1));
params.set_start_time(0);
params.set_duration(100);
OliveClipInstance clip(nullptr, desc, params);
// Test bit depth mapping
EXPECT_EQ(clip.getUnmappedBitDepth(), kOfxBitDepthByte);
// Test component mapping
EXPECT_EQ(clip.getUnmappedComponents(), kOfxImageComponentRGBA);
// Test premultiplication
EXPECT_EQ(clip.getPremult(), kOfxImagePreMultiplied);
// Test aspect ratio
EXPECT_DOUBLE_EQ(clip.getAspectRatio(), 16.0 / 9.0);
// Test frame rate
EXPECT_DOUBLE_EQ(clip.getFrameRate(), 24.0);
// Test frame range
double start_frame = 0.0, end_frame = 0.0;
clip.getFrameRange(start_frame, end_frame);
EXPECT_DOUBLE_EQ(start_frame, 0.0);
EXPECT_DOUBLE_EQ(end_frame, 100.0 * 24.0); // duration * frame_rate
}
TEST(PluginSmokeClip, ClipDifferentPixelFormats)
{
// Test U16 format
{
OFX::Host::ImageEffect::ClipDescriptor desc(
kOfxImageEffectOutputClipName);
VideoParams params =
MakeVideoParams(640, 480, core::PixelFormat::U16, 3, false);
OliveClipInstance clip(nullptr, desc, params);
EXPECT_EQ(clip.getUnmappedBitDepth(), kOfxBitDepthShort);
EXPECT_EQ(clip.getUnmappedComponents(), kOfxImageComponentRGB);
EXPECT_EQ(clip.getPremult(), kOfxImageUnPreMultiplied);
}
// Test F16 format
{
OFX::Host::ImageEffect::ClipDescriptor desc(
kOfxImageEffectOutputClipName);
VideoParams params =
MakeVideoParams(640, 480, core::PixelFormat::F16, 4, true);
OliveClipInstance clip(nullptr, desc, params);
EXPECT_EQ(clip.getUnmappedBitDepth(), kOfxBitDepthHalf);
EXPECT_EQ(clip.getUnmappedComponents(), kOfxImageComponentRGBA);
EXPECT_EQ(clip.getPremult(), kOfxImagePreMultiplied);
}
// Test F32 format
{
OFX::Host::ImageEffect::ClipDescriptor desc(
kOfxImageEffectOutputClipName);
VideoParams params =
MakeVideoParams(640, 480, core::PixelFormat::F32, 4, false);
OliveClipInstance clip(nullptr, desc, params);
EXPECT_EQ(clip.getUnmappedBitDepth(), kOfxBitDepthFloat);
EXPECT_EQ(clip.getUnmappedComponents(), kOfxImageComponentRGBA);
EXPECT_EQ(clip.getPremult(), kOfxImageUnPreMultiplied);
}
}
TEST(PluginSmokeClip, SourceClipNotConnected)
{
OFX::Host::ImageEffect::ClipDescriptor desc("Source");
VideoParams params =
MakeVideoParams(320, 240, core::PixelFormat::U8, 4, false);
OliveClipInstance clip(nullptr, desc, params);
// Source clips should not be connected (no input provided in test)
EXPECT_FALSE(clip.getConnected());
EXPECT_FALSE(clip.getContinuousSamples());
}
// ============================================================================
// Smoke Test: Image
// ============================================================================
TEST(PluginSmokeImage, BasicAllocation)
{
OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName);
VideoParams params =
MakeVideoParams(64, 64, core::PixelFormat::U8, 4, true);
OliveClipInstance clip(nullptr, desc, params);
Image image(clip);
OfxRectI bounds = { 0, 0, 64, 64 };
OfxRectI rod = bounds;
image.AllocateFromParams(params, bounds, rod, true);
EXPECT_NE(image.data(), nullptr);
EXPECT_EQ(image.width(), 64);
EXPECT_EQ(image.height(), 64);
EXPECT_EQ(image.row_bytes(), 64 * 4);
EXPECT_EQ(image.pixel_format(), core::PixelFormat::U8);
EXPECT_EQ(image.channel_count(), 4);
}
TEST(PluginSmokeImage, ClearOnAllocate)
{
OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName);
VideoParams params =
MakeVideoParams(16, 16, core::PixelFormat::U8, 4, false);
OliveClipInstance clip(nullptr, desc, params);
Image image(clip);
OfxRectI bounds = { 0, 0, 16, 16 };
OfxRectI rod = bounds;
// Allocate without clear
image.AllocateFromParams(params, bounds, rod, false);
ASSERT_NE(image.data(), nullptr);
// Write some data
std::memset(image.data(), 0xAB, image.row_bytes() * image.height());
// Reallocate with clear
image.AllocateFromParams(params, bounds, rod, true);
// Verify data is cleared
bool all_zero = true;
for (int y = 0; y < 16 && all_zero; ++y) {
for (int x = 0; x < 16 * 4; ++x) {
if (image.data()[y * image.row_bytes() + x] != 0) {
all_zero = false;
break;
}
}
}
EXPECT_TRUE(all_zero);
}
TEST(PluginSmokeImage, ResizeOnAllocate)
{
OFX::Host::ImageEffect::ClipDescriptor desc(kOfxImageEffectOutputClipName);
VideoParams params =
MakeVideoParams(32, 32, core::PixelFormat::U8, 4, false);
OliveClipInstance clip(nullptr, desc, params);
Image image(clip);
OfxRectI bounds = { 0, 0, 32, 32 };
OfxRectI rod = bounds;
image.AllocateFromParams(params, bounds, rod, true);
EXPECT_EQ(image.width(), 32);
EXPECT_EQ(image.height(), 32);
// Resize to smaller
OfxRectI new_bounds = { 0, 0, 16, 16 };
image.EnsureAllocatedFromParams(params, new_bounds, rod, false);
EXPECT_EQ(image.width(), 16);
EXPECT_EQ(image.height(), 16);
}
// ============================================================================
// Smoke Test: Parameter Instances (without node binding)
// ============================================================================
TEST(PluginSmokeParam, IntegerNullNode)
{
OFX::Host::Param::Descriptor desc(kOfxParamTypeInteger, "TestInt");
IntegerInstance instance(nullptr, desc);
// Default value should be 0
int value = -1;
EXPECT_EQ(instance.get(value), kOfxStatOK);
EXPECT_EQ(value, 0);
// Set value
EXPECT_EQ(instance.set(42), kOfxStatOK);
// Get value back
EXPECT_EQ(instance.get(value), kOfxStatOK);
EXPECT_EQ(value, 42);
// Get at time (should return same value without node)
int time_value = -1;
EXPECT_EQ(instance.get(1.0, time_value), kOfxStatOK);
EXPECT_EQ(time_value, 42);
}
TEST(PluginSmokeParam, DoubleNullNode)
{
OFX::Host::Param::Descriptor desc(kOfxParamTypeDouble, "TestDouble");
DoubleInstance instance(nullptr, "TestDouble", desc);
double value = -1.0;
EXPECT_EQ(instance.get(value), kOfxStatOK);
EXPECT_DOUBLE_EQ(value, 0.0);
EXPECT_EQ(instance.set(3.14159), kOfxStatOK);
EXPECT_EQ(instance.get(value), kOfxStatOK);
EXPECT_DOUBLE_EQ(value, 3.14159);
}
TEST(PluginSmokeParam, BooleanNullNode)
{
OFX::Host::Param::Descriptor desc(kOfxParamTypeBoolean, "TestBool");
BooleanInstance instance(nullptr, "TestBool", desc);
bool value = true; // Start with opposite
EXPECT_EQ(instance.get(value), kOfxStatOK);
EXPECT_FALSE(value);
EXPECT_EQ(instance.set(true), kOfxStatOK);
EXPECT_EQ(instance.get(value), kOfxStatOK);
EXPECT_TRUE(value);
}
TEST(PluginSmokeParam, ChoiceNullNode)
{
OFX::Host::Param::Descriptor desc(kOfxParamTypeChoice, "TestChoice");
ChoiceInstance instance(nullptr, "TestChoice", desc);
int value = -1;
EXPECT_EQ(instance.get(value), kOfxStatOK);
EXPECT_EQ(value, 0);
EXPECT_EQ(instance.set(2), kOfxStatOK);
EXPECT_EQ(instance.get(value), kOfxStatOK);
EXPECT_EQ(value, 2);
}
TEST(PluginSmokeParam, RGBANullNode)
{
OFX::Host::Param::Descriptor desc(kOfxParamTypeRGBA, "TestColor");
RGBAInstance instance(nullptr, "TestColor", desc);
double r = 0, g = 0, b = 0, a = 0;
EXPECT_EQ(instance.get(r, g, b, a), kOfxStatOK);
EXPECT_DOUBLE_EQ(r, 0.0);
EXPECT_DOUBLE_EQ(g, 0.0);
EXPECT_DOUBLE_EQ(b, 0.0);
EXPECT_DOUBLE_EQ(a, 0.0);
EXPECT_EQ(instance.set(1.0, 0.5, 0.25, 1.0), kOfxStatOK);
EXPECT_EQ(instance.get(r, g, b, a), kOfxStatOK);
EXPECT_DOUBLE_EQ(r, 1.0);
EXPECT_DOUBLE_EQ(g, 0.5);
EXPECT_DOUBLE_EQ(b, 0.25);
EXPECT_DOUBLE_EQ(a, 1.0);
}
TEST(PluginSmokeParam, RGBNullNode)
{
OFX::Host::Param::Descriptor desc(kOfxParamTypeRGB, "TestRGB");
RGBInstance instance(nullptr, "TestRGB", desc);
double r = 0, g = 0, b = 0;
EXPECT_EQ(instance.get(r, g, b), kOfxStatOK);
EXPECT_DOUBLE_EQ(r, 0.0);
EXPECT_DOUBLE_EQ(g, 0.0);
EXPECT_DOUBLE_EQ(b, 0.0);
EXPECT_EQ(instance.set(0.8, 0.6, 0.4), kOfxStatOK);
EXPECT_EQ(instance.get(r, g, b), kOfxStatOK);
EXPECT_DOUBLE_EQ(r, 0.8);
EXPECT_DOUBLE_EQ(g, 0.6);
EXPECT_DOUBLE_EQ(b, 0.4);
}
TEST(PluginSmokeParam, Double2DNullNode)
{
OFX::Host::Param::Descriptor desc(kOfxParamTypeDouble2D, "TestVec2");
Double2DInstance instance(nullptr, "TestVec2", desc);
double x = 0, y = 0;
EXPECT_EQ(instance.get(x, y), kOfxStatOK);
EXPECT_DOUBLE_EQ(x, 0.0);
EXPECT_DOUBLE_EQ(y, 0.0);
EXPECT_EQ(instance.set(10.5, 20.5), kOfxStatOK);
EXPECT_EQ(instance.get(x, y), kOfxStatOK);
EXPECT_DOUBLE_EQ(x, 10.5);
EXPECT_DOUBLE_EQ(y, 20.5);
}
TEST(PluginSmokeParam, Integer2DNullNode)
{
OFX::Host::Param::Descriptor desc(kOfxParamTypeInteger2D, "TestIVec2");
Integer2DInstance instance(nullptr, "TestIVec2", desc);
int x = 0, y = 0;
EXPECT_EQ(instance.get(x, y), kOfxStatOK);
EXPECT_EQ(x, 0);
EXPECT_EQ(y, 0);
EXPECT_EQ(instance.set(100, 200), kOfxStatOK);
EXPECT_EQ(instance.get(x, y), kOfxStatOK);
EXPECT_EQ(x, 100);
EXPECT_EQ(y, 200);
}
TEST(PluginSmokeParam, Double3DNullNode)
{
OFX::Host::Param::Descriptor desc(kOfxParamTypeDouble3D, "TestVec3");
Double3DInstance instance(nullptr, "TestVec3", desc);
double x = 0, y = 0, z = 0;
EXPECT_EQ(instance.get(x, y, z), kOfxStatOK);
EXPECT_DOUBLE_EQ(x, 0.0);
EXPECT_DOUBLE_EQ(y, 0.0);
EXPECT_DOUBLE_EQ(z, 0.0);
EXPECT_EQ(instance.set(1.0, 2.0, 3.0), kOfxStatOK);
EXPECT_EQ(instance.get(x, y, z), kOfxStatOK);
EXPECT_DOUBLE_EQ(x, 1.0);
EXPECT_DOUBLE_EQ(y, 2.0);
EXPECT_DOUBLE_EQ(z, 3.0);
}
TEST(PluginSmokeParam, Integer3DNullNode)
{
OFX::Host::Param::Descriptor desc(kOfxParamTypeInteger3D, "TestIVec3");
Integer3DInstance instance(nullptr, "TestIVec3", desc);
int x = 0, y = 0, z = 0;
EXPECT_EQ(instance.get(x, y, z), kOfxStatOK);
EXPECT_EQ(x, 0);
EXPECT_EQ(y, 0);
EXPECT_EQ(z, 0);
EXPECT_EQ(instance.set(10, 20, 30), kOfxStatOK);
EXPECT_EQ(instance.get(x, y, z), kOfxStatOK);
EXPECT_EQ(x, 10);
EXPECT_EQ(y, 20);
EXPECT_EQ(z, 30);
}
TEST(PluginSmokeParam, StringNullNode)
{
OFX::Host::Param::Descriptor desc(kOfxParamTypeString, "TestString");
StringInstance instance(nullptr, "TestString", desc);
std::string value;
EXPECT_EQ(instance.get(value), kOfxStatOK);
EXPECT_TRUE(value.empty());
EXPECT_EQ(instance.set("hello world"), kOfxStatOK);
EXPECT_EQ(instance.get(value), kOfxStatOK);
EXPECT_EQ(value, "hello world");
}
TEST(PluginSmokeParam, CustomNullNode)
{
OFX::Host::Param::Descriptor desc(kOfxParamTypeCustom, "TestCustom");
CustomInstance instance(nullptr, "TestCustom", desc);
std::string value;
EXPECT_EQ(instance.get(value), kOfxStatOK);
// Custom params may have default values
EXPECT_EQ(instance.set("custom data"), kOfxStatOK);
EXPECT_EQ(instance.get(value), kOfxStatOK);
EXPECT_EQ(value, "custom data");
}
// ============================================================================
// Smoke Test: Plugin Renderer
// ============================================================================
TEST(PluginSmokeRenderer, BytesToPixelsConversion)
{
VideoParams params(100, 100, core::PixelFormat::U8, 4, core::rational(1, 1),
VideoParams::kInterlaceNone, 1);
// 4 channels * 1 byte = 4 bytes per pixel
EXPECT_EQ(detail::BytesToPixels(400, params), 100);
EXPECT_EQ(detail::BytesToPixels(0, params), 0);
// Test with RGB (3 channels)
VideoParams params_rgb(100, 100, core::PixelFormat::U8, 3,
core::rational(1, 1), VideoParams::kInterlaceNone,
1);
EXPECT_EQ(detail::BytesToPixels(300, params_rgb), 100);
}
TEST(PluginSmokeRenderer, BytesToPixelsInvalidInput)
{
VideoParams params(100, 100, core::PixelFormat::U8, 4, core::rational(1, 1),
VideoParams::kInterlaceNone, 1);
// Negative input should return 0
EXPECT_EQ(detail::BytesToPixels(-1, params), 0);
}
// ============================================================================
// Smoke Test: Plugin Job
// ============================================================================
@@ -572,101 +157,6 @@ TEST(PluginSmokeJob, JobWithTextureValue)
EXPECT_FALSE(job.GetValues().isEmpty());
}
// ============================================================================
// Smoke Test: Plugin Node (basic lifecycle)
// ============================================================================
TEST(PluginSmokeNode, NodeRequiresValidInstance)
{
// PluginNode requires a valid OFX instance
// Creating without one should be handled gracefully
// Note: This test documents expected behavior
// A PluginNode cannot be created without an instance
// The constructor requires an OFX::Host::ImageEffect::Instance
EXPECT_TRUE(true); // Placeholder for documentation
}
// ============================================================================
// Smoke Test: Integration Components
// ============================================================================
TEST(PluginSmokeIntegration, VideoParamsToOfxMapping)
{
// Test U8 -> Byte mapping
{
VideoParams params =
MakeVideoParams(100, 100, core::PixelFormat::U8, 4, false);
OFX::Host::ImageEffect::ClipDescriptor desc(
kOfxImageEffectOutputClipName);
OliveClipInstance clip(nullptr, desc, params);
EXPECT_EQ(clip.getUnmappedBitDepth(), kOfxBitDepthByte);
}
// Test U16 -> Short mapping
{
VideoParams params =
MakeVideoParams(100, 100, core::PixelFormat::U16, 4, false);
OFX::Host::ImageEffect::ClipDescriptor desc(
kOfxImageEffectOutputClipName);
OliveClipInstance clip(nullptr, desc, params);
EXPECT_EQ(clip.getUnmappedBitDepth(), kOfxBitDepthShort);
}
// Test F16 -> Half mapping
{
VideoParams params =
MakeVideoParams(100, 100, core::PixelFormat::F16, 4, false);
OFX::Host::ImageEffect::ClipDescriptor desc(
kOfxImageEffectOutputClipName);
OliveClipInstance clip(nullptr, desc, params);
EXPECT_EQ(clip.getUnmappedBitDepth(), kOfxBitDepthHalf);
}
// Test F32 -> Float mapping
{
VideoParams params =
MakeVideoParams(100, 100, core::PixelFormat::F32, 4, false);
OFX::Host::ImageEffect::ClipDescriptor desc(
kOfxImageEffectOutputClipName);
OliveClipInstance clip(nullptr, desc, params);
EXPECT_EQ(clip.getUnmappedBitDepth(), kOfxBitDepthFloat);
}
}
TEST(PluginSmokeIntegration, ComponentCountMapping)
{
// Test RGB (3 channels)
{
VideoParams params =
MakeVideoParams(100, 100, core::PixelFormat::U8, 3, false);
OFX::Host::ImageEffect::ClipDescriptor desc(
kOfxImageEffectOutputClipName);
OliveClipInstance clip(nullptr, desc, params);
EXPECT_EQ(clip.getUnmappedComponents(), kOfxImageComponentRGB);
}
// Test RGBA (4 channels)
{
VideoParams params =
MakeVideoParams(100, 100, core::PixelFormat::U8, 4, false);
OFX::Host::ImageEffect::ClipDescriptor desc(
kOfxImageEffectOutputClipName);
OliveClipInstance clip(nullptr, desc, params);
EXPECT_EQ(clip.getUnmappedComponents(), kOfxImageComponentRGBA);
}
// Test Alpha (1 channel)
{
VideoParams params =
MakeVideoParams(100, 100, core::PixelFormat::U8, 1, false);
OFX::Host::ImageEffect::ClipDescriptor desc(
kOfxImageEffectOutputClipName);
OliveClipInstance clip(nullptr, desc, params);
EXPECT_EQ(clip.getUnmappedComponents(), kOfxImageComponentAlpha);
}
}
// ============================================================================
// Smoke Test: Thread Safety
// ============================================================================
@@ -708,44 +198,6 @@ TEST(PluginSmokeThread, ConcurrentImageAllocation)
EXPECT_EQ(success_count.load(), num_threads * num_allocs_per_thread);
}
TEST(PluginSmokeThread, ConcurrentParamAccess)
{
const int num_threads = 4;
const int num_ops_per_thread = 100;
OFX::Host::Param::Descriptor desc(kOfxParamTypeInteger, "ConcurrentInt");
IntegerInstance instance(nullptr, desc);
std::atomic<int> success_count{ 0 };
std::mutex access_mutex;
std::vector<std::thread> threads;
for (int t = 0; t < num_threads; ++t) {
threads.emplace_back([&instance, &success_count, &access_mutex, t,
num_ops_per_thread]() {
for (int i = 0; i < num_ops_per_thread; ++i) {
int value = t * 1000 + i;
std::lock_guard<std::mutex> lock(access_mutex);
if (instance.set(value) == kOfxStatOK) {
int read_value = -1;
if (instance.get(read_value) == kOfxStatOK) {
// Without node binding, value should be what we just set
if (read_value == value) {
success_count++;
}
}
}
}
});
}
for (auto &t : threads) {
t.join();
}
EXPECT_EQ(success_count.load(), num_threads * num_ops_per_thread);
}
} // namespace test
} // namespace plugin
} // namespace olive
+48 -22
View File
@@ -3,6 +3,7 @@
#include <QApplication>
#include "audio/audiomanager.h"
#include "config/config.h"
#include "dialog/preferences/tabs/preferencesbehaviortab.h"
#include "dialog/preferences/tabs/preferencesgeneraltab.h"
#include "dialog/preferences/tabs/preferencesaudiotab.h"
@@ -41,32 +42,52 @@ TEST(PreferencesBehaviorTab, RenderingCategoryHasGraphicsBackendCombobox)
EXPECT_FALSE(tab.findChildren<QCheckBox *>().isEmpty());
}
TEST(PreferencesBehaviorTab, BehaviorPrefTrProvidesTranslations)
TEST(PreferencesBehaviorTab, BehaviorPrefTrReturnsExactSourceStrings)
{
QStringList keys;
keys << QStringLiteral("Enable hover focus")
<< QStringLiteral("Select also selects all children in the graph")
<< QStringLiteral("Double-clicking a node opens its properties")
<< QStringLiteral("Auto-Seek to Beginning of Sequence")
<< QStringLiteral("Scroll wheel zooms instead of scrolling")
<< QStringLiteral("Enable audio scrubbing");
foreach (const QString &key, keys) {
EXPECT_FALSE(
PreferencesBehaviorTab::BehaviorPrefTr(key.toUtf8().constData())
.isEmpty())
<< key.toStdString();
}
// BehaviorPrefTr() provides the shared source strings used by the other
// preference tabs; without a translator installed it returns the source
// text unchanged. Pin the exact strings so accidental edits are caught
// (they would silently change the translation keys and the cross-tab
// lookups that rely on them).
EXPECT_EQ(PreferencesBehaviorTab::BehaviorPrefTr("Behavior"),
QStringLiteral("Behavior"));
EXPECT_EQ(PreferencesBehaviorTab::BehaviorPrefTr("Enable hover focus"),
QStringLiteral("Enable hover focus"));
EXPECT_EQ(PreferencesBehaviorTab::BehaviorPrefTr("Enable slider ladder"),
QStringLiteral("Enable slider ladder"));
EXPECT_EQ(PreferencesBehaviorTab::BehaviorPrefTr(
"Scrolling zooms by default"),
QStringLiteral("Scrolling zooms by default"));
EXPECT_EQ(PreferencesBehaviorTab::BehaviorPrefTr("Enable audio scrubbing"),
QStringLiteral("Enable audio scrubbing"));
}
TEST(PreferencesBehaviorTab, RenderingCategoryContainsDefaultBackend)
{
// Force the config back to the registered default so the selected entry
// is deterministic regardless of test order
const QVariant saved_backend =
Config::Current()[QStringLiteral("GraphicsBackend")];
Config::Current()[QStringLiteral("GraphicsBackend")] =
QStringLiteral("opengl");
{
PreferencesBehaviorTab tab(PreferencesBehaviorTab::kCategoryRendering);
QList<QComboBox *> boxes = tab.findChildren<QComboBox *>();
ASSERT_FALSE(boxes.isEmpty());
QComboBox *backend_box = boxes.first();
EXPECT_GT(backend_box->count(), 0);
// config.cpp registers "opengl" as the default GraphicsBackend
const int opengl_index =
backend_box->findData(QStringLiteral("opengl"));
ASSERT_NE(opengl_index, -1);
EXPECT_EQ(backend_box->itemText(opengl_index),
QStringLiteral("OpenGL"));
EXPECT_EQ(backend_box->currentIndex(), opengl_index);
}
Config::Current()[QStringLiteral("GraphicsBackend")] = saved_backend;
}
TEST(PreferencesGeneralTab, ContainsHoverFocusOption)
@@ -120,15 +141,20 @@ TEST(PreferencesAudioTab, IncludesAudioScrubbingOption)
{
PreferencesAudioTab tab;
QList<QCheckBox *> boxes = tab.findChildren<QCheckBox *>();
bool found = false;
foreach (QCheckBox *box, boxes) {
if (!box->text().isEmpty()) {
found = true;
// Locate the scrubbing checkbox by its exact (untranslated) label
QCheckBox *scrubbing = nullptr;
foreach (QCheckBox *box, tab.findChildren<QCheckBox *>()) {
if (box->text() == QStringLiteral("Enable audio scrubbing")) {
scrubbing = box;
break;
}
}
EXPECT_TRUE(found);
ASSERT_NE(scrubbing, nullptr);
// Its initial state mirrors the AudioScrubbing config entry
EXPECT_EQ(scrubbing->isChecked(),
Config::Current()[QStringLiteral("AudioScrubbing")].toBool());
}
AudioManager::DestroyInstance();
+109 -40
View File
@@ -1,13 +1,16 @@
#include <gtest/gtest.h>
#include <QCoreApplication>
#include <QSignalSpy>
#include "codec/conformmanager.h"
#include "node/generator/solid/solid.h"
#include "node/output/viewer/viewer.h"
#include "node/project.h"
#include "render/diskmanager.h"
#include "render/previewautocacher.h"
#include "render/rendermanager.h"
#include "render/videoparams.h"
using namespace olive;
@@ -36,6 +39,22 @@ protected:
DiskManager::DestroyInstance();
}
ViewerOutput *CreateViewer()
{
auto *viewer = new ViewerOutput();
viewer->setParent(project_.get());
return viewer;
}
ViewerOutput *CreateViewerWithValidParams()
{
ViewerOutput *viewer = CreateViewer();
viewer->SetVideoParams(
VideoParams(64, 64, rational(1, 25), PixelFormat::U8,
VideoParams::kRGBAChannelCount));
return viewer;
}
std::unique_ptr<Project> project_;
};
@@ -45,45 +64,119 @@ TEST_F(PreviewAutoCacherTest, ConstructionInitializesDefaultState)
EXPECT_FALSE(cacher.IsRenderingCustomRange());
}
TEST_F(PreviewAutoCacherTest, SetProjectToNullDoesNotCrash)
// With a project set, a single-frame request is dispatched to the render
// pipeline, so the next request must not cancel it. After SetProject(nullptr)
// the copied graph is gone, so requests can only stay queued and each new
// request cancels the previously queued one.
TEST_F(PreviewAutoCacherTest, SetProjectToNullStopsSingleFrameDispatch)
{
ViewerOutput *viewer = CreateViewerWithValidParams();
// The single-frame path renders the node connected to the viewer's
// texture input, so connect something the copier can duplicate.
auto *solid = new SolidGenerator();
solid->setParent(project_.get());
Node::ConnectEdge(solid, NodeInput(viewer, ViewerOutput::kTextureInput));
PreviewAutoCacher cacher;
cacher.SetProject(project_.get());
RenderTicketPtr dispatched = cacher.GetSingleFrame(viewer, rational(0));
ASSERT_NE(dispatched, nullptr);
// A dispatched ticket is owned by the render pipeline; the next request
// must leave it alone
RenderTicketPtr next = cacher.GetSingleFrame(viewer, rational(1));
ASSERT_NE(next, nullptr);
EXPECT_EQ(dispatched->GetFinishCount(), 0);
EXPECT_TRUE(dispatched->IsRunning());
cacher.SetProject(nullptr);
// Without a copied graph there is nothing to dispatch to: the request
// stays queued and the next request cancels it
RenderTicketPtr queued = cacher.GetSingleFrame(viewer, rational(2));
ASSERT_NE(queued, nullptr);
RenderTicketPtr cancelling = cacher.GetSingleFrame(viewer, rational(3));
ASSERT_NE(cancelling, nullptr);
EXPECT_EQ(queued->GetFinishCount(), 1);
EXPECT_FALSE(queued->HasResult());
cacher.SetProject(nullptr);
EXPECT_FALSE(cacher.IsRenderingCustomRange());
}
TEST_F(PreviewAutoCacherTest, SetRendersPausedTogglesState)
// While renders are paused, forced cache ranges must stay queued; unpausing
// must dispatch them.
TEST_F(PreviewAutoCacherTest, SetRendersPausedBlocksAndResumesCacheJobs)
{
ViewerOutput *viewer = CreateViewerWithValidParams();
PreviewAutoCacher cacher;
cacher.SetProject(project_.get());
QSignalSpy stop_spy(&cacher, &PreviewAutoCacher::StopCacheProxyTasks);
cacher.SetRendersPaused(true);
cacher.ForceCacheRange(viewer, TimeRange(rational(0), rational(1, 25)));
EXPECT_TRUE(cacher.IsRenderingCustomRange());
EXPECT_EQ(stop_spy.count(), 0);
// The dummy backend finishes each ticket without a result, exhausting the
// range as soon as it is dispatched
cacher.SetRendersPaused(false);
EXPECT_FALSE(cacher.IsRenderingCustomRange());
EXPECT_GE(stop_spy.count(), 1);
// Deliver the queued RenderTicketWatcher::Finished emissions so the
// completed watchers are reaped before teardown.
QCoreApplication::processEvents();
cacher.SetProject(nullptr);
}
TEST_F(PreviewAutoCacherTest, SetThumbnailsPausedTogglesState)
// pause_thumbnails_ gates the same pending-video-job dispatch loop, so it is
// observable the same way as pause_renders_.
TEST_F(PreviewAutoCacherTest, SetThumbnailsPausedBlocksAndResumesCacheJobs)
{
ViewerOutput *viewer = CreateViewerWithValidParams();
PreviewAutoCacher cacher;
cacher.SetProject(project_.get());
QSignalSpy stop_spy(&cacher, &PreviewAutoCacher::StopCacheProxyTasks);
cacher.SetThumbnailsPaused(true);
cacher.ForceCacheRange(viewer, TimeRange(rational(0), rational(1, 25)));
EXPECT_TRUE(cacher.IsRenderingCustomRange());
EXPECT_EQ(stop_spy.count(), 0);
cacher.SetThumbnailsPaused(false);
EXPECT_FALSE(cacher.IsRenderingCustomRange());
EXPECT_GE(stop_spy.count(), 1);
// Deliver the queued RenderTicketWatcher::Finished emissions so the
// completed watchers are reaped before teardown.
QCoreApplication::processEvents();
cacher.SetProject(nullptr);
}
TEST_F(PreviewAutoCacherTest, SetPlayheadStoresPlayhead)
// ClearSingleFrameRenders only cancels already-dispatched passthrough renders;
// a single-frame ticket that is still queued must be left untouched.
TEST_F(PreviewAutoCacherTest, ClearSingleFrameRendersLeavesQueuedTicketPending)
{
PreviewAutoCacher cacher;
cacher.SetPlayhead(rational(42));
}
ViewerOutput *viewer = CreateViewer();
TEST_F(PreviewAutoCacherTest, ClearSingleFrameRendersDoesNotCrashWhenEmpty)
{
PreviewAutoCacher cacher;
RenderTicketPtr ticket = cacher.GetSingleFrame(viewer, rational(0));
ASSERT_NE(ticket, nullptr);
cacher.ClearSingleFrameRenders();
}
TEST_F(PreviewAutoCacherTest,
ClearSingleFrameRendersThatArentRunningDoesNotCrashWhenEmpty)
{
PreviewAutoCacher cacher;
cacher.ClearSingleFrameRendersThatArentRunning();
EXPECT_TRUE(ticket->IsRunning());
EXPECT_EQ(ticket->GetFinishCount(), 0);
EXPECT_FALSE(ticket->HasResult());
}
TEST_F(PreviewAutoCacherTest, GetSingleFrameWithoutProjectReturnsTicket)
@@ -95,27 +188,3 @@ TEST_F(PreviewAutoCacherTest, GetSingleFrameWithoutProjectReturnsTicket)
RenderTicketPtr ticket = cacher.GetSingleFrame(viewer, rational(0));
EXPECT_NE(ticket, nullptr);
}
TEST_F(PreviewAutoCacherTest, ForceCacheRangeDoesNotCrash)
{
auto *viewer = new ViewerOutput();
viewer->setParent(project_.get());
PreviewAutoCacher cacher;
cacher.ForceCacheRange(viewer, TimeRange(rational(0), rational(1)));
cacher.SetProject(nullptr);
}
TEST_F(PreviewAutoCacherTest, CancelVideoTasksDoesNotCrashWhenIdle)
{
PreviewAutoCacher cacher;
cacher.CancelVideoTasks(false);
cacher.CancelVideoTasks(true);
}
TEST_F(PreviewAutoCacherTest, CancelAudioTasksDoesNotCrashWhenIdle)
{
PreviewAutoCacher cacher;
cacher.CancelAudioTasks(false);
cacher.CancelAudioTasks(true);
}
+40 -2
View File
@@ -1,5 +1,9 @@
#include <gtest/gtest.h>
#include <QCheckBox>
#include <QPushButton>
#include <QTreeWidget>
#include "config/config.h"
#include "dialog/proxy/proxydialog.h"
#include "node/project/footage/footage.h"
@@ -35,11 +39,45 @@ TEST(ProxyDialog, ConstructsInGlobalModeWithNullParent)
TEST(ProxyDialog, ConstructsWithFootageList)
{
olive::Footage footage;
olive::Footage footage(QStringLiteral("/tmp/oak-proxy-test.mov"));
const QVector<olive::Footage *> items = { &footage };
olive::ProxyDialog dialog(nullptr, items);
SUCCEED();
EXPECT_EQ(dialog.windowTitle(), QStringLiteral("Proxy Settings"));
// Global mode has no footage tree at all
olive::ProxyDialog global_dialog(nullptr);
EXPECT_EQ(global_dialog.findChild<QTreeWidget *>(), nullptr);
// Footage mode shows one tree row per item with its proxy state
auto *tree = dialog.findChild<QTreeWidget *>();
ASSERT_NE(tree, nullptr);
ASSERT_EQ(tree->topLevelItemCount(), 1);
EXPECT_EQ(tree->topLevelItem(0)->text(0),
QStringLiteral("/tmp/oak-proxy-test.mov"));
EXPECT_EQ(tree->topLevelItem(0)->text(1), QStringLiteral("missing"));
// Fresh footage has no custom params, so the custom settings checkbox
// starts unchecked
QCheckBox *custom_checkbox = nullptr;
foreach (QCheckBox *box, dialog.findChildren<QCheckBox *>()) {
if (box->text() ==
QStringLiteral("Use custom settings for selected footage")) {
custom_checkbox = box;
break;
}
}
ASSERT_NE(custom_checkbox, nullptr);
EXPECT_FALSE(custom_checkbox->isChecked());
// Footage mode adds generate/delete actions next to Close
QStringList button_texts;
foreach (QPushButton *b, dialog.findChildren<QPushButton *>()) {
button_texts << b->text();
}
EXPECT_TRUE(button_texts.contains(QStringLiteral("Generate Proxies")));
EXPECT_TRUE(button_texts.contains(QStringLiteral("Delete Proxies")));
EXPECT_TRUE(button_texts.contains(QStringLiteral("Close")));
}
TEST(ProxyDialog, AcceptSavesGlobalSettingsToConfig)
+68 -10
View File
@@ -2,8 +2,11 @@
#include <QCoreApplication>
#include <QDir>
#include <QEventLoop>
#include <QFile>
#include <QFileInfo>
#include <QTemporaryDir>
#include <QTimer>
#include <QXmlStreamReader>
#include <QXmlStreamWriter>
@@ -12,6 +15,7 @@
#include "node/project/footage/footage.h"
#include "render/job/footagejob.h"
#include "task/proxy/proxy.h"
#include "task/taskmanager.h"
namespace
{
@@ -217,39 +221,93 @@ TEST(ProxyManager, FootageClearRemovesProxyMetadata)
TEST(ProxyManager, EmitsProxyFinishedState)
{
// Drives a real proxy job through ProxyManager so that ProxyFinished is
// emitted by the manager's own task completion path
const QString ffmpeg = olive::ProxyManager::FindFFmpegExecutable(
ProxyConfigValue("FFmpegPath").toString());
if (ffmpeg.isEmpty()) {
GTEST_SKIP() << "ffmpeg executable not available";
}
const QString source =
QDir(QStringLiteral(OAK_TEST_SOURCE_DIR))
.filePath(QStringLiteral("tests/demo.mp4"));
ASSERT_TRUE(QFileInfo::exists(source));
const bool created_task_manager =
(olive::TaskManager::instance() == nullptr);
if (created_task_manager) {
olive::TaskManager::CreateInstance();
}
olive::ProxyManager::CreateInstance();
QTemporaryDir cache;
ASSERT_TRUE(cache.isValid());
// A small, fast preset keeps the encode of the 17 second demo clip quick
olive::ProxyManager::ProxyParams params;
params.width = 320;
params.height = 180;
params.preset = QStringLiteral("ultrafast");
params.include_audio = true;
const QString expected_proxy = olive::ProxyManager::GetProxyFilename(
cache.path(), source, 0, params);
bool received = false;
QString received_source;
int received_stream = -1;
QString received_proxy;
olive::ProxyManager::ProxyState received_state =
olive::ProxyManager::kProxyMissing;
bool ready_received = false;
QEventLoop loop;
QObject::connect(
olive::ProxyManager::instance(), &olive::ProxyManager::ProxyFinished,
&loop,
[&received, &received_source, &received_stream, &received_proxy,
&received_state](const QString &source_filename, int stream_index,
const QString &proxy_filename,
&received_state, &loop](const QString &source_filename,
int stream_index, const QString &proxy_filename,
olive::ProxyManager::ProxyState state) {
received = true;
received_source = source_filename;
received_stream = stream_index;
received_proxy = proxy_filename;
received_state = state;
loop.quit();
});
QObject::connect(olive::ProxyManager::instance(),
&olive::ProxyManager::ProxyReady, &loop,
[&ready_received](const QString &, int, const QString &) {
ready_received = true;
});
// Generous timeout; failure to finish in time fails the test below
QTimer::singleShot(120000, &loop, &QEventLoop::quit);
emit olive::ProxyManager::instance()
-> ProxyFinished(QStringLiteral("/media/source.mov"), 0,
QStringLiteral("/cache/proxy/example.mp4"),
olive::ProxyManager::kProxyFailed);
const olive::ProxyManager::Proxy proxy =
olive::ProxyManager::instance()->GetOrStartProxy(cache.path(), source, 0,
params);
ASSERT_EQ(proxy.state, olive::ProxyManager::kProxyGenerating);
ASSERT_NE(proxy.task, nullptr);
EXPECT_TRUE(received);
EXPECT_EQ(received_source, QStringLiteral("/media/source.mov"));
loop.exec();
ASSERT_TRUE(received) << "Timed out waiting for proxy generation";
EXPECT_TRUE(ready_received);
EXPECT_EQ(received_source, source);
EXPECT_EQ(received_stream, 0);
EXPECT_EQ(received_proxy, QStringLiteral("/cache/proxy/example.mp4"));
EXPECT_EQ(received_state, olive::ProxyManager::kProxyFailed);
EXPECT_EQ(received_proxy, expected_proxy);
EXPECT_EQ(received_state, olive::ProxyManager::kProxyReady);
// The manager moved the completed proxy into its final location
EXPECT_TRUE(QFileInfo::exists(expected_proxy));
EXPECT_EQ(olive::ProxyManager::GetProxyState(expected_proxy),
olive::ProxyManager::kProxyReady);
olive::ProxyManager::DestroyInstance();
if (created_task_manager) {
olive::TaskManager::DestroyInstance();
}
}
TEST(ProxyManager, WorkingProxyFilenamePrependsExtension)
+28 -9
View File
@@ -466,9 +466,6 @@ TEST(TextureDummy, AccessorsAndNoOpIo)
EXPECT_FALSE(texture.IsJob());
EXPECT_EQ(texture.job(), nullptr);
EXPECT_EQ(int(olive::Texture::kDefaultInterpolation),
int(olive::Texture::kMipmappedLinear));
char data[4] = {};
texture.Upload(data, 4);
texture.Download(data, 4);
@@ -683,14 +680,22 @@ TEST_F(RenderMiscAutoCacherTest,
}
// A conform-ready notification with no conform-blocked audio ranges must be a
// harmless no-op.
// harmless no-op: no cache jobs may be queued and no signals emitted.
TEST_F(RenderMiscAutoCacherTest, ConformReadyWithoutPendingConformsIsNoOp)
{
olive::PreviewAutoCacher cacher;
cacher.SetProject(project_.get());
QSignalSpy stop_spy(&cacher, &olive::PreviewAutoCacher::StopCacheProxyTasks);
QSignalSpy progress_spy(&cacher,
&olive::PreviewAutoCacher::SignalCacheProxyTaskProgress);
emit olive::ConformManager::instance()->ConformReady();
EXPECT_EQ(stop_spy.count(), 0);
EXPECT_EQ(progress_spy.count(), 0);
EXPECT_FALSE(cacher.IsRenderingCustomRange());
cacher.SetProject(nullptr);
}
@@ -700,24 +705,34 @@ TEST_F(RenderMiscAutoCacherTest, CacheProxyTaskCancelledClearsPendingJobs)
{
auto *viewer = new olive::ViewerOutput();
viewer->setParent(project_.get());
viewer->SetVideoParams(
olive::VideoParams(64, 64, olive::rational(1, 25),
olive::PixelFormat::U8,
olive::VideoParams::kRGBAChannelCount));
olive::PreviewAutoCacher cacher;
// No project is set, so the forced range sits in the pending queue.
// No project is set, so the forced range cannot be dispatched and sits in
// the pending queue
cacher.ForceCacheRange(
viewer, olive::TimeRange(olive::rational(0), olive::rational(1)));
EXPECT_TRUE(cacher.IsRenderingCustomRange());
EXPECT_TRUE(QMetaObject::invokeMethod(&cacher, "CacheProxyTaskCancelled",
Qt::DirectConnection));
// With the pending jobs cleared, the custom range is no longer being
// rendered
EXPECT_FALSE(cacher.IsRenderingCustomRange());
cacher.SetProject(nullptr);
}
// With an unknown/dummy graphics backend the RenderManager never creates the
// GPU-side objects. Those pointers must be null (previously they were left
// uninitialized, so callers such as ViewerWidget dereferenced garbage and
// crashed).
TEST(RenderManagerDummyBackend, GpuMembersAreNullRatherThanUninitialized)
// GPU-side objects. The auto-cacher pointer must be null (previously it was
// left uninitialized, so callers such as ViewerWidget dereferenced garbage
// and crashed).
TEST(RenderManagerDummyBackend, GpuCacherMemberIsNullRatherThanUninitialized)
{
const QVariant previous =
olive::Config::Current()[QStringLiteral("GraphicsBackend")];
@@ -726,6 +741,10 @@ TEST(RenderManagerDummyBackend, GpuMembersAreNullRatherThanUninitialized)
olive::RenderManager::CreateInstance();
EXPECT_EQ(olive::RenderManager::instance()->backend(),
olive::RenderManager::kDummy);
EXPECT_EQ(olive::RenderManager::instance()->requested_backend(),
olive::RenderManager::kDummy);
EXPECT_EQ(olive::RenderManager::instance()->GetCacher(), nullptr);
olive::RenderManager::DestroyInstance();
@@ -17,9 +17,7 @@
#include <QCoreApplication>
#include <QDir>
#include <QElapsedTimer>
#include <QFile>
#include <QFileInfo>
#include <QImage>
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
@@ -131,31 +129,6 @@ double SampleBrightnessF32(const void *data, int width, int height, int stride)
return samples > 0 ? avg / samples : 0.0;
}
void SaveFrameAsPng(const void *data, int width, int height,
const QString &path)
{
QImage img(width, height, QImage::Format_RGBA8888);
const auto *src = reinterpret_cast<const float *>(data);
for (int y = 0; y < height; ++y) {
uchar *dst = img.scanLine(y);
for (int x = 0; x < width; ++x) {
for (int c = 0; c < 4; ++c) {
float v = src[(y * width + x) * 4 + c];
if (v < 0.0f)
v = 0.0f;
if (v > 1.0f)
v = 1.0f;
dst[(x * 4) + c] = static_cast<uchar>(v * 255.0f);
}
}
}
if (!img.save(path)) {
std::cerr << "Failed to save " << path.toStdString() << std::endl;
} else {
std::cerr << "Saved " << path.toStdString() << std::endl;
}
}
} // namespace
class RenderWorkerFootageTest : public ::testing::Test {
@@ -490,14 +463,6 @@ TEST_F(RenderWorkerFootageTest, VulkanFootageIsNotBlack)
EXPECT_GT(brightness, 0.01)
<< "Worker output frame is black (brightness=" << brightness << ")";
SaveFrameAsPng(
output_data, output_width_, output_height_,
temp_dir_.filePath(QStringLiteral("worker_output_vulkan.png")));
QFile::remove(QStringLiteral("/tmp/worker_output_vulkan.png"));
QFile::copy(temp_dir_.filePath(QStringLiteral("worker_output_vulkan.png")),
QStringLiteral("/tmp/worker_output_vulkan.png"));
std::cerr << "Vulkan output copied to /tmp/worker_output_vulkan.png"
<< std::endl;
output_pool_->Release(consumed_slot);
}
@@ -526,13 +491,5 @@ TEST_F(RenderWorkerFootageTest, OpenGLFootageIsNotBlack)
EXPECT_GT(brightness, 0.01)
<< "Worker output frame is black (brightness=" << brightness << ")";
SaveFrameAsPng(
output_data, output_width_, output_height_,
temp_dir_.filePath(QStringLiteral("worker_output_opengl.png")));
QFile::remove(QStringLiteral("/tmp/worker_output_opengl.png"));
QFile::copy(temp_dir_.filePath(QStringLiteral("worker_output_opengl.png")),
QStringLiteral("/tmp/worker_output_opengl.png"));
std::cerr << "OpenGL output copied to /tmp/worker_output_opengl.png"
<< std::endl;
output_pool_->Release(consumed_slot);
}
+39 -1
View File
@@ -154,6 +154,25 @@ protected:
std::unique_ptr<olive::Project> project_;
};
namespace
{
// Exposes RenderTask's protected accessors so the test can inspect the
// private viewer the task builds.
class InspectablePreCacheTask : public olive::PreCacheTask {
public:
InspectablePreCacheTask(olive::Footage *footage, int index,
olive::Sequence *sequence)
: PreCacheTask(footage, index, sequence)
{
}
using olive::RenderTask::video_params;
using olive::RenderTask::viewer;
};
} // namespace
TEST_F(TaskPreCacheTest, ConstructorCopiesFootageIntoPrivateProject)
{
const QString path = QDir(QStringLiteral(OAK_TEST_SOURCE_DIR))
@@ -171,8 +190,27 @@ TEST_F(TaskPreCacheTest, ConstructorCopiesFootageIntoPrivateProject)
// Construction copies the footage into a private project and wires it to a
// private viewer; it must not touch the render pipeline. Run() itself is
// not exercised here since it requires live render workers.
olive::PreCacheTask task(footage, 0, sequence);
InspectablePreCacheTask task(footage, 0, sequence);
EXPECT_TRUE(task.GetTitle().contains(path));
EXPECT_TRUE(task.GetTitle().contains(QStringLiteral(":0")));
// The private viewer must mirror the sequence's parameters
olive::ViewerOutput *viewer = task.viewer();
ASSERT_NE(viewer, nullptr);
EXPECT_EQ(task.video_params(), sequence->GetVideoParams());
EXPECT_EQ(viewer->GetVideoParams(), sequence->GetVideoParams());
// The viewer's texture input must be fed by a private copy of the footage:
// same file, different node, living in the task's private project rather
// than the caller's
olive::Node *connected = viewer->GetConnectedTextureOutput();
ASSERT_NE(connected, nullptr);
EXPECT_NE(connected, footage);
auto *copied_footage = dynamic_cast<olive::Footage *>(connected);
ASSERT_NE(copied_footage, nullptr);
EXPECT_EQ(copied_footage->filename(), footage->filename());
EXPECT_NE(copied_footage->project(), project_.get());
EXPECT_EQ(copied_footage->project(), viewer->project());
}
+14 -3
View File
@@ -38,11 +38,22 @@ TEST(SeekableWidget, SetScrollAdjustsScrollBar)
widget.SetScroll(0);
EXPECT_EQ(widget.GetScroll(), 0);
int max_scroll = widget.horizontalScrollBar()->maximum();
if (max_scroll > 0) {
// Give the scene a deterministic length much wider than the viewport so
// the horizontal scrollbar gains a non-zero range (60 seconds at
// 100 px/second)
widget.SetTimebase(olive::rational(1, 30));
widget.SetScale(100.0);
widget.SetEndTime(olive::rational(60));
const int max_scroll = widget.horizontalScrollBar()->maximum();
ASSERT_GT(max_scroll, 0);
widget.SetScroll(max_scroll);
EXPECT_EQ(widget.GetScroll(), max_scroll);
}
// Values beyond the range clamp to the scrollbar's maximum
widget.SetScroll(max_scroll + 1000);
EXPECT_EQ(widget.GetScroll(), max_scroll);
}
TEST(SeekableWidget, SetMarkersAndWorkAreaAreReflected)
+45 -16
View File
@@ -51,21 +51,50 @@ TEST(TimelineCoordinate, CopyAndAssignment)
TEST(TimelineCoordinate, Equality)
{
olive::TimelineCoordinate a(olive::core::rational(5, 1),
olive::Track::Reference(olive::Track::kVideo,
1));
olive::TimelineCoordinate b(olive::core::rational(5, 1),
olive::Track::Reference(olive::Track::kVideo,
1));
olive::TimelineCoordinate c(olive::core::rational(6, 1),
olive::Track::Reference(olive::Track::kVideo,
1));
olive::TimelineCoordinate d(olive::core::rational(5, 1),
olive::Track::Reference(olive::Track::kAudio,
1));
// TimelineCoordinate provides no operator== of its own; equality is
// observable through the real operators of its components (rational and
// Track::Reference)
const olive::TimelineCoordinate a(
olive::core::rational(5, 1),
olive::Track::Reference(olive::Track::kVideo, 1));
EXPECT_EQ(a.GetFrame(), b.GetFrame());
EXPECT_EQ(a.GetTrack(), b.GetTrack());
EXPECT_NE(a.GetFrame(), c.GetFrame());
EXPECT_NE(a.GetTrack(), d.GetTrack());
// Distinct objects with identical frame and track compare equal in both
// components
const olive::TimelineCoordinate b(
olive::core::rational(5, 1),
olive::Track::Reference(olive::Track::kVideo, 1));
EXPECT_TRUE(a.GetFrame() == b.GetFrame());
EXPECT_TRUE(a.GetTrack() == b.GetTrack());
EXPECT_FALSE(a.GetFrame() != b.GetFrame());
EXPECT_FALSE(a.GetTrack() != b.GetTrack());
// A different frame breaks frame equality while the track stays equal
const olive::TimelineCoordinate c(
olive::core::rational(6, 1),
olive::Track::Reference(olive::Track::kVideo, 1));
EXPECT_FALSE(a.GetFrame() == c.GetFrame());
EXPECT_TRUE(a.GetFrame() != c.GetFrame());
EXPECT_TRUE(a.GetTrack() == c.GetTrack());
// A different track type or index breaks track equality while the frame
// stays equal
const olive::TimelineCoordinate d(
olive::core::rational(5, 1),
olive::Track::Reference(olive::Track::kAudio, 1));
const olive::TimelineCoordinate e(
olive::core::rational(5, 1),
olive::Track::Reference(olive::Track::kVideo, 2));
EXPECT_TRUE(a.GetTrack() != d.GetTrack());
EXPECT_FALSE(a.GetTrack() == d.GetTrack());
EXPECT_TRUE(a.GetTrack() != e.GetTrack());
EXPECT_TRUE(a.GetFrame() == d.GetFrame());
EXPECT_TRUE(a.GetFrame() == e.GetFrame());
// Mutating a copy breaks equality with the original
olive::TimelineCoordinate mutated = a;
mutated.SetFrame(olive::core::rational(7, 1));
EXPECT_TRUE(mutated.GetFrame() != a.GetFrame());
mutated.SetFrame(olive::core::rational(5, 1));
mutated.SetTrack(olive::Track::Reference(olive::Track::kSubtitle, 0));
EXPECT_TRUE(mutated.GetTrack() != a.GetTrack());
}
+39 -1
View File
@@ -783,11 +783,49 @@ TEST_F(TimelineUndoGeneralTest, AddDefaultTransitionAddsDualTransition)
TEST_F(TimelineUndoGeneralTest, AddDefaultTransitionEmptyClipListIsHarmless)
{
// A real timeline with two adjacent clips; the empty command must leave
// every observable detail of it untouched
olive::Sequence *sequence = CreateSequence(project_.get());
olive::TrackList *list = sequence->track_list(olive::Track::kVideo);
olive::Track *track = CreateTrack(project_.get());
olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(4));
olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(4));
track->AppendBlock(a);
track->AppendBlock(b);
AppendTrackToList(list, track);
// Layout: a [0,4], b [4,8]
olive::TimelineAddDefaultTransitionCommand cmd(
{}, olive::core::rational(1, 30));
EXPECT_EQ(cmd.GetRelevantProject(), nullptr);
// redo/undo on an empty command must be harmless no-ops
// redo on an empty command adds no transitions and changes nothing
cmd.redo_now();
ASSERT_EQ(track->Blocks().size(), 2);
EXPECT_EQ(track->Blocks().at(0), a);
EXPECT_EQ(track->Blocks().at(1), b);
EXPECT_EQ(a->length(), olive::core::rational(4));
EXPECT_EQ(a->media_in(), olive::core::rational(0));
EXPECT_EQ(a->in(), olive::core::rational(0));
EXPECT_EQ(a->out(), olive::core::rational(4));
EXPECT_EQ(b->length(), olive::core::rational(4));
EXPECT_EQ(b->media_in(), olive::core::rational(0));
EXPECT_EQ(b->in(), olive::core::rational(4));
EXPECT_EQ(b->out(), olive::core::rational(8));
EXPECT_EQ(track->track_length(), olive::core::rational(8));
// undo is equally a no-op
cmd.undo_now();
ASSERT_EQ(track->Blocks().size(), 2);
EXPECT_EQ(track->Blocks().at(0), a);
EXPECT_EQ(track->Blocks().at(1), b);
EXPECT_EQ(a->length(), olive::core::rational(4));
EXPECT_EQ(a->media_in(), olive::core::rational(0));
EXPECT_EQ(a->in(), olive::core::rational(0));
EXPECT_EQ(a->out(), olive::core::rational(4));
EXPECT_EQ(b->length(), olive::core::rational(4));
EXPECT_EQ(b->media_in(), olive::core::rational(0));
EXPECT_EQ(b->in(), olive::core::rational(4));
EXPECT_EQ(b->out(), olive::core::rational(8));
EXPECT_EQ(track->track_length(), olive::core::rational(8));
}
+11 -11
View File
@@ -78,21 +78,21 @@ TEST(TimelineWaveformSync, ExtractEnvelopeUsesOnlyValidatedRanges)
// 3 seconds at 20 windows per second == 60 windows.
EXPECT_EQ(envelope.size(), 60);
// Window before the validated region should be silent.
EXPECT_DOUBLE_EQ(envelope.at(0), 0.0);
// Windows before the validated region are silent placeholders
for (int i = 0; i < 20; ++i) {
EXPECT_DOUBLE_EQ(envelope.at(i), 0.0);
}
// Windows inside the validated region should have a non-zero peak.
bool found_nonzero = false;
// The validated second was filled with a constant 1.0 signal, so every
// window inside it must peak at exactly 1.0
for (int i = 20; i < 40; ++i) {
if (envelope.at(i) > 0.0) {
found_nonzero = true;
break;
EXPECT_DOUBLE_EQ(envelope.at(i), 1.0);
}
}
EXPECT_TRUE(found_nonzero);
// Window after the validated region should also be silent.
EXPECT_DOUBLE_EQ(envelope.at(59), 0.0);
// Windows after the validated region are silent placeholders too
for (int i = 40; i < 60; ++i) {
EXPECT_DOUBLE_EQ(envelope.at(i), 0.0);
}
}
TEST(TimelineWaveformSync, ExtractEnvelopeReportsValidityMask)
+141 -40
View File
@@ -11,15 +11,22 @@
#include <gtest/gtest.h>
#include <QCoreApplication>
#include <QThread>
#include <QElapsedTimer>
#include <QSignalSpy>
// Viewer headers
#include "widget/viewer/viewerplaybacktimer.h"
#include "widget/viewer/viewerqueue.h"
#include "widget/viewer/viewersafemargininfo.h"
#include "render/previewautocacher.h"
#include "codec/conformmanager.h"
#include "node/output/viewer/viewer.h"
#include "node/project.h"
#include "render/audioplaybackcache.h"
#include "render/diskmanager.h"
#include "render/previewautocacher.h"
#include "render/rendermanager.h"
#include "olive/core/util/rational.h"
using namespace olive;
@@ -39,8 +46,10 @@ namespace test
TEST(ViewerSmokeTimer, DefaultConstruction)
{
ViewerPlaybackTimer timer;
// Timer should be in a valid but not-started state
// After Start() is called, it should return valid timestamps
// After Start() is called, the timer must return valid timestamps
timer.Start(0, 1, 1.0 / 24.0);
EXPECT_GE(timer.GetTimestampNow(), 0);
}
TEST(ViewerSmokeTimer, BasicTiming)
@@ -58,9 +67,9 @@ TEST(ViewerSmokeTimer, BasicTiming)
QThread::msleep(50); // 50ms
int64_t ts2 = timer.GetTimestampNow();
// At 24fps, 50ms should be approximately 1 frame (or slightly more)
// Allow for some timing variance
EXPECT_GE(ts2, ts);
// At 24fps, 50ms is more than one frame period (~41.7ms), so the
// timestamp must have advanced by at least one frame
EXPECT_GT(ts2, ts);
}
TEST(ViewerSmokeTimer, PlaybackSpeedForward)
@@ -325,8 +334,10 @@ TEST(ViewerSmokeSafeMargin, CopyConstruction)
TEST(ViewerSmokeAudioCache, DefaultConstruction)
{
AudioPlaybackCache cache;
// Should construct without crashing
SUCCEED();
// A fresh cache has invalid (unset) audio parameters and no validated ranges
EXPECT_FALSE(cache.GetParameters().is_valid());
EXPECT_TRUE(cache.GetValidatedRanges().isEmpty());
}
TEST(ViewerSmokeAudioCache, ParameterSetters)
@@ -354,37 +365,120 @@ TEST(ViewerSmokeAudioCache, ValidateWithRange)
// Smoke Test: PreviewAutoCacher (basic lifecycle)
// ============================================================================
// NOTE: PreviewAutoCacher requires a QApplication and proper initialization.
// These tests are disabled in headless mode.
// PreviewAutoCacher runs headless with the dummy render backend; this fixture
// mirrors the one in preview_autocacher_test.cpp.
class ViewerSmokeAutoCacherTest : public ::testing::Test {
protected:
void SetUp() override
{
ColorManager::SetUpDefaultConfig();
TEST(ViewerSmokeAutoCacher, DISABLED_Construction)
// Use the dummy render backend so PreviewAutoCacher can be exercised
// without initializing OpenGL/Vulkan in the unit-test process.
OLIVE_CONFIG("GraphicsBackend") = QStringLiteral("dummy");
DiskManager::CreateInstance();
ConformManager::CreateInstance();
RenderManager::CreateInstance();
project_ = std::make_unique<Project>();
project_->Initialize();
}
void TearDown() override
{
project_.reset();
RenderManager::DestroyInstance();
ConformManager::DestroyInstance();
DiskManager::DestroyInstance();
}
ViewerOutput *CreateViewerWithValidParams()
{
auto *viewer = new ViewerOutput();
viewer->setParent(project_.get());
viewer->SetVideoParams(
VideoParams(64, 64, rational(1, 25), PixelFormat::U8,
VideoParams::kRGBAChannelCount));
return viewer;
}
std::unique_ptr<Project> project_;
};
TEST_F(ViewerSmokeAutoCacherTest, Construction)
{
// PreviewAutoCacher requires full GUI environment
SUCCEED();
PreviewAutoCacher cacher;
// A freshly constructed cacher has no project and no custom range running
EXPECT_FALSE(cacher.IsRenderingCustomRange());
}
TEST(ViewerSmokeAutoCacher, DISABLED_SetPlayhead)
TEST_F(ViewerSmokeAutoCacherTest, PauseControls)
{
// PreviewAutoCacher requires full GUI environment
SUCCEED();
ViewerOutput *viewer = CreateViewerWithValidParams();
PreviewAutoCacher cacher;
cacher.SetProject(project_.get());
// While renders are paused, a forced cache range must stay queued
cacher.SetRendersPaused(true);
cacher.ForceCacheRange(viewer, TimeRange(rational(0), rational(1, 25)));
EXPECT_TRUE(cacher.IsRenderingCustomRange());
// Unpausing must dispatch it; the dummy backend finishes each ticket
// without a result, which exhausts the range immediately
cacher.SetRendersPaused(false);
EXPECT_FALSE(cacher.IsRenderingCustomRange());
// Deliver the queued RenderTicketWatcher::Finished emissions so the
// completed watchers are reaped before teardown.
QCoreApplication::processEvents();
cacher.SetProject(nullptr);
}
TEST(ViewerSmokeAutoCacher, DISABLED_PauseControls)
TEST_F(ViewerSmokeAutoCacherTest, CacheRequestSchedulesRenderWhenNotIgnored)
{
// PreviewAutoCacher requires full GUI environment
SUCCEED();
ViewerOutput *viewer = CreateViewerWithValidParams();
PreviewAutoCacher cacher;
cacher.SetProject(project_.get());
QSignalSpy stop_spy(&cacher, &PreviewAutoCacher::StopCacheProxyTasks);
// A cache request on a connected node's cache must be picked up and
// rendered, emitting StopCacheProxyTasks when the range is exhausted
viewer->video_frame_cache()->Request(
viewer, TimeRange(rational(0), rational(1, 25)));
EXPECT_GE(stop_spy.count(), 1);
// Deliver the queued RenderTicketWatcher::Finished emissions so the
// completed watchers are reaped before teardown.
QCoreApplication::processEvents();
cacher.SetProject(nullptr);
}
TEST(ViewerSmokeAutoCacher, DISABLED_SetIgnoreCacheRequests)
TEST_F(ViewerSmokeAutoCacherTest, SetIgnoreCacheRequests)
{
// PreviewAutoCacher requires full GUI environment
SUCCEED();
}
ViewerOutput *viewer = CreateViewerWithValidParams();
TEST(ViewerSmokeAutoCacher, DISABLED_SetDisplayColorProcessor)
{
// PreviewAutoCacher requires full GUI environment
SUCCEED();
PreviewAutoCacher cacher;
// Must be set before SetProject(), which is when the cache connections
// would be made
cacher.SetIgnoreCacheRequests(true);
cacher.SetProject(project_.get());
QSignalSpy stop_spy(&cacher, &PreviewAutoCacher::StopCacheProxyTasks);
// With cache requests ignored, requesting a range must not queue any job
viewer->video_frame_cache()->Request(
viewer, TimeRange(rational(0), rational(1, 25)));
EXPECT_EQ(stop_spy.count(), 0);
EXPECT_FALSE(cacher.IsRenderingCustomRange());
cacher.SetProject(nullptr);
}
// ============================================================================
@@ -475,15 +569,19 @@ TEST(ViewerSmokeThread, ConcurrentTimerAccess)
timer.Start(0, 1, 1.0 / 30.0);
std::vector<std::thread> threads;
std::atomic<int> success_count{ 0 };
std::atomic<int> monotonic_violations{ 0 };
// Each thread reads the timer repeatedly; because playback is forward, a
// thread must never observe a timestamp smaller than the one it read before
for (int t = 0; t < num_threads; ++t) {
threads.emplace_back([&timer, &success_count, num_iterations]() {
threads.emplace_back([&timer, &monotonic_violations, num_iterations]() {
int64_t previous = 0;
for (int i = 0; i < num_iterations; ++i) {
int64_t ts = timer.GetTimestampNow();
if (ts >= 0) {
success_count++;
const int64_t ts = timer.GetTimestampNow();
if (ts < previous) {
monotonic_violations++;
}
previous = ts;
}
});
}
@@ -492,7 +590,9 @@ TEST(ViewerSmokeThread, ConcurrentTimerAccess)
t.join();
}
EXPECT_EQ(success_count.load(), num_threads * num_iterations);
EXPECT_EQ(monotonic_violations.load(), 0);
// The threads ran long enough that the timer must have advanced at all
EXPECT_GE(timer.GetTimestampNow(), 0);
}
TEST(ViewerSmokeThread, ConcurrentQueueAccess)
@@ -535,7 +635,7 @@ TEST(ViewerSmokeIntegration, PlaybackSequenceSimulation)
ViewerPlaybackTimer timer;
ViewerQueue queue;
// Start playback at frame 0, 24fps
// Start playback at frame 0, 24fps; timestamps are expressed in frames
timer.Start(0, 1, 1.0 / 24.0);
// Queue some frames
@@ -544,11 +644,11 @@ TEST(ViewerSmokeIntegration, PlaybackSequenceSimulation)
queue.AppendTimewise(frame, 1);
}
// Get current timestamp
int64_t current_ts = timer.GetTimestampNow();
// Get current timestamp (in frames) and convert it to a time in seconds
const int64_t current_ts = timer.GetTimestampNow();
const rational current_time(current_ts, 24);
// Find frame closest to current time
rational current_time(current_ts, 1);
// Find the first queued frame at or after the current playback time
bool found = false;
for (const auto &frame : queue) {
if (frame.timestamp >= current_time) {
@@ -557,8 +657,9 @@ TEST(ViewerSmokeIntegration, PlaybackSequenceSimulation)
}
}
// Should have frames available
EXPECT_FALSE(queue.empty());
// Playback just started at frame 0 and the queue holds frames 0-9, so a
// current-or-future frame must be available
EXPECT_TRUE(found);
}
TEST(ViewerSmokeIntegration, SafeMarginWithDifferentAspectRatios)
+36 -5
View File
@@ -1,5 +1,6 @@
#include <gtest/gtest.h>
#include <QFontMetrics>
#include <QLabel>
#include <QPushButton>
#include <QSignalSpy>
@@ -9,6 +10,7 @@
#include "node/output/viewer/viewer.h"
#include "olive/core/util/timecodefunctions.h"
#include "render/diskmanager.h"
#include "timeline/timelinemarker.h"
#include "widget/playbackcontrols/playbackcontrols.h"
#include "widget/slider/base/sliderbase.h"
#include "widget/slider/base/sliderlabel.h"
@@ -35,14 +37,33 @@ void EnsureAppSingletons()
TEST(TimeRuler, ConstructionWithAndWithoutDecorations)
{
EnsureAppSingletons();
// Text shown, cache status hidden
TimeRuler plain;
EXPECT_EQ(plain.GetMarkers(), nullptr);
EXPECT_EQ(plain.GetWorkArea(), nullptr);
// Text hidden, cache status shown
TimeRuler decorated(false, true);
// The height is fixed and derived from the enabled decorations: base
// text height plus marker height always, another text height when text
// is visible, and the cache indicator height when cache status is shown
const QFontMetrics fm = plain.fontMetrics();
const int marker_h = TimelineMarker::GetMarkerHeight(fm);
EXPECT_EQ(plain.minimumHeight(), 2 * fm.height() + marker_h);
EXPECT_EQ(plain.maximumHeight(), plain.minimumHeight());
EXPECT_EQ(decorated.minimumHeight(),
fm.height() + PlaybackCache::GetCacheIndicatorHeight() + marker_h);
EXPECT_EQ(decorated.maximumHeight(), decorated.minimumHeight());
// Centered text only affects painting, not geometry
decorated.SetCenteredText(true);
SUCCEED();
EXPECT_EQ(decorated.minimumHeight(),
fm.height() + PlaybackCache::GetCacheIndicatorHeight() + marker_h);
}
TEST(TimeRuler, TimebaseAndScaleDriveTimePixelConversion)
@@ -253,11 +274,21 @@ TEST_F(PlaybackControlsTest, SetEndTimeFormatsEndTimecodeLabel)
}
ASSERT_NE(end_label, nullptr);
// Pin the display mode so the expected strings don't depend on whatever
// the config happens to hold
const core::Timecode::Display saved_display =
Core::instance()->GetTimecodeDisplay();
Core::instance()->SetTimecodeDisplay(core::Timecode::kTimecodeNonDropFrame);
// 30 seconds at 30 fps is frame 900 = 30 seconds + 0 frames
controls.SetEndTime(rational(30));
const QString expected = QString::fromStdString(
core::Timecode::time_to_timecode(rational(30), rational(1, 30),
Core::instance()->GetTimecodeDisplay()));
EXPECT_EQ(end_label->text(), expected);
EXPECT_EQ(end_label->text(), QStringLiteral("00:00:30:00"));
// 1.5 seconds at 30 fps is frame 45 = 1 second + 15 frames
controls.SetEndTime(rational(3, 2));
EXPECT_EQ(end_label->text(), QStringLiteral("00:00:01:15"));
Core::instance()->SetTimecodeDisplay(saved_display);
}
TEST_F(PlaybackControlsTest, PlayPauseStackSwitchesVisibleButton)