From 807e02614b01541a3552d8d7faa6946bbed41349 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 17 Jul 2026 13:07:56 +0800 Subject: [PATCH] tests: coverage for common utils, cli, timeline undo, tasks, codec, ui - common: Html doc<->HTML conversion (17), OIIOUtils (6) - cli: CLIProgressDialog rendering and CLITaskDialog (8) - timeline: undo commands for pointer/ripple/split/track/workarea (43) and general commands (22) - task: project import/load, import error dialog, cache tasks (13) - codec: OIIO decoder/encoder, planar file device, FFmpeg encoder (15) - ui: HumanStrings (6), icons (4), StyleManager (4) --- tests/gtest/CMakeLists.txt | 13 + tests/gtest/cli_test.cpp | 158 ++ tests/gtest/codec_ffmpegencoder_test.cpp | 120 ++ tests/gtest/codec_oiio_test.cpp | 184 +++ tests/gtest/codec_planarfiledevice_test.cpp | 148 ++ tests/gtest/common_html_test.cpp | 250 ++++ tests/gtest/common_oiioutils_test.cpp | 124 ++ tests/gtest/task_cache_test.cpp | 178 +++ tests/gtest/task_project_test.cpp | 348 +++++ tests/gtest/timeline_undo_general_test.cpp | 793 ++++++++++ tests/gtest/timeline_undo_test.cpp | 1443 +++++++++++++++++++ tests/gtest/ui_humanstrings_test.cpp | 72 + tests/gtest/ui_icons_test.cpp | 116 ++ tests/gtest/ui_style_test.cpp | 78 + 14 files changed, 4025 insertions(+) create mode 100644 tests/gtest/cli_test.cpp create mode 100644 tests/gtest/codec_ffmpegencoder_test.cpp create mode 100644 tests/gtest/codec_oiio_test.cpp create mode 100644 tests/gtest/codec_planarfiledevice_test.cpp create mode 100644 tests/gtest/common_html_test.cpp create mode 100644 tests/gtest/common_oiioutils_test.cpp create mode 100644 tests/gtest/task_cache_test.cpp create mode 100644 tests/gtest/task_project_test.cpp create mode 100644 tests/gtest/timeline_undo_general_test.cpp create mode 100644 tests/gtest/timeline_undo_test.cpp create mode 100644 tests/gtest/ui_humanstrings_test.cpp create mode 100644 tests/gtest/ui_icons_test.cpp create mode 100644 tests/gtest/ui_style_test.cpp diff --git a/tests/gtest/CMakeLists.txt b/tests/gtest/CMakeLists.txt index 68596ec23..374a1829d 100644 --- a/tests/gtest/CMakeLists.txt +++ b/tests/gtest/CMakeLists.txt @@ -112,6 +112,19 @@ add_executable(olive-gtest timeline_coordinate_test.cpp timeline_waveform_sync_test.cpp timeline_workarea_test.cpp + common_html_test.cpp + common_oiioutils_test.cpp + ui_humanstrings_test.cpp + cli_test.cpp + timeline_undo_test.cpp + timeline_undo_general_test.cpp + task_project_test.cpp + task_cache_test.cpp + codec_oiio_test.cpp + codec_planarfiledevice_test.cpp + codec_ffmpegencoder_test.cpp + ui_icons_test.cpp + ui_style_test.cpp ) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Test) diff --git a/tests/gtest/cli_test.cpp b/tests/gtest/cli_test.cpp new file mode 100644 index 000000000..07165dae6 --- /dev/null +++ b/tests/gtest/cli_test.cpp @@ -0,0 +1,158 @@ +#include + +#include "cli/cliprogress/cliprogressdialog.h" +#include "cli/clitask/clitaskdialog.h" +#include "task/task.h" + +namespace +{ + +class DummyTask : public olive::Task { +public: + explicit DummyTask(bool succeed) + : succeed_(succeed) + { + SetTitle(QStringLiteral("Dummy")); + } + +protected: + virtual bool Run() override + { + emit ProgressChanged(0.5); + emit ProgressChanged(1.0); + return succeed_; + } + +private: + bool succeed_; +}; + +} // namespace + +TEST(CLIProgress, ConstructorPrintsTitleAndZeroPercent) +{ + testing::internal::CaptureStdout(); + { + olive::CLIProgressDialog dlg(QStringLiteral("Exporting")); + } + const QString out = + QString::fromStdString(testing::internal::GetCapturedStdout()); + + EXPECT_TRUE(out.contains(QStringLiteral("Exporting"))); + EXPECT_TRUE(out.contains(QStringLiteral("0%"))); +} + +TEST(CLIProgress, SameProgressValueDoesNotRedraw) +{ + olive::CLIProgressDialog dlg(QStringLiteral("Job")); + + testing::internal::CaptureStdout(); + dlg.SetProgress(0.0); + const QString out = + QString::fromStdString(testing::internal::GetCapturedStdout()); + + EXPECT_TRUE(out.isEmpty()); +} + +TEST(CLIProgress, ProgressRendersPercentage) +{ + olive::CLIProgressDialog dlg(QStringLiteral("Job")); + + testing::internal::CaptureStdout(); + dlg.SetProgress(0.25); + const QString out = + QString::fromStdString(testing::internal::GetCapturedStdout()); + + EXPECT_TRUE(out.contains(QStringLiteral("25%"))); +} + +TEST(CLIProgress, LongTitleIsTruncatedWithEllipsis) +{ + const QString long_title(80, QLatin1Char('a')); + + testing::internal::CaptureStdout(); + { + olive::CLIProgressDialog dlg(long_title); + } + const QString out = + QString::fromStdString(testing::internal::GetCapturedStdout()); + + EXPECT_TRUE(out.contains(QStringLiteral("..."))); + EXPECT_FALSE(out.contains(long_title)); +} + +TEST(CLIProgress, BarFillMatchesProgress) +{ + olive::CLIProgressDialog dlg(QStringLiteral("Job")); + + testing::internal::CaptureStdout(); + dlg.SetProgress(1.0); + const QString out = + QString::fromStdString(testing::internal::GetCapturedStdout()); + + // 80-column layout: bar area is 80/2-7 = 33 columns, all filled at 100%. + EXPECT_EQ(out.count(QLatin1Char('=')), 33); +} + +TEST(CLIProgress, PercentageIsPaddedToThreeColumns) +{ + olive::CLIProgressDialog dlg(QStringLiteral("Job")); + + auto pct_field = [&](double p) { + testing::internal::CaptureStdout(); + dlg.SetProgress(p); + const QString out = + QString::fromStdString(testing::internal::GetCapturedStdout()); + const int bracket = out.indexOf(QLatin1Char(']')); + const int percent = out.indexOf(QLatin1Char('%')); + EXPECT_GE(bracket, 0); + EXPECT_GT(percent, bracket); + return out.mid(bracket + 1, percent - bracket - 1); + }; + + // Single, double and triple digit percentages should all occupy the same + // field width so the bar stays aligned while progressing. + EXPECT_EQ(pct_field(0.05), QStringLiteral(" 5")); + EXPECT_EQ(pct_field(0.5), QStringLiteral(" 50")); + EXPECT_EQ(pct_field(1.0), QStringLiteral(" 100")); +} + +TEST(CLITask, RunReturnsTaskResult) +{ + { + DummyTask task(true); + olive::CLITaskDialog dlg(&task); + + testing::internal::CaptureStdout(); + const bool ok = dlg.Run(); + testing::internal::GetCapturedStdout(); + + EXPECT_TRUE(ok); + } + + { + DummyTask task(false); + olive::CLITaskDialog dlg(&task); + + testing::internal::CaptureStdout(); + const bool ok = dlg.Run(); + testing::internal::GetCapturedStdout(); + + EXPECT_FALSE(ok); + } +} + +TEST(CLITask, TaskProgressIsForwardedToDisplay) +{ + DummyTask task(true); + olive::CLITaskDialog dlg(&task); + + testing::internal::CaptureStdout(); + dlg.Run(); + const QString out = + QString::fromStdString(testing::internal::GetCapturedStdout()); + + EXPECT_TRUE(out.contains(QStringLiteral("Dummy"))); + EXPECT_TRUE(out.contains(QStringLiteral("50%"))); + EXPECT_TRUE(out.contains(QStringLiteral("100%"))); +} diff --git a/tests/gtest/codec_ffmpegencoder_test.cpp b/tests/gtest/codec_ffmpegencoder_test.cpp new file mode 100644 index 000000000..36baec5fb --- /dev/null +++ b/tests/gtest/codec_ffmpegencoder_test.cpp @@ -0,0 +1,120 @@ +#include + +#include + +#include +#include + +#include + +#include "codec/ffmpeg/ffmpegencoder.h" + +TEST(CodecFFmpegEncoder, PixelFormatsForCodec) +{ + olive::EncodingParams params; + olive::FFmpegEncoder encoder(params); + + const QStringList png_fmts = + encoder.GetPixelFormatsForCodec(olive::ExportCodec::kCodecPNG); + EXPECT_FALSE(png_fmts.isEmpty()); + EXPECT_TRUE(png_fmts.contains(QStringLiteral("rgba"))); + + EXPECT_TRUE( + encoder.GetPixelFormatsForCodec(olive::ExportCodec::kCodecCount) + .isEmpty()); +} + +TEST(CodecFFmpegEncoder, SampleFormatsForCodec) +{ + olive::EncodingParams params; + olive::FFmpegEncoder encoder(params); + + // PCM is handled with a custom list whose first element is the default + const std::vector pcm = + encoder.GetSampleFormatsForCodec(olive::ExportCodec::kCodecPCM); + ASSERT_FALSE(pcm.empty()); + EXPECT_EQ(pcm.front(), olive::core::SampleFormat::S16); + + EXPECT_FALSE(encoder.GetSampleFormatsForCodec(olive::ExportCodec::kCodecAAC) + .empty()); + EXPECT_TRUE(encoder.GetSampleFormatsForCodec(olive::ExportCodec::kCodecCount) + .empty()); +} + +TEST(CodecFFmpegEncoder, OpenWithInvalidPixelFormatFails) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + olive::EncodingParams params; + params.SetFilename(dir.filePath(QStringLiteral("invalid.mkv"))); + // A default-constructed VideoParams has PixelFormat::INVALID, for which no + // bridge pixel format exists + params.EnableVideo(olive::VideoParams(), olive::ExportCodec::kCodecPNG); + + olive::FFmpegEncoder encoder(params); + EXPECT_FALSE(encoder.Open()); + EXPECT_FALSE(encoder.GetError().isEmpty()); +} + +TEST(CodecFFmpegEncoder, EncodePngVideoAndProbeBack) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("encoder_test.mkv")); + + const int width = 64; + const int height = 64; + const int frame_count = 5; + + olive::EncodingParams params; + params.SetFilename(path); + olive::VideoParams video_params(width, height, olive::core::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + video_params.set_frame_rate(olive::core::rational(30, 1)); + params.EnableVideo(video_params, olive::ExportCodec::kCodecPNG); + params.set_video_pix_fmt(QStringLiteral("rgba")); + + olive::FFmpegEncoder encoder(params); + ASSERT_TRUE(encoder.Open()) << encoder.GetError().toStdString(); + EXPECT_EQ(encoder.GetDesiredPixelFormat(), olive::core::PixelFormat::U8); + + for (int f = 0; f < frame_count; f++) { + olive::FramePtr frame = olive::Frame::Create(); + frame->set_video_params(video_params); + ASSERT_TRUE(frame->allocate()); + for (int i = 0; i < width * height; i++) { + uint8_t *px = reinterpret_cast(frame->data()) + i * 4; + px[0] = uint8_t(f * 40); + px[1] = 128; + px[2] = 64; + px[3] = 255; + } + ASSERT_TRUE(encoder.WriteFrame(frame, olive::core::rational(f, 30))) + << encoder.GetError().toStdString(); + } + encoder.Close(); + + // The file must exist and probe back as a 64x64 video + ASSERT_TRUE(QFileInfo::exists(path)); + + FBProbe *probe = fb_probe_create(); + ASSERT_NE(probe, nullptr); + ASSERT_EQ(fb_probe_open(probe, path.toUtf8().constData()), 0); + + bool found_video = false; + const int stream_count = fb_probe_get_stream_count(probe); + for (int i = 0; i < stream_count; i++) { + FBStreamInfo info; + if (fb_probe_get_stream_info(probe, i, &info) == 0 && + info.codec_type == FB_MEDIA_TYPE_VIDEO) { + found_video = true; + EXPECT_EQ(info.width, width); + EXPECT_EQ(info.height, height); + } + } + EXPECT_TRUE(found_video); + + fb_probe_close(probe); + fb_probe_free(&probe); +} diff --git a/tests/gtest/codec_oiio_test.cpp b/tests/gtest/codec_oiio_test.cpp new file mode 100644 index 000000000..e51262621 --- /dev/null +++ b/tests/gtest/codec_oiio_test.cpp @@ -0,0 +1,184 @@ +#include + +#include +#include +#include + +#include "codec/decoder.h" +#include "codec/oiio/oiiodecoder.h" +#include "codec/oiio/oiioencoder.h" +#include "node/project/footage/footagedescription.h" + +namespace +{ +QString ImgPath() +{ + return QDir(QStringLiteral(OAK_TEST_SOURCE_DIR)) + .filePath(QStringLiteral("tests/img.png")); +} +} + +TEST(CodecOIIO, ProbePngReportsStillImage) +{ + olive::OIIODecoder decoder; + olive::FootageDescription desc = decoder.Probe(ImgPath(), nullptr); + + ASSERT_TRUE(desc.IsValid()); + EXPECT_EQ(desc.decoder(), QStringLiteral("oiio")); + EXPECT_EQ(desc.GetStreamCount(), 1); + + const QVector &streams = desc.GetVideoStreams(); + ASSERT_EQ(streams.size(), 1); + EXPECT_EQ(streams.first().width(), 1920); + EXPECT_EQ(streams.first().height(), 1080); + EXPECT_EQ(streams.first().format(), olive::core::PixelFormat::U8); + EXPECT_EQ(streams.first().channel_count(), 4); + EXPECT_EQ(streams.first().video_type(), olive::VideoParams::kVideoTypeStill); + EXPECT_EQ(streams.first().stream_index(), 0); + EXPECT_TRUE(streams.first().enabled()); +} + +TEST(CodecOIIO, ProbeUnsupportedExtensionReturnsInvalid) +{ + olive::OIIODecoder decoder; + olive::FootageDescription desc = + decoder.Probe(QStringLiteral("nonexistent.zzz"), nullptr); + + EXPECT_FALSE(desc.IsValid()); + EXPECT_TRUE(desc.GetVideoStreams().isEmpty()); +} + +TEST(CodecOIIO, DecodePngFrame) +{ + const QString path = ImgPath(); + ASSERT_TRUE(QFileInfo::exists(path)); + + olive::DecoderPtr decoder = + olive::Decoder::CreateFromID(QStringLiteral("oiio")); + ASSERT_TRUE(decoder); + + ASSERT_TRUE(decoder->Open(olive::Decoder::CodecStream(path, 0, nullptr))); + + olive::Decoder::RetrieveVideoParams params; + params.time = olive::core::rational(0); + params.divider = 1; + + olive::FramePtr frame = decoder->RetrieveVideoFrame(params); + ASSERT_TRUE(frame); + ASSERT_TRUE(frame->is_allocated()); + EXPECT_EQ(frame->width(), 1920); + EXPECT_EQ(frame->height(), 1080); + // Still images are always converted to F32 by OIIODecoder + EXPECT_EQ(frame->format(), olive::core::PixelFormat::F32); + EXPECT_EQ(frame->channel_count(), 4); + EXPECT_EQ(frame->timestamp(), olive::core::rational(0)); + EXPECT_GT(frame->allocated_size(), 0); + + bool has_nonzero_byte = false; + const char *data = frame->const_data(); + for (int i = 0; i < frame->allocated_size(); i++) { + if (data[i] != 0) { + has_nonzero_byte = true; + break; + } + } + EXPECT_TRUE(has_nonzero_byte); + + decoder->Close(); +} + +TEST(CodecOIIO, DecodeWithDividerHalvesResolution) +{ + olive::DecoderPtr decoder = + olive::Decoder::CreateFromID(QStringLiteral("oiio")); + ASSERT_TRUE(decoder); + + ASSERT_TRUE( + decoder->Open(olive::Decoder::CodecStream(ImgPath(), 0, nullptr))); + + olive::Decoder::RetrieveVideoParams params; + params.time = olive::core::rational(0); + params.divider = 2; + + olive::FramePtr frame = decoder->RetrieveVideoFrame(params); + ASSERT_TRUE(frame); + ASSERT_TRUE(frame->is_allocated()); + EXPECT_EQ(frame->width(), 960); + EXPECT_EQ(frame->height(), 540); + + decoder->Close(); +} + +TEST(CodecOIIO, EncodePngAndDecodeBack) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath(QStringLiteral("roundtrip.png")); + + const int width = 64; + const int height = 32; + + olive::EncodingParams params; + params.SetFilename(path); + params.EnableVideo( + olive::VideoParams(width, height, olive::core::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount), + olive::ExportCodec::kCodecPNG); + + olive::OIIOEncoder encoder(params); + ASSERT_TRUE(encoder.Open()); + + olive::FramePtr frame = olive::Frame::Create(); + frame->set_video_params(params.video_params()); + ASSERT_TRUE(frame->allocate()); + for (int y = 0; y < height; y++) { + uint8_t *row = reinterpret_cast(frame->data()) + + y * frame->linesize_bytes(); + for (int x = 0; x < width; x++) { + row[x * 4 + 0] = uint8_t(x * 2); + row[x * 4 + 1] = uint8_t(y * 4); + row[x * 4 + 2] = 200; + row[x * 4 + 3] = 255; + } + } + + EXPECT_TRUE(encoder.WriteFrame(frame, olive::core::rational(0))); + encoder.Close(); + + ASSERT_TRUE(QFileInfo::exists(path)); + + // Decode the file we just wrote and verify dimensions and pixels + olive::DecoderPtr decoder = + olive::Decoder::CreateFromID(QStringLiteral("oiio")); + ASSERT_TRUE(decoder); + ASSERT_TRUE(decoder->Open(olive::Decoder::CodecStream(path, 0, nullptr))); + + olive::Decoder::RetrieveVideoParams rp; + rp.time = olive::core::rational(0); + rp.divider = 1; + + olive::FramePtr back = decoder->RetrieveVideoFrame(rp); + ASSERT_TRUE(back); + EXPECT_EQ(back->width(), width); + EXPECT_EQ(back->height(), height); + + // U8 -> F32 conversion scales by 1/255 + const float eps = 1.0f / 255.0f; + olive::core::Color c = back->get_pixel(10, 5); + EXPECT_NEAR(c.red(), 20.0f / 255.0f, eps); + EXPECT_NEAR(c.green(), 20.0f / 255.0f, eps); + EXPECT_NEAR(c.blue(), 200.0f / 255.0f, eps); + EXPECT_NEAR(c.alpha(), 1.0f, eps); + + decoder->Close(); +} + +TEST(CodecOIIO, EncoderRejectsAudioAndSubtitles) +{ + olive::EncodingParams params; + olive::OIIOEncoder encoder(params); + + olive::SampleBuffer buffer; + EXPECT_FALSE(encoder.WriteAudio(buffer)); + EXPECT_FALSE(encoder.WriteSubtitle(nullptr)); +} diff --git a/tests/gtest/codec_planarfiledevice_test.cpp b/tests/gtest/codec_planarfiledevice_test.cpp new file mode 100644 index 000000000..19cc4cac0 --- /dev/null +++ b/tests/gtest/codec_planarfiledevice_test.cpp @@ -0,0 +1,148 @@ +#include + +#include +#include +#include + +#include "codec/planarfiledevice.h" + +TEST(CodecPlanarFileDevice, WriteThenReadRoundTrip) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + const QVector names = { + dir.filePath(QStringLiteral("ch0.bin")), + dir.filePath(QStringLiteral("ch1.bin")) + }; + + const qint64 bytes = 1024; + QVector src(2); + src[0].resize(int(bytes)); + src[1].resize(int(bytes)); + for (int i = 0; i < bytes; i++) { + src[0][i] = char(i & 0xFF); + src[1][i] = char(255 - (i & 0xFF)); + } + + olive::PlanarFileDevice dev; + EXPECT_FALSE(dev.isOpen()); + + const char *in[2] = { src[0].constData(), src[1].constData() }; + ASSERT_TRUE(dev.open(names, QIODevice::WriteOnly)); + EXPECT_TRUE(dev.isOpen()); + EXPECT_EQ(dev.write(in, bytes), bytes); + dev.close(); + EXPECT_FALSE(dev.isOpen()); + + // Each channel must have landed in its own file + for (int ch = 0; ch < 2; ch++) { + QFile f(names[ch]); + ASSERT_TRUE(f.open(QIODevice::ReadOnly)); + EXPECT_EQ(f.size(), bytes); + EXPECT_EQ(f.readAll(), src[ch]); + } + + // Read back through the device + QVector dst(2); + dst[0].resize(int(bytes)); + dst[1].resize(int(bytes)); + char *out[2] = { dst[0].data(), dst[1].data() }; + + ASSERT_TRUE(dev.open(names, QIODevice::ReadOnly)); + EXPECT_EQ(dev.size(), bytes); + EXPECT_EQ(dev.read(out, bytes), bytes); + dev.close(); + + EXPECT_EQ(dst[0], src[0]); + EXPECT_EQ(dst[1], src[1]); +} + +TEST(CodecPlanarFileDevice, OpenWhileOpenFails) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + const QVector names = { dir.filePath(QStringLiteral("ch.bin")) }; + + olive::PlanarFileDevice dev; + ASSERT_TRUE(dev.open(names, QIODevice::WriteOnly)); + EXPECT_FALSE(dev.open(names, QIODevice::WriteOnly)); + EXPECT_TRUE(dev.isOpen()); + dev.close(); + EXPECT_FALSE(dev.isOpen()); + + // After closing, the device can be opened again + ASSERT_TRUE(dev.open(names, QIODevice::ReadOnly)); + dev.close(); +} + +TEST(CodecPlanarFileDevice, OpenMissingFileForReadFails) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + const QVector names = { dir.filePath(QStringLiteral("missing.bin")) }; + + olive::PlanarFileDevice dev; + EXPECT_FALSE(dev.open(names, QIODevice::ReadOnly)); + EXPECT_FALSE(dev.isOpen()); + EXPECT_EQ(dev.size(), 0); +} + +TEST(CodecPlanarFileDevice, SeekAndReadWithOffset) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + const QVector names = { dir.filePath(QStringLiteral("ch.bin")) }; + + const qint64 bytes = 256; + QByteArray src; + src.resize(int(bytes)); + for (int i = 0; i < bytes; i++) { + src[i] = char(i); + } + + olive::PlanarFileDevice dev; + const char *in = src.constData(); + ASSERT_TRUE(dev.open(names, QIODevice::WriteOnly)); + EXPECT_EQ(dev.write(&in, bytes), bytes); + dev.close(); + + ASSERT_TRUE(dev.open(names, QIODevice::ReadOnly)); + + // seek() moves the file position used by subsequent reads + QByteArray slice; + slice.resize(4); + char *slice_data = slice.data(); + EXPECT_TRUE(dev.seek(100)); + EXPECT_EQ(dev.read(&slice_data, 4), 4); + EXPECT_EQ(slice, src.mid(100, 4)); + + // offset applies to the destination pointer, not the file position + QByteArray buf; + buf.resize(16); + buf.fill('\0'); + char *buf_data = buf.data(); + EXPECT_TRUE(dev.seek(200)); + EXPECT_EQ(dev.read(&buf_data, 4, 8), 4); + EXPECT_EQ(buf.mid(0, 8), QByteArray(8, '\0')); + EXPECT_EQ(buf.mid(8, 4), src.mid(200, 4)); + + dev.close(); +} + +TEST(CodecPlanarFileDevice, ClosedDeviceOperationsAreSafe) +{ + olive::PlanarFileDevice dev; + + char *read_buf = nullptr; + const char *write_buf = nullptr; + EXPECT_EQ(dev.read(&read_buf, 10), -1); + EXPECT_EQ(dev.write(&write_buf, 10), -1); + EXPECT_EQ(dev.size(), 0); + + // Closing a device that was never opened must not crash + dev.close(); +} diff --git a/tests/gtest/common_html_test.cpp b/tests/gtest/common_html_test.cpp new file mode 100644 index 000000000..85eef292b --- /dev/null +++ b/tests/gtest/common_html_test.cpp @@ -0,0 +1,250 @@ +#include + +#include +#include + +#include "common/html.h" + +namespace +{ + +QTextDocument *MakeDoc(const QString &plain_text) +{ + auto *doc = new QTextDocument(); + QTextCursor c(doc); + c.insertText(plain_text); + return doc; +} + +// Extract the single fragment of a single-block document for format checks. +QTextFragment OnlyFragment(QTextDocument *doc) +{ + QTextBlock block = doc->begin(); + auto it = block.begin(); + EXPECT_NE(it, block.end()); + return it.fragment(); +} + +} // namespace + +TEST(CommonHtml, DocToHtmlWrapsTextInParagraph) +{ + std::unique_ptr doc(MakeDoc(QStringLiteral("hello"))); + + const QString html = olive::Html::DocToHtml(doc.get()); + + EXPECT_TRUE(html.contains(QStringLiteral(""))); +} + +TEST(CommonHtml, DocToHtmlEscapesSpecialCharacters) +{ + std::unique_ptr doc(MakeDoc(QStringLiteral("a&\"c\""))); + + const QString html = olive::Html::DocToHtml(doc.get()); + + EXPECT_FALSE(html.contains(QStringLiteral("a"))); + EXPECT_TRUE(html.contains(QStringLiteral("<"))); + EXPECT_TRUE(html.contains(QStringLiteral("&"))); +} + +TEST(CommonHtml, AlignmentIsWrittenAsAttribute) +{ + QTextDocument doc; + QTextCursor c(&doc); + QTextBlockFormat fmt; + fmt.setAlignment(Qt::AlignRight); + c.setBlockFormat(fmt); + c.insertText(QStringLiteral("right")); + + const QString html = olive::Html::DocToHtml(&doc); + + EXPECT_TRUE(html.contains(QStringLiteral("align=\"right\""))); +} + +TEST(CommonHtml, CenterAlignmentIsWrittenAsAttribute) +{ + QTextDocument doc; + QTextCursor c(&doc); + QTextBlockFormat fmt; + fmt.setAlignment(Qt::AlignHCenter); + c.setBlockFormat(fmt); + c.insertText(QStringLiteral("center")); + + const QString html = olive::Html::DocToHtml(&doc); + + EXPECT_TRUE(html.contains(QStringLiteral("align=\"center\""))); +} + +TEST(CommonHtml, LeftAlignmentWritesNoAlignAttribute) +{ + QTextDocument doc; + QTextCursor c(&doc); + QTextBlockFormat fmt; + fmt.setAlignment(Qt::AlignLeft); + c.setBlockFormat(fmt); + c.insertText(QStringLiteral("left")); + + const QString html = olive::Html::DocToHtml(&doc); + + EXPECT_FALSE(html.contains(QStringLiteral("align="))); +} + +TEST(CommonHtml, CharFormatRoundTrip) +{ + QTextDocument doc; + QTextCursor c(&doc); + QTextCharFormat fmt; + fmt.setFontWeight(QFont::Bold); + fmt.setFontItalic(true); + fmt.setFontUnderline(true); + fmt.setFontStrikeOut(true); + fmt.setFontOverline(true); + fmt.setFontPointSize(24.0); + fmt.setForeground(QColor(255, 0, 0)); + c.insertText(QStringLiteral("styled"), fmt); + + const QString html = olive::Html::DocToHtml(&doc); + + QTextDocument parsed; + olive::Html::HtmlToDoc(&parsed, html); + + ASSERT_EQ(parsed.begin().text(), QStringLiteral("styled")); + const QTextFragment frag = OnlyFragment(&parsed); + const QTextCharFormat &out = frag.charFormat(); + EXPECT_EQ(out.fontWeight(), QFont::Bold); + EXPECT_TRUE(out.fontItalic()); + EXPECT_TRUE(out.fontUnderline()); + EXPECT_TRUE(out.fontStrikeOut()); + EXPECT_TRUE(out.fontOverline()); + EXPECT_DOUBLE_EQ(out.fontPointSize(), 24.0); + EXPECT_EQ(out.foreground().color(), QColor(255, 0, 0)); +} + +TEST(CommonHtml, ColorWithAlphaRoundTripsAsRgba) +{ + QTextDocument doc; + QTextCursor c(&doc); + QTextCharFormat fmt; + QColor semi(10, 20, 30, 128); + fmt.setForeground(semi); + c.insertText(QStringLiteral("x"), fmt); + + const QString html = olive::Html::DocToHtml(&doc); + EXPECT_TRUE(html.contains(QStringLiteral("rgba("))); + + QTextDocument parsed; + olive::Html::HtmlToDoc(&parsed, html); + + const QTextFragment frag = OnlyFragment(&parsed); + const QColor out = frag.charFormat().foreground().color(); + EXPECT_EQ(out.red(), semi.red()); + EXPECT_EQ(out.green(), semi.green()); + EXPECT_EQ(out.blue(), semi.blue()); + EXPECT_NEAR(out.alphaF(), semi.alphaF(), 0.01); +} + +TEST(CommonHtml, BlockAlignmentRoundTrips) +{ + QTextDocument doc; + QTextCursor c(&doc); + QTextBlockFormat bfmt; + bfmt.setAlignment(Qt::AlignHCenter); + c.setBlockFormat(bfmt); + c.insertText(QStringLiteral("centered")); + + QTextDocument parsed; + olive::Html::HtmlToDoc(&parsed, olive::Html::DocToHtml(&doc)); + + EXPECT_TRUE(parsed.begin().blockFormat().alignment() & Qt::AlignHCenter); +} + +TEST(CommonHtml, MultipleBlocksSurviveRoundTrip) +{ + QTextDocument doc; + QTextCursor c(&doc); + c.insertText(QStringLiteral("first")); + c.insertBlock(); + c.insertText(QStringLiteral("second")); + + QTextDocument parsed; + olive::Html::HtmlToDoc(&parsed, olive::Html::DocToHtml(&doc)); + + EXPECT_EQ(parsed.blockCount(), 2); + EXPECT_EQ(parsed.begin().text(), QStringLiteral("first")); + EXPECT_EQ(parsed.begin().next().text(), QStringLiteral("second")); +} + +TEST(CommonHtml, LineSeparatorBecomesBr) +{ + QTextDocument doc; + QTextCursor c(&doc); + c.insertText(QStringLiteral("line1")); + c.insertText(QString(QChar(QChar::LineSeparator)) + + QStringLiteral("line2")); + + const QString html = olive::Html::DocToHtml(&doc); + + EXPECT_TRUE(html.contains(QStringLiteral("
"))); +} + +TEST(CommonHtml, HtmlToDocReplacesExistingContent) +{ + QTextDocument doc; + QTextCursor c(&doc); + c.insertText(QStringLiteral("old content that should disappear")); + + olive::Html::HtmlToDoc(&doc, QStringLiteral("

