Files
oak-editor/tests/gtest/widget_handles_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

397 lines
12 KiB
C++

#include <gtest/gtest.h>
#include <QDateTime>
#include <QFontMetrics>
#include <QImage>
#include <QLabel>
#include <QPainter>
#include <QSignalSpy>
#include <QTest>
#include "node/block/clip/clip.h"
#include "node/color/colormanager/colormanager.h"
#include "node/output/track/track.h"
#include "node/output/track/tracklist.h"
#include "node/project.h"
#include "node/project/sequence/sequence.h"
#include "timeline/timelinemarker.h"
#include "widget/playbackcontrols/dragbutton.h"
#include "widget/taskview/elapsedcounterwidget.h"
#include "widget/timelinewidget/cliphandle.h"
#include "widget/timelinewidget/trackhandle.h"
#include "widget/timeruler/markerhandle.h"
#include "widget/timeruler/markerpainting.h"
#include "oakutil/qtutils.h"
TEST(WidgetDragButton, DefaultsAndPlainClick)
{
olive::DragButton btn;
btn.resize(80, 30);
EXPECT_EQ(btn.cursor().shape(), Qt::OpenHandCursor);
QSignalSpy drag_spy(&btn, &olive::DragButton::drag_started);
QSignalSpy click_spy(&btn, &QPushButton::clicked);
// A plain click is still a normal button click, not a drag
QTest::mouseClick(&btn, Qt::LeftButton, Qt::NoModifier, QPoint(10, 10));
EXPECT_EQ(drag_spy.count(), 0);
EXPECT_EQ(click_spy.count(), 1);
}
TEST(WidgetDragButton, DragEmitsDragStartedOncePerPress)
{
olive::DragButton btn;
btn.resize(80, 30);
QSignalSpy drag_spy(&btn, &olive::DragButton::drag_started);
// Press then move: the first move with a button held starts the drag
QTest::mousePress(&btn, Qt::LeftButton, Qt::NoModifier, QPoint(10, 10));
QTest::mouseMove(&btn, QPoint(20, 15));
ASSERT_EQ(drag_spy.count(), 1);
// The drag state latches until release
QTest::mouseMove(&btn, QPoint(30, 20));
EXPECT_EQ(drag_spy.count(), 1);
QTest::mouseRelease(&btn, Qt::LeftButton, Qt::NoModifier, QPoint(30, 20));
// A new press re-arms the drag detection
QTest::mousePress(&btn, Qt::LeftButton, Qt::NoModifier, QPoint(10, 10));
QTest::mouseMove(&btn, QPoint(25, 15));
EXPECT_EQ(drag_spy.count(), 2);
QTest::mouseRelease(&btn, Qt::LeftButton, Qt::NoModifier, QPoint(25, 15));
}
TEST(WidgetDragButton, MoveWithoutButtonsDoesNotEmit)
{
olive::DragButton btn;
btn.resize(80, 30);
QSignalSpy drag_spy(&btn, &olive::DragButton::drag_started);
QTest::mouseMove(&btn, QPoint(40, 15));
EXPECT_EQ(drag_spy.count(), 0);
}
TEST(WidgetElapsedCounter, InitialLabelsAreZero)
{
olive::ElapsedCounterWidget w;
const auto labels = w.findChildren<QLabel *>();
ASSERT_EQ(labels.size(), 2);
EXPECT_EQ(labels.at(0)->text(), QStringLiteral("Elapsed: 00:00:00"));
EXPECT_EQ(labels.at(1)->text(), QStringLiteral("Remaining: 00:00:00"));
}
TEST(WidgetElapsedCounter, HalfProgressMakesElapsedEqualRemaining)
{
olive::ElapsedCounterWidget w;
const auto labels = w.findChildren<QLabel *>();
ASSERT_EQ(labels.size(), 2);
// At 50% progress, remaining time is derived from the same elapsed
// value, so both labels must agree regardless of wall-clock drift
w.start(QDateTime::currentMSecsSinceEpoch() - 60000);
w.set_progress(0.5);
const QString elapsed = labels.at(0)->text();
const QString remaining = labels.at(1)->text();
EXPECT_NE(elapsed, QStringLiteral("Elapsed: 00:00:00"));
EXPECT_EQ(elapsed, QStringLiteral("Elapsed: ") +
remaining.mid(QStringLiteral("Remaining: ").size()));
// A full progress report leaves nothing remaining
w.set_progress(1.0);
EXPECT_EQ(labels.at(1)->text(), QStringLiteral("Remaining: 00:00:00"));
w.stop();
}
TEST(WidgetElapsedCounter, ZeroProgressResetsLabels)
{
olive::ElapsedCounterWidget w;
const auto labels = w.findChildren<QLabel *>();
ASSERT_EQ(labels.size(), 2);
w.start(QDateTime::currentMSecsSinceEpoch() - 60000);
w.set_progress(0.5);
EXPECT_NE(labels.at(0)->text(), QStringLiteral("Elapsed: 00:00:00"));
// No progress means no estimate at all
w.set_progress(0.0);
EXPECT_EQ(labels.at(0)->text(), QStringLiteral("Elapsed: 00:00:00"));
EXPECT_EQ(labels.at(1)->text(), QStringLiteral("Remaining: 00:00:00"));
w.stop();
}
TEST(WidgetClipHandle, EmptyClipDefaults)
{
OakEngineBlock *clip = olive::clip_create_empty("Test Clip");
ASSERT_NE(clip, nullptr);
EXPECT_EQ(olive::cliphandle(clip),
reinterpret_cast<OakEngineClip *>(clip));
EXPECT_DOUBLE_EQ(olive::clip_speed(clip), 1.0);
EXPECT_EQ(olive::clip_loop_mode(clip), 0);
EXPECT_FALSE(olive::clip_is_reversed(clip));
EXPECT_FALSE(olive::clip_maintain_audio_pitch(clip));
EXPECT_FALSE(olive::clip_is_autocaching(clip));
// Nothing is connected to the buffer input and the clip is on no track
EXPECT_EQ(olive::clip_connected_node(clip), nullptr);
EXPECT_EQ(olive::clip_thumbnails(clip), nullptr);
EXPECT_EQ(olive::clip_waveform(clip), nullptr);
EXPECT_EQ(olive::clip_connected_video_cache(clip), nullptr);
EXPECT_EQ(olive::block_track_handle(clip), nullptr);
EXPECT_EQ(olive::clip_media_in(clip), olive::Rational(0));
const olive::TimeRange range = olive::clip_media_range(clip);
EXPECT_EQ(range.in(), olive::Rational(0));
EXPECT_EQ(range.out(), olive::Rational(0));
// Invalidation requests with nothing connected are a safe no-op
olive::clip_request_invalidate_connected(clip);
delete reinterpret_cast<olive::ClipBlock *>(clip);
}
TEST(WidgetClipHandle, SetMediaInRoundTripsThroughFacade)
{
OakEngineBlock *clip = olive::clip_create_empty(nullptr);
ASSERT_NE(clip, nullptr);
olive::clip_set_media_in(clip, olive::Rational(2, 1));
EXPECT_EQ(olive::clip_media_in(clip), olive::Rational(2, 1));
// Media out is media in plus the clip's (zero) length
const olive::TimeRange range = olive::clip_media_range(clip);
EXPECT_EQ(range.in(), olive::Rational(2, 1));
EXPECT_EQ(range.out(), olive::Rational(2, 1));
delete reinterpret_cast<olive::ClipBlock *>(clip);
}
TEST(WidgetClipHandle, NullClipIsTolerated)
{
olive::clip_set_media_in(nullptr, olive::Rational(1, 1));
olive::clip_request_invalidate_connected(nullptr);
olive::clip_request_invalidate_connected(
nullptr, true,
olive::TimeRange(olive::Rational(0, 1), olive::Rational(1, 1)));
SUCCEED();
}
TEST(WidgetTrackHandle, NullTrackIsTolerated)
{
EXPECT_EQ(olive::trackhandle(nullptr), nullptr);
EXPECT_EQ(olive::track_sequence_handle(nullptr), nullptr);
EXPECT_EQ(olive::track_type_of(nullptr), -1);
EXPECT_EQ(olive::track_index_of(nullptr), -1);
EXPECT_FALSE(olive::track_is_locked(nullptr));
EXPECT_FALSE(olive::track_is_muted(nullptr));
}
TEST(WidgetTrackHandle, ConnectedTrackReportsSequenceIndexAndFlags)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
project.initialize();
auto *sequence = new olive::Sequence();
sequence->setParent(&project);
sequence->add_default_nodes();
olive::TrackList *video = sequence->track_list(olive::Track::k_video);
olive::TrackList *audio = sequence->track_list(olive::Track::k_audio);
ASSERT_EQ(video->get_track_count(), 1);
ASSERT_EQ(audio->get_track_count(), 1);
olive::Track *video_track = video->get_track_at(0);
auto *h = reinterpret_cast<OakEngineTrack *>(video_track);
EXPECT_EQ(olive::trackhandle(h), h);
EXPECT_EQ(olive::track_type_of(h), OAKENGINE_TRACK_TYPE_VIDEO);
EXPECT_EQ(olive::track_index_of(h), 0);
EXPECT_EQ(olive::track_sequence_handle(h),
reinterpret_cast<OakEngineSequence *>(sequence));
EXPECT_FALSE(olive::track_is_locked(h));
EXPECT_FALSE(olive::track_is_muted(h));
video_track->set_locked(true);
EXPECT_TRUE(olive::track_is_locked(h));
video_track->set_muted(true);
EXPECT_TRUE(olive::track_is_muted(h));
// Lock/mute are per-track; the audio track is unaffected
auto *ah = reinterpret_cast<OakEngineTrack *>(audio->get_track_at(0));
EXPECT_EQ(olive::track_type_of(ah), OAKENGINE_TRACK_TYPE_AUDIO);
EXPECT_FALSE(olive::track_is_locked(ah));
EXPECT_FALSE(olive::track_is_muted(ah));
}
TEST(WidgetMarkerHandle, DetachedMarkerAccessors)
{
OakEngineMarker *m = oakengine_marker_create(3, 5, 1, 8, 1, "Intro");
ASSERT_NE(m, nullptr);
const olive::TimeRange t = olive::marker_time(m);
EXPECT_EQ(t.in(), olive::Rational(5, 1));
EXPECT_EQ(t.out(), olive::Rational(8, 1));
EXPECT_EQ(olive::marker_name(m), QStringLiteral("Intro"));
EXPECT_EQ(olive::marker_color(m), 3);
// ADL customization points used by the selection manager
EXPECT_EQ(olive::selection_time(m), olive::Rational(5, 1));
EXPECT_EQ(olive::selection_time_end(m), olive::Rational(8, 1));
EXPECT_EQ(olive::selection_time_target_parent(m), nullptr);
oakengine_marker_free(m);
}
TEST(WidgetMarkerHandle, SetTimeLiveUpdatesRange)
{
OakEngineMarker *m = oakengine_marker_create(0, 5, 1, 8, 1, "M");
ASSERT_NE(m, nullptr);
olive::marker_set_time_live(
m, olive::TimeRange(olive::Rational(1, 1), olive::Rational(2, 1)));
const olive::TimeRange t = olive::marker_time(m);
EXPECT_EQ(t.in(), olive::Rational(1, 1));
EXPECT_EQ(t.out(), olive::Rational(2, 1));
oakengine_marker_free(m);
}
TEST(WidgetMarkerHandle, SelectionSetTimePreservesLength)
{
OakEngineMarker *m = oakengine_marker_create(0, 5, 1, 8, 1, nullptr);
ASSERT_NE(m, nullptr);
// Moving the in-point drags the out-point along, keeping a length of 3
olive::selection_set_time(m, olive::Rational(10, 1));
const olive::TimeRange t = olive::marker_time(m);
EXPECT_EQ(t.in(), olive::Rational(10, 1));
EXPECT_EQ(t.out(), olive::Rational(13, 1));
oakengine_marker_free(m);
}
TEST(WidgetMarkerHandle, SiblingDetectionWithinList)
{
// The list asserts that in-points are unique, so use distinct times
olive::TimelineMarkerList list;
olive::TimelineMarker a(
1,
olive::core::TimeRange(olive::core::Rational(5, 1),
olive::core::Rational(5, 1)),
QStringLiteral("A"), &list);
olive::TimelineMarker b(
2,
olive::core::TimeRange(olive::core::Rational(7, 1),
olive::core::Rational(7, 1)),
QStringLiteral("B"), &list);
olive::TimelineMarker c(
3,
olive::core::TimeRange(olive::core::Rational(9, 1),
olive::core::Rational(9, 1)),
QStringLiteral("C"), &list);
ASSERT_EQ(list.size(), 3);
auto *hc = reinterpret_cast<OakEngineMarker *>(&c);
// Another marker sits at t=5, so c has a sibling there
EXPECT_TRUE(olive::marker_has_sibling_at_time(hc, olive::Rational(5, 1)));
// c is the only marker at t=9; a marker is not its own sibling
EXPECT_FALSE(olive::marker_has_sibling_at_time(hc, olive::Rational(9, 1)));
// No marker at all at t=11
EXPECT_FALSE(olive::marker_has_sibling_at_time(hc, olive::Rational(11, 1)));
}
TEST(WidgetMarkerPainting, HeightMatchesFontMetrics)
{
QImage img(200, 100, QImage::Format_ARGB32);
img.fill(Qt::transparent);
QPainter p(&img);
EXPECT_EQ(olive::MarkerPainting::height(p.fontMetrics()),
p.fontMetrics().height());
}
TEST(WidgetMarkerPainting, PointMarkerGeometryAndFill)
{
QImage img(200, 100, QImage::Format_ARGB32);
img.fill(Qt::transparent);
QPainter p(&img);
const QFontMetrics fm = p.fontMetrics();
const int h = olive::MarkerPainting::height(fm);
const int w = olive::QtUtils::q_font_metrics_width(fm, QStringLiteral("H"));
const QPoint pt(100, 90);
const QRect r = olive::MarkerPainting::draw(&p, pt, -1, 10.0, false,
QString(), 0,
olive::Rational(2, 1),
olive::Rational(2, 1));
p.end();
// A point marker (in == out) is centered horizontally on the anchor and
// sits one marker-height above it
EXPECT_EQ(r, QRect(pt.x() - w / 2, pt.y() - h, w, h));
// The polygon body is filled around its center
const QColor px = img.pixelColor(QPoint(pt.x(), pt.y() - h / 2));
EXPECT_GT(px.alpha(), 0);
}
TEST(WidgetMarkerPainting, RangedMarkerGeometryScalesWithRange)
{
QImage img(400, 100, QImage::Format_ARGB32);
img.fill(Qt::transparent);
QPainter p(&img);
const QFontMetrics fm = p.fontMetrics();
const int h = olive::MarkerPainting::height(fm);
const QPoint pt(50, 90);
const QRect r = olive::MarkerPainting::draw(&p, pt, -1, 10.0, false,
QStringLiteral("Span"), 0,
olive::Rational(2, 1),
olive::Rational(5, 1));
p.end();
// A ranged marker starts at the anchor and extends (out - in) * scale px
EXPECT_EQ(r, QRect(pt.x(), pt.y() - h, 30, h));
// The rect body is filled
const QColor px = img.pixelColor(QPoint(pt.x() + 2, pt.y() - h / 2));
EXPECT_GT(px.alpha(), 0);
}
TEST(WidgetMarkerPainting, SelectionAndLabelDoNotAffectGeometry)
{
QImage img(400, 100, QImage::Format_ARGB32);
img.fill(Qt::transparent);
QPainter p(&img);
const QPoint pt(100, 90);
const QRect plain = olive::MarkerPainting::draw(
&p, pt, -1, 10.0, false, QString(), 0, olive::Rational(2, 1),
olive::Rational(2, 1));
// Selected + a label to the right of the marker
const QRect selected = olive::MarkerPainting::draw(
&p, pt, 300, 10.0, true, QStringLiteral("Named"), 0,
olive::Rational(2, 1), olive::Rational(2, 1));
p.end();
EXPECT_EQ(plain, selected);
}