Files
oak-editor/tests/gtest/mainwindow2_test.cpp
Mike-Solar 82162580ef test(app): add gtest coverage for previously untested app modules
Add 312 test cases across 14 new files under tests/gtest, covering:

- app/common utility headers (tooltypes, projecttypes, nodedatatypes,
  trackreferencehandle, undowrapper, configwrapper, subtitleapp, ...)
- all 8 standardcombos widgets
- slider base classes (NumericSliderBase, SliderLadder)
- timeline/track handles, marker handle/painting, drag button,
  elapsed counter
- timebased views, selection manager, time target, resizable timeline
  scrollbar
- viewer sizer, display buffer, playback timer, text editor
- timeline common helpers, TimelineAndTrackView, TrackView(-item/-splitter)
- project explorer views, navigation and undo commands
- node view items/edges/connectors/minimap/toolbar/context
- node param view sub-widgets
- about/export/sequence/footage-properties dialogs
- scope widgets (histogram/vectorscope/waveform), managed display
- main menu, main window undo commands, viewer/footage panels,
  engine event bridge
- keyframe view handle/input connection, multicam display,
  node combo box, audio monitor

Includes regression tests for the TimelineAndTrackView teardown and
TimeScaledObject inverted-limits fixes. Full suite: 2341 tests, 0 failures
(64 skips are pre-existing GL-dependent cases under the offscreen QPA).
2026-08-05 14:53:26 +08:00

1004 lines
30 KiB
C++