new

")); + + EXPECT_EQ(doc.toPlainText().trimmed(), QStringLiteral("new")); +} + +TEST(CommonHtml, HtmlToDocHandlesInvalidMarkupWithoutCrash) +{ + QTextDocument doc; + + // Malformed markup must not crash; error is only logged. + olive::Html::HtmlToDoc(&doc, QStringLiteral("

unclosed")); + + SUCCEED(); +} + +TEST(CommonHtml, EmptyHtmlProducesEmptyDocument) +{ + QTextDocument doc; + olive::Html::HtmlToDoc(&doc, QString()); + + EXPECT_LE(doc.blockCount(), 1); + EXPECT_TRUE(doc.toPlainText().trimmed().isEmpty()); +} + +TEST(CommonHtml, LetterSpacingAndStretchRoundTrip) +{ + QTextDocument doc; + QTextCursor c(&doc); + QTextCharFormat fmt; + fmt.setFontLetterSpacing(150.0); + fmt.setFontStretch(125); + c.insertText(QStringLiteral("spaced"), fmt); + + QTextDocument parsed; + olive::Html::HtmlToDoc(&parsed, olive::Html::DocToHtml(&doc)); + + const QTextFragment frag = OnlyFragment(&parsed); + EXPECT_DOUBLE_EQ(frag.charFormat().fontLetterSpacing(), 150.0); + EXPECT_EQ(frag.charFormat().fontStretch(), 125); +} + +TEST(CommonHtml, NestedInlineTagsMergeFormats) +{ + QTextDocument doc; + olive::Html::HtmlToDoc( + &doc, QStringLiteral( + "

both

