diff --git a/tests/gtest/CMakeLists.txt b/tests/gtest/CMakeLists.txt index 374a1829d..ab7ebca4b 100644 --- a/tests/gtest/CMakeLists.txt +++ b/tests/gtest/CMakeLists.txt @@ -125,6 +125,9 @@ add_executable(olive-gtest codec_ffmpegencoder_test.cpp ui_icons_test.cpp ui_style_test.cpp + dialog_editing_test.cpp + dialog_misc_test.cpp + dialog_export_test.cpp ) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Test) diff --git a/tests/gtest/dialog_editing_test.cpp b/tests/gtest/dialog_editing_test.cpp new file mode 100644 index 000000000..6f3523b4f --- /dev/null +++ b/tests/gtest/dialog_editing_test.cpp @@ -0,0 +1,834 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core.h" +#include "dialog/footageproperties/footageproperties.h" +#include "dialog/footagerelink/footagerelinkdialog.h" +#include "dialog/keyframeproperties/keyframeproperties.h" +#include "dialog/markerproperties/markerpropertiesdialog.h" +#include "dialog/projectproperties/projectproperties.h" +#include "dialog/sequence/sequence.h" +#include "dialog/sequence/sequencedialogparametertab.h" +#include "dialog/sequence/sequencedialogpresettab.h" +#include "dialog/sequence/sequencepreset.h" +#include "dialog/speedduration/speeddurationdialog.h" +#include "node/block/clip/clip.h" +#include "node/color/colormanager/colormanager.h" +#include "node/math/math/math.h" +#include "node/output/track/track.h" +#include "node/project.h" +#include "node/project/footage/footage.h" +#include "node/project/sequence/sequence.h" +#include "render/diskmanager.h" +#include "timeline/timelinemarker.h" +#include "widget/colorlabelmenu/colorcodingcombobox.h" +#include "widget/slider/floatslider.h" +#include "widget/slider/rationalslider.h" + +namespace +{ + +// Several of these dialogs push undo commands to the global undo stack on +// accept(), which requires the Core singleton (see project_factory_test.cpp) +void EnsureAppSingletons() +{ + if (!olive::Core::instance()) { + new olive::Core(olive::Core::CoreParams()); // intentionally leaked + } + if (!olive::DiskManager::instance()) { + olive::DiskManager::CreateInstance(); + } +} + +void ClearUndoStack() +{ + if (olive::Core::instance()) { + olive::Core::instance()->undo_stack()->clear(); + } +} + +std::unique_ptr CreateProject() +{ + olive::ColorManager::SetUpDefaultConfig(); + + auto project = std::make_unique(); + project->Initialize(); + return project; +} + +// Redirects QStandardPaths (used for the sequence preset file, which +// PresetManager rewrites on destruction) to a disposable test location +class StandardPathsTestModeGuard { +public: + StandardPathsTestModeGuard() + { + QStandardPaths::setTestModeEnabled(true); + } + + ~StandardPathsTestModeGuard() + { + QStandardPaths::setTestModeEnabled(false); + } +}; + +olive::ClipBlock *CreateClip(olive::Project *project, + const olive::rational &length) +{ + auto *clip = new olive::ClipBlock(); + clip->setParent(project); + clip->set_length_and_media_out(length); + return clip; +} + +olive::Track *CreateTrackWithClip(olive::Project *project, + olive::ClipBlock *clip) +{ + auto *track = new olive::Track(); + track->setParent(project); + track->AppendBlock(clip); + return track; +} + +} // namespace + +// +// speedduration +// +TEST(DialogSpeedDuration, InitialValuesReflectSingleClip) +{ + EnsureAppSingletons(); + auto project = CreateProject(); + auto *clip = CreateClip(project.get(), olive::rational(4)); + CreateTrackWithClip(project.get(), clip); + + olive::SpeedDurationDialog dialog({ clip }, olive::rational(1, 24)); + + auto *speed_slider = dialog.findChild(); + auto *dur_slider = dialog.findChild(); + ASSERT_NE(speed_slider, nullptr); + ASSERT_NE(dur_slider, nullptr); + + EXPECT_DOUBLE_EQ(speed_slider->GetValue(), 1.0); + EXPECT_EQ(dur_slider->GetValue(), olive::rational(4)); + EXPECT_FALSE(speed_slider->IsTristate()); + EXPECT_FALSE(dur_slider->IsTristate()); +} + +TEST(DialogSpeedDuration, LinkedSpeedChangeUpdatesDuration) +{ + EnsureAppSingletons(); + auto project = CreateProject(); + auto *clip = CreateClip(project.get(), olive::rational(4)); + CreateTrackWithClip(project.get(), clip); + + olive::SpeedDurationDialog dialog({ clip }, olive::rational(1, 24)); + + auto *speed_slider = dialog.findChild(); + auto *dur_slider = dialog.findChild(); + + // Programmatic SetValue() does not emit ValueChanged (only user edits + // do), so emit the signal explicitly to drive the linked update + speed_slider->SetValue(2.0); + emit speed_slider->ValueChanged(2.0); + EXPECT_EQ(dur_slider->GetValue(), olive::rational(2)); + + speed_slider->SetValue(0.5); + emit speed_slider->ValueChanged(0.5); + EXPECT_EQ(dur_slider->GetValue(), olive::rational(8)); +} + +TEST(DialogSpeedDuration, LinkedDurationChangeUpdatesSpeed) +{ + EnsureAppSingletons(); + auto project = CreateProject(); + auto *clip = CreateClip(project.get(), olive::rational(4)); + CreateTrackWithClip(project.get(), clip); + + olive::SpeedDurationDialog dialog({ clip }, olive::rational(1, 24)); + + auto *speed_slider = dialog.findChild(); + auto *dur_slider = dialog.findChild(); + + // Programmatic SetValue() does not emit ValueChanged (only user edits + // do), so emit the signal explicitly to drive the linked update + dur_slider->SetValue(olive::rational(2)); + emit dur_slider->ValueChanged(olive::rational(2)); + EXPECT_DOUBLE_EQ(speed_slider->GetValue(), 2.0); + + dur_slider->SetValue(olive::rational(16)); + emit dur_slider->ValueChanged(olive::rational(16)); + EXPECT_DOUBLE_EQ(speed_slider->GetValue(), 0.25); +} + +TEST(DialogSpeedDuration, AcceptAppliesSpeedAndLength) +{ + EnsureAppSingletons(); + auto project = CreateProject(); + auto *clip = CreateClip(project.get(), olive::rational(4)); + CreateTrackWithClip(project.get(), clip); + + { + olive::SpeedDurationDialog dialog({ clip }, olive::rational(1, 24)); + + // Doubling the speed with the link checked halves the duration + auto *speed_slider = dialog.findChild(); + speed_slider->SetValue(2.0); + emit speed_slider->ValueChanged(2.0); + + dialog.accept(); + } + + EXPECT_DOUBLE_EQ(clip->speed(), 2.0); + EXPECT_EQ(clip->length(), olive::rational(2)); + + ClearUndoStack(); +} + +TEST(DialogSpeedDuration, DifferingSpeedsAcrossClipsProduceTristate) +{ + EnsureAppSingletons(); + auto project = CreateProject(); + auto *clip_a = CreateClip(project.get(), olive::rational(4)); + auto *clip_b = CreateClip(project.get(), olive::rational(4)); + CreateTrackWithClip(project.get(), clip_a); + clip_b->SetStandardValue(olive::ClipBlock::kSpeedInput, 2.0); + CreateTrackWithClip(project.get(), clip_b); + + olive::SpeedDurationDialog dialog({ clip_a, clip_b }, + olive::rational(1, 24)); + + EXPECT_TRUE(dialog.findChild()->IsTristate()); + // Durations are identical, so the duration slider must not be tristate + EXPECT_FALSE(dialog.findChild()->IsTristate()); +} + +TEST(DialogSpeedDuration, AcceptDerivesPerClipSpeedFromDuration) +{ + EnsureAppSingletons(); + auto project = CreateProject(); + auto *clip_a = CreateClip(project.get(), olive::rational(4)); + auto *clip_b = CreateClip(project.get(), olive::rational(4)); + CreateTrackWithClip(project.get(), clip_a); + clip_b->SetStandardValue(olive::ClipBlock::kSpeedInput, 2.0); + CreateTrackWithClip(project.get(), clip_b); + + { + olive::SpeedDurationDialog dialog({ clip_a, clip_b }, + olive::rational(1, 24)); + // Speed is tristate, so accept() must compute each clip's speed + // from its own length/speed ratio: speed = old_speed * old_len / new_len + dialog.findChild()->SetValue( + olive::rational(2)); + dialog.accept(); + } + + EXPECT_EQ(clip_a->length(), olive::rational(2)); + EXPECT_EQ(clip_b->length(), olive::rational(2)); + EXPECT_DOUBLE_EQ(clip_a->speed(), 2.0); + EXPECT_DOUBLE_EQ(clip_b->speed(), 4.0); + + ClearUndoStack(); +} + +// +// keyframeproperties +// +TEST(DialogKeyframeProperties, SingleKeyAcceptWritesAllFields) +{ + EnsureAppSingletons(); + auto project = CreateProject(); + + auto *node = new olive::MathNode(); + node->setParent(project.get()); + auto *key = new olive::NodeKeyframe(olive::rational(0), 1.0, + olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + key->setParent(node); + + // The dialog stores the keyframe vector by reference, so it must outlive it + std::vector keys = { key }; + + { + olive::KeyframePropertiesDialog dialog(keys, olive::rational(1, 24)); + + auto *time_slider = dialog.findChild(); + auto *type_select = dialog.findChild(); + auto *bezier_group = dialog.findChild(); + ASSERT_NE(time_slider, nullptr); + ASSERT_NE(type_select, nullptr); + ASSERT_NE(bezier_group, nullptr); + + // Initial state reflects the keyframe + EXPECT_TRUE(time_slider->isEnabled()); + EXPECT_EQ(time_slider->GetValue(), olive::rational(0)); + ASSERT_EQ(type_select->count(), 3); + EXPECT_EQ(type_select->currentData().toInt(), olive::NodeKeyframe::kLinear); + EXPECT_FALSE(bezier_group->isEnabled()); + + // Switching to Bezier enables the bezier handle editors + type_select->setCurrentIndex(2); + ASSERT_EQ(type_select->currentData().toInt(), + olive::NodeKeyframe::kBezier); + EXPECT_TRUE(bezier_group->isEnabled()); + + time_slider->SetValue(olive::rational(1, 2)); + + const QList sliders = + dialog.findChildren(); + ASSERT_EQ(sliders.size(), 4); + sliders.at(0)->SetValue(0.1); // bezier in x + sliders.at(1)->SetValue(0.2); // bezier in y + sliders.at(2)->SetValue(0.3); // bezier out x + sliders.at(3)->SetValue(0.4); // bezier out y + + dialog.accept(); + } + + EXPECT_EQ(key->time(), olive::rational(1, 2)); + EXPECT_EQ(key->type(), olive::NodeKeyframe::kBezier); + EXPECT_DOUBLE_EQ(key->bezier_control_in().x(), 0.1); + EXPECT_DOUBLE_EQ(key->bezier_control_in().y(), 0.2); + EXPECT_DOUBLE_EQ(key->bezier_control_out().x(), 0.3); + EXPECT_DOUBLE_EQ(key->bezier_control_out().y(), 0.4); + + ClearUndoStack(); +} + +TEST(DialogKeyframeProperties, MixedTypesAddPlaceholderItem) +{ + EnsureAppSingletons(); + auto project = CreateProject(); + + auto *node = new olive::MathNode(); + node->setParent(project.get()); + auto *key_a = new olive::NodeKeyframe(olive::rational(0), 1.0, + olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + auto *key_b = new olive::NodeKeyframe(olive::rational(1), 2.0, + olive::NodeKeyframe::kHold, 0, -1, + olive::MathNode::kParamAIn); + key_a->setParent(node); + key_b->setParent(node); + + // The dialog stores the keyframe vector by reference, so it must outlive it + std::vector keys = { key_a, key_b }; + + { + olive::KeyframePropertiesDialog dialog(keys, olive::rational(1, 24)); + + auto *type_select = dialog.findChild(); + // An "--" placeholder item with data -1 is prepended for mixed types + ASSERT_EQ(type_select->count(), 4); + EXPECT_EQ(type_select->itemData(0).toInt(), -1); + EXPECT_EQ(type_select->currentIndex(), 0); + + dialog.accept(); + } + + // Accepting with the placeholder selected must not change key types + EXPECT_EQ(key_a->type(), olive::NodeKeyframe::kLinear); + EXPECT_EQ(key_b->type(), olive::NodeKeyframe::kHold); + + ClearUndoStack(); +} + +TEST(DialogKeyframeProperties, KeysOnSameTrackDisableTimeEdit) +{ + EnsureAppSingletons(); + auto project = CreateProject(); + + auto *node = new olive::MathNode(); + node->setParent(project.get()); + auto *key_a = new olive::NodeKeyframe(olive::rational(0), 1.0, + olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + auto *key_b = new olive::NodeKeyframe(olive::rational(1), 2.0, + olive::NodeKeyframe::kLinear, 0, -1, + olive::MathNode::kParamAIn); + key_a->setParent(node); + key_b->setParent(node); + + // The dialog stores the keyframe vector by reference, so it must outlive it + std::vector keys = { key_a, key_b }; + + olive::KeyframePropertiesDialog dialog(keys, olive::rational(1, 24)); + + // Moving two keys of the same track in time could reorder them, so the + // time editor must be disabled + EXPECT_FALSE(dialog.findChild()->isEnabled()); +} + +// +// markerproperties +// +TEST(DialogMarkerProperties, SingleMarkerAcceptWritesFields) +{ + EnsureAppSingletons(); + + olive::TimelineMarker marker( + 3, + olive::TimeRange(olive::rational(1), olive::rational(2)), + QStringLiteral("Marker A")); + + { + olive::MarkerPropertiesDialog dialog({ &marker }, + olive::rational(1, 24)); + + auto *label_edit = dialog.findChild(); + auto *color_menu = dialog.findChild(); + const QList sliders = + dialog.findChildren(); + ASSERT_NE(label_edit, nullptr); + ASSERT_NE(color_menu, nullptr); + ASSERT_EQ(sliders.size(), 2); + + EXPECT_EQ(label_edit->text(), QStringLiteral("Marker A")); + EXPECT_EQ(color_menu->GetSelectedColor(), 3); + EXPECT_EQ(sliders.at(0)->GetValue(), olive::rational(1)); + EXPECT_EQ(sliders.at(1)->GetValue(), olive::rational(2)); + + label_edit->setText(QStringLiteral("Renamed")); + sliders.at(0)->SetValue(olive::rational(1, 2)); + sliders.at(1)->SetValue(olive::rational(3, 2)); + + dialog.accept(); + } + + EXPECT_EQ(marker.name(), QStringLiteral("Renamed")); + EXPECT_EQ(marker.time().in(), olive::rational(1, 2)); + EXPECT_EQ(marker.time().out(), olive::rational(3, 2)); + EXPECT_EQ(marker.color(), 3); + + ClearUndoStack(); +} + +TEST(DialogMarkerProperties, MultipleMarkersDisableTimeAndShowPlaceholder) +{ + EnsureAppSingletons(); + + olive::TimelineMarker marker_a( + 1, + olive::TimeRange(olive::rational(1), olive::rational(2)), + QStringLiteral("Alpha")); + olive::TimelineMarker marker_b( + 2, + olive::TimeRange(olive::rational(5), olive::rational(6)), + QStringLiteral("Beta")); + + { + olive::MarkerPropertiesDialog dialog({ &marker_a, &marker_b }, + olive::rational(1, 24)); + + auto *label_edit = dialog.findChild(); + auto *color_menu = dialog.findChild(); + const QList sliders = + dialog.findChildren(); + + // Time cannot be edited for multiple markers + EXPECT_FALSE(sliders.at(0)->isEnabled()); + EXPECT_TRUE(sliders.at(0)->IsTristate()); + EXPECT_FALSE(sliders.at(1)->isEnabled()); + EXPECT_TRUE(sliders.at(1)->IsTristate()); + + // Differing names show a placeholder instead of text + EXPECT_TRUE(label_edit->text().isEmpty()); + EXPECT_FALSE(label_edit->placeholderText().isEmpty()); + + // Differing colors are represented by -1 + EXPECT_EQ(color_menu->GetSelectedColor(), -1); + + dialog.accept(); + } + + // Nothing should have been written back + EXPECT_EQ(marker_a.name(), QStringLiteral("Alpha")); + EXPECT_EQ(marker_b.name(), QStringLiteral("Beta")); + EXPECT_EQ(marker_a.color(), 1); + EXPECT_EQ(marker_b.color(), 2); + + ClearUndoStack(); +} + +// +// sequence +// +TEST(DialogSequencePreset, SaveLoadRoundTrip) +{ + olive::SequencePreset preset(QStringLiteral("Test Preset"), 1920, 1080, + olive::rational(24, 1), olive::rational(1, 1), + olive::VideoParams::kInterlacedTopFirst, 48000, + olive::core::kChannelLayoutStereo, 2, + olive::PixelFormat::F16, true); + + QByteArray xml; + QBuffer buffer(&xml); + buffer.open(QIODevice::WriteOnly); + QXmlStreamWriter writer(&buffer); + writer.writeStartDocument(); + writer.writeStartElement(QStringLiteral("preset")); + preset.Save(&writer); + writer.writeEndElement(); + writer.writeEndDocument(); + buffer.close(); + + olive::SequencePreset loaded; + QBuffer read_buffer(&xml); + read_buffer.open(QIODevice::ReadOnly); + QXmlStreamReader reader(&read_buffer); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name().toString(), QStringLiteral("preset")); + loaded.Load(&reader); + + EXPECT_EQ(loaded.GetName(), QStringLiteral("Test Preset")); + EXPECT_EQ(loaded.width(), 1920); + EXPECT_EQ(loaded.height(), 1080); + EXPECT_EQ(loaded.frame_rate(), olive::rational(24, 1)); + EXPECT_EQ(loaded.pixel_aspect(), olive::rational(1, 1)); + EXPECT_EQ(loaded.interlacing(), olive::VideoParams::kInterlacedTopFirst); + EXPECT_EQ(loaded.sample_rate(), 48000); + EXPECT_EQ(loaded.channel_layout(), olive::core::kChannelLayoutStereo); + EXPECT_EQ(loaded.preview_divider(), 2); + EXPECT_EQ(loaded.preview_format(), olive::PixelFormat::F16); + EXPECT_TRUE(loaded.preview_autocache()); +} + +TEST(DialogSequencePreset, LoadsLegacyInterlacingElement) +{ + // Older builds wrote the interlacing element as "interlacing_"; such + // preset files must still load + QByteArray xml = R"( +Legacy +1280 +720 +24/1 +1/1 +2 +44100 +3 +1 +4 +0 +)"; + + olive::SequencePreset loaded; + QBuffer read_buffer(&xml); + read_buffer.open(QIODevice::ReadOnly); + QXmlStreamReader reader(&read_buffer); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name().toString(), QStringLiteral("preset")); + loaded.Load(&reader); + + EXPECT_EQ(loaded.GetName(), QStringLiteral("Legacy")); + EXPECT_EQ(loaded.width(), 1280); + EXPECT_EQ(loaded.interlacing(), + static_cast(2)); +} + +TEST(DialogSequenceParameterTab, ReflectsSequenceParameters) +{ + auto project = CreateProject(); + + auto *sequence = new olive::Sequence(); + sequence->setParent(project.get()); + sequence->SetVideoParams(olive::VideoParams( + 1920, 1080, olive::rational(1001, 30000), olive::PixelFormat::F32, + olive::VideoParams::kInternalChannelCount, olive::rational(1, 1), + olive::VideoParams::kInterlaceNone, 2)); + sequence->SetAudioParams(olive::AudioParams( + 48000, olive::core::kChannelLayoutStereo, + olive::Sequence::kDefaultSampleFormat)); + + olive::SequenceDialogParameterTab tab(sequence); + + EXPECT_EQ(tab.GetSelectedVideoWidth(), 1920); + EXPECT_EQ(tab.GetSelectedVideoHeight(), 1080); + EXPECT_EQ(tab.GetSelectedVideoFrameRate(), olive::rational(30000, 1001)); + EXPECT_EQ(tab.GetSelectedVideoPixelAspect(), olive::rational(1, 1)); + EXPECT_EQ(tab.GetSelectedVideoInterlacingMode(), + olive::VideoParams::kInterlaceNone); + EXPECT_EQ(tab.GetSelectedPreviewResolution(), 2); + EXPECT_EQ(tab.GetSelectedPreviewFormat(), olive::PixelFormat::F32); + EXPECT_EQ(tab.GetSelectedAudioSampleRate(), 48000); + EXPECT_EQ(tab.GetSelectedAudioChannelLayout(), + olive::core::kChannelLayoutStereo); +} + +TEST(DialogSequenceParameterTab, PresetChangedAppliesValues) +{ + auto project = CreateProject(); + + auto *sequence = new olive::Sequence(); + sequence->setParent(project.get()); + sequence->SetVideoParams(olive::VideoParams( + 1920, 1080, olive::rational(1, 24), olive::PixelFormat::F32, + olive::VideoParams::kInternalChannelCount, olive::rational(1, 1), + olive::VideoParams::kInterlaceNone, 1)); + sequence->SetAudioParams(olive::AudioParams( + 48000, olive::core::kChannelLayoutStereo, + olive::Sequence::kDefaultSampleFormat)); + + olive::SequenceDialogParameterTab tab(sequence); + + tab.PresetChanged(olive::SequencePreset( + QStringLiteral("Preset"), 1280, 720, olive::rational(24, 1), + olive::rational(1, 1), olive::VideoParams::kInterlacedTopFirst, 44100, + olive::core::kChannelLayoutStereo, 4, olive::PixelFormat::F16, false)); + + EXPECT_EQ(tab.GetSelectedVideoWidth(), 1280); + EXPECT_EQ(tab.GetSelectedVideoHeight(), 720); + EXPECT_EQ(tab.GetSelectedVideoFrameRate(), olive::rational(24, 1)); + EXPECT_EQ(tab.GetSelectedVideoInterlacingMode(), + olive::VideoParams::kInterlacedTopFirst); + EXPECT_EQ(tab.GetSelectedAudioSampleRate(), 44100); + EXPECT_EQ(tab.GetSelectedPreviewResolution(), 4); + EXPECT_EQ(tab.GetSelectedPreviewFormat(), olive::PixelFormat::F16); +} + +TEST(DialogSequenceDialog, AcceptNonUndoableAppliesParameters) +{ + StandardPathsTestModeGuard test_mode; + EnsureAppSingletons(); + auto project = CreateProject(); + + auto *sequence = new olive::Sequence(); + sequence->setParent(project.get()); + sequence->SetLabel(QStringLiteral("Seq A")); + sequence->SetVideoParams(olive::VideoParams( + 1920, 1080, olive::rational(1, 24), olive::PixelFormat::F32, + olive::VideoParams::kInternalChannelCount, olive::rational(1, 1), + olive::VideoParams::kInterlaceNone, 1)); + sequence->SetAudioParams(olive::AudioParams( + 48000, olive::core::kChannelLayoutStereo, + olive::Sequence::kDefaultSampleFormat)); + + { + olive::SequenceDialog dialog(sequence, olive::SequenceDialog::kExisting); + dialog.SetUndoable(false); + + auto *tab = dialog.findChild(); + ASSERT_NE(tab, nullptr); + tab->PresetChanged(olive::SequencePreset( + QStringLiteral("Preset"), 1280, 720, olive::rational(24, 1), + olive::rational(1, 1), olive::VideoParams::kInterlacedTopFirst, + 44100, olive::core::kChannelLayoutStereo, 4, + olive::PixelFormat::F32, false)); + + auto *name_field = dialog.findChild(); + ASSERT_NE(name_field, nullptr); + name_field->setText(QStringLiteral("Seq B")); + + dialog.accept(); + EXPECT_EQ(dialog.result(), QDialog::Accepted); + } + + EXPECT_EQ(sequence->GetLabel(), QStringLiteral("Seq B")); + EXPECT_EQ(sequence->GetVideoParams().width(), 1280); + EXPECT_EQ(sequence->GetVideoParams().height(), 720); + EXPECT_EQ(sequence->GetVideoParams().frame_rate(), olive::rational(24, 1)); + EXPECT_EQ(sequence->GetVideoParams().interlacing(), + olive::VideoParams::kInterlacedTopFirst); + EXPECT_EQ(sequence->GetVideoParams().divider(), 4); + EXPECT_EQ(sequence->GetAudioParams().sample_rate(), 44100); +} + +TEST(DialogSequenceDialog, AcceptUndoablePushesCommand) +{ + StandardPathsTestModeGuard test_mode; + EnsureAppSingletons(); + auto project = CreateProject(); + + auto *sequence = new olive::Sequence(); + sequence->setParent(project.get()); + sequence->SetLabel(QStringLiteral("Seq A")); + sequence->SetVideoParams(olive::VideoParams( + 1920, 1080, olive::rational(1, 24), olive::PixelFormat::F32, + olive::VideoParams::kInternalChannelCount, olive::rational(1, 1), + olive::VideoParams::kInterlaceNone, 1)); + sequence->SetAudioParams(olive::AudioParams( + 48000, olive::core::kChannelLayoutStereo, + olive::Sequence::kDefaultSampleFormat)); + + { + olive::SequenceDialog dialog(sequence, olive::SequenceDialog::kExisting); + + auto *tab = dialog.findChild(); + ASSERT_NE(tab, nullptr); + tab->PresetChanged(olive::SequencePreset( + QStringLiteral("Preset"), 640, 360, olive::rational(24, 1), + olive::rational(1, 1), olive::VideoParams::kInterlaceNone, 48000, + olive::core::kChannelLayoutStereo, 1, olive::PixelFormat::F32, + false)); + + dialog.accept(); + EXPECT_EQ(dialog.result(), QDialog::Accepted); + } + + EXPECT_EQ(sequence->GetVideoParams().width(), 640); + EXPECT_EQ(sequence->GetVideoParams().height(), 360); + + ClearUndoStack(); +} + +TEST(DialogSequenceDialog, PresetTabListsDefaultPresets) +{ + StandardPathsTestModeGuard test_mode; + EnsureAppSingletons(); + auto project = CreateProject(); + + auto *sequence = new olive::Sequence(); + sequence->setParent(project.get()); + + olive::SequenceDialog dialog(sequence, olive::SequenceDialog::kExisting); + + auto *tree = dialog.findChild(); + ASSERT_NE(tree, nullptr); + // "My Presets" plus the standard preset folders + EXPECT_GE(tree->topLevelItemCount(), 4); +} + +// +// footageproperties +// +TEST(DialogFootageProperties, AcceptRenamesAndSetsSourceStartTime) +{ + EnsureAppSingletons(); + auto project = CreateProject(); + + auto *footage = new olive::Footage(); + footage->setParent(project.get()); + footage->SetLabel(QStringLiteral("Clip A")); + footage->set_filename(QStringLiteral("/tmp/oak-nonexistent.mp4")); + + { + olive::FootagePropertiesDialog dialog(nullptr, footage); + + auto *name_field = dialog.findChild(); + auto *start_enable = dialog.findChild(); + auto *start_spin = dialog.findChild(); + ASSERT_NE(name_field, nullptr); + ASSERT_NE(start_enable, nullptr); + ASSERT_NE(start_spin, nullptr); + + EXPECT_EQ(name_field->text(), QStringLiteral("Clip A")); + EXPECT_FALSE(start_enable->isChecked()); + EXPECT_FALSE(start_spin->isEnabled()); + + name_field->setText(QStringLiteral("Clip B")); + start_enable->setChecked(true); + EXPECT_TRUE(start_spin->isEnabled()); + start_spin->setValue(12.5); + + // accept() is a private slot, invoke it through the meta-object + QMetaObject::invokeMethod(&dialog, "accept"); + } + + EXPECT_EQ(footage->GetLabel(), QStringLiteral("Clip B")); + EXPECT_TRUE(footage->HasSourceStartTime()); + EXPECT_EQ(footage->source_start_time(), olive::rational(25, 2)); + EXPECT_EQ(footage->source_start_time_source(), QStringLiteral("manual")); + + { + // Unchecking the box must clear the source start time again + olive::FootagePropertiesDialog dialog(nullptr, footage); + + auto *start_enable = dialog.findChild(); + ASSERT_NE(start_enable, nullptr); + EXPECT_TRUE(start_enable->isChecked()); + start_enable->setChecked(false); + + // accept() is a private slot, invoke it through the meta-object + QMetaObject::invokeMethod(&dialog, "accept"); + } + + EXPECT_FALSE(footage->HasSourceStartTime()); + + ClearUndoStack(); +} + +// +// footagerelink +// +TEST(DialogFootageRelink, TableListsFootageAndFilenames) +{ + auto project = CreateProject(); + + auto *footage_a = new olive::Footage(); + footage_a->setParent(project.get()); + footage_a->SetLabel(QStringLiteral("Footage A")); + footage_a->set_filename(QStringLiteral("/old/path/a.mp4")); + + auto *footage_b = new olive::Footage(); + footage_b->setParent(project.get()); + footage_b->SetLabel(QStringLiteral("Footage B")); + footage_b->set_filename(QStringLiteral("/old/path/b.mp4")); + + olive::FootageRelinkDialog dialog({ footage_a, footage_b }); + + auto *table = dialog.findChild(); + ASSERT_NE(table, nullptr); + ASSERT_EQ(table->topLevelItemCount(), 2); + EXPECT_EQ(table->topLevelItem(0)->text(0), QStringLiteral("Footage A")); + EXPECT_EQ(table->topLevelItem(0)->text(1), + QStringLiteral("/old/path/a.mp4")); + EXPECT_EQ(table->topLevelItem(1)->text(0), QStringLiteral("Footage B")); + EXPECT_EQ(table->topLevelItem(1)->text(1), + QStringLiteral("/old/path/b.mp4")); +} + +// +// projectproperties +// +TEST(DialogProjectProperties, OcioValidationTogglesOnInvalidFilename) +{ + EnsureAppSingletons(); + auto project = CreateProject(); + + olive::ProjectPropertiesDialog dialog(project.get(), nullptr); + + EXPECT_TRUE(dialog.windowTitle().contains(project->name())); + + // The first QLineEdit in the dialog is the OCIO config filename + auto *ocio_edit = dialog.findChild(); + ASSERT_NE(ocio_edit, nullptr); + + // A bad config path flags the line edit as invalid (red text) + ocio_edit->setText(QStringLiteral("/definitely/not/a/config.ocio")); + EXPECT_TRUE(ocio_edit->styleSheet().contains(QStringLiteral("red"))); + + // Restoring an empty (default) filename clears the error again + ocio_edit->setText(QString()); + EXPECT_TRUE(ocio_edit->styleSheet().isEmpty()); + + // The default config provides selectable input color spaces + bool found_populated_combo = false; + foreach (QComboBox *combo, dialog.findChildren()) { + if (combo->count() > 2) { + found_populated_combo = true; + break; + } + } + EXPECT_TRUE(found_populated_combo); +} + +TEST(DialogProjectProperties, AcceptWithDefaultsClosesDialog) +{ + EnsureAppSingletons(); + auto project = CreateProject(); + + olive::ProjectPropertiesDialog dialog(project.get(), nullptr); + dialog.accept(); + + EXPECT_EQ(dialog.result(), QDialog::Accepted); +} diff --git a/tests/gtest/dialog_export_test.cpp b/tests/gtest/dialog_export_test.cpp new file mode 100644 index 000000000..c835e632f --- /dev/null +++ b/tests/gtest/dialog_export_test.cpp @@ -0,0 +1,336 @@ +#include + +#include +#include +#include +#include +#include +#include + +#include "codec/encoder.h" +#include "dialog/export/codec/h264section.h" +#include "dialog/export/codec/imagesection.h" +#include "dialog/export/exportadvancedvideodialog.h" +#include "dialog/export/exportaudiotab.h" +#include "dialog/export/exportformatcombobox.h" +#include "dialog/export/exportsavepresetdialog.h" +#include "dialog/export/exportsubtitlestab.h" +#include "dialog/export/exportvideotab.h" +#include "node/color/colormanager/colormanager.h" +#include "node/project.h" + +namespace +{ + +// Redirects QStandardPaths (used for the export preset directory) to a +// disposable test location for the lifetime of the guard +class StandardPathsTestModeGuard { +public: + StandardPathsTestModeGuard() + { + QStandardPaths::setTestModeEnabled(true); + } + + ~StandardPathsTestModeGuard() + { + QStandardPaths::setTestModeEnabled(false); + } +}; + +QList MenuFormatData(const olive::ExportFormatComboBox &combo) +{ + QList formats; + // custom_menu_ is an olive::Menu (a QMenu without its own Q_OBJECT) + auto *menu = combo.findChild(); + if (!menu) { + return formats; + } + foreach (QAction *a, menu->actions()) { + if (!a->isSeparator() && a->data().isValid()) { + formats.append(a->data().toInt()); + } + } + return formats; +} + +} // namespace + +// +// export: format combobox +// +TEST(DialogExportFormatComboBox, GetSetFormatRoundTrip) +{ + olive::ExportFormatComboBox combo; + + // Before any selection the format is the invalid placeholder + EXPECT_EQ(combo.GetFormat(), olive::ExportFormat::kFormatCount); + + combo.SetFormat(olive::ExportFormat::kFormatMatroska); + EXPECT_EQ(combo.GetFormat(), olive::ExportFormat::kFormatMatroska); + EXPECT_EQ(combo.currentText(), + olive::ExportFormat::GetName(olive::ExportFormat::kFormatMatroska)); +} + +TEST(DialogExportFormatComboBox, MenuSelectionEmitsFormatChanged) +{ + olive::ExportFormatComboBox combo; + QSignalSpy spy(&combo, &olive::ExportFormatComboBox::FormatChanged); + + QAction action(QStringLiteral("QuickTime"), &combo); + action.setData(static_cast(olive::ExportFormat::kFormatQuickTime)); + + QMetaObject::invokeMethod(&combo, "HandleIndexChange", + Q_ARG(QAction *, &action)); + + EXPECT_EQ(combo.GetFormat(), olive::ExportFormat::kFormatQuickTime); + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.first().first().toInt(), + static_cast(olive::ExportFormat::kFormatQuickTime)); +} + +TEST(DialogExportFormatComboBox, AudioOnlyModeListsOnlyAudioFormats) +{ + olive::ExportFormatComboBox combo( + olive::ExportFormatComboBox::kShowAudioOnly); + + const QList formats = MenuFormatData(combo); + EXPECT_FALSE(formats.isEmpty()); + + foreach (int f, formats) { + const auto fmt = static_cast(f); + EXPECT_TRUE(olive::ExportFormat::GetVideoCodecs(fmt).isEmpty()) + << "Format " << f << " should not have video codecs"; + EXPECT_FALSE(olive::ExportFormat::GetAudioCodecs(fmt).isEmpty()) + << "Format " << f << " should have audio codecs"; + } + + EXPECT_TRUE(formats.contains( + static_cast(olive::ExportFormat::kFormatWAV))); + EXPECT_FALSE(formats.contains( + static_cast(olive::ExportFormat::kFormatMatroska))); +} + +// +// export: audio tab +// +TEST(DialogExportAudioTab, SetFormatPopulatesCodecs) +{ + olive::ExportAudioTab tab; + + const QList codecs = + olive::ExportFormat::GetAudioCodecs(olive::ExportFormat::kFormatMatroska); + ASSERT_FALSE(codecs.isEmpty()); + + EXPECT_EQ(tab.SetFormat(olive::ExportFormat::kFormatMatroska), + codecs.size()); + + // The first codec is auto-selected + EXPECT_EQ(tab.GetCodec(), codecs.first()); +} + +TEST(DialogExportAudioTab, LosslessCodecDisablesBitRate) +{ + olive::ExportAudioTab tab; + tab.SetFormat(olive::ExportFormat::kFormatMatroska); + + tab.SetCodec(olive::ExportCodec::kCodecAAC); + EXPECT_TRUE(tab.bit_rate_slider()->isEnabled()); + EXPECT_EQ(tab.GetCodec(), olive::ExportCodec::kCodecAAC); + + // PCM is lossless, so no bit rate setting applies + tab.SetCodec(olive::ExportCodec::kCodecPCM); + EXPECT_EQ(tab.GetCodec(), olive::ExportCodec::kCodecPCM); + EXPECT_FALSE(tab.bit_rate_slider()->isEnabled()); + EXPECT_TRUE(tab.bit_rate_slider()->IsTristate()); +} + +TEST(DialogExportAudioTab, FormatWithoutAudioCodecsDisablesTab) +{ + olive::ExportAudioTab tab; + + // PNG carries no audio + EXPECT_EQ(tab.SetFormat(olive::ExportFormat::kFormatPNG), 0); + EXPECT_FALSE(tab.isEnabled()); +} + +// +// export: subtitles tab +// +TEST(DialogExportSubtitlesTab, SidecarStateFollowsFormatCapabilities) +{ + olive::ExportSubtitlesTab tab; + tab.SetSidecarFormat(olive::ExportFormat::kFormatSRT); + + auto *sidecar_box = tab.findChild(); + ASSERT_NE(sidecar_box, nullptr); + + // Matroska can embed subtitles: sidecar is optional and off by default + tab.SetFormat(olive::ExportFormat::kFormatMatroska); + EXPECT_TRUE(sidecar_box->isEnabled()); + EXPECT_FALSE(tab.GetSidecarEnabled()); + EXPECT_EQ(tab.GetSubtitleCodec(), olive::ExportCodec::kCodecSRT); + + // SetSidecarEnabled toggles the check state (used to restore params) + tab.SetSidecarEnabled(true); + EXPECT_TRUE(tab.GetSidecarEnabled()); + tab.SetSidecarEnabled(false); + EXPECT_FALSE(tab.GetSidecarEnabled()); + + // SRT is a subtitles-only format: sidecar makes no sense, forced off + tab.SetFormat(olive::ExportFormat::kFormatSRT); + EXPECT_FALSE(sidecar_box->isEnabled()); + EXPECT_FALSE(tab.GetSidecarEnabled()); + + // WAV cannot carry subtitles at all: sidecar is forced on + tab.SetFormat(olive::ExportFormat::kFormatWAV); + EXPECT_FALSE(sidecar_box->isEnabled()); + EXPECT_TRUE(tab.GetSidecarEnabled()); +} + +// +// export: video tab +// +TEST(DialogExportVideoTab, SetFormatPopulatesCodecs) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + + olive::ExportVideoTab tab(project.color_manager()); + + const QList codecs = + olive::ExportFormat::GetVideoCodecs(olive::ExportFormat::kFormatMatroska); + ASSERT_FALSE(codecs.isEmpty()); + + EXPECT_EQ(tab.SetFormat(olive::ExportFormat::kFormatMatroska), + codecs.size()); + EXPECT_EQ(tab.GetSelectedCodec(), codecs.first()); + + tab.SetSelectedCodec(olive::ExportCodec::kCodecH265); + EXPECT_EQ(tab.GetSelectedCodec(), olive::ExportCodec::kCodecH265); +} + +TEST(DialogExportVideoTab, CodecSelectsMatchingSection) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + + olive::ExportVideoTab tab(project.color_manager()); + tab.SetFormat(olive::ExportFormat::kFormatMatroska); + + // First Matroska codec is H.264, which has a dedicated section + tab.VideoCodecChanged(); + EXPECT_NE(tab.GetCodecSection(), nullptr); + + // Still image codecs get the image section instead + tab.SetFormat(olive::ExportFormat::kFormatPNG); + tab.VideoCodecChanged(); + EXPECT_NE(dynamic_cast(tab.GetCodecSection()), + nullptr); +} + +TEST(DialogExportVideoTab, ImageSequenceCheckboxRoundTrips) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + + olive::ExportVideoTab tab(project.color_manager()); + tab.SetFormat(olive::ExportFormat::kFormatPNG); + tab.VideoCodecChanged(); + + tab.SetImageSequence(true); + EXPECT_TRUE(tab.IsImageSequenceSet()); + + tab.SetImageSequence(false); + EXPECT_FALSE(tab.IsImageSequenceSet()); +} + +TEST(DialogExportVideoTab, MaintainAspectTogglesScalingMethod) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + + olive::ExportVideoTab tab(project.color_manager()); + + tab.maintain_aspect_checkbox()->setChecked(true); + EXPECT_FALSE(tab.scaling_method_combobox()->isEnabled()); + + tab.maintain_aspect_checkbox()->setChecked(false); + EXPECT_TRUE(tab.scaling_method_combobox()->isEnabled()); +} + +// +// export: codec sections +// +TEST(DialogExportH264CRFSection, ValueRoundTripsAndClamps) +{ + olive::H264CRFSection section(olive::H264CRFSection::kDefaultH264CRF); + + EXPECT_EQ(section.GetValue(), olive::H264CRFSection::kDefaultH264CRF); + + section.SetValue(30); + EXPECT_EQ(section.GetValue(), 30); + + section.SetValue(99); + EXPECT_EQ(section.GetValue(), 51); + + section.SetValue(-5); + EXPECT_EQ(section.GetValue(), 0); +} + +TEST(DialogExportH264BitRateSection, BitRateRoundTripsInBits) +{ + olive::H264BitRateSection section; + + section.SetTargetBitRate(8000000); + EXPECT_EQ(section.GetTargetBitRate(), 8000000); + + section.SetMaximumBitRate(16000000); + EXPECT_EQ(section.GetMaximumBitRate(), 16000000); +} + +// +// export: advanced video dialog +// +TEST(DialogExportAdvancedVideo, FieldsRoundTrip) +{ + olive::ExportAdvancedVideoDialog dialog({ QStringLiteral("yuv420p"), + QStringLiteral("yuv422p") }); + + dialog.set_threads(4); + EXPECT_EQ(dialog.threads(), 4); + + dialog.set_pix_fmt(QStringLiteral("yuv422p")); + EXPECT_EQ(dialog.pix_fmt(), QStringLiteral("yuv422p")); + + dialog.set_yuv_range(olive::VideoParams::kColorRangeFull); + EXPECT_EQ(dialog.yuv_range(), olive::VideoParams::kColorRangeFull); +} + +// +// export: save preset dialog +// +TEST(DialogExportSavePreset, AcceptWritesPresetFile) +{ + StandardPathsTestModeGuard test_mode; + + olive::EncodingParams params; + + olive::ExportSavePresetDialog dialog(params); + + auto *name_edit = dialog.findChild(); + ASSERT_NE(name_edit, nullptr); + name_edit->setText(QStringLiteral("oak-test-preset")); + + EXPECT_EQ(dialog.GetSelectedPresetName(), + QStringLiteral("oak-test-preset")); + + dialog.accept(); + EXPECT_EQ(dialog.result(), QDialog::Accepted); + + EXPECT_TRUE(olive::EncodingParams::GetListOfPresets().contains( + QStringLiteral("oak-test-preset"))); + + // Clean up the preset file written to the test config location + QFile::remove(QDir(olive::EncodingParams::GetPresetPath()) + .filePath(QStringLiteral("oak-test-preset"))); +} diff --git a/tests/gtest/dialog_misc_test.cpp b/tests/gtest/dialog_misc_test.cpp new file mode 100644 index 000000000..e371f372c --- /dev/null +++ b/tests/gtest/dialog_misc_test.cpp @@ -0,0 +1,592 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "common/filefunctions.h" +#include "config/config.h" +#include "core.h" +#include "dialog/about/about.h" +#include "dialog/actionsearch/actionsearch.h" +#include "dialog/autorecovery/autorecoverydialog.h" +#include "dialog/color/colordialog.h" +#include "dialog/configbase/configdialogbase.h" +#include "dialog/diskcache/diskcachedialog.h" +#include "dialog/preferences/keysequenceeditor.h" +#include "dialog/preferences/tabs/preferencesappearancetab.h" +#include "dialog/preferences/tabs/preferencesdisktab.h" +#include "dialog/preferences/tabs/preferencesluttab.h" +#include "dialog/progress/progress.h" +#include "dialog/rendercancel/rendercancel.h" +#include "dialog/task/task.h" +#include "dialog/text/text.h" +#include "node/color/colormanager/colormanager.h" +#include "node/project.h" +#include "render/diskmanager.h" + +namespace +{ + +void EnsureAppSingletons() +{ + if (!olive::Core::instance()) { + new olive::Core(olive::Core::CoreParams()); // intentionally leaked + } + if (!olive::DiskManager::instance()) { + olive::DiskManager::CreateInstance(); + } +} + +// Redirects QStandardPaths (config/autorecovery locations) to a disposable +// test location for the lifetime of the guard +class StandardPathsTestModeGuard { +public: + StandardPathsTestModeGuard() + { + QStandardPaths::setTestModeEnabled(true); + } + + ~StandardPathsTestModeGuard() + { + QStandardPaths::setTestModeEnabled(false); + } +}; + +class DummyTab : public olive::ConfigDialogBaseTab { +public: + bool validate_result = true; + int accept_count = 0; + + virtual bool Validate() override + { + return validate_result; + } + + virtual void Accept(olive::MultiUndoCommand *) override + { + ++accept_count; + } +}; + +class TestConfigDialog : public olive::ConfigDialogBase { +public: + using olive::ConfigDialogBase::AddTab; + using olive::ConfigDialogBase::ConfigDialogBase; + + bool accept_event_called = false; + +protected: + virtual void AcceptEvent() override + { + accept_event_called = true; + } +}; + +class DummyTask : public olive::Task { +public: + DummyTask() + { + SetTitle(QStringLiteral("DummyTask")); + } + +protected: + virtual bool Run() override + { + return true; + } +}; + +} // namespace + +// +// about +// +TEST(DialogAbout, WelcomeDialogHasDontShowAgainCheckbox) +{ + olive::AboutDialog welcome(true); + EXPECT_NE(welcome.findChild(), nullptr); + + olive::AboutDialog about(false); + EXPECT_EQ(about.findChild(), nullptr); +} + +TEST(DialogAbout, AcceptWithCheckboxWritesConfig) +{ + const QVariant old_value = + olive::Config::Current()[QStringLiteral("ShowWelcomeDialog")]; + + { + olive::AboutDialog welcome(true); + QCheckBox *box = welcome.findChild(); + ASSERT_NE(box, nullptr); + box->setChecked(true); + welcome.accept(); + } + + EXPECT_FALSE( + olive::Config::Current()[QStringLiteral("ShowWelcomeDialog")].toBool()); + + olive::Config::Current()[QStringLiteral("ShowWelcomeDialog")] = old_value; +} + +// +// actionsearch +// +TEST(DialogActionSearch, SearchFiltersActionsCaseInsensitively) +{ + QWidget parent; + olive::ActionSearch dialog(&parent); + + QMenuBar bar; + QMenu *file_menu = bar.addMenu(QStringLiteral("&File")); + file_menu->addAction(QStringLiteral("New Project")); + file_menu->addAction(QStringLiteral("&Open...")); + file_menu->addSeparator(); + QMenu *recent_menu = file_menu->addMenu(QStringLiteral("Recent")); + recent_menu->addAction(QStringLiteral("Project A")); + bar.addMenu(QStringLiteral("&Edit"))->addAction(QStringLiteral("Undo")); + + dialog.SetMenuBar(&bar); + + auto *entry = dialog.findChild(); + auto *list = dialog.findChild(); + ASSERT_NE(entry, nullptr); + ASSERT_NE(list, nullptr); + + entry->setText(QStringLiteral("OPEN")); + ASSERT_EQ(list->count(), 1); + // Item text is "action\n(menu hierarchy)" with accelerator '&' stripped + EXPECT_TRUE(list->item(0)->text().contains(QStringLiteral("Open..."))); + EXPECT_TRUE(list->item(0)->text().contains(QStringLiteral("(File)"))); + // First match is auto-selected for keyboard use + EXPECT_TRUE(list->item(0)->isSelected()); + + entry->setText(QStringLiteral("zzzz-no-match")); + EXPECT_EQ(list->count(), 0); + + // Nested menus are searched recursively + entry->setText(QStringLiteral("project a")); + ASSERT_EQ(list->count(), 1); + EXPECT_TRUE(list->item(0)->text().contains(QStringLiteral("Recent"))); +} + +TEST(DialogActionSearch, PerformActionTriggersSelectedAction) +{ + QWidget parent; + olive::ActionSearch dialog(&parent); + + QMenuBar bar; + QMenu *file_menu = bar.addMenu(QStringLiteral("&File")); + QAction *open_action = file_menu->addAction(QStringLiteral("Open...")); + + dialog.SetMenuBar(&bar); + + bool triggered = false; + QObject::connect(open_action, &QAction::triggered, + [&triggered]() { triggered = true; }); + + auto *entry = dialog.findChild(); + entry->setText(QStringLiteral("open")); + + QMetaObject::invokeMethod(&dialog, "perform_action"); + + EXPECT_TRUE(triggered); + EXPECT_EQ(dialog.result(), QDialog::Accepted); +} + +TEST(DialogActionSearch, SelectionMovesUpAndDown) +{ + QWidget parent; + olive::ActionSearch dialog(&parent); + + QMenuBar bar; + QMenu *file_menu = bar.addMenu(QStringLiteral("&File")); + file_menu->addAction(QStringLiteral("Alpha")); + file_menu->addAction(QStringLiteral("Beta")); + + dialog.SetMenuBar(&bar); + + auto *entry = dialog.findChild(); + auto *list = dialog.findChild(); + entry->setText(QStringLiteral("a")); + ASSERT_GE(list->count(), 2); + ASSERT_TRUE(list->item(0)->isSelected()); + + QMetaObject::invokeMethod(&dialog, "move_selection_down"); + EXPECT_TRUE(list->item(1)->isSelected()); + + QMetaObject::invokeMethod(&dialog, "move_selection_up"); + EXPECT_TRUE(list->item(0)->isSelected()); + + // Already at the top: moving up again is a no-op + QMetaObject::invokeMethod(&dialog, "move_selection_up"); + EXPECT_TRUE(list->item(0)->isSelected()); +} + +// +// autorecovery +// +TEST(DialogAutoRecovery, PopulatesTreeFromRecoveryFolders) +{ + StandardPathsTestModeGuard test_mode; + EnsureAppSingletons(); + + const QString root = olive::FileFunctions::GetAutoRecoveryRoot(); + const QString folder = QStringLiteral("uuid-abc"); + QDir recovery_dir(QDir(root).filePath(folder)); + ASSERT_TRUE(recovery_dir.mkpath(QStringLiteral("."))); + + QFile realname(recovery_dir.filePath(QStringLiteral("realname.txt"))); + ASSERT_TRUE(realname.open(QFile::WriteOnly)); + realname.write("My Project"); + realname.close(); + + QFile newest(recovery_dir.filePath(QStringLiteral("1700000000.ove"))); + ASSERT_TRUE(newest.open(QFile::WriteOnly)); + newest.write("x"); + newest.close(); + + QFile oldest(recovery_dir.filePath(QStringLiteral("1699999000.ove"))); + ASSERT_TRUE(oldest.open(QFile::WriteOnly)); + oldest.write("x"); + oldest.close(); + + // Non-project files must be ignored + QFile notes(recovery_dir.filePath(QStringLiteral("notes.txt"))); + ASSERT_TRUE(notes.open(QFile::WriteOnly)); + notes.write("x"); + notes.close(); + + { + olive::AutoRecoveryDialog dialog(QStringLiteral("message"), { folder }, + true, nullptr); + + auto *tree = dialog.findChild(); + ASSERT_NE(tree, nullptr); + ASSERT_EQ(tree->topLevelItemCount(), 1); + + QTreeWidgetItem *top = tree->topLevelItem(0); + // Pretty name comes from realname.txt + EXPECT_EQ(top->text(0), QStringLiteral("My Project")); + ASSERT_EQ(top->childCount(), 2); + + // Entries are newest-first; autocheck_latest checks only the newest + EXPECT_EQ(top->child(0)->checkState(0), Qt::Checked); + EXPECT_EQ(top->child(1)->checkState(0), Qt::Unchecked); + + // Timestamped filenames are shown as a formatted date/time + EXPECT_EQ(top->child(0)->text(0), + QDateTime::fromSecsSinceEpoch(1700000000).toString()); + EXPECT_TRUE(top->child(0) + ->data(0, Qt::UserRole) + .toString() + .endsWith(QStringLiteral("1700000000.ove"))); + } + + { + // Without autocheck_latest nothing is pre-checked + olive::AutoRecoveryDialog dialog(QStringLiteral("message"), { folder }, + false, nullptr); + + auto *tree = dialog.findChild(); + QTreeWidgetItem *top = tree->topLevelItem(0); + EXPECT_EQ(top->child(0)->checkState(0), Qt::Unchecked); + EXPECT_EQ(top->child(1)->checkState(0), Qt::Unchecked); + + // Accepting with nothing checked must not attempt to open anything + dialog.accept(); + EXPECT_EQ(dialog.result(), QDialog::Accepted); + } + + QDir(root).removeRecursively(); +} + +TEST(DialogAutoRecovery, MissingRealnameFallsBackToFolderName) +{ + StandardPathsTestModeGuard test_mode; + EnsureAppSingletons(); + + const QString root = olive::FileFunctions::GetAutoRecoveryRoot(); + const QString folder = QStringLiteral("uuid-no-realname"); + QDir recovery_dir(QDir(root).filePath(folder)); + ASSERT_TRUE(recovery_dir.mkpath(QStringLiteral("."))); + + QFile recovery(recovery_dir.filePath(QStringLiteral("1700000000.ove"))); + ASSERT_TRUE(recovery.open(QFile::WriteOnly)); + recovery.write("x"); + recovery.close(); + + olive::AutoRecoveryDialog dialog(QStringLiteral("message"), { folder }, + false, nullptr); + + auto *tree = dialog.findChild(); + ASSERT_NE(tree, nullptr); + ASSERT_EQ(tree->topLevelItemCount(), 1); + EXPECT_EQ(tree->topLevelItem(0)->text(0), folder); + + QDir(root).removeRecursively(); +} + +// +// configbase +// +TEST(DialogConfigBase, AddTabPopulatesListAndStack) +{ + TestConfigDialog dialog; + + auto *tab_a = new DummyTab(); + auto *tab_b = new DummyTab(); + dialog.AddTab(tab_a, QStringLiteral("First")); + dialog.AddTab(tab_b, QStringLiteral("Second")); + + auto *list = dialog.findChild(); + auto *stack = dialog.findChild(); + ASSERT_NE(list, nullptr); + ASSERT_NE(stack, nullptr); + + EXPECT_EQ(list->count(), 2); + EXPECT_EQ(stack->count(), 2); + + dialog.SetCurrentTab(1); + EXPECT_EQ(list->currentRow(), 1); + EXPECT_EQ(stack->currentIndex(), 1); +} + +TEST(DialogConfigBase, AcceptCallsTabsAndAcceptEvent) +{ + EnsureAppSingletons(); + + TestConfigDialog dialog; + auto *tab_a = new DummyTab(); + auto *tab_b = new DummyTab(); + dialog.AddTab(tab_a, QStringLiteral("First")); + dialog.AddTab(tab_b, QStringLiteral("Second")); + + // accept() is a private slot, invoke it through the meta-object + QMetaObject::invokeMethod(&dialog, "accept"); + + EXPECT_EQ(tab_a->accept_count, 1); + EXPECT_EQ(tab_b->accept_count, 1); + EXPECT_TRUE(dialog.accept_event_called); + EXPECT_EQ(dialog.result(), QDialog::Accepted); +} + +TEST(DialogConfigBase, FailedValidateBlocksAccept) +{ + EnsureAppSingletons(); + + TestConfigDialog dialog; + auto *tab_a = new DummyTab(); + auto *tab_b = new DummyTab(); + tab_a->validate_result = false; + dialog.AddTab(tab_a, QStringLiteral("First")); + dialog.AddTab(tab_b, QStringLiteral("Second")); + + // accept() is a private slot, invoke it through the meta-object + QMetaObject::invokeMethod(&dialog, "accept"); + + EXPECT_EQ(tab_a->accept_count, 0); + EXPECT_EQ(tab_b->accept_count, 0); + EXPECT_FALSE(dialog.accept_event_called); + EXPECT_NE(dialog.result(), QDialog::Accepted); +} + +// +// diskcache +// +TEST(DialogDiskCache, AcceptAppliesLimitAndClearOnClose) +{ + QTemporaryDir temp_dir; + ASSERT_TRUE(temp_dir.isValid()); + + olive::DiskCacheFolder folder(temp_dir.path()); + + olive::DiskCacheDialog dialog(&folder); + + auto *limit_slider = dialog.findChild(); + auto *clear_box = dialog.findChild(); + ASSERT_NE(limit_slider, nullptr); + ASSERT_NE(clear_box, nullptr); + + // Fields reflect the folder's current settings (20 GB default) + EXPECT_DOUBLE_EQ(limit_slider->GetValue(), 20.0); + EXPECT_FALSE(clear_box->isChecked()); + + limit_slider->SetValue(5.0); + clear_box->setChecked(true); + + dialog.accept(); + + EXPECT_EQ(folder.GetLimit(), + 5 * static_cast(olive::kBytesInGigabyte)); + EXPECT_TRUE(folder.GetClearOnClose()); +} + +// +// text +// +TEST(DialogText, TextIsReadBackFromEditor) +{ + olive::TextDialog dialog(QStringLiteral("Hello")); + EXPECT_EQ(dialog.text(), QStringLiteral("Hello")); + + auto *edit = dialog.findChild(); + ASSERT_NE(edit, nullptr); + edit->setPlainText(QStringLiteral("World")); + EXPECT_EQ(dialog.text(), QStringLiteral("World")); +} + +// +// progress +// +TEST(DialogProgress, CancelButtonEmitsCancelledAndDisables) +{ + olive::ProgressDialog dialog(QStringLiteral("Working..."), + QStringLiteral("Title")); + EXPECT_EQ(dialog.windowTitle(), QStringLiteral("Title")); + + QPushButton *cancel_btn = nullptr; + foreach (QPushButton *btn, dialog.findChildren()) { + if (btn->text() == QStringLiteral("Cancel")) { + cancel_btn = btn; + break; + } + } + ASSERT_NE(cancel_btn, nullptr); + + bool cancelled = false; + QObject::connect(&dialog, &olive::ProgressDialog::Cancelled, + [&cancelled]() { cancelled = true; }); + + cancel_btn->click(); + + EXPECT_TRUE(cancelled); + EXPECT_FALSE(cancel_btn->isEnabled()); +} + +// +// rendercancel +// +TEST(DialogRenderCancel, IdleWorkersDoNotBlock) +{ + olive::RenderCancelDialog dialog; + + dialog.SetWorkerCount(2); + dialog.WorkerStarted(); + dialog.WorkerDone(); + + // No busy workers: must return immediately without exec()ing + dialog.RunIfWorkersAreBusy(); + + EXPECT_FALSE(dialog.isVisible()); +} + +// +// task +// +TEST(DialogTask, WrapsAndOwnsTask) +{ + auto *task = new DummyTask(); + QPointer task_guard(task); + + auto *dialog = new olive::TaskDialog(task, QStringLiteral("Title")); + + EXPECT_EQ(dialog->GetTask(), task); + EXPECT_EQ(task->parent(), dialog); + + // The dialog takes ownership of the task + delete dialog; + EXPECT_TRUE(task_guard.isNull()); +} + +// +// color +// +TEST(DialogColor, SelectedColorRoundTrips) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + + olive::ColorDialog dialog(project.color_manager(), + olive::Color(1.0f, 0.0f, 0.0f, 1.0f)); + + olive::ManagedColor selected = dialog.GetSelectedColor(); + EXPECT_GT(selected.red(), selected.green()); + EXPECT_GT(selected.red(), selected.blue()); + + dialog.SetColor(olive::Color(0.0f, 0.0f, 1.0f, 1.0f)); + + olive::ManagedColor blue = dialog.GetSelectedColor(); + EXPECT_GT(blue.blue(), blue.red()); + EXPECT_GT(blue.blue(), blue.green()); +} + +// +// preferences (remaining tabs) +// +TEST(PreferencesAppearanceTab, ContainsStyleAndColorChoices) +{ + olive::PreferencesAppearanceTab tab; + + EXPECT_FALSE(tab.findChildren().isEmpty()); + EXPECT_FALSE(tab.findChildren().isEmpty()); +} + +TEST(PreferencesDiskTab, ValidatesUnchangedCacheLocation) +{ + EnsureAppSingletons(); + + olive::PreferencesDiskTab tab; + + // Unchanged location must validate without prompting + EXPECT_TRUE(tab.Validate()); +} + +TEST(PreferencesLutTab, ConstructsWithDirectoryList) +{ + olive::PreferencesLutTab tab; + + EXPECT_NE(tab.findChild(), nullptr); +} + +TEST(DialogKeySequenceEditor, TransfersShortcutsToAndFromAction) +{ + QAction action(QStringLiteral("Test Action")); + action.setShortcut(QKeySequence(QStringLiteral("Ctrl+T"))); + action.setProperty("id", QStringLiteral("test.action")); + action.setProperty("keydefault", QKeySequence(QStringLiteral("Ctrl+T"))); + + olive::KeySequenceEditor editor(nullptr, &action); + + // Editor initializes from the action's current shortcut + EXPECT_EQ(editor.keySequence(), QKeySequence(QStringLiteral("Ctrl+T"))); + EXPECT_EQ(editor.action_name(), QStringLiteral("test.action")); + + // Shortcut matching the default does not need to be saved + EXPECT_TRUE(editor.export_shortcut().isEmpty()); + + editor.setKeySequence(QKeySequence(QStringLiteral("Ctrl+U"))); + EXPECT_EQ(editor.export_shortcut(), + QStringLiteral("test.action\tCtrl+U")); + + // The action is only updated when set_action_shortcut() is called + EXPECT_EQ(action.shortcut(), QKeySequence(QStringLiteral("Ctrl+T"))); + editor.set_action_shortcut(); + EXPECT_EQ(action.shortcut(), QKeySequence(QStringLiteral("Ctrl+U"))); + + editor.reset_to_default(); + EXPECT_EQ(editor.keySequence(), QKeySequence(QStringLiteral("Ctrl+T"))); +}