diff --git a/tests/gtest/audio_manager_viewer_test.cpp b/tests/gtest/audio_manager_viewer_test.cpp index 0e040d0a1..13d73e749 100644 --- a/tests/gtest/audio_manager_viewer_test.cpp +++ b/tests/gtest/audio_manager_viewer_test.cpp @@ -16,6 +16,7 @@ #include #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); - 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())); + 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())); + } } -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) diff --git a/tests/gtest/audio_smoke_test.cpp b/tests/gtest/audio_smoke_test.cpp index 5a49e2436..2669fd465 100644 --- a/tests/gtest/audio_smoke_test.cpp +++ b/tests/gtest/audio_smoke_test.cpp @@ -12,6 +12,10 @@ #include +#include +#include +#include + #include #include #include @@ -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 left(kSamples, 0.5f); + std::vector 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 left(kSamples, 0.5f); + std::vector 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 left(kSamples, 0.5f); + std::vector 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 left(kSamples, 0.5f); + std::vector 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, + [¬ify_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, + [¬ify_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 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 threads; - std::atomic 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(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 diff --git a/tests/gtest/audio_waveform_sync_test.cpp b/tests/gtest/audio_waveform_sync_test.cpp index e5b12d519..e31800bf8 100644 --- a/tests/gtest/audio_waveform_sync_test.cpp +++ b/tests/gtest/audio_waveform_sync_test.cpp @@ -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) { - EXPECT_GE(masked.confidence, unmasked.confidence); - } + // 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) diff --git a/tests/gtest/color_lut_test.cpp b/tests/gtest/color_lut_test.cpp index 1a163a14c..452f705d4 100644 --- a/tests/gtest/color_lut_test.cpp +++ b/tests/gtest/color_lut_test.cpp @@ -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( - olive::ColorManager::GetDefaultConfig()->getProcessor( - transform)); - } catch (const std::exception &e) { - processor = nullptr; - } - }); - - EXPECT_EQ(processor, nullptr); + // 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)), + OCIO::Exception); } TEST(ColorProcessor, ConvertColorWithIdentityProcessor) diff --git a/tests/gtest/common_commandlineparser_test.cpp b/tests/gtest/common_commandlineparser_test.cpp index ba81d83cb..386c3e9a6 100644 --- a/tests/gtest/common_commandlineparser_test.cpp +++ b/tests/gtest/common_commandlineparser_test.cpp @@ -1,7 +1,23 @@ #include +#include + #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); } diff --git a/tests/gtest/common_debug_test.cpp b/tests/gtest/common_debug_test.cpp index 5d5107955..5f203af78 100644 --- a/tests/gtest/common_debug_test.cpp +++ b/tests/gtest/common_debug_test.cpp @@ -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")); } diff --git a/tests/gtest/common_filefunctions_test.cpp b/tests/gtest/common_filefunctions_test.cpp index 43b0aec89..653207de5 100644 --- a/tests/gtest/common_filefunctions_test.cpp +++ b/tests/gtest/common_filefunctions_test.cpp @@ -1,10 +1,25 @@ #include +#include #include #include #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()); } diff --git a/tests/gtest/common_html_test.cpp b/tests/gtest/common_html_test.cpp index 85eef292b..8c5f1ef9d 100644 --- a/tests/gtest/common_html_test.cpp +++ b/tests/gtest/common_html_test.cpp @@ -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); } diff --git a/tests/gtest/common_jobtime_test.cpp b/tests/gtest/common_jobtime_test.cpp index 955c6dfcc..5f5f050b1 100644 --- a/tests/gtest/common_jobtime_test.cpp +++ b/tests/gtest/common_jobtime_test.cpp @@ -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; - QDebug debug(QtDebugMsg); - debug << 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()))); } diff --git a/tests/gtest/common_qtutils_test.cpp b/tests/gtest/common_qtutils_test.cpp index 4a672026b..10ed04232 100644 --- a/tests/gtest/common_qtutils_test.cpp +++ b/tests/gtest/common_qtutils_test.cpp @@ -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)))); } diff --git a/tests/gtest/config_test.cpp b/tests/gtest/config_test.cpp index 3c0893442..78963d12a 100644 --- a/tests/gtest/config_test.cpp +++ b/tests/gtest/config_test.cpp @@ -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) diff --git a/tests/gtest/core_bezier_test.cpp b/tests/gtest/core_bezier_test.cpp index c43304755..7263574c7 100644 --- a/tests/gtest/core_bezier_test.cpp +++ b/tests/gtest/core_bezier_test.cpp @@ -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) diff --git a/tests/gtest/core_color_test.cpp b/tests/gtest/core_color_test.cpp index 6d7217642..94196ea95 100644 --- a/tests/gtest/core_color_test.cpp +++ b/tests/gtest/core_color_test.cpp @@ -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) diff --git a/tests/gtest/core_samplebuffer_test.cpp b/tests/gtest/core_samplebuffer_test.cpp index de67911bc..b979c4c27 100644 --- a/tests/gtest/core_samplebuffer_test.cpp +++ b/tests/gtest/core_samplebuffer_test.cpp @@ -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); } diff --git a/tests/gtest/core_timecode_test.cpp b/tests/gtest/core_timecode_test.cpp index 99d14ddbb..1d39f6e0d 100644 --- a/tests/gtest/core_timecode_test.cpp +++ b/tests/gtest/core_timecode_test.cpp @@ -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) diff --git a/tests/gtest/ffmpeg_decoder_hw_test.cpp b/tests/gtest/ffmpeg_decoder_hw_test.cpp index 37adf37dc..485179578 100644 --- a/tests/gtest/ffmpeg_decoder_hw_test.cpp +++ b/tests/gtest/ffmpeg_decoder_hw_test.cpp @@ -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(); diff --git a/tests/gtest/footage_test.cpp b/tests/gtest/footage_test.cpp index 29b555129..af508e19d 100644 --- a/tests/gtest/footage_test.cpp +++ b/tests/gtest/footage_test.cpp @@ -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()); + // 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().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()); + 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(); + 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(); + 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()); + 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().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()); + 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().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()); + 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().cacheKey(), + olive::icon::Subtitles.cacheKey()); } TEST_F(FootageTest, ProxyChangesMarkProjectModifiedAndEmitSignal) diff --git a/tests/gtest/module_smoke_test.cpp b/tests/gtest/module_smoke_test.cpp index 7b3e01fc0..8d8ec04b0 100644 --- a/tests/gtest/module_smoke_test.cpp +++ b/tests/gtest/module_smoke_test.cpp @@ -1,7 +1,6 @@ #include #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()); -} diff --git a/tests/gtest/node_inputimmediate_test.cpp b/tests/gtest/node_inputimmediate_test.cpp index 60973bbf6..39b024022 100644 --- a/tests/gtest/node_inputimmediate_test.cpp +++ b/tests/gtest/node_inputimmediate_test.cpp @@ -2,7 +2,6 @@ #include -#include #include #include #include @@ -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); } diff --git a/tests/gtest/node_project_test.cpp b/tests/gtest/node_project_test.cpp index bd467f5bc..9f3e42194 100644 --- a/tests/gtest/node_project_test.cpp +++ b/tests/gtest/node_project_test.cpp @@ -1,5 +1,9 @@ #include +#include +#include +#include + #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); } diff --git a/tests/gtest/node_view_test.cpp b/tests/gtest/node_view_test.cpp index b3d0eee29..be107f532 100644 --- a/tests/gtest/node_view_test.cpp +++ b/tests/gtest/node_view_test.cpp @@ -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); } diff --git a/tests/gtest/panel_test.cpp b/tests/gtest/panel_test.cpp index e9ab09d32..b21ad7dca 100644 --- a/tests/gtest/panel_test.cpp +++ b/tests/gtest/panel_test.cpp @@ -1,7 +1,9 @@ #include +#include #include #include +#include #include @@ -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(); + 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()->text().contains( + QStringLiteral("G: 1 (255)"))); + EXPECT_TRUE(samplers.at(1)->findChild()->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(&project); CurvePanel panel; - panel.SetNode(math); - panel.SetNode(nullptr); - panel.SetNodes({ math }); - panel.SetNodes({}); + auto *tree = panel.findChild(); + 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(); + 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 splitters = + panel.timeline_widget()->findChildren( + 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 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) diff --git a/tests/gtest/plugin_format_conversion_test.cpp b/tests/gtest/plugin_format_conversion_test.cpp index 073a65e94..5bc45bc52 100644 --- a/tests/gtest/plugin_format_conversion_test.cpp +++ b/tests/gtest/plugin_format_conversion_test.cpp @@ -6,7 +6,9 @@ #include #include #include +#include +#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(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(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 diff --git a/tests/gtest/plugin_smoke_test.cpp b/tests/gtest/plugin_smoke_test.cpp index 1ae28db2c..398c796b1 100644 --- a/tests/gtest/plugin_smoke_test.cpp +++ b/tests/gtest/plugin_smoke_test.cpp @@ -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 #include -#include #include #include @@ -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 success_count{ 0 }; - std::mutex access_mutex; - std::vector 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 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 diff --git a/tests/gtest/preferences_behavior_tab_test.cpp b/tests/gtest/preferences_behavior_tab_test.cpp index 066ae5cd2..362e2100c 100644 --- a/tests/gtest/preferences_behavior_tab_test.cpp +++ b/tests/gtest/preferences_behavior_tab_test.cpp @@ -3,6 +3,7 @@ #include #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().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) { - PreferencesBehaviorTab tab(PreferencesBehaviorTab::kCategoryRendering); - QList boxes = tab.findChildren(); - ASSERT_FALSE(boxes.isEmpty()); + // 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"); - QComboBox *backend_box = boxes.first(); - EXPECT_GT(backend_box->count(), 0); + { + PreferencesBehaviorTab tab(PreferencesBehaviorTab::kCategoryRendering); + QList boxes = tab.findChildren(); + ASSERT_FALSE(boxes.isEmpty()); + + QComboBox *backend_box = boxes.first(); + + // 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 boxes = tab.findChildren(); - 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()) { + 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(); diff --git a/tests/gtest/preview_autocacher_test.cpp b/tests/gtest/preview_autocacher_test.cpp index 58219f16c..8447be152 100644 --- a/tests/gtest/preview_autocacher_test.cpp +++ b/tests/gtest/preview_autocacher_test.cpp @@ -1,13 +1,16 @@ #include +#include #include #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_; }; @@ -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); -} diff --git a/tests/gtest/proxy_dialog_test.cpp b/tests/gtest/proxy_dialog_test.cpp index 264be7ec0..da6876bbe 100644 --- a/tests/gtest/proxy_dialog_test.cpp +++ b/tests/gtest/proxy_dialog_test.cpp @@ -1,5 +1,9 @@ #include +#include +#include +#include + #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 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(), nullptr); + + // Footage mode shows one tree row per item with its proxy state + auto *tree = dialog.findChild(); + 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()) { + 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()) { + 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) diff --git a/tests/gtest/proxy_manager_test.cpp b/tests/gtest/proxy_manager_test.cpp index a147c9bc1..cedd6806c 100644 --- a/tests/gtest/proxy_manager_test.cpp +++ b/tests/gtest/proxy_manager_test.cpp @@ -2,8 +2,11 @@ #include #include +#include #include +#include #include +#include #include #include @@ -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, - olive::ProxyManager::ProxyState state) { + &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) diff --git a/tests/gtest/render_misc_test.cpp b/tests/gtest/render_misc_test.cpp index aa9289199..4e8706fee 100644 --- a/tests/gtest/render_misc_test.cpp +++ b/tests/gtest/render_misc_test.cpp @@ -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(); diff --git a/tests/gtest/render_worker_footage_test.cpp b/tests/gtest/render_worker_footage_test.cpp index 4b2d0458b..9682d4230 100644 --- a/tests/gtest/render_worker_footage_test.cpp +++ b/tests/gtest/render_worker_footage_test.cpp @@ -17,9 +17,7 @@ #include #include #include -#include #include -#include #include #include #include @@ -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(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(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); } diff --git a/tests/gtest/task_cache_test.cpp b/tests/gtest/task_cache_test.cpp index 97164b5e5..a3c7f2a46 100644 --- a/tests/gtest/task_cache_test.cpp +++ b/tests/gtest/task_cache_test.cpp @@ -154,6 +154,25 @@ protected: std::unique_ptr 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(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()); } diff --git a/tests/gtest/timebased_widget_test.cpp b/tests/gtest/timebased_widget_test.cpp index f2a67671f..215e48997 100644 --- a/tests/gtest/timebased_widget_test.cpp +++ b/tests/gtest/timebased_widget_test.cpp @@ -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) { - widget.SetScroll(max_scroll); - EXPECT_EQ(widget.GetScroll(), max_scroll); - } + // 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) diff --git a/tests/gtest/timeline_coordinate_test.cpp b/tests/gtest/timeline_coordinate_test.cpp index 61db18803..fb67e2865 100644 --- a/tests/gtest/timeline_coordinate_test.cpp +++ b/tests/gtest/timeline_coordinate_test.cpp @@ -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()); } diff --git a/tests/gtest/timeline_undo_general_test.cpp b/tests/gtest/timeline_undo_general_test.cpp index 6e4c56d47..f4bc84e6f 100644 --- a/tests/gtest/timeline_undo_general_test.cpp +++ b/tests/gtest/timeline_undo_general_test.cpp @@ -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)); } diff --git a/tests/gtest/timeline_waveform_sync_test.cpp b/tests/gtest/timeline_waveform_sync_test.cpp index e73a3f5e0..b282b2e54 100644 --- a/tests/gtest/timeline_waveform_sync_test.cpp +++ b/tests/gtest/timeline_waveform_sync_test.cpp @@ -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 inside the validated region should have a non-zero peak. - bool found_nonzero = false; - for (int i = 20; i < 40; ++i) { - if (envelope.at(i) > 0.0) { - found_nonzero = true; - break; - } + // Windows before the validated region are silent placeholders + for (int i = 0; i < 20; ++i) { + EXPECT_DOUBLE_EQ(envelope.at(i), 0.0); } - EXPECT_TRUE(found_nonzero); - // Window after the validated region should also be silent. - EXPECT_DOUBLE_EQ(envelope.at(59), 0.0); + // 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) { + EXPECT_DOUBLE_EQ(envelope.at(i), 1.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) diff --git a/tests/gtest/viewer_smoke_test.cpp b/tests/gtest/viewer_smoke_test.cpp index aefc1b7d3..f2ed4553c 100644 --- a/tests/gtest/viewer_smoke_test.cpp +++ b/tests/gtest/viewer_smoke_test.cpp @@ -11,15 +11,22 @@ #include +#include #include #include +#include // 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_->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_; +}; + +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 threads; - std::atomic success_count{ 0 }; + std::atomic 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) diff --git a/tests/gtest/widget_timeruler_playback_test.cpp b/tests/gtest/widget_timeruler_playback_test.cpp index b32010442..5b154e096 100644 --- a/tests/gtest/widget_timeruler_playback_test.cpp +++ b/tests/gtest/widget_timeruler_playback_test.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -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)