")); + + const QTextFragment frag = OnlyFragment(&doc); + EXPECT_TRUE(frag.charFormat().fontItalic()); + EXPECT_EQ(frag.charFormat().fontWeight(), 600 / 8); +} diff --git a/tests/gtest/common_oiioutils_test.cpp b/tests/gtest/common_oiioutils_test.cpp new file mode 100644 index 000000000..2c813fa8e --- /dev/null +++ b/tests/gtest/common_oiioutils_test.cpp @@ -0,0 +1,124 @@ +#include + +#include + +#include "codec/frame.h" +#include "common/oiioutils.h" + +TEST(CommonOIIOUtils, BaseTypeFromPixelFormat) +{ + using olive::core::PixelFormat; + + EXPECT_EQ(olive::OIIOUtils::GetOIIOBaseTypeFromFormat(PixelFormat::U8), + OIIO::TypeDesc::UINT8); + EXPECT_EQ(olive::OIIOUtils::GetOIIOBaseTypeFromFormat(PixelFormat::U16), + OIIO::TypeDesc::UINT16); + EXPECT_EQ(olive::OIIOUtils::GetOIIOBaseTypeFromFormat(PixelFormat::F16), + OIIO::TypeDesc::HALF); + EXPECT_EQ(olive::OIIOUtils::GetOIIOBaseTypeFromFormat(PixelFormat::F32), + OIIO::TypeDesc::FLOAT); + EXPECT_EQ(olive::OIIOUtils::GetOIIOBaseTypeFromFormat(PixelFormat::U10), + OIIO::TypeDesc::UNKNOWN); + EXPECT_EQ(olive::OIIOUtils::GetOIIOBaseTypeFromFormat(PixelFormat::INVALID), + OIIO::TypeDesc::UNKNOWN); +} + +TEST(CommonOIIOUtils, PixelFormatFromBaseType) +{ + using olive::core::PixelFormat; + + EXPECT_EQ(olive::OIIOUtils::GetFormatFromOIIOBasetype(OIIO::TypeDesc::UINT8), + PixelFormat::U8); + EXPECT_EQ( + olive::OIIOUtils::GetFormatFromOIIOBasetype(OIIO::TypeDesc::UINT16), + PixelFormat::U16); + EXPECT_EQ(olive::OIIOUtils::GetFormatFromOIIOBasetype(OIIO::TypeDesc::HALF), + PixelFormat::F16); + EXPECT_EQ(olive::OIIOUtils::GetFormatFromOIIOBasetype(OIIO::TypeDesc::FLOAT), + PixelFormat::F32); + EXPECT_EQ( + olive::OIIOUtils::GetFormatFromOIIOBasetype(OIIO::TypeDesc::UNKNOWN), + PixelFormat::INVALID); + EXPECT_EQ(olive::OIIOUtils::GetFormatFromOIIOBasetype(OIIO::TypeDesc::DOUBLE), + PixelFormat::INVALID); + EXPECT_EQ(olive::OIIOUtils::GetFormatFromOIIOBasetype(OIIO::TypeDesc::STRING), + PixelFormat::INVALID); +} + +TEST(CommonOIIOUtils, FormatRoundTripIsSymmetric) +{ + using olive::core::PixelFormat; + + for (PixelFormat fmt : + { PixelFormat::U8, PixelFormat::U16, PixelFormat::F16, + PixelFormat::F32 }) { + EXPECT_EQ(olive::OIIOUtils::GetFormatFromOIIOBasetype( + olive::OIIOUtils::GetOIIOBaseTypeFromFormat(fmt)), + fmt); + } +} + +TEST(CommonOIIOUtils, PixelAspectRatioDefaultsToOne) +{ + OIIO::ImageSpec spec(16, 16, 4, OIIO::TypeDesc::FLOAT); + + const olive::core::rational par = + olive::OIIOUtils::GetPixelAspectRatioFromOIIO(spec); + + EXPECT_EQ(par, olive::core::rational(1, 1)); +} + +TEST(CommonOIIOUtils, PixelAspectRatioIsReadFromAttribute) +{ + OIIO::ImageSpec spec(16, 16, 4, OIIO::TypeDesc::FLOAT); + spec.attribute("PixelAspectRatio", 2.0f); + + const olive::core::rational par = + olive::OIIOUtils::GetPixelAspectRatioFromOIIO(spec); + + EXPECT_EQ(par, olive::core::rational(2, 1)); +} + +TEST(CommonOIIOUtils, FrameBufferRoundTripPreservesPixels) +{ + using olive::core::PixelFormat; + + const olive::VideoParams params(8, 8, PixelFormat::F32, + olive::VideoParams::kRGBAChannelCount); + + auto frame = olive::Frame::Create(); + frame->set_video_params(params); + frame->allocate(); + + frame->set_pixel(0, 0, olive::Color(0.1f, 0.2f, 0.3f, 1.0f)); + frame->set_pixel(7, 7, olive::Color(0.9f, 0.8f, 0.7f, 0.6f)); + + OIIO::ImageSpec spec(params.effective_width(), params.effective_height(), + params.channel_count(), + olive::OIIOUtils::GetOIIOBaseTypeFromFormat( + params.format())); + OIIO::ImageBuf buf(spec); + ASSERT_TRUE(buf.initialized()); + + olive::OIIOUtils::FrameToBuffer(frame.get(), &buf); + + auto out = olive::Frame::Create(); + out->set_video_params(params); + out->allocate(); + // Poison the output so a failed transfer is visible. + std::memset(out->data(), 0xFF, out->allocated_size()); + + olive::OIIOUtils::BufferToFrame(&buf, out.get()); + + const olive::Color a = out->get_pixel(0, 0); + EXPECT_NEAR(a.red(), 0.1f, 1e-5f); + EXPECT_NEAR(a.green(), 0.2f, 1e-5f); + EXPECT_NEAR(a.blue(), 0.3f, 1e-5f); + EXPECT_NEAR(a.alpha(), 1.0f, 1e-5f); + + const olive::Color b = out->get_pixel(7, 7); + EXPECT_NEAR(b.red(), 0.9f, 1e-5f); + EXPECT_NEAR(b.green(), 0.8f, 1e-5f); + EXPECT_NEAR(b.blue(), 0.7f, 1e-5f); + EXPECT_NEAR(b.alpha(), 0.6f, 1e-5f); +} diff --git a/tests/gtest/task_cache_test.cpp b/tests/gtest/task_cache_test.cpp new file mode 100644 index 000000000..97164b5e5 --- /dev/null +++ b/tests/gtest/task_cache_test.cpp @@ -0,0 +1,178 @@ +#include + +#include +#include +#include +#include +#include + +#include "codec/conformmanager.h" +#include "config/config.h" +#include "core.h" +#include "node/color/colormanager/colormanager.h" +#include "node/project.h" +#include "node/project/footage/footage.h" +#include "node/project/sequence/sequence.h" +#include "render/diskmanager.h" +#include "render/rendermanager.h" +#include "task/customcache/customcachetask.h" +#include "task/precache/precachetask.h" + +TEST(TaskCustomCache, CancelBeforeRunReturnsImmediately) +{ + olive::CustomCacheTask task(QStringLiteral("Sequence")); + + bool cancelled_emitted = false; + QObject::connect(&task, &olive::CustomCacheTask::Cancelled, &task, + [&cancelled_emitted] { cancelled_emitted = true; }); + + task.Cancel(); + EXPECT_TRUE(cancelled_emitted); + + // Run() sees the cancel flag on entry and returns without ever blocking + EXPECT_TRUE(task.Start()); +} + +TEST(TaskCustomCache, RunBlocksUntilCancelled) +{ + olive::CustomCacheTask task(QStringLiteral("Sequence")); + + bool run_returned = false; + bool run_result = false; + QThread *thread = QThread::create([&] { + run_result = task.Start(); + run_returned = true; + }); + thread->start(); + + // The task waits on its condition variable until cancelled + EXPECT_FALSE(thread->wait(200)); + EXPECT_FALSE(run_returned); + + bool cancelled_emitted = false; + QObject::connect(&task, &olive::CustomCacheTask::Cancelled, &task, + [&cancelled_emitted] { cancelled_emitted = true; }); + + task.Cancel(); + + ASSERT_TRUE(thread->wait(5000)); + EXPECT_TRUE(run_returned); + EXPECT_TRUE(run_result); + EXPECT_TRUE(cancelled_emitted); + + delete thread; +} + +TEST(TaskCustomCache, FinishWakesRunWithoutEmittingCancelled) +{ + olive::CustomCacheTask task(QStringLiteral("Sequence")); + + QThread *thread = QThread::create([&] { task.Start(); }); + thread->start(); + + EXPECT_FALSE(thread->wait(200)); + + bool cancelled_emitted = false; + QObject::connect(&task, &olive::CustomCacheTask::Cancelled, &task, + [&cancelled_emitted] { cancelled_emitted = true; }); + + task.Finish(); + + ASSERT_TRUE(thread->wait(5000)); + EXPECT_FALSE(cancelled_emitted); + + delete thread; +} + +class TaskPreCacheTest : public ::testing::Test { +protected: + void SetUp() override + { + if (!olive::Core::instance()) { + // Leaked intentionally: Core is process-wide (matches footage_probe_test) + new olive::Core(olive::Core::CoreParams()); + } + + olive::ColorManager::SetUpDefaultConfig(); + + // Use the dummy render backend so no GPU is touched (matches + // preview_autocacher_test) + olive::Config::Current()[QStringLiteral("GraphicsBackend")] = + QStringLiteral("dummy"); + + created_disk_manager_ = (olive::DiskManager::instance() == nullptr); + if (created_disk_manager_) { + olive::DiskManager::CreateInstance(); + } + created_conform_manager_ = (olive::ConformManager::instance() == nullptr); + if (created_conform_manager_) { + olive::ConformManager::CreateInstance(); + } + created_render_manager_ = (olive::RenderManager::instance() == nullptr); + if (created_render_manager_) { + olive::RenderManager::CreateInstance(); + } + + // Sandbox the footage metadata cache so real probes write into the + // temp dir instead of the user's cache + old_cache_home_ = qgetenv("XDG_CACHE_HOME"); + had_cache_home_ = qEnvironmentVariableIsSet("XDG_CACHE_HOME"); + qputenv("XDG_CACHE_HOME", + QDir(temp_dir_.path()).filePath(QStringLiteral("xdg")).toUtf8()); + QDir().mkpath( + QStandardPaths::writableLocation(QStandardPaths::CacheLocation)); + + project_ = std::make_unique(); + project_->Initialize(); + } + + void TearDown() override + { + project_.reset(); + if (created_render_manager_) { + olive::RenderManager::DestroyInstance(); + } + if (created_conform_manager_) { + olive::ConformManager::DestroyInstance(); + } + if (created_disk_manager_) { + olive::DiskManager::DestroyInstance(); + } + if (had_cache_home_) { + qputenv("XDG_CACHE_HOME", old_cache_home_); + } else { + qunsetenv("XDG_CACHE_HOME"); + } + } + + QTemporaryDir temp_dir_; + QByteArray old_cache_home_; + bool had_cache_home_ = false; + bool created_disk_manager_ = false; + bool created_conform_manager_ = false; + bool created_render_manager_ = false; + std::unique_ptr project_; +}; + +TEST_F(TaskPreCacheTest, ConstructorCopiesFootageIntoPrivateProject) +{ + const QString path = QDir(QStringLiteral(OAK_TEST_SOURCE_DIR)) + .filePath(QStringLiteral("tests/img.png")); + ASSERT_TRUE(QFileInfo::exists(path)); + + auto *footage = new olive::Footage(path); + footage->setParent(project_.get()); + ASSERT_TRUE(footage->IsValid()); + + auto *sequence = new olive::Sequence(); + sequence->setParent(project_.get()); + sequence->set_default_parameters(); + + // 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); + + EXPECT_TRUE(task.GetTitle().contains(path)); + EXPECT_TRUE(task.GetTitle().contains(QStringLiteral(":0"))); +} diff --git a/tests/gtest/task_project_test.cpp b/tests/gtest/task_project_test.cpp new file mode 100644 index 000000000..d0f9cd830 --- /dev/null +++ b/tests/gtest/task_project_test.cpp @@ -0,0 +1,348 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core.h" +#include "node/color/colormanager/colormanager.h" +#include "node/factory.h" +#include "node/input/time/timeinput.h" +#include "node/project.h" +#include "node/project/folder/folder.h" +#include "node/project/footage/footage.h" +#include "node/project/serializer/serializer.h" +#include "render/diskmanager.h" +#include "task/project/import/import.h" +#include "task/project/import/importerrordialog.h" +#include "task/project/load/load.h" +#include "undo/undocommand.h" + +namespace +{ + +QString TestImagePath() +{ + return QDir(QStringLiteral(OAK_TEST_SOURCE_DIR)) + .filePath(QStringLiteral("tests/img.png")); +} + +} // namespace + +class TaskProjectImportTest : public ::testing::Test { +protected: + void SetUp() override + { + if (!olive::Core::instance()) { + // Leaked intentionally: Core is process-wide (matches footage_probe_test) + new olive::Core(olive::Core::CoreParams()); + } + + created_disk_manager_ = (olive::DiskManager::instance() == nullptr); + if (created_disk_manager_) { + olive::DiskManager::CreateInstance(); + } + + // Sandbox the footage metadata cache so real probes write into the + // temp dir instead of the user's cache + old_cache_home_ = qgetenv("XDG_CACHE_HOME"); + had_cache_home_ = qEnvironmentVariableIsSet("XDG_CACHE_HOME"); + qputenv("XDG_CACHE_HOME", + QDir(temp_dir_.path()).filePath(QStringLiteral("xdg")).toUtf8()); + QDir().mkpath( + QStandardPaths::writableLocation(QStandardPaths::CacheLocation)); + + olive::ColorManager::SetUpDefaultConfig(); + + project_ = std::make_unique(); + project_->Initialize(); + } + + void TearDown() override + { + project_.reset(); + if (created_disk_manager_) { + olive::DiskManager::DestroyInstance(); + } + if (had_cache_home_) { + qputenv("XDG_CACHE_HOME", old_cache_home_); + } else { + qunsetenv("XDG_CACHE_HOME"); + } + } + + QTemporaryDir temp_dir_; + QByteArray old_cache_home_; + bool had_cache_home_ = false; + bool created_disk_manager_ = false; + std::unique_ptr project_; +}; + +TEST_F(TaskProjectImportTest, ImportOfUnprobeableFileCollectsInvalidList) +{ + const QString path = + QDir(temp_dir_.path()).filePath(QStringLiteral("not_media.txt")); + { + QFile file(path); + ASSERT_TRUE(file.open(QFile::WriteOnly)); + file.write("this is not a media file"); + } + + olive::ProjectImportTask task(project_->root(), { path }); + EXPECT_EQ(task.GetFileCount(), 1); + + double last_progress = -1.0; + QObject::connect(&task, &olive::Task::ProgressChanged, &task, + [&last_progress](double p) { last_progress = p; }); + + ASSERT_TRUE(task.Start()); + + EXPECT_TRUE(task.HasInvalidFiles()); + ASSERT_EQ(task.GetInvalidFiles().size(), 1); + EXPECT_EQ(task.GetInvalidFiles().first(), path); + EXPECT_TRUE(task.GetImportedFootage().isEmpty()); + EXPECT_DOUBLE_EQ(last_progress, 1.0); + + // The undo command exists but contains no children since nothing was added + ASSERT_NE(task.GetCommand(), nullptr); + EXPECT_EQ(task.GetCommand()->child_count(), 0); + delete task.GetCommand(); +} + +TEST_F(TaskProjectImportTest, ImportOfImageFileAddsFootageThroughUndoCommand) +{ + const QString path = TestImagePath(); + ASSERT_TRUE(QFileInfo::exists(path)); + + olive::ProjectImportTask task(project_->root(), { path }); + EXPECT_EQ(task.GetFileCount(), 1); + + ASSERT_TRUE(task.Start()); + + EXPECT_FALSE(task.HasInvalidFiles()); + ASSERT_EQ(task.GetImportedFootage().size(), 1); + + olive::Footage *footage = task.GetImportedFootage().first(); + EXPECT_EQ(footage->filename(), path); + EXPECT_EQ(footage->GetLabel(), QStringLiteral("img.png")); + EXPECT_TRUE(footage->IsValid()); + + // Nothing is in the folder until the command is redone + EXPECT_TRUE(project_->root()->children().isEmpty()); + + ASSERT_NE(task.GetCommand(), nullptr); + task.GetCommand()->redo_now(); + + ASSERT_EQ(project_->root()->children().size(), 1); + EXPECT_EQ(project_->root()->children().first(), + static_cast(footage)); + EXPECT_TRUE(project_->nodes().contains(footage)); + + task.GetCommand()->undo_now(); + EXPECT_TRUE(project_->root()->children().isEmpty()); + + delete task.GetCommand(); +} + +TEST_F(TaskProjectImportTest, ImportOfDirectoryCreatesFolderHierarchy) +{ + const QString src = TestImagePath(); + ASSERT_TRUE(QFileInfo::exists(src)); + + const QString dir_path = + QDir(temp_dir_.path()).filePath(QStringLiteral("media")); + const QString sub_path = QDir(dir_path).filePath(QStringLiteral("sub")); + ASSERT_TRUE(QDir().mkpath(sub_path)); + + const QString first = QDir(dir_path).filePath(QStringLiteral("a.png")); + const QString second = QDir(sub_path).filePath(QStringLiteral("b.png")); + ASSERT_TRUE(QFile::copy(src, first)); + ASSERT_TRUE(QFile::copy(src, second)); + + olive::ProjectImportTask task(project_->root(), { dir_path }); + EXPECT_EQ(task.GetFileCount(), 2); + + ASSERT_TRUE(task.Start()); + EXPECT_FALSE(task.HasInvalidFiles()); + EXPECT_EQ(task.GetImportedFootage().size(), 2); + + ASSERT_NE(task.GetCommand(), nullptr); + task.GetCommand()->redo_now(); + + // Importing a directory creates a folder named after it under the target + const QVector &root_children = project_->root()->children(); + ASSERT_EQ(root_children.size(), 1); + auto *top_folder = dynamic_cast(root_children.first()); + ASSERT_NE(top_folder, nullptr); + EXPECT_EQ(top_folder->GetLabel(), QStringLiteral("media")); + + // ...which holds the first image plus a subfolder with the second image + QVector all_footage = + top_folder->ListChildrenOfType(); + EXPECT_EQ(all_footage.size(), 2); + + QVector sub_folders = + top_folder->ListChildrenOfType(); + ASSERT_EQ(sub_folders.size(), 1); + EXPECT_EQ(sub_folders.first()->GetLabel(), QStringLiteral("sub")); + + task.GetCommand()->undo_now(); + EXPECT_TRUE(project_->root()->children().isEmpty()); + + delete task.GetCommand(); +} + +TEST_F(TaskProjectImportTest, CancelledBeforeRunReturnsFalseAndDropsCommand) +{ + const QString path = TestImagePath(); + ASSERT_TRUE(QFileInfo::exists(path)); + + olive::ProjectImportTask task(project_->root(), { path }); + task.Cancel(); + + EXPECT_FALSE(task.Start()); + EXPECT_EQ(task.GetCommand(), nullptr); + EXPECT_TRUE(task.GetImportedFootage().isEmpty()); + EXPECT_FALSE(task.HasInvalidFiles()); + EXPECT_TRUE(project_->root()->children().isEmpty()); +} + +TEST(TaskProjectImportErrorDialog, PopulatesListWithFailedFilenames) +{ + const QStringList failed = { QStringLiteral("/tmp/first.xyz"), + QStringLiteral("/tmp/second.xyz"), + QStringLiteral("/tmp/third.xyz") }; + + olive::ProjectImportErrorDialog dialog(failed); + + EXPECT_EQ(dialog.windowTitle(), QStringLiteral("Import Error")); + + auto *list = dialog.findChild(); + ASSERT_NE(list, nullptr); + ASSERT_EQ(list->count(), failed.size()); + for (int i = 0; i < failed.size(); i++) { + EXPECT_EQ(list->item(i)->text(), failed.at(i)); + } + + auto *buttons = dialog.findChild(); + ASSERT_NE(buttons, nullptr); + EXPECT_NE(buttons->button(QDialogButtonBox::Ok), nullptr); +} + +TEST(TaskProjectImportErrorDialog, OkButtonAcceptsDialog) +{ + olive::ProjectImportErrorDialog dialog( + { QStringLiteral("/tmp/first.xyz") }); + + auto *buttons = dialog.findChild(); + ASSERT_NE(buttons, nullptr); + + QPushButton *ok = buttons->button(QDialogButtonBox::Ok); + ASSERT_NE(ok, nullptr); + ok->click(); + + EXPECT_EQ(dialog.result(), QDialog::Accepted); + EXPECT_TRUE(dialog.isHidden()); +} + +class TaskProjectLoadTest : public ::testing::Test { +protected: + void SetUp() override + { + created_disk_manager_ = (olive::DiskManager::instance() == nullptr); + if (created_disk_manager_) { + olive::DiskManager::CreateInstance(); + } + + olive::ColorManager::SetUpDefaultConfig(); + olive::NodeFactory::Initialize(); + olive::ProjectSerializer::Initialize(); + } + + void TearDown() override + { + olive::ProjectSerializer::Destroy(); + if (created_disk_manager_) { + olive::DiskManager::DestroyInstance(); + } + } + + QString SaveProjectToTempFile(olive::Project *project, + const QString &filename) + { + const QString path = QDir(temp_dir_.path()).filePath(filename); + olive::ProjectSerializer::SaveData data( + olive::ProjectSerializer::kProject, project, path); + olive::ProjectSerializer::Result result = + olive::ProjectSerializer::Save(data, false); + if (result.code() != olive::ProjectSerializer::kSuccess) { + return QString(); + } + return path; + } + + QTemporaryDir temp_dir_; + bool created_disk_manager_ = false; +}; + +TEST_F(TaskProjectLoadTest, LoadingValidProjectSucceeds) +{ + olive::Project project; + project.Initialize(); + + auto *node = new olive::TimeInput(); + node->SetLabel(QStringLiteral("TimeInput")); + node->setParent(&project); + + const QString path = + SaveProjectToTempFile(&project, QStringLiteral("project.ove")); + ASSERT_FALSE(path.isEmpty()); + ASSERT_TRUE(QFileInfo::exists(path)); + + olive::ProjectLoadTask task(path); + EXPECT_EQ(task.GetFilename(), path); + EXPECT_EQ(task.GetLoadedProject(), nullptr); + + ASSERT_TRUE(task.Start()) << task.GetError().toStdString(); + + olive::Project *loaded = task.GetLoadedProject(); + ASSERT_NE(loaded, nullptr); + EXPECT_EQ(loaded->filename(), path); + EXPECT_FALSE(loaded->nodes().isEmpty()); + + delete loaded; +} + +TEST_F(TaskProjectLoadTest, LoadingMissingFileFails) +{ + const QString path = + QDir(temp_dir_.path()).filePath(QStringLiteral("missing.ove")); + + olive::ProjectLoadTask task(path); + + EXPECT_FALSE(task.Start()); + EXPECT_FALSE(task.GetError().isEmpty()); + EXPECT_EQ(task.GetLoadedProject(), nullptr); +} + +TEST_F(TaskProjectLoadTest, LoadingCorruptFileFails) +{ + const QString path = + QDir(temp_dir_.path()).filePath(QStringLiteral("corrupt.ove")); + { + QFile file(path); + ASSERT_TRUE(file.open(QFile::WriteOnly)); + file.write("this is not a project file"); + } + + olive::ProjectLoadTask task(path); + + EXPECT_FALSE(task.Start()); + EXPECT_FALSE(task.GetError().isEmpty()); + EXPECT_EQ(task.GetLoadedProject(), nullptr); +} diff --git a/tests/gtest/timeline_undo_general_test.cpp b/tests/gtest/timeline_undo_general_test.cpp new file mode 100644 index 000000000..6e4c56d47 --- /dev/null +++ b/tests/gtest/timeline_undo_general_test.cpp @@ -0,0 +1,793 @@ +#include + +#include + +#include "node/block/clip/clip.h" +#include "node/block/gap/gap.h" +#include "node/block/transition/crossdissolve/crossdissolvetransition.h" +#include "node/color/colormanager/colormanager.h" +#include "node/factory.h" +#include "node/math/math/math.h" +#include "node/math/merge/merge.h" +#include "node/output/track/track.h" +#include "node/output/track/tracklist.h" +#include "node/output/viewer/viewer.h" +#include "node/project.h" +#include "node/project/sequence/sequence.h" +#include "timeline/timelineundogeneral.h" + +namespace +{ + +olive::Sequence *CreateSequence(olive::Project *project) +{ + auto *sequence = new olive::Sequence(); + sequence->setParent(project); + return sequence; +} + +olive::Track *CreateTrack(olive::Project *project) +{ + auto *track = new olive::Track(); + track->setParent(project); + return track; +} + +olive::ClipBlock *CreateClip(olive::Project *project, + const olive::core::rational &length) +{ + auto *clip = new olive::ClipBlock(); + clip->setParent(project); + clip->set_length_and_media_out(length); + return clip; +} + +olive::GapBlock *CreateGap(olive::Project *project, + const olive::core::rational &length) +{ + auto *gap = new olive::GapBlock(); + gap->setParent(project); + gap->set_length_and_media_out(length); + return gap; +} + +olive::CrossDissolveTransition *CreateTransition(olive::Project *project, + const olive::core::rational &length) +{ + auto *transition = new olive::CrossDissolveTransition(); + transition->setParent(project); + transition->set_length_and_media_out(length); + return transition; +} + +void AppendTrackToList(olive::TrackList *list, olive::Track *track) +{ + list->ArrayAppend(); + olive::Node::ConnectEdge(track, + list->track_input(list->ArraySize() - 1)); +} + +} // namespace + +class TimelineUndoGeneralTest : public ::testing::Test { +protected: + void SetUp() override + { + olive::ColorManager::SetUpDefaultConfig(); + + project_ = std::make_unique(); + project_->Initialize(); + } + + std::unique_ptr project_; +}; + +TEST_F(TimelineUndoGeneralTest, BlockResizeCommandChangesLength) +{ + olive::ClipBlock *clip = CreateClip(project_.get(), olive::core::rational(4)); + + olive::BlockResizeCommand cmd(clip, olive::core::rational(2)); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(clip->length(), olive::core::rational(2)); + // Resizing from the out point leaves the media in point alone + EXPECT_EQ(clip->media_in(), olive::core::rational(0)); + + cmd.undo_now(); + EXPECT_EQ(clip->length(), olive::core::rational(4)); + + // Resizing to zero length is allowed on a detached block + olive::BlockResizeCommand to_zero(clip, olive::core::rational(0)); + to_zero.redo_now(); + EXPECT_EQ(clip->length(), olive::core::rational(0)); + to_zero.undo_now(); + EXPECT_EQ(clip->length(), olive::core::rational(4)); +} + +TEST_F(TimelineUndoGeneralTest, BlockResizeWithMediaInCommandShiftsMediaIn) +{ + olive::ClipBlock *clip = CreateClip(project_.get(), olive::core::rational(4)); + ASSERT_EQ(clip->media_in(), olive::core::rational(0)); + + olive::BlockResizeWithMediaInCommand cmd(clip, olive::core::rational(2)); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(clip->length(), olive::core::rational(2)); + // Resizing from the in point pushes the media in point forward + EXPECT_EQ(clip->media_in(), olive::core::rational(2)); + + cmd.undo_now(); + EXPECT_EQ(clip->length(), olive::core::rational(4)); + EXPECT_EQ(clip->media_in(), olive::core::rational(0)); +} + +TEST_F(TimelineUndoGeneralTest, BlockSetMediaInCommandSetsAndRestores) +{ + olive::ClipBlock *clip = CreateClip(project_.get(), olive::core::rational(4)); + ASSERT_EQ(clip->media_in(), olive::core::rational(0)); + + olive::BlockSetMediaInCommand cmd(clip, olive::core::rational(3)); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(clip->media_in(), olive::core::rational(3)); + + cmd.undo_now(); + EXPECT_EQ(clip->media_in(), olive::core::rational(0)); +} + +TEST_F(TimelineUndoGeneralTest, BlockEnableDisableCommandToggles) +{ + olive::ClipBlock *clip = CreateClip(project_.get(), olive::core::rational(4)); + ASSERT_TRUE(clip->is_enabled()); + + olive::BlockEnableDisableCommand cmd(clip, false); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_FALSE(clip->is_enabled()); + + cmd.undo_now(); + EXPECT_TRUE(clip->is_enabled()); +} + +TEST_F(TimelineUndoGeneralTest, AddTrackCommandConnectsDirectly) +{ + olive::Sequence *sequence = CreateSequence(project_.get()); + olive::TrackList *list = sequence->track_list(olive::Track::kVideo); + + olive::TimelineAddTrackCommand cmd(list, false); + olive::Track *track = cmd.track(); + ASSERT_NE(track, nullptr); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(list->GetTrackCount(), 1); + EXPECT_EQ(list->GetTrackAt(0), track); + EXPECT_EQ(track->sequence(), sequence); + EXPECT_EQ(track->type(), olive::Track::kVideo); + EXPECT_EQ(track->Index(), 0); + EXPECT_EQ(track->project(), project_.get()); + // The first track connects straight to the sequence's texture input + EXPECT_TRUE(sequence->IsInputConnected(olive::ViewerOutput::kTextureInput)); + EXPECT_EQ(sequence->GetConnectedTextureOutput(), track); + + cmd.undo_now(); + EXPECT_EQ(list->GetTrackCount(), 0); + EXPECT_EQ(list->ArraySize(), 0); + EXPECT_EQ(track->sequence(), nullptr); + EXPECT_EQ(track->project(), nullptr); + EXPECT_FALSE(sequence->IsInputConnected(olive::ViewerOutput::kTextureInput)); +} + +TEST_F(TimelineUndoGeneralTest, AddTrackCommandInsertsVideoMergeNode) +{ + olive::Sequence *sequence = CreateSequence(project_.get()); + olive::TrackList *list = sequence->track_list(olive::Track::kVideo); + + // The first track takes the direct connection + olive::TimelineAddTrackCommand first(list, false); + first.redo_now(); + olive::Track *track1 = first.track(); + ASSERT_EQ(sequence->GetConnectedTextureOutput(), track1); + + // Adding another video track with automerge inserts a merge node + olive::TimelineAddTrackCommand second(list, true); + olive::Track *track2 = second.track(); + + second.redo_now(); + EXPECT_EQ(list->GetTrackCount(), 2); + + olive::Node *merge = sequence->GetConnectedTextureOutput(); + ASSERT_NE(merge, nullptr); + EXPECT_NE(merge, track1); + EXPECT_NE(merge, track2); + EXPECT_EQ(merge->project(), project_.get()); + EXPECT_EQ(olive::NodeInput(merge, olive::MergeNode::kBaseIn) + .GetConnectedOutput(), + track1); + EXPECT_EQ(olive::NodeInput(merge, olive::MergeNode::kBlendIn) + .GetConnectedOutput(), + track2); + + second.undo_now(); + // The direct connection from the first track is restored + EXPECT_EQ(sequence->GetConnectedTextureOutput(), track1); + EXPECT_EQ(merge->project(), nullptr); + EXPECT_EQ(track2->project(), nullptr); + EXPECT_EQ(list->GetTrackCount(), 1); +} + +TEST_F(TimelineUndoGeneralTest, AddTrackCommandMergesAudioWithMathNode) +{ + olive::Sequence *sequence = CreateSequence(project_.get()); + olive::TrackList *list = sequence->track_list(olive::Track::kAudio); + + olive::TimelineAddTrackCommand first(list, false); + first.redo_now(); + olive::Track *track1 = first.track(); + ASSERT_EQ(sequence->GetConnectedSampleOutput(), track1); + + olive::TimelineAddTrackCommand second(list, true); + olive::Track *track2 = second.track(); + + second.redo_now(); + EXPECT_EQ(list->GetTrackCount(), 2); + + // Audio tracks are summed with a math (add) node + olive::Node *math = sequence->GetConnectedSampleOutput(); + ASSERT_NE(math, nullptr); + EXPECT_NE(math, track1); + EXPECT_NE(math, track2); + EXPECT_EQ(math->id(), QStringLiteral("org.olivevideoeditor.Olive.math")); + EXPECT_EQ(olive::NodeInput(math, olive::MathNode::kParamAIn) + .GetConnectedOutput(), + track1); + EXPECT_EQ(olive::NodeInput(math, olive::MathNode::kParamBIn) + .GetConnectedOutput(), + track2); + + second.undo_now(); + EXPECT_EQ(sequence->GetConnectedSampleOutput(), track1); + EXPECT_EQ(math->project(), nullptr); + EXPECT_EQ(list->GetTrackCount(), 1); +} + +TEST_F(TimelineUndoGeneralTest, RemoveTrackCommandRemovesAndRestores) +{ + olive::Sequence *sequence = CreateSequence(project_.get()); + olive::TrackList *list = sequence->track_list(olive::Track::kVideo); + + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *clip = CreateClip(project_.get(), olive::core::rational(4)); + track->AppendBlock(clip); + AppendTrackToList(list, track); + ASSERT_EQ(track->Index(), 0); + + olive::TimelineRemoveTrackCommand cmd(track); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(track->project(), nullptr); + EXPECT_EQ(track->sequence(), nullptr); + EXPECT_EQ(list->GetTrackCount(), 0); + EXPECT_EQ(list->ArraySize(), 0); + // The clip was an exclusive dependency of the track and left the graph too + EXPECT_EQ(clip->project(), nullptr); + + cmd.undo_now(); + EXPECT_EQ(track->project(), project_.get()); + EXPECT_EQ(clip->project(), project_.get()); + EXPECT_EQ(list->GetTrackCount(), 1); + EXPECT_EQ(list->ArraySize(), 1); + EXPECT_EQ(list->GetTrackAt(0), track); + EXPECT_EQ(track->Index(), 0); + EXPECT_EQ(track->sequence(), sequence); +} + +TEST_F(TimelineUndoGeneralTest, TransitionRemoveCommandRestoresClipLengths) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(1)); + olive::CrossDissolveTransition *transition = + CreateTransition(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + b->set_media_in(olive::core::rational(1)); + track->AppendBlock(a); + track->AppendBlock(transition); + track->AppendBlock(b); + olive::Node::ConnectEdge( + a, olive::NodeInput(transition, + olive::TransitionBlock::kOutBlockInput)); + olive::Node::ConnectEdge( + b, olive::NodeInput(transition, olive::TransitionBlock::kInBlockInput)); + // Layout: a [0,1], transition [1,3], b [3,6] with media_in 1 + ASSERT_EQ(transition->connected_out_block(), a); + ASSERT_EQ(transition->connected_in_block(), b); + ASSERT_TRUE(transition->is_dual_transition()); + + olive::TransitionRemoveCommand cmd(transition, true); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + // Both clips reclaim the half of the transition that overlapped them + EXPECT_EQ(a->length(), olive::core::rational(2)); + EXPECT_EQ(a->in(), olive::core::rational(0)); + EXPECT_EQ(a->out(), olive::core::rational(2)); + EXPECT_EQ(b->length(), olive::core::rational(4)); + EXPECT_EQ(b->media_in(), olive::core::rational(0)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(6)); + EXPECT_EQ(transition->track(), nullptr); + EXPECT_EQ(transition->project(), nullptr); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(track->Blocks().at(1), transition); + EXPECT_EQ(transition->project(), project_.get()); + EXPECT_EQ(transition->in(), olive::core::rational(1)); + EXPECT_EQ(transition->out(), olive::core::rational(3)); + EXPECT_EQ(transition->connected_out_block(), a); + EXPECT_EQ(transition->connected_in_block(), b); + EXPECT_EQ(a->length(), olive::core::rational(1)); + EXPECT_EQ(b->length(), olive::core::rational(3)); + EXPECT_EQ(b->media_in(), olive::core::rational(1)); + EXPECT_EQ(b->in(), olive::core::rational(3)); + EXPECT_EQ(b->out(), olive::core::rational(6)); +} + +TEST_F(TimelineUndoGeneralTest, ReplaceBlockWithGapCreatesGap) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + olive::ClipBlock *c = CreateClip(project_.get(), olive::core::rational(1)); + track->AppendBlock(a); + track->AppendBlock(b); + track->AppendBlock(c); + // Layout: a [0,2], b [2,5], c [5,6] + + olive::TrackReplaceBlockWithGapCommand cmd(track, b); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + olive::Block *gap = track->Blocks().at(1); + EXPECT_NE(dynamic_cast(gap), nullptr); + EXPECT_EQ(gap->in(), olive::core::rational(2)); + EXPECT_EQ(gap->out(), olive::core::rational(5)); + EXPECT_EQ(b->track(), nullptr); + EXPECT_EQ(c->in(), olive::core::rational(5)); + EXPECT_EQ(track->track_length(), olive::core::rational(6)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); + EXPECT_EQ(c->in(), olive::core::rational(5)); +} + +TEST_F(TimelineUndoGeneralTest, ReplaceBlockWithGapExtendsPreviousGap) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::GapBlock *g = CreateGap(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + olive::ClipBlock *c = CreateClip(project_.get(), olive::core::rational(1)); + track->AppendBlock(g); + track->AppendBlock(b); + track->AppendBlock(c); + // Layout: gap [0,2], b [2,5], c [5,6] + + olive::TrackReplaceBlockWithGapCommand cmd(track, b); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(0), g); + // The preceding gap grows to absorb the removed block's time + EXPECT_EQ(g->length(), olive::core::rational(5)); + EXPECT_EQ(g->in(), olive::core::rational(0)); + EXPECT_EQ(g->out(), olive::core::rational(5)); + EXPECT_EQ(c->in(), olive::core::rational(5)); + EXPECT_EQ(c->out(), olive::core::rational(6)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(g->length(), olive::core::rational(2)); + EXPECT_EQ(g->out(), olive::core::rational(2)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); + EXPECT_EQ(c->in(), olive::core::rational(5)); +} + +TEST_F(TimelineUndoGeneralTest, ReplaceBlockWithGapExtendsNextGap) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(2)); + olive::GapBlock *g = CreateGap(project_.get(), olive::core::rational(2)); + track->AppendBlock(a); + track->AppendBlock(b); + track->AppendBlock(g); + // Layout: a [0,2], b [2,4], gap [4,6] + + olive::TrackReplaceBlockWithGapCommand cmd(track, b); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(1), g); + // The following gap grows backwards to absorb the removed block's time + EXPECT_EQ(g->length(), olive::core::rational(4)); + EXPECT_EQ(g->in(), olive::core::rational(2)); + EXPECT_EQ(g->out(), olive::core::rational(6)); + EXPECT_EQ(a->out(), olive::core::rational(2)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(4)); + EXPECT_EQ(g->length(), olive::core::rational(2)); + EXPECT_EQ(g->in(), olive::core::rational(4)); + EXPECT_EQ(g->out(), olive::core::rational(6)); +} + +TEST_F(TimelineUndoGeneralTest, ReplaceBlockWithGapMergesSurroundingGaps) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::GapBlock *g1 = CreateGap(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(2)); + olive::GapBlock *g2 = CreateGap(project_.get(), olive::core::rational(2)); + olive::ClipBlock *c = CreateClip(project_.get(), olive::core::rational(2)); + track->AppendBlock(g1); + track->AppendBlock(b); + track->AppendBlock(g2); + track->AppendBlock(c); + // Layout: gap [0,2], b [2,4], gap [4,6], c [6,8] + + olive::TrackReplaceBlockWithGapCommand cmd(track, b); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(0), g1); + // Both surrounding gaps merge into one covering the removed block + EXPECT_EQ(g1->length(), olive::core::rational(6)); + EXPECT_EQ(g1->in(), olive::core::rational(0)); + EXPECT_EQ(g1->out(), olive::core::rational(6)); + EXPECT_EQ(c->in(), olive::core::rational(6)); + EXPECT_EQ(c->out(), olive::core::rational(8)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 4); + EXPECT_EQ(track->Blocks().at(0), g1); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(track->Blocks().at(2), g2); + EXPECT_EQ(track->Blocks().at(3), c); + EXPECT_EQ(g1->length(), olive::core::rational(2)); + EXPECT_EQ(g2->length(), olive::core::rational(2)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(4)); + EXPECT_EQ(g2->in(), olive::core::rational(4)); + EXPECT_EQ(g2->out(), olive::core::rational(6)); + EXPECT_EQ(c->in(), olive::core::rational(6)); +} + +TEST_F(TimelineUndoGeneralTest, ReplaceBlockWithGapRemovesLastBlock) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + track->AppendBlock(a); + track->AppendBlock(b); + // Layout: a [0,2], b [2,5] + + olive::TrackReplaceBlockWithGapCommand cmd(track, b); + + cmd.redo_now(); + // The last block needs no gap, it is simply removed + ASSERT_EQ(track->Blocks().size(), 1); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(b->track(), nullptr); + EXPECT_EQ(track->track_length(), olive::core::rational(2)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); +} + +TEST_F(TimelineUndoGeneralTest, ReplaceBlockWithGapRemovesPrecedingGapAtEnd) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::GapBlock *g = CreateGap(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + track->AppendBlock(g); + track->AppendBlock(b); + // Layout: gap [0,2], b [2,5] + + olive::TrackReplaceBlockWithGapCommand cmd(track, b); + + cmd.redo_now(); + // Removing the last block also removes the now-pointless gap before it + EXPECT_TRUE(track->Blocks().isEmpty()); + EXPECT_EQ(track->track_length(), olive::core::rational(0)); + EXPECT_EQ(g->track(), nullptr); + EXPECT_EQ(b->track(), nullptr); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(0), g); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(g->in(), olive::core::rational(0)); + EXPECT_EQ(g->out(), olive::core::rational(2)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); +} + +TEST_F(TimelineUndoGeneralTest, InsertGapsSplitsClipAndInsertsGap) +{ + 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(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(4)); + track->AppendBlock(a); + track->AppendBlock(b); + AppendTrackToList(list, track); + // Layout: a [0,2], b [2,6] + + olive::TrackListInsertGaps cmd(list, olive::core::rational(3), + olive::core::rational(2)); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + // b is split at the insert point and a gap goes between the halves + ASSERT_EQ(track->Blocks().size(), 4); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(b->length(), olive::core::rational(1)); + EXPECT_EQ(b->out(), olive::core::rational(3)); + olive::Block *gap = track->Blocks().at(2); + EXPECT_NE(dynamic_cast(gap), nullptr); + EXPECT_EQ(gap->in(), olive::core::rational(3)); + EXPECT_EQ(gap->out(), olive::core::rational(5)); + olive::Block *second_half = track->Blocks().at(3); + EXPECT_EQ(second_half->in(), olive::core::rational(5)); + EXPECT_EQ(second_half->out(), olive::core::rational(8)); + EXPECT_EQ(track->track_length(), olive::core::rational(8)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(b->length(), olive::core::rational(4)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(6)); + EXPECT_EQ(track->track_length(), olive::core::rational(6)); +} + +TEST_F(TimelineUndoGeneralTest, InsertGapsExtendsExistingGap) +{ + olive::Sequence *sequence = CreateSequence(project_.get()); + olive::TrackList *list = sequence->track_list(olive::Track::kVideo); + olive::Track *track = CreateTrack(project_.get()); + olive::GapBlock *g = CreateGap(project_.get(), olive::core::rational(4)); + olive::ClipBlock *c = CreateClip(project_.get(), olive::core::rational(2)); + track->AppendBlock(g); + track->AppendBlock(c); + AppendTrackToList(list, track); + // Layout: gap [0,4], c [4,6] + + olive::TrackListInsertGaps cmd(list, olive::core::rational(3), + olive::core::rational(2)); + + cmd.redo_now(); + // A gap already at the insert point simply grows + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(g->length(), olive::core::rational(6)); + EXPECT_EQ(g->out(), olive::core::rational(6)); + EXPECT_EQ(c->in(), olive::core::rational(6)); + EXPECT_EQ(c->out(), olive::core::rational(8)); + + cmd.undo_now(); + EXPECT_EQ(g->length(), olive::core::rational(4)); + EXPECT_EQ(g->out(), olive::core::rational(4)); + EXPECT_EQ(c->in(), olive::core::rational(4)); + EXPECT_EQ(c->out(), olive::core::rational(6)); +} + +TEST_F(TimelineUndoGeneralTest, InsertGapsSkipsLockedTracks) +{ + olive::Sequence *sequence = CreateSequence(project_.get()); + olive::TrackList *list = sequence->track_list(olive::Track::kVideo); + + olive::Track *t1 = CreateTrack(project_.get()); + t1->AppendBlock(CreateClip(project_.get(), olive::core::rational(4))); + AppendTrackToList(list, t1); + + olive::Track *t2 = CreateTrack(project_.get()); + olive::ClipBlock *c = CreateClip(project_.get(), olive::core::rational(4)); + t2->AppendBlock(c); + AppendTrackToList(list, t2); + t2->SetLocked(true); + // Layout: t1 [0,4] / t2 [0,4] + + olive::TrackListInsertGaps cmd(list, olive::core::rational(2), + olive::core::rational(2)); + + cmd.redo_now(); + EXPECT_EQ(t1->track_length(), olive::core::rational(6)); + // The locked track is untouched + EXPECT_EQ(t2->Blocks().size(), 1); + EXPECT_EQ(t2->track_length(), olive::core::rational(4)); + + cmd.undo_now(); + EXPECT_EQ(t1->track_length(), olive::core::rational(4)); + EXPECT_EQ(t2->track_length(), olive::core::rational(4)); +} + +TEST_F(TimelineUndoGeneralTest, InsertGapsAtOrBeyondEndDoesNothing) +{ + 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(2)); + track->AppendBlock(a); + AppendTrackToList(list, track); + // Layout: a [0,2] + + // Exactly at the end of the last block no gap is needed + olive::TrackListInsertGaps at_end(list, olive::core::rational(2), + olive::core::rational(2)); + at_end.redo_now(); + ASSERT_EQ(track->Blocks().size(), 1); + EXPECT_EQ(track->track_length(), olive::core::rational(2)); + at_end.undo_now(); + EXPECT_EQ(track->track_length(), olive::core::rational(2)); + + // Beyond all content there is nothing to split or extend + olive::TrackListInsertGaps beyond(list, olive::core::rational(5), + olive::core::rational(2)); + beyond.redo_now(); + ASSERT_EQ(track->Blocks().size(), 1); + EXPECT_EQ(track->track_length(), olive::core::rational(2)); + beyond.undo_now(); + EXPECT_EQ(track->track_length(), olive::core::rational(2)); +} + +TEST_F(TimelineUndoGeneralTest, AddDefaultTransitionAddsInAndOutTransitions) +{ + olive::NodeFactory::Initialize(); + + olive::Sequence *sequence = CreateSequence(project_.get()); + olive::TrackList *list = sequence->track_list(olive::Track::kVideo); + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *c = CreateClip(project_.get(), olive::core::rational(4)); + track->AppendBlock(c); + AppendTrackToList(list, track); + // Layout: c [0,4] + + olive::TimelineAddDefaultTransitionCommand cmd( + { c }, olive::core::rational(1, 30)); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + + // A lone clip gets an in transition and an out transition, each one second + ASSERT_EQ(track->Blocks().size(), 3); + auto *in_transition = + dynamic_cast(track->Blocks().at(0)); + auto *out_transition = + dynamic_cast(track->Blocks().at(2)); + ASSERT_NE(in_transition, nullptr); + ASSERT_NE(out_transition, nullptr); + + EXPECT_EQ(in_transition->in(), olive::core::rational(0)); + EXPECT_EQ(in_transition->out(), olive::core::rational(1)); + EXPECT_EQ(in_transition->connected_in_block(), c); + EXPECT_EQ(in_transition->connected_out_block(), nullptr); + EXPECT_FALSE(in_transition->is_dual_transition()); + + EXPECT_EQ(c->length(), olive::core::rational(2)); + EXPECT_EQ(c->media_in(), olive::core::rational(1)); + EXPECT_EQ(c->in(), olive::core::rational(1)); + EXPECT_EQ(c->out(), olive::core::rational(3)); + + EXPECT_EQ(out_transition->in(), olive::core::rational(3)); + EXPECT_EQ(out_transition->out(), olive::core::rational(4)); + EXPECT_EQ(out_transition->connected_out_block(), c); + EXPECT_EQ(out_transition->connected_in_block(), nullptr); + + // The total length of the track is unchanged + EXPECT_EQ(track->track_length(), olive::core::rational(4)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 1); + EXPECT_EQ(track->Blocks().at(0), c); + EXPECT_EQ(c->length(), olive::core::rational(4)); + EXPECT_EQ(c->media_in(), olive::core::rational(0)); + EXPECT_EQ(c->in(), olive::core::rational(0)); + EXPECT_EQ(c->out(), olive::core::rational(4)); + EXPECT_EQ(in_transition->project(), nullptr); + EXPECT_EQ(out_transition->project(), nullptr); +} + +TEST_F(TimelineUndoGeneralTest, AddDefaultTransitionAddsDualTransition) +{ + olive::NodeFactory::Initialize(); + + 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( + { a, b }, olive::core::rational(1, 30)); + + cmd.redo_now(); + + // Adjacent clips get an in transition on a, a dual transition between + // them, and an out transition on b + ASSERT_EQ(track->Blocks().size(), 5); + auto *in_transition = + dynamic_cast(track->Blocks().at(0)); + auto *dual_transition = + dynamic_cast(track->Blocks().at(2)); + auto *out_transition = + dynamic_cast(track->Blocks().at(4)); + ASSERT_NE(in_transition, nullptr); + ASSERT_NE(dual_transition, nullptr); + ASSERT_NE(out_transition, nullptr); + + EXPECT_TRUE(dual_transition->is_dual_transition()); + EXPECT_EQ(dual_transition->connected_out_block(), a); + EXPECT_EQ(dual_transition->connected_in_block(), b); + // A centered dual transition overlaps each clip by half its length + EXPECT_EQ(dual_transition->in_offset(), olive::core::rational(1, 2)); + EXPECT_EQ(dual_transition->out_offset(), olive::core::rational(1, 2)); + + EXPECT_EQ(a->length(), olive::core::rational(5, 2)); + EXPECT_EQ(a->in(), olive::core::rational(1)); + EXPECT_EQ(a->out(), olive::core::rational(7, 2)); + EXPECT_EQ(b->length(), olive::core::rational(5, 2)); + EXPECT_EQ(b->media_in(), olive::core::rational(1, 2)); + EXPECT_EQ(b->in(), olive::core::rational(9, 2)); + EXPECT_EQ(b->out(), olive::core::rational(7)); + + EXPECT_EQ(dual_transition->in(), olive::core::rational(7, 2)); + EXPECT_EQ(dual_transition->out(), olive::core::rational(9, 2)); + EXPECT_EQ(track->track_length(), olive::core::rational(8)); + + 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)); +} + +TEST_F(TimelineUndoGeneralTest, AddDefaultTransitionEmptyClipListIsHarmless) +{ + olive::TimelineAddDefaultTransitionCommand cmd( + {}, olive::core::rational(1, 30)); + EXPECT_EQ(cmd.GetRelevantProject(), nullptr); + + // redo/undo on an empty command must be harmless no-ops + cmd.redo_now(); + cmd.undo_now(); +} diff --git a/tests/gtest/timeline_undo_test.cpp b/tests/gtest/timeline_undo_test.cpp new file mode 100644 index 000000000..4c8ee0b62 --- /dev/null +++ b/tests/gtest/timeline_undo_test.cpp @@ -0,0 +1,1443 @@ +#include + +#include + +#include "node/block/clip/clip.h" +#include "node/block/gap/gap.h" +#include "node/block/transition/crossdissolve/crossdissolvetransition.h" +#include "node/color/colormanager/colormanager.h" +#include "node/math/math/math.h" +#include "node/output/track/track.h" +#include "node/output/track/tracklist.h" +#include "node/project.h" +#include "node/project/sequence/sequence.h" +#include "timeline/timelineundocommon.h" +#include "timeline/timelineundopointer.h" +#include "timeline/timelineundoripple.h" +#include "timeline/timelineundosplit.h" +#include "timeline/timelineundotrack.h" +#include "timeline/timelineundoworkarea.h" + +namespace +{ + +olive::Sequence *CreateSequence(olive::Project *project) +{ + auto *sequence = new olive::Sequence(); + sequence->setParent(project); + return sequence; +} + +olive::Track *CreateTrack(olive::Project *project) +{ + auto *track = new olive::Track(); + track->setParent(project); + return track; +} + +olive::ClipBlock *CreateClip(olive::Project *project, + const olive::core::rational &length) +{ + auto *clip = new olive::ClipBlock(); + clip->setParent(project); + clip->set_length_and_media_out(length); + return clip; +} + +olive::GapBlock *CreateGap(olive::Project *project, + const olive::core::rational &length) +{ + auto *gap = new olive::GapBlock(); + gap->setParent(project); + gap->set_length_and_media_out(length); + return gap; +} + +void AppendTrackToList(olive::TrackList *list, olive::Track *track) +{ + list->ArrayAppend(); + olive::Node::ConnectEdge(track, + list->track_input(list->ArraySize() - 1)); +} + +} // namespace + +class TimelineUndoTest : public ::testing::Test { +protected: + void SetUp() override + { + olive::ColorManager::SetUpDefaultConfig(); + + project_ = std::make_unique(); + project_->Initialize(); + } + + std::unique_ptr project_; +}; + +// +// timelineundotrack.h +// +TEST_F(TimelineUndoTest, RippleRemoveBlockCommandRemovesAndRestores) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + olive::ClipBlock *c = CreateClip(project_.get(), olive::core::rational(1)); + track->AppendBlock(a); + track->AppendBlock(b); + track->AppendBlock(c); + // Layout: a [0,2], b [2,5], c [5,6] + + olive::TrackRippleRemoveBlockCommand cmd(track, b); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(track->Blocks().at(1), c); + EXPECT_EQ(b->track(), nullptr); + EXPECT_EQ(c->in(), olive::core::rational(2)); + EXPECT_EQ(c->out(), olive::core::rational(3)); + EXPECT_EQ(track->track_length(), olive::core::rational(3)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(b->track(), track); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); + EXPECT_EQ(c->in(), olive::core::rational(5)); + EXPECT_EQ(track->track_length(), olive::core::rational(6)); +} + +TEST_F(TimelineUndoTest, PrependBlockCommandInsertsAndRemoves) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(2)); + track->AppendBlock(b); + + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(1)); + olive::TrackPrependBlockCommand cmd(track, a); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(a->in(), olive::core::rational(0)); + EXPECT_EQ(a->out(), olive::core::rational(1)); + EXPECT_EQ(b->in(), olive::core::rational(1)); + EXPECT_EQ(b->out(), olive::core::rational(3)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 1); + EXPECT_EQ(track->Blocks().at(0), b); + EXPECT_EQ(a->track(), nullptr); + EXPECT_EQ(b->in(), olive::core::rational(0)); + EXPECT_EQ(b->out(), olive::core::rational(2)); +} + +TEST_F(TimelineUndoTest, InsertBlockAfterCommandInsertsAndRemoves) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(1)); + olive::ClipBlock *c = CreateClip(project_.get(), olive::core::rational(1)); + track->AppendBlock(a); + track->AppendBlock(c); + + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(2)); + olive::TrackInsertBlockAfterCommand cmd(track, b, a); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(b->in(), olive::core::rational(1)); + EXPECT_EQ(b->out(), olive::core::rational(3)); + EXPECT_EQ(c->in(), olive::core::rational(3)); + EXPECT_EQ(track->track_length(), olive::core::rational(4)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(b->track(), nullptr); + EXPECT_EQ(c->in(), olive::core::rational(1)); + EXPECT_EQ(track->track_length(), olive::core::rational(2)); +} + +TEST_F(TimelineUndoTest, ReplaceBlockCommandSwapsAndRestores) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + track->AppendBlock(a); + track->AppendBlock(b); + + olive::ClipBlock *r = CreateClip(project_.get(), olive::core::rational(3)); + olive::TrackReplaceBlockCommand cmd(track, b, r); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(1), r); + EXPECT_EQ(b->track(), nullptr); + EXPECT_EQ(r->in(), olive::core::rational(2)); + EXPECT_EQ(r->out(), olive::core::rational(5)); + EXPECT_EQ(track->track_length(), olive::core::rational(5)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(r->track(), nullptr); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); +} + +// +// timelineundoworkarea.h +// +TEST_F(TimelineUndoTest, WorkareaSetEnabledCommandToggles) +{ + olive::TimelineWorkArea workarea; + ASSERT_FALSE(workarea.enabled()); + + olive::WorkareaSetEnabledCommand cmd(project_.get(), &workarea, true); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_TRUE(workarea.enabled()); + + cmd.undo_now(); + EXPECT_FALSE(workarea.enabled()); +} + +TEST_F(TimelineUndoTest, WorkareaSetRangeCommandSetsAndRestores) +{ + olive::TimelineWorkArea workarea; + const olive::core::TimeRange old_range(olive::core::rational(2), + olive::core::rational(6)); + const olive::core::TimeRange new_range(olive::core::rational(3), + olive::core::rational(9)); + workarea.set_range(old_range); + + // The two-argument form captures the workarea's current range as the old one + olive::WorkareaSetRangeCommand cmd(&workarea, new_range); + + cmd.redo_now(); + EXPECT_EQ(workarea.range(), new_range); + + cmd.undo_now(); + EXPECT_EQ(workarea.range(), old_range); +} + +TEST_F(TimelineUndoTest, WorkareaSetRangeCommandExplicitOldRange) +{ + olive::TimelineWorkArea workarea; + const olive::core::TimeRange old_range(olive::core::rational(0), + olive::core::rational(10)); + const olive::core::TimeRange new_range(olive::core::rational(4), + olive::core::rational(5)); + workarea.set_range(old_range); + + olive::WorkareaSetRangeCommand cmd(&workarea, new_range, old_range); + + cmd.redo_now(); + EXPECT_EQ(workarea.range(), new_range); + EXPECT_EQ(workarea.in(), olive::core::rational(4)); + EXPECT_EQ(workarea.out(), olive::core::rational(5)); + + cmd.undo_now(); + EXPECT_EQ(workarea.range(), old_range); +} + +// +// timelineundocommon.h +// +TEST_F(TimelineUndoTest, NodeCanBeRemovedReflectsConnections) +{ + olive::ClipBlock *clip = CreateClip(project_.get(), olive::core::rational(2)); + + // An unconnected node has no output connections and can be removed + EXPECT_TRUE(olive::NodeCanBeRemoved(clip)); + + olive::Track *track = CreateTrack(project_.get()); + track->AppendBlock(clip); + EXPECT_FALSE(olive::NodeCanBeRemoved(clip)); + + track->RippleRemoveBlock(clip); + EXPECT_TRUE(olive::NodeCanBeRemoved(clip)); +} + +TEST_F(TimelineUndoTest, CreateAndRunRemoveCommandRemovesFromGraph) +{ + auto *node = new olive::MathNode(); + node->setParent(project_.get()); + ASSERT_EQ(node->project(), project_.get()); + + olive::UndoCommand *cmd = olive::CreateAndRunRemoveCommand(node); + EXPECT_EQ(node->project(), nullptr); + EXPECT_FALSE(project_->nodes().contains(node)); + + cmd->undo_now(); + EXPECT_EQ(node->project(), project_.get()); + EXPECT_TRUE(project_->nodes().contains(node)); + + delete cmd; +} + +// +// timelineundosplit.h +// +TEST_F(TimelineUndoTest, BlockSplitCommandSplitsAndMerges) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(4)); + track->AppendBlock(a); + // Layout: a [0,4] + + olive::BlockSplitCommand cmd(a, olive::core::rational(1)); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + + olive::Block *split = cmd.new_block(); + ASSERT_NE(split, nullptr); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(track->Blocks().at(1), split); + + EXPECT_EQ(a->length(), olive::core::rational(1)); + EXPECT_EQ(a->in(), olive::core::rational(0)); + EXPECT_EQ(a->out(), olive::core::rational(1)); + + EXPECT_EQ(split->length(), olive::core::rational(3)); + EXPECT_EQ(split->in(), olive::core::rational(1)); + EXPECT_EQ(split->out(), olive::core::rational(4)); + // The second half's media in point is offset by the split time + EXPECT_EQ(static_cast(split)->media_in(), + olive::core::rational(1)); + + EXPECT_EQ(track->track_length(), olive::core::rational(4)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 1); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(a->length(), olive::core::rational(4)); + EXPECT_EQ(a->in(), olive::core::rational(0)); + EXPECT_EQ(a->out(), olive::core::rational(4)); + EXPECT_EQ(split->track(), nullptr); +} + +TEST_F(TimelineUndoTest, BlockSplitCommandMovesOutTransitionToNewBlock) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(4)); + auto *transition = new olive::CrossDissolveTransition(); + transition->setParent(project_.get()); + transition->set_length_and_media_out(olive::core::rational(2)); + track->AppendBlock(a); + track->AppendBlock(transition); + olive::Node::ConnectEdge( + a, olive::NodeInput(transition, olive::TransitionBlock::kOutBlockInput)); + ASSERT_EQ(transition->connected_out_block(), a); + // Layout: a [0,4], transition [4,6] + + olive::BlockSplitCommand cmd(a, olive::core::rational(2)); + cmd.redo_now(); + + olive::Block *split = cmd.new_block(); + ASSERT_NE(split, nullptr); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(split->in(), olive::core::rational(2)); + EXPECT_EQ(split->out(), olive::core::rational(4)); + EXPECT_EQ(transition->in(), olive::core::rational(4)); + + // The out transition moved from the original block to the split block + EXPECT_EQ(transition->connected_out_block(), split); + EXPECT_EQ(olive::NodeInput(transition, + olive::TransitionBlock::kOutBlockInput) + .GetConnectedOutput(), + split); + + cmd.undo_now(); + + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(a->length(), olive::core::rational(4)); + EXPECT_EQ(transition->connected_out_block(), a); + EXPECT_EQ(transition->in(), olive::core::rational(4)); +} + +TEST_F(TimelineUndoTest, BlockSplitPreservingLinksCommandSplitsLinkedBlocks) +{ + olive::Track *video_track = CreateTrack(project_.get()); + olive::Track *audio_track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(4)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(4)); + video_track->AppendBlock(a); + audio_track->AppendBlock(b); + olive::Node::Link(a, b); + + olive::BlockSplitPreservingLinksCommand cmd( + { a, b }, { olive::core::rational(2) }); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + + ASSERT_EQ(video_track->Blocks().size(), 2); + ASSERT_EQ(audio_track->Blocks().size(), 2); + EXPECT_EQ(a->length(), olive::core::rational(2)); + EXPECT_EQ(b->length(), olive::core::rational(2)); + + olive::Block *a_split = cmd.GetSplit(a, 0); + olive::Block *b_split = cmd.GetSplit(b, 0); + ASSERT_NE(a_split, nullptr); + ASSERT_NE(b_split, nullptr); + EXPECT_EQ(a_split->in(), olive::core::rational(2)); + EXPECT_EQ(b_split->in(), olive::core::rational(2)); + + // The original link survives and the new halves are linked together too + EXPECT_TRUE(olive::Node::AreLinked(a, b)); + EXPECT_TRUE(olive::Node::AreLinked(a_split, b_split)); + + // Invalid lookups return null instead of crashing + EXPECT_EQ(cmd.GetSplit(a, 1), nullptr); + EXPECT_EQ(cmd.GetSplit(a, -1), nullptr); + olive::ClipBlock *stray = + CreateClip(project_.get(), olive::core::rational(1)); + EXPECT_EQ(cmd.GetSplit(stray, 0), nullptr); + + cmd.undo_now(); + + ASSERT_EQ(video_track->Blocks().size(), 1); + ASSERT_EQ(audio_track->Blocks().size(), 1); + EXPECT_EQ(a->length(), olive::core::rational(4)); + EXPECT_EQ(b->length(), olive::core::rational(4)); + EXPECT_TRUE(olive::Node::AreLinked(a, b)); +} + +TEST_F(TimelineUndoTest, TrackSplitAtTimeCommandSplitsContainingBlock) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + track->AppendBlock(a); + track->AppendBlock(b); + // Layout: a [0,2], b [2,5] + + olive::TrackSplitAtTimeCommand cmd(track, olive::core::rational(3)); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(b->length(), olive::core::rational(1)); + EXPECT_EQ(b->out(), olive::core::rational(3)); + EXPECT_EQ(track->Blocks().at(2)->in(), olive::core::rational(3)); + EXPECT_EQ(track->Blocks().at(2)->out(), olive::core::rational(5)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(b->length(), olive::core::rational(3)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); +} + +TEST_F(TimelineUndoTest, TrackSplitAtTimeCommandNoOpOutsideBlocks) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + track->AppendBlock(a); + track->AppendBlock(b); + // Layout: a [0,2], b [2,5] + + // A time exactly on a block boundary is not contained by any block + olive::TrackSplitAtTimeCommand on_edge(track, olive::core::rational(2)); + on_edge.redo_now(); + EXPECT_EQ(track->Blocks().size(), 2); + on_edge.undo_now(); + EXPECT_EQ(track->Blocks().size(), 2); + + // A time past the end of the track contains nothing either + olive::TrackSplitAtTimeCommand past_end(track, olive::core::rational(10)); + past_end.redo_now(); + EXPECT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(b->length(), olive::core::rational(3)); + past_end.undo_now(); + EXPECT_EQ(track->Blocks().size(), 2); +} + +// +// timelineundoripple.h +// +TEST_F(TimelineUndoTest, RippleRemoveAreaRemovesMiddleBlock) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + olive::ClipBlock *c = CreateClip(project_.get(), olive::core::rational(1)); + track->AppendBlock(a); + track->AppendBlock(b); + track->AppendBlock(c); + // Layout: a [0,2], b [2,5], c [5,6] + + olive::TrackRippleRemoveAreaCommand cmd( + track, olive::core::TimeRange(olive::core::rational(2), + olive::core::rational(5))); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(track->Blocks().at(1), c); + EXPECT_EQ(c->in(), olive::core::rational(2)); + EXPECT_EQ(c->out(), olive::core::rational(3)); + EXPECT_EQ(track->track_length(), olive::core::rational(3)); + + // The removed block was taken out of the graph entirely + EXPECT_EQ(b->track(), nullptr); + EXPECT_EQ(b->project(), nullptr); + + // An insertion would go after the block preceding the removed area + EXPECT_EQ(cmd.GetInsertionIndex(), a); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(b->project(), project_.get()); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); + EXPECT_EQ(c->in(), olive::core::rational(5)); + EXPECT_EQ(track->track_length(), olive::core::rational(6)); +} + +TEST_F(TimelineUndoTest, RippleRemoveAreaRemovesMultipleBlocks) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(1)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(1)); + olive::ClipBlock *c = CreateClip(project_.get(), olive::core::rational(1)); + olive::ClipBlock *d = CreateClip(project_.get(), olive::core::rational(1)); + track->AppendBlock(a); + track->AppendBlock(b); + track->AppendBlock(c); + track->AppendBlock(d); + // Layout: a [0,1], b [1,2], c [2,3], d [3,4] + + olive::TrackRippleRemoveAreaCommand cmd( + track, olive::core::TimeRange(olive::core::rational(1), + olive::core::rational(3))); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(track->Blocks().at(1), d); + EXPECT_EQ(d->in(), olive::core::rational(1)); + EXPECT_EQ(d->out(), olive::core::rational(2)); + EXPECT_EQ(track->track_length(), olive::core::rational(2)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 4); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(track->Blocks().at(2), c); + EXPECT_EQ(track->Blocks().at(3), d); + EXPECT_EQ(b->in(), olive::core::rational(1)); + EXPECT_EQ(c->in(), olive::core::rational(2)); + EXPECT_EQ(d->in(), olive::core::rational(3)); + EXPECT_EQ(track->track_length(), olive::core::rational(4)); +} + +TEST_F(TimelineUndoTest, RippleRemoveAreaTrimsBothEnds) +{ + 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); + // Layout: a [0,4], b [4,8] + + olive::TrackRippleRemoveAreaCommand cmd( + track, olive::core::TimeRange(olive::core::rational(2), + olive::core::rational(6))); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + // a is out-trimmed to the range start, b is in-trimmed to the range end + EXPECT_EQ(a->length(), olive::core::rational(2)); + EXPECT_EQ(a->in(), olive::core::rational(0)); + EXPECT_EQ(a->out(), olive::core::rational(2)); + EXPECT_EQ(b->length(), olive::core::rational(2)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(4)); + EXPECT_EQ(track->track_length(), olive::core::rational(4)); + + cmd.undo_now(); + EXPECT_EQ(a->length(), olive::core::rational(4)); + EXPECT_EQ(b->length(), olive::core::rational(4)); + EXPECT_EQ(a->in(), olive::core::rational(0)); + EXPECT_EQ(a->out(), olive::core::rational(4)); + EXPECT_EQ(b->in(), olive::core::rational(4)); + EXPECT_EQ(b->out(), olive::core::rational(8)); +} + +TEST_F(TimelineUndoTest, RippleRemoveAreaSplicesBlock) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(10)); + track->AppendBlock(a); + // Layout: a [0,10] + + olive::TrackRippleRemoveAreaCommand cmd( + track, olive::core::TimeRange(olive::core::rational(3), + olive::core::rational(6))); + + cmd.redo_now(); + + // The block is split around the removed range and the second half is + // in-trimmed by the range length + olive::Block *spliced = cmd.GetSplicedBlock(); + ASSERT_NE(spliced, nullptr); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(a->length(), olive::core::rational(3)); + EXPECT_EQ(a->out(), olive::core::rational(3)); + EXPECT_EQ(spliced->in(), olive::core::rational(3)); + EXPECT_EQ(spliced->out(), olive::core::rational(7)); + EXPECT_EQ(spliced->length(), olive::core::rational(4)); + EXPECT_EQ(static_cast(spliced)->media_in(), + olive::core::rational(6)); + EXPECT_EQ(track->track_length(), olive::core::rational(7)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 1); + EXPECT_EQ(a->length(), olive::core::rational(10)); + EXPECT_EQ(a->in(), olive::core::rational(0)); + EXPECT_EQ(a->out(), olive::core::rational(10)); +} + +TEST_F(TimelineUndoTest, RippleRemoveAreaTrimsGapWhenSplittingGapsDisabled) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::GapBlock *g = CreateGap(project_.get(), olive::core::rational(5)); + track->AppendBlock(g); + // Layout: gap [0,5] + + olive::TrackRippleRemoveAreaCommand cmd( + track, olive::core::TimeRange(olive::core::rational(2), + olive::core::rational(4))); + ASSERT_EQ(cmd.GetSplicedBlock(), nullptr); + + cmd.redo_now(); + // Gaps are not spliced by default, the gap is just trimmed by the range + ASSERT_EQ(track->Blocks().size(), 1); + EXPECT_EQ(g->length(), olive::core::rational(3)); + EXPECT_EQ(g->in(), olive::core::rational(0)); + EXPECT_EQ(g->out(), olive::core::rational(3)); + EXPECT_EQ(cmd.GetSplicedBlock(), nullptr); + + cmd.undo_now(); + EXPECT_EQ(g->length(), olive::core::rational(5)); + EXPECT_EQ(g->out(), olive::core::rational(5)); +} + +TEST_F(TimelineUndoTest, RippleRemoveAreaNoOpOnEmptyRange) +{ + olive::Track *track = CreateTrack(project_.get()); + + // Empty track: nothing to remove + olive::TrackRippleRemoveAreaCommand empty_track( + track, olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(5))); + empty_track.redo_now(); + EXPECT_TRUE(track->Blocks().isEmpty()); + empty_track.undo_now(); + EXPECT_TRUE(track->Blocks().isEmpty()); + + // Range fully beyond the track's content: also nothing to remove + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + track->AppendBlock(a); + olive::TrackRippleRemoveAreaCommand past_end( + track, olive::core::TimeRange(olive::core::rational(5), + olive::core::rational(7))); + past_end.redo_now(); + ASSERT_EQ(track->Blocks().size(), 1); + EXPECT_EQ(a->length(), olive::core::rational(2)); + EXPECT_EQ(track->track_length(), olive::core::rational(2)); + past_end.undo_now(); + EXPECT_EQ(track->track_length(), olive::core::rational(2)); +} + +TEST_F(TimelineUndoTest, TrackListRippleRemoveAreaAffectsAllTracks) +{ + olive::Sequence *sequence = CreateSequence(project_.get()); + olive::TrackList *list = sequence->track_list(olive::Track::kVideo); + + olive::Track *t1 = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + t1->AppendBlock(a); + t1->AppendBlock(b); + AppendTrackToList(list, t1); + + olive::Track *t2 = CreateTrack(project_.get()); + olive::ClipBlock *c = CreateClip(project_.get(), olive::core::rational(3)); + olive::ClipBlock *d = CreateClip(project_.get(), olive::core::rational(3)); + t2->AppendBlock(c); + t2->AppendBlock(d); + AppendTrackToList(list, t2); + // t1: a [0,2], b [2,5] / t2: c [0,3], d [3,6] + + olive::TrackListRippleRemoveAreaCommand cmd(list, olive::core::rational(2), + olive::core::rational(4)); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + // t1: b is in-trimmed by the range + EXPECT_EQ(b->length(), olive::core::rational(1)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(3)); + // t2: c is out-trimmed and d is in-trimmed + EXPECT_EQ(c->length(), olive::core::rational(2)); + EXPECT_EQ(d->length(), olive::core::rational(2)); + EXPECT_EQ(d->in(), olive::core::rational(2)); + EXPECT_EQ(d->out(), olive::core::rational(4)); + + cmd.undo_now(); + EXPECT_EQ(b->length(), olive::core::rational(3)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); + EXPECT_EQ(c->length(), olive::core::rational(3)); + EXPECT_EQ(d->length(), olive::core::rational(3)); + EXPECT_EQ(d->in(), olive::core::rational(3)); + EXPECT_EQ(d->out(), olive::core::rational(6)); +} + +TEST_F(TimelineUndoTest, TrackListRippleRemoveAreaSkipsLockedTracks) +{ + olive::Sequence *sequence = CreateSequence(project_.get()); + olive::TrackList *list = sequence->track_list(olive::Track::kVideo); + + olive::Track *t1 = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + t1->AppendBlock(a); + t1->AppendBlock(b); + AppendTrackToList(list, t1); + + olive::Track *t2 = CreateTrack(project_.get()); + olive::ClipBlock *c = CreateClip(project_.get(), olive::core::rational(3)); + t2->AppendBlock(c); + AppendTrackToList(list, t2); + t2->SetLocked(true); + + olive::TrackListRippleRemoveAreaCommand cmd(list, olive::core::rational(2), + olive::core::rational(4)); + + cmd.redo_now(); + EXPECT_EQ(b->length(), olive::core::rational(1)); + // The locked track is untouched + EXPECT_EQ(c->length(), olive::core::rational(3)); + EXPECT_EQ(c->in(), olive::core::rational(0)); + EXPECT_EQ(c->out(), olive::core::rational(3)); + + cmd.undo_now(); + EXPECT_EQ(b->length(), olive::core::rational(3)); + EXPECT_EQ(c->length(), olive::core::rational(3)); +} + +TEST_F(TimelineUndoTest, TimelineRippleRemoveAreaAffectsAllTrackTypes) +{ + olive::Sequence *sequence = CreateSequence(project_.get()); + + olive::Track *video = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + video->AppendBlock(a); + video->AppendBlock(b); + AppendTrackToList(sequence->track_list(olive::Track::kVideo), video); + + olive::Track *audio = CreateTrack(project_.get()); + olive::ClipBlock *c = CreateClip(project_.get(), olive::core::rational(3)); + audio->AppendBlock(c); + AppendTrackToList(sequence->track_list(olive::Track::kAudio), audio); + // video: a [0,2], b [2,5] / audio: c [0,3] + + olive::TimelineRippleRemoveAreaCommand cmd(sequence, + olive::core::rational(1), + olive::core::rational(3)); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(a->length(), olive::core::rational(1)); + EXPECT_EQ(b->length(), olive::core::rational(2)); + EXPECT_EQ(b->in(), olive::core::rational(1)); + EXPECT_EQ(b->out(), olive::core::rational(3)); + EXPECT_EQ(c->length(), olive::core::rational(1)); + EXPECT_EQ(c->out(), olive::core::rational(1)); + + cmd.undo_now(); + EXPECT_EQ(a->length(), olive::core::rational(2)); + EXPECT_EQ(b->length(), olive::core::rational(3)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); + EXPECT_EQ(c->length(), olive::core::rational(3)); + EXPECT_EQ(c->out(), olive::core::rational(3)); +} + +TEST_F(TimelineUndoTest, RippleToolCommandTrimsBlockAndRipples) +{ + 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(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + track->AppendBlock(a); + track->AppendBlock(b); + AppendTrackToList(list, track); + // Layout: a [0,2], b [2,5] + + QHash info; + info.insert(track, { a, false }); + olive::TrackListRippleToolCommand cmd(list, info, olive::core::rational(1), + olive::Timeline::kTrimOut); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + // a extends by one, pushing b later + EXPECT_EQ(a->length(), olive::core::rational(3)); + EXPECT_EQ(a->out(), olive::core::rational(3)); + EXPECT_EQ(b->in(), olive::core::rational(3)); + EXPECT_EQ(b->out(), olive::core::rational(6)); + EXPECT_EQ(track->track_length(), olive::core::rational(6)); + + cmd.undo_now(); + EXPECT_EQ(a->length(), olive::core::rational(2)); + EXPECT_EQ(a->out(), olive::core::rational(2)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); +} + +TEST_F(TimelineUndoTest, RippleToolCommandAppendsGap) +{ + 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(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + track->AppendBlock(a); + track->AppendBlock(b); + AppendTrackToList(list, track); + // Layout: a [0,2], b [2,5] + + // Rather than resizing a block, a gap of the movement length is inserted + QHash info; + info.insert(track, { b, true }); + olive::TrackListRippleToolCommand cmd(list, info, olive::core::rational(1), + olive::Timeline::kTrimOut); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + olive::Block *gap = track->Blocks().at(1); + EXPECT_NE(dynamic_cast(gap), nullptr); + EXPECT_EQ(gap->length(), olive::core::rational(1)); + EXPECT_EQ(gap->in(), olive::core::rational(2)); + EXPECT_EQ(gap->out(), olive::core::rational(3)); + EXPECT_EQ(b->in(), olive::core::rational(3)); + EXPECT_EQ(b->out(), olive::core::rational(6)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); + EXPECT_EQ(track->track_length(), olive::core::rational(5)); +} + +TEST_F(TimelineUndoTest, RippleToolCommandRemovesZeroLengthGap) +{ + 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(2)); + olive::GapBlock *g = CreateGap(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(2)); + track->AppendBlock(a); + track->AppendBlock(g); + track->AppendBlock(b); + AppendTrackToList(list, track); + // Layout: a [0,2], gap [2,4], b [4,6] + + // Trimming the gap by its entire length removes it from the track and graph + QHash info; + info.insert(track, { g, false }); + olive::TrackListRippleToolCommand cmd(list, info, + olive::core::rational(-2), + olive::Timeline::kTrimOut); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(4)); + EXPECT_EQ(g->track(), nullptr); + EXPECT_EQ(g->project(), nullptr); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(track->Blocks().at(1), g); + EXPECT_EQ(g->project(), project_.get()); + EXPECT_EQ(g->in(), olive::core::rational(2)); + EXPECT_EQ(g->out(), olive::core::rational(4)); + EXPECT_EQ(b->in(), olive::core::rational(4)); + EXPECT_EQ(b->out(), olive::core::rational(6)); +} + +TEST_F(TimelineUndoTest, RippleDeleteGapsAtRegionsRemovesGap) +{ + olive::Sequence *sequence = CreateSequence(project_.get()); + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::GapBlock *g = CreateGap(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(2)); + track->AppendBlock(a); + track->AppendBlock(g); + track->AppendBlock(b); + AppendTrackToList(sequence->track_list(olive::Track::kVideo), track); + // Layout: a [0,2], gap [2,4], b [4,6] + + olive::TimelineRippleDeleteGapsAtRegionsCommand::RangeList regions; + regions.append({ track, olive::core::TimeRange(olive::core::rational(2), + olive::core::rational(4)) }); + olive::TimelineRippleDeleteGapsAtRegionsCommand cmd(sequence, regions); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + // commands_ is populated by prepare(), which runs on the first redo + cmd.redo_now(); + EXPECT_TRUE(cmd.HasCommands()); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(4)); + EXPECT_EQ(g->track(), nullptr); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(track->Blocks().at(1), g); + EXPECT_EQ(g->in(), olive::core::rational(2)); + EXPECT_EQ(g->out(), olive::core::rational(4)); + EXPECT_EQ(b->in(), olive::core::rational(4)); + EXPECT_EQ(b->out(), olive::core::rational(6)); +} + +TEST_F(TimelineUndoTest, RippleDeleteGapsAtRegionsIgnoresNonGapRegion) +{ + olive::Sequence *sequence = CreateSequence(project_.get()); + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + track->AppendBlock(a); + AppendTrackToList(sequence->track_list(olive::Track::kVideo), track); + // Layout: a [0,2] + + // The region covers a clip rather than a gap, so there is nothing to do + olive::TimelineRippleDeleteGapsAtRegionsCommand::RangeList regions; + regions.append({ track, olive::core::TimeRange(olive::core::rational(0), + olive::core::rational(2)) }); + olive::TimelineRippleDeleteGapsAtRegionsCommand cmd(sequence, regions); + + // prepare() finds no gap for the region, so no sub-commands are created + cmd.redo_now(); + EXPECT_FALSE(cmd.HasCommands()); + ASSERT_EQ(track->Blocks().size(), 1); + EXPECT_EQ(a->length(), olive::core::rational(2)); + + cmd.undo_now(); + EXPECT_EQ(track->track_length(), olive::core::rational(2)); +} + +// +// timelineundopointer.h +// +TEST_F(TimelineUndoTest, BlockTrimCommandTrimOutCreatesGap) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + track->AppendBlock(a); + track->AppendBlock(b); + // Layout: a [0,2], b [2,5] + + // Trimming a shorter with a clip adjacent creates a gap to fill the space + olive::BlockTrimCommand cmd(track, a, olive::core::rational(1), + olive::Timeline::kTrimOut); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(a->length(), olive::core::rational(1)); + EXPECT_EQ(a->out(), olive::core::rational(1)); + olive::Block *gap = track->Blocks().at(1); + EXPECT_NE(dynamic_cast(gap), nullptr); + EXPECT_EQ(gap->in(), olive::core::rational(1)); + EXPECT_EQ(gap->out(), olive::core::rational(2)); + // b is unaffected + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); + EXPECT_EQ(track->track_length(), olive::core::rational(5)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(a->length(), olive::core::rational(2)); + EXPECT_EQ(a->out(), olive::core::rational(2)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); +} + +TEST_F(TimelineUndoTest, BlockTrimCommandTrimOutIntoGap) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::GapBlock *g = CreateGap(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(2)); + track->AppendBlock(a); + track->AppendBlock(g); + track->AppendBlock(b); + // Layout: a [0,2], gap [2,4], b [4,6] + + // Trimming a longer consumes time from the adjacent gap + olive::BlockTrimCommand cmd(track, a, olive::core::rational(3), + olive::Timeline::kTrimOut); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(a->length(), olive::core::rational(3)); + EXPECT_EQ(a->out(), olive::core::rational(3)); + EXPECT_EQ(g->length(), olive::core::rational(1)); + EXPECT_EQ(g->in(), olive::core::rational(3)); + EXPECT_EQ(g->out(), olive::core::rational(4)); + EXPECT_EQ(b->in(), olive::core::rational(4)); + EXPECT_EQ(b->out(), olive::core::rational(6)); + + cmd.undo_now(); + EXPECT_EQ(a->length(), olive::core::rational(2)); + EXPECT_EQ(a->out(), olive::core::rational(2)); + EXPECT_EQ(g->length(), olive::core::rational(2)); + EXPECT_EQ(g->in(), olive::core::rational(2)); + EXPECT_EQ(g->out(), olive::core::rational(4)); + EXPECT_EQ(b->in(), olive::core::rational(4)); +} + +TEST_F(TimelineUndoTest, BlockTrimCommandTrimOutConsumesWholeGap) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::GapBlock *g = CreateGap(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(2)); + track->AppendBlock(a); + track->AppendBlock(g); + track->AppendBlock(b); + // Layout: a [0,2], gap [2,4], b [4,6] + + // Trimming a longer by the exact gap length removes the gap entirely + olive::BlockTrimCommand cmd(track, a, olive::core::rational(4), + olive::Timeline::kTrimOut); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(a->length(), olive::core::rational(4)); + EXPECT_EQ(a->out(), olive::core::rational(4)); + EXPECT_EQ(b->in(), olive::core::rational(4)); + EXPECT_EQ(b->out(), olive::core::rational(6)); + // By default the consumed gap is removed from the graph too + EXPECT_EQ(g->track(), nullptr); + EXPECT_EQ(g->project(), nullptr); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(track->Blocks().at(1), g); + EXPECT_EQ(g->project(), project_.get()); + EXPECT_EQ(g->length(), olive::core::rational(2)); + EXPECT_EQ(g->in(), olive::core::rational(2)); + EXPECT_EQ(g->out(), olive::core::rational(4)); + EXPECT_EQ(a->length(), olive::core::rational(2)); + EXPECT_EQ(b->in(), olive::core::rational(4)); +} + +TEST_F(TimelineUndoTest, BlockTrimCommandConsumedGapCanStayInGraph) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::GapBlock *g = CreateGap(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(2)); + track->AppendBlock(a); + track->AppendBlock(g); + track->AppendBlock(b); + // Layout: a [0,2], gap [2,4], b [4,6] + + olive::BlockTrimCommand cmd(track, a, olive::core::rational(4), + olive::Timeline::kTrimOut); + cmd.SetRemoveZeroLengthFromGraph(false); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + // The gap is off the track but remains in the graph when asked to stay + EXPECT_EQ(g->track(), nullptr); + EXPECT_EQ(g->project(), project_.get()); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(track->Blocks().at(1), g); + EXPECT_EQ(g->in(), olive::core::rational(2)); + EXPECT_EQ(g->out(), olive::core::rational(4)); + EXPECT_EQ(a->length(), olive::core::rational(2)); +} + +TEST_F(TimelineUndoTest, BlockTrimCommandTrimInCreatesGap) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + track->AppendBlock(a); + track->AppendBlock(b); + // Layout: a [0,2], b [2,5] + + // Trimming b's in point shorter with a clip before it creates a gap + olive::BlockTrimCommand cmd(track, b, olive::core::rational(2), + olive::Timeline::kTrimIn); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(track->Blocks().at(0), a); + olive::Block *gap = track->Blocks().at(1); + EXPECT_NE(dynamic_cast(gap), nullptr); + EXPECT_EQ(gap->in(), olive::core::rational(2)); + EXPECT_EQ(gap->out(), olive::core::rational(3)); + EXPECT_EQ(b->length(), olive::core::rational(2)); + EXPECT_EQ(b->in(), olive::core::rational(3)); + EXPECT_EQ(b->out(), olive::core::rational(5)); + EXPECT_EQ(b->media_in(), olive::core::rational(1)); + // a is unaffected + EXPECT_EQ(a->in(), olive::core::rational(0)); + EXPECT_EQ(a->out(), olive::core::rational(2)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(b->length(), olive::core::rational(3)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); + EXPECT_EQ(b->media_in(), olive::core::rational(0)); +} + +TEST_F(TimelineUndoTest, BlockTrimCommandTrimInRollEdit) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + track->AppendBlock(a); + track->AppendBlock(b); + // Layout: a [0,2], b [2,5] + + // A roll edit extends the adjacent clip instead of creating a gap + olive::BlockTrimCommand cmd(track, b, olive::core::rational(2), + olive::Timeline::kTrimIn); + cmd.SetTrimIsARollEdit(true); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(a->length(), olive::core::rational(3)); + EXPECT_EQ(a->out(), olive::core::rational(3)); + EXPECT_EQ(b->length(), olive::core::rational(2)); + EXPECT_EQ(b->in(), olive::core::rational(3)); + EXPECT_EQ(b->out(), olive::core::rational(5)); + EXPECT_EQ(b->media_in(), olive::core::rational(1)); + EXPECT_EQ(track->track_length(), olive::core::rational(5)); + + cmd.undo_now(); + EXPECT_EQ(a->length(), olive::core::rational(2)); + EXPECT_EQ(a->out(), olive::core::rational(2)); + EXPECT_EQ(b->length(), olive::core::rational(3)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); + EXPECT_EQ(b->media_in(), olive::core::rational(0)); +} + +TEST_F(TimelineUndoTest, BlockTrimCommandTrimOutLastBlockHasNoAdjacent) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + track->AppendBlock(a); + track->AppendBlock(b); + // Layout: a [0,2], b [2,5] + + // Trimming the last block's out point shorter shortens the whole track + olive::BlockTrimCommand cmd(track, b, olive::core::rational(1), + olive::Timeline::kTrimOut); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(b->length(), olive::core::rational(1)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(3)); + EXPECT_EQ(track->track_length(), olive::core::rational(3)); + + cmd.undo_now(); + EXPECT_EQ(b->length(), olive::core::rational(3)); + EXPECT_EQ(b->out(), olive::core::rational(5)); + EXPECT_EQ(track->track_length(), olive::core::rational(5)); +} + +TEST_F(TimelineUndoTest, BlockTrimCommandSameLengthDoesNothing) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + track->AppendBlock(a); + track->AppendBlock(b); + + // Trimming to the current length is a no-op + olive::BlockTrimCommand cmd(track, a, olive::core::rational(2), + olive::Timeline::kTrimOut); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(a->length(), olive::core::rational(2)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + + cmd.undo_now(); + EXPECT_EQ(a->length(), olive::core::rational(2)); + EXPECT_EQ(b->in(), olive::core::rational(2)); +} + +TEST_F(TimelineUndoTest, TrackSlideCommandShiftsGaps) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::GapBlock *g1 = CreateGap(project_.get(), olive::core::rational(2)); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(3)); + olive::GapBlock *g2 = CreateGap(project_.get(), olive::core::rational(2)); + track->AppendBlock(g1); + track->AppendBlock(a); + track->AppendBlock(g2); + // Layout: gap [0,2], a [2,5], gap [5,7] + + // Sliding a one frame later grows the leading gap and shrinks the trailing one + olive::TrackSlideCommand cmd(track, { a }, g1, g2, + olive::core::rational(1)); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + EXPECT_EQ(g1->length(), olive::core::rational(3)); + EXPECT_EQ(g1->out(), olive::core::rational(3)); + EXPECT_EQ(a->in(), olive::core::rational(3)); + EXPECT_EQ(a->out(), olive::core::rational(6)); + EXPECT_EQ(g2->length(), olive::core::rational(1)); + EXPECT_EQ(g2->in(), olive::core::rational(6)); + EXPECT_EQ(g2->out(), olive::core::rational(7)); + EXPECT_EQ(track->track_length(), olive::core::rational(7)); + + cmd.undo_now(); + EXPECT_EQ(g1->length(), olive::core::rational(2)); + EXPECT_EQ(a->in(), olive::core::rational(2)); + EXPECT_EQ(a->out(), olive::core::rational(5)); + EXPECT_EQ(g2->length(), olive::core::rational(2)); + EXPECT_EQ(g2->in(), olive::core::rational(5)); + EXPECT_EQ(g2->out(), olive::core::rational(7)); +} + +TEST_F(TimelineUndoTest, TrackSlideCommandRemovesOutAdjacent) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::GapBlock *g1 = CreateGap(project_.get(), olive::core::rational(2)); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(3)); + olive::GapBlock *g2 = CreateGap(project_.get(), olive::core::rational(2)); + track->AppendBlock(g1); + track->AppendBlock(a); + track->AppendBlock(g2); + // Layout: gap [0,2], a [2,5], gap [5,7] + + // Sliding right by the trailing gap's length consumes it + olive::TrackSlideCommand cmd(track, { a }, g1, g2, + olive::core::rational(2)); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(0), g1); + EXPECT_EQ(track->Blocks().at(1), a); + EXPECT_EQ(g1->length(), olive::core::rational(4)); + EXPECT_EQ(g1->out(), olive::core::rational(4)); + EXPECT_EQ(a->in(), olive::core::rational(4)); + EXPECT_EQ(a->out(), olive::core::rational(7)); + EXPECT_EQ(g2->track(), nullptr); + EXPECT_EQ(g2->project(), nullptr); + EXPECT_EQ(track->track_length(), olive::core::rational(7)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(track->Blocks().at(0), g1); + EXPECT_EQ(track->Blocks().at(1), a); + EXPECT_EQ(track->Blocks().at(2), g2); + EXPECT_EQ(g2->project(), project_.get()); + EXPECT_EQ(g1->length(), olive::core::rational(2)); + EXPECT_EQ(g1->out(), olive::core::rational(2)); + EXPECT_EQ(a->in(), olive::core::rational(2)); + EXPECT_EQ(a->out(), olive::core::rational(5)); + EXPECT_EQ(g2->in(), olive::core::rational(5)); + EXPECT_EQ(g2->out(), olive::core::rational(7)); +} + +TEST_F(TimelineUndoTest, TrackSlideCommandRemovesInAdjacent) +{ + olive::Track *track = CreateTrack(project_.get()); + olive::GapBlock *g1 = CreateGap(project_.get(), olive::core::rational(2)); + olive::ClipBlock *a = CreateClip(project_.get(), olive::core::rational(3)); + olive::GapBlock *g2 = CreateGap(project_.get(), olive::core::rational(2)); + track->AppendBlock(g1); + track->AppendBlock(a); + track->AppendBlock(g2); + // Layout: gap [0,2], a [2,5], gap [5,7] + + // Sliding left by the leading gap's length consumes it + olive::TrackSlideCommand cmd(track, { a }, g1, g2, + olive::core::rational(-2)); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(track->Blocks().at(1), g2); + EXPECT_EQ(a->in(), olive::core::rational(0)); + EXPECT_EQ(a->out(), olive::core::rational(3)); + EXPECT_EQ(g2->length(), olive::core::rational(4)); + EXPECT_EQ(g2->in(), olive::core::rational(3)); + EXPECT_EQ(g2->out(), olive::core::rational(7)); + EXPECT_EQ(g1->track(), nullptr); + EXPECT_EQ(g1->project(), nullptr); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(track->Blocks().at(0), g1); + EXPECT_EQ(track->Blocks().at(1), a); + EXPECT_EQ(track->Blocks().at(2), g2); + EXPECT_EQ(g1->project(), project_.get()); + EXPECT_EQ(g1->in(), olive::core::rational(0)); + EXPECT_EQ(g1->out(), olive::core::rational(2)); + EXPECT_EQ(a->in(), olive::core::rational(2)); + EXPECT_EQ(a->out(), olive::core::rational(5)); + EXPECT_EQ(g2->length(), olive::core::rational(2)); + EXPECT_EQ(g2->in(), olive::core::rational(5)); + EXPECT_EQ(g2->out(), olive::core::rational(7)); +} + +TEST_F(TimelineUndoTest, TrackPlaceBlockCommandAppendsToEmptyTrack) +{ + olive::Sequence *sequence = CreateSequence(project_.get()); + olive::TrackList *list = sequence->track_list(olive::Track::kVideo); + olive::Track *track = CreateTrack(project_.get()); + AppendTrackToList(list, track); + + olive::ClipBlock *p = CreateClip(project_.get(), olive::core::rational(2)); + olive::TrackPlaceBlockCommand cmd(list, 0, p, olive::core::rational(0)); + EXPECT_EQ(cmd.GetRelevantProject(), project_.get()); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 1); + EXPECT_EQ(track->Blocks().at(0), p); + EXPECT_EQ(p->in(), olive::core::rational(0)); + EXPECT_EQ(p->out(), olive::core::rational(2)); + + cmd.undo_now(); + EXPECT_TRUE(track->Blocks().isEmpty()); + EXPECT_EQ(p->track(), nullptr); +} + +TEST_F(TimelineUndoTest, TrackPlaceBlockCommandInsertsGapToReachPoint) +{ + olive::Sequence *sequence = CreateSequence(project_.get()); + olive::TrackList *list = sequence->track_list(olive::Track::kVideo); + olive::Track *track = CreateTrack(project_.get()); + AppendTrackToList(list, track); + + // Placing past the end of the track pads with a gap + olive::ClipBlock *p = CreateClip(project_.get(), olive::core::rational(2)); + olive::TrackPlaceBlockCommand cmd(list, 0, p, olive::core::rational(3)); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 2); + olive::Block *gap = track->Blocks().at(0); + EXPECT_NE(dynamic_cast(gap), nullptr); + EXPECT_EQ(gap->in(), olive::core::rational(0)); + EXPECT_EQ(gap->out(), olive::core::rational(3)); + EXPECT_EQ(track->Blocks().at(1), p); + EXPECT_EQ(p->in(), olive::core::rational(3)); + EXPECT_EQ(p->out(), olive::core::rational(5)); + EXPECT_EQ(track->track_length(), olive::core::rational(5)); + + cmd.undo_now(); + EXPECT_TRUE(track->Blocks().isEmpty()); + EXPECT_EQ(track->track_length(), olive::core::rational(0)); +} + +TEST_F(TimelineUndoTest, TrackPlaceBlockCommandOverwritesMiddle) +{ + 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(2)); + olive::ClipBlock *b = CreateClip(project_.get(), olive::core::rational(3)); + olive::ClipBlock *c = CreateClip(project_.get(), olive::core::rational(1)); + track->AppendBlock(a); + track->AppendBlock(b); + track->AppendBlock(c); + AppendTrackToList(list, track); + // Layout: a [0,2], b [2,5], c [5,6] + + // Placing a two-second block at 2 trims b's head to make room + olive::ClipBlock *p = CreateClip(project_.get(), olive::core::rational(2)); + olive::TrackPlaceBlockCommand cmd(list, 0, p, olive::core::rational(2)); + + cmd.redo_now(); + ASSERT_EQ(track->Blocks().size(), 4); + EXPECT_EQ(track->Blocks().at(0), a); + EXPECT_EQ(track->Blocks().at(1), p); + EXPECT_EQ(p->in(), olive::core::rational(2)); + EXPECT_EQ(p->out(), olive::core::rational(4)); + EXPECT_EQ(track->Blocks().at(2), b); + EXPECT_EQ(b->length(), olive::core::rational(1)); + EXPECT_EQ(b->in(), olive::core::rational(4)); + EXPECT_EQ(b->out(), olive::core::rational(5)); + EXPECT_EQ(track->Blocks().at(3), c); + EXPECT_EQ(c->in(), olive::core::rational(5)); + EXPECT_EQ(c->out(), olive::core::rational(6)); + + cmd.undo_now(); + ASSERT_EQ(track->Blocks().size(), 3); + EXPECT_EQ(p->track(), nullptr); + EXPECT_EQ(track->Blocks().at(1), b); + EXPECT_EQ(b->length(), olive::core::rational(3)); + EXPECT_EQ(b->in(), olive::core::rational(2)); + EXPECT_EQ(b->out(), olive::core::rational(5)); + EXPECT_EQ(c->in(), olive::core::rational(5)); +} + +TEST_F(TimelineUndoTest, TrackPlaceBlockCommandAddsMissingTracks) +{ + olive::Sequence *sequence = CreateSequence(project_.get()); + olive::TrackList *list = sequence->track_list(olive::Track::kVideo); + ASSERT_EQ(list->GetTrackCount(), 0); + + // Placing on track index 1 of an empty list creates both tracks + olive::ClipBlock *p = CreateClip(project_.get(), olive::core::rational(2)); + olive::TrackPlaceBlockCommand cmd(list, 1, p, olive::core::rational(0)); + + cmd.redo_now(); + ASSERT_EQ(list->GetTrackCount(), 2); + olive::Track *placed_track = list->GetTrackAt(1); + ASSERT_NE(placed_track, nullptr); + ASSERT_EQ(placed_track->Blocks().size(), 1); + EXPECT_EQ(placed_track->Blocks().at(0), p); + EXPECT_EQ(p->in(), olive::core::rational(0)); + EXPECT_EQ(p->out(), olive::core::rational(2)); + + cmd.undo_now(); + EXPECT_EQ(p->track(), nullptr); + EXPECT_EQ(list->GetTrackCount(), 0); +} diff --git a/tests/gtest/ui_humanstrings_test.cpp b/tests/gtest/ui_humanstrings_test.cpp new file mode 100644 index 000000000..ac59fd97a --- /dev/null +++ b/tests/gtest/ui_humanstrings_test.cpp @@ -0,0 +1,72 @@ +#include + +#include "ui/humanstrings.h" + +TEST(UIHumanStrings, SampleRateToStringAppendsHz) +{ + EXPECT_EQ(olive::HumanStrings::SampleRateToString(44100), + QStringLiteral("44100 Hz")); + EXPECT_EQ(olive::HumanStrings::SampleRateToString(48000), + QStringLiteral("48000 Hz")); + EXPECT_EQ(olive::HumanStrings::SampleRateToString(0), QStringLiteral("0 Hz")); +} + +TEST(UIHumanStrings, KnownChannelLayoutsHaveNames) +{ + EXPECT_EQ(olive::HumanStrings::ChannelLayoutToString( + olive::kChannelLayoutMono), + QStringLiteral("Mono")); + EXPECT_EQ(olive::HumanStrings::ChannelLayoutToString( + olive::kChannelLayoutStereo), + QStringLiteral("Stereo")); + EXPECT_EQ(olive::HumanStrings::ChannelLayoutToString( + olive::kChannelLayout2_1), + QStringLiteral("2.1")); + EXPECT_EQ(olive::HumanStrings::ChannelLayoutToString( + olive::kChannelLayout5Point1), + QStringLiteral("5.1")); + EXPECT_EQ(olive::HumanStrings::ChannelLayoutToString( + olive::kChannelLayout7Point1), + QStringLiteral("7.1")); +} + +TEST(UIHumanStrings, UnknownChannelLayoutFallsBackToHex) +{ + const QString s = olive::HumanStrings::ChannelLayoutToString(0x1234); + + EXPECT_TRUE(s.startsWith(QStringLiteral("Unknown (0x"))); + EXPECT_TRUE(s.contains(QStringLiteral("1234"))); +} + +TEST(UIHumanStrings, AllSampleFormatsHaveNonEmptyNames) +{ + using olive::core::SampleFormat; + + for (SampleFormat fmt : + { SampleFormat::U8, SampleFormat::S16, SampleFormat::S32, + SampleFormat::S64, SampleFormat::F32, SampleFormat::F64, + SampleFormat::U8P, SampleFormat::S16P, SampleFormat::S32P, + SampleFormat::S64P, SampleFormat::F32P, SampleFormat::F64P }) { + const QString s = olive::HumanStrings::FormatToString(fmt); + EXPECT_FALSE(s.isEmpty()); + EXPECT_FALSE(s.startsWith(QStringLiteral("Unknown"))); + } +} + +TEST(UIHumanStrings, PackedAndPlanarFormatsAreDistinguished) +{ + using olive::core::SampleFormat; + + EXPECT_TRUE(olive::HumanStrings::FormatToString(SampleFormat::F32) + .contains(QStringLiteral("Packed"))); + EXPECT_TRUE(olive::HumanStrings::FormatToString(SampleFormat::F32P) + .contains(QStringLiteral("Planar"))); +} + +TEST(UIHumanStrings, InvalidSampleFormatFallsBackToHex) +{ + const QString s = olive::HumanStrings::FormatToString( + olive::core::SampleFormat::INVALID); + + EXPECT_TRUE(s.startsWith(QStringLiteral("Unknown (0x"))); +} diff --git a/tests/gtest/ui_icons_test.cpp b/tests/gtest/ui_icons_test.cpp new file mode 100644 index 000000000..a96a0c489 --- /dev/null +++ b/tests/gtest/ui_icons_test.cpp @@ -0,0 +1,116 @@ +#include + +#include +#include +#include + +#include "ui/icons/icons.h" + +TEST(UIIcons, ThemeResourcesAreRegistered) +{ + // Both themes compile their icons into the binary via AUTORCC resources + EXPECT_TRUE( + QFile::exists(QStringLiteral(":/style/olive-dark/png/play.16.png"))); + EXPECT_TRUE( + QFile::exists(QStringLiteral(":/style/olive-light/png/play.16.png"))); + EXPECT_TRUE(QFile::exists(QStringLiteral(":/style/olive-dark/palette.ini"))); + EXPECT_TRUE(QFile::exists(QStringLiteral(":/style/olive-dark/style.css"))); +} + +TEST(UIIcons, CreateLoadsAllSizes) +{ + QIcon icon = olive::icon::Create(QStringLiteral(":/style/olive-dark"), + QStringLiteral("play")); + ASSERT_FALSE(icon.isNull()); + + // genicons.sh generates 16/32/64/128 px variants of every icon + const QList sizes = icon.availableSizes(); + for (int sz : { 16, 32, 64, 128 }) { + EXPECT_TRUE(sizes.contains(QSize(sz, sz))) << "Missing size " << sz; + } + + EXPECT_FALSE(icon.pixmap(QSize(32, 32)).isNull()); +} + +TEST(UIIcons, CreateWithUnknownNameIsNull) +{ + QIcon icon = olive::icon::Create(QStringLiteral(":/style/olive-dark"), + QStringLiteral("no-such-icon")); + EXPECT_TRUE(icon.isNull()); + EXPECT_TRUE(icon.availableSizes().isEmpty()); +} + +TEST(UIIcons, LoadAllPopulatesGlobalIcons) +{ + olive::icon::LoadAll(QStringLiteral(":/style/olive-dark")); + + const QVector all = { &olive::icon::GoToStart, + &olive::icon::PrevFrame, + &olive::icon::Play, + &olive::icon::Pause, + &olive::icon::NextFrame, + &olive::icon::GoToEnd, + &olive::icon::New, + &olive::icon::Open, + &olive::icon::Save, + &olive::icon::Undo, + &olive::icon::Redo, + &olive::icon::TreeView, + &olive::icon::ListView, + &olive::icon::IconView, + &olive::icon::ToolPointer, + &olive::icon::ToolEdit, + &olive::icon::ToolRipple, + &olive::icon::ToolRolling, + &olive::icon::ToolRazor, + &olive::icon::ToolSlip, + &olive::icon::ToolSlide, + &olive::icon::ToolHand, + &olive::icon::ToolTransition, + &olive::icon::ToolTrackSelect, + &olive::icon::Folder, + &olive::icon::Sequence, + &olive::icon::Video, + &olive::icon::Audio, + &olive::icon::Image, + &olive::icon::MiniMap, + &olive::icon::TriUp, + &olive::icon::TriLeft, + &olive::icon::TriDown, + &olive::icon::TriRight, + &olive::icon::TextBold, + &olive::icon::TextItalic, + &olive::icon::TextUnderline, + &olive::icon::TextStrikethrough, + &olive::icon::TextSmallCaps, + &olive::icon::TextAlignLeft, + &olive::icon::TextAlignRight, + &olive::icon::TextAlignCenter, + &olive::icon::TextAlignJustify, + &olive::icon::TextAlignTop, + &olive::icon::TextAlignBottom, + &olive::icon::TextAlignMiddle, + &olive::icon::Snapping, + &olive::icon::ZoomIn, + &olive::icon::ZoomOut, + &olive::icon::Record, + &olive::icon::Add, + &olive::icon::Error, + &olive::icon::DirUp, + &olive::icon::Clock, + &olive::icon::Diamond, + &olive::icon::Plus, + &olive::icon::Minus, + &olive::icon::AddEffect, + &olive::icon::EyeOpened, + &olive::icon::EyeClosed, + &olive::icon::LockOpened, + &olive::icon::LockClosed, + &olive::icon::Pencil, + &olive::icon::Subtitles, + &olive::icon::ColorPicker }; + + for (const QIcon *icon : all) { + EXPECT_FALSE(icon->isNull()); + } +} diff --git a/tests/gtest/ui_style_test.cpp b/tests/gtest/ui_style_test.cpp new file mode 100644 index 000000000..e69b262a6 --- /dev/null +++ b/tests/gtest/ui_style_test.cpp @@ -0,0 +1,78 @@ +#include + +#include +#include +#include +#include + +#include "ui/style/style.h" + +TEST(UIStyle, InitPopulatesThemesAndAppliesStyle) +{ + olive::StyleManager::Init(); + + const QMap &themes = olive::StyleManager::available_themes(); + EXPECT_EQ(themes.size(), 2); + EXPECT_EQ(themes.value(QStringLiteral("olive-dark")), + QStringLiteral("Oak Dark")); + EXPECT_EQ(themes.value(QStringLiteral("olive-light")), + QStringLiteral("Oak Light")); + + // Whatever the config says, the current style must be a known theme + EXPECT_TRUE(themes.contains(olive::StyleManager::GetStyle())); + EXPECT_FALSE(qApp->styleSheet().isEmpty()); +} + +TEST(UIStyle, SetStyleAppliesPaletteFromIni) +{ + olive::StyleManager::SetStyle(QStringLiteral("olive-dark")); + EXPECT_EQ(olive::StyleManager::GetStyle(), QStringLiteral("olive-dark")); + + // Values from app/ui/style/olive-dark/palette.ini + const QPalette p = qApp->palette(); + EXPECT_EQ(p.color(QPalette::Window), QColor(QStringLiteral("#353535"))); + EXPECT_EQ(p.color(QPalette::Base), QColor(QStringLiteral("#191919"))); + EXPECT_EQ(p.color(QPalette::Text), QColor(QStringLiteral("#FFFFFF"))); + EXPECT_EQ(p.color(QPalette::Highlight), QColor(QStringLiteral("#2A82DA"))); + EXPECT_EQ(p.color(QPalette::Disabled, QPalette::Text), + QColor(QStringLiteral("#A0A0A0"))); + EXPECT_EQ(p.color(QPalette::Disabled, QPalette::ButtonText), + QColor(QStringLiteral("#808080"))); + + EXPECT_FALSE(qApp->styleSheet().isEmpty()); +} + +TEST(UIStyle, SetStyleSwitchesThemes) +{ + olive::StyleManager::SetStyle(QStringLiteral("olive-dark")); + const QColor dark_window = qApp->palette().color(QPalette::Window); + + olive::StyleManager::SetStyle(QStringLiteral("olive-light")); + EXPECT_EQ(olive::StyleManager::GetStyle(), QStringLiteral("olive-light")); + + // Values from app/ui/style/olive-light/palette.ini + EXPECT_EQ(qApp->palette().color(QPalette::Window), + QColor(QStringLiteral("#D0D0D0"))); + EXPECT_NE(qApp->palette().color(QPalette::Window), dark_window); + + // Restore the default theme for subsequent tests + olive::StyleManager::SetStyle(olive::StyleManager::kDefaultStyle); + EXPECT_EQ(olive::StyleManager::GetStyle(), + QStringLiteral("olive-dark")); +} + +TEST(UIStyle, SetStyleWithMissingThemeClearsOverrides) +{ + olive::StyleManager::SetStyle(QStringLiteral("does-not-exist")); + EXPECT_EQ(olive::StyleManager::GetStyle(), + QStringLiteral("does-not-exist")); + + // No palette.ini/style.css in this theme: fall back to standard palette + // and an empty stylesheet + EXPECT_TRUE(qApp->styleSheet().isEmpty()); + EXPECT_EQ(qApp->palette().color(QPalette::Window), + qApp->style()->standardPalette().color(QPalette::Window)); + + // Restore the default theme for subsequent tests + olive::StyleManager::SetStyle(olive::StyleManager::kDefaultStyle); +}