#include <gtest/gtest.h>
#include <QAction>
#include <QGuiApplication>
#include <QMenu>
#include <QMenuBar>
#include <QOffscreenSurface>
#include <QOpenGLContext>
#include <QSignalSpy>
#include <kddockwidgets/KDDockWidgets.h>
#include "audio/audiomanager.h"
#include "common/tooltypes.h"
#include "config/config.h"
#include "core.h"
#include "engineeventbridge.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 "oakengine/undo.h"
#include "oakengine/viewer.h"
#include "oakutil/oaknode.h"
#include "panel/footageviewer/footageviewer.h"
#include "panel/panelmanager.h"
#include "panel/project/footagemanagementpanel.h"
#include "panel/timebased/timebased.h"
#include "panel/viewer/viewerbase.h"
#include "render/diskmanager.h"
#include "render/rendermanager.h"
#include "task/taskmanager.h"
#include "undo/undocommand.h"
#include "widget/menu/menu.h"
#include "widget/menu/menushared.h"
#include "widget/timebased/timebasedwidget.h"
#include "window/mainwindow/mainmenu.h"
#include "window/mainwindow/mainwindow.h"
#include "window/mainwindow/mainwindowundo.h"
using namespace olive;
namespace
{
// Widgets/panels that talk to Core::instance() at construction require the
// application singleton, but not a MainWindow
void ensure_core()
{
if (!Core::instance()) {
new Core(); // intentionally leaked
}
}
// Panels register with the PanelManager singleton and ViewerWidget-based
// panels talk to TaskManager/RenderManager in their constructors. Same
// pattern as PanelEnvironment in panel_test.cpp.
class PanelTestEnvironment {
public:
PanelTestEnvironment()
{
ColorManager::set_up_default_config();
ensure_core();
KDDockWidgets::initFrontend(KDDockWidgets::FrontendType::QtWidgets);
if (!PanelManager::instance()) {
PanelManager::create_instance();
created_panel_manager_ = true;
}
if (!TaskManager::instance()) {
TaskManager::create_instance();
created_task_manager_ = true;
}
if (!RenderManager::instance()) {
saved_backend_ =
Config::current()[QStringLiteral("GraphicsBackend")];
Config::current()[QStringLiteral("GraphicsBackend")] =
QStringLiteral("opengl");
RenderManager::create_instance();
created_render_manager_ = true;
}
if (!DiskManager::instance()) {
DiskManager::create_instance();
created_disk_manager_ = true;
}
}
~PanelTestEnvironment()
{
if (created_panel_manager_) {
PanelManager::instance()->delete_all_panels();
PanelManager::destroy_instance();
}
if (created_task_manager_) {
TaskManager::destroy_instance();
}
if (created_render_manager_) {
RenderManager::destroy_instance();
Config::current()[QStringLiteral("GraphicsBackend")] =
saved_backend_;
}
if (created_disk_manager_) {
DiskManager::destroy_instance();
}
}
private:
bool created_panel_manager_ = false;
bool created_task_manager_ = false;
bool created_render_manager_ = false;
bool created_disk_manager_ = false;
QVariant saved_backend_;
};
// Full environment for MainWindow construction, mirroring the setup in
// mainwindow_test.cpp (MainWindow needs MenuShared and AudioManager on top
// of the panel singletons)
class MainWindowEnvironment : public PanelTestEnvironment {
public:
MainWindowEnvironment()
{
if (!MenuShared::instance()) {
MenuShared::create_instance();
created_menu_shared_ = true;
}
if (!AudioManager::instance()) {
AudioManager::create_instance();
created_audio_manager_ = true;
}
// Suppress the modal welcome dialog shown on first show
saved_welcome_ = Config::current()[QStringLiteral("ShowWelcomeDialog")];
Config::current()[QStringLiteral("ShowWelcomeDialog")] = false;
}
~MainWindowEnvironment()
{
Config::current()[QStringLiteral("ShowWelcomeDialog")] =
saved_welcome_;
if (created_audio_manager_) {
AudioManager::destroy_instance();
}
if (created_menu_shared_) {
MenuShared::destroy_instance();
}
}
private:
bool created_menu_shared_ = false;
bool created_audio_manager_ = false;
QVariant saved_welcome_;
};
// MainWindow instantiates viewer panels containing QOpenGLWidget, which the
// offscreen QPA cannot paint and which crashes on platforms without a usable
// GL implementation (see the MainWindow test in mainwindow_test.cpp)
bool main_window_unsupported_here()
{
if (QGuiApplication::platformName() == QStringLiteral("offscreen")) {
return true;
}
QOffscreenSurface probe_surface;
probe_surface.create();
QOpenGLContext probe_context;
const bool gl_available =
probe_context.create() && probe_context.makeCurrent(&probe_surface);
probe_context.doneCurrent();
return !gl_available;
}
// GTEST_SKIP only returns from the enclosing function, so the guard has to
// sit in the test body itself
#define SKIP_WITHOUT_MAIN_WINDOW() \
if (main_window_unsupported_here()) { \
GTEST_SKIP() << "MainWindow requires a GL-capable platform"; \
}
// Recursively finds the menu item conformed with the given "id" property.
// Iterates actions() rather than findChildren() because some items (the
// engine undo actions, the alternate delete item) are not menu children.
QAction *find_item_by_id(QMenu *menu, const QString &id)
{
foreach (QAction *a, menu->actions()) {
if (a->property("id").toString() == id) {
return a;
}
if (QMenu *sub = a->menu()) {
if (QAction *found = find_item_by_id(sub, id)) {
return found;
}
}
}
return nullptr;
}
QMenu *top_level_menu(QMenuBar *bar, const QString &title)
{
foreach (QAction *a, bar->actions()) {
if (a->menu() && a->menu()->title() == title) {
return a->menu();
}
}
return nullptr;
}
// Exposes the protected set_viewer_widget and counts the playback overrides
// so signal routing into the panel can be observed
class ProbeViewerPanel : public ViewerPanelBase {
public:
explicit ProbeViewerPanel(const QString &object_name)
: ViewerPanelBase(object_name)
{
}
void install_widget(ViewerWidget *vw)
{
set_viewer_widget(vw);
}
virtual void play_pause() override
{
play_pause_count_++;
ViewerPanelBase::play_pause();
}
virtual void play_in_to_out() override
{
play_in_to_out_count_++;
ViewerPanelBase::play_in_to_out();
}
virtual void shuttle_left() override
{
shuttle_left_count_++;
ViewerPanelBase::shuttle_left();
}
virtual void shuttle_stop() override
{
shuttle_stop_count_++;
ViewerPanelBase::shuttle_stop();
}
virtual void shuttle_right() override
{
shuttle_right_count_++;
ViewerPanelBase::shuttle_right();
}
int play_pause_count_ = 0;
int play_in_to_out_count_ = 0;
int shuttle_left_count_ = 0;
int shuttle_stop_count_ = 0;
int shuttle_right_count_ = 0;
};
class ProbeTimeBasedPanel : public TimeBasedPanel {
public:
using TimeBasedPanel::TimeBasedPanel;
using TimeBasedPanel::set_time_based_widget;
};
// Minimal FootageManagementPanel implementation for interface dispatch tests
class FakeFootageManagementPanel : public FootageManagementPanel {
public:
virtual QVector<OakEngineNode *> get_selected_footage() const override
{
return selection_;
}
QVector<OakEngineNode *> selection_;
};
int g_counting_redo_calls = 0;
int g_counting_undo_calls = 0;
void counting_redo(void *userdata)
{
Q_UNUSED(userdata)
g_counting_redo_calls++;
}
void counting_undo(void *userdata)
{
Q_UNUSED(userdata)
g_counting_undo_calls++;
}
} // namespace
class EngineEventBridgeTest : public ::testing::Test {
protected:
void SetUp() override
{
ColorManager::set_up_default_config();
ensure_core();
project_.initialize();
}
// Adds a node of type T parented to the fixture project
template <typename T> T *add_node()
{
auto *node = new T();
node->setParent(&project_);
return node;
}
Project project_;
};
class ViewerPanelBaseTest : public ::testing::Test {
protected:
PanelTestEnvironment env_;
};
class FootageManagementPanelTest : public ::testing::Test {
protected:
PanelTestEnvironment env_;
};
// --- EngineEventBridge (app/engineeventbridge) ---
TEST_F(EngineEventBridgeTest, SubscribeRejectsNullAndMismatchedHandles)
{
auto *math = add_node<MathNode>();
auto *math_handle = reinterpret_cast<OakEngineNode *>(math);
EngineEventBridge bridge;
// NULL handle fails, per the oakengine_event_subscribe contract
EXPECT_EQ(bridge.subscribe(nullptr, OAKENGINE_EVENT_NODE_LABEL_CHANGED), 0);
// A plain node is neither a Project nor a viewer: both families reject it
EXPECT_EQ(bridge.subscribe(math_handle,
OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED),
0);
EXPECT_EQ(bridge.subscribe(math_handle,
OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED),
0);
}
TEST_F(EngineEventBridgeTest, NodeLabelChangedForwardsSourceAndLabel)
{
auto *math = add_node<MathNode>();
auto *math_handle = reinterpret_cast<OakEngineNode *>(math);
EngineEventBridge bridge;
ASSERT_GT(bridge.subscribe(math_handle, OAKENGINE_EVENT_NODE_LABEL_CHANGED),
0);
QSignalSpy spy(&bridge, &EngineEventBridge::node_label_changed);
math->set_label(QStringLiteral("Bridge Label"));
ASSERT_EQ(spy.count(), 1);
EXPECT_EQ(spy.first().at(0).value<OakEngineNode *>(), math_handle);
EXPECT_EQ(spy.first().at(1).toString(), QStringLiteral("Bridge Label"));
// Node::set_label() guards on actual changes; re-setting emits nothing
math->set_label(QStringLiteral("Bridge Label"));
EXPECT_EQ(spy.count(), 1);
}
TEST_F(EngineEventBridgeTest, FolderInsertAndRemoveForwardChildAndIndex)
{
auto *math = add_node<MathNode>();
Folder *root = project_.root();
auto *root_handle = reinterpret_cast<OakEngineNode *>(root);
auto *child_handle = reinterpret_cast<OakEngineNode *>(math);
EngineEventBridge bridge;
EXPECT_GT(bridge.subscribe(root_handle,
OAKENGINE_EVENT_FOLDER_BEGIN_INSERT_ITEM),
0);
EXPECT_GT(bridge.subscribe(root_handle,
OAKENGINE_EVENT_FOLDER_END_INSERT_ITEM),
0);
EXPECT_GT(bridge.subscribe(root_handle,
OAKENGINE_EVENT_FOLDER_BEGIN_REMOVE_ITEM),
0);
EXPECT_GT(bridge.subscribe(root_handle,
OAKENGINE_EVENT_FOLDER_END_REMOVE_ITEM),
0);
QSignalSpy begin_insert(&bridge,
&EngineEventBridge::folder_begin_insert_item);
QSignalSpy end_insert(&bridge, &EngineEventBridge::folder_end_insert_item);
QSignalSpy begin_remove(&bridge,
&EngineEventBridge::folder_begin_remove_item);
QSignalSpy end_remove(&bridge, &EngineEventBridge::folder_end_remove_item);
// The insert index is always the current child count (append-only)
const int insert_index = root->item_child_count();
FolderAddChild add(root, math);
add.redo_now();
ASSERT_EQ(begin_insert.count(), 1);
EXPECT_EQ(begin_insert.first().at(0).value<OakEngineNode *>(), root_handle);
EXPECT_EQ(begin_insert.first().at(1).value<OakEngineNode *>(),
child_handle);
EXPECT_EQ(begin_insert.first().at(2).toInt(), insert_index);
ASSERT_EQ(end_insert.count(), 1);
EXPECT_EQ(end_insert.first().first().value<OakEngineNode *>(), root_handle);
// Undoing the add runs through Folder::InputDisconnectedEvent
add.undo_now();
ASSERT_EQ(begin_remove.count(), 1);
EXPECT_EQ(begin_remove.first().at(0).value<OakEngineNode *>(), root_handle);
EXPECT_EQ(begin_remove.first().at(1).value<OakEngineNode *>(),
child_handle);
EXPECT_EQ(begin_remove.first().at(2).toInt(), insert_index);
ASSERT_EQ(end_remove.count(), 1);
EXPECT_EQ(end_remove.first().first().value<OakEngineNode *>(), root_handle);
}
TEST_F(EngineEventBridgeTest, ProjectNameAndModifiedForward)
{
auto *project_handle = reinterpret_cast<OakEngineProject *>(&project_);
EngineEventBridge bridge;
EXPECT_GT(bridge.subscribe(project_handle,
OAKENGINE_EVENT_PROJECT_NAME_CHANGED),
0);
EXPECT_GT(bridge.subscribe(project_handle,
OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED),
0);
QSignalSpy name_spy(&bridge, &EngineEventBridge::project_name_changed);
QSignalSpy modified_spy(&bridge,
&EngineEventBridge::project_modified_changed);
project_.set_filename(QStringLiteral("/tmp/bridge_test_project.ove"));
EXPECT_EQ(name_spy.count(), 1);
project_.set_modified(true);
ASSERT_EQ(modified_spy.count(), 1);
EXPECT_TRUE(modified_spy.first().first().toBool());
project_.set_modified(false);
ASSERT_EQ(modified_spy.count(), 2);
EXPECT_FALSE(modified_spy.at(1).first().toBool());
}
TEST_F(EngineEventBridgeTest, ViewerPlayheadForwardsRationalPayload)
{
auto *sequence = add_node<Sequence>();
auto *sequence_handle = reinterpret_cast<OakEngineNode *>(sequence);
EngineEventBridge bridge;
ASSERT_GT(bridge.subscribe(sequence_handle,
OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED),
0);
QSignalSpy spy(&bridge, &EngineEventBridge::viewer_playhead_changed);
// Rational payloads are delivered as num/den pairs
ASSERT_EQ(oakengine_viewer_set_playhead(sequence_handle, 2, 1),
OAKENGINE_OK);
ASSERT_EQ(spy.count(), 1);
EXPECT_EQ(spy.first().at(0).value<OakEngineNode *>(), sequence_handle);
EXPECT_EQ(spy.first().at(1).toLongLong(), 2);
EXPECT_EQ(spy.first().at(2).toLongLong(), 1);
}
TEST_F(EngineEventBridgeTest, UnsubscribeStopsDelivery)
{
auto *math = add_node<MathNode>();
auto *math_handle = reinterpret_cast<OakEngineNode *>(math);
EngineEventBridge bridge;
const int64_t sub =
bridge.subscribe(math_handle, OAKENGINE_EVENT_NODE_LABEL_CHANGED);
ASSERT_GT(sub, 0);
QSignalSpy spy(&bridge, &EngineEventBridge::node_label_changed);
math->set_label(QStringLiteral("Before"));
ASSERT_EQ(spy.count(), 1);
bridge.unsubscribe(sub);
math->set_label(QStringLiteral("After"));
EXPECT_EQ(spy.count(), 1);
// Unknown or already-dead ids are ignored
bridge.unsubscribe(sub);
bridge.unsubscribe(0);
bridge.unsubscribe(424242);
math->set_label(QStringLiteral("Later"));
EXPECT_EQ(spy.count(), 1);
}
TEST_F(EngineEventBridgeTest, UnsubscribeAllStopsEverySubscription)
{
auto *math = add_node<MathNode>();
auto *math_handle = reinterpret_cast<OakEngineNode *>(math);
auto *project_handle = reinterpret_cast<OakEngineProject *>(&project_);
EngineEventBridge bridge;
ASSERT_GT(bridge.subscribe(math_handle, OAKENGINE_EVENT_NODE_LABEL_CHANGED),
0);
ASSERT_GT(bridge.subscribe(project_handle,
OAKENGINE_EVENT_PROJECT_NAME_CHANGED),
0);
QSignalSpy label_spy(&bridge, &EngineEventBridge::node_label_changed);
QSignalSpy name_spy(&bridge, &EngineEventBridge::project_name_changed);
bridge.unsubscribe_all();
math->set_label(QStringLiteral("Muted"));
project_.set_filename(QStringLiteral("/tmp/muted_project.ove"));
EXPECT_EQ(label_spy.count(), 0);
EXPECT_EQ(name_spy.count(), 0);
}
TEST_F(EngineEventBridgeTest, DestroyingBridgeDropsItsSubscriptions)
{
auto *math = add_node<MathNode>();
auto *math_handle = reinterpret_cast<OakEngineNode *>(math);
// A second bridge proves live subscribers are still notified afterwards
EngineEventBridge control;
ASSERT_GT(control.subscribe(math_handle, OAKENGINE_EVENT_NODE_LABEL_CHANGED),
0);
QSignalSpy control_spy(&control, &EngineEventBridge::node_label_changed);
auto *doomed = new EngineEventBridge();
ASSERT_GT(doomed->subscribe(math_handle, OAKENGINE_EVENT_NODE_LABEL_CHANGED),
0);
delete doomed;
// Must not dispatch into the freed bridge
math->set_label(QStringLiteral("After Delete"));
ASSERT_EQ(control_spy.count(), 1);
EXPECT_EQ(control_spy.first().at(1).toString(),
QStringLiteral("After Delete"));
}
TEST_F(EngineEventBridgeTest, UndoIndexChangedFiresOnPushAndJump)
{
ASSERT_NE(oakengine_undo_handle(), nullptr);
EngineEventBridge bridge;
ASSERT_GT(bridge.subscribe(oakengine_undo_handle(),
OAKENGINE_EVENT_UNDO_INDEX_CHANGED),
0);
QSignalSpy spy(&bridge, &EngineEventBridge::undo_index_changed);
const int64_t index_before = oakengine_undo_index();
g_counting_redo_calls = 0;
g_counting_undo_calls = 0;
void *cmd = oakengine_undo_command_create("gtest bridge command",
counting_redo, counting_undo,
nullptr, nullptr);
ASSERT_NE(cmd, nullptr);
ASSERT_EQ(oakengine_undo_push(cmd, "gtest bridge command"), OAKENGINE_OK);
EXPECT_EQ(g_counting_redo_calls, 1);
EXPECT_EQ(oakengine_undo_index(), index_before + 1);
ASSERT_GE(spy.count(), 1);
EXPECT_EQ(spy.last().first().toInt(), int(index_before + 1));
ASSERT_EQ(oakengine_undo_jump(index_before), OAKENGINE_OK);
EXPECT_EQ(g_counting_undo_calls, 1);
EXPECT_EQ(oakengine_undo_index(), index_before);
EXPECT_EQ(spy.last().first().toInt(), int(index_before));
}
// --- MainWindow undo commands (window/mainwindow/mainwindowundo) ---
TEST(MainWindowUndo, CommandsAreDistinctAndFreeable)
{
ColorManager::set_up_default_config();
ensure_core();
Project project;
project.initialize();
auto *sequence = new Sequence();
sequence->setParent(&project);
const oak::Node sequence_node(reinterpret_cast<OakEngineNode *>(sequence));
const int64_t rows_before = oakengine_undo_count();
void *open_cmd = make_open_sequence_command(sequence_node);
void *close_cmd = make_close_sequence_command(sequence_node);
ASSERT_NE(open_cmd, nullptr);
ASSERT_NE(close_cmd, nullptr);
EXPECT_NE(open_cmd, close_cmd);
// Creation must not execute the commands or touch the undo stack
EXPECT_EQ(oakengine_undo_count(), rows_before);
oakengine_undo_command_free(open_cmd);
oakengine_undo_command_free(close_cmd);
EXPECT_EQ(oakengine_undo_count(), rows_before);
}
TEST(MainWindowUndo, RedoUndoDriveMainWindowSequenceState)
{
SKIP_WITHOUT_MAIN_WINDOW();
MainWindowEnvironment env;
// The command callbacks reach the window through Core::main_window(),
// which is only assigned by the private Core::start_gui(); a directly
// constructed MainWindow does not register itself there, so the redo/undo
// path cannot run in this harness.
if (!Core::instance()->main_window()) {
GTEST_SKIP() << "Core::main_window() is unset without Core::start_gui()";
}
Project project;
project.initialize();
auto *sequence = new Sequence();
sequence->setParent(&project);
const oak::Node sequence_node(reinterpret_cast<OakEngineNode *>(sequence));
auto *sequence_handle = reinterpret_cast<OakEngineNode *>(sequence);
MainWindow *window = Core::instance()->main_window();
EXPECT_FALSE(window->is_sequence_open(sequence_handle));
// Open command: redo opens, undo closes; the close command mirrors it
void *open_cmd = make_open_sequence_command(sequence_node);
ASSERT_NE(open_cmd, nullptr);
ASSERT_EQ(oakengine_undo_command_redo_now(open_cmd), OAKENGINE_OK);
EXPECT_TRUE(window->is_sequence_open(sequence_handle));
ASSERT_EQ(oakengine_undo_command_undo_now(open_cmd), OAKENGINE_OK);
EXPECT_FALSE(window->is_sequence_open(sequence_handle));
oakengine_undo_command_free(open_cmd);
ASSERT_NE(window->open_sequence(sequence_handle), nullptr);
void *close_cmd = make_close_sequence_command(sequence_node);
ASSERT_NE(close_cmd, nullptr);
ASSERT_EQ(oakengine_undo_command_redo_now(close_cmd), OAKENGINE_OK);
EXPECT_FALSE(window->is_sequence_open(sequence_handle));
ASSERT_EQ(oakengine_undo_command_undo_now(close_cmd), OAKENGINE_OK);
EXPECT_TRUE(window->is_sequence_open(sequence_handle));
oakengine_undo_command_free(close_cmd);
window->close_sequence(sequence_handle);
}
// --- ViewerPanelBase (panel/viewer/viewerbase) ---
TEST_F(ViewerPanelBaseTest, ConstructionWiresViewerWidget)
{
ProbeViewerPanel panel(QStringLiteral("ViewerBaseConstructPanel"));
EXPECT_EQ(panel.objectName(), QStringLiteral("ViewerBaseConstructPanel"));
auto *vw = new ViewerWidget(&panel);
panel.install_widget(vw);
EXPECT_EQ(panel.get_viewer_widget(), vw);
EXPECT_EQ(panel.get_time_based_widget(), vw);
// play_internal() returns early without a connected viewer node, so none
// of the playback commands may start playback on a fresh panel
EXPECT_FALSE(vw->is_playing());
panel.play_pause();
EXPECT_EQ(panel.play_pause_count_, 1);
EXPECT_FALSE(vw->is_playing());
panel.play_in_to_out();
EXPECT_EQ(panel.play_in_to_out_count_, 1);
EXPECT_FALSE(vw->is_playing());
panel.shuttle_left();
EXPECT_EQ(panel.shuttle_left_count_, 1);
panel.shuttle_right();
EXPECT_EQ(panel.shuttle_right_count_, 1);
panel.shuttle_stop();
EXPECT_EQ(panel.shuttle_stop_count_, 1);
EXPECT_FALSE(vw->is_playing());
}
TEST_F(ViewerPanelBaseTest, ForwardsViewerWidgetSignals)
{
ProbeViewerPanel panel(QStringLiteral("ViewerBaseForwardPanel"));
auto *vw = new ViewerWidget(&panel);
panel.install_widget(vw);
// Lambdas avoid relying on opaque-handle metatypes for QSignalSpy
void *received_texture = nullptr;
int texture_count = 0;
QObject::connect(&panel, &ViewerPanelBase::texture_changed,
[&received_texture, &texture_count](void *t) {
received_texture = t;
texture_count++;
});
OakEngineColorManager *received_manager =
reinterpret_cast<OakEngineColorManager *>(quintptr(1));
int manager_count = 0;
QObject::connect(&panel, &ViewerPanelBase::color_manager_changed,
[&received_manager, &manager_count](OakEngineColorManager *m) {
received_manager = m;
manager_count++;
});
emit vw->texture_changed(&panel);
EXPECT_EQ(texture_count, 1);
EXPECT_EQ(received_texture, static_cast<void *>(&panel));
emit vw->color_manager_changed(nullptr);
EXPECT_EQ(manager_count, 1);
EXPECT_EQ(received_manager, nullptr);
}
TEST_F(ViewerPanelBaseTest, RoutesTimeBasedPanelPlaybackRequests)
{
ProbeViewerPanel panel(QStringLiteral("ViewerBaseRoutingPanel"));
panel.install_widget(new ViewerWidget(&panel));
ProbeTimeBasedPanel source(QStringLiteral("ViewerBaseRoutingSource"));
source.set_time_based_widget(new TimeBasedWidget(false, false, &source));
panel.connect_time_based_panel(&source);
// TimeBasedPanel actions become request signals the viewer panel hears
source.play_pause();
EXPECT_EQ(panel.play_pause_count_, 1);
source.play_in_to_out();
EXPECT_EQ(panel.play_in_to_out_count_, 1);
source.shuttle_left();
EXPECT_EQ(panel.shuttle_left_count_, 1);
source.shuttle_stop();
EXPECT_EQ(panel.shuttle_stop_count_, 1);
source.shuttle_right();
EXPECT_EQ(panel.shuttle_right_count_, 1);
// After disconnecting, requests no longer reach the panel
panel.disconnect_time_based_panel(&source);
source.play_pause();
source.shuttle_left();
EXPECT_EQ(panel.play_pause_count_, 1);
EXPECT_EQ(panel.shuttle_left_count_, 1);
}
TEST_F(ViewerPanelBaseTest, FocusChangeSignalsAreSafeWhenIdle)
{
ProbeViewerPanel panel_a(QStringLiteral("ViewerBaseFocusPanelA"));
panel_a.install_widget(new ViewerWidget(&panel_a));
ProbeViewerPanel panel_b(QStringLiteral("ViewerBaseFocusPanelB"));
panel_b.install_widget(new ViewerWidget(&panel_b));
ProbeTimeBasedPanel non_viewer(QStringLiteral("ViewerBaseFocusOther"));
non_viewer.set_time_based_widget(
new TimeBasedWidget(false, false, &non_viewer));
// Nothing is playing, so focus changes must leave both viewers untouched
emit PanelManager::instance()->focused_panel_changed(&non_viewer);
EXPECT_FALSE(panel_a.get_viewer_widget()->is_playing());
emit PanelManager::instance()->focused_panel_changed(&panel_b);
EXPECT_FALSE(panel_a.get_viewer_widget()->is_playing());
EXPECT_FALSE(panel_b.get_viewer_widget()->is_playing());
emit PanelManager::instance()->focused_panel_changed(nullptr);
EXPECT_FALSE(panel_a.get_viewer_widget()->is_playing());
}
// --- FootageManagementPanel (panel/project/footagemanagementpanel) ---
TEST_F(FootageManagementPanelTest, InterfaceDispatchesToImplementation)
{
Project project;
project.initialize();
auto *math = new MathNode();
math->setParent(&project);
FakeFootageManagementPanel fake;
FootageManagementPanel *iface = &fake;
EXPECT_TRUE(iface->get_selected_footage().isEmpty());
fake.selection_.append(reinterpret_cast<OakEngineNode *>(math));
fake.selection_.append(reinterpret_cast<OakEngineNode *>(project.root()));
ASSERT_EQ(iface->get_selected_footage().size(), 2);
EXPECT_EQ(iface->get_selected_footage().at(0),
reinterpret_cast<OakEngineNode *>(math));
EXPECT_EQ(iface->get_selected_footage().at(1),
reinterpret_cast<OakEngineNode *>(project.root()));
}
TEST_F(FootageManagementPanelTest, FootageViewerDiscoverableThroughInterface)
{
Project project;
project.initialize();
auto *viewer = new ViewerOutput();
viewer->setParent(&project);
auto *viewer_handle = reinterpret_cast<OakEngineNode *>(viewer);
FootageViewerPanel panel;
// The lookup MainMenu's insert/overwrite actions perform
emit panel.shown(Qt::OtherFocusReason);
FootageManagementPanel *found =
PanelManager::instance()->most_recently_focused<FootageManagementPanel>();
ASSERT_EQ(found, static_cast<FootageManagementPanel *>(&panel));
EXPECT_TRUE(found->get_selected_footage().isEmpty());
panel.connect_viewer_node(viewer_handle);
ASSERT_EQ(found->get_selected_footage().size(), 1);
EXPECT_EQ(found->get_selected_footage().first(), viewer_handle);
panel.disconnect_viewer_node();
EXPECT_TRUE(found->get_selected_footage().isEmpty());
}
// --- MainMenu (window/mainwindow/mainmenu) - requires a MainWindow ---
TEST(MainMenu, TopLevelMenusHaveExpectedTitles)
{
SKIP_WITHOUT_MAIN_WINDOW();
MainWindowEnvironment env;
MainWindow window;
auto *menu = qobject_cast<MainMenu *>(window.menuBar());
ASSERT_NE(menu, nullptr);
const QStringList expected = { QStringLiteral("&File"), QStringLiteral("&Edit"),
QStringLiteral("&View"), QStringLiteral("&Playback"),
QStringLiteral("&Sequence"), QStringLiteral("&Window"),
QStringLiteral("&Tools"), QStringLiteral("&Help") };
ASSERT_EQ(window.menuBar()->actions().size(), expected.size());
for (int i = 0; i < expected.size(); i++) {
QMenu *m = window.menuBar()->actions().at(i)->menu();
ASSERT_NE(m, nullptr) << i;
EXPECT_EQ(m->title(), expected.at(i));
}
}
TEST(MainMenu, EditMenuContainsUndoActionsAndAlternateDelete)
{
SKIP_WITHOUT_MAIN_WINDOW();
MainWindowEnvironment env;
MainWindow window;
QMenu *edit_menu =
top_level_menu(window.menuBar(), QStringLiteral("&Edit"));
ASSERT_NE(edit_menu, nullptr);
// The undo/redo items are the engine undo stack's own QActions
QAction *undo_item = find_item_by_id(edit_menu, QStringLiteral("undo"));
QAction *redo_item = find_item_by_id(edit_menu, QStringLiteral("redo"));
ASSERT_NE(undo_item, nullptr);
ASSERT_NE(redo_item, nullptr);
EXPECT_EQ(undo_item,
reinterpret_cast<QAction *>(oakengine_undo_undo_action()));
EXPECT_EQ(redo_item,
reinterpret_cast<QAction *>(oakengine_undo_redo_action()));
// The alternate delete item hides while the menu is open
QAction *delete2 = find_item_by_id(edit_menu, QStringLiteral("delete2"));
ASSERT_NE(delete2, nullptr);
EXPECT_TRUE(delete2->isVisible());
emit edit_menu->aboutToShow();
EXPECT_FALSE(delete2->isVisible());
emit edit_menu->aboutToHide();
EXPECT_TRUE(delete2->isVisible());
}
TEST(MainMenu, ToolItemsDriveCoreToolState)
{
SKIP_WITHOUT_MAIN_WINDOW();
MainWindowEnvironment env;
MainWindow window;
QMenu *tools_menu =
top_level_menu(window.menuBar(), QStringLiteral("&Tools"));
ASSERT_NE(tools_menu, nullptr);
QAction *razor = find_item_by_id(tools_menu, QStringLiteral("razortool"));
QAction *hand = find_item_by_id(tools_menu, QStringLiteral("handtool"));
ASSERT_NE(razor, nullptr);
ASSERT_NE(hand, nullptr);
Core::instance()->set_tool(Tool::k_pointer);
razor->trigger();
EXPECT_EQ(Core::instance()->tool(), Tool::k_razor);
// aboutToShow re-checks whichever tool Core currently holds
Core::instance()->set_tool(Tool::k_hand);
emit tools_menu->aboutToShow();
EXPECT_TRUE(hand->isChecked());
EXPECT_FALSE(razor->isChecked());
Core::instance()->set_tool(Tool::k_pointer);
}
TEST(MainMenu, SnappingAndLoopItemsPersistState)
{
SKIP_WITHOUT_MAIN_WINDOW();
MainWindowEnvironment env;
MainWindow window;
QMenu *tools_menu =
top_level_menu(window.menuBar(), QStringLiteral("&Tools"));
ASSERT_NE(tools_menu, nullptr);
QAction *snapping = find_item_by_id(tools_menu, QStringLiteral("snapping"));
ASSERT_NE(snapping, nullptr);
ASSERT_TRUE(snapping->isCheckable());
// Connected straight to Core::set_snapping
const bool snapping_before = snapping->isChecked();
snapping->trigger();
EXPECT_EQ(Core::instance()->snapping(), !snapping_before);
snapping->trigger();
EXPECT_EQ(Core::instance()->snapping(), snapping_before);
QMenu *playback_menu =
top_level_menu(window.menuBar(), QStringLiteral("&Playback"));
ASSERT_NE(playback_menu, nullptr);
QAction *loop = find_item_by_id(playback_menu, QStringLiteral("loop"));
ASSERT_NE(loop, nullptr);
ASSERT_TRUE(loop->isCheckable());
// aboutToShow syncs the check state from the config
const QVariant old_loop = Config::current()[QStringLiteral("Loop")];
Config::current()[QStringLiteral("Loop")] = false;
emit playback_menu->aboutToShow();
EXPECT_FALSE(loop->isChecked());
loop->trigger();
EXPECT_TRUE(Config::current()[QStringLiteral("Loop")].toBool());
loop->trigger();
EXPECT_FALSE(Config::current()[QStringLiteral("Loop")].toBool());
Config::current()[QStringLiteral("Loop")] = old_loop;
}
TEST(MainMenu, FileMenuReflectsActiveProjectAndRecentList)
{
SKIP_WITHOUT_MAIN_WINDOW();
MainWindowEnvironment env;
MainWindow window;
QMenu *file_menu =
top_level_menu(window.menuBar(), QStringLiteral("&File"));
ASSERT_NE(file_menu, nullptr);
QAction *save = find_item_by_id(file_menu, QStringLiteral("saveproj"));
QAction *save_as = find_item_by_id(file_menu, QStringLiteral("saveprojas"));
ASSERT_NE(save, nullptr);
ASSERT_NE(save_as, nullptr);
emit file_menu->aboutToShow();
// Save items are enabled only with an active project
const bool has_active_project =
Core::instance()->get_active_project() != nullptr;
EXPECT_EQ(save->isEnabled(), has_active_project);
EXPECT_EQ(save_as->isEnabled(), has_active_project);
if (!has_active_project) {
EXPECT_EQ(save->text(), QStringLiteral("&Save Project"));
EXPECT_EQ(save_as->text(), QStringLiteral("Save Project &As"));
}
// With no recent projects the submenu shows a disabled placeholder
QMenu *open_recent = nullptr;
foreach (QAction *a, file_menu->actions()) {
if (a->menu() &&
a->menu()->title() == QStringLiteral("Open &Recent")) {
open_recent = a->menu();
break;
}
}
ASSERT_NE(open_recent, nullptr);
if (Core::instance()->get_recent_project_count() == 0) {
ASSERT_FALSE(open_recent->actions().isEmpty());
QAction *placeholder = open_recent->actions().first();
EXPECT_EQ(placeholder->text(), QStringLiteral("(None)"));
EXPECT_FALSE(placeholder->isEnabled());
} else {
const int count = Core::instance()->get_recent_project_count();
int entries = 0;
foreach (QAction *a, open_recent->actions()) {
if (a->data().isValid()) {
entries++;
}
}
EXPECT_EQ(entries, count);
}
}