From ba55123b131ebaff4201020aef1066128d594160 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 17 Jul 2026 16:53:13 +0800 Subject: [PATCH] tests: coverage for panels, main window, ratio dialog (44 cases) - all 19 panel classes: construction, titles, context/signal wiring, save/load data round-trips - MainWindowLayoutInfo XML round-trip, MainStatusBar, offscreen MainWindow construction with standard panels and menus - RatioDialog parsing (decimal and : / ; separators), validation --- tests/gtest/CMakeLists.txt | 3 + tests/gtest/common_ratiodialog_test.cpp | 146 +++++ tests/gtest/mainwindow_test.cpp | 359 +++++++++++ tests/gtest/panel_test.cpp | 772 ++++++++++++++++++++++++ 4 files changed, 1280 insertions(+) create mode 100644 tests/gtest/common_ratiodialog_test.cpp create mode 100644 tests/gtest/mainwindow_test.cpp create mode 100644 tests/gtest/panel_test.cpp diff --git a/tests/gtest/CMakeLists.txt b/tests/gtest/CMakeLists.txt index b4c7bf55e..920627a8b 100644 --- a/tests/gtest/CMakeLists.txt +++ b/tests/gtest/CMakeLists.txt @@ -136,6 +136,9 @@ add_executable(olive-gtest widget_timeruler_playback_test.cpp widget_projectexplorer_test.cpp widget_panels_model_test.cpp + panel_test.cpp + mainwindow_test.cpp + common_ratiodialog_test.cpp ) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Test) diff --git a/tests/gtest/common_ratiodialog_test.cpp b/tests/gtest/common_ratiodialog_test.cpp new file mode 100644 index 000000000..1a7239078 --- /dev/null +++ b/tests/gtest/common_ratiodialog_test.cpp @@ -0,0 +1,146 @@ +#include + +#include + +#include +#include +#include +#include + +#include "common/ratiodialog.h" + +using namespace olive; + +namespace +{ + +// Drives the modal dialogs that GetFloatRatioFromUser() pops up: feeds the +// queued text responses into QInputDialogs and accepts any QMessageBox shown +// in between (i.e. the invalid-ratio warning) +class DialogDriver : public QObject { +public: + explicit DialogDriver(const QStringList &responses) + : responses_(responses) + { + connect(&timer_, &QTimer::timeout, this, &DialogDriver::Step); + timer_.start(10); + } + +private: + void Step() + { + QWidget *modal = QApplication::activeModalWidget(); + if (!modal) { + return; + } + + if (auto *box = qobject_cast(modal)) { + box->accept(); + return; + } + + if (auto *input = qobject_cast(modal)) { + if (responses_.isEmpty()) { + // No more responses queued: cancel the dialog + input->reject(); + timer_.stop(); + return; + } + + input->setTextValue(responses_.takeFirst()); + input->accept(); + + if (responses_.isEmpty()) { + timer_.stop(); + } + } + } + + QStringList responses_; + QTimer timer_; +}; + +} // namespace + +TEST(CommonRatioDialog, AcceptsPlainDecimal) +{ + DialogDriver driver({ QStringLiteral("1.5") }); + + bool ok = false; + const double ratio = GetFloatRatioFromUser(nullptr, QStringLiteral("Test"), &ok); + + EXPECT_TRUE(ok); + EXPECT_DOUBLE_EQ(ratio, 1.5); +} + +TEST(CommonRatioDialog, AcceptsColonSeparatedRatio) +{ + DialogDriver driver({ QStringLiteral("16:9") }); + + bool ok = false; + const double ratio = GetFloatRatioFromUser(nullptr, QStringLiteral("Test"), &ok); + + EXPECT_TRUE(ok); + EXPECT_DOUBLE_EQ(ratio, 16.0 / 9.0); +} + +TEST(CommonRatioDialog, AcceptsSlashSeparatedRatio) +{ + DialogDriver driver({ QStringLiteral("4/3") }); + + bool ok = false; + const double ratio = GetFloatRatioFromUser(nullptr, QStringLiteral("Test"), &ok); + + EXPECT_TRUE(ok); + EXPECT_DOUBLE_EQ(ratio, 4.0 / 3.0); +} + +TEST(CommonRatioDialog, AcceptsSemicolonSeparatedRatio) +{ + DialogDriver driver({ QStringLiteral("1;2") }); + + bool ok = false; + const double ratio = GetFloatRatioFromUser(nullptr, QStringLiteral("Test"), &ok); + + EXPECT_TRUE(ok); + EXPECT_DOUBLE_EQ(ratio, 0.5); +} + +TEST(CommonRatioDialog, CancelReturnsNaN) +{ + // An empty response list makes the driver cancel the input dialog + DialogDriver driver({}); + + bool ok = true; + const double ratio = GetFloatRatioFromUser(nullptr, QStringLiteral("Test"), &ok); + + EXPECT_FALSE(ok); + EXPECT_TRUE(std::isnan(ratio)); +} + +TEST(CommonRatioDialog, InvalidInputWarnsAndRetries) +{ + // "banana" fails to parse (the driver accepts the warning box), the retry + // with a valid ratio succeeds + DialogDriver driver({ QStringLiteral("banana"), QStringLiteral("2") }); + + bool ok = false; + const double ratio = GetFloatRatioFromUser(nullptr, QStringLiteral("Test"), &ok); + + EXPECT_TRUE(ok); + EXPECT_DOUBLE_EQ(ratio, 2.0); +} + +TEST(CommonRatioDialog, RejectsNonPositiveValues) +{ + // Zero and negative values are rejected with a warning before a valid + // value is accepted + DialogDriver driver( + { QStringLiteral("0"), QStringLiteral("-4:2"), QStringLiteral("3") }); + + bool ok = false; + const double ratio = GetFloatRatioFromUser(nullptr, QStringLiteral("Test"), &ok); + + EXPECT_TRUE(ok); + EXPECT_DOUBLE_EQ(ratio, 3.0); +} diff --git a/tests/gtest/mainwindow_test.cpp b/tests/gtest/mainwindow_test.cpp new file mode 100644 index 000000000..2ff648530 --- /dev/null +++ b/tests/gtest/mainwindow_test.cpp @@ -0,0 +1,359 @@ +#include + +#include +#include +#include +#include +#include + +#include "audio/audiomanager.h" +#include "config/config.h" +#include "node/color/colormanager/colormanager.h" +#include "core.h" +#include "node/output/viewer/viewer.h" +#include "node/project.h" +#include "node/project/folder/folder.h" +#include "node/project/sequence/sequence.h" +#include "panel/panelmanager.h" +#include "render/diskmanager.h" +#include "render/rendermanager.h" +#include "task/task.h" +#include "task/taskmanager.h" +#include "widget/menu/menushared.h" +#include "window/mainwindow/mainstatusbar.h" +#include "window/mainwindow/mainwindow.h" +#include "window/mainwindow/mainwindowlayoutinfo.h" + +using namespace olive; + +namespace +{ + +class DummyTask : public Task { +public: + DummyTask() + { + SetTitle(QStringLiteral("Status Test Task")); + } + +protected: + virtual bool Run() override + { + return true; + } +}; + +} // namespace + +TEST(MainWindowLayoutInfo, AccessorsStoreAndRetrieve) +{ + Project project; + project.Initialize(); + auto *folder = new Folder(); + folder->setParent(&project); + auto *sequence = new Sequence(); + sequence->setParent(&project); + auto *viewer = new ViewerOutput(); + viewer->setParent(&project); + + MainWindowLayoutInfo info; + EXPECT_TRUE(info.open_folders().empty()); + EXPECT_TRUE(info.open_sequences().empty()); + EXPECT_TRUE(info.open_viewers().empty()); + EXPECT_TRUE(info.panel_data().empty()); + EXPECT_TRUE(info.state().isEmpty()); + + info.add_folder(folder); + info.add_sequence(sequence); + info.add_viewer(viewer); + + ASSERT_EQ(info.open_folders().size(), 1); + EXPECT_EQ(info.open_folders().front(), folder); + ASSERT_EQ(info.open_sequences().size(), 1); + EXPECT_EQ(info.open_sequences().front(), sequence); + ASSERT_EQ(info.open_viewers().size(), 1); + EXPECT_EQ(info.open_viewers().front(), viewer); + + PanelWidget::Info data; + data[QStringLiteral("key")] = QStringLiteral("value"); + info.set_panel_data(QStringLiteral("panel_a"), data); + ASSERT_EQ(info.panel_data().size(), 1); + EXPECT_EQ(info.panel_data() + .at(QStringLiteral("panel_a")) + .at(QStringLiteral("key")), + QStringLiteral("value")); + + // move_panel_data renames the entry + info.move_panel_data(QStringLiteral("panel_a"), + QStringLiteral("panel_b")); + EXPECT_EQ(info.panel_data().count(QStringLiteral("panel_a")), 0); + ASSERT_EQ(info.panel_data().count(QStringLiteral("panel_b")), 1); + EXPECT_EQ(info.panel_data() + .at(QStringLiteral("panel_b")) + .at(QStringLiteral("key")), + QStringLiteral("value")); + + info.set_state(QByteArray("layout-state")); + EXPECT_EQ(info.state(), QByteArray("layout-state")); +} + +TEST(MainWindowLayoutInfo, XmlRoundTripPreservesEverything) +{ + Project project; + project.Initialize(); + auto *folder = new Folder(); + folder->setParent(&project); + auto *sequence = new Sequence(); + sequence->setParent(&project); + auto *viewer = new ViewerOutput(); + viewer->setParent(&project); + + MainWindowLayoutInfo info; + info.add_folder(folder); + info.add_sequence(sequence); + info.add_viewer(viewer); + PanelWidget::Info data; + data[QStringLiteral("splitter")] = QStringLiteral("AAA="); + info.set_panel_data(QStringLiteral("TimelinePanel"), data); + info.set_state(QByteArray("binary\x01\x02state", 12)); + + QString xml; + QXmlStreamWriter writer(&xml); + writer.writeStartDocument(); + writer.writeStartElement(QStringLiteral("layout")); + info.toXml(&writer); + writer.writeEndElement(); + writer.writeEndDocument(); + + QHash node_map; + node_map.insert(reinterpret_cast(folder), folder); + node_map.insert(reinterpret_cast(sequence), sequence); + node_map.insert(reinterpret_cast(viewer), viewer); + + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("layout")); + + MainWindowLayoutInfo loaded = MainWindowLayoutInfo::fromXml(&reader, node_map); + + ASSERT_EQ(loaded.open_folders().size(), 1); + EXPECT_EQ(loaded.open_folders().front(), folder); + ASSERT_EQ(loaded.open_sequences().size(), 1); + EXPECT_EQ(loaded.open_sequences().front(), sequence); + + // Open viewers must survive the round trip too + ASSERT_EQ(loaded.open_viewers().size(), 1); + EXPECT_EQ(loaded.open_viewers().front(), viewer); + + EXPECT_EQ(loaded.state(), info.state()); + + ASSERT_EQ(loaded.panel_data().count(QStringLiteral("TimelinePanel")), 1); + EXPECT_EQ(loaded.panel_data() + .at(QStringLiteral("TimelinePanel")) + .at(QStringLiteral("splitter")), + QStringLiteral("AAA=")); + + // No unknown nodes leak into the viewers list: it must contain exactly the + // viewer that was added, not a duplicate of the sequences list + EXPECT_NE(loaded.open_viewers().front(), + static_cast(sequence)); +} + +TEST(MainWindowLayoutInfo, FromXmlSkipsUnknownElementsAndNodes) +{ + const QString xml = QStringLiteral( + "" + "1" + "2" + "3" + "" + "QUJD" + ""); + + // No node map entries: pointers resolve to nullptr + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("layout")); + + MainWindowLayoutInfo info = MainWindowLayoutInfo::fromXml(&reader, {}); + + // Unknown pointers resolve to null but are still listed + ASSERT_EQ(info.open_folders().size(), 1); + EXPECT_EQ(info.open_folders().front(), nullptr); + ASSERT_EQ(info.open_sequences().size(), 1); + EXPECT_EQ(info.open_sequences().front(), nullptr); + ASSERT_EQ(info.open_viewers().size(), 1); + EXPECT_EQ(info.open_viewers().front(), nullptr); + EXPECT_EQ(info.state(), QByteArray("ABC")); + EXPECT_TRUE(info.panel_data().empty()); +} + +TEST(MainWindowStatusBar, ConstructionDefaults) +{ + MainStatusBar bar; + bar.show(); + + auto *progress = bar.findChild(); + ASSERT_NE(progress, nullptr); + EXPECT_FALSE(progress->isVisible()); +} + +TEST(MainWindowStatusBar, ReflectsTaskManagerState) +{ + TaskManager manager; + + MainStatusBar bar; + bar.show(); + auto *progress = bar.findChild(); + ASSERT_NE(progress, nullptr); + + bar.ConnectTaskManager(&manager); + + auto *task = new DummyTask(); + manager.AddTask(task); + + // One running task shows its title and the progress bar + EXPECT_EQ(bar.currentMessage(), QStringLiteral("Status Test Task")); + EXPECT_TRUE(progress->isVisible()); + + // Progress signals are forwarded to the bar + emit task->ProgressChanged(0.5); + EXPECT_EQ(progress->value(), 50); + + // When the task list empties, the bar hides and the message clears + manager.CancelTaskAndWait(task); + QTRY_COMPARE_WITH_TIMEOUT(manager.GetTaskCount(), 0, 2000); + EXPECT_TRUE(bar.currentMessage().isEmpty()); + EXPECT_FALSE(progress->isVisible()); + EXPECT_EQ(progress->value(), 0); + + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); +} + +TEST(MainWindowStatusBar, DoubleClickEmitsSignal) +{ + MainStatusBar bar; + bar.show(); + + QSignalSpy spy(&bar, &MainStatusBar::DoubleClicked); + QTest::mouseDClick(&bar, Qt::LeftButton); + EXPECT_GE(spy.count(), 1); +} + +// Evaluation of full MainWindow construction under the offscreen platform. +// Core::StartGUI() is private, so this test replicates its relevant steps: +// it creates the singletons MainWindow depends on (MenuShared, PanelManager, +// AudioManager, DiskManager, plus TaskManager for the status bar and +// RenderManager for the viewer panels' display widgets) and then instantiates +// MainWindow directly. +TEST(MainWindow, ConstructsOffscreenWithPanelsAndMenus) +{ + // Must precede RenderManager creation: PreviewAutoCacher constructs a + // Project whose ColorManager dereferences the default OCIO config + ColorManager::SetUpDefaultConfig(); + + const bool created_task_manager = (TaskManager::instance() == nullptr); + if (created_task_manager) { + TaskManager::CreateInstance(); + } + const bool created_render_manager = (RenderManager::instance() == nullptr); + QVariant saved_backend; + if (created_render_manager) { + // Another suite may have left an experimental backend in the config; + // RenderManager needs a real one to create its cacher + saved_backend = Config::Current()[QStringLiteral("GraphicsBackend")]; + Config::Current()[QStringLiteral("GraphicsBackend")] = + QStringLiteral("opengl"); + RenderManager::CreateInstance(); + } + const bool created_disk_manager = (DiskManager::instance() == nullptr); + if (created_disk_manager) { + DiskManager::CreateInstance(); + } + const bool created_menu_shared = (MenuShared::instance() == nullptr); + if (created_menu_shared) { + MenuShared::CreateInstance(); + } + const bool created_panel_manager = (PanelManager::instance() == nullptr); + if (created_panel_manager) { + PanelManager::CreateInstance(); + } + const bool created_audio_manager = (AudioManager::instance() == nullptr); + if (created_audio_manager) { + AudioManager::CreateInstance(); + } + if (!Core::instance()) { + new Core(Core::CoreParams()); // intentionally leaked + } + KDDockWidgets::initFrontend(KDDockWidgets::FrontendType::QtWidgets); + + // Suppress the modal welcome dialog shown on first show + const QVariant welcome_setting = + Config::Current()[QStringLiteral("ShowWelcomeDialog")]; + Config::Current()[QStringLiteral("ShowWelcomeDialog")] = false; + + MainWindow *window = new MainWindow(); + window->showMaximized(); + + // The standard panels were created and registered by name + PanelManager *panels = PanelManager::instance(); + ASSERT_NE(panels, nullptr); + EXPECT_GE(panels->panels().size(), 10); + EXPECT_NE(panels->GetPanelWithName(QStringLiteral("NodePanel")), nullptr); + EXPECT_NE(panels->GetPanelWithName(QStringLiteral("ProjectPanel")), + nullptr); + // Timeline panels get their index appended to the unique name + EXPECT_NE(panels->GetPanelWithName(QStringLiteral("TimelinePanel:0")), + nullptr); + EXPECT_NE(panels->GetPanelWithName(QStringLiteral("SequenceViewerPanel")), + nullptr); + EXPECT_NE(panels->GetPanelWithName(QStringLiteral("FootageViewerPanel")), + nullptr); + + // The menu bar is fully populated (this is what the action search dialog + // indexes) + QMenuBar *menu_bar = window->menuBar(); + ASSERT_NE(menu_bar, nullptr); + int total_actions = 0; + foreach (QAction *menu_action, menu_bar->actions()) { + if (QMenu *menu = menu_action->menu()) { + total_actions += menu->actions().size(); + } + } + EXPECT_GE(menu_bar->actions().size(), 5); + EXPECT_GT(total_actions, 0); + + // Status bar and window title are set up + EXPECT_NE(window->statusBar(), nullptr); + EXPECT_FALSE(window->windowTitle().isEmpty()); + + // The default layout restore is queued at construction; pumping the event + // loop must not crash + QCoreApplication::processEvents(QEventLoop::AllEvents, 100); + + Config::Current()[QStringLiteral("ShowWelcomeDialog")] = welcome_setting; + + // Tear down in reverse order: window first, then its panels, then only + // the singletons this test created + delete window; + if (created_panel_manager) { + PanelManager::instance()->DeleteAllPanels(); + PanelManager::DestroyInstance(); + } + if (created_audio_manager) { + AudioManager::DestroyInstance(); + } + if (created_menu_shared) { + MenuShared::DestroyInstance(); + } + if (created_render_manager) { + RenderManager::DestroyInstance(); + Config::Current()[QStringLiteral("GraphicsBackend")] = saved_backend; + } + if (created_task_manager) { + TaskManager::DestroyInstance(); + } + if (created_disk_manager) { + DiskManager::DestroyInstance(); + } +} diff --git a/tests/gtest/panel_test.cpp b/tests/gtest/panel_test.cpp new file mode 100644 index 000000000..e9ab09d32 --- /dev/null +++ b/tests/gtest/panel_test.cpp @@ -0,0 +1,772 @@ +#include + +#include +#include + +#include + +#include "core.h" +#include "config/config.h" +#include "node/color/colormanager/colormanager.h" +#include "node/math/math/math.h" +#include "node/output/viewer/viewer.h" +#include "node/project.h" +#include "node/project/folder/folder.h" +#include "node/project/sequence/sequence.h" +#include "panel/audiomonitor/audiomonitor.h" +#include "panel/curve/curve.h" +#include "panel/footageviewer/footageviewer.h" +#include "panel/history/historypanel.h" +#include "panel/multicam/multicampanel.h" +#include "panel/node/node.h" +#include "panel/panel.h" +#include "panel/panelmanager.h" +#include "panel/param/param.h" +#include "panel/pixelsampler/pixelsamplerpanel.h" +#include "panel/project/project.h" +#include "panel/scope/scope.h" +#include "panel/sequenceviewer/sequenceviewer.h" +#include "panel/table/table.h" +#include "panel/taskmanager/taskmanager.h" +#include "panel/timebased/timebased.h" +#include "panel/timeline/timeline.h" +#include "panel/tool/tool.h" +#include "panel/viewer/viewer.h" +#include "render/diskmanager.h" +#include "render/rendermanager.h" +#include "task/task.h" +#include "task/taskmanager.h" +#include "undo/undostack.h" +#include "widget/curvewidget/curvewidget.h" +#include "widget/history/historywidget.h" +#include "widget/taskview/taskview.h" +#include "widget/taskview/taskviewitem.h" +#include "widget/timebased/timebasedwidget.h" +#include "widget/toolbar/toolbar.h" + +using namespace olive; + +namespace +{ + +// Panels register with the PanelManager singleton and several of them talk to +// Core, TaskManager and RenderManager in their constructors +class PanelEnvironment { +public: + PanelEnvironment() + { + ColorManager::SetUpDefaultConfig(); + + if (!Core::instance()) { + new Core(Core::CoreParams()); // intentionally leaked + } + + KDDockWidgets::initFrontend(KDDockWidgets::FrontendType::QtWidgets); + + if (!PanelManager::instance()) { + PanelManager::CreateInstance(); + created_panel_manager_ = true; + } + + if (!TaskManager::instance()) { + TaskManager::CreateInstance(); + created_task_manager_ = true; + } + + if (!RenderManager::instance()) { + // Another suite may have left an experimental backend in the + // config; RenderManager needs a real one to create its cacher + saved_backend_ = + Config::Current()[QStringLiteral("GraphicsBackend")]; + Config::Current()[QStringLiteral("GraphicsBackend")] = + QStringLiteral("opengl"); + RenderManager::CreateInstance(); + created_render_manager_ = true; + } + + if (!DiskManager::instance()) { + DiskManager::CreateInstance(); + created_disk_manager_ = true; + } + } + + ~PanelEnvironment() + { + // Panels must be gone before the manager that tracks them + if (created_panel_manager_) { + PanelManager::instance()->DeleteAllPanels(); + PanelManager::DestroyInstance(); + } + if (created_task_manager_) { + TaskManager::DestroyInstance(); + } + if (created_render_manager_) { + RenderManager::DestroyInstance(); + Config::Current()[QStringLiteral("GraphicsBackend")] = + saved_backend_; + } + if (created_disk_manager_) { + DiskManager::DestroyInstance(); + } + } + +private: + bool created_panel_manager_ = false; + bool created_task_manager_ = false; + bool created_render_manager_ = false; + bool created_disk_manager_ = false; + QVariant saved_backend_; +}; + +// Exposes the protected title/subtitle slots of the base class for testing +class TestPanel : public PanelWidget { +public: + explicit TestPanel(const QString &name) + : PanelWidget(name) + { + } + + using PanelWidget::SetSubtitle; + using PanelWidget::SetTitle; +}; + +class TestTimeBasedPanel : public TimeBasedPanel { +public: + using TimeBasedPanel::SetTimeBasedWidget; + using TimeBasedPanel::TimeBasedPanel; +}; + +class DummyTask : public Task { +public: + DummyTask() + { + SetTitle(QStringLiteral("Panel Test Task")); + } + +protected: + virtual bool Run() override + { + return true; + } +}; + +} // namespace + +class PanelTest : public ::testing::Test { +protected: + void SetUp() override + { + env_ = new PanelEnvironment(); + } + + void TearDown() override + { + delete env_; + } + + template T *AddNode(Project *project) + { + auto *node = new T(); + node->setParent(project); + return node; + } + + PanelEnvironment *env_; +}; + +TEST_F(PanelTest, PanelWidgetBaseTitleSubtitleFormatting) +{ + TestPanel panel(QStringLiteral("BaseTestPanel")); + EXPECT_EQ(panel.objectName(), QStringLiteral("BaseTestPanel")); + + panel.SetTitle(QStringLiteral("Title")); + EXPECT_EQ(panel.title(), QStringLiteral("Title")); + + panel.SetSubtitle(QStringLiteral("Sub")); + EXPECT_EQ(panel.title(), QStringLiteral("Title: Sub")); + + // Clearing the subtitle falls back to the bare title + panel.SetSubtitle(QString()); + EXPECT_EQ(panel.title(), QStringLiteral("Title")); +} + +TEST_F(PanelTest, PanelWidgetBaseRegistersWithPanelManager) +{ + PanelManager *manager = PanelManager::instance(); + const int panel_count_before = manager->panels().size(); + + auto *panel = new TestPanel(QStringLiteral("RegisteredPanel")); + EXPECT_TRUE(manager->panels().contains(panel)); + EXPECT_EQ(manager->panels().size(), panel_count_before + 1); + EXPECT_EQ(manager->GetPanelWithName(QStringLiteral("RegisteredPanel")), + panel); + EXPECT_TRUE(manager->GetPanelsOfType().contains(panel)); + + delete panel; + EXPECT_FALSE(manager->panels().contains(panel)); + EXPECT_EQ(manager->GetPanelWithName(QStringLiteral("RegisteredPanel")), + nullptr); +} + +TEST_F(PanelTest, PanelWidgetBaseCloseBehavior) +{ + TestPanel panel(QStringLiteral("CloseTestPanel")); + panel.show(); + ASSERT_TRUE(panel.isVisible()); + + // Default behavior: close hides the panel + panel.close(); + EXPECT_FALSE(panel.isVisible()); + + // With SetSignalInsteadOfClose the close is vetoed and CloseRequested is + // emitted instead + panel.SetSignalInsteadOfClose(true); + QSignalSpy spy(&panel, &PanelWidget::CloseRequested); + panel.show(); + ASSERT_TRUE(panel.isVisible()); + panel.close(); + EXPECT_EQ(spy.count(), 1); + EXPECT_TRUE(panel.isVisible()); +} + +TEST_F(PanelTest, PanelWidgetBaseDefaultActionsAreNoOps) +{ + TestPanel panel(QStringLiteral("NoOpTestPanel")); + + // Default SaveData is empty and LoadData accepts anything + EXPECT_TRUE(panel.SaveData().empty()); + panel.LoadData(PanelWidget::Info()); + + // All default actions are no-ops and must not crash + panel.ZoomIn(); + panel.ZoomOut(); + panel.GoToStart(); + panel.PrevFrame(); + panel.PlayPause(); + panel.PlayInToOut(); + panel.NextFrame(); + panel.GoToEnd(); + panel.SelectAll(); + panel.DeselectAll(); + panel.RippleToIn(); + panel.RippleToOut(); + panel.EditToIn(); + panel.EditToOut(); + panel.ShuttleLeft(); + panel.ShuttleStop(); + panel.ShuttleRight(); + panel.GoToPrevCut(); + panel.GoToNextCut(); + panel.RenameSelected(); + panel.DeleteSelected(); + panel.RippleDelete(); + panel.IncreaseTrackHeight(); + panel.DecreaseTrackHeight(); + panel.SetIn(); + panel.SetOut(); + panel.ResetIn(); + panel.ResetOut(); + panel.ClearInOut(); + panel.SetMarker(); + panel.ToggleLinks(); + panel.CutSelected(); + panel.CopySelected(); + panel.Paste(); + panel.PasteInsert(); + panel.ToggleShowAll(); + panel.GoToIn(); + panel.GoToOut(); + panel.DeleteInToOut(); + panel.RippleDeleteInToOut(); + panel.ToggleSelectedEnabled(); + panel.Duplicate(); + panel.SetColorLabel(1); + panel.NudgeLeft(); + panel.NudgeRight(); + panel.MoveInToPlayhead(); + panel.MoveOutToPlayhead(); + + SUCCEED(); +} + +TEST_F(PanelTest, PanelWidgetBaseBorderAndFocus) +{ + TestPanel panel(QStringLiteral("BorderTestPanel")); + panel.SetBorderVisible(true); + panel.show(); + + // The shown signal is wired to grab focus + emit panel.shown(Qt::OtherFocusReason); + emit panel.hidden(); + + // Focus history lookup by type works through PanelManager + EXPECT_EQ(PanelManager::instance()->MostRecentlyFocused(), + &panel); +} + +TEST_F(PanelTest, PixelSamplerPanelConstruction) +{ + PixelSamplerPanel panel; + EXPECT_EQ(panel.objectName(), QStringLiteral("PixelSamplerPanel")); + EXPECT_EQ(panel.title(), QStringLiteral("Pixel Sampler")); + + // Feeding values through the slot must not crash + Color red(1.0, 0.0, 0.0, 1.0); + Color green(0.0, 1.0, 0.0, 1.0); + panel.SetValues(red, green); + + // The panel hooks its visibility into Core's pixel sampling requests + emit panel.shown(Qt::OtherFocusReason); + emit panel.hidden(); +} + +TEST_F(PanelTest, TaskManagerPanelReflectsTaskManager) +{ + TaskManagerPanel panel; + EXPECT_EQ(panel.objectName(), QStringLiteral("TaskManagerPanel")); + EXPECT_EQ(panel.title(), QStringLiteral("Task Manager")); + + // The panel's TaskView is wired to the TaskManager singleton + auto *task = new DummyTask(); + TaskManager::instance()->AddTask(task); + + auto *view = panel.findChild(); + ASSERT_NE(view, nullptr); + EXPECT_NE(view->findChild(), nullptr); + + // TaskManager owns the task now; cancel it and let the removal propagate + TaskManager::instance()->CancelTaskAndWait(task); + QCoreApplication::processEvents(QEventLoop::AllEvents, 100); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); +} + +TEST_F(PanelTest, CurvePanelConstructionAndScaling) +{ + CurvePanel panel; + EXPECT_EQ(panel.objectName(), QStringLiteral("CurvePanel")); + EXPECT_TRUE(panel.title().startsWith(QStringLiteral("Curve Editor"))); + ASSERT_NE(panel.GetTimeBasedWidget(), nullptr); + + auto *curve = static_cast(panel.GetTimeBasedWidget()); + + // Track height actions scale the curve view vertically + const double initial_scale = curve->GetVerticalScale(); + panel.IncreaseTrackHeight(); + EXPECT_DOUBLE_EQ(curve->GetVerticalScale(), initial_scale * 2); + panel.DecreaseTrackHeight(); + EXPECT_DOUBLE_EQ(curve->GetVerticalScale(), initial_scale); + + // Selection actions on an empty view are harmless + panel.SelectAll(); + panel.DeselectAll(); + panel.DeleteSelected(); +} + +TEST_F(PanelTest, CurvePanelSetNodes) +{ + Project project; + project.Initialize(); + auto *math = AddNode(&project); + + CurvePanel panel; + panel.SetNode(math); + panel.SetNode(nullptr); + panel.SetNodes({ math }); + panel.SetNodes({}); + + SUCCEED(); +} + +TEST_F(PanelTest, ParamPanelConstructionAndContexts) +{ + Project project; + project.Initialize(); + + ParamPanel panel; + EXPECT_EQ(panel.objectName(), QStringLiteral("ParamPanel")); + EXPECT_EQ(panel.title(), QStringLiteral("Parameter Editor")); + ASSERT_NE(panel.GetParamView(), nullptr); + + EXPECT_TRUE(panel.GetContexts().isEmpty()); + panel.SetContexts({ project.root() }); + ASSERT_EQ(panel.GetContexts().size(), 1); + EXPECT_EQ(panel.GetContexts().first(), project.root()); + + // Selection slots on an empty selection are harmless + panel.SelectAll(); + panel.DeselectAll(); + panel.DeleteSelected(); +} + +TEST_F(PanelTest, ParamPanelForwardsViewSignals) +{ + Project project; + project.Initialize(); + auto *math = AddNode(&project); + + ParamPanel panel; + + QSignalSpy focused_spy(&panel, &ParamPanel::FocusedNodeChanged); + emit panel.GetParamView()->FocusedNodeChanged(math); + ASSERT_EQ(focused_spy.count(), 1); + EXPECT_EQ(focused_spy.first().first().value(), math); + + QSignalSpy selected_spy(&panel, &ParamPanel::SelectedNodesChanged); + emit panel.GetParamView()->SelectedNodesChanged({ { math, nullptr } }); + EXPECT_EQ(selected_spy.count(), 1); + + QSignalSpy text_spy(&panel, &ParamPanel::RequestViewerToStartEditingText); + emit panel.GetParamView()->RequestViewerToStartEditingText(); + EXPECT_EQ(text_spy.count(), 1); +} + +TEST_F(PanelTest, ProjectPanelTracksProject) +{ + Project project; + project.Initialize(); + project.set_filename(QStringLiteral("/tmp/panel_test_project.ove")); + + ProjectPanel panel(QStringLiteral("ProjectPanelTest")); + EXPECT_EQ(panel.objectName(), QStringLiteral("ProjectPanelTest")); + EXPECT_EQ(panel.project(), nullptr); + + QSignalSpy name_spy(&panel, &ProjectPanel::ProjectNameChanged); + panel.set_project(&project); + EXPECT_EQ(panel.project(), &project); + EXPECT_EQ(name_spy.count(), 1); + EXPECT_EQ(panel.get_root(), project.root()); + + // The subtitle reflects the project name in the panel title + EXPECT_TRUE(panel.title().contains(project.name())); + + // A child folder can become the shown root + auto *folder = AddNode(&project); + FolderAddChild(project.root(), folder).redo_now(); + panel.set_root(folder); + EXPECT_EQ(panel.get_root(), folder); +} + +TEST_F(PanelTest, ProjectPanelSelectsChildNodes) +{ + Project project; + project.Initialize(); + auto *math = AddNode(&project); + FolderAddChild(project.root(), math).redo_now(); + + ProjectPanel panel(QStringLiteral("ProjectPanelSelectTest")); + panel.set_project(&project); + + QSignalSpy selection_spy(&panel, &ProjectPanel::SelectionChanged); + + ASSERT_TRUE(panel.SelectItem(math)); + EXPECT_TRUE(panel.SelectedItems().contains(math)); + EXPECT_GT(selection_spy.count(), 0); +} + +TEST_F(PanelTest, TimeBasedPanelSignalsAndTimebase) +{ + TestTimeBasedPanel panel(QStringLiteral("TimeBasedTestPanel")); + panel.SetTimeBasedWidget(new TimeBasedWidget(false, false, &panel)); + + QSignalSpy play_pause_spy(&panel, &TimeBasedPanel::PlayPauseRequested); + panel.PlayPause(); + EXPECT_EQ(play_pause_spy.count(), 1); + + QSignalSpy play_in_out_spy(&panel, &TimeBasedPanel::PlayInToOutRequested); + panel.PlayInToOut(); + EXPECT_EQ(play_in_out_spy.count(), 1); + + QSignalSpy shuttle_left_spy(&panel, &TimeBasedPanel::ShuttleLeftRequested); + panel.ShuttleLeft(); + EXPECT_EQ(shuttle_left_spy.count(), 1); + + QSignalSpy shuttle_stop_spy(&panel, &TimeBasedPanel::ShuttleStopRequested); + panel.ShuttleStop(); + EXPECT_EQ(shuttle_stop_spy.count(), 1); + + QSignalSpy shuttle_right_spy(&panel, + &TimeBasedPanel::ShuttleRightRequested); + panel.ShuttleRight(); + EXPECT_EQ(shuttle_right_spy.count(), 1); + + panel.SetTimebase(rational(1, 30)); + EXPECT_EQ(panel.timebase(), rational(1, 30)); +} + +TEST_F(PanelTest, TimeBasedPanelConnectViewerUpdatesSubtitle) +{ + Project project; + project.Initialize(); + auto *viewer = AddNode(&project); + viewer->SetLabel(QStringLiteral("My Viewer")); + + TestTimeBasedPanel panel(QStringLiteral("TimeBasedConnectPanel")); + panel.SetTimeBasedWidget(new TimeBasedWidget(false, false, &panel)); + EXPECT_EQ(panel.GetConnectedViewer(), nullptr); + + panel.ConnectViewerNode(viewer); + EXPECT_EQ(panel.GetConnectedViewer(), viewer); + EXPECT_TRUE(panel.title().contains(QStringLiteral("My Viewer"))); + + // Label changes on the viewer propagate to the panel title + viewer->SetLabel(QStringLiteral("Renamed Viewer")); + EXPECT_TRUE(panel.title().contains(QStringLiteral("Renamed Viewer"))); + + panel.DisconnectViewerNode(); + EXPECT_EQ(panel.GetConnectedViewer(), nullptr); +} + +TEST_F(PanelTest, NodeTablePanelConstruction) +{ + Project project; + project.Initialize(); + auto *math = AddNode(&project); + + NodeTablePanel panel; + EXPECT_EQ(panel.objectName(), QStringLiteral("NodeTablePanel")); + EXPECT_EQ(panel.title(), QStringLiteral("Table View")); + ASSERT_NE(panel.GetTimeBasedWidget(), nullptr); + + panel.SelectNodes({ math }); + panel.DeselectNodes({ math }); + + SUCCEED(); +} + +TEST_F(PanelTest, MulticamPanelConstruction) +{ + MulticamPanel panel; + EXPECT_EQ(panel.objectName(), QStringLiteral("MultiCamPanel")); + EXPECT_TRUE(panel.title().startsWith(QStringLiteral("Multi-Cam"))); + EXPECT_NE(panel.GetMulticamWidget(), nullptr); + EXPECT_EQ(panel.GetConnectedViewer(), nullptr); +} + +TEST_F(PanelTest, HistoryPanelReflectsUndoStack) +{ + HistoryPanel panel; + EXPECT_EQ(panel.objectName(), QStringLiteral("HistoryPanel")); + EXPECT_EQ(panel.title(), QStringLiteral("History")); + + // The embedded HistoryWidget displays Core's undo stack + auto *widget = panel.findChild(); + ASSERT_NE(widget, nullptr); + EXPECT_EQ(widget->model(), Core::instance()->undo_stack()); +} + +TEST_F(PanelTest, FootageViewerPanelConstruction) +{ + Project project; + project.Initialize(); + auto *viewer = AddNode(&project); + + FootageViewerPanel panel; + EXPECT_EQ(panel.objectName(), QStringLiteral("FootageViewerPanel")); + EXPECT_TRUE(panel.title().startsWith(QStringLiteral("Footage Viewer"))); + EXPECT_NE(panel.GetFootageViewerWidget(), nullptr); + + // With nothing connected there is no selected footage + EXPECT_TRUE(panel.GetSelectedFootage().isEmpty()); + + panel.ConnectViewerNode(viewer); + ASSERT_EQ(panel.GetSelectedFootage().size(), 1); + EXPECT_EQ(panel.GetSelectedFootage().first(), viewer); + + panel.DisconnectViewerNode(); + EXPECT_TRUE(panel.GetSelectedFootage().isEmpty()); +} + +TEST_F(PanelTest, NodePanelConstructionAndContexts) +{ + Project project; + project.Initialize(); + + NodePanel panel; + EXPECT_EQ(panel.objectName(), QStringLiteral("NodePanel")); + EXPECT_EQ(panel.title(), QStringLiteral("Node Editor")); + EXPECT_NE(panel.GetNodeWidget(), nullptr); + + EXPECT_TRUE(panel.GetContexts().isEmpty()); + panel.SetContexts({ project.root() }); + ASSERT_EQ(panel.GetContexts().size(), 1); + EXPECT_EQ(panel.GetContexts().first(), project.root()); + + // Node selection actions on an empty scene are harmless + panel.SelectAll(); + panel.DeselectAll(); +} + +TEST_F(PanelTest, NodePanelForwardsViewSignals) +{ + Project project; + project.Initialize(); + auto *math = AddNode(&project); + + NodePanel panel; + panel.SetContexts({ project.root() }); + + QSignalSpy selected_spy(&panel, &NodePanel::NodesSelected); + emit panel.GetNodeWidget()->view()->NodesSelected({ math }); + ASSERT_EQ(selected_spy.count(), 1); + + QSignalSpy deselected_spy(&panel, &NodePanel::NodesDeselected); + emit panel.GetNodeWidget()->view()->NodesDeselected({ math }); + EXPECT_EQ(deselected_spy.count(), 1); + + QSignalSpy selection_spy(&panel, &NodePanel::NodeSelectionChanged); + emit panel.GetNodeWidget()->view()->NodeSelectionChanged({ math }); + EXPECT_EQ(selection_spy.count(), 1); +} + +TEST_F(PanelTest, TimelinePanelConstructionAndSequence) +{ + Project project; + project.Initialize(); + auto *sequence = AddNode(&project); + + TimelinePanel panel(QStringLiteral("TimelinePanelTest")); + EXPECT_EQ(panel.objectName(), QStringLiteral("TimelinePanelTest")); + EXPECT_NE(panel.timeline_widget(), nullptr); + EXPECT_EQ(panel.GetSequence(), nullptr); + + panel.ConnectViewerNode(sequence); + EXPECT_EQ(panel.GetConnectedViewer(), sequence); + EXPECT_EQ(panel.GetSequence(), sequence); + + // Selection actions on an empty sequence are harmless + panel.SelectAll(); + panel.DeselectAll(); + EXPECT_TRUE(panel.GetSelectedBlocks().isEmpty()); +} + +TEST_F(PanelTest, TimelinePanelSaveLoadDataRoundTrip) +{ + TimelinePanel panel(QStringLiteral("TimelinePanelDataTest")); + + PanelWidget::Info info = panel.SaveData(); + ASSERT_EQ(info.size(), 1); + EXPECT_TRUE(info.count(QStringLiteral("splitter"))); + + // Loading the saved state back must not throw or crash + panel.LoadData(info); + + // Loading twice is idempotent + panel.LoadData(info); + + SUCCEED(); +} + +TEST_F(PanelTest, ToolPanelReflectsCoreToolState) +{ + ToolPanel panel; + EXPECT_EQ(panel.objectName(), QStringLiteral("ToolPanel")); + EXPECT_EQ(panel.title(), QStringLiteral("Tools")); + + auto *toolbar = panel.findChild(); + ASSERT_NE(toolbar, nullptr); + + // The toolbar drives Core's active tool through the panel's connections + Core::instance()->SetTool(Tool::kPointer); + emit toolbar->ToolChanged(Tool::kHand); + EXPECT_EQ(Core::instance()->tool(), Tool::kHand); + + // ...and snapping state + emit toolbar->SnappingChanged(false); + EXPECT_FALSE(Core::instance()->snapping()); + emit toolbar->SnappingChanged(true); + EXPECT_TRUE(Core::instance()->snapping()); + + Core::instance()->SetTool(Tool::kPointer); +} + +TEST_F(PanelTest, ViewerPanelConstruction) +{ + ViewerPanel panel(QStringLiteral("ViewerPanelTest")); + EXPECT_EQ(panel.objectName(), QStringLiteral("ViewerPanelTest")); + EXPECT_TRUE(panel.title().startsWith(QStringLiteral("Viewer"))); + EXPECT_NE(panel.GetViewerWidget(), nullptr); + EXPECT_EQ(panel.GetConnectedViewer(), nullptr); +} + +TEST_F(PanelTest, SequenceViewerPanelConstruction) +{ + SequenceViewerPanel panel; + EXPECT_EQ(panel.objectName(), QStringLiteral("SequenceViewerPanel")); + EXPECT_TRUE(panel.title().startsWith(QStringLiteral("Sequence Viewer"))); + EXPECT_NE(panel.GetViewerWidget(), nullptr); +} + +TEST_F(PanelTest, ViewerPanelConnectTimeBasedPanel) +{ + ViewerPanel viewer_panel(QStringLiteral("ViewerWiringPanel")); + CurvePanel curve_panel; + + // Routing playback commands from a timebased panel to the viewer must not + // crash, even with nothing connected to the viewer + viewer_panel.ConnectTimeBasedPanel(&curve_panel); + + QSignalSpy spy(&curve_panel, &TimeBasedPanel::PlayPauseRequested); + curve_panel.PlayPause(); + EXPECT_EQ(spy.count(), 1); + + curve_panel.ShuttleLeft(); + curve_panel.ShuttleStop(); + curve_panel.ShuttleRight(); + + viewer_panel.DisconnectTimeBasedPanel(&curve_panel); +} + +TEST_F(PanelTest, ScopePanelConstructionAndTypeSwitching) +{ + ScopePanel panel; + EXPECT_EQ(panel.objectName(), QStringLiteral("ScopePanel")); + EXPECT_EQ(panel.title(), QStringLiteral("Scopes")); + EXPECT_EQ(panel.GetConnectedViewerPanel(), nullptr); + + // Every scope type has a human readable name + for (int i = 0; i < ScopePanel::kTypeCount; i++) { + EXPECT_FALSE( + ScopePanel::TypeToName(static_cast(i)).isEmpty()); + } + + auto *combo = panel.findChild(); + auto *stack = panel.findChild(); + ASSERT_NE(combo, nullptr); + ASSERT_NE(stack, nullptr); + ASSERT_EQ(stack->count(), ScopePanel::kTypeCount); + + // SetType switches the visible scope through the combo box + panel.SetType(ScopePanel::kTypeHistogram); + EXPECT_EQ(combo->currentIndex(), ScopePanel::kTypeHistogram); + + panel.SetType(ScopePanel::kTypeVectorscope); + EXPECT_EQ(combo->currentIndex(), ScopePanel::kTypeVectorscope); +} + +TEST_F(PanelTest, ScopePanelViewerConnection) +{ + ScopePanel scope_panel; + ViewerPanel viewer_panel(QStringLiteral("ScopeSourcePanel")); + + scope_panel.SetViewerPanel(&viewer_panel); + EXPECT_EQ(scope_panel.GetConnectedViewerPanel(), &viewer_panel); + + // Setting the same panel again is a no-op + scope_panel.SetViewerPanel(&viewer_panel); + EXPECT_EQ(scope_panel.GetConnectedViewerPanel(), &viewer_panel); + + // Disconnecting clears the reference buffer connection + scope_panel.SetViewerPanel(nullptr); + EXPECT_EQ(scope_panel.GetConnectedViewerPanel(), nullptr); +} + +TEST_F(PanelTest, AudioMonitorPanelConstruction) +{ + AudioMonitorPanel panel; + EXPECT_EQ(panel.objectName(), QStringLiteral("AudioMonitor")); + EXPECT_EQ(panel.title(), QStringLiteral("Audio Monitor")); + EXPECT_FALSE(panel.IsPlaying()); + + panel.SetParams(core::AudioParams(48000, core::kChannelLayoutStereo, + core::SampleFormat::F32P)); +}