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).
871 lines
26 KiB
C++
871 lines
26 KiB
C++
#include <gtest/gtest.h>
|
|
|
|
#include <QImage>
|
|
#include <QMouseEvent>
|
|
#include <QSignalSpy>
|
|
#include <QTest>
|
|
#include <QWheelEvent>
|
|
|
|
#include "core.h"
|
|
#include "node/output/viewer/viewer.h"
|
|
#include "timeline/timelinemarker.h"
|
|
#include "timeline/timelineworkarea.h"
|
|
#include "widget/resizablescrollbar/resizabletimelinescrollbar.h"
|
|
#include "widget/timebased/timebasedview.h"
|
|
#include "widget/timebased/timebasedviewselectionmanager.h"
|
|
#include "widget/timebased/timescaledobject.h"
|
|
#include "widget/timetarget/timetarget.h"
|
|
|
|
namespace
|
|
{
|
|
|
|
// HandMovableView (base of TimeBasedView) connects to Core::instance() at
|
|
// construction, so the application singleton must exist
|
|
void ensure_core()
|
|
{
|
|
if (!olive::Core::instance()) {
|
|
new olive::Core(); // intentionally leaked
|
|
}
|
|
}
|
|
|
|
// TimeScaledObject is not a QObject and its interesting hooks are protected,
|
|
// so expose them through a probe
|
|
class ProbeScaledObject : public olive::TimeScaledObject {
|
|
public:
|
|
int timebase_events = 0;
|
|
olive::Rational last_timebase;
|
|
int scale_events = 0;
|
|
double last_scale = 0.0;
|
|
|
|
void pub_set_minimum_scale(const double &min)
|
|
{
|
|
set_minimum_scale(min);
|
|
}
|
|
void pub_set_maximum_scale(const double &max)
|
|
{
|
|
set_maximum_scale(max);
|
|
}
|
|
|
|
protected:
|
|
void TimebaseChangedEvent(const olive::Rational &tb) override
|
|
{
|
|
timebase_events++;
|
|
last_timebase = tb;
|
|
}
|
|
|
|
void ScaleChangedEvent(const double &scale) override
|
|
{
|
|
scale_events++;
|
|
last_scale = scale;
|
|
}
|
|
};
|
|
|
|
// Records the y-axis hook and exposes the protected playhead/zoom entry
|
|
// points of TimeBasedView
|
|
class ProbeTimeBasedView : public olive::TimeBasedView {
|
|
public:
|
|
int y_scale_events = 0;
|
|
double last_y_scale = 0.0;
|
|
std::vector<void *> select_events;
|
|
std::vector<void *> deselect_events;
|
|
|
|
void pub_set_y_axis_enabled(bool e)
|
|
{
|
|
set_y_axis_enabled(e);
|
|
}
|
|
void pub_zoom(QWheelEvent *e, double multiplier, const QPointF &pos)
|
|
{
|
|
zoom_into_cursor_position(e, multiplier, pos);
|
|
}
|
|
bool pub_playhead_press(QMouseEvent *e)
|
|
{
|
|
return playhead_press(e);
|
|
}
|
|
bool pub_playhead_move(QMouseEvent *e)
|
|
{
|
|
return playhead_move(e);
|
|
}
|
|
bool pub_playhead_release(QMouseEvent *e)
|
|
{
|
|
return playhead_release(e);
|
|
}
|
|
|
|
void SelectionManagerSelectEvent(void *obj) override
|
|
{
|
|
select_events.push_back(obj);
|
|
}
|
|
void SelectionManagerDeselectEvent(void *obj) override
|
|
{
|
|
deselect_events.push_back(obj);
|
|
}
|
|
|
|
protected:
|
|
void VerticalScaleChangedEvent(double scale) override
|
|
{
|
|
y_scale_events++;
|
|
last_y_scale = scale;
|
|
}
|
|
};
|
|
|
|
// Stand-in selectable object for the templated selection manager; the drag
|
|
// paths (which need engine keyframe/marker free functions) are never
|
|
// instantiated by these tests
|
|
struct FakeSelectable {
|
|
};
|
|
|
|
// Records the protected TimeTargetObject hooks
|
|
class ProbeTimeTarget : public olive::TimeTargetObject {
|
|
public:
|
|
std::vector<OakEngineNode *> connected;
|
|
std::vector<OakEngineNode *> disconnected;
|
|
std::vector<OakEngineNode *> changed;
|
|
|
|
protected:
|
|
void TimeTargetConnectEvent(OakEngineNode *n) override
|
|
{
|
|
connected.push_back(n);
|
|
}
|
|
void TimeTargetDisconnectEvent(OakEngineNode *n) override
|
|
{
|
|
disconnected.push_back(n);
|
|
}
|
|
void TimeTargetChangedEvent(OakEngineNode *n) override
|
|
{
|
|
changed.push_back(n);
|
|
}
|
|
};
|
|
|
|
QMouseEvent make_press(const QPointF &pos, Qt::MouseButton button,
|
|
Qt::KeyboardModifiers mods = Qt::NoModifier)
|
|
{
|
|
return QMouseEvent(QEvent::MouseButtonPress, pos, pos, pos, button, button,
|
|
mods);
|
|
}
|
|
|
|
// The selection manager hit-tests in unscaled scene coordinates: it maps the
|
|
// view-local click through mapToScene() and then unscale_point(). A hidden
|
|
// QGraphicsView centers its (zero-height) scene rect inside the viewport, so
|
|
// view-local coordinates are NOT scene coordinates; use the inverse mapping
|
|
// to aim a synthetic event at an unscaled scene point deterministically.
|
|
QPoint view_pos_for_unscaled_point(olive::TimeBasedView *view, const QPointF &p)
|
|
{
|
|
return view->mapFromScene(view->scale_point(p));
|
|
}
|
|
|
|
// Renders a widget into a fresh image so two states can be compared
|
|
// pixel-for-pixel without needing the window to be exposed
|
|
QImage render_widget(QWidget *w)
|
|
{
|
|
QImage img(w->size(), QImage::Format_ARGB32);
|
|
img.fill(Qt::transparent);
|
|
w->render(&img);
|
|
return img;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST(TimeScaledObject, DefaultsAreUnitScaleAndNullTimebase)
|
|
{
|
|
olive::TimelineScaledWidget w;
|
|
EXPECT_DOUBLE_EQ(w.get_scale(), 1.0);
|
|
EXPECT_TRUE(w.timebase().isNull());
|
|
// NB: timebase_dbl() is only meaningful after set_timebase(); the
|
|
// constructor leaves it uninitialized, so it is not asserted here
|
|
EXPECT_GT(w.get_maximum_scale(), 1.0);
|
|
}
|
|
|
|
TEST(TimeScaledObject, SetTimebaseCachesDoubleAndFiresEvent)
|
|
{
|
|
ProbeScaledObject o;
|
|
o.set_timebase(olive::Rational(1, 30));
|
|
|
|
EXPECT_EQ(o.timebase(), olive::Rational(1, 30));
|
|
EXPECT_NEAR(o.timebase_dbl(), 1.0 / 30.0, 1e-12);
|
|
EXPECT_EQ(o.timebase_events, 1);
|
|
EXPECT_EQ(o.last_timebase, olive::Rational(1, 30));
|
|
|
|
o.set_timebase(olive::Rational(1, 24));
|
|
EXPECT_EQ(o.timebase_events, 2);
|
|
EXPECT_NEAR(o.timebase_dbl(), 1.0 / 24.0, 1e-12);
|
|
}
|
|
|
|
TEST(TimeScaledObject, ScaleClampsToMinimumAndMaximum)
|
|
{
|
|
ProbeScaledObject o;
|
|
o.pub_set_minimum_scale(0.5);
|
|
o.pub_set_maximum_scale(10.0);
|
|
|
|
// Default scale (1.0) is inside the new limits, so no clamp event yet
|
|
EXPECT_DOUBLE_EQ(o.get_scale(), 1.0);
|
|
|
|
o.set_scale(100.0);
|
|
EXPECT_DOUBLE_EQ(o.get_scale(), 10.0);
|
|
EXPECT_DOUBLE_EQ(o.last_scale, 10.0);
|
|
|
|
o.set_scale(0.01);
|
|
EXPECT_DOUBLE_EQ(o.get_scale(), 0.5);
|
|
EXPECT_DOUBLE_EQ(o.last_scale, 0.5);
|
|
|
|
o.set_scale(2.0);
|
|
EXPECT_DOUBLE_EQ(o.get_scale(), 2.0);
|
|
EXPECT_EQ(o.scale_events, 3);
|
|
}
|
|
|
|
TEST(TimeScaledObject, ShrinkingLimitsPullScaleIntoRange)
|
|
{
|
|
ProbeScaledObject o;
|
|
o.set_scale(5.0);
|
|
|
|
// Lowering the maximum below the current scale clamps the scale down
|
|
o.pub_set_maximum_scale(2.0);
|
|
EXPECT_DOUBLE_EQ(o.get_scale(), 2.0);
|
|
|
|
// Raising the minimum above the current scale pushes the scale up
|
|
o.pub_set_maximum_scale(100.0);
|
|
o.pub_set_minimum_scale(4.0);
|
|
EXPECT_DOUBLE_EQ(o.get_scale(), 4.0);
|
|
}
|
|
|
|
TEST(TimeScaledObject, InvertedLimitsNeverBreakScaleClamp)
|
|
{
|
|
ProbeScaledObject o;
|
|
o.set_scale(5.0);
|
|
|
|
// Narrow the valid range to [2, 3]
|
|
o.pub_set_maximum_scale(3.0);
|
|
o.pub_set_minimum_scale(2.0);
|
|
EXPECT_DOUBLE_EQ(o.get_scale(), 3.0);
|
|
|
|
// Raising the minimum past the maximum pulls the maximum up with it, so
|
|
// the scale lands exactly on the new minimum instead of hitting an
|
|
// inverted std::clamp (undefined behavior)
|
|
o.pub_set_minimum_scale(10.0);
|
|
EXPECT_DOUBLE_EQ(o.get_maximum_scale(), 10.0);
|
|
EXPECT_DOUBLE_EQ(o.get_scale(), 10.0);
|
|
|
|
// Lowering the maximum past the minimum pushes the minimum down instead
|
|
o.pub_set_maximum_scale(4.0);
|
|
EXPECT_DOUBLE_EQ(o.get_maximum_scale(), 4.0);
|
|
EXPECT_DOUBLE_EQ(o.get_scale(), 4.0);
|
|
}
|
|
|
|
TEST(TimeScaledObject, TimeToSceneScalesTime)
|
|
{
|
|
olive::TimelineScaledWidget w;
|
|
|
|
// Null timebase maps everything to zero
|
|
EXPECT_DOUBLE_EQ(w.time_to_scene(olive::Rational(10)), 0.0);
|
|
|
|
w.set_timebase(olive::Rational(1, 30));
|
|
w.set_scale(30.0);
|
|
|
|
// time_to_scene is time in seconds multiplied by the scale
|
|
EXPECT_DOUBLE_EQ(w.time_to_scene(olive::Rational(1, 30)), 1.0);
|
|
EXPECT_DOUBLE_EQ(w.time_to_scene(olive::Rational(2)), 60.0);
|
|
}
|
|
|
|
TEST(TimeScaledObject, SceneToTimeFloorsCeilsAndRounds)
|
|
{
|
|
olive::TimelineScaledWidget w;
|
|
w.set_timebase(olive::Rational(1, 30));
|
|
w.set_scale(30.0);
|
|
|
|
// 45.5 px / 30 px-per-frame = 45.5 frames
|
|
// floor for positive, ceil for negative, nearest when rounding
|
|
EXPECT_EQ(w.scene_to_time(45.5), olive::Rational(45, 30));
|
|
EXPECT_EQ(w.scene_to_time(-10.5), olive::Rational(-10, 30));
|
|
EXPECT_EQ(w.scene_to_time(45.5, true), olive::Rational(46, 30));
|
|
|
|
// Exact grid points map exactly
|
|
EXPECT_EQ(w.scene_to_time(60.0), olive::Rational(2));
|
|
|
|
// Null timebase always yields a null time
|
|
olive::TimelineScaledWidget null_tb;
|
|
EXPECT_TRUE(null_tb.scene_to_time(123.0).isNull());
|
|
EXPECT_TRUE(
|
|
olive::TimeScaledObject::scene_to_time(10.0, 1.0, olive::Rational())
|
|
.isNull());
|
|
}
|
|
|
|
TEST(TimeScaledObject, SceneToTimeNoGridIgnoresTimebase)
|
|
{
|
|
// Static form is a pure scale division
|
|
const olive::Rational r =
|
|
olive::TimeScaledObject::scene_to_time_no_grid(3.0, 2.0);
|
|
EXPECT_NEAR(r.to_double(), 1.5, 1e-9);
|
|
|
|
// The instance form with a null timebase divides by the current scale
|
|
olive::TimelineScaledWidget w;
|
|
w.set_scale(2.0);
|
|
EXPECT_NEAR(w.scene_to_time_no_grid(3.0).to_double(), 1.5, 1e-9);
|
|
|
|
// A set timebase does not affect the no-grid conversion
|
|
w.set_timebase(olive::Rational(1, 30));
|
|
EXPECT_NEAR(w.scene_to_time_no_grid(3.0).to_double(), 1.5, 1e-9);
|
|
}
|
|
|
|
TEST(TimeScaledObject, ScaleFromDimensionsMath)
|
|
{
|
|
// (viewport / 10 * 9) / content
|
|
EXPECT_DOUBLE_EQ(
|
|
olive::TimeScaledObject::calculate_scale_from_dimensions(1000, 500),
|
|
1.8);
|
|
EXPECT_DOUBLE_EQ(
|
|
olive::TimeScaledObject::calculate_padding_from_dimension_scale(100),
|
|
5.0);
|
|
|
|
olive::TimelineScaledWidget w;
|
|
w.set_scale_from_dimensions(1000, 500);
|
|
EXPECT_DOUBLE_EQ(w.get_scale(), 1.8);
|
|
}
|
|
|
|
TEST(TimeBasedView, ConstructionDefaults)
|
|
{
|
|
ensure_core();
|
|
|
|
olive::TimeBasedView view;
|
|
EXPECT_DOUBLE_EQ(view.get_scale(), 1.0);
|
|
EXPECT_DOUBLE_EQ(view.get_y_scale(), 1.0);
|
|
EXPECT_FALSE(view.is_snapped());
|
|
EXPECT_EQ(view.get_snap_service(), nullptr);
|
|
EXPECT_EQ(view.get_viewer_node(), nullptr);
|
|
EXPECT_FALSE(view.is_dragging_playhead());
|
|
EXPECT_EQ(view.dragMode(), QGraphicsView::NoDrag);
|
|
EXPECT_NE(view.scene(), nullptr);
|
|
}
|
|
|
|
TEST(TimeBasedView, SnapEnableDisableTogglesState)
|
|
{
|
|
ensure_core();
|
|
|
|
olive::TimeBasedView view;
|
|
view.enable_snap({ olive::Rational(1), olive::Rational(2) });
|
|
EXPECT_TRUE(view.is_snapped());
|
|
|
|
view.disable_snap();
|
|
EXPECT_FALSE(view.is_snapped());
|
|
}
|
|
|
|
TEST(TimeBasedView, YScaleEventOnlyFiresWhenAxisEnabled)
|
|
{
|
|
ensure_core();
|
|
|
|
ProbeTimeBasedView view;
|
|
|
|
// Y axis disabled: the value is stored but no event fires
|
|
view.set_y_scale(2.0);
|
|
EXPECT_DOUBLE_EQ(view.get_y_scale(), 2.0);
|
|
EXPECT_EQ(view.y_scale_events, 0);
|
|
|
|
view.pub_set_y_axis_enabled(true);
|
|
view.set_y_scale(3.0);
|
|
EXPECT_DOUBLE_EQ(view.get_y_scale(), 3.0);
|
|
EXPECT_EQ(view.y_scale_events, 1);
|
|
EXPECT_DOUBLE_EQ(view.last_y_scale, 3.0);
|
|
}
|
|
|
|
TEST(TimeBasedView, ScalePointAndUnscalePointAreInverses)
|
|
{
|
|
ensure_core();
|
|
|
|
olive::TimeBasedView view;
|
|
view.set_scale(2.0);
|
|
view.set_y_scale(4.0);
|
|
|
|
const QPointF scaled = view.scale_point(QPointF(1.5, 0.5));
|
|
EXPECT_DOUBLE_EQ(scaled.x(), 3.0);
|
|
EXPECT_DOUBLE_EQ(scaled.y(), 2.0);
|
|
|
|
const QPointF unscaled = view.unscale_point(scaled);
|
|
EXPECT_DOUBLE_EQ(unscaled.x(), 1.5);
|
|
EXPECT_DOUBLE_EQ(unscaled.y(), 0.5);
|
|
}
|
|
|
|
TEST(TimeBasedView, EndTimeDrivesSceneRectRightEdge)
|
|
{
|
|
ensure_core();
|
|
|
|
olive::TimeBasedView view;
|
|
view.resize(400, 300);
|
|
view.set_timebase(olive::Rational(1, 30));
|
|
view.set_scale(100.0);
|
|
view.set_end_time(olive::Rational(10));
|
|
|
|
// update_scene_rect pins the left edge to zero and puts the right edge
|
|
// one viewport width past the end of the content
|
|
QRectF rect = view.scene()->sceneRect();
|
|
EXPECT_DOUBLE_EQ(rect.left(), 0.0);
|
|
EXPECT_DOUBLE_EQ(rect.right(), 10.0 * 100.0 + view.width());
|
|
|
|
// Changing the scale re-runs update_scene_rect
|
|
view.set_scale(200.0);
|
|
rect = view.scene()->sceneRect();
|
|
EXPECT_DOUBLE_EQ(rect.right(), 10.0 * 200.0 + view.width());
|
|
}
|
|
|
|
TEST(TimeBasedView, ZoomWithoutYAxisEmitsHorizontalScaleChange)
|
|
{
|
|
ensure_core();
|
|
|
|
ProbeTimeBasedView view;
|
|
view.resize(400, 300);
|
|
QSignalSpy spy(&view, &olive::TimeBasedView::scale_changed);
|
|
|
|
QWheelEvent wheel(QPointF(10, 10), QPointF(10, 10), QPoint(),
|
|
QPoint(0, 120), Qt::NoButton, Qt::NoModifier,
|
|
Qt::NoScrollPhase, false);
|
|
view.pub_zoom(&wheel, 2.0, QPointF(10, 10));
|
|
|
|
// The view only emits the requested scale; applying it is up to the
|
|
// parent widget, so the stored scale is unchanged
|
|
ASSERT_EQ(spy.count(), 1);
|
|
EXPECT_DOUBLE_EQ(spy.first().first().toDouble(), 2.0);
|
|
EXPECT_DOUBLE_EQ(view.get_scale(), 1.0);
|
|
EXPECT_DOUBLE_EQ(view.get_y_scale(), 1.0);
|
|
}
|
|
|
|
TEST(TimeBasedView, ZoomWithYAxisAndShiftOnlyScalesVertically)
|
|
{
|
|
ensure_core();
|
|
|
|
ProbeTimeBasedView view;
|
|
view.resize(400, 300);
|
|
view.pub_set_y_axis_enabled(true);
|
|
QSignalSpy spy(&view, &olive::TimeBasedView::scale_changed);
|
|
|
|
// Shift (without Alt) restricts the zoom to the vertical axis
|
|
QWheelEvent wheel(QPointF(10, 10), QPointF(10, 10), QPoint(),
|
|
QPoint(0, 120), Qt::NoButton, Qt::ShiftModifier,
|
|
Qt::NoScrollPhase, false);
|
|
view.pub_zoom(&wheel, 2.0, QPointF(10, 10));
|
|
|
|
EXPECT_EQ(spy.count(), 0);
|
|
EXPECT_DOUBLE_EQ(view.get_y_scale(), 2.0);
|
|
EXPECT_EQ(view.y_scale_events, 1);
|
|
}
|
|
|
|
TEST(TimeBasedView, ZoomWithYAxisShiftAndAltOnlyScalesHorizontally)
|
|
{
|
|
ensure_core();
|
|
|
|
ProbeTimeBasedView view;
|
|
view.resize(400, 300);
|
|
view.pub_set_y_axis_enabled(true);
|
|
QSignalSpy spy(&view, &olive::TimeBasedView::scale_changed);
|
|
|
|
QWheelEvent wheel(QPointF(10, 10), QPointF(10, 10), QPoint(),
|
|
QPoint(0, 120), Qt::NoButton,
|
|
Qt::ShiftModifier | Qt::AltModifier, Qt::NoScrollPhase,
|
|
false);
|
|
view.pub_zoom(&wheel, 2.0, QPointF(10, 10));
|
|
|
|
ASSERT_EQ(spy.count(), 1);
|
|
EXPECT_DOUBLE_EQ(spy.first().first().toDouble(), 2.0);
|
|
EXPECT_DOUBLE_EQ(view.get_y_scale(), 1.0);
|
|
EXPECT_EQ(view.y_scale_events, 0);
|
|
}
|
|
|
|
TEST(TimeBasedView, PlayheadDragRequiresPaintedPlayheadRect)
|
|
{
|
|
ensure_core();
|
|
|
|
ProbeTimeBasedView view;
|
|
view.resize(400, 300);
|
|
|
|
// The playhead hit rect is only populated by drawForeground; before any
|
|
// paint the rect is empty and nothing can grab the playhead
|
|
QMouseEvent press = make_press(QPointF(50, 50), Qt::LeftButton);
|
|
EXPECT_FALSE(view.pub_playhead_press(&press));
|
|
EXPECT_FALSE(view.is_dragging_playhead());
|
|
|
|
// Move/release are no-ops without an active drag
|
|
QMouseEvent move(QEvent::MouseMove, QPointF(60, 50), QPointF(60, 50),
|
|
QPointF(60, 50), Qt::NoButton, Qt::LeftButton,
|
|
Qt::NoModifier);
|
|
EXPECT_FALSE(view.pub_playhead_move(&move));
|
|
|
|
QMouseEvent release(QEvent::MouseButtonRelease, QPointF(60, 50),
|
|
QPointF(60, 50), QPointF(60, 50), Qt::LeftButton,
|
|
Qt::NoButton, Qt::NoModifier);
|
|
EXPECT_FALSE(view.pub_playhead_release(&release));
|
|
}
|
|
|
|
TEST(TimeBasedView, SetViewerNodeStoresAndClears)
|
|
{
|
|
ensure_core();
|
|
|
|
olive::TimeBasedView view;
|
|
auto *viewer = new olive::ViewerOutput();
|
|
OakEngineNode *handle = reinterpret_cast<OakEngineNode *>(viewer);
|
|
|
|
view.set_viewer_node(handle);
|
|
EXPECT_EQ(view.get_viewer_node(), handle);
|
|
|
|
view.set_viewer_node(nullptr);
|
|
EXPECT_EQ(view.get_viewer_node(), nullptr);
|
|
|
|
delete viewer;
|
|
}
|
|
|
|
TEST(TimeBasedViewSelectionManager, SelectDeselectSemantics)
|
|
{
|
|
ensure_core();
|
|
|
|
olive::TimeBasedView view;
|
|
olive::TimeBasedViewSelectionManager<FakeSelectable> mgr(&view);
|
|
|
|
FakeSelectable a, b;
|
|
EXPECT_TRUE(mgr.get_selected_objects().empty());
|
|
|
|
EXPECT_TRUE(mgr.select(&a));
|
|
EXPECT_TRUE(mgr.is_selected(&a));
|
|
EXPECT_FALSE(mgr.is_selected(&b));
|
|
|
|
// Re-selecting an already selected object is a no-op
|
|
EXPECT_FALSE(mgr.select(&a));
|
|
EXPECT_EQ(mgr.get_selected_objects().size(), 1u);
|
|
|
|
EXPECT_TRUE(mgr.select(&b));
|
|
EXPECT_EQ(mgr.get_selected_objects().size(), 2u);
|
|
|
|
EXPECT_TRUE(mgr.deselect(&a));
|
|
EXPECT_FALSE(mgr.is_selected(&a));
|
|
|
|
// Deselecting something not selected reports failure
|
|
EXPECT_FALSE(mgr.deselect(&a));
|
|
|
|
mgr.clear_selection();
|
|
EXPECT_TRUE(mgr.get_selected_objects().empty());
|
|
EXPECT_FALSE(mgr.is_selected(&b));
|
|
}
|
|
|
|
TEST(TimeBasedViewSelectionManager, ObjectAtPointPrefersLastDrawn)
|
|
{
|
|
ensure_core();
|
|
|
|
olive::TimeBasedView view; // scale 1.0, y scale 1.0: scene == unscaled
|
|
olive::TimeBasedViewSelectionManager<FakeSelectable> mgr(&view);
|
|
|
|
FakeSelectable bottom, top;
|
|
mgr.declare_drawn_object(&bottom, QRectF(0, 0, 100, 100));
|
|
mgr.declare_drawn_object(&top, QRectF(50, 50, 100, 100));
|
|
|
|
// In the overlap the object drawn later (i.e. on top) wins
|
|
EXPECT_EQ(mgr.get_object_at_point(QPointF(75, 75)), &top);
|
|
// Outside the overlap the bottom object is hit
|
|
EXPECT_EQ(mgr.get_object_at_point(QPointF(25, 25)), &bottom);
|
|
// A complete miss returns nullptr
|
|
EXPECT_EQ(mgr.get_object_at_point(QPointF(500, 500)), nullptr);
|
|
|
|
mgr.clear_drawn_objects();
|
|
EXPECT_EQ(mgr.get_object_at_point(QPointF(25, 25)), nullptr);
|
|
}
|
|
|
|
TEST(TimeBasedViewSelectionManager, ObjectAtPointMatchesDeclaredRectBounds)
|
|
{
|
|
ensure_core();
|
|
|
|
olive::TimeBasedView view; // scale 1.0, y scale 1.0: scene == unscaled
|
|
olive::TimeBasedViewSelectionManager<FakeSelectable> mgr(&view);
|
|
|
|
FakeSelectable item;
|
|
mgr.declare_drawn_object(&item, QRectF(10, 10, 30, 30));
|
|
|
|
// The rect spans scene coordinates [10, 40] on both axes
|
|
EXPECT_EQ(mgr.get_object_at_point(QPointF(10, 10)), &item);
|
|
EXPECT_EQ(mgr.get_object_at_point(QPointF(40, 40)), &item);
|
|
EXPECT_EQ(mgr.get_object_at_point(QPointF(41, 41)), nullptr);
|
|
EXPECT_EQ(mgr.get_object_at_point(QPointF(9, 20)), nullptr);
|
|
}
|
|
|
|
TEST(TimeBasedViewSelectionManager, MousePressSelectsAndClears)
|
|
{
|
|
ensure_core();
|
|
|
|
ProbeTimeBasedView view;
|
|
olive::TimeBasedViewSelectionManager<FakeSelectable> mgr(&view);
|
|
|
|
FakeSelectable item;
|
|
mgr.declare_drawn_object(&item, QRectF(10, 10, 20, 20));
|
|
|
|
// Left click on the object selects it and notifies the view
|
|
QMouseEvent hit = make_press(
|
|
view_pos_for_unscaled_point(&view, QPointF(15, 15)), Qt::LeftButton);
|
|
EXPECT_EQ(mgr.mouse_press(&hit), &item);
|
|
EXPECT_TRUE(mgr.is_selected(&item));
|
|
ASSERT_EQ(view.select_events.size(), 1u);
|
|
EXPECT_EQ(view.select_events.at(0), &item);
|
|
|
|
// Left click on empty space clears the selection and returns nothing
|
|
QMouseEvent miss = make_press(
|
|
view_pos_for_unscaled_point(&view, QPointF(300, 300)), Qt::LeftButton);
|
|
EXPECT_EQ(mgr.mouse_press(&miss), nullptr);
|
|
EXPECT_TRUE(mgr.get_selected_objects().empty());
|
|
|
|
// Right click also selects
|
|
QMouseEvent right = make_press(
|
|
view_pos_for_unscaled_point(&view, QPointF(15, 15)), Qt::RightButton);
|
|
EXPECT_EQ(mgr.mouse_press(&right), &item);
|
|
EXPECT_TRUE(mgr.is_selected(&item));
|
|
}
|
|
|
|
TEST(TimeBasedViewSelectionManager, ShiftClickTogglesWithoutClearing)
|
|
{
|
|
ensure_core();
|
|
|
|
ProbeTimeBasedView view;
|
|
olive::TimeBasedViewSelectionManager<FakeSelectable> mgr(&view);
|
|
|
|
FakeSelectable a, b;
|
|
mgr.declare_drawn_object(&a, QRectF(10, 10, 20, 20));
|
|
mgr.declare_drawn_object(&b, QRectF(100, 100, 20, 20));
|
|
|
|
// Plain click selects a
|
|
QMouseEvent press_a = make_press(
|
|
view_pos_for_unscaled_point(&view, QPointF(15, 15)), Qt::LeftButton);
|
|
mgr.mouse_press(&press_a);
|
|
ASSERT_TRUE(mgr.is_selected(&a));
|
|
|
|
// Shift-click on b adds it without clearing a
|
|
QMouseEvent shift_b =
|
|
make_press(view_pos_for_unscaled_point(&view, QPointF(105, 105)),
|
|
Qt::LeftButton, Qt::ShiftModifier);
|
|
EXPECT_EQ(mgr.mouse_press(&shift_b), &b);
|
|
EXPECT_TRUE(mgr.is_selected(&a));
|
|
EXPECT_TRUE(mgr.is_selected(&b));
|
|
|
|
// Shift-click on the already-selected a deselects it and reports no
|
|
// object under the cursor
|
|
QMouseEvent shift_a =
|
|
make_press(view_pos_for_unscaled_point(&view, QPointF(15, 15)),
|
|
Qt::LeftButton, Qt::ShiftModifier);
|
|
EXPECT_EQ(mgr.mouse_press(&shift_a), nullptr);
|
|
EXPECT_FALSE(mgr.is_selected(&a));
|
|
EXPECT_TRUE(mgr.is_selected(&b));
|
|
ASSERT_EQ(view.deselect_events.size(), 1u);
|
|
EXPECT_EQ(view.deselect_events.at(0), &a);
|
|
}
|
|
|
|
TEST(TimeBasedViewSelectionManager, RubberBandSelectsIntersectingObjects)
|
|
{
|
|
ensure_core();
|
|
|
|
olive::TimeBasedView view;
|
|
view.resize(400, 300);
|
|
olive::TimeBasedViewSelectionManager<FakeSelectable> mgr(&view);
|
|
|
|
FakeSelectable inside, outside;
|
|
mgr.declare_drawn_object(&inside, QRectF(10, 10, 20, 20));
|
|
mgr.declare_drawn_object(&outside, QRectF(300, 300, 20, 20));
|
|
|
|
EXPECT_FALSE(mgr.is_rubber_banding());
|
|
|
|
QMouseEvent press = make_press(
|
|
view_pos_for_unscaled_point(&view, QPointF(0, 0)), Qt::LeftButton);
|
|
mgr.rubber_band_start(&press);
|
|
EXPECT_TRUE(mgr.is_rubber_banding());
|
|
|
|
mgr.rubber_band_move(view_pos_for_unscaled_point(&view, QPointF(100, 100)));
|
|
EXPECT_TRUE(mgr.is_selected(&inside));
|
|
EXPECT_FALSE(mgr.is_selected(&outside));
|
|
|
|
mgr.rubber_band_stop();
|
|
EXPECT_FALSE(mgr.is_rubber_banding());
|
|
|
|
// Stopping twice is harmless
|
|
mgr.rubber_band_stop();
|
|
}
|
|
|
|
TEST(TimeBasedViewSelectionManager, RubberBandKeepsPreselection)
|
|
{
|
|
ensure_core();
|
|
|
|
olive::TimeBasedView view;
|
|
view.resize(400, 300);
|
|
olive::TimeBasedViewSelectionManager<FakeSelectable> mgr(&view);
|
|
|
|
FakeSelectable pre, banded;
|
|
mgr.declare_drawn_object(&pre, QRectF(300, 300, 20, 20));
|
|
mgr.declare_drawn_object(&banded, QRectF(10, 10, 20, 20));
|
|
mgr.select(&pre);
|
|
|
|
QMouseEvent press = make_press(
|
|
view_pos_for_unscaled_point(&view, QPointF(0, 0)), Qt::LeftButton);
|
|
mgr.rubber_band_start(&press);
|
|
mgr.rubber_band_move(view_pos_for_unscaled_point(&view, QPointF(100, 100)));
|
|
|
|
// Objects selected before the band started stay selected
|
|
EXPECT_TRUE(mgr.is_selected(&pre));
|
|
EXPECT_TRUE(mgr.is_selected(&banded));
|
|
|
|
mgr.rubber_band_stop();
|
|
}
|
|
|
|
TEST(TimeTargetObject, DefaultsToNullTarget)
|
|
{
|
|
olive::TimeTargetObject target;
|
|
EXPECT_EQ(target.get_time_target(), nullptr);
|
|
}
|
|
|
|
TEST(TimeTargetObject, SetTimeTargetFiresEventSequence)
|
|
{
|
|
auto *node_a = new olive::ViewerOutput();
|
|
auto *node_b = new olive::ViewerOutput();
|
|
OakEngineNode *handle_a = reinterpret_cast<OakEngineNode *>(node_a);
|
|
OakEngineNode *handle_b = reinterpret_cast<OakEngineNode *>(node_b);
|
|
|
|
ProbeTimeTarget target;
|
|
|
|
// null -> a: changed + connect, no disconnect
|
|
target.set_time_target(handle_a);
|
|
EXPECT_EQ(target.get_time_target(), handle_a);
|
|
EXPECT_TRUE(target.disconnected.empty());
|
|
ASSERT_EQ(target.changed.size(), 1u);
|
|
EXPECT_EQ(target.changed.at(0), handle_a);
|
|
ASSERT_EQ(target.connected.size(), 1u);
|
|
EXPECT_EQ(target.connected.at(0), handle_a);
|
|
|
|
// a -> b: disconnect(a), changed(b), connect(b)
|
|
target.set_time_target(handle_b);
|
|
ASSERT_EQ(target.disconnected.size(), 1u);
|
|
EXPECT_EQ(target.disconnected.at(0), handle_a);
|
|
ASSERT_EQ(target.changed.size(), 2u);
|
|
EXPECT_EQ(target.changed.at(1), handle_b);
|
|
ASSERT_EQ(target.connected.size(), 2u);
|
|
EXPECT_EQ(target.connected.at(1), handle_b);
|
|
|
|
// b -> null: disconnect(b), changed(null), no connect
|
|
target.set_time_target(nullptr);
|
|
EXPECT_EQ(target.get_time_target(), nullptr);
|
|
ASSERT_EQ(target.disconnected.size(), 2u);
|
|
EXPECT_EQ(target.disconnected.at(1), handle_b);
|
|
ASSERT_EQ(target.changed.size(), 3u);
|
|
EXPECT_EQ(target.changed.at(2), nullptr);
|
|
EXPECT_EQ(target.connected.size(), 2u);
|
|
|
|
delete node_a;
|
|
delete node_b;
|
|
}
|
|
|
|
TEST(TimeTargetObject, AdjustedTimePassesThroughWhenEitherNodeIsNull)
|
|
{
|
|
auto *node = new olive::ViewerOutput();
|
|
OakEngineNode *handle = reinterpret_cast<OakEngineNode *>(node);
|
|
|
|
olive::TimeTargetObject target;
|
|
target.set_path_index(1);
|
|
|
|
// Null source returns the input unchanged
|
|
EXPECT_EQ(target.get_adjusted_time(nullptr, handle, olive::Rational(5),
|
|
olive::k_transform_towards_output),
|
|
olive::Rational(5));
|
|
|
|
// Null target returns the input unchanged
|
|
EXPECT_EQ(target.get_adjusted_time(handle, nullptr, olive::Rational(7),
|
|
olive::k_transform_towards_input),
|
|
olive::Rational(7));
|
|
|
|
// Same for the TimeRange overload
|
|
const olive::TimeRange range(olive::Rational(2), olive::Rational(9));
|
|
const olive::TimeRange out = target.get_adjusted_time(
|
|
nullptr, nullptr, range, olive::k_transform_towards_output);
|
|
EXPECT_EQ(out.in(), olive::Rational(2));
|
|
EXPECT_EQ(out.out(), olive::Rational(9));
|
|
|
|
delete node;
|
|
}
|
|
|
|
TEST(ResizableTimelineScrollBar, ConstructionDefaults)
|
|
{
|
|
olive::ResizableTimelineScrollBar bar;
|
|
EXPECT_EQ(bar.orientation(), Qt::Vertical);
|
|
EXPECT_EQ(bar.singleStep(), 20); // inherited from ResizableScrollBar
|
|
EXPECT_DOUBLE_EQ(bar.get_scale(), 1.0); // TimeScaledObject base
|
|
EXPECT_TRUE(bar.timebase().isNull());
|
|
|
|
olive::ResizableTimelineScrollBar hbar(Qt::Horizontal);
|
|
EXPECT_EQ(hbar.orientation(), Qt::Horizontal);
|
|
}
|
|
|
|
TEST(ResizableTimelineScrollBar, NullConnectionsAreSafe)
|
|
{
|
|
olive::ResizableTimelineScrollBar bar(Qt::Horizontal);
|
|
bar.connect_markers(nullptr);
|
|
bar.connect_work_area(nullptr);
|
|
bar.SetScale(2.0);
|
|
|
|
// Disconnecting when nothing was ever connected is equally safe
|
|
bar.connect_markers(nullptr);
|
|
bar.connect_work_area(nullptr);
|
|
}
|
|
|
|
TEST(ResizableTimelineScrollBar, EnabledWorkAreaChangesPaintedPixels)
|
|
{
|
|
olive::ResizableTimelineScrollBar bar(Qt::Horizontal);
|
|
bar.resize(400, 20);
|
|
bar.set_timebase(olive::Rational(1, 30));
|
|
bar.set_scale(10.0);
|
|
|
|
olive::TimelineWorkArea workarea; // disabled by default
|
|
auto *handle = reinterpret_cast<OakEngineWorkarea *>(&workarea);
|
|
bar.connect_work_area(handle);
|
|
oakengine_workarea_set_range(handle, 1, 1, 10, 1);
|
|
|
|
const QImage disabled = render_widget(&bar);
|
|
|
|
oakengine_workarea_set_enabled(handle, 1);
|
|
const QImage enabled = render_widget(&bar);
|
|
EXPECT_NE(disabled, enabled);
|
|
|
|
// Disabling again restores the base scrollbar paint exactly
|
|
oakengine_workarea_set_enabled(handle, 0);
|
|
EXPECT_EQ(render_widget(&bar), disabled);
|
|
|
|
// Disconnecting the workarea also restores it while the workarea is on
|
|
oakengine_workarea_set_enabled(handle, 1);
|
|
bar.connect_work_area(nullptr);
|
|
EXPECT_EQ(render_widget(&bar), disabled);
|
|
}
|
|
|
|
TEST(ResizableTimelineScrollBar, MarkersChangePaintedPixels)
|
|
{
|
|
olive::ResizableTimelineScrollBar bar(Qt::Horizontal);
|
|
bar.resize(400, 20);
|
|
bar.set_timebase(olive::Rational(1, 30));
|
|
bar.set_scale(10.0);
|
|
|
|
const QImage clean = render_widget(&bar);
|
|
|
|
olive::TimelineMarkerList list;
|
|
auto *list_handle = reinterpret_cast<OakEngineMarkerList *>(&list);
|
|
bar.connect_markers(list_handle);
|
|
|
|
// Parenting a marker to the list registers it through the list's
|
|
// childEvent
|
|
auto *marker = new olive::TimelineMarker(
|
|
0, olive::TimeRange(olive::Rational(2), olive::Rational(5)),
|
|
QStringLiteral("m"), &list);
|
|
ASSERT_EQ(oakengine_marker_list_count(list_handle), 1);
|
|
|
|
const QImage with_marker = render_widget(&bar);
|
|
EXPECT_NE(clean, with_marker);
|
|
|
|
// Removing the marker restores the base paint. NB: the list only
|
|
// unregisters a marker on ChildRemoved, which must arrive while the
|
|
// marker is still fully constructed (its childEvent dynamic_casts the
|
|
// child to TimelineMarker), so reparent before deleting — the same
|
|
// order MarkerRemoveCommand uses.
|
|
marker->setParent(nullptr);
|
|
delete marker;
|
|
EXPECT_EQ(render_widget(&bar), clean);
|
|
|
|
// Disconnecting the list with a marker present also restores it
|
|
auto *marker2 = new olive::TimelineMarker(
|
|
0, olive::TimeRange(olive::Rational(2), olive::Rational(5)),
|
|
QStringLiteral("m2"), &list);
|
|
bar.connect_markers(nullptr);
|
|
EXPECT_EQ(render_widget(&bar), clean);
|
|
delete marker2;
|
|
}
|
|
|