Compare commits

..
13 Commits
Author SHA1 Message Date
Mike-Solar bb09872a2f Merge remote-tracking branch 'upstream/main'
CI / build-test (warp-macos-15-arm64-6x) (push) Canceled after 0s
CI / build-test (warp-ubuntu-latest-x64-8x) (push) Canceled after 0s
CI / build-test (warp-windows-latest-x64-16x) (push) Canceled after 0s
2026-08-05 14:57:23 +08:00
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
Mike-Solar 7b6042168a fix(widget): harden TimelineAndTrackView teardown and TimeScaledObject limits
- TimelineAndTrackView: disconnect the scrollbar sync connections in the
  destructor. Child views reset their scene during destruction, which can
  re-emit QScrollBar::valueChanged into a half-destroyed object and trip
  Qt's assertObjectType abort.
- TimeScaledObject: keep minimum <= maximum when setting scale limits so
  set_scale()'s std::clamp is never called with inverted limits (UB).
  Setting the maximum below the minimum now pulls the minimum down, and
  vice versa, matching QAbstractSlider conventions.
2026-08-05 14:53:04 +08:00
Mike-Solar 774eda75fe docs(riir): design inter-module C ABI interfaces and split out oakstorage
- riir.md: split project file read/write into standalone oakstorage
  module (pluggable backend, DB-replaceable); add Mermaid data flow
  diagram of all modules; adjust batch table and milestones
- riir/04-interfaces.md: new provides/consumes contract matrix for
  all modules
- riir/M10-oakstorage.md: new manual with frozen C API (typed opaque
  handles, manual vtable for storage backends, URI addressing)
- riir/01: codify pure-C OO interface rules (typed handles instead of
  void*, init/free pairs, manual vtables, no C++ objects or member
  calls across shared library boundaries); upper layers issue commands
  only, no module-to-module event subscriptions (async task callbacks
  excepted)
- sync 00/02/03 and M2-M9 manuals: remove subscribe APIs, replace
  void* with typed handles, update tests accordingly
2026-08-05 14:48:08 +08:00
Mike-Solar b55d626c0c docs: dependency analysis and two-person split for issues 9-20 (#34-#45) 2026-08-05 01:16:07 +08:00
Mike-Solar 00328ee95e Merge pull request #57 from Mike-Solar/main
app: migrate playhead/undo/modified events off EventBridge (issues 5-8)
2026-08-05 01:12:31 +08:00
Mike-Solar 40eea8d97c ci: use smaller runners 2026-08-05 00:59:09 +08:00
Mike-Solar a030f2da20 app: migrate playhead/undo/modified events off EventBridge (issues 5-8)
- issue 5 (#53): ExportDialog playhead via PlaybackController
- issue 6 (#54): timebasedwidget/timelinewidget playhead via PlaybackController
- issue 7 (#55): historywidget via app-internal Core::undo_index_changed
- issue 8 (#33): PROJECT_MODIFIED_CHANGED re-broadcast as Core::project_modified_changed
2026-08-05 00:52:37 +08:00
Mike-Solar 1738dbf224 Merge pull request #56 from Mike-Solar/main
app: PlaybackController + playhead subscription migration (issues 0c,…
2026-08-05 00:13:06 +08:00
Mike-Solar b77344b506 app: PlaybackController + playhead subscription migration (issues 0c, 1-4)
New app/playback/playbackcontroller.{h,cpp}: a lazily created
process-wide singleton whose set_playhead() forwards to
oakengine_viewer_set_playhead and re-broadcasts playhead_changed as a
plain Qt signal. Every app-side oakengine_viewer_set_playhead call site
(17 across viewer, timelinewidget, timelineview, import tool,
timebasedwidget, keyframecontrol, export dialog, mainwindow) now goes
through it.

Migrate the first four playhead subscribers off the raw C event
subscription / EngineEventBridge to the controller signal:
- TimeBasedView (issue 1)
- NodeParamViewWidgetBridge (issue 2)
- NodeParamViewKeyframeControl (issue 3)
- NodeParamViewConnectedLabel (issue 4)

Each stores a QMetaObject::Connection filtered on its viewer node and
disconnects on teardown; the viewer_sub_ members are gone. Plan docs
mark issues 0c and 1-4 as done.

Closes #30, closes #31, closes #32, closes #51, closes #52.
2026-08-05 00:01:12 +08:00
Mike-Solar ac7d942ec7 Merge pull request #27 from lulumumu9999/main
fix: issue #26 Assign independent ID for AUDIO_MANAGER_OUTPUT_NOTIFY
2026-08-04 23:24:05 +08:00
Mike-Solar 5baa9e3814 Merge branch 'main' into lulumumu9999/main 2026-08-04 23:10:33 +08:00
lulumumu9999 e995aa80ec fix: issue0a 修复音频事件ID碰撞,给AUDIO_MANAGER_OUTPUT_NOTIFY分配独立ID 2026-08-02 23:54:11 +08:00
65 changed files with 10806 additions and 289 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [warp-ubuntu-latest-x64-16x, warp-macos-15-arm64-12x, warp-windows-latest-x64-32x]
os: [warp-ubuntu-latest-x64-8x, warp-macos-15-arm64-6x, warp-windows-latest-x64-16x]
env:
CMAKE_BUILD_TYPE: Release
CCACHE_DIR: ${{ github.workspace }}/.ccache
+2
View File
@@ -24,6 +24,8 @@ set(OLIVE_SOURCES
common/colorcodingapp.h
common/colorcodingapp.cpp
common/nodevaluehandle.h
playback/playbackcontroller.h
playback/playbackcontroller.cpp
)
#set(OLIVE_RESOURCES)
+25 -5
View File
@@ -102,6 +102,18 @@ Core::Core(const OakEngineAppParams *params)
oakengine_app_create(&default_params);
}
// Re-broadcast undo-stack index changes as the app-internal Qt signal
// undo_index_changed (issue 7 of the EventBridge elimination plan), so
// widgets no longer need raw oakengine_event_subscribe callbacks. Core is
// a process-lifetime singleton, so the subscription is never unsubscribed.
oakengine_event_subscribe(
oakengine_undo_handle(), OAKENGINE_EVENT_UNDO_INDEX_CHANGED,
[](const oakengine_event *event, void *userdata) {
emit static_cast<Core *>(userdata)->undo_index_changed(
static_cast<int>(event->a));
},
this);
// Register the UI handlers that the engine uses to request user interaction
// through the C ABI callback struct instead of engine_core_->set_*_handler().
{
@@ -772,6 +784,11 @@ void Core::start_gui(bool full_screen)
// Create main window and open it
main_window_ = new MainWindow();
// The title-bar modified marker follows the app-internal signal
// (re-broadcast from the active project in on_active_project_changed).
connect(this, &Core::project_modified_changed, main_window_,
&QMainWindow::setWindowModified);
// Route engine notifications to the UI
connect(this, &Core::tool_changed, this, [](const Tool::Item &) {});
// Status-bar and lifecycle notifications are handled through the facade
@@ -1103,14 +1120,17 @@ void Core::on_active_project_changed(OakEngineProject *p)
main_window_->set_project(p);
if (p) {
// Keep the window's modified state in sync via event subscription
// (connection is removed automatically when the project is deleted).
// Re-broadcast the project's modified flag as the app-internal Qt
// signal project_modified_changed (issue 8 of the EventBridge
// elimination plan); the main window drives setWindowModified from
// it. The subscription is removed automatically when the project is
// deleted.
oakengine_event_subscribe(p, OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED,
[](const oakengine_event *event, void *userdata) {
QMainWindow *mw = static_cast<QMainWindow *>(userdata);
mw->setWindowModified(event->a != 0);
emit static_cast<Core *>(userdata)->project_modified_changed(
event->a != 0);
},
main_window_);
this);
}
}
+17
View File
@@ -288,6 +288,23 @@ signals:
void color_picker_enabled(bool e);
void color_picker_color_emitted(const Color &reference, const Color &display);
/**
* @brief App-internal re-broadcast of the engine undo-stack index change
* (issue 7 of the EventBridge elimination plan). Widgets connect to this
* instead of raw oakengine_event_subscribe callbacks on
* OAKENGINE_EVENT_UNDO_INDEX_CHANGED. The argument is the new stack index.
*/
void undo_index_changed(int index);
/**
* @brief App-internal re-broadcast of the active project's modified-flag
* change (issue 8 of the EventBridge elimination plan). The main window
* drives setWindowModified from this instead of a raw
* oakengine_event_subscribe callback on
* OAKENGINE_EVENT_PROJECT_MODIFIED_CHANGED.
*/
void project_modified_changed(bool modified);
private:
/**
* @brief Get the file filter than can be used with QFileDialog to open and save compatible projects
+13 -21
View File
@@ -47,6 +47,7 @@
#include "oakengine/encoding.h"
#include "oakengine/timeline.h"
#include "oakengine/viewer.h"
#include "playback/playbackcontroller.h"
#include "ui/icons/icons.h"
#include "widget/timeruler/timeruler.h"
#include "common/configwrapper.h"
@@ -307,19 +308,20 @@ ExportDialog::ExportDialog(OakEngineNode *viewer_node, bool stills_only_mode,
video_tab_ = new ExportVideoTab(color_manager_);
add_preferences_tab(video_tab_, tr("Video"));
// Set video tab time and make connections
viewer_sub_ = oakengine_event_subscribe(
viewer_node,
OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED,
[](const oakengine_event *event, void *userdata) {
auto *dlg = static_cast<ExportDialog *>(userdata);
auto *tab = dlg->video_tab_;
tab->set_time(Rational(event->a, event->b));
},
this);
// Set video tab time and make connections. The dialog follows the
// playhead through PlaybackController instead of a raw C event
// subscription (issue 5 of the EventBridge elimination plan); the
// connection auto-disconnects with `this`.
connect(PlaybackController::instance(),
&PlaybackController::playhead_changed, this,
[this](OakEngineNode *n, const Rational &time) {
if (n == viewer_node_) {
video_tab_->set_time(time);
}
});
connect(video_tab_, &ExportVideoTab::time_changed, this,
[viewer_node](const Rational &time) {
oakengine_viewer_set_playhead(
PlaybackController::instance()->set_playhead(
viewer_node,
time.numerator(), time.denominator());
});
@@ -462,16 +464,6 @@ ExportDialog::ExportDialog(OakEngineNode *viewer_node, bool stills_only_mode,
subtitle_tab_->setEnabled(subtitles_enabled_->isChecked());
}
ExportDialog::~ExportDialog()
{
// Raw C-API subscription carries `this` as userdata; not covered by
// Qt's auto-disconnect.
if (viewer_sub_ > 0) {
oakengine_event_unsubscribe(viewer_sub_);
viewer_sub_ = 0;
}
}
Rational ExportDialog::get_selected_timebase() const
{
return video_tab_->get_selected_frame_rate().flipped();
-3
View File
@@ -49,7 +49,6 @@ public:
: ExportDialog(viewer_node, false, parent)
{
}
~ExportDialog() override;
Rational get_selected_timebase() const;
void set_selected_timebase(const Rational &r);
@@ -77,8 +76,6 @@ private:
OakEngineNode *viewer_node_;
int64_t viewer_sub_ = 0;
int previously_selected_format_;
Rational get_export_length() const;
+38
View File
@@ -0,0 +1,38 @@
#include "playbackcontroller.h"
namespace olive
{
PlaybackController *PlaybackController::instance_ = nullptr;
PlaybackController *PlaybackController::instance()
{
if (!instance_) {
instance_ = new PlaybackController();
}
return instance_;
}
PlaybackController::PlaybackController(QObject *parent)
: QObject(parent)
{
}
void PlaybackController::set_playhead(OakEngineNode *viewer,
const core::Rational &time)
{
if (!viewer) {
return;
}
oakengine_viewer_set_playhead(viewer, time.numerator(),
time.denominator());
emit playhead_changed(viewer, time);
}
void PlaybackController::set_playhead(OakEngineNode *viewer, int64_t num,
int64_t den)
{
set_playhead(viewer, core::Rational(num, den));
}
}
+50
View File
@@ -0,0 +1,50 @@
#ifndef OAK_PLAYBACKCONTROLLER_H
#define OAK_PLAYBACKCONTROLLER_H
#include <QObject>
#include "oakengine/viewer.h"
#include "olive/core/util/rational.h"
namespace olive
{
/**
* @brief App-internal hub for playhead changes (issue 0c of the
* EventBridge elimination plan).
*
* Every app-side `oakengine_viewer_set_playhead` call site goes through
* set_playhead(), which forwards to the engine and re-broadcasts
* playhead_changed as a plain Qt signal. Widgets subscribe to that signal
* instead of OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED via EngineEventBridge
* or raw oakengine_event_subscribe callbacks.
*/
class PlaybackController : public QObject {
Q_OBJECT
public:
/**
* @brief Lazily created process-wide instance (leaked at exit, like
* Core). Widget tests construct widgets without Core::Start(), so the
* instance must not depend on explicit startup.
*/
static PlaybackController *instance();
/**
* @brief Move the playhead and notify app subscribers.
*/
void set_playhead(OakEngineNode *viewer, const core::Rational &time);
void set_playhead(OakEngineNode *viewer, int64_t num, int64_t den);
signals:
void playhead_changed(OakEngineNode *viewer,
const olive::core::Rational &time);
private:
explicit PlaybackController(QObject *parent = nullptr);
static PlaybackController *instance_;
};
}
#endif // OAK_PLAYBACKCONTROLLER_H
+10 -33
View File
@@ -21,7 +21,7 @@
#include "historywidget.h"
#include "oakengine/events.h"
#include "core.h"
#include "oakengine/undo.h"
namespace olive
@@ -30,22 +30,13 @@ namespace olive
HistoryModel::HistoryModel(QObject *parent)
: QAbstractItemModel(parent)
{
sub_ = oakengine_event_subscribe(
oakengine_undo_handle(), OAKENGINE_EVENT_UNDO_INDEX_CHANGED,
[](const oakengine_event *event, void *userdata) {
Q_UNUSED(event)
auto *self = static_cast<HistoryModel *>(userdata);
self->beginResetModel();
self->endResetModel();
},
this);
}
HistoryModel::~HistoryModel()
{
if (sub_ > 0) {
oakengine_event_unsubscribe(sub_);
}
// Refresh on Core's app-internal undo signal (issue 7 of the EventBridge
// elimination plan) instead of a raw C event subscription; the connection
// auto-disconnects with `this`.
connect(Core::instance(), &Core::undo_index_changed, this, [this](int) {
beginResetModel();
endResetModel();
});
}
QModelIndex HistoryModel::index(int row, int column,
@@ -124,26 +115,12 @@ HistoryWidget::HistoryWidget(QWidget *parent)
this->setModel(model_);
this->setRootIsDecorated(false);
undo_sub_ = oakengine_event_subscribe(
oakengine_undo_handle(), OAKENGINE_EVENT_UNDO_INDEX_CHANGED,
[](const oakengine_event *event, void *userdata) {
auto *self = static_cast<HistoryWidget *>(userdata);
self->index_changed(static_cast<int>(event->a));
},
this);
connect(Core::instance(), &Core::undo_index_changed, this,
&HistoryWidget::index_changed);
connect(this->selectionModel(), &QItemSelectionModel::currentRowChanged,
this, &HistoryWidget::current_row_changed);
}
HistoryWidget::~HistoryWidget()
{
// Raw subscription carries `this` as userdata; cancel it or the engine
// calls back into a dead widget (the undo stack outlives us).
if (undo_sub_ > 0) {
oakengine_event_unsubscribe(undo_sub_);
}
}
void HistoryWidget::index_changed(int i)
{
this->selectionModel()->select(this->model()->index(i - 1, 0),
+1 -8
View File
@@ -36,13 +36,12 @@ namespace olive
* Replaces the direct use of the engine's UndoStack as a Qt item model.
* Semantics mirror engine/undo/undostack.cpp: two columns (Number, Action),
* rows are all commands on the stack (done first, then undone), undone rows
* are shown gray. Refreshes itself on OAKENGINE_EVENT_UNDO_INDEX_CHANGED.
* are shown gray. Refreshes itself on Core::undo_index_changed.
*/
class HistoryModel : public QAbstractItemModel {
Q_OBJECT
public:
explicit HistoryModel(QObject *parent = nullptr);
~HistoryModel() override;
QModelIndex index(int row, int column,
const QModelIndex &parent = QModelIndex()) const override;
@@ -53,22 +52,16 @@ public:
int role = Qt::DisplayRole) const override;
QVariant headerData(int section, Qt::Orientation orientation,
int role = Qt::DisplayRole) const override;
private:
int64_t sub_ = 0;
};
class HistoryWidget : public QTreeView {
Q_OBJECT
public:
HistoryWidget(QWidget *parent = nullptr);
~HistoryWidget() override;
private:
HistoryModel *model_;
int64_t undo_sub_ = 0;
size_t current_row_;
private slots:
@@ -21,6 +21,8 @@
#include "nodeparamviewconnectedlabel.h"
#include "playback/playbackcontroller.h"
#include <QHBoxLayout>
#include "oakutil/qtutils.h"
@@ -112,30 +114,26 @@ NodeParamViewConnectedLabel::NodeParamViewConnectedLabel(const oak::Input &input
NodeParamViewConnectedLabel::~NodeParamViewConnectedLabel()
{
// Raw C-API subscription carries `this` as userdata; not covered by
// Qt's auto-disconnect. Unsubscribe or the next playhead event calls
// into a dead object.
// Drop the PlaybackController connection (Qt would auto-disconnect
// anyway, but keep the symmetric teardown).
set_viewer_node(nullptr);
}
void NodeParamViewConnectedLabel::set_viewer_node(OakEngineNode *viewer)
{
if (viewer_) {
oakengine_event_unsubscribe(viewer_sub_);
viewer_sub_ = 0;
}
disconnect(viewer_conn_);
viewer_ = viewer;
if (viewer_) {
viewer_sub_ = oakengine_event_subscribe(
viewer_,
OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED,
[](const oakengine_event *, void *userdata) {
static_cast<NodeParamViewConnectedLabel *>(userdata)
->update_value_tree();
},
this);
viewer_conn_ = connect(
PlaybackController::instance(),
&PlaybackController::playhead_changed, this,
[this](OakEngineNode *n, const core::Rational &) {
if (n == viewer_) {
update_value_tree();
}
});
update_value_tree();
}
}
@@ -72,7 +72,7 @@ private:
EngineEventBridge *bridge_ = nullptr;
int64_t viewer_sub_ = 0;
QMetaObject::Connection viewer_conn_;
private slots:
void set_value_tree_visible(bool e);
@@ -28,6 +28,7 @@
#include "oakengine/events.h"
#include "oakengine/undo.h"
#include "oakengine/viewer.h"
#include "playback/playbackcontroller.h"
#include "oakengine/node.h"
#include "ui/icons/icons.h"
@@ -159,13 +160,9 @@ NodeParamViewKeyframeControl::NodeParamViewKeyframeControl(bool right_align,
NodeParamViewKeyframeControl::~NodeParamViewKeyframeControl()
{
// Raw C-API subscription carries `this` as userdata; it is not covered
// by Qt's auto-disconnect. Without this, a playhead event delivered
// after destruction calls update_state() on a dead object.
if (viewer_sub_ > 0) {
oakengine_event_unsubscribe(viewer_sub_);
viewer_sub_ = 0;
}
// Qt auto-disconnect covers the PlaybackController connection; make it
// explicit for symmetry with TimeTargetDisconnectEvent.
disconnect(viewer_conn_);
// Drop the keyframe_* bridge subscriptions too (same raw-userdata
// mechanism underneath).
set_input(oak::Input());
@@ -211,22 +208,18 @@ void NodeParamViewKeyframeControl::set_input(const oak::Input &input)
void NodeParamViewKeyframeControl::TimeTargetDisconnectEvent(OakEngineNode *v)
{
if (viewer_sub_ > 0) {
oakengine_event_unsubscribe(viewer_sub_);
viewer_sub_ = 0;
}
disconnect(viewer_conn_);
}
void NodeParamViewKeyframeControl::TimeTargetConnectEvent(OakEngineNode *v)
{
viewer_sub_ = oakengine_event_subscribe(
v,
OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED,
[](const oakengine_event *, void *userdata) {
static_cast<NodeParamViewKeyframeControl *>(userdata)
->update_state();
},
this);
viewer_conn_ = connect(
PlaybackController::instance(), &PlaybackController::playhead_changed,
this, [this, v](OakEngineNode *n, const core::Rational &) {
if (n == v) {
update_state();
}
});
update_state();
}
@@ -391,7 +384,7 @@ void NodeParamViewKeyframeControl::go_to_previous_key()
&previous_time) &&
get_time_target()) {
Rational key_time = convert_to_viewer_time(previous_time);
oakengine_viewer_set_playhead(
PlaybackController::instance()->set_playhead(
reinterpret_cast<OakEngineNode *>(get_time_target()),
key_time.numerator(), key_time.denominator());
}
@@ -408,7 +401,7 @@ void NodeParamViewKeyframeControl::go_to_next_key()
&next_time) &&
get_time_target()) {
Rational key_time = convert_to_viewer_time(next_time);
oakengine_viewer_set_playhead(
PlaybackController::instance()->set_playhead(
reinterpret_cast<OakEngineNode *>(get_time_target()),
key_time.numerator(), key_time.denominator());
}
@@ -77,7 +77,7 @@ private:
int64_t keyframe_removed_sub_ = 0;
int64_t keyframe_time_sub_ = 0;
int64_t viewer_sub_ = 0;
QMetaObject::Connection viewer_conn_;
private slots:
void show_buttons_from_keyframe_enable(bool e);
@@ -21,6 +21,8 @@
#include "nodeparamviewwidgetbridge.h"
#include "playback/playbackcontroller.h"
#include <QCheckBox>
#include <QFontComboBox>
#include <QUrl>
@@ -192,10 +194,7 @@ NodeParamViewWidgetBridge::~NodeParamViewWidgetBridge()
{
// oakengine_dragger_create() ownership is ours (no-op on NULL)
oakengine_dragger_free(dragger_);
// Raw viewer subscription carries `this` as userdata
if (viewer_sub_ > 0) {
oakengine_event_unsubscribe(viewer_sub_);
}
disconnect(viewer_conn_);
}
int get_slider_count(NodeValueType::Type type)
@@ -1131,22 +1130,18 @@ void NodeParamViewWidgetBridge::set_timebase(const Rational &timebase)
void NodeParamViewWidgetBridge::TimeTargetDisconnectEvent(OakEngineNode *v)
{
if (viewer_sub_ > 0) {
oakengine_event_unsubscribe(viewer_sub_);
viewer_sub_ = 0;
}
disconnect(viewer_conn_);
}
void NodeParamViewWidgetBridge::TimeTargetConnectEvent(OakEngineNode *v)
{
viewer_sub_ = oakengine_event_subscribe(
v,
OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED,
[](const oakengine_event *, void *userdata) {
static_cast<NodeParamViewWidgetBridge *>(userdata)
->update_widget_values();
},
this);
viewer_conn_ = connect(
PlaybackController::instance(), &PlaybackController::playhead_changed,
this, [this, v](OakEngineNode *n, const core::Rational &) {
if (n == v) {
update_widget_values();
}
});
}
void NodeParamViewWidgetBridge::input_value_changed(OakEngineNode *source,
@@ -121,7 +121,7 @@ private:
EngineEventBridge *bridge_ = nullptr;
int64_t viewer_sub_ = 0;
QMetaObject::Connection viewer_conn_;
private slots:
void widget_callback();
+11 -11
View File
@@ -21,6 +21,8 @@
#include "timebasedview.h"
#include "playback/playbackcontroller.h"
#include <QGraphicsRectItem>
#include <QMouseEvent>
#include <QScrollBar>
@@ -157,21 +159,19 @@ void TimeBasedView::set_y_scale(const double &y_scale)
void TimeBasedView::set_viewer_node(OakEngineNode *v)
{
if (viewer_) {
oakengine_event_unsubscribe(viewer_sub_);
viewer_sub_ = 0;
}
disconnect(viewer_conn_);
viewer_ = v;
if (viewer_) {
viewer_sub_ = oakengine_event_subscribe(
viewer_,
OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED,
[](const oakengine_event *, void *userdata) {
static_cast<TimeBasedView *>(userdata)->viewport()->update();
},
this);
viewer_conn_ = connect(
PlaybackController::instance(),
&PlaybackController::playhead_changed, this,
[this](OakEngineNode *n, const core::Rational &) {
if (n == viewer_) {
viewport()->update();
}
});
}
}
+1 -1
View File
@@ -148,7 +148,7 @@ private:
OakEngineNode *viewer_;
int64_t viewer_sub_ = 0;
QMetaObject::Connection viewer_conn_;
};
}
+21 -12
View File
@@ -34,6 +34,7 @@
#include "oakengine/node.h"
#include "oakengine/timeline.h"
#include "oakengine/viewer.h"
#include "playback/playbackcontroller.h"
#include "oakengine/timeline.h"
#include "oakengine/undo.h"
#include "widget/keyframeview/keyframehandle.h"
@@ -116,6 +117,7 @@ void TimeBasedWidget::connect_viewer_node(OakEngineNode *node)
// Disconnect old bridge subscriptions and connections
disconnect(bridge_, nullptr, this, nullptr);
bridge_->unsubscribe_all();
disconnect(playhead_conn_);
if (viewer_node_) {
oak_video_params vp;
@@ -159,16 +161,23 @@ void TimeBasedWidget::connect_viewer_node(OakEngineNode *node)
if (viewer_node_) {
// Subscribe to viewer events via bridge
bridge_->subscribe(node, OAKENGINE_EVENT_VIEWER_LENGTH_CHANGED);
bridge_->subscribe(node, OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED);
bridge_->subscribe(node, OAKENGINE_EVENT_NODE_REMOVED_FROM_GRAPH);
connect(bridge_, &EngineEventBridge::viewer_length_changed, this,
[this](OakEngineNode *, qint64, qint64) {
update_maximum_scroll();
});
connect(bridge_, &EngineEventBridge::viewer_playhead_changed, this,
[this](OakEngineNode *, qint64 num, qint64 den) {
playhead_time_changed(Rational(num, den));
// Playhead changes arrive via PlaybackController (issue 6 of the
// EventBridge elimination plan) instead of the bridge's
// viewer_playhead_changed.
playhead_conn_ = connect(
PlaybackController::instance(),
&PlaybackController::playhead_changed, this,
[this](OakEngineNode *n, const Rational &time) {
if (n == get_connected_node()) {
playhead_time_changed(time);
}
});
// Node removed from graph - use the bridge signal
@@ -552,7 +561,7 @@ void TimeBasedWidget::go_to_prev_cut()
}
}
oakengine_viewer_set_playhead(sequence_node,
PlaybackController::instance()->set_playhead(sequence_node,
closest_cut.numerator(), closest_cut.denominator());
}
@@ -619,7 +628,7 @@ void TimeBasedWidget::go_to_next_cut()
}
if (closest_cut < RATIONAL_MAX) {
oakengine_viewer_set_playhead(sequence_node,
PlaybackController::instance()->set_playhead(sequence_node,
closest_cut.numerator(), closest_cut.denominator());
}
}
@@ -627,7 +636,7 @@ void TimeBasedWidget::go_to_next_cut()
void TimeBasedWidget::go_to_start()
{
if (viewer_node_) {
oakengine_viewer_set_playhead(get_connected_node(), 0, 1);
PlaybackController::instance()->set_playhead(get_connected_node(), 0, 1);
}
}
@@ -643,7 +652,7 @@ void TimeBasedWidget::prev_frame()
}
{
Rational _pt = qMax(Rational(0), proposed_time);
oakengine_viewer_set_playhead(get_connected_node(),
PlaybackController::instance()->set_playhead(get_connected_node(),
_pt.numerator(), _pt.denominator());
}
}
@@ -659,7 +668,7 @@ void TimeBasedWidget::next_frame()
// Catch rounding error, assume this time is snapped and just add a timebase
proposed_time += timebase();
}
oakengine_viewer_set_playhead(get_connected_node(),
PlaybackController::instance()->set_playhead(get_connected_node(),
proposed_time.numerator(), proposed_time.denominator());
}
}
@@ -668,7 +677,7 @@ void TimeBasedWidget::go_to_end()
{
if (viewer_node_) {
const Rational length = viewer_output_length(viewer_node_.data());
oakengine_viewer_set_playhead(get_connected_node(),
PlaybackController::instance()->set_playhead(get_connected_node(),
length.numerator(), length.denominator());
}
}
@@ -1006,7 +1015,7 @@ void TimeBasedWidget::go_to_in()
oakengine_viewer_workarea wa;
oakengine_viewer_get_workarea(get_connected_node(), &wa);
if (wa.enabled) {
oakengine_viewer_set_playhead(get_connected_node(),
PlaybackController::instance()->set_playhead(get_connected_node(),
wa.in_num, wa.in_den);
} else {
go_to_start();
@@ -1020,7 +1029,7 @@ void TimeBasedWidget::go_to_out()
oakengine_viewer_workarea wa;
oakengine_viewer_get_workarea(get_connected_node(), &wa);
if (wa.enabled) {
oakengine_viewer_set_playhead(get_connected_node(),
PlaybackController::instance()->set_playhead(get_connected_node(),
wa.out_num, wa.out_den);
} else {
go_to_end();
+2
View File
@@ -169,6 +169,8 @@ protected:
EngineEventBridge *bridge_ = nullptr;
QMetaObject::Connection playhead_conn_;
virtual void ConnectNodeEvent(OakEngineNode *)
{
}
+12
View File
@@ -116,6 +116,12 @@ void TimeScaledObject::set_maximum_scale(const double &max)
{
max_scale_ = max;
// Keep min <= max so the clamp in set_scale() is never called with
// inverted limits (undefined behavior)
if (min_scale_ > max_scale_) {
min_scale_ = max_scale_;
}
if (get_scale() > max_scale_) {
set_scale(max_scale_);
}
@@ -125,6 +131,12 @@ void TimeScaledObject::set_minimum_scale(const double &min)
{
min_scale_ = min;
// Keep min <= max so the clamp in set_scale() is never called with
// inverted limits (undefined behavior)
if (max_scale_ < min_scale_) {
max_scale_ = min_scale_;
}
if (get_scale() < min_scale_) {
set_scale(min_scale_);
}
@@ -54,6 +54,16 @@ TimelineAndTrackView::TimelineAndTrackView(Qt::Alignment vertical_alignment,
splitter_->setSizes({ 180, width() });
}
TimelineAndTrackView::~TimelineAndTrackView()
{
// When the child views are destroyed they reset their scene, which can
// push a valueChanged through these connections while this object is
// already half-destroyed (Qt aborts on a slot invoked past its class's
// destructor). Detach first so teardown emissions go nowhere.
disconnect(view_->verticalScrollBar(), nullptr, this, nullptr);
disconnect(track_view_->verticalScrollBar(), nullptr, this, nullptr);
}
QSplitter *TimelineAndTrackView::splitter() const
{
return splitter_;
@@ -42,6 +42,8 @@ public:
TrackView *track_view() const;
virtual ~TimelineAndTrackView() override;
private:
QSplitter *splitter_;
+19 -10
View File
@@ -56,6 +56,7 @@
#include "oakengine/proxy.h"
#include "oakengine/timeline.h"
#include "oakengine/viewer.h"
#include "playback/playbackcontroller.h"
#include "common/configwrapper.h"
#include "tool/add.h"
#include "tool/beam.h"
@@ -555,7 +556,7 @@ TimelineWidget::TimelineWidget(QWidget *parent)
start = std::min(start, block_in(b));
}
if (start != RATIONAL_MAX) {
oakengine_viewer_set_playhead(
PlaybackController::instance()->set_playhead(
reinterpret_cast<OakEngineNode *>(get_connected_node()),
start.numerator(), start.denominator());
}
@@ -741,7 +742,6 @@ void TimelineWidget::ConnectNodeEvent(OakEngineNode *n)
// Subscribe to viewer events via bridge
bridge_->subscribe(handle, OAKENGINE_EVENT_VIEWER_FRAME_RATE_CHANGED);
bridge_->subscribe(handle, OAKENGINE_EVENT_VIEWER_SAMPLE_RATE_CHANGED);
bridge_->subscribe(handle, OAKENGINE_EVENT_VIEWER_PLAYHEAD_CHANGED);
connect(bridge_, &EngineEventBridge::viewer_frame_rate_changed, this,
[this](OakEngineNode *, qint64, qint64) {
@@ -751,14 +751,21 @@ void TimelineWidget::ConnectNodeEvent(OakEngineNode *n)
[this](OakEngineNode *, int) {
sample_rate_changed();
});
connect(bridge_, &EngineEventBridge::viewer_playhead_changed, this,
[this](OakEngineNode *, qint64 num, qint64 den) {
this->timecode_label_->set_value(Rational(num, den));
// Keep the timecode label in sync via PlaybackController (issue 6 of the
// EventBridge elimination plan) instead of the bridge's
// viewer_playhead_changed.
timecode_playhead_conn_ = connect(
PlaybackController::instance(), &PlaybackController::playhead_changed,
this, [this](OakEngineNode *n, const Rational &time) {
if (n == get_connected_node()) {
this->timecode_label_->set_value(time);
}
});
connect(timecode_label_, &RationalSlider::value_changed, this,
[handle](const Rational &time) {
oakengine_viewer_set_playhead(
PlaybackController::instance()->set_playhead(
handle, time.numerator(), time.denominator());
});
{
@@ -805,6 +812,8 @@ void TimelineWidget::DisconnectNodeEvent(OakEngineNode *n)
// Bridge subscriptions and connections are cleaned up by
// TimeBasedWidget::connect_viewer_node (disconnect(bridge_, nullptr, this, nullptr))
disconnect(timecode_playhead_conn_);
deselect_all();
foreach (OakEngineTrack *track,
@@ -1076,7 +1085,7 @@ void TimelineWidget::DeleteSelected(bool ripple)
clear_ghosts();
if (ripple && rippled && new_playhead != RATIONAL_MAX) {
oakengine_viewer_set_playhead(
PlaybackController::instance()->set_playhead(
reinterpret_cast<OakEngineNode *>(get_connected_node()),
new_playhead.numerator(), new_playhead.denominator());
}
@@ -1308,7 +1317,7 @@ void TimelineWidget::delete_in_to_out(bool ripple)
// Playhead move is not undoable and stays here (same as before).
if (ripple) {
oakengine_viewer_set_playhead(
PlaybackController::instance()->set_playhead(
reinterpret_cast<OakEngineNode *>(get_connected_node()),
wa_in.numerator(), wa_in.denominator());
}
@@ -3196,14 +3205,14 @@ void TimelineWidget::ripple_to(TimelineApp::MovementMode mode)
// If we rippled, ump to where new cut is if applicable
if (mode == TimelineApp::k_trim_in) {
oakengine_viewer_set_playhead(
PlaybackController::instance()->set_playhead(
reinterpret_cast<OakEngineNode *>(get_connected_node()),
closest_point_to_playhead.numerator(),
closest_point_to_playhead.denominator());
} else if (mode == TimelineApp::k_trim_out &&
closest_point_to_playhead ==
viewer_playhead(reinterpret_cast<OakEngineNode *>(get_connected_node()))) {
oakengine_viewer_set_playhead(
PlaybackController::instance()->set_playhead(
reinterpret_cast<OakEngineNode *>(get_connected_node()),
playhead_time.numerator(), playhead_time.denominator());
}
@@ -290,6 +290,7 @@ public slots:
void rename_selected_blocks();
signals:
void block_selection_changed(const QVector<OakEngineBlock *> &selected_blocks);
void request_capture_start(const TimeRange &time,
@@ -358,6 +359,8 @@ private:
RationalSlider *timecode_label_;
QMetaObject::Connection timecode_playhead_conn_;
QVector<OakEngineBlock *> selected_blocks_;
QVector<OakEngineBlock *> added_blocks_;
+2 -1
View File
@@ -36,6 +36,7 @@
#include "oakengine/timeline.h"
#include "oakengine/undo.h"
#include "oakengine/viewer.h"
#include "playback/playbackcontroller.h"
#include "oakengine/project.h"
#include "widget/timelinewidget/cliphandle.h"
#include "window/mainwindow/mainwindow.h"
@@ -252,7 +253,7 @@ void ImportTool::place_at(const DraggedFootageData &footage,
drop_ghosts(insert, command);
if (jump_to_end) {
oakengine_viewer_set_playhead(
PlaybackController::instance()->set_playhead(
reinterpret_cast<OakEngineNode *>(this->sequence()),
max.numerator(), max.denominator());
}
@@ -35,6 +35,7 @@
#include "oakengine/preview.h"
#include "oakengine/timeline.h"
#include "oakengine/viewer.h"
#include "playback/playbackcontroller.h"
#include "panel/panelmanager.h"
#include "panel/timeline/timeline.h"
#include "widget/timelinewidget/cliphandle.h"
@@ -268,7 +269,7 @@ void TimelineView::mousePressEvent(QMouseEvent *event)
it++) {
if (it.value().contains(scene_pos)) {
const Rational marker_in = marker_time_range(it.key()).in();
oakengine_viewer_set_playhead(get_viewer_node(),
PlaybackController::instance()->set_playhead(get_viewer_node(),
marker_in.numerator(),
marker_in.denominator());
break;
+7 -6
View File
@@ -47,6 +47,7 @@
#include "oakengine/display.h"
#include "widget/viewer/displaybuffer.h"
#include "oakengine/viewer.h"
#include "playback/playbackcontroller.h"
#include "oakengine/videoparams.h"
#include "panel/multicam/multicampanel.h"
#include "panel/panelmanager.h"
@@ -402,7 +403,7 @@ void ViewerWidget::ConnectNodeEvent(OakEngineNode *handle)
// Connect controls to set_playhead via facade
connect(controls_, &PlaybackControls::time_changed, this,
[handle](const Rational &time) {
oakengine_viewer_set_playhead(
PlaybackController::instance()->set_playhead(
handle, time.numerator(), time.denominator());
});
@@ -649,7 +650,7 @@ void ViewerWidget::set_gizmos(OakEngineNode *node)
void ViewerWidget::start_capture(TimelineWidget *source, const TimeRange &time,
const TrackReference &track)
{
oakengine_viewer_set_playhead(get_connected_node(),
PlaybackController::instance()->set_playhead(get_connected_node(),
time.in().numerator(), time.in().denominator());
arm_for_recording();
@@ -1349,10 +1350,10 @@ void ViewerWidget::play_internal(int speed, bool in_to_out_only)
if (!in_to_out_only &&
viewer_output_playhead(get_connected_node()) >= last_frame) {
if (speed > 0) {
oakengine_viewer_set_playhead(get_connected_node(),
PlaybackController::instance()->set_playhead(get_connected_node(),
0, 1);
} else {
oakengine_viewer_set_playhead(get_connected_node(),
PlaybackController::instance()->set_playhead(get_connected_node(),
last_frame.numerator(), last_frame.denominator());
}
}
@@ -2118,7 +2119,7 @@ void ViewerWidget::play(bool in_to_out_only)
OAKENGINE_OK &&
wa.enabled) {
// Jump to in point
oakengine_viewer_set_playhead(get_connected_node(),
PlaybackController::instance()->set_playhead(get_connected_node(),
wa.in_num, wa.in_den);
} else {
in_to_out_only = false;
@@ -2347,7 +2348,7 @@ void ViewerWidget::playback_timer_update()
// pausing. Even if we pause it later with `end_of_line`, we prefer pausing after setting the time
// so that an audio scrub event, etc. isn't sent.
time_changed_from_timer_ = true;
oakengine_viewer_set_playhead(get_connected_node(),
PlaybackController::instance()->set_playhead(get_connected_node(),
time_to_set.numerator(), time_to_set.denominator());
time_changed_from_timer_ = false;
if (end_of_line) {
+2 -1
View File
@@ -40,6 +40,7 @@
#include "common/configwrapper.h"
#include "oakengine/project.h"
#include "oakengine/viewer.h"
#include "playback/playbackcontroller.h"
#include "oakengine/undo.h"
#include "widget/viewer/vieweroutpututils.h"
@@ -619,7 +620,7 @@ void MainWindow::reveal_viewer_in_footage_viewer(OakEngineNode *r,
}
oakengine_undo_push(command, tr("Set Footage Workarea").toUtf8().constData());
oakengine_viewer_set_playhead(
PlaybackController::instance()->set_playhead(
r,
range.in().numerator(), range.in().denominator());
}
+9 -9
View File
@@ -64,7 +64,7 @@ C callbacks just queue to the GUI thread and re-emit typed Qt signals.
later (b) migrations must use it instead of their own subscriptions.
- **Required: build + ctest all green.**
### issue 0c — App-internal PlaybackController (1 day)
### issue 0c ✅ (done) — App-internal PlaybackController (1 day)
Create `app/playback/playbackcontroller.{h,cpp}` with a
`playhead_changed(oak::Node viewer, Rational)` signal. Route every
`oakengine_viewer_set_playhead` call site in the app (ViewerWidget playback
@@ -82,37 +82,37 @@ In other words: one app-side controller that every
## Playhead migrations (pattern A, about half a day each)
### issue 1 — timebasedview playhead subscription
### issue 1 ✅ (done) — timebasedview playhead subscription
`app/widget/timebased/timebasedview.cpp:168` (raw C callback). Reconnect to
`PlaybackController::playhead_changed`.
- Acceptance: the ruler playhead line moves during playback; no raw C
subscription remains.
- **Required: build + ctest all green.**
### issue 2 — NodeParamViewWidgetBridge playhead
### issue 2 ✅ (done) — NodeParamViewWidgetBridge playhead
`app/widget/nodeparamview/nodeparamviewwidgetbridge.cpp:1140` (raw C).
- Acceptance: keyframe-interpolated slider values follow the playhead
during playback.
- **Required: build + ctest all green.**
### issue 3 — NodeParamViewKeyframeControl playhead
### issue 3 ✅ (done) — NodeParamViewKeyframeControl playhead
`app/widget/nodeparamview/nodeparamviewkeyframecontrol.cpp:222` (raw C).
- Acceptance: prev/next/toggle keyframe buttons have the correct state as
the playhead moves.
- **Required: build + ctest all green.**
### issue 4 — NodeParamViewConnectedLabel playhead
### issue 4 ✅ (done) — NodeParamViewConnectedLabel playhead
`app/widget/nodeparamview/nodeparamviewconnectedlabel.cpp:131` (raw C).
- Acceptance: the value tree refreshes with the playhead.
- **Required: build + ctest all green.**
### issue 5 — ExportDialog playhead
### issue 5 ✅ (done) — ExportDialog playhead
`app/dialog/export/export.cpp:311` (raw C).
- Acceptance: the export dialog's in/out times stay in sync with the
playhead.
- **Required: build + ctest all green.**
### issue 6 — timelinewidget / viewerdisplay playhead family (bridge subscriptions)
### issue 6 ✅ (done) — timelinewidget / viewerdisplay playhead family (bridge subscriptions)
`app/widget/timelinewidget/timelinewidget.cpp` (around :741-743) and the
related viewerdisplay subscriptions. Reconnect to PlaybackController.
- Acceptance: timeline timecode/playhead position display correctly.
@@ -122,7 +122,7 @@ related viewerdisplay subscriptions. Reconnect to PlaybackController.
## Undo & modified migrations (pattern A, about half a day each)
### issue 7 — historywidget UNDO_INDEX_CHANGED
### issue 7 ✅ (done) — historywidget UNDO_INDEX_CHANGED
`app/widget/history/historywidget.cpp:33,127` (two raw C callbacks).
Emit an app-internal `undo_index_changed(int)` at Core's undo/redo/push
exit points and reconnect historywidget to it.
@@ -130,7 +130,7 @@ exit points and reconnect historywidget to it.
the correct row.
- **Required: build + ctest all green.**
### issue 8 — core.cpp PROJECT_MODIFIED_CHANGED
### issue 8 ✅ (done) — core.cpp PROJECT_MODIFIED_CHANGED
`app/core.cpp:1108` (raw C). The modified flag is driven by the undo stack,
so the app can derive it at push/undo/redo/load; drive `setWindowModified`
from an app-internal signal instead.
+73
View File
@@ -0,0 +1,73 @@
# Issues 920 (#34#45): Dependency Analysis & Two-Person Split
Scope: the twelve "Structure migrations" sub-issues of #26 (Eliminating
EventBridge), GitHub issues #34#45. Each is independently shippable; this
document captures the real ordering constraints so two people can work in
parallel without stepping on each other.
## Hard dependencies
Only two kinds exist:
1. **The issue 7 signal (`Core::undo_index_changed`)** — required by issues
9, 11, 15, 16 (and used by 10/12/13/17 for undo refresh).
✅ Already landed in `a030f2da2` (issue 7 / #55). **No longer blocks
anything.**
2. **The issue 15 "project load finished" hook** (`Core` broadcasts after
TaskDialog load success). Explicitly reused by:
- **issue 13 (#38)** — "re-read uniformly after undo/**load**"
- **issue 17 (#42)** — "undo and **load** rebuild uniformly"
- **issue 19 (#44)** — "on load completion do a uniform model reset
(**reuse the issue 15 hook**)"
Everything else has **no ordering constraints**.
## Dependency graph
```
issue 7 (done) ──┬─> 9, 10, 11, 12, 16 (undo refresh signal, available now)
└─> 15 ──┬─> 13 (project-load hook)
├─> 17
└─> 19
14, 18, 20 — fully independent
```
## File-overlap (merge-conflict) risks
| Pair | Overlap | Severity |
|------|---------|----------|
| 15 / 16 | both in `app/widget/nodeview/` (+ `mainwindow.cpp` in 16) | low — different files |
| 17 / 18 | both in `app/widget/timelinewidget/` (17 heavily edits `timelinewidget.cpp`) | medium — keep in the same person's queue |
| 12 / 13 / 14 | all under `app/widget/nodeparamview/` | low — different files |
| 9 / 10 | marker/workarea pattern is identical; not the same files | none, but cheap to do together |
## Suggested split
**Person A — "signals already available" batch (start immediately):**
- issue 9 (#34) — seekablewidget marker/workarea
- issue 10 (#35) — resizabletimelinescrollbar marker/workarea (same pattern as 9)
- issue 11 (#36) — nodeviewitem label/color/message/array
- issue 12 (#37) — NodeParamViewItem / arraywidget / keyframecontrol
- issue 14 (#39) — NodeParamView group passthrough / context
- issue 20 (#45) — misc leftovers
**Person B — "project-load hook" chain (15 first, then its dependents):**
- issue 15 (#40) — nodeviewcontext structure core + **add the load hook**
- issue 16 (#41) — nodeview NODE_REMOVED_FROM_GRAPH (same area as 15)
- issue 13 (#38) — nodeparamviewwidgetbridge values (needs the hook)
- issue 17 (#42) — timelinewidget track/block structure (needs the hook; keeps 18's neighbor in one queue)
- issue 18 (#43) — trackviewitem index/muted
- issue 19 (#44) — projectviewmodel folder/label (needs the hook)
Rationale: B owns everything that consumes the issue 15 hook, so the hook's
API is designed and used by one person with no cross-team blocking. A's
batch only needs the already-landed issue 7 signal, so both can start today.
Workload is ~6 × 0.5 day each side.
Ground rules still apply per issue: `cmake --build cmake-build-debug -j8 &&
cd cmake-build-debug && ctest -j4` must be green (122/122) before shipping,
and remove the matching `bridge_->subscribe` / `oakengine_event_subscribe`
calls when done.
@@ -67,7 +67,7 @@ events; C callbacks just queue to the GUI thread and re-emit typed Qt signals.
must use it instead of their own subscriptions.
- **必做 / Requiredbuild + ctest 全绿。**
### issue 0c — 建 app 内 PlaybackController1 天)
### issue 0c ✅ (done) — 建 app 内 PlaybackController1 天)
新建 `app/playback/playbackcontroller.{h,cpp}`,信号
`playhead_changed(oak::Node viewer, Rational)`
把 app 内所有 `oakengine_viewer_set_playhead` 调用点(ViewerWidget 播放循环、
@@ -83,33 +83,33 @@ goes through; it re-broadcasts `playhead_changed` as an app-internal Qt signal.
## playhead 族迁移 / Playhead migrations(模式 A,约半天/个)
### issue 1 — timebasedview 的 playhead 订阅
### issue 1 ✅ (done) — timebasedview 的 playhead 订阅
`app/widget/timebased/timebasedview.cpp:168`(裸 C 回调)。改连
PlaybackController::playhead_changed。
- 验收 / Acceptance:播放时 ruler 播放头线正常移动;无裸 C 订阅残留。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 2 — NodeParamViewWidgetBridge playhead
### issue 2 ✅ (done) — NodeParamViewWidgetBridge playhead
`app/widget/nodeparamview/nodeparamviewwidgetbridge.cpp:1140`(裸 C)。
- 验收 / Acceptance:播放时关键帧插值滑条值随播放头更新。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 3 — NodeParamViewKeyframeControl playhead
### issue 3 ✅ (done) — NodeParamViewKeyframeControl playhead
`app/widget/nodeparamview/nodeparamviewkeyframecontrol.cpp:222`(裸 C)。
- 验收 / Acceptance:播放头移动时 prev/next/toggle 关键帧按钮状态正确。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 4 — NodeParamViewConnectedLabel playhead
### issue 4 ✅ (done) — NodeParamViewConnectedLabel playhead
`app/widget/nodeparamview/nodeparamviewconnectedlabel.cpp:131`(裸 C)。
- 验收 / Acceptance:值树随播放头刷新。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 5 — ExportDialog playhead
### issue 5 ✅ (done) — ExportDialog playhead
`app/dialog/export/export.cpp:311`(裸 C)。
- 验收 / Acceptance:导出对话框 in/out 时间随播放头同步。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 6 — timelinewidget / viewerdisplay 的 playhead 族(bridge 订阅)
### issue 6 ✅ (done) — timelinewidget / viewerdisplay 的 playhead 族(bridge 订阅)
`app/widget/timelinewidget/timelinewidget.cpp`:741-743 一带)与 viewerdisplay
的相关订阅。改连 PlaybackController。
- 验收 / Acceptance:时间线时间码/播放头位置显示正确。
@@ -119,14 +119,14 @@ PlaybackController::playhead_changed。
## undo/modified 族迁移 / Undo & modified migrations(模式 A,约半天/个)
### issue 7 — historywidget 的 UNDO_INDEX_CHANGED
### issue 7 ✅ (done) — historywidget 的 UNDO_INDEX_CHANGED
`app/widget/history/historywidget.cpp:33,127`(裸 C 两处)。
在 Core 的 undo/redo/push 出口 emit app 内 `undo_index_changed(int)`
historywidget 改连它。
- 验收 / Acceptanceundo/redo 后历史列表 model reset 且选中行正确。
- **必做 / Requiredbuild + ctest 全绿。**
### issue 8 — core.cpp 的 PROJECT_MODIFIED_CHANGED
### issue 8 ✅ (done) — core.cpp 的 PROJECT_MODIFIED_CHANGED
`app/core.cpp:1108`(裸 C)。modified 由 undo 栈驱动,app 在
push/undo/redo/load 处即可推导;改为 app 内信号驱动 `setWindowModified`
- 验收 / Acceptance:编辑/撤销/保存后标题栏修改标记正确。
@@ -0,0 +1,70 @@
# Issues 920#34#45):依赖关系梳理与两人分工建议
范围:#26Eliminating EventBridge)的 12 个 "Structure migrations"
子任务,对应 GitHub issue #34#45。每个 issue 都可以独立交付,本文档
梳理真实的先后约束,让两个人可以并行推进而互不阻塞。
## 硬依赖
只有两类:
1. **issue 7 的信号(`Core::undo_index_changed`** —— issue 9、11、
15、16 明确依赖它(10/12/13/17 的 undo 刷新也要用)。
✅ 已在 `a030f2da2` 落地(issue 7 / #55)。**不再构成阻塞。**
2. **issue 15 新增的 "project load finished" 钩子**Core 在 TaskDialog
加载成功后广播)。明确复用它的有:
- **issue 13#38** —— "re-read uniformly after undo/**load**"
- **issue 17#42** —— "undo and **load** rebuild uniformly"
- **issue 19#44** —— "on load completion do a uniform model
reset**复用 issue 15 的钩子**"
其余 issue **没有任何先后约束**
## 依赖图
```
issue 7(已完成) ──┬─> 9, 10, 11, 12, 16 undo 刷新信号,现已可用)
└─> 15 ──┬─> 13 (项目加载钩子)
├─> 17
└─> 19
14、18、20 —— 完全独立
```
## 文件重叠(合并冲突)风险
| 组合 | 重叠区域 | 程度 |
|------|---------|------|
| 15 / 16 | 都在 `app/widget/nodeview/`16 还涉及 `mainwindow.cpp` | 低——不同文件 |
| 17 / 18 | 都在 `app/widget/timelinewidget/`17 会大改 `timelinewidget.cpp`) | 中——建议排在同一个人的队列里 |
| 12 / 13 / 14 | 都在 `app/widget/nodeparamview/` 下 | 低——不同文件 |
| 9 / 10 | marker/workarea 的处理模式完全相同,但不是同一批文件 | 无冲突,但连着做成本低 |
## 建议分工
**A 同学 —— "信号已就绪" 批次(今天就能开始):**
- issue 9#34)—— seekablewidget marker/workarea
- issue 10#35)—— resizabletimelinescrollbar marker/workarea(与 9 同模式)
- issue 11#36)—— nodeviewitem label/color/message/array
- issue 12#37)—— NodeParamViewItem / arraywidget / keyframecontrol
- issue 14#39)—— NodeParamView group passthrough / context
- issue 20#45)—— 零散收尾
**B 同学 —— "项目加载钩子" 链(先做 15,再做它的下游):**
- issue 15#40)—— nodeviewcontext 结构核心 + **新增加载钩子**
- issue 16#41)—— nodeview NODE_REMOVED_FROM_GRAPH(与 15 同区域)
- issue 13#38)—— nodeparamviewwidgetbridge 参数值(依赖钩子)
- issue 17#42)—— timelinewidget track/block 结构(依赖钩子;同时把 18 的邻域保持在同一队列)
- issue 18#43)—— trackviewitem index/muted
- issue 19#44)—— projectviewmodel folder/label(依赖钩子)
理由:所有消费 issue 15 钩子的 issue 都归 B,钩子的 API 由同一个人
设计并使用,零跨人阻塞;A 的批次只依赖已经落地的 issue 7 信号,
两人今天都能开工。工作量两侧均约为 6 × 0.5 天。
每个 issue 的通用要求不变:提交前
`cmake --build cmake-build-debug -j8 && cd cmake-build-debug && ctest -j4`
必须全绿(122/122),完成后移除对应的
`bridge_->subscribe` / `oakengine_event_subscribe` 调用。
+102 -7
View File
@@ -147,11 +147,11 @@
liboakengine-facade(壳:capi + 事件 + init
┌────────┬────────┼─────────┬──────────┐
oaktask oakrender oakplugin oakaudio oakserialize
│ │ │ │ │
└────────┴────┬───┴──────────┴──────────┘
┌────────┬────────┼─────────┬──────────┬──────────────
oaktask oakrender oakplugin oakaudio oakserialize oakstorage
│ │ │ │ │ (工程文件读写,
└────────┴────┬───┴──────────┴──────────┘ 独立模块,未来
替换为数据库)
oakmodel(节点图 + 项目模型 + 时间线模型)
┌────────┼─────────┐
@@ -160,6 +160,99 @@
ffmpeg_bridge(已是 C ABI .so
```
**oakstorage 单列说明(本计划对原模块图的唯一结构性修改)**:
工程文件的读写(`node/project/serializer` 的落盘路径 + `task/project/`
load/save/loadotio/saveotio 的文件 IO)从 oakserialize / oaktask 中**单独拆出**为
oakstorage 模块。它对上只暴露**存储后端无关**的 C ABI(打开/保存/探测工程,
URI 寻址),当前唯一后端是 XML .ove 文件;**未来替换为数据库时只新增一个
后端实现,上层(oaktask/facade)零改动**。剪贴板序列化(copy/paste)不属于
存储,仍留在 oakserialize。详细接口设计见 `riir/04-interfaces.md`
`riir/M10-oakstorage.md`
### 3.1.1 模块数据流图(Mermaid
下图描述终态各模块之间的调用与数据流向。**实线 = 命令调用(上层→下层,
内部 C ABI `oak<mod>_*`,调用方知道影响);虚线 = 仅两种允许的反向通知:
facade→app 的 `oakengine_event` 通道,以及异步任务(oaktask 任务 /
渲染 ticket / GPU 帧完成)的完成回调。下层对上层没有事件订阅。**
```mermaid
flowchart TB
subgraph 消费侧["消费侧(不感知实现语言)"]
APP["oak-editor (app)"]
CLI["oak-cli"]
WRK["oak-render-worker"]
end
FACADE["liboakengine-facade<br/>壳:oakengine_* 公共 C ABI(冻结)<br/>+ 事件注册表 + init/shutdown"]
subgraph 引擎模块["引擎模块(facade 之下,只经内部 C ABI 互调)"]
TASK["oaktask<br/>任务编排(Task/TaskManager"]
RENDER["oakrender<br/>渲染管线/缓存/色彩"]
PLUGIN["oakplugin<br/>OpenFX 宿主"]
AUDIO["oakaudio<br/>音频 DSP/输出/电平"]
SERIAL["oakserialize<br/>节点图 XML 序列化<br/>(剪贴板 copy/paste,不落盘)"]
STORAGE["oakstorage<br/>工程持久化(URI 打开/保存/探测)<br/>后端可插拔:ove-xml 文件|未来 oakdb 数据库"]
UNDO["oakundo<br/>UndoStack/UndoCommand"]
MODEL["oakmodel<br/>Node 类型簇 + Project/Sequence/Track/Block<br/>(最大不可拆分类型簇)"]
CODEC["oakcodec<br/>decoder/encoder/conform/proxy"]
CORE["liboakcore<br/>rational/timecode/bezier/samplebuffer"]
BACKEND["oakbackend<br/>GPU 插件:oakgl / oakvulkan / 未来 Rust(wgpu)"]
FFMPEG["ffmpeg_bridge<br/>FFmpeg 的 C ABI 桥(现成 .so"]
end
APP -->|"oakengine_* 调用"| FACADE
CLI --> FACADE
WRK --> FACADE
FACADE -.->|"oakengine_event 变更通知<br/>(发射线程同步回调)"| APP
FACADE -->|"任务工厂/进度"| TASK
FACADE -->|"渲染请求/帧句柄"| RENDER
FACADE --> AUDIO
FACADE --> PLUGIN
FACADE -->|"工程打开/保存(URI"| STORAGE
FACADE --> UNDO
TASK -->|"load/save 任务委托<br/>Project 句柄 + XML 字节流)"| STORAGE
TASK -->|"import/conform/proxy 任务"| CODEC
TASK -->|"导出任务"| RENDER
RENDER -->|"遍历节点图求值"| MODEL
RENDER -->|"解码帧请求"| CODEC
RENDER -->|"上传纹理/绘制"| BACKEND
RENDER -->|"OCIO 色彩变换"| CORE
STORAGE -->|"反序列化建图 / 序列化取图(同步命令)"| MODEL
SERIAL --> MODEL
MODEL -->|"读取媒体参数"| CODEC
MODEL --> CORE
UNDO -->|"命令持有 Node/Project 句柄"| MODEL
AUDIO --> CORE
CODEC --> FFMPEG
CODEC --> CORE
BACKEND -.->|"帧完成(异步任务回调,ticket 返回通道)"| RENDER
```
**数据流要点**
1. **命令流(自上而下)**app 的每个动作 → facade `oakengine_*` → 对应
模块内部 C ABI。facade 是唯一入口,模块不允许被 app 直接链接。
2. **工程 IO 流**`oaktask` 创建 load/save 任务 → 委托 `oakstorage`
oakstorage 按 URI scheme 选后端(`file://*.ove` → ove-xml 后端,
未来 `oakdb://` → 数据库后端),序列化/反序列化时对 `oakmodel` 建图
取图。**替换为数据库只发生在 oakstorage 内部。**
3. **帧数据流**`oakcodec``ffmpeg_bridge` 解码 → `oakrender` 遍历
`oakmodel` 节点图求值 → `oakbackend`GPU 插件)上屏/导出;帧以不透明
句柄 + buf/size 约定跨边界,不传递 C++ 对象。
4. **通知流(仅两种反向通道,虚线)**:模块间没有事件订阅——上层调
下层只有命令,调用方知道影响;变更通知由命令发起层(通常是 facade)
`oakengine_event` 同步回调给 app,Rust 化后只是发射端换语言,
通道不变(§6.1)。另一例外是异步任务的完成回调(oaktask 任务、
渲染 ticket、GPU 帧完成)——回调即该异步命令的返回通道。
5. **undo 流**:所有可撤销编辑(无论来自 facade 还是模块内部)都包装成
`OakUndoCommand` 进入 `oakundo` 栈,命令体内只持 oakmodel 句柄。
**关键架构事实(拆分顺序的依据)**:
- `Node` 及其子类簇(Project/Folder/Footage/Sequence/Block/Track/Clip/Gap/
Transition/Subtitle/各效果节点)是 C++ 继承绑死的**不可拆分类型簇**——
@@ -177,7 +270,8 @@
| M0 | **oakcore** | liboakcore 整体(rational/timecode/bezier/samplebuffer/audioparamsQt-free | 无 | 极低;工具链试金石 |
| M1 | **oakaudio** | AudioProcessor、AudioSynchronizer、AudioLevelMeter、波形计算 | oakcore | 低;顺带消掉 AudioProcessor 豁免项 |
| M2 | **oakcodec** | decoder/encoder/conform/proxy | ffmpeg_bridge | 中;FFmpeg 行为复刻 |
| M3 | **oakserialize** | node/project/serializer/*XML 项目文件 | oakmodel(经 facade node/project 族) | 中;round-trip 必须字节一致 |
| M3a | **oakstorage** | node/project/serializer 落盘路径 + task/project/{load,save,loadotio,saveotio} 文件 IO(工程持久化,后端可插拔:当前 XML 文件,未来数据库 | oakmodel(经 facade node/project 族) | 中;round-trip 必须字节一致;后端接口一次冻结 |
| M3b | **oakserialize** | node/project/serializer 的剪贴板/节点图 XML 序列化(copy/paste,不落盘) | oakmodel(经 facade node/project 族) | 中;round-trip 必须字节一致 |
| M4 | **oakundo** | UndoCommand/UndoStack/MultiUndoCommand | oakmodel(经 facade | 中;全局调用点多 |
| M5 | **oakrender** | RenderManager/ticket/watcher/cache/PreviewAutoCacher/ColorProcessor | oakmodel、oakcodec | 高;线程与 OCIO |
| M6 | **oakmodel** | Node/NodeInput/keyframe/traverser/factory/Project/Folder/Footage/Sequence/Block/Track/效果节点 | oakcore、oakcodec | 最高;最大类型簇 |
@@ -312,7 +406,8 @@ timeline.h,本就是为外部消费设计的)充当模块间缝,缝的质
1. **S1 完成**:Rust 工具链 + 门禁脚本进 CIM0liboakcoreG6 退役。
2. **M1M2 完成**:音频 DSP 与编解码 Rust 化;AudioProcessor 豁免项消除。
3. **M3M4 完成**序列化与 undo Rust 化;项目文件 round-trip 金标准常青。
3. **M3aM4 完成**工程存储(oakstorage,含后端可插拔接口冻结)、剪贴板
序列化与 undo Rust 化;项目文件 round-trip 金标准常青。
4. **M5 完成**:渲染管线 Rust 化(OCIO 孤岛与否已裁决并记录)。
5. **M6 完成**oakmodel Rust 化——**最大里程碑**,此后 liboakengine 主体为 Rust。
6. **M7M8 完成**:任务系统与 facade 壳 Rust 化;liboakengine.soC++ 版)正式退役。
+8 -5
View File
@@ -6,9 +6,10 @@
> 并稳定后,才逐模块用 Rust 重写(届时模块的 C ABI 原样保留,Rust
> 实现替换 C++ 实现对调用方透明)。
>
> 阅读顺序:`00`(本文)→ `01-adapter-pattern.md`(适配器规范
> 所有模块共用)→ `02-modules-and-order.md`(模块清单、依赖矩阵、
> 拆分顺序)→ `03-testing.md`(测试规范)→ `M1``M9`模块执行
> 阅读顺序:`00`(本文)→ `01-adapter-pattern.md`(适配器规范 + §0
> 接口铁律,所有模块共用)→ `02-modules-and-order.md`(模块清单、依赖矩阵、
> 拆分顺序)→ `03-testing.md`(测试规范)→ `04-interfaces.md`(模块
> 接口 provides/consumes 全表)→ `M1``M10`(逐模块执行
> 手册,**C API 已在各手册中冻结**)。
## 目标与判据
@@ -18,8 +19,10 @@
```
oakcore(已有,不动)
oakcommon ─ oakundo ─ oaknode ─ oaktimeline ─ oakcodec ─ oakrender ─ oaktask ─ oakplugin
oakaudio ───────────────────┤
└────────────── oakstorage(工程持久化, ┘
后端可插拔:文件→数据库)
oakaudio ─────────────┐
liboakengine= facade + coreengine,纯装配层)
```
+54 -29
View File
@@ -4,6 +4,32 @@
> C API 设计与适配类实现都必须照此执行。命名、内存所有权、错误码、
> 线程与信号的处理在此**冻结**。
## 0. 接口铁律(2026-08 修订,优先级高于本文件其余各节)
模块边界上的接口必须是**纯 C 的、面向对象的**:
1. **对象 = 不透明句柄**。每个跨界类型是一个不透明结构体指针:
`typedef struct OakModClazz OakModClazz;`**手写 struct 标签的不透明
指针**,禁止用 `void *` 充当对象——现有代码里 `void *parent_qobject`
`void *oaktask_import_take_command()` 这类用法是反面教材,新接口一律
禁止,旧接口在所属模块拆分时顺手改为有类型句柄)。
2. **构造/析构 = init/free 函数对**`oakmod_clazz_init*()` /
`oakmod_clazz_free()`。free 对 NULL 是 no-op。
3. **成员函数 = 首参为 self 句柄的普通函数**
`oakmod_clazz_<func>(OakModClazz *self, ...)`;静态成员函数无 self
`_s` 后缀)。
4. **多态 = 手工虚函数表**。确需"基类句柄 + 多种实现"(如存储后端、
undo 命令、渲染后端插件)时,在公共头里定义纯 C 函数指针表
`typedef struct { ...; int (*save)(...); ... } OakModClazzVTable;`),
提供侧填充、消费侧经表调用,**不得让 C++ vtable 跨界**。模板见
`M10-oakstorage.md` §2.3 的 `OakStorageBackend`
5. **禁止 C++ 对象跨越动态库边界**:边界上只出现 C 类型(整数、double、
指针、`const char *`、纯 C POD、不透明句柄)。C++ 类实例、引用、
`std::` 类型、Qt 类型一律不得出现在任何模块的 `include/` 公共头里。
6. **禁止跨界调用 C++ 成员函数**:消费侧对提供侧对象的一切操作必须经
该对象的 C ABI 函数;拿到句柄后 `reinterpret_cast` 回 C++ 指针再调
成员函数视为违规(nm 审计 + 代码评审双保险)。
## 1. 提供侧:C API 层
对被消费的每个 C++ 类 `Clazz`,模块在 `include/oak<mod>/clazz.h`
@@ -32,7 +58,7 @@ OAKMOD_API <ret> oakmod_clazz_<func>_s(/* 参数 */);
3. 多个构造重载用后缀区分:`oakmod_clazz_init`(默认)、
`oakmod_clazz_init_from_file``oakmod_clazz_init_copy` 等。
4. 命名全小写,模块前缀 `oak<mod>_`oakundo/oaknode/oaktimeline/
oakcodec/oakrender/oaktask/oakaudio/oakplugin/oakcommon)。
oakcodec/oakrender/oaktask/oakaudio/oakplugin/oakcommon/oakstorage)。
5. 导出宏 `OAKMOD_API``oakengine/export.h` 样式
`__attribute__((visibility("default")))`),模块编译加
`-fvisibility=hidden`——每个模块**出生即 visibility 干净**
@@ -99,22 +125,26 @@ C ABI 上只允许:整数、`double`、`int64_t`、指针、`const char *`、
| `Qt::enum`/内部枚举 | `int`(取值表写进手册,两侧枚举**序数一致性**用 static_assert 或测试钉死) |
| `std::shared_ptr<T>` | 不透明句柄 + retain/free(协议见 display.h R7-A 的先例) |
## 4. 信号、回调与线程
## 4. 信号、回调与线程(2026-08 修订:上层对下层只有命令)
Qt 信号不许跨模块。处理优先级:
Qt 信号不许跨模块;**下层对上层也不许持有回调**——上层调用下层时
必须知道其影响(改了什么全在返回值/出参里),变更通知由**调用方所在
层**发出,不经下层反向通知。各模块 C ABI 因此一律不含
subscribe/unsubscribe 类函数。处理规则:
1. **回调注册**`oakmod_clazz_set_<event>_cb(self, fn, userdata)`
提供侧在发信号处调 `fn(event_pod, userdata)`。userdata 所有权归
注册方,适配类析构时先 `set_*_cb(self, NULL, NULL)` 反注册
2. **事件总线**:模块级通知(非单对象)用
`oakmod_subscribe(event_id, fn, userdata)` → 返回订阅 id
`oakmod_unsubscribe(id)`——照 `oakengine/events.h` 的现成模式。
3. 线程语义照现状:提供侧在发射线程同步调回调(DirectConnection
1. **同步命令**提供侧把结果放在返回值/出参;消费侧适配类在调用后
自行发 Qt 信号(适配类知道刚执行了什么命令,见 §7 例)。原
`connect()` 到适配类信号的 widget 代码零改动
2. **唯一例外——异步任务**:后台执行单元(oaktask 任务、oakrender
渲染 ticket)提交时拿不到结果,允许进度/完成回调作为该命令的
返回通道:`oak<mod>_<async>_start(handle, done_fn, userdata)` 形式,
一次性语义,完成后自动失效。
3. 线程语义照现状:异步回调在发射线程同步调用(DirectConnection
等价),需要跨线程排队是消费侧适配类自己的事(它可以用
`QMetaObject::invokeMethod(..., Qt::QueuedConnection)`)。
4. **适配类可以把 C 回调再转回 Qt 信号**:适配类继承 QObject、
静态 trampoline 里 `emit` 同名信号——消费侧原有 `connect()` 全部
零改动。这是大多数 widget 侧适配的默认做法
4. **facade→app 的 `oakengine_event` 通道不在此列**:那是引擎对最外层
的唯一通知机制(riir.md §6.1),事件由 facade 在命令完成后发射,
不由下层模块直接发射
## 5. 继承与虚函数
@@ -139,40 +169,35 @@ Qt 信号不许跨模块。处理优先级:
```c
/* oakundo/include/oakundo/undostack.h */
typedef struct OakUndoStack OakUndoStack;
OAKUNDO_API OakUndoStack *oakundo_undostack_init(void *parent_qobject);
typedef struct OakUndoObjectParent OakUndoObjectParent; /* borrowed QObject 挂载点 */
OAKUNDO_API OakUndoStack *oakundo_undostack_init(
const OakUndoObjectParent *parent);
OAKUNDO_API void oakundo_undostack_free(OakUndoStack *self);
OAKUNDO_API void oakundo_undostack_push(OakUndoStack *self,
OakUndoCommand *cmd, const char *name);
OAKUNDO_API int oakundo_undostack_can_undo(const OakUndoStack *self);
/* ... 完整表见 M2 手册 ... */
OAKUNDO_API int64_t oakundo_undostack_subscribe(OakUndoStack *self,
int event_id, oakundo_event_fn fn, void *userdata);
OAKUNDO_API void oakundo_unsubscribe(int64_t id);
OAKUNDO_API int64_t oakundo_undostack_index(const OakUndoStack *self);
/* ... 完整表见 M2 手册(纯命令接口,无任何 subscribe ... */
```
```cpp
// 消费侧 adapter/undostack.h —— app/other 模块里 connect() 零改动
// 消费侧 adapter/undostack.h —— app/other 模块里 connect() 零改动
// 通知规则(§4):适配类发了变更命令,它知道影响,信号由适配类自己 emit。
class UndoStack : public QObject {
Q_OBJECT
public:
explicit UndoStack(QObject *p = nullptr)
: QObject(p), h_(oakundo_undostack_init(p)) {
sub_ = oakundo_undostack_subscribe(h_, OAKUNDO_EVENT_INDEX_CHANGED,
&UndoStack::tramp, this);
}
~UndoStack() override { oakundo_unsubscribe(sub_); oakundo_undostack_free(h_); }
: QObject(p), h_(oakundo_undostack_init(
reinterpret_cast<const OakUndoObjectParent *>(p))) {}
~UndoStack() override { oakundo_undostack_free(h_); }
void push(UndoCommand *c, const QString &n) {
oakundo_undostack_push(h_, c->handle(), n.toUtf8().constData());
emit index_changed(int(oakundo_undostack_index(h_))); // 命令后自发通知
}
bool canUndo() const { return oakundo_undostack_can_undo(h_) != 0; }
signals:
void index_changed(int);
private:
static void tramp(int event_id, int64_t a, int64_t b, void *ud) {
if (event_id == OAKUNDO_EVENT_INDEX_CHANGED)
emit static_cast<UndoStack *>(ud)->index_changed(int(a));
}
OakUndoStack *h_;
int64_t sub_;
};
```
+7 -1
View File
@@ -23,6 +23,11 @@
已知分层违规 1 处:`render/` 引用了 `src/capi/displayinternal.h`
R7-A 重做 display.h 时一并消除)。
> **2026-08 增补(oakstorage 拆出)**:矩阵扫描早于 oakstorage 单列。
> 其依赖关系为:oakstorage → nodeserializer 落盘路径,含版本化
> serializerXXXXXX 族)+ commonoaktask → oakstorageload/save/otio
> 委托);facade → oakstorage。详细契约见 04 §2.10 与 M10。
## 2. 模块定义与拆分顺序
顺序原则:叶子先、根后;每步只引入"已拆模块的 C ABI",不引入
@@ -34,11 +39,12 @@
| M1 | oakcommon | `common/`41 文件工具集)+ `config/` | common→render/node/undo/codec/plugin 的 12 次反向 include(清单见 M1 §3 |
| M2 | oakundo | `undo/`undocommand/undostack | undo→node/project.h 1 处(M2 §3 |
| M3 | oaknode | `node/`(图、工厂、keyframe、nodeundo、traverser | node→render 47、node→codec 8、node→timeline 5、node→audio 4、node→undo 4M3 §3,最大的活) |
| M3a | **oakstorage** | `node/project/serializer` 落盘路径 + `task/project/{load,save,loadotio,saveotio}` 文件 IO(**工程持久化单列**,后端可插拔:当前 XML 文件,未来数据库) | storage→node(序列化建图取图,经 oaknode C ABI);手册 M10 |
| M4 | oaktimeline | `timeline/`marker/workarea/timeline undo 命令族/timelinecommon | timeline→node 32(经 oaknode C ABI + 适配类) |
| M5 | oakcodec | `codec/`decoder/encoder/frame/proxy/conform | codec→render 11videoparams 等随 M3.5 下沉)、codec→task 5、codec→node 3 |
| M6 | oakaudio | `audio/`AudioManager/AudioProcessor/输出) | audio→render 2、audio→codec 1 |
| M7 | oakrender | `render/`Renderer/PlaybackCache/ColorManager/帧缓存/job | render→node 38、render→codec 9、render→task 2、render→undo 1、render→src 1(违规) |
| M8 | oaktask | `task/`Task/TaskManager/项目 load/save/import/OTIO | task→node 38、task→codec 4、task→render 4 |
| M8 | oaktask | `task/`Task/TaskManager/项目任务编排/cache 任务;**工程文件 IO 已划给 oakstorageM3a** | task→node 38、task→codec 4、task→render 4、task→storageload/save 委托) |
| M9 | oakplugin | `pluginSupport/`OpenFX host | plugin→node 6、plugin→render 6、plugin→undo 2、plugin→coreengine 2 |
| — | liboakengine | `src/capi` + `coreengine` + `tool/` + `ui/` 残余 | 纯装配层:facade 内部调用改经各模块 C ABI(或保持现状直接链,见 M9 §4 裁决) |
+4 -2
View File
@@ -29,8 +29,10 @@
4. **枚举序数一致性**:C 侧 POD/枚举与 C++ 侧枚举的映射(01 §3 表)
每个映射 1 个 TEST(如 `oakundo` 的 movement mode 0-3 ⇄
`Timeline::MovementMode`)。
5. **事件/回调**:每个 `set_*_cb`/subscribe 至少 1 个 TEST:触发后
断言回调被调、payload 正确;反注册后断言不再被调。
5. **回调(仅异步任务)**:模块间 C ABI 无 subscribe 类接口(04 §3);
仅异步命令(任务/渲染 ticket)的进度/完成回调需要测试:触发后
断言回调被调、payload 正确、FINISHED 后自动失效。同步命令的测试
改为"调用后直接读状态断言生效"。
6. **所有权**:borrowed 句柄(文档注释标了 `/* borrowed */` 的)
free 后原对象仍存活,1 个 TEST。
+147
View File
@@ -0,0 +1,147 @@
# 04 · 模块间接口设计(provides / consumes 全表)
> 本文是各模块手册(M1M10)C API 的**汇总视图**:每个模块对外提供哪些
> 接口族、消费哪些模块的哪些接口族、边界上流什么数据。逐函数签名以各
> M 手册的"冻结 C API"节为准;本文管"模块与模块之间的契约关系",管不到
> 逐函数细节。
>
> 全部接口遵守 `01-adapter-pattern.md` §0 的铁律:**纯 C、面向对象**——
> 有类型的不透明句柄(禁止 `void *` 当对象)、`init_*`/`free_*` 构造析构、
> 首参 self 的普通函数当成员函数、多态用手工函数指针表、C++ 对象与成员
> 函数一律不跨动态库边界。
## 1. 接口关系总表
行 = 消费方,列 = 提供方;单元格 = 消费的接口族(详见各提供方手册)。
| 消费 ↓ \ 提供 → | oakcommon | oakundo | oaknode | oaktimeline | oakcodec | oakaudio | oakrender | oakstorage | oaktask | oakplugin | oakcore |
|---|---|---|---|---|---|---|---|---|---|---|---|
| **facade**src/capi | 工具/类型 | undo 全族 | node/project/footage/serializer(clipboard) 族 | timeline 全族 | decoder/frame/proxy 族 | audio 全族 | renderer/playback/preview 族 | open/save/probe 族 | task/manager 全族 | plugin 全族 | rational/timecode |
| **oaktask** | 工具 | command 句柄 | footage/project/sequence/folder 族 | — | conform/proxy 族 | — | 导出用 render 族 | **load/save/otio 委托** | — | — | — |
| **oakrender** | videoparams/colortransformM3.5 下沉) | command 句柄 | node 遍历/取值、project、colormanager | — | decoder/frame 族 | 音频参数 | — | — | — | plugin 实例句柄 | — |
| **oakplugin** | 工具 | command 句柄 | node 参数读写 | — | — | — | 帧缓冲/纹理句柄 | — | — | — | — |
| **oakaudio** | 工具 | — | — | — | frame/解码(1 处) | — | videoparams(下沉后消失) | — | — | — | samplebuffer |
| **oakcodec** | videoparams/subtitleparams(下沉) | — | footage 流信息(3 处) | — | — | — | — | — | — | — | rational |
| **oaktimeline** | timelinecommon 枚举(下沉) | command 句柄 | track/block/sequence 族(32 处) | — | — | — | — | — | — | — | — |
| **oaknode** | 枚举/常量/工具 | undocommand.h4 处) | — | marker/workarea2 处,M4 反向) | decoder/frame/proxy8 处,M5 反向) | audio 参数(4 处,M6 反向) | colorprocessor/rendermanager/jobM7 反向,02 §4 裁决 A | — | — | — | rational/bezier |
| **oakundo** | 工具 | — | — | — | — | — | — | — | — | — | — |
| **oakstorage** | 工具 | — | **project/root/序列化建图取图** | — | — | — | — | — | — | — | — |
| **oakcommon** | — | — | — | — | — | — | — | — | — | — | — |
(空格 = 无依赖。"N 处"数据来自 02 的 include 扫描。oakcore 与
ffmpeg_bridge 为现成独立库,不参与拆分顺序。)
## 2. 逐模块接口契约
### 2.1 oakcommonM1)— 纯下沉,无业务对象
- **提供**`include/oakcommon/types.h` 的全模块共用 POD(时间戳/区间/
枚举常量,含 M3.5 下沉的 `OakVideoParams`/`OakSubtitleParams`/
`OakColorTransform`);工具函数(全 `_s` 静态式,无句柄)。
- **消费**:无(叶子)。
- **边界数据**:纯 POD 值,无所有权问题。
### 2.2 oakundoM2)— 手工虚表的第一个用户
- **提供**`OakUndoCommand` / `OakUndoStack` 两类句柄。命令的多态
redo/undo 行为)经 **回调函数指针** 实现
`oakundo_command_create(name, redo, undo, free_fn, userdata)`),
即铁律 §0.4 的手工虚表;消费侧**不构造 C++ 子类**。
- **消费**oakcommon。
- **边界数据**:命令句柄(owned)。无事件——push/undo/redo 的调用方
知道栈索引变化,通知由调用方(facade 适配层)发出(见 §3)。
### 2.3 oaknodeM3)— 最大提供方
- **提供**`OakNodeNode/NodeGroup/NodeKeyframe/NodeFactory/NodeTraverser/
Project/Folder/Sequence/Track/TrackList/Block/Footage/ColorManager`
句柄族。逐族清单见 M3 §2。无订阅接口——所有修改经命令函数完成,
调用方知道影响(§3)。
- **消费**oakcommon、oakundo;对 render/codec/audio/timeline 的引用按
02 §3/§4 的反向切割表在各模块就位后改经其 C ABI。
- **边界数据**:节点句柄(borrowed 为主,工程拥有节点)、
`oak_node_value` POD、id 字符串(buf/size)。
### 2.4 oaktimelineM4
- **提供**marker/workarea/timeline 编辑原语句柄族(`OakTimelineMarker`
等),timeline 专用 undo 命令**经 oakundo 的回调式命令**注册,不自带
命令子类。
- **消费**:oaknode32 处,全部经句柄族)、oakundo、oakcommon。
### 2.5 oakcodecM5
- **提供**`OakCodecDecoder/Encoder/Frame/ProxyManager` 句柄族;
帧以 `OakCodecFrame *` 不透明句柄跨边界(owned,配对 free),
像素数据经 `oakcodec_frame_data(frame, plane, &linesize)` 取出指针
borrowed,生命周期随 frame)。
- **消费**oakcommon、oaknodefootage 流信息)、oakcore、ffmpeg_bridge。
### 2.6 oakaudioM6
- **提供**`OakAudioManager/Processor/Synchronizer` 句柄族;波形/电平
数据以 POD 数组 + count 出参。
- **消费**oakcoresamplebuffer)、oakcodec1 处)。
### 2.7 oakrenderM7
- **提供**`OakRenderRenderer/Ticket/Cache/ColorProcessor` 句柄族;
渲染结果帧为 owned 句柄;渲染 ticket 是**异步命令**(后台线程),
进度/完成回调是它的返回通道——这是 §3 允许回调的唯一情形
(线程语义按 riir.md §6.2 钉死)。
- **消费**oaknode、oakcodec、oakcommon、oakundo1 处)、oakbackend
GPU 插件,经 `renderbackend_c.h` 手工虚表——现有先例)。
### 2.8 oaktaskM8)— 编排者
- **提供**`OakTaskTask` 句柄 + 任务工厂族 + TaskManager 查询函数。
任务是**异步命令**:进度/完成回调即其返回通道(§3 唯一例外),
无其他事件。load/save/import/otio 任务工厂保留,但**实现改为委托
oakstorage**(见 M10 §4);任务结果(Project、Footage 列表)以
**有类型句柄**返回(`oaktask_import_take_command``void *` 返回
按铁律 §0.1 改为 `OakUndoCommand *`)。
- **消费**oakstorage、oaknode、oakcodec、oakrender、oaktimeline。
### 2.9 oakpluginM9
- **提供**OFX 插件加载/实例句柄族(`OakPluginHost/Instance`)。
- **消费**oaknode、oakrender、oakundo。
### 2.10 oakstorageM10,新拆)— 工程持久化
- **提供**`OakStorageProject`(打开的工程会话)、`OakStorageBackend`
(手工虚函数表,后端注册用)两类句柄 + `oakstorage_open/save/probe`
静态函数。**URI 寻址**`file://…/*.ove` 走内建 ove-xml 后端;未来
`oakdb://` 走数据库后端——替换数据库 = 新增一个后端实现并注册,
消费侧零改动。
- **消费**:oaknode(反序列化建图 / 序列化取图)、oakcommon。
- **边界数据**:工程句柄(owned)、XML 字节流(buf/size)、后端表。
无事件——open/save 是同步命令,成败与结果全在返回值里,调用方
oaktask/facade)知道影响,由它发通知(§3)。
- 详见 `M10-oakstorage.md`
## 3. 通知的统一约定(2026-08 修订:上层对下层只有命令)
**铁律:上层调下层只发命令,不调订阅。** 上层调用下层时必须知道该调用的
影响——改了什么、结果是什么,全部由返回值/出参告知,调用方自己决定后续
动作。因此:
- **模块间 C ABI 一律不提供 subscribe/unsubscribe 类接口。** 下层不持有
上层的函数指针,不反向通知。各模块手册里原有的 `oak<mod>_subscribe`
设计全部作废(M2/M3/M10 已改)。
- **变更通知由命令发起方负责。** 例:app 经 facade 调 `undo` 命令后,
facade 知道栈索引变了,由 facade 经既有 `oakengine_event` 通道通知
app 侧——通知的起点永远是最靠近调用者的那一层。
- **唯一例外:异步任务。** 后台执行的单元(oaktask 的任务、oakrender 的
渲染 ticket)本质上是"提交时拿不到结果"的命令,允许进度/完成回调——
回调即该命令的返回通道,线程语义按 01 §4 / riir.md §6.2 钉死。
同步接口不得配回调。
## 4. 错误与所有权(横向统一)
- 返回码:`0 = OK`,负值 `OAK<MOD>_E_*`(值与 oakengine 现有对齐);
细节经 `oak<mod>_last_error(buf, size)`(线程局部)。
- 所有权注释三档:`/* owned */`init/take 返回,必须配对 free)、
`/* borrowed */`(访问器返回,禁 free)、`/* retained */`register
类,配对 unregister)。句柄默认 owned。
- 每个模块提供 `oak<mod>_debug_alive_count()`(测试专用,钉死泄漏)。
+176
View File
@@ -0,0 +1,176 @@
# M10 · oakstorage 拆分手册(工程持久化,数据库替换预备)
> 内容:工程文件的读写——`engine/node/project/serializer/` 的**落盘路径**
> Load/Save/SaveData/LoadData 的文件分支 + 版本化 serializerXXXXXX 族)
> 与 `engine/task/project/{load,save,loadotio,saveotio}` 的**文件 IO 部分**。
> **不含**剪贴板序列化(copy/paste 的节点图 XML 留在 oaknode 的
> serializer 族,见 M3)。
> 依赖:oaknodeproject/root/序列化建图取图)、oakcommon。
> 被依赖:oaktaskload/save 任务委托)、facade。
> 拆分顺序:M3aoaknode 之后、oakserialize 同批)。
>
> **本模块存在的理由**:把"工程从哪来、存到哪去"收敛成唯一模块、唯一
> 接口,当前后端是 XML .ove 文件,**未来整体替换为数据库**。替换时新增
> 一个后端实现并注册即可,oaktask / facade / app 零改动。因此本模块的
> 接口按"存储后端无关"设计:URI 寻址、手工虚函数表、字节流 + 句柄,
> 接口里**不出现任何文件路径特有的概念以外的存储语义**(事务、连接串
> 等都藏在后端内部)。
## 1. 目标形态
```
oakstorage/
include/oakstorage/{storage.h, backend.h, types.h, export.h}
src/
capi_storage.cpp # storage.h 实现:URI 分发 + 会话
backends/ove_xml/ # 内建后端:XML .ove(现有 serializer 落盘路径迁入)
backends/... # 未来:oakdb(数据库后端)
tests/ # oakstorage_gtest
```
## 2. 冻结 C API
遵守 01 §0 铁律:纯 C、有类型不透明句柄、init/free、self 首参、
多态用手工虚函数表。
### 2.1 `oakstorage/types.h`
```c
/* 错误码(0=OK,负值错误;版本/格式探测用正值信息码) */
#define OAKSTORAGE_OK 0
#define OAKSTORAGE_TOO_OLD 1 /* 工程版本过旧(信息码) */
#define OAKSTORAGE_TOO_NEW 2 /* 工程版本过新 */
#define OAKSTORAGE_UNKNOWN_VERSION 3
#define OAKSTORAGE_E_INVALID -1
#define OAKSTORAGE_E_STATE -2
#define OAKSTORAGE_E_NOT_FOUND -3
#define OAKSTORAGE_E_FAILED -4
#define OAKSTORAGE_E_NO_BACKEND -5 /* 无后端认领该 URI */
#define OAKSTORAGE_E_FORMAT -6 /* 解析失败(XML/DB 约束) */
#define OAKSTORAGE_E_IO -7 /* 读写失败 */
```
### 2.2 `oakstorage/storage.h`(消费侧主接口)
```c
/* 打开的工程会话:包裹一个已加载(或待保存)的 oakmodel Project。
* owned 句柄,配对 oakstorage_project_free。 */
typedef struct OakStorageProject OakStorageProject;
/* --- 静态函数(无 self,对应"类方法" ---
* 全部是同步命令:成败与结果全在返回值/出参里,调用方知道影响,
* 由调用方(oaktask/facade)负责对外通知;本模块无任何回调/事件。 */
/* 探测 URI:返回认领该 URI 的后端名(buf/size,先 NULL 查长度),
* 或负值(OAKSTORAGE_E_NO_BACKEND)。不写盘、不建会话。 */
OAKSTORAGE_API int oakstorage_probe(const char *uri, char *buf, int buf_size);
/* 打开工程(load)。URI scheme 选后端:
* file:///path/to/proj.ove → ove-xml 后端
* file:///…/proj.otio → otio 后端(import 语义)
* oakdb://… → 未来数据库后端
* 失败返回 NULL,细节经 oakstorage_last_error。 */
OAKSTORAGE_API OakStorageProject *oakstorage_open(
const char *uri, int *result_code);
/* 把 project 保存到 URIsave / save-as)。
* `options`:位掩码,OAKSTORAGE_SAVE_COMPRESS 等;后端忽略不识别的位。 */
#define OAKSTORAGE_SAVE_COMPRESS 0x1
OAKSTORAGE_API int oakstorage_save(OakNodeProject *project,
const char *uri, unsigned options);
/* --- 成员函数(self 首参) --- */
OAKSTORAGE_API void oakstorage_project_free(OakStorageProject *self);
/* 取出工程句柄:所有权转移给调用方(此后 self 为空壳,仍须 free)。
* 对应 oakengine/task.h 的 take_project 语义。 */
OAKSTORAGE_API OakNodeProject *oakstorage_project_take_project(
OakStorageProject *self);
/* borrowed:不转移所有权 */
OAKSTORAGE_API OakNodeProject *oakstorage_project_project(
const OakStorageProject *self);
/* 会话来源 URIbuf/size */
OAKSTORAGE_API int oakstorage_project_uri(const OakStorageProject *self,
char *buf, int buf_size);
OAKSTORAGE_API int oakstorage_last_error(char *buf, int buf_size);
OAKSTORAGE_API int oakstorage_debug_alive_count(void); /* 测试专用 */
```
### 2.3 `oakstorage/backend.h`(手工虚函数表——后端注册接口)
多态按 01 §0.4:纯 C 函数指针表,提供侧(后端)填充,oakstorage 核心
经表调用。**后端实现不进公共头**;数据库后端未来只是多注册一行。
```c
/* 存储后端虚表。所有函数必需;返回码用 OAKSTORAGE_*。
* 句柄协议:load 成功时 *out_project 收到 owned OakNodeProject*
* 后端可在 vtable 之外持有任意私有状态(连接池、事务句柄等)。 */
typedef struct OakStorageBackend {
const char *name; /* "ove-xml" / "otio" / "oakdb"(静态字符串) */
const char *uri_scheme; /* "file" / "oakdb";同一 scheme 可注册多个
* 后端,按 can_handle 顺序裁决 */
/* 是否认领该 URI(后缀、magic bytes、连接可达性等,后端自决) */
int (*can_handle)(const char *uri);
/* 加载:URI → owned Project 句柄;*result_code 收 OAKSTORAGE_* */
OakNodeProject *(*load)(const char *uri, int *result_code,
char *err_buf, int err_buf_size);
/* 保存:Project → URIoptions 透传 storage.h 的位掩码 */
int (*save)(OakNodeProject *project, const char *uri, unsigned options,
char *err_buf, int err_buf_size);
} OakStorageBackend;
/* 注册/注销。oakstorage 核心不拷贝表体——后端必须保证表与 name 字符串
* 在 unregister 前存活(内建后端为静态存储期,天然满足)。 */
OAKSTORAGE_API int oakstorage_backend_register(const OakStorageBackend *backend);
OAKSTORAGE_API int oakstorage_backend_unregister(const char *name);
```
**数据库替换路径(未来的活,接口已预留)**:
1. 写 `backends/oakdb/``can_handle``oakdb://``load/save` 走 SQL
建表 schema 是后端私事;
2. `oakstorage_backend_register(&oakdb_backend);` 一行接入;
3. oaktask/facade/app 不动;`file://` 的 .ove 后端继续共存(迁移期
双后端并存,经 URI 显式选择)。
**反向约束**:任何"必须改本手册 §2.2 才能接数据库"的需求,说明接口
冻结有洞——先改本手册再动手。
### 2.4 与 oaktask 的边界(任务只是壳)
M8 的任务工厂保留原签名,实现改为薄委托:
```c
/* oaktask/project.cpp(概念) */
OakTaskTask *oaktask_create_project_load(const char *filename) {
/* 任务体内:oakstorage_open(uri) → 完成回调里
* oakstorage_project_take_project() */
}
```
import/conform/precache 等非工程 IO 任务不经 oakstorage。
## 3. 切割点
| 现状 | 处理 |
|---|---|
| `node/project/serializer/*` 落盘路径在 oaknode 内 | 文件分支 + serializerXXXXXX 版本族迁入 `oakstorage/backends/ove_xml/`;**节点图 XML 生成/解析(SaveData/LoadData 的内存形态)仍属 oaknode**ove-xml 后端经 oaknode C ABI`oaknode_serializer_*` 族)取图/建图。剪贴板分支留 oaknodeM3/M3b |
| `task/project/load|save` 含文件 IO | IO 部分下沉 oakstorage;task 保留任务编排(进度、取消、事件) |
| `task/project/loadotio|saveotio` | 同上,注册为 "otio" 后端(scheme=filecan_handle 认 .otio |
| serializer 对 Qt 文件对话框/布局的引用 | 布局信息(SerializedLayoutInfo)随保存走 options 的不透明 blobbuf/size),不进入本手册冻结面 |
## 4. 测试(映射 03 §2/§3
- **round-trip 字节一致**(金标准):project_with_footage.ove →
`oakstorage_open``oakstorage_save` 到临时 URI → 两文件字节一致;
再 load 后 `oakstorage_project_project()` 非空、root 非空。
- probe.ove(压缩/未压缩各一)、.otio、未知 scheme →
E_NO_BACKEND。
- 错误路径:不存在文件 open → NULL + last_error 非空;TOO_NEW 版本
头 → result_code = OAKSTORAGE_TOO_NEW。
- 后端虚表:注册一个内存 mock 后端(`mem://`load/save 记日志),
断言 open/save 全走虚表、unregister 后 probe 报 E_NO_BACKEND——
**这条用例就是"数据库可插拔"的接口验证**
- `oakstorage_debug_alive_count()`open/take/free 配对无泄漏。
+14 -11
View File
@@ -40,7 +40,12 @@ OAKUNDO_API void oakundo_command_free(OakUndoCommand *cmd); /* NULL no-op */
```c
typedef struct OakUndoStack OakUndoStack;
OAKUNDO_API OakUndoStack *oakundo_undostack_init(void *parent_qobject);
typedef struct OakUndoObjectParent OakUndoObjectParent; /* borrowed QObject 挂载点 */
OAKUNDO_API OakUndoStack *oakundo_undostack_init(
const OakUndoObjectParent *parent);
/* OakUndoObjectParentQObject 父子树挂载点的 borrowed 不透明句柄
* 2026-08 修订:按 01 §0.1 由 void* 改为有类型句柄;
* 提供侧内部即 QObject*,消费侧不可解引用;可传 NULL 表无父) */
OAKUNDO_API void oakundo_undostack_free(OakUndoStack *self);
OAKUNDO_API void oakundo_undostack_push(OakUndoStack *self,
@@ -65,16 +70,13 @@ OAKUNDO_API void oakundo_undostack_update_actions(OakUndoStack *self);
/* QAction* 句柄(GUI 菜单绑定用,borrowed */
OAKUNDO_API void *oakundo_undostack_undo_action(OakUndoStack *self);
OAKUNDO_API void *oakundo_undostack_redo_action(OakUndoStack *self);
/* 事件(index_changed */
#define OAKUNDO_EVENT_INDEX_CHANGED 1
typedef void (*oakundo_event_fn)(int event_id, int64_t a, int64_t b,
void *userdata);
OAKUNDO_API int64_t oakundo_undostack_subscribe(OakUndoStack *self,
int event_id, oakundo_event_fn fn, void *userdata);
OAKUNDO_API void oakundo_unsubscribe(int64_t subscription_id);
```
**无事件接口(2026-08 修订)**:上层对下层只有命令。push/undo/redo/jump
的调用方知道栈索引的变化(`oakundo_undostack_index` 调用后即可读),
`index_changed` 通知改由**调用方所在层**(facade 的 undo 适配层)在
每次变更命令后自行发出——oakundo 不持有任何上层回调。
## 3. 切割点(1 处)
`undocommand.cpp` include `node/project.h``get_relevant_project()`
@@ -96,7 +98,8 @@ Project* 本来就只是作为不透明身份被使用(修改标记归属)
- 每条 API 正常+错误路径(NULL self、空 push 不入栈——
空 MultiUndoCommand 被删除的既有行为必须有 TEST 钉死)。
- push/undo/redo/jump/clear 全序列;command_text/is_done 边界行
- 事件:push 后 index_changed 触发且 a=新 indexunsubscribe 后不再触发。
- push/undo/redo/jump/clear 全序列;command_text/is_done 边界行
每步后 `oakundo_undostack_index` 读数与预期一致(替代原事件断言——
调用方知道影响,直接读状态)。
- 往返测试:C API 与适配类各做一遍 push-undo-redo,状态一致。
- `oakundo_debug_alive_count()`init/free 配对无泄漏。
+11 -9
View File
@@ -40,16 +40,18 @@ project.h、timeline.h 的对应函数就是模板,参数命名前缀换
| Track / TrackList | timeline | height/mute/lock/index/type、block 增删、split/ripple 原语 |
| Block / ClipBlock / GapBlock / TransitionBlock | timeline | in/out/length/media_in、speed/reverse/loop、links |
| Footage | task | filename、streams、proxy、duration |
| ProjectSerializer | task | save/load/copy/pasteclipboard 族,照 oakengine/serializer.h 模板) |
| ProjectSerializer | task / oakstorage | **剪贴板** copy/paste + 节点图 XML 的内存形态(SaveData/LoadData,照 oakengine/serializer.h 模板);**落盘 save/load 迁 oakstorageM10),不在本模块** |
| ColorManager | render | config、default config、display transform |
**特殊约定**
1. undoable 变体与 live 变体成对(`_live` 后缀或 `, void *command`
尾参),与 oakengine 现状一致。
2. 事件:Node 族事件(label/input/keyframe/context 等)经
`oaknode_subscribe(handle, event_id, fn, userdata)`——事件 ID 表
直接沿用 `oakengine/events.h` 的 70-95 段(值不变,便于
EngineEventBridge 逐步换绑)。
1. undoable 变体与 live 变体成对(`_live` 后缀或
`, OakUndoCommand *command` 尾参——有类型句柄,禁 void*),
与 oakengine 现状一致。
2. 无事件订阅接口(2026-08 修订,04 §3):oaknode 的所有修改都经
命令函数完成,调用方知道影响;Node 族的变更通知(label/input/
keyframe/context 等)由 facade 在执行命令后经既有 `oakengine_event`
通道发出(事件 id 沿用 `oakengine/events.h` 70-95 段,值不变),
oaknode 自身不持有任何上层回调。
3. `Node *``Project *` 等句柄即 `OakNodeNode *`/`OakNodeProject *`
不透明。
4. 虚函数不出模块(01 §5);具体节点类型经
@@ -73,8 +75,8 @@ M3 阶段判据(放宽版):oaknode 目录就位、C API 实现、oaknode_g
- 重点:Node 增删连边、Project/Folder 层级、Track 属性、
keyframe live/undoable 对称、serializer 剪贴板往返、
factory 枚举与创建
- 事件:每族至少 1 个 subscribe/trigger/unsubscribe 用例
factory 枚举与创建;每个变更命令执行后直接读状态断言生效
(无事件可断言——通知在 facade 层测)
- 枚举序数:NodeValue::Type ⇄ oak_node_value_type 映射表(已在
nodevaluehandle.h 钉过一次,oaknode 测试再钉一次,防两侧漂移)。
- `oaknode_debug_alive_count()` 泄漏断言。
+11 -11
View File
@@ -35,17 +35,16 @@ OAKTL_API int oaktimeline_marker_at(const OakTimelineMarkerList *l, int i,
int64_t *in_ts, int64_t *out_ts, int *color, char *name_buf, int n);
OAKTL_API int oaktimeline_marker_add(OakTimelineMarkerList *l,
int64_t in_ts, int64_t out_ts, const char *name, int color,
void *command); /* command=NULL 时自行入栈 */
OakUndoCommand *command); /* command=NULL 时自行入栈2026-08void* → 有类型句柄) */
OAKTL_API int oaktimeline_marker_remove_at(OakTimelineMarkerList *l,
int i, void *command);
int i, OakUndoCommand *command);
OAKTL_API int oaktimeline_marker_set_time(OakTimelineMarkerList *l,
int i, int64_t in_ts, int64_t out_ts, void *command);
int i, int64_t in_ts, int64_t out_ts, OakUndoCommand *command);
OAKTL_API int oaktimeline_marker_set_props(OakTimelineMarkerList *l,
int i, int color, const char *name, void *command);
/* 事件MARKER_ADDED/REMOVED/MODIFIEDid 沿用 oakengine events 段) */
OAKTL_API int64_t oaktimeline_subscribe(void *handle, int32_t event_id,
oaktl_event_fn fn, void *userdata);
OAKTL_API void oaktimeline_unsubscribe(int64_t id);
int i, int color, const char *name, OakUndoCommand *command);
/* 事件接口(2026-08 修订,04 §3):marker 的增删改都是调用方发的
* 命令,MARKER_ADDED/REMOVED/MODIFIED 通知由调用方所在层(facade,
* id 沿用 oakengine events 段)在命令后发出。 */
```
### 2.2 `oaktimeline/workarea.h`
@@ -58,9 +57,9 @@ OAKTL_API int oaktimeline_workarea_set_range(OakTimelineWorkarea *w,
int64_t in_ts, int64_t out_ts); /* live */
OAKTL_API int oaktimeline_workarea_set_range_undoable(
OakTimelineWorkarea *w, int64_t in_ts, int64_t out_ts,
int64_t old_in_ts, int64_t old_out_ts, void *command);
int64_t old_in_ts, int64_t old_out_ts, OakUndoCommand *command);
OAKTL_API int oaktimeline_workarea_set_enabled_undoable(
OakTimelineWorkarea *w, int enabled, void *command);
OakTimelineWorkarea *w, int enabled, OakUndoCommand *command);
OAKTL_API void oaktimeline_workarea_reset(int64_t *in_ts, int64_t *out_ts);
/* load/save 经 oakcommon_xml 句柄在 oaknode 序列化路径调用 */
OAKTL_API int oaktimeline_workarea_load(OakTimelineWorkarea *w,
@@ -107,7 +106,8 @@ OAKTL_API int64_t oaktimeline_nearest_block_ts(OakNodeTrack *track,
## 4. 测试(映射 03 §2/§3
- marker:增删改查、undo 往返(push 后 undo 恢复)、事件三件套。
- marker:增删改查、undo 往返(push 后 undo 恢复);每命令后读
count/at 断言生效(无事件——通知在 facade 层测)。
- workareaset/get、undoable 旧值恢复、reset 哨兵、xml 往返。
- edit:每个 `_command` 工厂 1 个"构造→入栈→undo 还原"用例
track 增删、place/replace/trim/split/ripple/slide)。
+3 -5
View File
@@ -45,10 +45,8 @@ OAKAU_API int oakaudio_manager_push(const float *samples,
int frame_count, double speed);
OAKAU_API int oakaudio_manager_is_playing(void);
OAKAU_API void oakaudio_manager_stop(void);
/* 输出参数变化事件 */
OAKAU_API int64_t oakaudio_manager_subscribe_params_changed(
oakaudio_event_fn fn, void *userdata);
OAKAU_API void oakaudio_unsubscribe(int64_t id);
/* 无事件接口(2026-08 修订,04 §3):输出参数的修改是调用方发的
* 命令(set_params),params_changed 通知由调用方所在层发出。 */
```
## 3. 切割点
@@ -63,5 +61,5 @@ OAKAU_API void oakaudio_unsubscribe(int64_t id);
- processoropen/convert/close 全链(44.1k stereo → 48k stereo
帧数换算正确、无爆音断言用能量差阈值)、速度 1.5x。
- manager:无音频设备环境用 null backend 初始化(现有后端探测
模式),params set/get 往返、事件触发
模式),params set/get 往返(set 后 get 读数即生效——无事件)
- `oakaudio_debug_alive_count()` 泄漏断言。
+6 -4
View File
@@ -51,9 +51,10 @@ OAKRD_API int oakrender_frame_cache_load(OakRenderCache *c,
OakCodecFrame **out_frame);
OAKRD_API void oakrender_frame_cache_save(OakRenderCache *c,
const char *path, const char *uuid, const OakCodecFrame *f);
/* 缓存事件(playback invalidated/validated、frame invalidated */
OAKRD_API int64_t oakrender_cache_subscribe(OakRenderCache *c,
int32_t event_id, oakrender_event_fn fn, void *userdata);
/* 缓存事件(2026-08 修订,04 §3):缓存的 invalidate/validate
* 编辑命令的调用方触发并知情,通知由 facade 在命令后发出;
* oakrender 不持有上层回调。渲染 ticket 的完成回调属异步命令
* 返回通道,不在此限(见 renderer.h 族)。 */
```
### 2.3 `oakrender/color.h`
@@ -107,7 +108,8 @@ OAKRD_API int oakrender_disk_cache_clear(void);
## 4. 测试(映射 03 §2/§3
- cacheinvalidate/validate 状态机、帧缓存存取往返、事件触发
- cacheinvalidate/validate 状态机、帧缓存存取往返(调用方触发
读状态断言——无事件)。
- color:默认 config 建置、processor convert 已知值(sRGB→Linear
抽样点数值断言,容差 1e-3)。
- managerrequest_frame 对 demo.mp4 + 最小 sequence 出帧非空
+16 -11
View File
@@ -1,7 +1,8 @@
# M8 · oaktask 拆分手册
> 内容:`engine/task/`Task 基类、TaskManager、project/
> load/save/import/loadotio/saveotio、cache 任务)。
> 内容:`engine/task/`Task 基类、TaskManager、任务编排、cache 任务;
> **工程文件 IO——project/load/save/loadotio/saveotio 的落盘部分——
> 已划给 oakstorageM10),本模块只做任务壳**)。
> 依赖:node 38、codec 4、render 4、common 4、config 2、timeline 1、
> coreengine 2。
> 拆分顺序第 8 位。
@@ -30,7 +31,8 @@ OAKTK_API int oaktask_task_succeeded(const OakTaskTask *t);
OAKTK_API int oaktask_task_progress(const OakTaskTask *t, double *out);
OAKTK_API int oaktask_task_title(OakTaskTask *t, char *buf, int n);
OAKTK_API int oaktask_task_error(OakTaskTask *t, char *buf, int n);
/* 事件:STARTED/PROGRESS/FINISHEDid 沿用 oakengine events 段) */
/* 进度/完成回调:任务是异步命令,回调即其返回通道——04 §3 唯一
* 例外情形(一次性语义,FINISHED 后自动失效) */
OAKTK_API int64_t oaktask_task_subscribe(OakTaskTask *t, int32_t event_id,
oaktask_event_fn fn, void *userdata);
```
@@ -42,6 +44,9 @@ OAKTK_API OakTaskTask *oaktask_create_project_load(const char *filename);
OAKTK_API OakTaskTask *oaktask_create_project_save(OakNodeProject *p,
const char *filename_or_NULL, int use_compression,
const void *layout_or_NULL);
/* 注:以上两个工厂是薄壳——文件 IO 全部委托 oakstorageM10 §2.4),
* 任务只保留进度/取消/事件编排;`use_compression` 等打包进
* oakstorage_save 的 options 位掩码。 */
OAKTK_API OakTaskTask *oaktask_create_project_import(OakNodeNode *folder,
const char *const *urls, int url_count);
OAKTK_API OakTaskTask *oaktask_create_project_load_otio(
@@ -50,7 +55,7 @@ OAKTK_API OakTaskTask *oaktask_create_project_save_otio(
OakNodeProject *p, const char *filename, const int *sequence_indexes,
int count);
/* import 结果(task 成功后读,borrowed */
OAKTK_API void *oaktask_import_take_command(OakTaskTask *t); /* 所有权转移 */
OAKTK_API OakUndoCommand *oaktask_import_take_command(OakTaskTask *t); /* 所有权转移2026-08 修订:按 01 §0.1 由 void* 改为有类型句柄) */
OAKTK_API int oaktask_import_footage_count(OakTaskTask *t);
OAKTK_API OakNodeNode *oaktask_import_footage_at(OakTaskTask *t, int i);
OAKTK_API int oaktask_import_invalid_count(OakTaskTask *t);
@@ -66,10 +71,9 @@ OAKTK_API OakNodeProject *oaktask_load_take_project(OakTaskTask *t);
OAKTK_API int oaktask_manager_count(void);
OAKTK_API OakTaskTask *oaktask_manager_at(int i); /* borrowed */
OAKTK_API void oaktask_manager_delete_finished(void);
/* 事件:TASK_ADDED/REMOVED/FAILED/LIST_CHANGED */
OAKTK_API int64_t oaktask_manager_subscribe(int32_t event_id,
oaktask_event_fn fn, void *userdata);
OAKTK_API void oaktask_unsubscribe(int64_t id);
/* 无 manager 事件(2026-08 修订,04 §3):任务的创建/删除都由调用方
* 发起(facade 建任务即知 ADDEDdelete_finished 的调用方知 REMOVED),
* LIST_CHANGED 通知由调用方所在层发出。 */
```
## 3. 切割点
@@ -77,6 +81,7 @@ OAKTK_API void oaktask_unsubscribe(int64_t id);
| 现状 | 处理 |
|---|---|
| task → node/ 38footage 6、project 5、sequence 3、serializer/layout 3、colormanager 2 等) | 全部经 oaknode C ABI + 适配类(M3 已就位)——本手册工作量主体 |
| task → 工程文件 IOload/save/loadotio/saveotio 落盘) | 划给 **oakstorage**(M3a/M10):任务工厂保留签名,实现改为委托 `oakstorage_open/save` |
| task → codec/ 4 | 经 oakcodec C ABIM5 |
| task → render/ 4 | 经 oakrender C ABIM7 |
| task → timeline/ 1 | 经 oaktimeline C ABIM4 |
@@ -88,7 +93,7 @@ OAKTK_API void oaktask_unsubscribe(int64_t id);
入栈)、save/load 往返(临时目录 .oveload 后 project 非空、
root 非空)。
- start_sync 成功/失败路径(不存在文件 → failed + error 非空)。
- 事件:STARTED→PROGRESS→FINISHED 序列(导入任务断言至少一次
PROGRESS 且 FINISHED.succeeded==1)。
- manager添加/删除/list_changed 事件
- 事件(异步任务例外)STARTED→PROGRESS→FINISHED 序列(导入任务
断言至少一次 PROGRESS 且 FINISHED.succeeded==1)。
- managercount/at/delete_finished 行为(无事件,直接读状态断言)
- `oaktask_debug_alive_count()` 泄漏断言。
+3 -1
View File
@@ -43,7 +43,9 @@ OAKPL_API int oakplugin_instance_get_param(OakPluginInstance *i,
const char *param_id, oak_node_value *out);
OAKPL_API int oakplugin_instance_render(OakPluginInstance *i,
OakCodecFrame *dst, const OakCodecFrame *src, int64_t ts);
/* 进度事件(R6 已把 cancelled 信号改成 C 回调,沿用该机制) */
/* 进度回调:instance_render 是异步命令(渲染中进行),进度/取消回调
* 即其返回通道——04 §3 唯一例外情形(R6 已把 cancelled 信号改成 C
* 回调,沿用该机制)。非异步接口一律不配回调。 */
OAKPL_API void oakplugin_instance_set_progress_cb(OakPluginInstance *i,
oakplugin_progress_fn fn, void *userdata);
OAKPL_API void oakplugin_instance_cancel(OakPluginInstance *i);
+1 -1
View File
@@ -207,7 +207,7 @@ extern "C" {
/* AudioManager family (handle: oakengine_audio_manager_handle(), see
* oakengine/audio.h). Fired when the output device or format changes. */
#define OAKENGINE_EVENT_AUDIO_MANAGER_OUTPUT_PARAMS_CHANGED 140 /**< no payload. */
#define OAKENGINE_EVENT_AUDIO_MANAGER_OUTPUT_NOTIFY 141 /**< no payload; emitted after each notify interval of audio has been consumed. */
#define OAKENGINE_EVENT_AUDIO_MANAGER_OUTPUT_NOTIFY 144 /**< no payload; emitted after each notify interval of audio has been consumed. */
/* ---- Playback cache / frame cache (B9c) ----------------------------------- */
#define OAKENGINE_EVENT_PLAYBACK_CACHE_INVALIDATED 141
+20
View File
@@ -1111,6 +1111,26 @@ QVector<QMetaObject::Connection> connect_event(
Qt::DirectConnection));
break;
}
case OAKENGINE_EVENT_AUDIO_MANAGER_OUTPUT_NOTIFY: {
auto *audio_manager = dynamic_cast<AudioManager *>(obj);
if (!audio_manager) {
break;
}
conns.append(QObject::connect(
audio_manager, &AudioManager::output_notify, audio_manager,
[fn, userdata, audio_manager]() {
invoke(fn, userdata,
OAKENGINE_EVENT_AUDIO_MANAGER_OUTPUT_NOTIFY,
audio_manager, 0, 0, nullptr);
},
Qt::DirectConnection));
break;
}
case OAKENGINE_EVENT_PLAYBACK_CACHE_INVALIDATED:
case OAKENGINE_EVENT_PLAYBACK_CACHE_VALIDATED: {
auto *cache = dynamic_cast<PlaybackCache *>(obj);
+14
View File
@@ -142,6 +142,20 @@ add_executable(olive-gtest
mainwindow_test.cpp
serialized_layout_xml_test.cpp
common_ratiodialog_test.cpp
common_misc2_test.cpp
widget_standardcombos_test.cpp
widget_sliderbase_test.cpp
widget_handles_test.cpp
widget_timebased2_test.cpp
widget_viewer2_test.cpp
timeline_common_test.cpp
widget_projectexplorer2_test.cpp
widget_nodeview_test.cpp
widget_nodeparamview_test.cpp
dialog_misc2_test.cpp
widget_scope_test.cpp
mainwindow2_test.cpp
widget_keyframe_multicam_test.cpp
)
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Test)
+802
View File
@@ -0,0 +1,802 @@
#include <gtest/gtest.h>
#include <QBuffer>
#include <QDataStream>
#include <QSet>
#include <QVariant>
#include <QVector2D>
#include <QVector3D>
#include <QVector4D>
#include "common/configwrapper.h"
#include "common/debugapp.h"
#include "common/nodedatatypes.h"
#include "common/nodevaluehandle.h"
#include "common/oakvaluehelper.h"
#include "common/projecttypes.h"
#include "common/subtitleapp.h"
#include "common/tooltypes.h"
#include "common/trackreferencehandle.h"
#include "common/undowrapper.h"
#include "oakengine/undo.h"
#include "oakutil/qtutils.h"
namespace
{
// OakConfigValue reads/writes go through the process-wide engine config, so
// snapshot and restore each key the tests touch
class ScopedConfigKey {
public:
explicit ScopedConfigKey(const QString &key)
: key_(key)
, old_value_(oakengine_config_string(key))
{
}
~ScopedConfigKey()
{
oakengine_config_set_string(key_.toUtf8().constData(),
old_value_.toUtf8().constData());
}
private:
static QString oakengine_config_string(const QString &key)
{
char buf[1024];
const int len = oakengine_config_get_string(key.toUtf8().constData(),
buf, sizeof(buf));
return QString::fromUtf8(buf, len);
}
QString key_;
QString old_value_;
};
// App-side undo command for wrap_app_undo_command(): counts invocations and
// reports its own destruction so ownership transfer can be observed
struct CountingCmd {
int *redo_count;
int *undo_count;
bool *destroyed;
void redo()
{
++*redo_count;
}
void undo()
{
++*undo_count;
}
~CountingCmd()
{
*destroyed = true;
}
};
} // namespace
TEST(ToolTypes, AddableObjectNamesCoverAllValues)
{
EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_empty),
QStringLiteral("Empty"));
EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_bars),
QStringLiteral("Bars"));
EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_shape),
QStringLiteral("Shape"));
EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_solid),
QStringLiteral("Solid"));
EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_title),
QStringLiteral("Title"));
EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_tone),
QStringLiteral("Tone"));
EXPECT_EQ(
olive::Tool::get_addable_object_name(olive::Tool::k_addable_subtitle),
QStringLiteral("Subtitle"));
// Out-of-range values fall through to "Unknown"
EXPECT_EQ(olive::Tool::get_addable_object_name(olive::Tool::k_addable_count),
QStringLiteral("Unknown"));
EXPECT_EQ(olive::Tool::get_addable_object_name(
static_cast<olive::Tool::AddableObject>(-1)),
QStringLiteral("Unknown"));
}
TEST(ToolTypes, AddableObjectIdsCoverAllValues)
{
EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_empty),
QStringLiteral("empty"));
EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_bars),
QStringLiteral("bars"));
EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_shape),
QStringLiteral("shape"));
EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_solid),
QStringLiteral("solid"));
EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_title),
QStringLiteral("title"));
EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_tone),
QStringLiteral("tone"));
EXPECT_EQ(olive::Tool::get_addable_object_id(olive::Tool::k_addable_subtitle),
QStringLiteral("subtitle"));
// Every real addable has a unique non-empty id
QSet<QString> ids;
for (int i = 0; i < olive::Tool::k_addable_count; i++) {
const QString id = olive::Tool::get_addable_object_id(
static_cast<olive::Tool::AddableObject>(i));
EXPECT_FALSE(id.isEmpty());
EXPECT_FALSE(ids.contains(id)) << id.toStdString();
ids.insert(id);
}
// Out-of-range values return an empty id
EXPECT_TRUE(
olive::Tool::get_addable_object_id(olive::Tool::k_addable_count).isEmpty());
EXPECT_TRUE(olive::Tool::get_addable_object_id(
static_cast<olive::Tool::AddableObject>(-1)).isEmpty());
}
TEST(ToolTypes, ItemOrdinalsMatchEngineMirror)
{
// The C ABI transports these as ints, so the ordinals are the contract
EXPECT_EQ(olive::Tool::k_none, 0);
EXPECT_EQ(olive::Tool::k_pointer, 1);
EXPECT_EQ(olive::Tool::k_track_select, 13);
EXPECT_EQ(olive::Tool::k_count, 14);
EXPECT_EQ(olive::Tool::k_addable_count, 7);
}
TEST(ProjectTypes, CacheSettingOrdinals)
{
EXPECT_EQ(olive::Project::k_cache_use_default_location, 0);
EXPECT_EQ(olive::Project::k_cache_store_alongside_project, 1);
EXPECT_EQ(olive::Project::k_cache_custom_path, 2);
}
TEST(NodeDataTypes, OrdinalsMatchEngineMirror)
{
EXPECT_EQ(olive::k_node_data_icon, 0);
EXPECT_EQ(olive::k_node_data_duration, 1);
EXPECT_EQ(olive::k_node_data_created_time, 2);
EXPECT_EQ(olive::k_node_data_modified_time, 3);
EXPECT_EQ(olive::k_node_data_frequency_rate, 4);
EXPECT_EQ(olive::k_node_data_tooltip, 5);
}
TEST(NodeValueHandle, TypeOrdinalsMatchEngineMirror)
{
EXPECT_EQ(olive::NodeValueType::k_none, 0);
EXPECT_EQ(olive::NodeValueType::k_boolean, 4);
EXPECT_EQ(olive::NodeValueType::k_vec2, 12);
EXPECT_EQ(olive::NodeValueType::k_data_type_count, 23);
}
TEST(NodeValueHandle, KeyframeTypeOrdinalsMatchEngineMirror)
{
EXPECT_EQ(olive::NodeKeyframeType::k_invalid, -1);
EXPECT_EQ(olive::NodeKeyframeType::k_linear, 0);
EXPECT_EQ(olive::NodeKeyframeType::k_hold, 1);
EXPECT_EQ(olive::NodeKeyframeType::k_bezier, 2);
}
TEST(NodeValueHandle, ToCMappingsDifferFromPlainCast)
{
// The two enums do NOT share ordinals; the helper must remap each one
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_int),
OAK_NODE_VALUE_INT);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_float),
OAK_NODE_VALUE_FLOAT);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_boolean),
OAK_NODE_VALUE_BOOL);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_rational),
OAK_NODE_VALUE_RATIONAL);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_color),
OAK_NODE_VALUE_COLOR);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_vec2),
OAK_NODE_VALUE_VEC2);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_vec3),
OAK_NODE_VALUE_VEC3);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_vec4),
OAK_NODE_VALUE_VEC4);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_combo),
OAK_NODE_VALUE_COMBO);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_file),
OAK_NODE_VALUE_STRING);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_text),
OAK_NODE_VALUE_TEXT);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_font),
OAK_NODE_VALUE_FONT);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_str_combo),
OAK_NODE_VALUE_STR_COMBO);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_binary),
OAK_NODE_VALUE_BINARY);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_bezier),
OAK_NODE_VALUE_BEZIER);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_texture),
OAK_NODE_VALUE_TEXTURE);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_samples),
OAK_NODE_VALUE_SAMPLES);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_video_params),
OAK_NODE_VALUE_VIDEO_PARAMS);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_audio_params),
OAK_NODE_VALUE_AUDIO_PARAMS);
// k_boolean=4 vs OAK_NODE_VALUE_BOOL=3: a plain cast would be wrong
EXPECT_NE(olive::NodeValueType::k_boolean, OAK_NODE_VALUE_BOOL);
}
TEST(NodeValueHandle, ToCReturnsMinusOneForUnrepresentable)
{
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_none), -1);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_matrix), -1);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_subtitle_params),
-1);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_push_button), -1);
EXPECT_EQ(olive::node_value_type_to_c(olive::NodeValueType::k_data_type_count),
-1);
EXPECT_EQ(olive::node_value_type_to_c(-7), -1);
}
TEST(TrackReferenceHandle, DefaultIsInvalidNone)
{
const olive::TrackReference ref;
EXPECT_EQ(ref.type(), olive::TrackReference::k_none);
EXPECT_EQ(ref.index(), -1);
EXPECT_FALSE(ref.is_valid());
EXPECT_TRUE(ref.to_string().isEmpty());
}
TEST(TrackReferenceHandle, ConstructionAndValidity)
{
const olive::TrackReference video(olive::TrackReference::k_video, 3);
EXPECT_EQ(video.type(), olive::TrackReference::k_video);
EXPECT_EQ(video.index(), 3);
EXPECT_TRUE(video.is_valid());
// A known type with a negative index is not valid
EXPECT_FALSE(
olive::TrackReference(olive::TrackReference::k_video, -1).is_valid());
// k_none and k_count are never valid, even with a non-negative index
EXPECT_FALSE(olive::TrackReference(olive::TrackReference::k_none, 5).is_valid());
EXPECT_FALSE(
olive::TrackReference(olive::TrackReference::k_count, 0).is_valid());
// Index zero is a valid track
EXPECT_TRUE(
olive::TrackReference(olive::TrackReference::k_audio, 0).is_valid());
}
TEST(TrackReferenceHandle, ComparisonOperators)
{
const olive::TrackReference a(olive::TrackReference::k_audio, 1);
const olive::TrackReference a2(olive::TrackReference::k_audio, 1);
const olive::TrackReference a3(olive::TrackReference::k_audio, 2);
const olive::TrackReference v0(olive::TrackReference::k_video, 0);
EXPECT_TRUE(a == a2);
EXPECT_FALSE(a != a2);
EXPECT_TRUE(a != a3);
EXPECT_TRUE(a != v0);
// Ordering is by type first (k_video=0 < k_audio=1), then index
EXPECT_TRUE(v0 < a);
EXPECT_FALSE(a < v0);
EXPECT_TRUE(a < a3);
EXPECT_FALSE(a3 < a);
EXPECT_FALSE(a < a2);
}
TEST(TrackReferenceHandle, TypeStringMappings)
{
EXPECT_EQ(olive::TrackReference::type_to_string(olive::TrackReference::k_video),
QStringLiteral("v"));
EXPECT_EQ(olive::TrackReference::type_to_string(olive::TrackReference::k_audio),
QStringLiteral("a"));
EXPECT_EQ(
olive::TrackReference::type_to_string(olive::TrackReference::k_subtitle),
QStringLiteral("s"));
EXPECT_TRUE(
olive::TrackReference::type_to_string(olive::TrackReference::k_none)
.isEmpty());
EXPECT_TRUE(
olive::TrackReference::type_to_string(olive::TrackReference::k_count)
.isEmpty());
EXPECT_EQ(olive::TrackReference::type_to_translated_string(
olive::TrackReference::k_video),
QStringLiteral("V"));
EXPECT_EQ(olive::TrackReference::type_to_translated_string(
olive::TrackReference::k_audio),
QStringLiteral("A"));
EXPECT_EQ(olive::TrackReference::type_to_translated_string(
olive::TrackReference::k_subtitle),
QStringLiteral("S"));
EXPECT_TRUE(olive::TrackReference::type_to_translated_string(
olive::TrackReference::k_none).isEmpty());
EXPECT_TRUE(olive::TrackReference::type_to_translated_string(
olive::TrackReference::k_count).isEmpty());
}
TEST(TrackReferenceHandle, StringRoundTrip)
{
const olive::TrackReference ref(olive::TrackReference::k_video, 12);
EXPECT_EQ(ref.to_string(), QStringLiteral("v:12"));
const olive::TrackReference parsed =
olive::TrackReference::from_string(QStringLiteral("v:12"));
EXPECT_EQ(parsed, ref);
EXPECT_TRUE(parsed.is_valid());
EXPECT_EQ(olive::TrackReference(olive::TrackReference::k_audio, 0).to_string(),
QStringLiteral("a:0"));
EXPECT_EQ(
olive::TrackReference(olive::TrackReference::k_subtitle, 7).to_string(),
QStringLiteral("s:7"));
// An invalid reference serializes to an empty string
EXPECT_TRUE(olive::TrackReference().to_string().isEmpty());
}
TEST(TrackReferenceHandle, FromStringRejectsGarbage)
{
// Bad prefixes and malformed input all parse back to the default ref
const olive::TrackReference fallback;
for (const QString &s : { QStringLiteral("x:1"), QStringLiteral("v"),
QStringLiteral("v:"), QStringLiteral("v:abc"),
QStringLiteral(""), QStringLiteral("a"),
QStringLiteral("vv:1") }) {
EXPECT_EQ(olive::TrackReference::from_string(s), fallback)
<< s.toStdString();
}
EXPECT_EQ(olive::TrackReference::type_from_string(QStringLiteral("v:1")),
olive::TrackReference::k_video);
EXPECT_EQ(olive::TrackReference::type_from_string(QStringLiteral("a:2")),
olive::TrackReference::k_audio);
EXPECT_EQ(olive::TrackReference::type_from_string(QStringLiteral("s:3")),
olive::TrackReference::k_subtitle);
EXPECT_EQ(olive::TrackReference::type_from_string(QStringLiteral("q:3")),
olive::TrackReference::k_none);
}
TEST(TrackReferenceHandle, HashAndSetSemantics)
{
QSet<olive::TrackReference> set;
set.insert(olive::TrackReference(olive::TrackReference::k_video, 0));
set.insert(olive::TrackReference(olive::TrackReference::k_video, 0));
set.insert(olive::TrackReference(olive::TrackReference::k_audio, 0));
EXPECT_EQ(set.size(), 2);
EXPECT_TRUE(
set.contains(olive::TrackReference(olive::TrackReference::k_video, 0)));
EXPECT_TRUE(
set.contains(olive::TrackReference(olive::TrackReference::k_audio, 0)));
EXPECT_FALSE(
set.contains(olive::TrackReference(olive::TrackReference::k_video, 1)));
// Equal references hash equally
EXPECT_EQ(qHash(olive::TrackReference(olive::TrackReference::k_subtitle, 2)),
qHash(olive::TrackReference(olive::TrackReference::k_subtitle, 2)));
}
TEST(TrackReferenceHandle, DataStreamRoundTrip)
{
QByteArray bytes;
{
QBuffer buffer(&bytes);
ASSERT_TRUE(buffer.open(QIODevice::WriteOnly));
QDataStream out(&buffer);
out << olive::TrackReference(olive::TrackReference::k_audio, 4)
<< olive::TrackReference();
}
QBuffer buffer(&bytes);
ASSERT_TRUE(buffer.open(QIODevice::ReadOnly));
QDataStream in(&buffer);
olive::TrackReference first, second;
in >> first >> second;
EXPECT_EQ(first, olive::TrackReference(olive::TrackReference::k_audio, 4));
EXPECT_EQ(second, olive::TrackReference());
}
TEST(OakValueHelper, QVariantToOakNodeValueScalars)
{
oak_node_value out;
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_int,
QVariant(42), &out));
EXPECT_EQ(out.type, OAK_NODE_VALUE_INT);
EXPECT_EQ(out.num, 42);
// Combos map to the COMBO facade type, not INT
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_combo,
QVariant(3), &out));
EXPECT_EQ(out.type, OAK_NODE_VALUE_COMBO);
EXPECT_EQ(out.num, 3);
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_float,
QVariant(2.5), &out));
EXPECT_EQ(out.type, OAK_NODE_VALUE_FLOAT);
EXPECT_DOUBLE_EQ(out.f[0], 2.5);
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_boolean,
QVariant(true), &out));
EXPECT_EQ(out.type, OAK_NODE_VALUE_BOOL);
EXPECT_EQ(out.num, 1);
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_boolean,
QVariant(false), &out));
EXPECT_EQ(out.num, 0);
ASSERT_TRUE(olive::QVariantToOakNodeValue(
olive::NodeValueType::k_rational,
QVariant::fromValue(olive::core::Rational(1001, 30000)), &out));
EXPECT_EQ(out.type, OAK_NODE_VALUE_RATIONAL);
EXPECT_EQ(out.num, 1001);
EXPECT_EQ(out.den, 30000);
// Types with no POD representation fail
EXPECT_FALSE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_text,
QVariant(QStringLiteral("x")),
&out));
EXPECT_FALSE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_matrix,
QVariant(), &out));
}
TEST(OakValueHelper, QVariantToOakNodeValueColorAndVectors)
{
oak_node_value out;
ASSERT_TRUE(olive::QVariantToOakNodeValue(
olive::NodeValueType::k_color,
QVariant::fromValue(olive::core::Color(0.25f, 0.5f, 0.75f, 1.0f)),
&out));
EXPECT_EQ(out.type, OAK_NODE_VALUE_COLOR);
EXPECT_DOUBLE_EQ(out.f[0], 0.25);
EXPECT_DOUBLE_EQ(out.f[1], 0.5);
EXPECT_DOUBLE_EQ(out.f[2], 0.75);
EXPECT_DOUBLE_EQ(out.f[3], 1.0);
ASSERT_TRUE(olive::QVariantToOakNodeValue(
olive::NodeValueType::k_vec2,
QVariant::fromValue(QVector2D(1.0f, 2.0f)), &out));
EXPECT_EQ(out.type, OAK_NODE_VALUE_VEC2);
EXPECT_DOUBLE_EQ(out.f[0], 1.0);
EXPECT_DOUBLE_EQ(out.f[1], 2.0);
ASSERT_TRUE(olive::QVariantToOakNodeValue(
olive::NodeValueType::k_vec3,
QVariant::fromValue(QVector3D(1.0f, 2.0f, 3.0f)), &out));
EXPECT_EQ(out.type, OAK_NODE_VALUE_VEC3);
EXPECT_DOUBLE_EQ(out.f[2], 3.0);
ASSERT_TRUE(olive::QVariantToOakNodeValue(
olive::NodeValueType::k_vec4,
QVariant::fromValue(QVector4D(1.0f, 2.0f, 3.0f, 4.0f)), &out));
EXPECT_EQ(out.type, OAK_NODE_VALUE_VEC4);
EXPECT_DOUBLE_EQ(out.f[3], 4.0);
}
TEST(OakValueHelper, TrackComponentUsesDeclaredTypeWithSingleSlot)
{
oak_node_value out;
// Beziers take a FLOAT per track, not the full-bezier POD type
ASSERT_TRUE(olive::NodeTrackComponentToOakNodeValue(
olive::NodeValueType::k_bezier, QVariant(0.75), &out));
EXPECT_EQ(out.type, OAK_NODE_VALUE_FLOAT);
EXPECT_DOUBLE_EQ(out.f[0], 0.75);
// A color channel is one float in f[0] but keeps the COLOR type
ASSERT_TRUE(olive::NodeTrackComponentToOakNodeValue(
olive::NodeValueType::k_color, QVariant(0.5), &out));
EXPECT_EQ(out.type, OAK_NODE_VALUE_COLOR);
EXPECT_DOUBLE_EQ(out.f[0], 0.5);
EXPECT_DOUBLE_EQ(out.f[1], 0.0);
ASSERT_TRUE(olive::NodeTrackComponentToOakNodeValue(
olive::NodeValueType::k_vec4, QVariant(1.5), &out));
EXPECT_EQ(out.type, OAK_NODE_VALUE_VEC4);
EXPECT_DOUBLE_EQ(out.f[0], 1.5);
ASSERT_TRUE(olive::NodeTrackComponentToOakNodeValue(
olive::NodeValueType::k_combo, QVariant(2), &out));
EXPECT_EQ(out.type, OAK_NODE_VALUE_COMBO);
EXPECT_EQ(out.num, 2);
ASSERT_TRUE(olive::NodeTrackComponentToOakNodeValue(
olive::NodeValueType::k_rational,
QVariant::fromValue(olive::core::Rational(1, 24)), &out));
EXPECT_EQ(out.type, OAK_NODE_VALUE_RATIONAL);
EXPECT_EQ(out.num, 1);
EXPECT_EQ(out.den, 24);
EXPECT_FALSE(olive::NodeTrackComponentToOakNodeValue(
olive::NodeValueType::k_text, QVariant(QStringLiteral("x")), &out));
}
TEST(OakValueHelper, PodBackToQVariantRoundTrips)
{
oak_node_value pod;
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_int,
QVariant(17), &pod));
EXPECT_EQ(olive::OakNodeValueToQVariant(pod).toLongLong(), 17);
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_float,
QVariant(3.25), &pod));
EXPECT_DOUBLE_EQ(olive::OakNodeValueToQVariant(pod).toDouble(), 3.25);
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_boolean,
QVariant(true), &pod));
EXPECT_TRUE(olive::OakNodeValueToQVariant(pod).toBool());
ASSERT_TRUE(olive::QVariantToOakNodeValue(
olive::NodeValueType::k_rational,
QVariant::fromValue(olive::core::Rational(24, 1)), &pod));
EXPECT_EQ(olive::OakNodeValueToQVariant(pod).value<olive::core::Rational>(),
olive::core::Rational(24, 1));
ASSERT_TRUE(olive::QVariantToOakNodeValue(
olive::NodeValueType::k_color,
QVariant::fromValue(olive::core::Color(0.1f, 0.2f, 0.3f, 0.4f)), &pod));
const olive::core::Color c =
olive::OakNodeValueToQVariant(pod).value<olive::core::Color>();
EXPECT_FLOAT_EQ(c.red(), 0.1f);
EXPECT_FLOAT_EQ(c.green(), 0.2f);
EXPECT_FLOAT_EQ(c.blue(), 0.3f);
EXPECT_FLOAT_EQ(c.alpha(), 0.4f);
ASSERT_TRUE(olive::QVariantToOakNodeValue(
olive::NodeValueType::k_vec3,
QVariant::fromValue(QVector3D(4.0f, 5.0f, 6.0f)), &pod));
EXPECT_EQ(olive::OakNodeValueToQVariant(pod).value<QVector3D>(),
QVector3D(4.0f, 5.0f, 6.0f));
// Combos come back as plain ints
ASSERT_TRUE(olive::QVariantToOakNodeValue(olive::NodeValueType::k_combo,
QVariant(5), &pod));
EXPECT_EQ(olive::OakNodeValueToQVariant(pod).toInt(), 5);
// Types with no POD representation produce an invalid QVariant
pod.type = OAK_NODE_VALUE_STRING;
EXPECT_FALSE(olive::OakNodeValueToQVariant(pod).isValid());
pod.type = OAK_NODE_VALUE_BINARY;
EXPECT_FALSE(olive::OakNodeValueToQVariant(pod).isValid());
pod.type = OAK_NODE_VALUE_NONE;
EXPECT_FALSE(olive::OakNodeValueToQVariant(pod).isValid());
}
TEST(OakValueHelper, KeyframeTypeToFacadeRenumbering)
{
// Facade easing: 0=linear, 1=bezier, 2=hold — NOT the engine ordinals
EXPECT_EQ(olive::NodeKeyframeTypeToFacade(olive::NodeKeyframeType::k_linear),
0);
EXPECT_EQ(olive::NodeKeyframeTypeToFacade(olive::NodeKeyframeType::k_bezier),
1);
EXPECT_EQ(olive::NodeKeyframeTypeToFacade(olive::NodeKeyframeType::k_hold), 2);
// Anything else (including k_invalid) falls back to linear
EXPECT_EQ(olive::NodeKeyframeTypeToFacade(olive::NodeKeyframeType::k_invalid),
0);
EXPECT_EQ(olive::NodeKeyframeTypeToFacade(99), 0);
}
TEST(UndoWrapper, WrapsRedoUndoAndOwnership)
{
int redo_count = 0;
int undo_count = 0;
bool destroyed = false;
void *cmd = olive::wrap_app_undo_command(
"GTest counting command",
new CountingCmd{ &redo_count, &undo_count, &destroyed });
ASSERT_NE(cmd, nullptr);
EXPECT_FALSE(destroyed);
// redo_now/undo_now forward to the wrapped object
EXPECT_EQ(oakengine_undo_command_redo_now(cmd), 0);
EXPECT_EQ(redo_count, 1);
EXPECT_EQ(undo_count, 0);
EXPECT_EQ(oakengine_undo_command_undo_now(cmd), 0);
EXPECT_EQ(undo_count, 1);
EXPECT_EQ(oakengine_undo_command_redo_now(cmd), 0);
EXPECT_EQ(redo_count, 2);
// Freeing the engine command deletes the wrapped object exactly once
oakengine_undo_command_free(cmd);
EXPECT_TRUE(destroyed);
}
TEST(ConfigWrapper, IntRoundTripAndComparisons)
{
const ScopedConfigKey guard(QStringLiteral("GTestConfigWrapperInt"));
olive::OakConfigValue value(QStringLiteral("GTestConfigWrapperInt"));
value = 42;
EXPECT_EQ(value.toInt(), 42);
EXPECT_EQ(static_cast<qint64>(value), 42);
EXPECT_TRUE(value == 42);
EXPECT_FALSE(value != 42);
EXPECT_TRUE(value == qint64(42));
EXPECT_TRUE(value.toBool()); // non-zero reads as true
value = 0;
EXPECT_EQ(value.toInt(), 0);
EXPECT_FALSE(value.toBool());
// Large values survive the int64 round trip
value = qint64(5000000000LL);
EXPECT_EQ(value.toLongLong(), 5000000000LL);
}
TEST(ConfigWrapper, StringRoundTripAndComparisons)
{
const ScopedConfigKey guard(QStringLiteral("GTestConfigWrapperString"));
olive::OakConfigValue value(QStringLiteral("GTestConfigWrapperString"));
value = QStringLiteral("hello world");
EXPECT_EQ(value.toString(), QStringLiteral("hello world"));
EXPECT_TRUE(value == QStringLiteral("hello world"));
EXPECT_TRUE(value == "hello world");
EXPECT_FALSE(value != "hello world");
EXPECT_TRUE(value != "goodbye");
// const char* assignment, including UTF-8 payloads
value = "caf\u00E9";
EXPECT_EQ(value.toString(), QStringLiteral("caf\u00E9"));
// The QVariant conversion yields a string variant
const QVariant as_variant = static_cast<QVariant>(value);
EXPECT_EQ(as_variant.typeId(), QMetaType::QString);
EXPECT_EQ(as_variant.toString(), QStringLiteral("caf\u00E9"));
}
TEST(ConfigWrapper, QVariantAssignmentDispatchesByType)
{
const ScopedConfigKey guard(QStringLiteral("GTestConfigWrapperVariant"));
olive::OakConfigValue value(QStringLiteral("GTestConfigWrapperVariant"));
value = QVariant(true);
EXPECT_TRUE(value.toBool());
value = QVariant(7);
EXPECT_EQ(value.toInt(), 7);
// Floating point variants are truncated to int64 by design
value = QVariant(3.9);
EXPECT_EQ(value.toLongLong(), 3);
// Everything else falls back to its string form
value = QVariant(QStringLiteral("plain"));
EXPECT_EQ(value.toString(), QStringLiteral("plain"));
}
TEST(ConfigWrapper, RationalValueParsesStoredString)
{
const ScopedConfigKey guard(QStringLiteral("GTestConfigWrapperRational"));
olive::OakConfigValue value(QStringLiteral("GTestConfigWrapperRational"));
value = QStringLiteral("1001/30000");
const olive::core::Rational r = value.value<olive::core::Rational>();
EXPECT_EQ(r, olive::core::Rational(1001, 30000));
EXPECT_EQ(r.numerator(), 1001);
EXPECT_EQ(r.denominator(), 30000);
}
TEST(ConfigWrapper, MissingKeyReadsDefaults)
{
// No guard: this key must never exist, so there is nothing to restore
olive::OakConfigValue value(
QStringLiteral("GTestConfigWrapperDefinitelyMissing"));
EXPECT_FALSE(value.toBool());
EXPECT_EQ(value.toInt(), 0);
EXPECT_EQ(value.toLongLong(), 0);
EXPECT_TRUE(value.toString().isEmpty());
}
TEST(ConfigWrapper, OakConfigMacrosBuildValues)
{
const ScopedConfigKey guard(QStringLiteral("GTestConfigWrapperMacro"));
OAK_CONFIG("GTestConfigWrapperMacro") = 9;
EXPECT_EQ(OAK_CONFIG("GTestConfigWrapperMacro").toInt(), 9);
OAK_CONFIG_STR(QStringLiteral("GTestConfigWrapperMacro")) =
QStringLiteral("macro");
EXPECT_EQ(OAK_CONFIG_STR(QStringLiteral("GTestConfigWrapperMacro")).toString(),
QStringLiteral("macro"));
}
TEST(SubtitleApp, DefaultAndParameterizedConstruction)
{
const olive::SubtitleApp empty;
EXPECT_TRUE(empty.text().isEmpty());
const olive::SubtitleApp sub(
olive::core::TimeRange(olive::core::Rational(1, 24),
olive::core::Rational(48, 24)),
QStringLiteral("Hello"));
EXPECT_EQ(sub.text(), QStringLiteral("Hello"));
EXPECT_EQ(sub.time().in(), olive::core::Rational(1, 24));
EXPECT_EQ(sub.time().out(), olive::core::Rational(48, 24));
EXPECT_EQ(sub.time().length(), olive::core::Rational(47, 24));
}
TEST(SubtitleApp, SettersRoundTrip)
{
olive::SubtitleApp sub;
sub.set_text(QStringLiteral("First"));
EXPECT_EQ(sub.text(), QStringLiteral("First"));
sub.set_time(olive::core::TimeRange(olive::core::Rational(0, 1),
olive::core::Rational(10, 1)));
EXPECT_EQ(sub.time().in(), olive::core::Rational(0, 1));
EXPECT_EQ(sub.time().out(), olive::core::Rational(10, 1));
sub.set_text(QStringLiteral("Second"));
EXPECT_EQ(sub.text(), QStringLiteral("Second"));
}
TEST(SubtitleApp, MetatypeRoundTrip)
{
const olive::SubtitleApp sub(
olive::core::TimeRange(olive::core::Rational(2, 1),
olive::core::Rational(5, 1)),
QStringLiteral("Packed"));
const QVariant v = QVariant::fromValue(sub);
EXPECT_EQ(v.typeId(), qMetaTypeId<olive::SubtitleApp>());
const olive::SubtitleApp out = v.value<olive::SubtitleApp>();
EXPECT_EQ(out.text(), QStringLiteral("Packed"));
EXPECT_EQ(out.time(), sub.time());
}
TEST(DebugApp, HandlerFormatsMessageTypeAndContext)
{
testing::internal::CaptureStderr();
olive::debug_handler(QtDebugMsg, QMessageLogContext("file.cpp", 12, "func",
"category"),
QStringLiteral("hello"));
const std::string out = testing::internal::GetCapturedStderr();
EXPECT_NE(out.find("Debug: hello"), std::string::npos);
EXPECT_NE(out.find("file.cpp"), std::string::npos);
EXPECT_NE(out.find("func"), std::string::npos);
// A null context file/function is reported as <null>
testing::internal::CaptureStderr();
olive::debug_handler(QtInfoMsg, QMessageLogContext(), QStringLiteral("plain"));
const std::string out2 = testing::internal::GetCapturedStderr();
EXPECT_NE(out2.find("Info: plain"), std::string::npos);
EXPECT_NE(out2.find("<null>"), std::string::npos);
}
TEST(DebugApp, HandlerSuppressesFilteredWarnings)
{
// QXcbIntegration noise is always dropped
testing::internal::CaptureStderr();
olive::debug_handler(QtWarningMsg, QMessageLogContext(),
QStringLiteral("QXcbIntegration: something"));
EXPECT_TRUE(testing::internal::GetCapturedStderr().empty());
// Other warnings depend on the OAK_TESTING environment variable, which the
// handler samples once; mirror that expectation here
testing::internal::CaptureStderr();
olive::debug_handler(QtWarningMsg, QMessageLogContext(),
QStringLiteral("ordinary warning"));
const std::string out = testing::internal::GetCapturedStderr();
if (qEnvironmentVariableIsSet("OAK_TESTING")) {
EXPECT_TRUE(out.empty());
} else {
EXPECT_NE(out.find("Warning: ordinary warning"), std::string::npos);
}
}
+672
View File
@@ -0,0 +1,672 @@
#include <gtest/gtest.h>
#include <memory>
#include <QCheckBox>
#include <QComboBox>
#include <QDir>
#include <QFile>
#include <QGroupBox>
#include <QImage>
#include <QSizePolicy>
#include <QSlider>
#include <QStandardPaths>
#include <QStringList>
#include <QXmlStreamReader>
#include <QXmlStreamWriter>
#include "dialog/about/scrollinglabel.h"
#include "dialog/export/codec/av1section.h"
#include "dialog/export/codec/cineformsection.h"
#include "dialog/export/codec/codecsection.h"
#include "dialog/export/codec/codecstack.h"
#include "dialog/footageproperties/streamproperties/audiostreamproperties.h"
#include "dialog/footageproperties/streamproperties/streamproperties.h"
#include "dialog/footageproperties/streamproperties/videostreamproperties.h"
#include "dialog/sequence/presetmanager.h"
#include "node/color/colormanager/colormanager.h"
#include "node/project.h"
#include "node/project/footage/footage.h"
#include "oakengine/encoding.h"
#include "oakengine/footage.h"
#include "oakutil/qtutils.h"
#include "render/videoparams.h"
#include "widget/slider/integerslider.h"
#include "widget/standardcombos/interlacedcombobox.h"
#include "widget/standardcombos/pixelaspectratiocombobox.h"
// dialog/about/patreon.h defines a global `QStringList patrons` (without
// extern); about.cpp already pulls that definition into the test binary, so
// including the header here would create a duplicate symbol. Reference the
// existing definition with a matching extern declaration instead.
extern QStringList patrons;
namespace
{
// Redirects QStandardPaths (used for the preset file location) to a
// disposable test location for the lifetime of the guard
class StandardPathsTestModeGuard {
public:
StandardPathsTestModeGuard()
{
QStandardPaths::setTestModeEnabled(true);
}
~StandardPathsTestModeGuard()
{
QStandardPaths::setTestModeEnabled(false);
}
};
// Concrete Preset with a name and an integer so the PresetManager load/save
// cycle has something observable to round-trip
class NameValuePreset : public olive::Preset {
public:
int value_ = 0;
virtual void load(QXmlStreamReader *reader) override
{
while (olive::xml_read_next_start_element(reader)) {
if (reader->name() == QStringLiteral("name")) {
set_name(reader->readElementText());
} else if (reader->name() == QStringLiteral("value")) {
value_ = reader->readElementText().toInt();
} else {
reader->skipCurrentElement();
}
}
}
virtual void save(QXmlStreamWriter *writer) const override
{
writer->writeTextElement(QStringLiteral("name"), get_name());
writer->writeTextElement(QStringLiteral("value"),
QString::number(value_));
}
};
// Footage subclass that exposes the protected stream mutator of ViewerOutput
// so tests can populate streams without probing real media
class TestableFootage : public olive::Footage {
public:
using olive::ViewerOutput::add_stream;
};
// A footage node with two video streams; the second carries the given
// (possibly empty) explicit colorspace override
TestableFootage *make_two_stream_footage(olive::Project *project,
const QString &explicit_colorspace)
{
auto *footage = new TestableFootage();
footage->setParent(project);
olive::VideoParams v0(1920, 1080, olive::Rational(1, 24),
olive::core::PixelFormat::u8, 4);
v0.set_stream_index(0);
v0.set_duration(48);
footage->add_stream(olive::Track::k_video, QVariant::fromValue(v0));
olive::VideoParams v1(1280, 720, olive::Rational(1, 24),
olive::core::PixelFormat::u8, 4);
v1.set_stream_index(1);
v1.set_duration(48);
v1.set_colorspace(explicit_colorspace);
footage->add_stream(olive::Track::k_video, QVariant::fromValue(v1));
return footage;
}
// The plain (non-combo-class) QComboBoxes of a VideoStreamProperties, in
// layout order: color space first, then color range. The widget also holds a
// PixelAspectRatioComboBox and an InterlacedComboBox, so filter those out.
QList<QComboBox *> plain_combos(QWidget *w)
{
QList<QComboBox *> out;
foreach (QComboBox *c, w->findChildren<QComboBox *>()) {
if (!qobject_cast<olive::PixelAspectRatioComboBox *>(c) &&
!qobject_cast<olive::InterlacedComboBox *>(c)) {
out.append(c);
}
}
return out;
}
// Renders the widget and counts pixels drawn in its palette text color.
// QWidget::render() fills an opaque window-color background, so alpha-based
// checks are useless; the scrolling text is painted in palette().text().
int count_text_pixels(QWidget *w)
{
QImage img(w->size(), QImage::Format_ARGB32);
img.fill(Qt::transparent);
w->render(&img);
const QColor text = w->palette().text().color();
int count = 0;
for (int y = 0; y < img.height(); y++) {
for (int x = 0; x < img.width(); x++) {
const QColor px = img.pixelColor(x, y);
if (qAbs(px.red() - text.red()) < 16 &&
qAbs(px.green() - text.green()) < 16 &&
qAbs(px.blue() - text.blue()) < 16) {
count++;
}
}
}
return count;
}
} // namespace
//
// about: patreon
//
TEST(DialogAboutPatreon, PatronsListIsEmptyInSourceTree)
{
// The committed patreon.h is generated with no entries; patreon.py fills
// the list in when packaging a release
EXPECT_TRUE(patrons.isEmpty());
}
//
// about: scrollinglabel
//
TEST(DialogScrollingLabel, SetTextSizesMinimumToContent)
{
olive::ScrollingLabel label;
EXPECT_EQ(label.minimumSize(), QSize(0, 0));
const QString long_line =
QStringLiteral("A considerably longer line of text");
label.set_text({ QStringLiteral("Hi"), long_line });
QFontMetrics fm = label.fontMetrics();
// Minimum height is always ten lines, minimum width the widest line
EXPECT_EQ(label.minimumHeight(), fm.height() * 10);
EXPECT_EQ(label.minimumWidth(),
olive::QtUtils::q_font_metrics_width(fm, long_line));
// A narrower text shrinks the minimum width again
label.set_text({ QStringLiteral("Hi") });
EXPECT_EQ(label.minimumWidth(),
olive::QtUtils::q_font_metrics_width(fm, QStringLiteral("Hi")));
EXPECT_EQ(label.minimumHeight(), fm.height() * 10);
// The text-list constructor applies the same sizing
olive::ScrollingLabel label2({ QStringLiteral("Hi") });
EXPECT_EQ(label2.minimumWidth(),
olive::QtUtils::q_font_metrics_width(fm, QStringLiteral("Hi")));
}
TEST(DialogScrollingLabel, EmptyTextKeepsOnlyLineHeightMinimum)
{
olive::ScrollingLabel label;
label.set_text({});
EXPECT_EQ(label.minimumWidth(), 0);
EXPECT_EQ(label.minimumHeight(), label.fontMetrics().height() * 10);
}
TEST(DialogScrollingLabel, AnimationScrollsTextIntoView)
{
olive::ScrollingLabel label(
{ QStringLiteral("Hello"), QStringLiteral("World") });
label.resize(120, 200);
// At offset zero every line sits below the widget: nothing is painted
EXPECT_EQ(count_text_pixels(&label), 0);
// animation_update() is a private slot; drive it through the meta-object.
// Half the widget height puts the first line near the vertical middle.
for (int i = 0; i < 100; i++) {
QMetaObject::invokeMethod(&label, "animation_update");
}
EXPECT_GT(count_text_pixels(&label), 0);
}
TEST(DialogScrollingLabel, AnimationWrapsAfterFullCycle)
{
olive::ScrollingLabel label(
{ QStringLiteral("Hello"), QStringLiteral("World") });
label.resize(120, 200);
// animate_ resets to 0 once it reaches height + lines * line_height
const int cycle = 200 + 2 * label.fontMetrics().height();
for (int i = 0; i < cycle; i++) {
QMetaObject::invokeMethod(&label, "animation_update");
}
// Back at offset zero the text has scrolled out of view again
EXPECT_EQ(count_text_pixels(&label), 0);
// start/stop only toggle the internal timer
label.start_animating();
label.stop_animating();
}
//
// sequence: presetmanager
//
TEST(DialogPresetManager, StartsEmptyWhenPresetFileMissing)
{
StandardPathsTestModeGuard test_mode;
const QString preset_name =
QStringLiteral("oak-gtest-presetmanager-missing");
const QString path =
QDir(olive::FileFunctions::get_configuration_location())
.filePath(preset_name);
QFile::remove(path);
{
olive::PresetManager<NameValuePreset> mgr(nullptr, preset_name);
EXPECT_EQ(mgr.get_number_of_presets(), 0);
EXPECT_TRUE(mgr.get_preset_data().isEmpty());
EXPECT_TRUE(mgr.get_custom_preset_filename().endsWith(preset_name));
}
// The destructor wrote an empty preset file; clean it up
QFile::remove(path);
}
TEST(DialogPresetManager, LoadsPresetsFromXmlFile)
{
StandardPathsTestModeGuard test_mode;
const QString preset_name =
QStringLiteral("oak-gtest-presetmanager-load");
const QString path =
QDir(olive::FileFunctions::get_configuration_location())
.filePath(preset_name);
QFile::remove(path);
{
QFile f(path);
ASSERT_TRUE(f.open(QFile::WriteOnly));
f.write("<?xml version=\"1.0\"?>\n"
"<presets>\n"
" <preset><name>Alpha</name><value>7</value></preset>\n"
" <preset><name>Beta</name><value>8</value></preset>\n"
"</presets>\n");
f.close();
}
{
olive::PresetManager<NameValuePreset> mgr(nullptr, preset_name);
ASSERT_EQ(mgr.get_number_of_presets(), 2);
EXPECT_EQ(mgr.get_preset(0)->get_name(), QStringLiteral("Alpha"));
EXPECT_EQ(mgr.get_preset(1)->get_name(), QStringLiteral("Beta"));
EXPECT_EQ(
std::static_pointer_cast<NameValuePreset>(mgr.get_preset(0))->value_,
7);
EXPECT_EQ(
std::static_pointer_cast<NameValuePreset>(mgr.get_preset(1))->value_,
8);
EXPECT_EQ(mgr.get_preset_data().size(), 2);
mgr.delete_preset(0);
ASSERT_EQ(mgr.get_number_of_presets(), 1);
EXPECT_EQ(mgr.get_preset(0)->get_name(), QStringLiteral("Beta"));
// Change the surviving preset so the destructor's save has something
// new to write
std::static_pointer_cast<NameValuePreset>(mgr.get_preset(0))->value_ =
42;
}
// The destructor persisted the current preset list to disk
{
olive::PresetManager<NameValuePreset> mgr(nullptr, preset_name);
ASSERT_EQ(mgr.get_number_of_presets(), 1);
EXPECT_EQ(mgr.get_preset(0)->get_name(), QStringLiteral("Beta"));
EXPECT_EQ(
std::static_pointer_cast<NameValuePreset>(mgr.get_preset(0))->value_,
42);
}
QFile::remove(path);
}
//
// export: codecsection (base class)
//
TEST(DialogCodecSection, BaseClassOptsAreNoOps)
{
olive::CodecSection section;
OakEngineEncodingParams *params = oakengine_encoding_params_create();
ASSERT_NE(params, nullptr);
section.add_opts(params);
// A base CodecSection writes no encoder options
char buf[64];
EXPECT_EQ(oakengine_encoding_params_video_option(params, "qp", buf,
static_cast<int>(sizeof(buf))),
OAKENGINE_E_NOT_FOUND);
// set_opts must accept anything without touching the widget
section.set_opts(params);
oakengine_encoding_params_destroy(params);
}
//
// export: codecstack
//
TEST(DialogCodecStack, CurrentWidgetExpandsOthersAreIgnored)
{
olive::CodecStack stack;
QWidget a;
QWidget b;
stack.addWidget(&a);
stack.addWidget(&b);
ASSERT_EQ(stack.count(), 2);
ASSERT_EQ(stack.currentIndex(), 0);
// addWidget() reapplies the policies for the current index
EXPECT_EQ(a.sizePolicy().horizontalPolicy(), QSizePolicy::Expanding);
EXPECT_EQ(a.sizePolicy().verticalPolicy(), QSizePolicy::Expanding);
EXPECT_EQ(b.sizePolicy().horizontalPolicy(), QSizePolicy::Ignored);
EXPECT_EQ(b.sizePolicy().verticalPolicy(), QSizePolicy::Ignored);
// Switching pages swaps the policies
stack.setCurrentIndex(1);
EXPECT_EQ(a.sizePolicy().horizontalPolicy(), QSizePolicy::Ignored);
EXPECT_EQ(b.sizePolicy().horizontalPolicy(), QSizePolicy::Expanding);
EXPECT_EQ(b.sizePolicy().verticalPolicy(), QSizePolicy::Expanding);
}
//
// export: av1section
//
TEST(DialogAV1CRFSection, DefaultsAndSliderRange)
{
// Copy to a local first: EXPECT_EQ binds by reference, which would
// odr-use the in-class-initialized static constant and fail to link
const int default_crf = olive::AV1CRFSection::k_default_a_v1_crf;
EXPECT_EQ(default_crf, 30);
olive::AV1CRFSection section(olive::AV1CRFSection::k_default_a_v1_crf);
EXPECT_EQ(section.get_value(), 30);
QSlider *slider = section.findChild<QSlider *>();
ASSERT_NE(slider, nullptr);
EXPECT_EQ(slider->minimum(), 0);
EXPECT_EQ(slider->maximum(), 63);
// The slider is the value source, clamped to 0-63
slider->setValue(45);
EXPECT_EQ(section.get_value(), 45);
slider->setValue(99);
EXPECT_EQ(section.get_value(), 63);
}
TEST(DialogAV1CRFSection, SliderSyncsIntoIntegerInput)
{
olive::AV1CRFSection section(30);
QSlider *slider = section.findChild<QSlider *>();
olive::IntegerSlider *input = section.findChild<olive::IntegerSlider *>();
ASSERT_NE(slider, nullptr);
ASSERT_NE(input, nullptr);
EXPECT_EQ(input->get_value(), 30);
// QSlider::valueChanged is wired to IntegerSlider::set_value
slider->setValue(20);
EXPECT_EQ(input->get_value(), 20);
}
TEST(DialogAV1Section, DefaultsAndAddOpts)
{
olive::AV1Section section;
// First combo is the preset list (0-13, default 8), second the
// compression method list (Constant Rate Factor only)
const auto combos = section.findChildren<QComboBox *>();
ASSERT_EQ(combos.size(), 2);
EXPECT_EQ(combos.at(0)->count(), 14);
EXPECT_EQ(combos.at(0)->currentIndex(), 8);
EXPECT_EQ(combos.at(1)->count(), 1);
OakEngineEncodingParams *params = oakengine_encoding_params_create();
ASSERT_NE(params, nullptr);
char buf[64];
// Default construction uses the default CRF
section.add_opts(params);
ASSERT_GT(oakengine_encoding_params_video_option(
params, "qp", buf, static_cast<int>(sizeof(buf))),
0);
EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("30"));
ASSERT_GT(oakengine_encoding_params_video_option(
params, "preset", buf, static_cast<int>(sizeof(buf))),
0);
EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("8"));
// Changed widget state is reflected in the written options
combos.at(0)->setCurrentIndex(3);
QSlider *slider = section.findChild<QSlider *>();
ASSERT_NE(slider, nullptr);
slider->setValue(20);
section.add_opts(params);
ASSERT_GT(oakengine_encoding_params_video_option(
params, "qp", buf, static_cast<int>(sizeof(buf))),
0);
EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("20"));
ASSERT_GT(oakengine_encoding_params_video_option(
params, "preset", buf, static_cast<int>(sizeof(buf))),
0);
EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("3"));
oakengine_encoding_params_destroy(params);
}
//
// export: cineformsection
//
TEST(DialogCineformSection, DefaultsToMediumQuality)
{
olive::CineformSection section;
QComboBox *quality = section.findChild<QComboBox *>();
ASSERT_NE(quality, nullptr);
// 13 FFmpeg quality levels (film3+ .. low), defaulting to "medium"
EXPECT_EQ(quality->count(), 13);
EXPECT_EQ(quality->currentIndex(), 10);
}
TEST(DialogCineformSection, OptsRoundTripThroughParams)
{
olive::CineformSection section;
QComboBox *quality = section.findChild<QComboBox *>();
ASSERT_NE(quality, nullptr);
OakEngineEncodingParams *params = oakengine_encoding_params_create();
ASSERT_NE(params, nullptr);
char buf[64];
section.add_opts(params);
ASSERT_GT(oakengine_encoding_params_video_option(
params, "quality", buf, static_cast<int>(sizeof(buf))),
0);
EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("10"));
quality->setCurrentIndex(5);
section.add_opts(params);
ASSERT_GT(oakengine_encoding_params_video_option(
params, "quality", buf, static_cast<int>(sizeof(buf))),
0);
EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("5"));
// set_opts applies a stored value back to the combo
oakengine_encoding_params_set_video_option(params, "quality", "2");
section.set_opts(params);
EXPECT_EQ(quality->currentIndex(), 2);
// A params handle without the key leaves the combo untouched
OakEngineEncodingParams *empty = oakengine_encoding_params_create();
ASSERT_NE(empty, nullptr);
section.set_opts(empty);
EXPECT_EQ(quality->currentIndex(), 2);
oakengine_encoding_params_destroy(empty);
oakengine_encoding_params_destroy(params);
}
//
// footageproperties: streamproperties (base class)
//
TEST(DialogStreamProperties, BaseClassIsANoOpStub)
{
olive::StreamProperties props;
EXPECT_TRUE(props.sanity_check());
// accept() takes an optional parent pointer and does nothing
props.accept(nullptr);
}
//
// footageproperties: audiostreamproperties
//
TEST(DialogAudioStreamProperties, ConstructsAroundFootageStream)
{
olive::Project project;
TestableFootage *footage = make_two_stream_footage(&project, QString());
olive::AudioStreamProperties props(
reinterpret_cast<OakEngineNode *>(footage), 0);
// The class is currently a stub: no UI, default sanity check, no-op accept
EXPECT_EQ(props.findChildren<QWidget *>().size(), 0);
EXPECT_TRUE(props.sanity_check());
props.accept(nullptr);
}
//
// footageproperties: videostreamproperties
//
TEST(DialogVideoStreamProperties, ReflectsStreamDefaults)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
TestableFootage *footage = make_two_stream_footage(&project, QString());
olive::VideoStreamProperties props(
reinterpret_cast<OakEngineNode *>(footage), 0);
auto *interlace = props.findChild<olive::InterlacedComboBox *>();
ASSERT_NE(interlace, nullptr);
EXPECT_EQ(interlace->get_interlace_mode(),
olive::VideoParams::k_interlace_none);
auto *par = props.findChild<olive::PixelAspectRatioComboBox *>();
ASSERT_NE(par, nullptr);
EXPECT_EQ(par->get_pixel_aspect_ratio(), olive::Rational(1, 1));
const auto combos = plain_combos(&props);
ASSERT_EQ(combos.size(), 2);
// Color space: "Default (...)" entry followed by every config colorspace
QComboBox *colorspace = combos.at(0);
EXPECT_TRUE(colorspace->itemText(0).startsWith(QStringLiteral("Default (")));
EXPECT_EQ(colorspace->count(),
1 + project.color_manager()->list_available_colorspaces().size());
// No override on the stream: the default entry stays selected
EXPECT_EQ(colorspace->currentIndex(), 0);
// Color range: limited/full, matching the stream's limited default
QComboBox *range = combos.at(1);
ASSERT_EQ(range->count(), 2);
EXPECT_EQ(range->itemData(0).toInt(),
olive::VideoParams::k_color_range_limited);
EXPECT_EQ(range->itemData(1).toInt(), olive::VideoParams::k_color_range_full);
EXPECT_EQ(range->currentIndex(), olive::VideoParams::k_color_range_limited);
// Regular video is not an image sequence: no image sequence group box
EXPECT_EQ(props.findChild<QGroupBox *>(), nullptr);
// The premultiplied-alpha checkbox only exists for 4-channel processing
QCheckBox *premult = props.findChild<QCheckBox *>();
if (olive::VideoParams::k_internal_channel_count == 4) {
ASSERT_NE(premult, nullptr);
EXPECT_FALSE(premult->isChecked());
} else {
EXPECT_EQ(premult, nullptr);
}
// Not an image sequence, so sanity_check() passes without prompting
EXPECT_TRUE(props.sanity_check());
}
TEST(DialogVideoStreamProperties, ReflectsExplicitColorspaceOverride)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
const QStringList spaces =
project.color_manager()->list_available_colorspaces();
ASSERT_FALSE(spaces.isEmpty());
TestableFootage *footage =
make_two_stream_footage(&project, spaces.first());
// Stream 1 carries the explicit colorspace override
olive::VideoStreamProperties props(
reinterpret_cast<OakEngineNode *>(footage), 1);
const auto combos = plain_combos(&props);
ASSERT_EQ(combos.size(), 2);
QComboBox *colorspace = combos.at(0);
EXPECT_EQ(colorspace->currentText(), spaces.first());
EXPECT_GT(colorspace->currentIndex(), 0);
}
TEST(DialogVideoStreamProperties, AcceptAppliesColorRangeChange)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
TestableFootage *footage = make_two_stream_footage(&project, QString());
olive::VideoStreamProperties props(
reinterpret_cast<OakEngineNode *>(footage), 0);
const auto combos = plain_combos(&props);
ASSERT_EQ(combos.size(), 2);
QComboBox *range = combos.at(1);
auto get_color_range = [&]() {
OakEngineFootage *handle = oakengine_footage_borrow(
reinterpret_cast<OakEngineNode *>(footage));
EXPECT_NE(handle, nullptr);
int color_range = -1;
oakengine_footage_get_video_stream_overrides(handle, 0, nullptr, 0,
&color_range, nullptr,
nullptr);
oakengine_footage_free(handle);
return color_range;
};
// Accepting without edits must not touch the stream
props.accept(nullptr);
EXPECT_EQ(get_color_range(), olive::VideoParams::k_color_range_limited);
// Switching to full range is written back through the facade
range->setCurrentIndex(1);
props.accept(nullptr);
EXPECT_EQ(get_color_range(), olive::VideoParams::k_color_range_full);
}
File diff suppressed because it is too large Load Diff
+569
View File
@@ -0,0 +1,569 @@
#include <gtest/gtest.h>
#include <QColor>
#include <QPointer>
#include <QPushButton>
#include <QScrollBar>
#include <QSignalSpy>
#include <QSplitter>
#include <QStackedWidget>
#include "core.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 "oakengine/node.h"
#include "oakengine/timeline.h"
#include "timeline/timelinecommonapp.h"
#include "widget/clickablelabel/clickablelabel.h"
#include "widget/focusablelineedit/focusablelineedit.h"
#include "widget/timelinewidget/timelineandtrackview.h"
#include "widget/timelinewidget/trackview/trackview.h"
#include "widget/timelinewidget/trackview/trackviewitem.h"
#include "widget/timelinewidget/trackview/trackviewsplitter.h"
#include "widget/timelinewidget/view/timelineview.h"
namespace
{
// HandMovableView (base of TimelineView) connects to Core::instance() at
// construction, so anything embedding a TimelineView needs the singleton
void ensure_core()
{
if (!olive::Core::instance()) {
new olive::Core(); // intentionally leaked
}
}
olive::Sequence *create_sequence(olive::Project *project)
{
auto *sequence = new olive::Sequence();
sequence->setParent(project);
return sequence;
}
// Mirrors sequence_test.cpp: grow the track input array, then connect the
// edge so the TrackList assigns type, index and owning sequence
olive::Track *append_track(olive::Project *project, olive::Sequence *sequence,
olive::Track::Type type)
{
auto *track = new olive::Track();
track->setParent(project);
olive::TrackList *list = sequence->track_list(type);
list->array_append();
olive::Node::connect_edge(track, list->track_input(list->array_size() - 1));
return track;
}
OakEngineSequence *seq_handle(olive::Sequence *sequence)
{
return reinterpret_cast<OakEngineSequence *>(sequence);
}
OakEngineNode *node_handle(olive::Node *node)
{
return reinterpret_cast<OakEngineNode *>(node);
}
OakEngineTrack *track_handle(olive::Track *track)
{
return reinterpret_cast<OakEngineTrack *>(track);
}
// TrackViewItem's mute/lock buttons are distinguished only by the checked
// color baked into their style sheets (red for mute, gray for lock)
QPushButton *find_button_by_checked_color(QWidget *parent, const QString &rgb)
{
for (QPushButton *b : parent->findChildren<QPushButton *>()) {
if (b->styleSheet().contains(rgb)) {
return b;
}
}
return nullptr;
}
} // namespace
TEST(TimelineApp, MovementModeOrdinalsMatchEngineAbi)
{
// The C ABI transports these as ints (OAKENGINE_MOVEMENT_MODE_*), so the
// app-side enum ordinals must stay in sync with the engine
EXPECT_EQ(int(olive::TimelineApp::k_none), OAKENGINE_MOVEMENT_MODE_NONE);
EXPECT_EQ(int(olive::TimelineApp::k_move), OAKENGINE_MOVEMENT_MODE_MOVE);
EXPECT_EQ(int(olive::TimelineApp::k_trim_in), OAKENGINE_MOVEMENT_MODE_TRIM_IN);
EXPECT_EQ(int(olive::TimelineApp::k_trim_out),
OAKENGINE_MOVEMENT_MODE_TRIM_OUT);
}
TEST(TimelineApp, IsATrimMode)
{
EXPECT_TRUE(olive::TimelineApp::is_a_trim_mode(olive::TimelineApp::k_trim_in));
EXPECT_TRUE(
olive::TimelineApp::is_a_trim_mode(olive::TimelineApp::k_trim_out));
EXPECT_FALSE(olive::TimelineApp::is_a_trim_mode(olive::TimelineApp::k_none));
EXPECT_FALSE(olive::TimelineApp::is_a_trim_mode(olive::TimelineApp::k_move));
}
TEST(TimelineApp, ThumbnailAndWaveformOrdinals)
{
EXPECT_EQ(int(olive::TimelineApp::k_thumbnail_off), 0);
EXPECT_EQ(int(olive::TimelineApp::k_thumbnail_in_out), 1);
EXPECT_EQ(int(olive::TimelineApp::k_thumbnail_on), 2);
EXPECT_EQ(int(olive::TimelineApp::k_waveforms_disabled), 0);
EXPECT_EQ(int(olive::TimelineApp::k_waveforms_enabled), 1);
}
TEST(TimelineApp, EditToInfoIsPlainStruct)
{
olive::TimelineApp::EditToInfo info;
info.track = nullptr;
info.nearest_block = nullptr;
info.nearest_time = olive::core::Rational(5, 1);
EXPECT_EQ(info.track, nullptr);
EXPECT_EQ(info.nearest_block, nullptr);
EXPECT_EQ(info.nearest_time, olive::core::Rational(5, 1));
olive::TimelineApp::EditToInfo copy = info;
EXPECT_EQ(copy.nearest_time, olive::core::Rational(5, 1));
}
TEST(TimelineAndTrackView, ConstructionWiresSplitterAndViews)
{
ensure_core();
olive::TimelineAndTrackView tav;
ASSERT_NE(tav.splitter(), nullptr);
ASSERT_NE(tav.view(), nullptr);
ASSERT_NE(tav.track_view(), nullptr);
EXPECT_EQ(tav.splitter()->orientation(), Qt::Horizontal);
EXPECT_FALSE(tav.splitter()->childrenCollapsible());
ASSERT_EQ(tav.splitter()->count(), 2);
// Track headers on the left, timeline view on the right
EXPECT_EQ(tav.splitter()->widget(0), tav.track_view());
EXPECT_EQ(tav.splitter()->widget(1), tav.view());
}
TEST(TimelineAndTrackView, ScrollbarsTrackEachOther)
{
ensure_core();
olive::TimelineAndTrackView tav;
QScrollBar *view_sb = tav.view()->verticalScrollBar();
QScrollBar *track_sb = tav.track_view()->verticalScrollBar();
ASSERT_NE(view_sb, nullptr);
ASSERT_NE(track_sb, nullptr);
// With an empty sequence both scrollbars have a degenerate range; give
// them explicit ranges so the sync math is observable. The ranges stick
// because nothing relayouts the hidden widget afterwards
view_sb->setRange(10, 100);
track_sb->setRange(0, 90);
// Scrolling the view scrolls the track headers by (value - minimum)
view_sb->setValue(50);
EXPECT_EQ(track_sb->value(), 40);
// Scrolling the track headers scrolls the view by (minimum + value)
track_sb->setValue(25);
EXPECT_EQ(view_sb->value(), 35);
// Deliberately no cleanup of the sync connections: leaving the scrollbars
// at non-zero values exercises ~TimelineAndTrackView, which must detach
// them before the child views reset their scenes during teardown
}
TEST(TrackView, ConstructionDefaults)
{
olive::TrackView view;
EXPECT_EQ(view.horizontalScrollBarPolicy(), Qt::ScrollBarAlwaysOff);
EXPECT_EQ(view.verticalScrollBarPolicy(), Qt::ScrollBarAlwaysOff);
EXPECT_TRUE(view.widgetResizable());
EXPECT_EQ(view.alignment(), Qt::Alignment(Qt::AlignLeft | Qt::AlignTop));
auto *splitter = view.findChild<olive::TrackViewSplitter *>();
ASSERT_NE(splitter, nullptr);
EXPECT_EQ(splitter->orientation(), Qt::Vertical);
// Only the trailing spacer widget before any track list is connected
EXPECT_EQ(splitter->count(), 1);
olive::TrackView bottom(Qt::AlignBottom);
EXPECT_EQ(bottom.alignment(), Qt::Alignment(Qt::AlignLeft | Qt::AlignBottom));
}
TEST(TrackView, ConnectTrackListPopulatesAndDisconnectClears)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
project.initialize();
olive::Sequence *sequence = create_sequence(&project);
append_track(&project, sequence, olive::Track::k_video);
append_track(&project, sequence, olive::Track::k_video);
append_track(&project, sequence, olive::Track::k_audio);
olive::TrackView view;
auto *splitter = view.findChild<olive::TrackViewSplitter *>();
ASSERT_NE(splitter, nullptr);
EXPECT_EQ(splitter->count(), 1);
// Binding the video track list adds one item per track (plus the spacer)
view.connect_track_list(seq_handle(sequence), OAKENGINE_TRACK_TYPE_VIDEO);
EXPECT_EQ(splitter->count(), 3);
// Rebinding to the audio list swaps the items, it doesn't accumulate
view.connect_track_list(seq_handle(sequence), OAKENGINE_TRACK_TYPE_AUDIO);
EXPECT_EQ(splitter->count(), 2);
view.disconnect_track_list();
EXPECT_EQ(splitter->count(), 1);
}
TEST(TrackView, InsertAndRemoveTrackUpdateSplitter)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
project.initialize();
olive::Sequence *sequence = create_sequence(&project);
append_track(&project, sequence, olive::Track::k_video);
olive::TrackView view;
auto *splitter = view.findChild<olive::TrackViewSplitter *>();
ASSERT_NE(splitter, nullptr);
view.connect_track_list(seq_handle(sequence), OAKENGINE_TRACK_TYPE_VIDEO);
ASSERT_EQ(splitter->count(), 2);
// A track added to the sequence afterwards can be inserted explicitly
olive::Track *t2 = append_track(&project, sequence, olive::Track::k_video);
view.insert_track(track_handle(t2));
ASSERT_EQ(splitter->count(), 3);
// AlignTop ordering: items first, spacer last
QPointer<QWidget> item = splitter->widget(1);
ASSERT_FALSE(item.isNull());
view.remove_track(track_handle(t2));
EXPECT_EQ(splitter->count(), 2);
// remove() deletes the item widget
EXPECT_TRUE(item.isNull());
}
TEST(TrackViewItem, LabelShowsTypePrefixAndCustomLabel)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
project.initialize();
olive::Sequence *sequence = create_sequence(&project);
olive::Track *v1 = append_track(&project, sequence, olive::Track::k_video);
olive::Track *v2 = append_track(&project, sequence, olive::Track::k_video);
olive::Track *a1 = append_track(&project, sequence, olive::Track::k_audio);
olive::Track *s1 = append_track(&project, sequence, olive::Track::k_subtitle);
ASSERT_EQ(
oakengine_node_set_label(node_handle(v1), "Hero"), 0);
olive::TrackViewItem item_v1(track_handle(v1));
olive::TrackViewItem item_v2(track_handle(v2));
olive::TrackViewItem item_a1(track_handle(a1));
olive::TrackViewItem item_s1(track_handle(s1));
auto *label_v1 = item_v1.findChild<olive::ClickableLabel *>();
auto *label_v2 = item_v2.findChild<olive::ClickableLabel *>();
auto *label_a1 = item_a1.findChild<olive::ClickableLabel *>();
auto *label_s1 = item_s1.findChild<olive::ClickableLabel *>();
ASSERT_NE(label_v1, nullptr);
ASSERT_NE(label_v2, nullptr);
ASSERT_NE(label_a1, nullptr);
ASSERT_NE(label_s1, nullptr);
// NLE-style prefix is 1-based (V1/V2, A1, S1) followed by the custom
// label or, when unset, the engine default track name
EXPECT_TRUE(label_v1->text().startsWith(QStringLiteral("V1 ")));
EXPECT_TRUE(label_v1->text().contains(QStringLiteral("Hero")));
EXPECT_TRUE(label_v2->text().startsWith(QStringLiteral("V2 ")));
EXPECT_FALSE(label_v2->text().contains(QStringLiteral("Hero")));
EXPECT_GT(label_v2->text().size(), 4);
EXPECT_TRUE(label_a1->text().startsWith(QStringLiteral("A1 ")));
EXPECT_TRUE(label_s1->text().startsWith(QStringLiteral("S1 ")));
}
TEST(TrackViewItem, RenameThroughLineEditRoundTrips)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
project.initialize();
olive::Sequence *sequence = create_sequence(&project);
olive::Track *track = append_track(&project, sequence, olive::Track::k_video);
olive::TrackViewItem item(track_handle(track));
auto *stack = item.findChild<QStackedWidget *>();
auto *label = item.findChild<olive::ClickableLabel *>();
auto *edit = item.findChild<olive::FocusableLineEdit *>();
ASSERT_NE(stack, nullptr);
ASSERT_NE(label, nullptr);
ASSERT_NE(edit, nullptr);
// The label is the resting page of the stack
EXPECT_EQ(stack->currentWidget(), label);
// Double-clicking the label swaps in the line edit
emit label->mouse_double_clicked();
EXPECT_EQ(stack->currentWidget(), edit);
// Confirming writes the label to the engine and swaps back
edit->setText(QStringLiteral("Renamed"));
emit edit->confirmed();
EXPECT_EQ(stack->currentWidget(), label);
EXPECT_TRUE(label->text().contains(QStringLiteral("Renamed")));
char buf[256];
buf[0] = '\0';
oakengine_node_get_label(node_handle(track), buf, sizeof(buf));
EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("Renamed"));
// Cancelling discards the edit and restores the label page
emit label->mouse_double_clicked();
EXPECT_EQ(stack->currentWidget(), edit);
edit->setText(QStringLiteral("Discarded"));
emit edit->cancelled();
EXPECT_EQ(stack->currentWidget(), label);
buf[0] = '\0';
oakengine_node_get_label(node_handle(track), buf, sizeof(buf));
EXPECT_EQ(QString::fromUtf8(buf), QStringLiteral("Renamed"));
EXPECT_TRUE(label->text().contains(QStringLiteral("Renamed")));
}
TEST(TrackViewItem, MuteAndLockButtonsDriveEngineState)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
project.initialize();
olive::Sequence *sequence = create_sequence(&project);
olive::Track *track = append_track(&project, sequence, olive::Track::k_video);
olive::TrackViewItem item(track_handle(track));
// The buttons are identified by the checked color baked into their style
// sheets (Qt::red for mute, Qt::gray for lock); resolve the colors the
// same way the implementation does -- QColor(Qt::gray).name() is not the
// SVG "#808080" one might expect
QPushButton *mute = find_button_by_checked_color(
&item, QColor(Qt::red).name());
QPushButton *lock = find_button_by_checked_color(
&item, QColor(Qt::gray).name());
ASSERT_NE(mute, nullptr);
ASSERT_NE(lock, nullptr);
EXPECT_TRUE(mute->isCheckable());
EXPECT_TRUE(lock->isCheckable());
EXPECT_FALSE(mute->isChecked());
EXPECT_FALSE(lock->isChecked());
OakEngineSequence *seq = seq_handle(sequence);
EXPECT_EQ(oakengine_track_is_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0), 0);
EXPECT_EQ(oakengine_track_is_locked(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0), 0);
// Clicking the button writes through to the engine track
mute->click();
EXPECT_EQ(oakengine_track_is_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0), 1);
EXPECT_TRUE(mute->isChecked());
// An external mute change reflects back on the button via the
// EngineEventBridge subscription
oakengine_track_set_muted(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0, 0);
EXPECT_FALSE(mute->isChecked());
lock->click();
EXPECT_EQ(oakengine_track_is_locked(seq, OAKENGINE_TRACK_TYPE_VIDEO, 0), 1);
EXPECT_TRUE(lock->isChecked());
}
TEST(TrackViewItem, DeleteTrackEmitsSignalAndRemovesTrack)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
project.initialize();
olive::Sequence *sequence = create_sequence(&project);
olive::Track *track = append_track(&project, sequence, olive::Track::k_video);
// Owned by the test so the dangling engine handle after removal can't
// outlive the widget
auto *item = new olive::TrackViewItem(track_handle(track));
OakEngineTrack *received = nullptr;
QObject::connect(item, &olive::TrackViewItem::about_to_delete_track,
[&received](OakEngineTrack *t) { received = t; });
// The context menu wires this slot with a queued connection; on the same
// thread a direct meta-call is equivalent and avoids the modal menu
QMetaObject::invokeMethod(item, "delete_track");
EXPECT_EQ(received, track_handle(track));
int video = -1, audio = -1, subtitle = -1;
oakengine_sequence_track_count(seq_handle(sequence), &video, &audio,
&subtitle);
EXPECT_EQ(video, 0);
delete item;
}
TEST(TrackViewSplitter, ConstructionDefaults)
{
olive::TrackViewSplitter splitter(Qt::AlignTop);
EXPECT_EQ(splitter.orientation(), Qt::Vertical);
EXPECT_EQ(splitter.handleWidth(), 1);
// The trailing spacer is added by the constructor
EXPECT_EQ(splitter.count(), 1);
EXPECT_EQ(splitter.height(), 0);
olive::TrackViewSplitter bottom(Qt::AlignBottom);
EXPECT_EQ(bottom.count(), 1);
}
TEST(TrackViewSplitter, InsertGrowsFixedHeightAndOrdersWidgets)
{
olive::TrackViewSplitter splitter(Qt::AlignTop);
auto *w1 = new QWidget();
auto *w2 = new QWidget();
splitter.insert(0, 100, w1);
// Height = item height + one handle width
EXPECT_EQ(splitter.count(), 2);
EXPECT_EQ(splitter.widget(0), w1);
EXPECT_EQ(splitter.height(), 101);
splitter.insert(1, 50, w2);
// Height = both items + two handle widths; spacer stays last
EXPECT_EQ(splitter.count(), 3);
EXPECT_EQ(splitter.widget(0), w1);
EXPECT_EQ(splitter.widget(1), w2);
EXPECT_EQ(splitter.height(), 152);
const QList<int> sz = splitter.sizes();
ASSERT_EQ(sz.size(), 3);
EXPECT_EQ(sz.at(0), 100);
EXPECT_EQ(sz.at(1), 50);
}
TEST(TrackViewSplitter, InsertAlignBottomAppendsAfterSpacer)
{
olive::TrackViewSplitter splitter(Qt::AlignBottom);
auto *w1 = new QWidget();
auto *w2 = new QWidget();
splitter.insert(0, 60, w1);
splitter.insert(1, 40, w2);
// With bottom alignment the spacer stays at the top and later inserts
// land between the spacer and the existing items
ASSERT_EQ(splitter.count(), 3);
EXPECT_EQ(splitter.widget(1), w2);
EXPECT_EQ(splitter.widget(2), w1);
// Height = both items + two handle widths
EXPECT_EQ(splitter.height(), 102);
}
TEST(TrackViewSplitter, RemoveDeletesWidgetAndShrinksHeight)
{
olive::TrackViewSplitter splitter(Qt::AlignTop);
auto *w1 = new QWidget();
auto *w2 = new QWidget();
splitter.insert(0, 100, w1);
splitter.insert(1, 50, w2);
ASSERT_EQ(splitter.height(), 152);
QPointer<QWidget> gone = w1;
splitter.remove(0);
EXPECT_TRUE(gone.isNull());
EXPECT_EQ(splitter.count(), 2);
EXPECT_EQ(splitter.widget(0), w2);
// Removing an item subtracts its height and one handle width
EXPECT_EQ(splitter.height(), 51);
const QList<int> sz = splitter.sizes();
ASSERT_EQ(sz.size(), 2);
EXPECT_EQ(sz.at(0), 50);
}
TEST(TrackViewSplitter, SetSpacerHeightGrowsTotalHeight)
{
olive::TrackViewSplitter splitter(Qt::AlignTop);
auto *w1 = new QWidget();
splitter.insert(0, 100, w1);
ASSERT_EQ(splitter.height(), 101);
// The spacer keeps a handle on the trailing edge; its height is added to
// the fixed total
splitter.set_spacer_height(40);
EXPECT_EQ(splitter.height(), 141);
}
TEST(TrackViewSplitter, SetTrackHeightAdjustsFixedHeight)
{
olive::TrackViewSplitter splitter(Qt::AlignTop);
splitter.insert(0, 100, new QWidget());
splitter.insert(1, 50, new QWidget());
ASSERT_EQ(splitter.height(), 152);
// Growing a track by 30px grows the whole splitter by 30px
splitter.set_track_height(0, 130);
EXPECT_EQ(splitter.height(), 182);
// The fixed height always moves by (new - old) for the addressed track;
// read the current size back instead of assuming an exact layout, since
// QSplitter redistributes sizes when the fixed height changes
const int old_size = splitter.sizes().at(1);
const int old_height = splitter.height();
splitter.set_track_height(1, 20);
EXPECT_EQ(splitter.height(), old_height + (20 - old_size));
}
TEST(TrackViewSplitter, HandlesAreTrackViewSplitterHandles)
{
olive::TrackViewSplitter splitter(Qt::AlignTop);
splitter.insert(0, 100, new QWidget());
// createHandle() must produce the custom handle type
EXPECT_NE(qobject_cast<olive::TrackViewSplitterHandle *>(splitter.handle(1)),
nullptr);
}
TEST(TrackViewSplitter, HandleReceiverResizesAndEmits)
{
olive::TrackViewSplitter splitter(Qt::AlignTop);
splitter.insert(0, 100, new QWidget());
splitter.insert(1, 50, new QWidget());
ASSERT_EQ(splitter.height(), 152);
const QList<int> sz = splitter.sizes();
ASSERT_EQ(sz.size(), 3);
ASSERT_EQ(sz.at(0), 100);
QSignalSpy spy(&splitter, &olive::TrackViewSplitter::track_height_changed);
// Dragging the handle below the first track grows it by the drag delta
auto *handle =
qobject_cast<olive::TrackViewSplitterHandle *>(splitter.handle(1));
ASSERT_NE(handle, nullptr);
splitter.handle_receiver(handle, 30);
ASSERT_EQ(spy.count(), 1);
EXPECT_EQ(spy.first().at(0).toInt(), 0);
EXPECT_EQ(spy.first().at(1).toInt(), 130);
EXPECT_EQ(splitter.height(), 182);
}
+396
View File
@@ -0,0 +1,396 @@
#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);
}
@@ -0,0 +1,660 @@
#include <gtest/gtest.h>
#include <cstring>
#include <memory>
#include <QApplication>
#include <QDialogButtonBox>
#include <QEvent>
#include <QPushButton>
#include <QSignalSpy>
#include <QTreeWidget>
#include <olive/core/render/audioparams.h>
#include <olive/core/render/samplebuffer.h>
#include "core.h"
#include "dialog/configbase/configdialogbasetab.h"
#include "dialog/otioproperties/otiopropertiesdialog.h"
#include "node/color/colormanager/colormanager.h"
#include "node/factory.h"
#include "node/input/multicam/multicamnode.h"
#include "node/keyframe.h"
#include "node/math/math/math.h"
#include "node/nodeundo.h"
#include "node/project.h"
#include "node/project/sequence/sequence.h"
#include "oakengine/node.h"
#include "render/diskmanager.h"
#include "widget/audiomonitor/audiomonitor.h"
#include "widget/keyframeview/keyframehandle.h"
#include "widget/keyframeview/keyframeview.h"
#include "widget/keyframeview/keyframeviewinputconnection.h"
#include "widget/multicam/multicamdisplay.h"
#include "widget/nodecombobox/nodecombobox.h"
using namespace olive;
namespace
{
// Keyframe views and the facade event subscriptions need the application
// singletons; create them once and leak them (same pattern as the other
// widget suites)
void ensure_app_singletons()
{
if (!olive::Core::instance()) {
new olive::Core(); // intentionally leaked
}
if (!olive::DiskManager::instance()) {
olive::DiskManager::create_instance();
}
}
NodeKeyframe *insert_keyframe(Node *node, const QString &input,
const Rational &time, const QVariant &value,
int track = 0)
{
auto *key =
new NodeKeyframe(time, value, NodeKeyframe::k_linear, track, -1, input);
NodeParamInsertKeyframeCommand(node, key).redo_now();
return key;
}
oak::KeyframeTrackRef track_ref(Node *n, const QString &input, int track)
{
return oak::KeyframeTrackRef(
oak::Input(reinterpret_cast<OakEngineNode *>(n), input), track);
}
OakEngineKeyframe *handle_of(NodeKeyframe *key)
{
return reinterpret_cast<OakEngineKeyframe *>(key);
}
// Minimal concrete tab: the base class only provides a default validate()
class RecordingTab : public olive::ConfigDialogBaseTab {
public:
virtual void accept(void *parent) override
{
accepted_with = parent;
}
void *accepted_with = nullptr;
};
} // namespace
//
// keyframehandle.h: facade accessors over OakEngineKeyframe identity handles
//
class KeyframeHandleTest : public ::testing::Test {
protected:
void SetUp() override
{
ColorManager::set_up_default_config();
ensure_app_singletons();
project_ = std::make_unique<Project>();
project_->initialize();
node_ = new MathNode();
node_->setParent(project_.get());
}
std::unique_ptr<Project> project_;
MathNode *node_ = nullptr;
};
TEST_F(KeyframeHandleTest, AccessorsReadEngineState)
{
NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in,
Rational(1, 2), 2.5);
const OakEngineKeyframe *handle = handle_of(key);
EXPECT_EQ(key_node(handle), reinterpret_cast<OakEngineNode *>(node_));
EXPECT_EQ(key_time(handle), Rational(1, 2));
EXPECT_EQ(key_easing(handle), 0); // facade order: 0 = linear
EXPECT_EQ(key_input_id(handle), MathNode::k_param_a_in);
EXPECT_EQ(key_track(handle), 0);
EXPECT_EQ(key_element(handle), -1);
}
TEST_F(KeyframeHandleTest, ValueAsDoubleConvertsNumericTypes)
{
NodeKeyframe *float_key = insert_keyframe(node_, MathNode::k_param_a_in,
Rational(0), 2.5);
const oak_node_value float_value = key_value(handle_of(float_key));
EXPECT_EQ(float_value.type, OAK_NODE_VALUE_FLOAT);
EXPECT_DOUBLE_EQ(key_value_as_double(handle_of(float_key)), 2.5);
// The base-class enabled input carries boolean keyframes
NodeKeyframe *bool_key = insert_keyframe(node_, Node::k_enabled_input,
Rational(0), true);
const oak_node_value bool_value = key_value(handle_of(bool_key));
EXPECT_EQ(bool_value.type, OAK_NODE_VALUE_BOOL);
EXPECT_DOUBLE_EQ(key_value_as_double(handle_of(bool_key)), 1.0);
NodeKeyframe *false_key = insert_keyframe(node_, Node::k_enabled_input,
Rational(1), false);
EXPECT_DOUBLE_EQ(key_value_as_double(handle_of(false_key)), 0.0);
}
TEST_F(KeyframeHandleTest, LiveValueSetUpdatesFacadeReads)
{
NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in,
Rational(0), 1.0);
oak_node_value v;
std::memset(&v, 0, sizeof(v));
v.type = OAK_NODE_VALUE_FLOAT;
v.f[0] = 7.25;
key_set_value_live(handle_of(key), v);
EXPECT_DOUBLE_EQ(key_value_as_double(handle_of(key)), 7.25);
EXPECT_DOUBLE_EQ(key->value().toDouble(), 7.25);
}
TEST_F(KeyframeHandleTest, LiveTimeSetMovesKeyframe)
{
NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in,
Rational(0), 0.0);
key_set_time_live(handle_of(key), Rational(2));
EXPECT_EQ(key_time(handle_of(key)), Rational(2));
EXPECT_EQ(key->time(), Rational(2));
// The selection ADL wrappers route through the same accessors
EXPECT_EQ(selection_time(handle_of(key)), Rational(2));
selection_set_time(handle_of(key), Rational(4));
EXPECT_EQ(key->time(), Rational(4));
}
TEST_F(KeyframeHandleTest, BezierPointRoundTrip)
{
NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in,
Rational(0), 0.0);
key->set_type(NodeKeyframe::k_bezier);
ASSERT_EQ(key_easing(handle_of(key)), 1); // facade order: 1 = bezier
key_set_bezier_point_live(handle_of(key), 0, QPointF(0.25, 0.75));
key_set_bezier_point_live(handle_of(key), 1, QPointF(-0.5, 1.5));
EXPECT_EQ(key_bezier_point(handle_of(key), 0), QPointF(0.25, 0.75));
EXPECT_EQ(key_bezier_point(handle_of(key), 1), QPointF(-0.5, 1.5));
// For a bezier keyframe the "valid" points are the actual points
EXPECT_EQ(key_valid_bezier_point(handle_of(key), 0), QPointF(0.25, 0.75));
EXPECT_EQ(key_valid_bezier_point(handle_of(key), 1), QPointF(-0.5, 1.5));
}
TEST_F(KeyframeHandleTest, HasSiblingAtTimeExcludesSelf)
{
// The sibling lookup goes through Node::get_keyframe_at_time_on_track(),
// which only searches tracks that are not on the standard value, so
// keyframing must be enabled on the input first
node_->set_input_is_keyframing(MathNode::k_param_a_in, true);
NodeKeyframe *key_a = insert_keyframe(node_, MathNode::k_param_a_in,
Rational(0), 0.0);
NodeKeyframe *key_b = insert_keyframe(node_, MathNode::k_param_a_in,
Rational(1), 1.0);
// Another keyframe sits at t=1
EXPECT_TRUE(key_has_sibling_at_time(handle_of(key_a), Rational(1)));
// t=0 resolves to key_a itself, which does not count as a sibling
EXPECT_FALSE(key_has_sibling_at_time(handle_of(key_a), Rational(0)));
// Nothing at t=5
EXPECT_FALSE(key_has_sibling_at_time(handle_of(key_a), Rational(5)));
EXPECT_TRUE(selection_has_sibling_at_time(handle_of(key_b), Rational(0)));
EXPECT_EQ(selection_time_target_parent(handle_of(key_a)),
reinterpret_cast<OakEngineNode *>(node_));
}
//
// keyframeviewinputconnection.h: per-track connection between engine keyframe
// events and a KeyframeView
//
class KeyframeConnectionTest : public ::testing::Test {
protected:
void SetUp() override
{
ColorManager::set_up_default_config();
ensure_app_singletons();
project_ = std::make_unique<Project>();
project_->initialize();
node_ = new MathNode();
node_->setParent(project_.get());
}
oak::KeyframeTrackRef param_a_ref() const
{
return track_ref(node_, MathNode::k_param_a_in, 0);
}
std::unique_ptr<Project> project_;
MathNode *node_ = nullptr;
};
TEST_F(KeyframeConnectionTest, DefaultsAndAccessors)
{
KeyframeView view;
const oak::KeyframeTrackRef ref = param_a_ref();
KeyframeViewInputConnection connection(ref, &view);
EXPECT_EQ(connection.get_keyframe_y(), 0);
EXPECT_EQ(connection.get_brush().color(), QColor(Qt::white));
EXPECT_EQ(connection.get_reference(), ref);
EXPECT_TRUE(connection.get_keyframes().isEmpty());
}
TEST_F(KeyframeConnectionTest, SetKeyframeYEmitsOnlyOnChange)
{
KeyframeView view;
KeyframeViewInputConnection connection(param_a_ref(), &view);
QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update);
connection.set_keyframe_y(10);
EXPECT_EQ(connection.get_keyframe_y(), 10);
EXPECT_EQ(spy.count(), 1);
// Same value is a no-op
connection.set_keyframe_y(10);
EXPECT_EQ(spy.count(), 1);
connection.set_keyframe_y(-3);
EXPECT_EQ(connection.get_keyframe_y(), -3);
EXPECT_EQ(spy.count(), 2);
}
TEST_F(KeyframeConnectionTest, SetBrushEmitsOnlyOnChange)
{
KeyframeView view;
KeyframeViewInputConnection connection(param_a_ref(), &view);
QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update);
// Same as the default brush: no emission
connection.set_brush(QBrush(Qt::white));
EXPECT_EQ(spy.count(), 0);
connection.set_brush(QBrush(Qt::red));
EXPECT_EQ(connection.get_brush().color(), QColor(Qt::red));
EXPECT_EQ(spy.count(), 1);
}
TEST_F(KeyframeConnectionTest, SetYBehaviorEmitsOnlyOnChange)
{
KeyframeView view;
KeyframeViewInputConnection connection(param_a_ref(), &view);
QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update);
// Default behavior is k_single_row
connection.set_y_behavior(KeyframeViewInputConnection::k_single_row);
EXPECT_EQ(spy.count(), 0);
connection.set_y_behavior(KeyframeViewInputConnection::k_value_is_height);
EXPECT_EQ(spy.count(), 1);
connection.set_y_behavior(KeyframeViewInputConnection::k_value_is_height);
EXPECT_EQ(spy.count(), 1);
}
TEST_F(KeyframeConnectionTest, MatchingKeyframeInsertionEmitsRequireUpdate)
{
KeyframeView view;
KeyframeViewInputConnection connection(param_a_ref(), &view);
QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update);
insert_keyframe(node_, MathNode::k_param_a_in, Rational(0), 0.0);
EXPECT_EQ(spy.count(), 1);
ASSERT_EQ(connection.get_keyframes().size(), 1);
// A keyframe on a different input of the same node does not match
insert_keyframe(node_, MathNode::k_param_b_in, Rational(0), 0.0);
EXPECT_EQ(spy.count(), 1);
EXPECT_EQ(connection.get_keyframes().size(), 1);
}
TEST_F(KeyframeConnectionTest, LiveValueAndTimeChangesEmitRequireUpdate)
{
NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in,
Rational(0), 0.0);
KeyframeView view;
KeyframeViewInputConnection connection(param_a_ref(), &view);
QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update);
oak_node_value v;
std::memset(&v, 0, sizeof(v));
v.type = OAK_NODE_VALUE_FLOAT;
v.f[0] = 3.0;
key_set_value_live(handle_of(key), v);
EXPECT_EQ(spy.count(), 1);
key_set_time_live(handle_of(key), Rational(3));
EXPECT_EQ(spy.count(), 2);
}
TEST_F(KeyframeConnectionTest, TypeChangeEmitsTypeChangedAndRequireUpdate)
{
// Node::invalidate_from_keyframe_type_changed() returns early when the
// track holds a single keyframe (interpolation is a no-op), so the type
// change signal only reaches the connection with two or more keyframes
NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in,
Rational(0), 0.0);
insert_keyframe(node_, MathNode::k_param_a_in, Rational(1), 1.0);
KeyframeView view;
KeyframeViewInputConnection connection(param_a_ref(), &view);
QSignalSpy update_spy(&connection,
&KeyframeViewInputConnection::require_update);
QSignalSpy type_spy(&connection,
&KeyframeViewInputConnection::type_changed);
key->set_type(NodeKeyframe::k_hold);
EXPECT_EQ(update_spy.count(), 1);
EXPECT_EQ(type_spy.count(), 1);
// Setting the same type again emits nothing
key->set_type(NodeKeyframe::k_hold);
EXPECT_EQ(update_spy.count(), 1);
EXPECT_EQ(type_spy.count(), 1);
}
TEST_F(KeyframeConnectionTest, RemovalEmitsRequireUpdate)
{
NodeKeyframe *key = insert_keyframe(node_, MathNode::k_param_a_in,
Rational(0), 0.0);
KeyframeView view;
KeyframeViewInputConnection connection(param_a_ref(), &view);
QSignalSpy spy(&connection, &KeyframeViewInputConnection::require_update);
NodeParamRemoveKeyframeCommand(key).redo_now();
EXPECT_EQ(spy.count(), 1);
EXPECT_TRUE(connection.get_keyframes().isEmpty());
}
//
// multicamdisplay.h: the shader/paint paths need a GL renderer, but the
// widget itself constructs offscreen and its node contract is facade-readable
//
TEST(WidgetMulticamDisplay, ConstructsWithoutNode)
{
ensure_app_singletons();
MulticamDisplay display;
EXPECT_FALSE(display.isVisible());
// Accepts (and clears) a null node without touching the paint path
display.set_multicam_node(nullptr);
}
TEST(WidgetMulticamDisplay, MulticamNodeStateMatchesPaintContract)
{
ensure_app_singletons();
MulticamDisplay display;
MultiCamNode node;
node.input_array_resize(MultiCamNode::k_sources_input, 3);
node.set_standard_value(MultiCamNode::k_current_input, 1);
auto *handle = reinterpret_cast<OakEngineNode *>(&node);
display.set_multicam_node(handle);
// on_paint() lays the highlight rect out from exactly these facade reads
EXPECT_EQ(oakengine_multicam_get_source_count(handle), 3);
EXPECT_EQ(oakengine_multicam_get_current_source(handle), 1);
int rows = 0, cols = 0;
oakengine_multicam_get_rows_and_columns(3, &rows, &cols);
EXPECT_EQ(rows, 2);
EXPECT_EQ(cols, 2);
int row = -1, col = -1;
oakengine_multicam_index_to_row_cols(1, rows, cols, &row, &col);
EXPECT_EQ(row, 0);
EXPECT_EQ(col, 1);
display.set_multicam_node(nullptr);
}
//
// nodecombobox.h: the popup path is modal; the retranslation and factory-name
// paths are not (selection round-trips are covered in widget_combos_test.cpp)
//
TEST(WidgetNodeComboBox, LanguageChangeRetranslatesSelection)
{
NodeFactory::initialize();
{
NodeComboBox combo;
const QString id = QStringLiteral("org.olivevideoeditor.Olive.math");
combo.set_node(id);
ASSERT_EQ(combo.count(), 1);
const QString before = combo.itemText(0);
EXPECT_EQ(before, NodeFactory::get_name_from_id(id));
ASSERT_FALSE(before.isEmpty());
QEvent lang(QEvent::LanguageChange);
QApplication::sendEvent(&combo, &lang);
// The selection survives and the label is re-fetched from the factory
EXPECT_EQ(combo.get_selected_node(), id);
ASSERT_EQ(combo.count(), 1);
EXPECT_EQ(combo.itemText(0), before);
}
NodeFactory::destroy();
}
TEST(WidgetNodeComboBox, UnknownIdProducesEmptyText)
{
NodeFactory::initialize();
{
NodeComboBox combo;
combo.set_node(QStringLiteral("org.example.bogus"));
// The id is stored even though the factory has no name for it
EXPECT_EQ(combo.get_selected_node(), QStringLiteral("org.example.bogus"));
ASSERT_EQ(combo.count(), 1);
EXPECT_TRUE(combo.itemText(0).isEmpty());
}
NodeFactory::destroy();
}
//
// audiomonitor.h: painting needs a GL context; the playback state machine,
// parameter handling and the static instance broadcasters do not
//
TEST(WidgetAudioMonitor, ConstructionDefaults)
{
AudioMonitor monitor;
EXPECT_FALSE(monitor.is_playing());
// The ctor sizes the minimum width to the font height
EXPECT_EQ(monitor.minimumWidth(), monitor.fontMetrics().height());
}
TEST(WidgetAudioMonitor, StartWaveformWithNullCacheStaysStopped)
{
AudioMonitor monitor;
// A null cache reports sample rate 0, so the start request is rejected
monitor.start_waveform(nullptr, Rational(0), 1);
EXPECT_FALSE(monitor.is_playing());
monitor.stop();
EXPECT_FALSE(monitor.is_playing());
}
TEST(WidgetAudioMonitor, PushSampleBufferWithoutParamsIsIgnored)
{
AudioMonitor monitor;
// No params set -> channel_count() is 0 -> early return
monitor.push_sample_buffer(SampleBuffer());
EXPECT_FALSE(monitor.is_playing());
}
TEST(WidgetAudioMonitor, PushSampleBufferWithParamsDoesNotStartPlayback)
{
const olive::core::AudioParams params(48000,
olive::core::k_channel_layout_stereo,
olive::core::SampleFormat::f32_p);
AudioMonitor monitor;
monitor.set_params(params);
SampleBuffer buffer(params, size_t(64));
ASSERT_TRUE(buffer.is_allocated());
float *left = buffer.data(0);
ASSERT_NE(left, nullptr);
for (int i = 0; i < 64; i++) {
left[i] = 0.5f;
}
// Level analysis runs, but sample playback is not waveform playback
monitor.push_sample_buffer(buffer);
EXPECT_FALSE(monitor.is_playing());
monitor.stop();
EXPECT_FALSE(monitor.is_playing());
}
TEST(WidgetAudioMonitor, StaticBroadcastsReachAllInstances)
{
const olive::core::AudioParams params(48000,
olive::core::k_channel_layout_stereo,
olive::core::SampleFormat::f32_p);
AudioMonitor first;
AudioMonitor second;
first.set_params(params);
second.set_params(params);
// A null waveform is rejected on every registered instance
AudioMonitor::start_waveform_on_all(nullptr, Rational(0), 1);
EXPECT_FALSE(first.is_playing());
EXPECT_FALSE(second.is_playing());
SampleBuffer buffer(params, size_t(32));
AudioMonitor::push_sample_buffer_on_all(buffer);
EXPECT_FALSE(first.is_playing());
EXPECT_FALSE(second.is_playing());
AudioMonitor::stop_on_all();
EXPECT_FALSE(first.is_playing());
EXPECT_FALSE(second.is_playing());
}
//
// configdialogbasetab.h
//
TEST(DialogConfigBaseTab, DefaultValidateAccepts)
{
RecordingTab tab;
EXPECT_TRUE(tab.validate());
}
TEST(DialogConfigBaseTab, AcceptReceivesParentPointer)
{
RecordingTab tab;
int parent_token = 0;
tab.accept(&parent_token);
EXPECT_EQ(tab.accepted_with, static_cast<void *>(&parent_token));
}
//
// otiopropertiesdialog.h: the per-sequence Settings button opens a modal
// SequenceDialog (not exercised); construction, listing and OK/Cancel are not
// modal
//
TEST(DialogOTIOProperties, ListsSequencesWithIndexedSettingsButtons)
{
ColorManager::set_up_default_config();
Project project;
project.initialize();
auto *seq_a = new Sequence();
seq_a->setParent(&project);
seq_a->set_label(QStringLiteral("Alpha"));
auto *seq_b = new Sequence();
seq_b->setParent(&project);
seq_b->set_label(QStringLiteral("Beta"));
const QList<OakEngineSequence *> sequences = {
reinterpret_cast<OakEngineSequence *>(seq_a),
reinterpret_cast<OakEngineSequence *>(seq_b),
};
OTIOPropertiesDialog dialog(
sequences, reinterpret_cast<OakEngineProject *>(&project));
EXPECT_EQ(dialog.windowTitle(), QStringLiteral("Load OpenTimelineIO Project"));
auto *table = dialog.findChild<QTreeWidget *>();
ASSERT_NE(table, nullptr);
EXPECT_EQ(table->columnCount(), 2);
EXPECT_EQ(table->headerItem()->text(0), QStringLiteral("Sequence"));
EXPECT_EQ(table->headerItem()->text(1), QStringLiteral("Actions"));
EXPECT_FALSE(table->rootIsDecorated());
ASSERT_EQ(table->topLevelItemCount(), 2);
EXPECT_EQ(table->topLevelItem(0)->text(0), QStringLiteral("Alpha"));
EXPECT_EQ(table->topLevelItem(1)->text(0), QStringLiteral("Beta"));
// Each row carries a Settings button whose "index" property is its row
for (int i = 0; i < 2; i++) {
QWidget *actions = table->itemWidget(table->topLevelItem(i), 1);
ASSERT_NE(actions, nullptr) << "row " << i;
auto *button = actions->findChild<QPushButton *>();
ASSERT_NE(button, nullptr) << "row " << i;
EXPECT_EQ(button->text(), QStringLiteral("Settings"));
EXPECT_EQ(button->property("index").toInt(), i);
}
}
TEST(DialogOTIOProperties, EmptyListAndButtonBoxResults)
{
ColorManager::set_up_default_config();
Project project;
project.initialize();
{
OTIOPropertiesDialog dialog(
{}, reinterpret_cast<OakEngineProject *>(&project));
auto *table = dialog.findChild<QTreeWidget *>();
ASSERT_NE(table, nullptr);
EXPECT_EQ(table->topLevelItemCount(), 0);
auto *buttons = dialog.findChild<QDialogButtonBox *>();
ASSERT_NE(buttons, nullptr);
QPushButton *ok = buttons->button(QDialogButtonBox::Ok);
ASSERT_NE(ok, nullptr);
ok->click();
EXPECT_EQ(dialog.result(), QDialog::Accepted);
}
{
OTIOPropertiesDialog dialog(
{}, reinterpret_cast<OakEngineProject *>(&project));
auto *buttons = dialog.findChild<QDialogButtonBox *>();
ASSERT_NE(buttons, nullptr);
QPushButton *cancel = buttons->button(QDialogButtonBox::Cancel);
ASSERT_NE(cancel, nullptr);
cancel->click();
EXPECT_EQ(dialog.result(), QDialog::Rejected);
}
}
+846
View File
@@ -0,0 +1,846 @@
#include <gtest/gtest.h>
#include <memory>
#include <QApplication>
#include <QCheckBox>
#include <QLabel>
#include <QMouseEvent>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QSignalSpy>
#include <QTest>
#include "core.h"
#include "node/color/colormanager/colormanager.h"
#include "node/generator/text/textv3.h"
#include "node/globals.h"
#include "node/math/math/math.h"
#include "node/project.h"
#include "node/project/folder/folder.h"
#include "oakengine/app.h"
#include "undo/undostack.h"
#include "widget/clickablelabel/clickablelabel.h"
#include "widget/collapsebutton/collapsebutton.h"
#include "widget/nodeparamview/nodeparambutton.h"
#include "widget/nodeparamview/nodeparamviewarraywidget.h"
#include "widget/nodeparamview/nodeparamviewconnectedlabel.h"
#include "widget/nodeparamview/nodeparamviewcontext.h"
#include "widget/nodeparamview/nodeparamviewitem.h"
#include "widget/nodeparamview/nodeparamviewitemtitlebar.h"
#include "widget/nodeparamview/nodeparamviewkeyframecontrol.h"
#include "widget/nodeparamview/nodeparamviewtextedit.h"
namespace
{
// Widgets that connect to Core::instance() at construction require the
// application singleton, but not a MainWindow
void ensure_core()
{
if (!olive::Core::instance()) {
new olive::Core(); // intentionally leaked
}
}
// Helper: wrap an engine Node* as oak::Node for the C ABI widget interface
inline oak::Node to_oak(olive::Node *n)
{
return oak::Node(reinterpret_cast<OakEngineNode *>(n));
}
// Helper: build an oak::Input value from an engine Node* + input id
inline oak::Input to_oak_input(olive::Node *n, const QString &input,
int element = -1)
{
return oak::Input(reinterpret_cast<OakEngineNode *>(n), input, element);
}
// The process-wide undo stack previously reached via Core::undo_stack();
// facade calls below (array insert/remove, keyframing, edge disconnect) push
// commands onto it, so it must be cleared before the owning Project dies
inline olive::UndoStack *app_undo_stack()
{
return static_cast<olive::UndoStack *>(oakengine_app_undo_stack());
}
// Minimal node with a plain float output so it can feed MathNode's
// parameter inputs in connection tests
class FloatSourceNode : public olive::Node {
public:
FloatSourceNode() = default;
NODE_DEFAULT_FUNCTIONS(FloatSourceNode)
virtual QString name() const override
{
return QStringLiteral("Test Float Source");
}
virtual QString id() const override
{
return QStringLiteral("org.oak.test.floatsource");
}
virtual QVector<CategoryID> category() const override
{
return { k_category_math };
}
virtual void value(const olive::NodeValueRow &value,
const olive::NodeGlobals &globals,
olive::NodeValueTable *table) const override
{
Q_UNUSED(value)
Q_UNUSED(globals)
table->push(olive::NodeValue::k_float, QVariant(1.5), this);
}
};
// Sends a mouse event straight to the widget, bypassing child hit-testing
// and cursor tracking (deterministic under the offscreen QPA)
void send_mouse_event(QWidget *w, QEvent::Type type, const QPointF &pos)
{
QMouseEvent ev(type, pos, pos, pos, Qt::LeftButton, Qt::LeftButton,
Qt::NoModifier);
QApplication::sendEvent(w, &ev);
}
} // namespace
// ---------------------------------------------------------------------------
// NodeParamButton (global namespace, plain QPushButton with a stored name)
// ---------------------------------------------------------------------------
TEST(WidgetNodeParamButton, ClickEmitsStoredName)
{
NodeParamButton btn(QStringLiteral("volume"));
// The name is stored separately; it is not used as the button text
EXPECT_TRUE(btn.text().isEmpty());
QSignalSpy spy(&btn, &NodeParamButton::on_pressed);
btn.click();
ASSERT_EQ(spy.count(), 1);
EXPECT_EQ(spy.first().first().toString(), QStringLiteral("volume"));
}
TEST(WidgetNodeParamButton, InstancesEmitTheirOwnNames)
{
NodeParamButton a(QStringLiteral("alpha"));
NodeParamButton b(QStringLiteral("beta"));
QStringList received;
QObject::connect(&a, &NodeParamButton::on_pressed,
[&received](const QString &n) { received.append(n); });
QObject::connect(&b, &NodeParamButton::on_pressed,
[&received](const QString &n) { received.append(n); });
b.click();
a.click();
b.click();
ASSERT_EQ(received.size(), 3);
EXPECT_EQ(received.at(0), QStringLiteral("beta"));
EXPECT_EQ(received.at(1), QStringLiteral("alpha"));
EXPECT_EQ(received.at(2), QStringLiteral("beta"));
}
// ---------------------------------------------------------------------------
// NodeParamViewItemTitleBar
// ---------------------------------------------------------------------------
TEST(WidgetNodeParamViewItemTitleBar, DefaultsAreExpandedWithHiddenButtons)
{
olive::NodeParamViewItemTitleBar bar;
// CollapseButton defaults to checked, so the bar starts expanded
EXPECT_TRUE(bar.is_expanded());
auto *collapse = bar.findChild<olive::CollapseButton *>();
ASSERT_NE(collapse, nullptr);
EXPECT_TRUE(collapse->isChecked());
EXPECT_TRUE(collapse->isVisibleTo(&bar));
// Pin / add-effect / enabled-checkbox all start hidden
QPushButton *pin = nullptr;
QPushButton *add_fx = nullptr;
for (QPushButton *b : bar.findChildren<QPushButton *>()) {
if (b == collapse) {
continue;
}
if (b->text() == QStringLiteral("P")) {
pin = b;
} else {
add_fx = b;
}
}
ASSERT_NE(pin, nullptr);
ASSERT_NE(add_fx, nullptr);
EXPECT_TRUE(pin->isCheckable());
EXPECT_FALSE(pin->isVisibleTo(&bar));
EXPECT_FALSE(add_fx->isVisibleTo(&bar));
auto *checkbox = bar.findChild<QCheckBox *>();
ASSERT_NE(checkbox, nullptr);
EXPECT_FALSE(checkbox->isVisibleTo(&bar));
}
TEST(WidgetNodeParamViewItemTitleBar, SetExpandedRoundTripsWithoutSignal)
{
olive::NodeParamViewItemTitleBar bar;
QSignalSpy spy(&bar,
&olive::NodeParamViewItemTitleBar::expanded_state_changed);
bar.set_expanded(false);
EXPECT_FALSE(bar.is_expanded());
bar.set_expanded(true);
EXPECT_TRUE(bar.is_expanded());
// Programmatic expansion changes do not count as user interaction
EXPECT_EQ(spy.count(), 0);
}
TEST(WidgetNodeParamViewItemTitleBar, SetTextUpdatesLabelAndTooltip)
{
olive::NodeParamViewItemTitleBar bar;
bar.set_text(QStringLiteral("My Node"));
auto *lbl = bar.findChild<QLabel *>();
ASSERT_NE(lbl, nullptr);
EXPECT_EQ(lbl->text(), QStringLiteral("My Node"));
EXPECT_EQ(lbl->toolTip(), QStringLiteral("My Node"));
}
TEST(WidgetNodeParamViewItemTitleBar, VisibilitySettersToggleChildren)
{
olive::NodeParamViewItemTitleBar bar;
QPushButton *pin = nullptr;
QPushButton *add_fx = nullptr;
for (QPushButton *b : bar.findChildren<QPushButton *>()) {
if (qobject_cast<olive::CollapseButton *>(b)) {
continue;
}
if (b->text() == QStringLiteral("P")) {
pin = b;
} else {
add_fx = b;
}
}
auto *checkbox = bar.findChild<QCheckBox *>();
ASSERT_NE(pin, nullptr);
ASSERT_NE(add_fx, nullptr);
ASSERT_NE(checkbox, nullptr);
bar.set_pin_button_visible(true);
EXPECT_TRUE(pin->isVisibleTo(&bar));
EXPECT_FALSE(add_fx->isVisibleTo(&bar));
bar.set_add_effect_button_visible(true);
EXPECT_TRUE(add_fx->isVisibleTo(&bar));
bar.set_enabled_check_box_visible(true);
EXPECT_TRUE(checkbox->isVisibleTo(&bar));
bar.set_pin_button_visible(false);
EXPECT_FALSE(pin->isVisibleTo(&bar));
}
TEST(WidgetNodeParamViewItemTitleBar, EnabledCheckBoxCheckedRoundTrips)
{
olive::NodeParamViewItemTitleBar bar;
auto *checkbox = bar.findChild<QCheckBox *>();
ASSERT_NE(checkbox, nullptr);
EXPECT_FALSE(checkbox->isChecked());
bar.set_enabled_check_box_checked(true);
EXPECT_TRUE(checkbox->isChecked());
bar.set_enabled_check_box_checked(false);
EXPECT_FALSE(checkbox->isChecked());
}
TEST(WidgetNodeParamViewItemTitleBar, CollapseClickEmitsExpandedState)
{
olive::NodeParamViewItemTitleBar bar;
auto *collapse = bar.findChild<olive::CollapseButton *>();
ASSERT_NE(collapse, nullptr);
QSignalSpy spy(&bar,
&olive::NodeParamViewItemTitleBar::expanded_state_changed);
// Starts expanded; clicking collapses
collapse->click();
ASSERT_EQ(spy.count(), 1);
EXPECT_FALSE(spy.first().first().toBool());
collapse->click();
ASSERT_EQ(spy.count(), 2);
EXPECT_TRUE(spy.at(1).first().toBool());
}
TEST(WidgetNodeParamViewItemTitleBar, ButtonsForwardTheirSignals)
{
olive::NodeParamViewItemTitleBar bar;
QPushButton *pin = nullptr;
QPushButton *add_fx = nullptr;
for (QPushButton *b : bar.findChildren<QPushButton *>()) {
if (qobject_cast<olive::CollapseButton *>(b)) {
continue;
}
if (b->text() == QStringLiteral("P")) {
pin = b;
} else {
add_fx = b;
}
}
auto *checkbox = bar.findChild<QCheckBox *>();
ASSERT_NE(pin, nullptr);
ASSERT_NE(add_fx, nullptr);
ASSERT_NE(checkbox, nullptr);
QSignalSpy pin_spy(&bar, &olive::NodeParamViewItemTitleBar::pin_toggled);
QSignalSpy add_spy(
&bar, &olive::NodeParamViewItemTitleBar::add_effect_button_clicked);
QSignalSpy enabled_spy(
&bar, &olive::NodeParamViewItemTitleBar::enabled_check_box_clicked);
pin->click();
ASSERT_EQ(pin_spy.count(), 1);
EXPECT_TRUE(pin_spy.first().first().toBool());
add_fx->click();
EXPECT_EQ(add_spy.count(), 1);
checkbox->click();
ASSERT_EQ(enabled_spy.count(), 1);
EXPECT_TRUE(enabled_spy.first().first().toBool());
}
TEST(WidgetNodeParamViewItemTitleBar, MousePressEmitsClicked)
{
olive::NodeParamViewItemTitleBar bar;
QSignalSpy spy(&bar, &olive::NodeParamViewItemTitleBar::clicked);
send_mouse_event(&bar, QEvent::MouseButtonPress, QPointF(5, 5));
EXPECT_EQ(spy.count(), 1);
}
TEST(WidgetNodeParamViewItemTitleBar, DoubleClickTogglesExpansion)
{
olive::NodeParamViewItemTitleBar bar;
ASSERT_TRUE(bar.is_expanded());
QSignalSpy spy(&bar,
&olive::NodeParamViewItemTitleBar::expanded_state_changed);
send_mouse_event(&bar, QEvent::MouseButtonDblClick, QPointF(5, 5));
EXPECT_FALSE(bar.is_expanded());
ASSERT_EQ(spy.count(), 1);
EXPECT_FALSE(spy.first().first().toBool());
send_mouse_event(&bar, QEvent::MouseButtonDblClick, QPointF(5, 5));
EXPECT_TRUE(bar.is_expanded());
EXPECT_EQ(spy.count(), 2);
}
// ---------------------------------------------------------------------------
// NodeParamViewTextEdit
// ---------------------------------------------------------------------------
TEST(WidgetNodeParamViewTextEdit, SetTextRoundTripsWithoutSignal)
{
olive::NodeParamViewTextEdit w;
QSignalSpy spy(&w, &olive::NodeParamViewTextEdit::text_edited);
w.setText(QStringLiteral("hello"));
EXPECT_EQ(w.text(), QStringLiteral("hello"));
// Programmatic changes don't count as user edits
EXPECT_EQ(spy.count(), 0);
}
TEST(WidgetNodeParamViewTextEdit, TypingEmitsTextEdited)
{
olive::NodeParamViewTextEdit w;
auto *edit = w.findChild<QPlainTextEdit *>();
ASSERT_NE(edit, nullptr);
QSignalSpy spy(&w, &olive::NodeParamViewTextEdit::text_edited);
QTest::keyClicks(edit, QStringLiteral("hi"));
ASSERT_EQ(spy.count(), 2);
EXPECT_EQ(spy.first().first().toString(), QStringLiteral("h"));
EXPECT_EQ(spy.at(1).first().toString(), QStringLiteral("hi"));
EXPECT_EQ(w.text(), QStringLiteral("hi"));
}
TEST(WidgetNodeParamViewTextEdit, SetTextPreservingCursorKeepsPosition)
{
olive::NodeParamViewTextEdit w;
auto *edit = w.findChild<QPlainTextEdit *>();
ASSERT_NE(edit, nullptr);
w.setText(QStringLiteral("hello"));
QTextCursor c = edit->textCursor();
c.setPosition(5);
edit->setTextCursor(c);
w.setTextPreservingCursor(QStringLiteral("hello world"));
EXPECT_EQ(w.text(), QStringLiteral("hello world"));
EXPECT_EQ(edit->textCursor().position(), 5);
}
TEST(WidgetNodeParamViewTextEdit, ViewerOnlyModeSwapsVisibleChildren)
{
olive::NodeParamViewTextEdit w;
auto *edit = w.findChild<QPlainTextEdit *>();
ASSERT_NE(edit, nullptr);
QPushButton *viewer_btn = nullptr;
QPushButton *edit_btn = nullptr;
for (QPushButton *b : w.findChildren<QPushButton *>()) {
if (b->text() == QStringLiteral("Edit In Viewer")) {
viewer_btn = b;
} else {
edit_btn = b;
}
}
ASSERT_NE(viewer_btn, nullptr);
ASSERT_NE(edit_btn, nullptr);
// Default mode: plain-text edit + dialog button, no viewer button
EXPECT_TRUE(edit->isVisibleTo(&w));
EXPECT_TRUE(edit_btn->isVisibleTo(&w));
EXPECT_FALSE(viewer_btn->isVisibleTo(&w));
w.set_edit_in_viewer_only_mode(true);
EXPECT_FALSE(edit->isVisibleTo(&w));
EXPECT_FALSE(edit_btn->isVisibleTo(&w));
EXPECT_TRUE(viewer_btn->isVisibleTo(&w));
w.set_edit_in_viewer_only_mode(false);
EXPECT_TRUE(edit->isVisibleTo(&w));
EXPECT_TRUE(edit_btn->isVisibleTo(&w));
EXPECT_FALSE(viewer_btn->isVisibleTo(&w));
}
TEST(WidgetNodeParamViewTextEdit, EditInViewerButtonEmitsRequest)
{
olive::NodeParamViewTextEdit w;
QPushButton *viewer_btn = nullptr;
for (QPushButton *b : w.findChildren<QPushButton *>()) {
if (b->text() == QStringLiteral("Edit In Viewer")) {
viewer_btn = b;
break;
}
}
ASSERT_NE(viewer_btn, nullptr);
QSignalSpy spy(&w, &olive::NodeParamViewTextEdit::request_edit_in_viewer);
viewer_btn->click();
EXPECT_EQ(spy.count(), 1);
}
// NOTE: the other push button opens a modal TextDialog (exec()); not
// testable under the offscreen QPA.
// ---------------------------------------------------------------------------
// Shared fixture for the engine-backed widgets
// ---------------------------------------------------------------------------
class NodeParamViewTest : public ::testing::Test {
protected:
void SetUp() override
{
olive::ColorManager::set_up_default_config();
ensure_core();
project_ = std::make_unique<olive::Project>();
project_->initialize();
}
void TearDown() override
{
// Undo commands pushed by facade calls reference project nodes
app_undo_stack()->clear();
project_.reset();
}
template <typename T> T *add_node()
{
auto *node = new T();
node->setParent(project_.get());
return node;
}
std::unique_ptr<olive::Project> project_;
};
// ---------------------------------------------------------------------------
// NodeParamViewConnectedLabel
// ---------------------------------------------------------------------------
TEST_F(NodeParamViewTest, ConnectedLabelDisconnectedInputShowsNothing)
{
auto *math = add_node<olive::MathNode>();
olive::NodeParamViewConnectedLabel w(
to_oak_input(math, olive::MathNode::k_param_a_in));
auto *lbl = w.findChild<olive::ClickableLabel *>();
ASSERT_NE(lbl, nullptr);
EXPECT_EQ(lbl->text(), QStringLiteral("Nothing"));
}
TEST_F(NodeParamViewTest, ConnectedLabelShowsSourceNameWhenConnected)
{
auto *source = add_node<FloatSourceNode>();
auto *math = add_node<olive::MathNode>();
olive::Node::connect_edge(
source, olive::NodeInput(math, olive::MathNode::k_param_a_in));
olive::NodeParamViewConnectedLabel w(
to_oak_input(math, olive::MathNode::k_param_a_in));
auto *lbl = w.findChild<olive::ClickableLabel *>();
ASSERT_NE(lbl, nullptr);
EXPECT_EQ(lbl->text(), QStringLiteral("Test Float Source"));
}
TEST_F(NodeParamViewTest, ConnectedLabelTracksLiveConnectAndDisconnect)
{
auto *source = add_node<FloatSourceNode>();
auto *math = add_node<olive::MathNode>();
olive::NodeParamViewConnectedLabel w(
to_oak_input(math, olive::MathNode::k_param_a_in));
auto *lbl = w.findChild<olive::ClickableLabel *>();
ASSERT_NE(lbl, nullptr);
EXPECT_EQ(lbl->text(), QStringLiteral("Nothing"));
// The widget subscribes to the engine's edge events via its bridge
OakEngineNode *src_handle = reinterpret_cast<OakEngineNode *>(source);
OakEngineNode *math_handle = reinterpret_cast<OakEngineNode *>(math);
const QByteArray input_id = olive::MathNode::k_param_a_in.toUtf8();
ASSERT_EQ(oakengine_node_connect(src_handle, math_handle,
input_id.constData()),
OAKENGINE_OK);
EXPECT_EQ(lbl->text(), QStringLiteral("Test Float Source"));
ASSERT_EQ(oakengine_node_disconnect(math_handle, input_id.constData()),
OAKENGINE_OK);
EXPECT_EQ(lbl->text(), QStringLiteral("Nothing"));
}
TEST_F(NodeParamViewTest, ConnectedLabelClickRequestsSourceSelection)
{
auto *source = add_node<FloatSourceNode>();
auto *math = add_node<olive::MathNode>();
olive::Node::connect_edge(
source, olive::NodeInput(math, olive::MathNode::k_param_a_in));
olive::NodeParamViewConnectedLabel w(
to_oak_input(math, olive::MathNode::k_param_a_in));
w.resize(400, 40);
w.show();
EXPECT_TRUE(QTest::qWaitForWindowExposed(&w));
auto *lbl = w.findChild<olive::ClickableLabel *>();
ASSERT_NE(lbl, nullptr);
// ClickableLabel only emits when the cursor is over it; if the
// offscreen platform can't track the cursor there's nothing to assert
QTest::mouseMove(lbl, QPoint(5, 5));
if (!lbl->underMouse()) {
GTEST_SKIP() << "Platform does not track cursor position";
}
OakEngineNode *received = nullptr;
QObject::connect(&w, &olive::NodeParamViewConnectedLabel::request_select_node,
[&received](OakEngineNode *n) { received = n; });
QTest::mouseClick(lbl, Qt::LeftButton);
EXPECT_EQ(received, reinterpret_cast<OakEngineNode *>(source));
}
// ---------------------------------------------------------------------------
// NodeParamViewKeyframeControl
// ---------------------------------------------------------------------------
TEST_F(NodeParamViewTest, KeyframeControlInvalidInputDisablesEverything)
{
olive::NodeParamViewKeyframeControl control;
EXPECT_FALSE(control.get_connected_input().is_valid());
// Both constructor forms build the same four buttons
olive::NodeParamViewKeyframeControl left_aligned(false, nullptr);
for (QWidget *c : { static_cast<QWidget *>(&control),
static_cast<QWidget *>(&left_aligned) }) {
const auto buttons = c->findChildren<QPushButton *>();
ASSERT_EQ(buttons.size(), 4);
int checkable_count = 0;
for (QPushButton *b : buttons) {
EXPECT_FALSE(b->isEnabled());
if (b->isCheckable()) {
checkable_count++;
}
}
// toggle + enable are checkable, prev/next are not
EXPECT_EQ(checkable_count, 2);
}
// With keyframing off only the enable button is shown
int visible_count = 0;
for (QPushButton *b : control.findChildren<QPushButton *>()) {
if (b->isVisibleTo(&control)) {
visible_count++;
EXPECT_TRUE(b->isCheckable());
EXPECT_FALSE(b->isChecked());
}
}
EXPECT_EQ(visible_count, 1);
}
TEST_F(NodeParamViewTest, KeyframeControlSetInputEnablesButtons)
{
auto *math = add_node<olive::MathNode>();
olive::NodeParamViewKeyframeControl control;
control.set_input(to_oak_input(math, olive::MathNode::k_param_a_in));
EXPECT_TRUE(control.get_connected_input().is_valid());
EXPECT_EQ(control.get_connected_input().input_id(),
olive::MathNode::k_param_a_in);
const auto buttons = control.findChildren<QPushButton *>();
ASSERT_EQ(buttons.size(), 4);
for (QPushButton *b : buttons) {
EXPECT_TRUE(b->isEnabled());
}
// Input is not keyframing yet: nav buttons stay hidden, enable unchecked
for (QPushButton *b : buttons) {
if (b->isCheckable() && b->isVisibleTo(&control)) {
EXPECT_FALSE(b->isChecked()); // the enable button
} else {
EXPECT_FALSE(b->isVisibleTo(&control)); // prev/toggle/next
}
}
}
TEST_F(NodeParamViewTest, KeyframeControlReflectsKeyframingChanges)
{
auto *math = add_node<olive::MathNode>();
olive::NodeParamViewKeyframeControl control;
control.set_input(to_oak_input(math, olive::MathNode::k_param_a_in));
// Identify the enable button while it is the only visible checkable one
QPushButton *enable_btn = nullptr;
for (QPushButton *b : control.findChildren<QPushButton *>()) {
if (b->isCheckable() && b->isVisibleTo(&control)) {
enable_btn = b;
break;
}
}
ASSERT_NE(enable_btn, nullptr);
EXPECT_FALSE(enable_btn->isChecked());
OakEngineNode *handle = reinterpret_cast<OakEngineNode *>(math);
const QByteArray input_id = olive::MathNode::k_param_a_in.toUtf8();
// Enabling keyframing through the facade fires the bridge event, which
// checks the enable button and reveals the navigation buttons
ASSERT_EQ(oakengine_node_set_input_keyframing(handle, input_id.constData(),
-1, 1, 0, 1, nullptr),
OAKENGINE_OK);
EXPECT_TRUE(enable_btn->isChecked());
for (QPushButton *b : control.findChildren<QPushButton *>()) {
EXPECT_TRUE(b->isVisibleTo(&control));
}
// Disabling hides the navigation buttons again
ASSERT_EQ(oakengine_node_set_input_keyframing(handle, input_id.constData(),
-1, 0, 0, 1, nullptr),
OAKENGINE_OK);
EXPECT_FALSE(enable_btn->isChecked());
for (QPushButton *b : control.findChildren<QPushButton *>()) {
EXPECT_EQ(b->isVisibleTo(&control), b == enable_btn);
}
}
// ---------------------------------------------------------------------------
// NodeParamViewArrayWidget / NodeParamViewArrayButton
// ---------------------------------------------------------------------------
TEST(WidgetNodeParamViewArrayButton, TypeDeterminesText)
{
olive::NodeParamViewArrayButton add(olive::NodeParamViewArrayButton::k_add);
EXPECT_EQ(add.text(), QStringLiteral("+"));
olive::NodeParamViewArrayButton remove(
olive::NodeParamViewArrayButton::k_remove);
EXPECT_EQ(remove.text(), QStringLiteral("-"));
}
TEST_F(NodeParamViewTest, ArrayWidgetCounterTracksArraySize)
{
auto *text = add_node<olive::TextGeneratorV3>();
olive::NodeParamViewArrayWidget w(to_oak(text),
olive::TextGeneratorV3::k_args_input);
auto *lbl = w.findChild<QLabel *>();
ASSERT_NE(lbl, nullptr);
EXPECT_EQ(lbl->text(), QStringLiteral("0 element(s)"));
OakEngineNode *handle = reinterpret_cast<OakEngineNode *>(text);
const QByteArray input_id = olive::TextGeneratorV3::k_args_input.toUtf8();
// The widget subscribes to the engine's array-size event via its bridge
ASSERT_EQ(oakengine_node_array_insert_at(handle, input_id.constData(), 0),
OAKENGINE_OK);
EXPECT_EQ(lbl->text(), QStringLiteral("1 element(s)"));
ASSERT_EQ(oakengine_node_array_insert_at(handle, input_id.constData(), 1),
OAKENGINE_OK);
EXPECT_EQ(lbl->text(), QStringLiteral("2 element(s)"));
ASSERT_EQ(oakengine_node_array_remove_at(handle, input_id.constData(), 0),
OAKENGINE_OK);
EXPECT_EQ(lbl->text(), QStringLiteral("1 element(s)"));
}
TEST_F(NodeParamViewTest, ArrayWidgetDoubleClickEmitsSignal)
{
auto *text = add_node<olive::TextGeneratorV3>();
olive::NodeParamViewArrayWidget w(to_oak(text),
olive::TextGeneratorV3::k_args_input);
QSignalSpy spy(&w, &olive::NodeParamViewArrayWidget::double_clicked);
send_mouse_event(&w, QEvent::MouseButtonDblClick, QPointF(2, 2));
EXPECT_EQ(spy.count(), 1);
}
// ---------------------------------------------------------------------------
// NodeParamViewContext
// ---------------------------------------------------------------------------
TEST_F(NodeParamViewTest, ContextDefaultConstruction)
{
olive::NodeParamViewContext ctx;
EXPECT_NE(ctx.get_dock_area(), nullptr);
EXPECT_TRUE(ctx.get_contexts().isEmpty());
EXPECT_TRUE(ctx.get_items().isEmpty());
}
TEST_F(NodeParamViewTest, ContextAddAndRemoveContexts)
{
auto *math = add_node<olive::MathNode>();
auto *folder = add_node<olive::Folder>();
olive::NodeParamViewContext ctx;
ctx.add_context(to_oak(math));
ctx.add_context(to_oak(folder));
ASSERT_EQ(ctx.get_contexts().size(), 2);
EXPECT_EQ(ctx.get_contexts().at(0), to_oak(math));
EXPECT_EQ(ctx.get_contexts().at(1), to_oak(folder));
ctx.remove_context(to_oak(math));
ASSERT_EQ(ctx.get_contexts().size(), 1);
EXPECT_EQ(ctx.get_contexts().first(), to_oak(folder));
// Removing something that isn't there is a no-op
ctx.remove_context(to_oak(math));
EXPECT_EQ(ctx.get_contexts().size(), 1);
}
TEST_F(NodeParamViewTest, ContextAddGetAndRemoveNodeItems)
{
auto *math = add_node<olive::MathNode>();
auto *folder = add_node<olive::Folder>();
olive::NodeParamViewContext ctx;
auto *item = new olive::NodeParamViewItem(to_oak(math),
olive::k_no_check_boxes, &ctx);
item->set_context(to_oak(folder));
ctx.add_node(item);
ASSERT_EQ(ctx.get_items().size(), 1);
EXPECT_EQ(ctx.get_items().first(), item);
// Lookup matches on (node, context) pair only
EXPECT_EQ(ctx.get_item(to_oak(math), to_oak(folder)), item);
EXPECT_EQ(ctx.get_item(to_oak(folder), to_oak(math)), nullptr);
EXPECT_EQ(ctx.get_item(to_oak(math), to_oak(math)), nullptr);
olive::NodeParamViewItem *about_to_delete = nullptr;
QObject::connect(
&ctx, &olive::NodeParamViewContext::about_to_delete_item,
[&about_to_delete](olive::NodeParamViewItem *i) {
about_to_delete = i;
});
// Removing a non-matching pair leaves the item alone
ctx.remove_node(to_oak(math), to_oak(math));
EXPECT_EQ(ctx.get_items().size(), 1);
EXPECT_EQ(about_to_delete, nullptr);
ctx.remove_node(to_oak(math), to_oak(folder));
EXPECT_EQ(about_to_delete, item);
EXPECT_TRUE(ctx.get_items().isEmpty());
EXPECT_EQ(ctx.get_item(to_oak(math), to_oak(folder)), nullptr);
}
TEST_F(NodeParamViewTest, ContextRemoveNodesWithContextRemovesOnlyMatches)
{
auto *math_a = add_node<olive::MathNode>();
auto *math_b = add_node<olive::MathNode>();
auto *folder_a = add_node<olive::Folder>();
auto *folder_b = add_node<olive::Folder>();
olive::NodeParamViewContext ctx;
auto *item_a = new olive::NodeParamViewItem(to_oak(math_a),
olive::k_no_check_boxes, &ctx);
item_a->set_context(to_oak(folder_a));
auto *item_b = new olive::NodeParamViewItem(to_oak(math_b),
olive::k_no_check_boxes, &ctx);
item_b->set_context(to_oak(folder_b));
ctx.add_node(item_a);
ctx.add_node(item_b);
ASSERT_EQ(ctx.get_items().size(), 2);
QVector<olive::NodeParamViewItem *> deleted;
QObject::connect(&ctx, &olive::NodeParamViewContext::about_to_delete_item,
[&deleted](olive::NodeParamViewItem *i) {
deleted.append(i);
});
ctx.remove_nodes_with_context(to_oak(folder_a));
ASSERT_EQ(deleted.size(), 1);
EXPECT_EQ(deleted.first(), item_a);
ASSERT_EQ(ctx.get_items().size(), 1);
EXPECT_EQ(ctx.get_items().first(), item_b);
EXPECT_EQ(ctx.get_item(to_oak(math_a), to_oak(folder_a)), nullptr);
EXPECT_EQ(ctx.get_item(to_oak(math_b), to_oak(folder_b)), item_b);
}
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -24,6 +24,7 @@
#include "render/rendermanager.h"
#include "oakengine/app.h"
#include "oakengine/undo.h"
#include "playback/playbackcontroller.h"
#include "task/task.h"
#include "undo/undostack.h"
#include "widget/colorbutton/colorbutton.h"
@@ -744,7 +745,10 @@ TEST_F(MulticamWidgetTest, FutureSwitchWaitsForPlayheadToAdvance)
widget.set_multicam_node(reinterpret_cast<OakEngineNode *>(viewer_b), reinterpret_cast<OakEngineNode *>(node), reinterpret_cast<OakEngineBlock *>(clip), Rational(5));
EXPECT_EQ(widget.get_connected_node(), reinterpret_cast<OakEngineNode *>(viewer_a));
// Once playback time advances, the queued switch takes effect
viewer_a->set_playhead(Rational(1));
// Once playback time advances, the queued switch takes effect. The app
// drives the playhead through PlaybackController, which is what
// TimeBasedWidget now listens to (EventBridge elimination, issue 6).
PlaybackController::instance()->set_playhead(
reinterpret_cast<OakEngineNode *>(viewer_a), Rational(1));
EXPECT_EQ(widget.get_connected_node(), reinterpret_cast<OakEngineNode *>(viewer_b));
}
@@ -0,0 +1,539 @@
#include <gtest/gtest.h>
#include <memory>
#include <QImage>
#include <QLabel>
#include <QPainter>
#include <QPushButton>
#include <QSignalSpy>
#include <QSlider>
#include <QStandardItemModel>
#include <QStringListModel>
#include <QStyleOptionViewItem>
#include <QTest>
#include "core.h"
#include "node/color/colormanager/colormanager.h"
#include "node/project.h"
#include "node/project/footage/footage.h"
#include "node/project/folder/folder.h"
#include "oakutil/define.h"
#include "render/diskmanager.h"
#include "widget/projectexplorer/projectexplorericonview.h"
#include "widget/projectexplorer/projectexplorericonviewitemdelegate.h"
#include "widget/projectexplorer/projectexplorerlistview.h"
#include "widget/projectexplorer/projectexplorerlistviewbase.h"
#include "widget/projectexplorer/projectexplorerlistviewitemdelegate.h"
#include "widget/projectexplorer/projectexplorernavigation.h"
#include "widget/projectexplorer/projectexplorertreeview.h"
using namespace olive;
namespace
{
// Undo commands that rename/move items go through app-wide singletons
void ensure_app_singletons()
{
if (!olive::Core::instance()) {
new olive::Core(); // intentionally leaked
}
if (!olive::DiskManager::instance()) {
olive::DiskManager::create_instance();
}
}
} // namespace
TEST(ProjectExplorerNavigation, DefaultsMatchDocumentedState)
{
ProjectExplorerNavigation nav(nullptr);
auto *dir_up = nav.findChild<QPushButton *>();
auto *label = nav.findChild<QLabel *>();
auto *slider = nav.findChild<QSlider *>();
ASSERT_NE(dir_up, nullptr);
ASSERT_NE(label, nullptr);
ASSERT_NE(slider, nullptr);
// Root folder assumption: no parent to go up to, no folder name
EXPECT_FALSE(dir_up->isEnabled());
EXPECT_TRUE(label->text().isEmpty());
// Slider spans the project icon size constants, defaulting to the default
EXPECT_EQ(slider->minimum(), k_project_icon_size_minimum);
EXPECT_EQ(slider->maximum(), k_project_icon_size_maximum);
EXPECT_EQ(slider->value(), k_project_icon_size_default);
EXPECT_EQ(slider->orientation(), Qt::Horizontal);
}
TEST(ProjectExplorerNavigation, SetTextUpdatesLabel)
{
ProjectExplorerNavigation nav(nullptr);
auto *label = nav.findChild<QLabel *>();
ASSERT_NE(label, nullptr);
nav.set_text(QStringLiteral("Media"));
EXPECT_EQ(label->text(), QStringLiteral("Media"));
nav.set_text(QString());
EXPECT_TRUE(label->text().isEmpty());
}
TEST(ProjectExplorerNavigation, DirUpButtonOnlyEmitsWhenEnabled)
{
ProjectExplorerNavigation nav(nullptr);
auto *dir_up = nav.findChild<QPushButton *>();
ASSERT_NE(dir_up, nullptr);
QSignalSpy spy(&nav, &ProjectExplorerNavigation::directory_up_clicked);
// Disabled button swallows clicks
dir_up->click();
EXPECT_EQ(spy.count(), 0);
nav.set_dir_up_enabled(true);
EXPECT_TRUE(dir_up->isEnabled());
dir_up->click();
EXPECT_EQ(spy.count(), 1);
// Disabling again silences it
nav.set_dir_up_enabled(false);
dir_up->click();
EXPECT_EQ(spy.count(), 1);
}
TEST(ProjectExplorerNavigation, SizeSliderEmitsSizeChanged)
{
ProjectExplorerNavigation nav(nullptr);
auto *slider = nav.findChild<QSlider *>();
ASSERT_NE(slider, nullptr);
QSignalSpy spy(&nav, &ProjectExplorerNavigation::size_changed);
slider->setValue(k_project_icon_size_minimum);
ASSERT_EQ(spy.count(), 1);
EXPECT_EQ(spy.first().first().toInt(), k_project_icon_size_minimum);
// The setter is implemented as a plain slider setValue, so despite the
// header comment claiming otherwise it does forward through the signal
nav.set_size_value(k_project_icon_size_maximum);
EXPECT_EQ(slider->value(), k_project_icon_size_maximum);
ASSERT_EQ(spy.count(), 2);
EXPECT_EQ(spy.at(1).first().toInt(), k_project_icon_size_maximum);
// Setting the same value again is a no-op, as QSlider only emits on change
nav.set_size_value(k_project_icon_size_maximum);
EXPECT_EQ(spy.count(), 2);
}
TEST(ProjectExplorerListViewBase, ConstructionDefaults)
{
ProjectExplorerListViewBase view(nullptr);
EXPECT_EQ(view.movement(), QListView::Free);
EXPECT_EQ(view.selectionMode(), QAbstractItemView::ExtendedSelection);
EXPECT_EQ(view.resizeMode(), QListView::Adjust);
EXPECT_EQ(view.contextMenuPolicy(), Qt::CustomContextMenu);
}
TEST(ProjectExplorerListViewBase, DoubleClickEmptyAreaEmitsSignal)
{
QStringListModel model({ QStringLiteral("One"), QStringLiteral("Two"),
QStringLiteral("Three") });
ProjectExplorerListViewBase view(nullptr);
view.setModel(&model);
view.resize(400, 300);
view.show();
ASSERT_TRUE(QTest::qWaitForWindowExposed(&view));
const QModelIndex first = model.index(0, 0);
const QRect item_rect = view.visualRect(first);
if (!item_rect.isValid()) {
GTEST_SKIP() << "View did not lay out items under the offscreen platform";
}
int hits = 0;
QObject::connect(&view,
&ProjectExplorerListViewBase::double_clicked_empty_area,
[&hits] { ++hits; });
// Double clicking an item is not an empty-area click
QTest::mouseDClick(view.viewport(), Qt::LeftButton, Qt::NoModifier,
item_rect.center());
EXPECT_EQ(hits, 0);
// Double clicking below the last item is
const QPoint empty(view.viewport()->width() - 4,
view.viewport()->height() - 4);
if (view.indexAt(empty).isValid()) {
GTEST_SKIP() << "Could not find an item-free spot in the viewport";
}
QTest::mouseDClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, empty);
EXPECT_EQ(hits, 1);
}
TEST(ProjectExplorerListViewBase, CtrlClickExtendsSelection)
{
QStringListModel model({ QStringLiteral("One"), QStringLiteral("Two"),
QStringLiteral("Three") });
ProjectExplorerListViewBase view(nullptr);
view.setModel(&model);
view.resize(400, 300);
view.show();
ASSERT_TRUE(QTest::qWaitForWindowExposed(&view));
const QModelIndex first = model.index(0, 0);
const QModelIndex third = model.index(2, 0);
const QRect first_rect = view.visualRect(first);
const QRect third_rect = view.visualRect(third);
if (!first_rect.isValid() || !third_rect.isValid()) {
GTEST_SKIP() << "View did not lay out items under the offscreen platform";
}
// Plain click selects exactly one item and makes it current
QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::NoModifier,
first_rect.center());
EXPECT_EQ(view.selectionModel()->currentIndex(), first);
EXPECT_EQ(view.selectionModel()->selectedIndexes().size(), 1);
// ExtendedSelection: Ctrl+click adds to the selection instead of replacing
QTest::mouseClick(view.viewport(), Qt::LeftButton, Qt::ControlModifier,
third_rect.center());
const QModelIndexList selected = view.selectionModel()->selectedIndexes();
EXPECT_EQ(selected.size(), 2);
EXPECT_TRUE(selected.contains(first));
EXPECT_TRUE(selected.contains(third));
}
TEST(ProjectExplorerListView, UsesListModeAndItemDelegate)
{
ProjectExplorerListView view(nullptr);
EXPECT_EQ(view.viewMode(), QListView::ListMode);
// The delegate classes carry no Q_OBJECT, so cast with RTTI
EXPECT_NE(dynamic_cast<ProjectExplorerListViewItemDelegate *>(
view.itemDelegate()),
nullptr);
// Inherits the base view behavior
EXPECT_EQ(view.selectionMode(), QAbstractItemView::ExtendedSelection);
EXPECT_EQ(view.contextMenuPolicy(), Qt::CustomContextMenu);
}
TEST(ProjectExplorerIconView, UsesIconModeAndItemDelegate)
{
ProjectExplorerIconView view(nullptr);
EXPECT_EQ(view.viewMode(), QListView::IconMode);
EXPECT_NE(dynamic_cast<ProjectExplorerIconViewItemDelegate *>(
view.itemDelegate()),
nullptr);
EXPECT_EQ(view.selectionMode(), QAbstractItemView::ExtendedSelection);
}
TEST(ProjectExplorerTreeView, ConstructionDefaultsEnableDragDrop)
{
ProjectExplorerTreeView view(nullptr);
EXPECT_EQ(view.selectionMode(), QAbstractItemView::ExtendedSelection);
EXPECT_EQ(view.dragDropMode(), QAbstractItemView::DragDrop);
EXPECT_TRUE(view.dragEnabled());
EXPECT_TRUE(view.acceptDrops());
EXPECT_EQ(view.contextMenuPolicy(), Qt::CustomContextMenu);
}
TEST(ProjectExplorerTreeView, DoubleClickEmptyAreaEmitsSignal)
{
QStringListModel model({ QStringLiteral("One"), QStringLiteral("Two"),
QStringLiteral("Three") });
ProjectExplorerTreeView view(nullptr);
view.setModel(&model);
view.resize(400, 300);
view.show();
ASSERT_TRUE(QTest::qWaitForWindowExposed(&view));
const QModelIndex first = model.index(0, 0);
const QRect item_rect = view.visualRect(first);
if (!item_rect.isValid()) {
GTEST_SKIP() << "View did not lay out items under the offscreen platform";
}
int hits = 0;
QObject::connect(&view, &ProjectExplorerTreeView::double_clicked_empty_area,
[&hits] { ++hits; });
QTest::mouseDClick(view.viewport(), Qt::LeftButton, Qt::NoModifier,
item_rect.center());
EXPECT_EQ(hits, 0);
const QPoint empty(view.viewport()->width() - 4,
view.viewport()->height() - 4);
if (view.indexAt(empty).isValid()) {
GTEST_SKIP() << "Could not find an item-free spot in the viewport";
}
QTest::mouseDClick(view.viewport(), Qt::LeftButton, Qt::NoModifier, empty);
EXPECT_EQ(hits, 1);
}
TEST(ProjectExplorerListViewItemDelegate, SizeHintIsSquareFromDecorationHeight)
{
ProjectExplorerListViewItemDelegate delegate;
QStyleOptionViewItem opt;
opt.decorationSize = QSize(10, 32);
EXPECT_EQ(delegate.sizeHint(opt, QModelIndex()), QSize(32, 32));
opt.decorationSize = QSize(64, 20);
EXPECT_EQ(delegate.sizeHint(opt, QModelIndex()), QSize(20, 20));
}
TEST(ProjectExplorerListViewItemDelegate, PaintFillsHighlightWhenSelected)
{
QStandardItemModel model;
model.appendRow(new QStandardItem(QStringLiteral("Clip")));
QStyleOptionViewItem opt;
opt.rect = QRect(0, 0, 200, 24);
opt.decorationSize = QSize(24, 24);
ProjectExplorerListViewItemDelegate delegate;
// Selected rows are filled with the palette highlight
QImage selected_img(200, 24, QImage::Format_ARGB32);
selected_img.fill(Qt::transparent);
opt.state = QStyle::State_Enabled | QStyle::State_Selected;
{
QPainter p(&selected_img);
delegate.paint(&p, opt, model.index(0, 0));
}
EXPECT_EQ(selected_img.pixelColor(0, 0).rgb(),
opt.palette.highlight().color().rgb());
// Unselected rows paint nothing into the icon area (no icon set)
QImage plain_img(200, 24, QImage::Format_ARGB32);
plain_img.fill(Qt::transparent);
opt.state = QStyle::State_Enabled;
{
QPainter p(&plain_img);
delegate.paint(&p, opt, model.index(0, 0));
}
EXPECT_EQ(plain_img.pixelColor(0, 0).alpha(), 0);
}
TEST(ProjectExplorerIconViewItemDelegate, SizeHintIsFixed)
{
ProjectExplorerIconViewItemDelegate delegate;
QStyleOptionViewItem opt;
opt.decorationSize = QSize(16, 16);
// Always 256x256 regardless of the option
EXPECT_EQ(delegate.sizeHint(opt, QModelIndex()), QSize(256, 256));
EXPECT_EQ(delegate.sizeHint(QStyleOptionViewItem(), QModelIndex()),
QSize(256, 256));
}
TEST(ProjectExplorerIconViewItemDelegate, PaintDrawsTextBand)
{
QStandardItemModel model;
model.appendRow(new QStandardItem(QStringLiteral("Clip")));
const QModelIndex index = model.index(0, 0);
model.setData(index, QStringLiteral("00:00:01:00"), Qt::UserRole);
ProjectExplorerIconViewItemDelegate delegate;
QStyleOptionViewItem opt;
opt.rect = QRect(0, 0, 256, 256);
// The text band occupies the bottom fm.height() rows of the cell; the
// painter over a bare QImage uses the default application font
const QFontMetrics fm((QFont()));
const int band_top = opt.rect.height() - fm.height();
ASSERT_GT(opt.rect.height() / 2, fm.height());
// Unselected: band background is white
QImage plain_img(256, 256, QImage::Format_ARGB32);
plain_img.fill(Qt::transparent);
opt.state = QStyle::State_Enabled;
{
QPainter p(&plain_img);
delegate.paint(&p, opt, index);
}
EXPECT_EQ(plain_img.pixelColor(opt.rect.width() / 2, band_top).rgb(),
QColor(Qt::white).rgb());
// Selected: band background follows the palette highlight
QImage selected_img(256, 256, QImage::Format_ARGB32);
selected_img.fill(Qt::transparent);
opt.state = QStyle::State_Enabled | QStyle::State_Selected;
{
QPainter p(&selected_img);
delegate.paint(&p, opt, index);
}
EXPECT_EQ(selected_img.pixelColor(opt.rect.width() / 2, band_top).rgb(),
opt.palette.highlight().color().rgb());
}
class ProjectExplorerUndoTest : public ::testing::Test {
protected:
void SetUp() override
{
ColorManager::set_up_default_config();
ensure_app_singletons();
project_ = std::make_unique<Project>();
project_->initialize();
}
template <typename T> T *make_node()
{
auto *node = new T();
node->setParent(project_.get());
return node;
}
std::unique_ptr<Project> project_;
};
TEST_F(ProjectExplorerUndoTest, FolderAddChildRedoUndoRoundTrips)
{
Folder *root = project_->root();
Footage *footage = make_node<Footage>();
ASSERT_EQ(root->item_child_count(), 0);
ASSERT_EQ(footage->folder(), nullptr);
FolderAddChild cmd(root, footage);
EXPECT_EQ(cmd.get_relevant_project(), project_.get());
cmd.redo_now();
EXPECT_EQ(root->item_child_count(), 1);
EXPECT_EQ(root->item_child(0), footage);
EXPECT_EQ(footage->folder(), root);
// UndoCommand guards on a done flag: a second redo is a no-op
cmd.redo_now();
EXPECT_EQ(root->item_child_count(), 1);
cmd.undo_now();
EXPECT_EQ(root->item_child_count(), 0);
EXPECT_EQ(footage->folder(), nullptr);
// Likewise a second undo does nothing
cmd.undo_now();
EXPECT_EQ(root->item_child_count(), 0);
}
TEST_F(ProjectExplorerUndoTest, FolderAddChildEmitsFolderInsertRemoveSignals)
{
Folder *root = project_->root();
Footage *footage = make_node<Footage>();
// The signal payload carries Node*, which is not a registered metatype,
// so count with lambdas rather than QSignalSpy
int insert_began = 0, insert_ended = 0, remove_began = 0, remove_ended = 0;
Node *inserted_node = nullptr;
int inserted_index = -1;
QObject::connect(root, &Folder::begin_insert_item,
[&](Node *n, int index) {
++insert_began;
inserted_node = n;
inserted_index = index;
});
QObject::connect(root, &Folder::end_insert_item,
[&] { ++insert_ended; });
QObject::connect(root, &Folder::begin_remove_item,
[&](Node *, int) { ++remove_began; });
QObject::connect(root, &Folder::end_remove_item,
[&] { ++remove_ended; });
FolderAddChild cmd(root, footage);
cmd.redo_now();
EXPECT_EQ(insert_began, 1);
EXPECT_EQ(insert_ended, 1);
EXPECT_EQ(inserted_node, footage);
EXPECT_EQ(inserted_index, 0);
EXPECT_EQ(remove_began, 0);
cmd.undo_now();
EXPECT_EQ(remove_began, 1);
EXPECT_EQ(remove_ended, 1);
EXPECT_EQ(insert_began, 1);
}
TEST_F(ProjectExplorerUndoTest, RemoveElementCommandRedoUndoRoundTrips)
{
Folder *root = project_->root();
Footage *a = make_node<Footage>();
Footage *b = make_node<Footage>();
FolderAddChild(root, a).redo_now();
FolderAddChild(root, b).redo_now();
ASSERT_EQ(root->item_child_count(), 2);
Folder::RemoveElementCommand cmd(root, a);
EXPECT_EQ(cmd.get_relevant_project(), project_.get());
cmd.redo_now();
EXPECT_EQ(root->item_child_count(), 1);
EXPECT_EQ(root->index_of_child(a), -1);
EXPECT_EQ(a->folder(), nullptr);
EXPECT_EQ(b->folder(), root);
cmd.undo_now();
EXPECT_EQ(root->item_child_count(), 2);
EXPECT_NE(root->index_of_child(a), -1);
EXPECT_EQ(a->folder(), root);
EXPECT_EQ(b->folder(), root);
}
TEST_F(ProjectExplorerUndoTest, RemoveElementCommandOnNonChildIsNoOp)
{
Folder *root = project_->root();
Footage *stray = make_node<Footage>();
ASSERT_EQ(root->item_child_count(), 0);
// The child was never connected to the folder, so the command finds no
// array index and both directions do nothing
Folder::RemoveElementCommand cmd(root, stray);
cmd.redo_now();
EXPECT_EQ(root->item_child_count(), 0);
EXPECT_EQ(stray->folder(), nullptr);
cmd.undo_now();
EXPECT_EQ(root->item_child_count(), 0);
}
TEST_F(ProjectExplorerUndoTest, NestedFolderAddChildTracksHierarchy)
{
Folder *root = project_->root();
Folder *folder = make_node<Folder>();
Footage *footage = make_node<Footage>();
FolderAddChild add_folder(root, folder);
add_folder.redo_now();
FolderAddChild add_footage(folder, footage);
add_footage.redo_now();
EXPECT_EQ(root->item_child_count(), 1);
EXPECT_EQ(folder->item_child_count(), 1);
EXPECT_TRUE(root->has_child_recursive(footage));
EXPECT_EQ(footage->folder(), folder);
// Undoing the inner add leaves the folder in place but empty
add_footage.undo_now();
EXPECT_EQ(folder->item_child_count(), 0);
EXPECT_EQ(footage->folder(), nullptr);
EXPECT_EQ(folder->folder(), root);
add_folder.undo_now();
EXPECT_EQ(root->item_child_count(), 0);
EXPECT_EQ(folder->folder(), nullptr);
}
+589
View File
@@ -0,0 +1,589 @@
#include <gtest/gtest.h>
#include <memory>
#include <QAction>
#include <QSignalSpy>
#include <QTest>
#include "config/config.h"
#include "node/color/colormanager/colormanager.h"
#include "node/project.h"
#include "widget/colorwheel/colorswatchwidget.h"
#include "widget/manageddisplay/manageddisplay.h"
#include "widget/menu/menu.h"
#include "widget/scope/histogram/histogram.h"
#include "widget/scope/scopebase/scopebase.h"
#include "widget/scope/vectorscope/vectorscope.h"
#include "widget/scope/waveform/waveform.h"
namespace
{
// ManagedDisplayWidget is abstract (on_paint), so a minimal concrete probe is
// needed even for tests that only exercise the base-class behavior
class ProbeDisplayWidget : public olive::ManagedDisplayWidget {
public:
using olive::ManagedDisplayWidget::ManagedDisplayWidget;
void *pub_renderer() const
{
return renderer();
}
bool pub_backend_neutral() const
{
return is_backend_neutral();
}
int processor_changed_events = 0;
protected:
virtual void on_paint() override
{
}
virtual void color_processor_changed_event() override
{
processor_changed_events++;
olive::ManagedDisplayWidget::color_processor_changed_event();
}
};
// Exposes the protected renderer state of the concrete scopes so tests can
// verify construction wiring without a GL context
class ProbeHistogramScope : public olive::HistogramScope {
public:
void *pub_renderer() const
{
return renderer();
}
bool pub_backend_neutral() const
{
return is_backend_neutral();
}
};
class ProbeWaveformScope : public olive::WaveformScope {
public:
void *pub_renderer() const
{
return renderer();
}
bool pub_backend_neutral() const
{
return is_backend_neutral();
}
oak_video_params pub_viewport_params() const
{
return get_viewport_params();
}
};
class ProbeVectorscopeScope : public olive::VectorscopeScope {
public:
void *pub_renderer() const
{
return renderer();
}
bool pub_backend_neutral() const
{
return is_backend_neutral();
}
};
// ColorSwatchWidget is abstract (get_color_from_screen_pos); this probe maps
// screen position deterministically to a color and records the change events
class ProbeSwatchWidget : public olive::ColorSwatchWidget {
public:
olive::Color pub_managed_color(const olive::Color &c) const
{
return get_managed_color(c);
}
Qt::GlobalColor pub_selector_color() const
{
return get_ui_selector_color();
}
int changed_events = 0;
bool last_external = false;
protected:
virtual olive::Color get_color_from_screen_pos(const QPoint &p) const override
{
return olive::Color(p.x() / 100.0, p.y() / 100.0, 0.5);
}
virtual void SelectedColorChangedEvent(const olive::Color &c,
bool external) override
{
changed_events++;
last_external = external;
olive::ColorSwatchWidget::SelectedColorChangedEvent(c, external);
}
};
// Returns the index of the first action whose data differs from the current
// transform component, or -1 when the menu offers no alternative
int alternative_action_index(olive::Menu *menu, const QString &current)
{
const QList<QAction *> acts = menu->actions();
for (int i = 0; i < acts.size(); i++) {
if (acts.at(i)->data().toString() != current) {
return i;
}
}
return -1;
}
} // namespace
TEST(WidgetManagedDisplay, DefaultConstructionState)
{
ProbeDisplayWidget w;
EXPECT_EQ(w.color_manager(), nullptr);
EXPECT_NE(w.pub_renderer(), nullptr);
// No transform has been chosen yet
const oak::ColorTransform &t = w.get_color_transform();
EXPECT_FALSE(t.is_display());
EXPECT_TRUE(t.output().isEmpty());
EXPECT_TRUE(t.view().isEmpty());
EXPECT_TRUE(t.look().isEmpty());
EXPECT_EQ(w.processor_changed_events, 0);
}
TEST(WidgetManagedDisplay, SetColorTransformWithoutManagerRoundTrips)
{
ProbeDisplayWidget w;
// ColorProcessorHandlePtr is not a registered metatype, so count the
// signal through a direct lambda connection instead of QSignalSpy
int signal_count = 0;
olive::ColorProcessorHandlePtr last_processor;
QObject::connect(&w, &olive::ManagedDisplayWidget::color_processor_changed,
[&signal_count, &last_processor](
olive::ColorProcessorHandlePtr p) {
signal_count++;
last_processor = p;
});
w.set_color_transform(
oak::ColorTransform(QStringLiteral("MyDisplay"),
QStringLiteral("MyView"), QStringLiteral("MyLook")));
const oak::ColorTransform &t = w.get_color_transform();
EXPECT_TRUE(t.is_display());
EXPECT_EQ(t.display(), QStringLiteral("MyDisplay"));
EXPECT_EQ(t.view(), QStringLiteral("MyView"));
EXPECT_EQ(t.look(), QStringLiteral("MyLook"));
// With no color manager connected, the processor is cleared and the
// change is signalled once
EXPECT_EQ(signal_count, 1);
EXPECT_EQ(last_processor, nullptr);
EXPECT_EQ(w.processor_changed_events, 1);
// A plain colorspace transform stores only the output name
w.set_color_transform(oak::ColorTransform(QStringLiteral("ACEScg")));
EXPECT_FALSE(w.get_color_transform().is_display());
EXPECT_EQ(w.get_color_transform().output(), QStringLiteral("ACEScg"));
EXPECT_EQ(signal_count, 2);
}
TEST(WidgetManagedDisplay, ConnectColorManagerDefaultsToDisplayViewTransform)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
ProbeDisplayWidget w;
int manager_signals = 0;
OakEngineColorManager *last_manager = nullptr;
QObject::connect(&w, &olive::ManagedDisplayWidget::color_manager_changed,
[&manager_signals,
&last_manager](OakEngineColorManager *m) {
manager_signals++;
last_manager = m;
});
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
EXPECT_EQ(w.color_manager(),
olive::oak_color_manager(project.color_manager()));
EXPECT_EQ(manager_signals, 1);
EXPECT_EQ(last_manager, olive::oak_color_manager(project.color_manager()));
// An empty transform is conformed to the config's default display/view so
// the widget would show a sensible image
const oak::ColorTransform &t = w.get_color_transform();
EXPECT_TRUE(t.is_display());
EXPECT_FALSE(t.display().isEmpty());
EXPECT_FALSE(t.view().isEmpty());
// Connecting the same manager again is a no-op
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
EXPECT_EQ(manager_signals, 1);
}
TEST(WidgetManagedDisplay, DisconnectColorManagerClearsState)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
ProbeDisplayWidget w;
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
int manager_signals = 0;
OakEngineColorManager *last_manager =
olive::oak_color_manager(project.color_manager());
QObject::connect(&w, &olive::ManagedDisplayWidget::color_manager_changed,
[&manager_signals,
&last_manager](OakEngineColorManager *m) {
manager_signals++;
last_manager = m;
});
w.disconnect_color_manager();
EXPECT_EQ(w.color_manager(), nullptr);
EXPECT_EQ(manager_signals, 1);
EXPECT_EQ(last_manager, nullptr);
}
TEST(WidgetManagedDisplay, DisplayMenuReflectsCurrentTransform)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
ProbeDisplayWidget w;
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
std::unique_ptr<olive::Menu> menu(w.get_display_menu(nullptr));
ASSERT_FALSE(menu->actions().isEmpty());
// Exactly the current display is checked
int checked = 0;
for (QAction *a : menu->actions()) {
EXPECT_TRUE(a->isCheckable());
EXPECT_EQ(a->data().toString(), a->text());
if (a->isChecked()) {
checked++;
EXPECT_EQ(a->data().toString(), w.get_color_transform().display());
}
}
EXPECT_EQ(checked, 1);
// Picking another display (if the config has one) retargets the transform
const int alt =
alternative_action_index(menu.get(), w.get_color_transform().display());
if (alt >= 0) {
const QString target = menu->actions().at(alt)->data().toString();
menu->actions().at(alt)->trigger();
EXPECT_EQ(w.get_color_transform().display(), target);
}
}
TEST(WidgetManagedDisplay, ViewMenuReflectsCurrentTransform)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
ProbeDisplayWidget w;
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
std::unique_ptr<olive::Menu> menu(w.get_view_menu(nullptr));
ASSERT_FALSE(menu->actions().isEmpty());
int checked = 0;
for (QAction *a : menu->actions()) {
EXPECT_TRUE(a->isCheckable());
if (a->isChecked()) {
checked++;
EXPECT_EQ(a->data().toString(), w.get_color_transform().view());
}
}
EXPECT_EQ(checked, 1);
const int alt =
alternative_action_index(menu.get(), w.get_color_transform().view());
if (alt >= 0) {
const QString target = menu->actions().at(alt)->data().toString();
menu->actions().at(alt)->trigger();
EXPECT_EQ(w.get_color_transform().view(), target);
// The display component survives a view change
EXPECT_FALSE(w.get_color_transform().display().isEmpty());
}
}
TEST(WidgetManagedDisplay, LookMenuStartsWithNoLookEntry)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
ProbeDisplayWidget w;
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
std::unique_ptr<olive::Menu> menu(w.get_look_menu(nullptr));
ASSERT_FALSE(menu->actions().isEmpty());
// The first entry is always the "(None)" entry carrying empty data
QAction *none = menu->actions().first();
EXPECT_TRUE(none->isCheckable());
EXPECT_EQ(none->data().toString(), QString());
// The default transform has no look, so "(None)" is checked
EXPECT_TRUE(w.get_color_transform().look().isEmpty());
EXPECT_TRUE(none->isChecked());
for (int i = 1; i < menu->actions().size(); i++) {
EXPECT_TRUE(menu->actions().at(i)->isCheckable());
EXPECT_EQ(menu->actions().at(i)->data().toString(),
menu->actions().at(i)->text());
}
}
TEST(WidgetManagedDisplay, ColorSpaceMenuListsConfigSpaces)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
ProbeDisplayWidget w;
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
const QStringList spaces =
project.color_manager()->list_available_colorspaces();
ASSERT_GE(spaces.size(), 2);
std::unique_ptr<olive::Menu> menu(w.get_color_space_menu(nullptr));
ASSERT_EQ(menu->actions().size(), spaces.size());
for (int i = 0; i < spaces.size(); i++) {
EXPECT_EQ(menu->actions().at(i)->data().toString(), spaces.at(i));
}
// Selecting a colorspace switches to a non-display transform
const int alt =
alternative_action_index(menu.get(), w.get_color_transform().output());
if (alt >= 0) {
const QString target = menu->actions().at(alt)->data().toString();
menu->actions().at(alt)->trigger();
EXPECT_EQ(w.get_color_transform().output(), target);
}
}
TEST(WidgetScopeBase, ViewportParamsTrackWidgetGeometry)
{
ProbeWaveformScope w;
w.resize(640, 360);
const oak_video_params vp = w.pub_viewport_params();
EXPECT_EQ(vp.width, int(640 * w.devicePixelRatioF()));
EXPECT_EQ(vp.height, int(360 * w.devicePixelRatioF()));
EXPECT_EQ(vp.format,
olive::Config::current()[QStringLiteral("OfflinePixelFormat")]
.toInt());
}
TEST(WidgetHistogramScope, ConstructionCreatesRendererWithoutTexture)
{
ProbeHistogramScope w;
// The renderer abstraction exists right after construction, but nothing
// color-related is connected yet
EXPECT_NE(w.pub_renderer(), nullptr);
EXPECT_EQ(w.color_manager(), nullptr);
EXPECT_TRUE(w.get_color_transform().output().isEmpty());
// A null buffer is accepted and simply schedules a repaint; there is no
// texture to retain
w.set_buffer(nullptr);
w.set_buffer(nullptr);
EXPECT_EQ(w.color_manager(), nullptr);
}
TEST(WidgetHistogramScope, ConnectColorManagerDefaultsTransform)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
ProbeHistogramScope w;
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
EXPECT_EQ(w.color_manager(),
olive::oak_color_manager(project.color_manager()));
EXPECT_TRUE(w.get_color_transform().is_display());
EXPECT_FALSE(w.get_color_transform().display().isEmpty());
EXPECT_FALSE(w.get_color_transform().view().isEmpty());
}
TEST(WidgetVectorscopeScope, ConstructionCreatesRenderer)
{
ProbeVectorscopeScope w;
EXPECT_NE(w.pub_renderer(), nullptr);
EXPECT_EQ(w.color_manager(), nullptr);
// Null buffers are tolerated without a connected manager
w.set_buffer(nullptr);
}
TEST(WidgetVectorscopeScope, ConnectColorManagerDefaultsTransform)
{
olive::ColorManager::set_up_default_config();
olive::Project project;
ProbeVectorscopeScope w;
w.connect_color_manager(olive::oak_color_manager(project.color_manager()));
EXPECT_TRUE(w.get_color_transform().is_display());
EXPECT_FALSE(w.get_color_transform().display().isEmpty());
}
TEST(WidgetWaveformScope, ParadeModeFollowsConfig)
{
const QVariant old =
olive::Config::current()[QStringLiteral("WaveformRgbParade")];
olive::Config::current()[QStringLiteral("WaveformRgbParade")] = true;
{
ProbeWaveformScope w;
EXPECT_TRUE(w.parade_mode());
EXPECT_NE(w.pub_renderer(), nullptr);
}
olive::Config::current()[QStringLiteral("WaveformRgbParade")] = false;
{
ProbeWaveformScope w;
EXPECT_FALSE(w.parade_mode());
}
olive::Config::current()[QStringLiteral("WaveformRgbParade")] = old;
}
TEST(WidgetWaveformScope, SetParadeModePersistsToConfig)
{
const QVariant old =
olive::Config::current()[QStringLiteral("WaveformRgbParade")];
{
ProbeWaveformScope w;
w.set_parade_mode(true);
EXPECT_TRUE(w.parade_mode());
EXPECT_TRUE(
olive::Config::current()[QStringLiteral("WaveformRgbParade")]
.toBool());
w.set_parade_mode(false);
EXPECT_FALSE(w.parade_mode());
EXPECT_FALSE(
olive::Config::current()[QStringLiteral("WaveformRgbParade")]
.toBool());
}
// A newly constructed scope picks the persisted value up
{
ProbeWaveformScope w;
EXPECT_FALSE(w.parade_mode());
}
olive::Config::current()[QStringLiteral("WaveformRgbParade")] = old;
}
TEST(WidgetColorSwatchWidget, SetSelectedColorRoundTrips)
{
ProbeSwatchWidget w;
w.set_selected_color(olive::Color(0.2, 0.4, 0.6));
EXPECT_FLOAT_EQ(w.get_selected_color().red(), 0.2f);
EXPECT_FLOAT_EQ(w.get_selected_color().green(), 0.4f);
EXPECT_FLOAT_EQ(w.get_selected_color().blue(), 0.6f);
// Programmatic changes count as external
EXPECT_EQ(w.changed_events, 1);
EXPECT_TRUE(w.last_external);
}
TEST(WidgetColorSwatchWidget, MousePressPicksColorAndEmits)
{
ProbeSwatchWidget w;
w.resize(100, 100);
// olive::Color is not a registered metatype in this harness, so capture
// the signal payload through a lambda
QVector<olive::Color> received;
QObject::connect(&w, &olive::ColorSwatchWidget::selected_color_changed,
[&received](const olive::Color &c) { received.append(c); });
QTest::mouseClick(&w, Qt::LeftButton, Qt::NoModifier, QPoint(10, 20));
ASSERT_EQ(received.size(), 1);
EXPECT_NEAR(received.first().red(), 0.10, 1e-6);
EXPECT_NEAR(received.first().green(), 0.20, 1e-6);
EXPECT_NEAR(received.first().blue(), 0.5, 1e-6);
// User interaction is reported as non-external
EXPECT_EQ(w.changed_events, 1);
EXPECT_FALSE(w.last_external);
EXPECT_NEAR(w.get_selected_color().red(), 0.10, 1e-6);
EXPECT_NEAR(w.get_selected_color().green(), 0.20, 1e-6);
}
TEST(WidgetColorSwatchWidget, DragEmitsForEachMove)
{
ProbeSwatchWidget w;
w.resize(100, 100);
QVector<olive::Color> received;
QObject::connect(&w, &olive::ColorSwatchWidget::selected_color_changed,
[&received](const olive::Color &c) { received.append(c); });
QTest::mousePress(&w, Qt::LeftButton, Qt::NoModifier, QPoint(10, 10));
ASSERT_EQ(received.size(), 1);
// A move while the left button is held updates the color...
QTest::mouseMove(&w, QPoint(30, 40));
ASSERT_EQ(received.size(), 2);
EXPECT_NEAR(received.at(1).red(), 0.30, 1e-6);
EXPECT_NEAR(received.at(1).green(), 0.40, 1e-6);
QTest::mouseRelease(&w, Qt::LeftButton, Qt::NoModifier, QPoint(30, 40));
EXPECT_EQ(received.size(), 2);
// ...but a bare move without a pressed button is ignored
QTest::mouseMove(&w, QPoint(50, 50));
EXPECT_EQ(received.size(), 2);
}
TEST(WidgetColorSwatchWidget, ManagedColorPassesThroughWithoutProcessors)
{
ProbeSwatchWidget w;
// With no color processors installed, the managed color is the input
const olive::Color in(0.1, 0.2, 0.3, 1.0);
const olive::Color out = w.pub_managed_color(in);
EXPECT_FLOAT_EQ(out.red(), 0.1f);
EXPECT_FLOAT_EQ(out.green(), 0.2f);
EXPECT_FLOAT_EQ(out.blue(), 0.3f);
// Setting null processors still forces a full external refresh
w.set_color_processor(nullptr, nullptr);
EXPECT_TRUE(w.last_external);
}
TEST(WidgetColorSwatchWidget, SelectorColorFollowsLuminance)
{
ProbeSwatchWidget w;
// Bright swatches get a black selector, dark swatches a white one
w.set_selected_color(olive::Color(1.0, 1.0, 1.0));
EXPECT_EQ(w.pub_selector_color(), Qt::black);
w.set_selected_color(olive::Color(0.0, 0.0, 0.0));
EXPECT_EQ(w.pub_selector_color(), Qt::white);
}
+436
View File
@@ -0,0 +1,436 @@
#include <gtest/gtest.h>
#include <QApplication>
#include <QLabel>
#include <QSignalSpy>
#include "common/configwrapper.h"
#include "widget/focusablelineedit/focusablelineedit.h"
#include "widget/slider/base/numericsliderbase.h"
#include "widget/slider/base/sliderlabel.h"
#include "widget/slider/base/sliderladder.h"
#include "widget/slider/sliderdisplaytypeapp.h"
namespace
{
// NumericSliderBase is abstract (SliderBase's value_to_string /
// string_to_value / value_signal_event are pure), so test it through a
// minimal concrete probe that also exposes the protected entry points
class ProbeNumericSlider : public olive::NumericSliderBase {
public:
void set_value(const QVariant &v)
{
set_value_internal(v);
}
QVariant get_value() const
{
return get_value_internal();
}
QVariant get_offset_public() const
{
return get_offset();
}
QVariant adjust_drag_public(const QVariant &start, const double &drag) const
{
return adjust_drag_distance_internal(start, drag);
}
bool greater_public(const QVariant &lhs, const QVariant &rhs) const
{
return value_greater_than(lhs, rhs);
}
bool less_public(const QVariant &lhs, const QVariant &rhs) const
{
return value_less_than(lhs, rhs);
}
bool can_set_value_public() const
{
return can_set_value();
}
void set_minimum_public(const QVariant &v)
{
set_minimum_internal(v);
}
void set_maximum_public(const QVariant &v)
{
set_maximum_internal(v);
}
QVector<QVariant> signaled;
protected:
virtual QString value_to_string(const QVariant &v) const override
{
return QString::number(v.toDouble());
}
virtual QVariant string_to_value(const QString &s, bool *ok) const override
{
return s.toDouble(ok);
}
virtual void value_signal_event(const QVariant &value) override
{
signaled.append(value);
}
};
// Forces ladder mode on for the duration of a test. Ladder mode keeps the
// SliderLadder away from the macOS cursor-grab path
// (CGAssociateMouseAndMouseCursorPosition), which would otherwise decouple
// the host's real cursor while a ladder widget exists
struct LadderConfigGuard {
LadderConfigGuard()
: old_(OAK_CONFIG("UseSliderLadders").toBool())
{
OAK_CONFIG("UseSliderLadders") = true;
}
~LadderConfigGuard()
{
OAK_CONFIG("UseSliderLadders") = old_;
}
bool old_;
};
// The drag ladder is an orphan top-level popup; dig it out of the
// application's top-level widget list
olive::SliderLadder *find_ladder()
{
const QWidgetList tops = QApplication::topLevelWidgets();
for (QWidget *w : tops) {
if (auto *l = qobject_cast<olive::SliderLadder *>(w)) {
return l;
}
}
return nullptr;
}
// Starts a ladder drag on the slider by emitting its label's press signal
void press_label(ProbeNumericSlider &s)
{
olive::SliderLabel *label = s.findChild<olive::SliderLabel *>();
ASSERT_NE(label, nullptr);
ASSERT_TRUE(QMetaObject::invokeMethod(label, "label_pressed"));
}
} // namespace
TEST(SliderDisplayType, OrdinalValuesAreAbiStable)
{
// These cross the engine C ABI as ints inside node input properties; the
// ordinals must match engine node/sliderdisplaytype.h
EXPECT_EQ(int(olive::slider::k_normal), 0);
EXPECT_EQ(int(olive::slider::k_decibel), 1);
EXPECT_EQ(int(olive::slider::k_percentage), 2);
EXPECT_EQ(int(olive::slider::k_time), 0);
EXPECT_EQ(int(olive::slider::k_float), 1);
EXPECT_EQ(int(olive::slider::k_rational), 2);
}
TEST(NumericSliderBase, DefaultStateIsIdle)
{
ProbeNumericSlider s;
EXPECT_FALSE(s.is_dragging());
EXPECT_TRUE(s.can_set_value_public());
EXPECT_EQ(s.cursor().shape(), Qt::SizeHorCursor);
}
TEST(NumericSliderBase, OffsetRoundTrips)
{
ProbeNumericSlider s;
EXPECT_FALSE(s.get_offset_public().isValid());
s.set_offset(7);
EXPECT_EQ(s.get_offset_public().toInt(), 7);
s.set_offset(-2.5);
EXPECT_DOUBLE_EQ(s.get_offset_public().toDouble(), -2.5);
}
TEST(NumericSliderBase, DragDistanceDefaultsToAddition)
{
ProbeNumericSlider s;
EXPECT_DOUBLE_EQ(s.adjust_drag_public(1.5, 2.25).toDouble(), 3.75);
EXPECT_DOUBLE_EQ(s.adjust_drag_public(1.5, -0.5).toDouble(), 1.0);
}
TEST(NumericSliderBase, ValueComparisonUsesNumericValues)
{
ProbeNumericSlider s;
EXPECT_TRUE(s.greater_public(2.5, 2.4));
EXPECT_FALSE(s.greater_public(1.0, 1.0));
EXPECT_TRUE(s.less_public(1, 2));
// String variants compare by their numeric value, not lexicographically
EXPECT_TRUE(s.less_public(QStringLiteral("9"), QStringLiteral("10")));
}
TEST(NumericSliderBase, SetMinimumClampsExistingValue)
{
ProbeNumericSlider s;
s.set_value(5.0);
s.set_minimum_public(10.0);
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 10.0);
// New values below the minimum clamp on the way in; programmatic sets do
// not emit the value-changed signal
s.set_value(3.0);
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 10.0);
EXPECT_TRUE(s.signaled.isEmpty());
s.set_value(42.0);
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 42.0);
}
TEST(NumericSliderBase, SetMaximumClampsExistingValue)
{
ProbeNumericSlider s;
s.set_value(5.0);
s.set_maximum_public(2.0);
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 2.0);
s.set_value(99.0);
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 2.0);
s.set_value(-7.0);
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), -7.0);
}
TEST(NumericSliderBase, LadderDragUpdatesValueAndSignals)
{
LadderConfigGuard guard;
{
ProbeNumericSlider s;
s.set_ladder_element_count(2);
s.set_value(10.0);
press_label(s);
ASSERT_TRUE(s.is_dragging());
EXPECT_FALSE(s.can_set_value_public());
olive::SliderLadder *ladder = find_ladder();
ASSERT_NE(ladder, nullptr);
// Two outer values either side plus the center entry
EXPECT_EQ(ladder->findChildren<olive::SliderLadderElement *>().size(), 5);
// External sets are blocked while the drag owns the value
s.set_value(50.0);
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 10.0);
EXPECT_TRUE(s.signaled.isEmpty());
// value * multiplier accumulates onto the drag start value
ASSERT_TRUE(QMetaObject::invokeMethod(
ladder, "dragged_by_value", Q_ARG(int, 3), Q_ARG(double, 2.0)));
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 16.0);
ASSERT_EQ(s.signaled.size(), 1);
EXPECT_DOUBLE_EQ(s.signaled.last().toDouble(), 16.0);
ASSERT_TRUE(QMetaObject::invokeMethod(
ladder, "dragged_by_value", Q_ARG(int, 1), Q_ARG(double, 1.0)));
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 17.0);
ASSERT_EQ(s.signaled.size(), 2);
// Releasing after a drag re-signals the final value and ends the drag
ladder->close();
EXPECT_FALSE(s.is_dragging());
EXPECT_TRUE(s.can_set_value_public());
ASSERT_EQ(s.signaled.size(), 3);
EXPECT_DOUBLE_EQ(s.signaled.last().toDouble(), 17.0);
// Programmatic sets are accepted again
s.set_value(3.0);
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 3.0);
}
// Flush the ladder's deleteLater and its queued drag-timer start only
// after the slider is gone, so a stray timer tick can't reach a ladder
// whose owner no longer exists
QCoreApplication::processEvents();
}
TEST(NumericSliderBase, LadderDragClampsToRange)
{
LadderConfigGuard guard;
{
ProbeNumericSlider s;
s.set_ladder_element_count(2);
s.set_minimum_public(0.0);
s.set_maximum_public(8.0);
s.set_value(5.0);
press_label(s);
olive::SliderLadder *ladder = find_ladder();
ASSERT_NE(ladder, nullptr);
ASSERT_TRUE(QMetaObject::invokeMethod(
ladder, "dragged_by_value", Q_ARG(int, -10), Q_ARG(double, 1.0)));
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 0.0);
ASSERT_TRUE(QMetaObject::invokeMethod(
ladder, "dragged_by_value", Q_ARG(int, 100), Q_ARG(double, 1.0)));
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 8.0);
ladder->close();
}
QCoreApplication::processEvents();
}
TEST(NumericSliderBase, ClickWithoutDragShowsEditor)
{
LadderConfigGuard guard;
{
ProbeNumericSlider s;
s.set_ladder_element_count(2);
s.set_value(4.0);
press_label(s);
olive::SliderLadder *ladder = find_ladder();
ASSERT_NE(ladder, nullptr);
// A press/release with no drag in between is treated as a click and
// opens the line editor instead of changing the value
ladder->close();
EXPECT_FALSE(s.is_dragging());
EXPECT_TRUE(s.signaled.isEmpty());
olive::FocusableLineEdit *editor =
s.findChild<olive::FocusableLineEdit *>();
ASSERT_NE(editor, nullptr);
EXPECT_EQ(s.currentWidget(), editor);
EXPECT_EQ(editor->text(), QStringLiteral("4"));
EXPECT_DOUBLE_EQ(s.get_value().toDouble(), 4.0);
}
QCoreApplication::processEvents();
}
TEST(SliderLadder, BuildsElementPerPowerOfTen)
{
LadderConfigGuard guard;
olive::SliderLadder ladder(1.0, 2, QStringLiteral("9999"));
EXPECT_TRUE(ladder.windowFlags() & Qt::Popup);
const auto elements = ladder.findChildren<olive::SliderLadderElement *>();
ASSERT_EQ(elements.size(), 5);
// Coarse multipliers on top, the unit entry in the middle, fine below
EXPECT_DOUBLE_EQ(elements.at(0)->get_multiplier(), 100.0);
EXPECT_DOUBLE_EQ(elements.at(1)->get_multiplier(), 10.0);
EXPECT_DOUBLE_EQ(elements.at(2)->get_multiplier(), 1.0);
EXPECT_DOUBLE_EQ(elements.at(3)->get_multiplier(), 0.1);
EXPECT_DOUBLE_EQ(elements.at(4)->get_multiplier(), 0.01);
// Only the center entry starts highlighted
for (int i = 0; i < elements.size(); i++) {
const QPalette::ColorRole expected =
(i == 2) ? QPalette::Highlight : QPalette::Window;
EXPECT_EQ(elements.at(i)->backgroundRole(), expected) << i;
}
}
TEST(SliderLadder, DragMultiplierScalesElements)
{
LadderConfigGuard guard;
olive::SliderLadder ladder(2.5, 1, QStringLiteral("9999"));
const auto elements = ladder.findChildren<olive::SliderLadderElement *>();
ASSERT_EQ(elements.size(), 3);
EXPECT_DOUBLE_EQ(elements.at(0)->get_multiplier(), 25.0);
EXPECT_DOUBLE_EQ(elements.at(1)->get_multiplier(), 2.5);
EXPECT_DOUBLE_EQ(elements.at(2)->get_multiplier(), 0.25);
}
TEST(SliderLadder, SetValueShowsOnHighlightedElementOnly)
{
LadderConfigGuard guard;
olive::SliderLadder ladder(1.0, 1, QStringLiteral("9999"));
ladder.set_value(QStringLiteral("42"));
const auto elements = ladder.findChildren<olive::SliderLadderElement *>();
ASSERT_EQ(elements.size(), 3);
for (int i = 0; i < elements.size(); i++) {
QLabel *label = elements.at(i)->findChild<QLabel *>();
ASSERT_NE(label, nullptr);
if (i == 1) {
EXPECT_EQ(label->text(), QStringLiteral("1\n42"));
} else {
EXPECT_FALSE(label->text().contains(QStringLiteral("42")));
}
}
}
TEST(SliderLadder, CloseEmitsReleased)
{
LadderConfigGuard guard;
olive::SliderLadder ladder(1.0, 1, QStringLiteral("9999"));
ladder.show();
QSignalSpy spy(&ladder, &olive::SliderLadder::released);
ladder.close();
EXPECT_EQ(spy.count(), 1);
}
TEST(SliderLadder, SingleElementHidesMultiplier)
{
LadderConfigGuard guard;
// With no outer values the ladder is a single readout, and its multiplier
// is hidden since there's nothing to switch between
olive::SliderLadder ladder(1.0, 0, QStringLiteral("9999"));
const auto elements = ladder.findChildren<olive::SliderLadderElement *>();
ASSERT_EQ(elements.size(), 1);
ladder.set_value(QStringLiteral("7"));
QLabel *label = elements.first()->findChild<QLabel *>();
ASSERT_NE(label, nullptr);
EXPECT_EQ(label->text(), QStringLiteral("7"));
}
TEST(SliderLadderElement, LabelReflectsHighlightAndMultiplierVisibility)
{
olive::SliderLadderElement e(2.5, QStringLiteral("9999"));
QLabel *label = e.findChild<QLabel *>();
ASSERT_NE(label, nullptr);
// Unhighlighted: only the multiplier shows, the value slot stays empty
EXPECT_EQ(label->text(), QStringLiteral("2.5\n"));
EXPECT_EQ(e.backgroundRole(), QPalette::Window);
e.set_value(QStringLiteral("9"));
EXPECT_EQ(label->text(), QStringLiteral("2.5\n"));
e.set_highlighted(true);
EXPECT_EQ(label->text(), QStringLiteral("2.5\n9"));
EXPECT_EQ(e.backgroundRole(), QPalette::Highlight);
e.set_highlighted(false);
EXPECT_EQ(e.backgroundRole(), QPalette::Window);
e.set_multiplier_visible(false);
EXPECT_EQ(label->text(), QStringLiteral("9"));
}
+496
View File
@@ -0,0 +1,496 @@
#include <gtest/gtest.h>
#include <vector>
#include <QApplication>
#include <QComboBox>
#include <QEvent>
#include <QSignalSpy>
#include "render/videoparams.h"
#include "ui/humanstrings.h"
#include "widget/standardcombos/standardcombos.h"
// These tests complement widget_combos_test.cpp, which already covers item
// counts and basic value round-trips. Here we cover item text/data contents,
// signal emissions, and the stateful edge cases (custom entries, restore
// behavior, language changes).
namespace
{
// QComboBox::currentIndexChanged is overloaded; spy on the int form
QSignalSpy *index_spy(QComboBox *combo)
{
return new QSignalSpy(combo,
static_cast<void (QComboBox::*)(int)>(
&QComboBox::currentIndexChanged));
}
} // namespace
TEST(WidgetStandardCombos, SampleRateTextsAndDataMatchHumanStrings)
{
olive::SampleRateComboBox combo;
ASSERT_EQ(combo.count(),
int(olive::AudioParams::k_supported_sample_rates.size()));
for (int i = 0; i < combo.count(); i++) {
const int rate = olive::AudioParams::k_supported_sample_rates.at(i);
EXPECT_EQ(combo.itemData(i).toInt(), rate);
EXPECT_EQ(combo.itemText(i),
olive::HumanStrings::sample_rate_to_string(rate));
EXPECT_FALSE(combo.itemText(i).isEmpty());
}
}
TEST(WidgetStandardCombos, SampleRateSetEmitsIndexChanged)
{
olive::SampleRateComboBox combo;
combo.set_sample_rate(olive::AudioParams::k_supported_sample_rates.back());
QSignalSpy *spy = index_spy(&combo);
combo.set_sample_rate(olive::AudioParams::k_supported_sample_rates.front());
ASSERT_EQ(spy->count(), 1);
EXPECT_EQ(spy->first().first().toInt(), 0);
delete spy;
}
TEST(WidgetStandardCombos, SampleRateUnknownRateLeavesSelection)
{
olive::SampleRateComboBox combo;
combo.set_sample_rate(olive::AudioParams::k_supported_sample_rates.back());
int sentinel = 7;
while (combo.findData(sentinel) != -1) {
sentinel++;
}
const int old_index = combo.currentIndex();
combo.set_sample_rate(sentinel);
EXPECT_EQ(combo.currentIndex(), old_index);
EXPECT_EQ(combo.get_sample_rate(),
olive::AudioParams::k_supported_sample_rates.back());
}
TEST(WidgetStandardCombos, ChannelLayoutTextsAndDataMatchHumanStrings)
{
olive::ChannelLayoutComboBox combo;
ASSERT_EQ(combo.count(),
int(olive::AudioParams::k_supported_channel_layouts.size()));
for (int i = 0; i < combo.count(); i++) {
const uint64_t layout =
olive::AudioParams::k_supported_channel_layouts.at(i);
EXPECT_EQ(combo.itemData(i).toULongLong(), layout);
EXPECT_EQ(combo.itemText(i),
olive::HumanStrings::channel_layout_to_string(layout));
EXPECT_FALSE(combo.itemText(i).isEmpty());
}
}
TEST(WidgetStandardCombos, ChannelLayoutSetEmitsIndexChanged)
{
olive::ChannelLayoutComboBox combo;
combo.set_channel_layout(
olive::AudioParams::k_supported_channel_layouts.back());
QSignalSpy *spy = index_spy(&combo);
combo.set_channel_layout(
olive::AudioParams::k_supported_channel_layouts.front());
ASSERT_EQ(spy->count(), 1);
EXPECT_EQ(spy->first().first().toInt(), 0);
delete spy;
}
TEST(WidgetStandardCombos, ChannelLayoutUnknownLayoutLeavesSelection)
{
olive::ChannelLayoutComboBox combo;
combo.set_channel_layout(
olive::AudioParams::k_supported_channel_layouts.back());
const uint64_t sentinel = ~uint64_t(0);
for (int i = 0; i < combo.count(); i++) {
ASSERT_NE(combo.itemData(i).toULongLong(), sentinel);
}
const int old_index = combo.currentIndex();
combo.set_channel_layout(sentinel);
EXPECT_EQ(combo.currentIndex(), old_index);
EXPECT_EQ(combo.get_channel_layout(),
olive::AudioParams::k_supported_channel_layouts.back());
}
TEST(WidgetStandardCombos, InterlacedTextsMatchEnumOrder)
{
olive::InterlacedComboBox combo;
ASSERT_EQ(combo.count(), 3);
EXPECT_EQ(combo.itemText(int(olive::VideoParams::k_interlace_none)),
QStringLiteral("None (Progressive)"));
EXPECT_EQ(combo.itemText(int(olive::VideoParams::k_interlaced_top_first)),
QStringLiteral("Top-Field First"));
EXPECT_EQ(
combo.itemText(int(olive::VideoParams::k_interlaced_bottom_first)),
QStringLiteral("Bottom-Field First"));
}
TEST(WidgetStandardCombos, InterlacedSetEmitsIndexChanged)
{
olive::InterlacedComboBox combo;
QSignalSpy *spy = index_spy(&combo);
combo.set_interlace_mode(olive::VideoParams::k_interlaced_bottom_first);
ASSERT_EQ(spy->count(), 1);
EXPECT_EQ(spy->first().first().toInt(),
int(olive::VideoParams::k_interlaced_bottom_first));
delete spy;
}
TEST(WidgetStandardCombos, PixelAspectRatioEntriesCarryNamesAndRatios)
{
olive::PixelAspectRatioComboBox combo;
const QVector<olive::Rational> &standards =
olive::VideoParams::k_standard_pixel_aspects;
const QStringList names =
olive::VideoParams::get_standard_pixel_aspect_ratio_names();
ASSERT_EQ(combo.count(), standards.size() + 1);
ASSERT_EQ(names.size(), standards.size());
for (int i = 0; i < standards.size(); i++) {
EXPECT_EQ(combo.itemData(i).value<olive::Rational>(), standards.at(i));
EXPECT_EQ(combo.itemText(i), names.at(i));
}
// Default selection is the first standard ratio
EXPECT_EQ(combo.currentIndex(), 0);
EXPECT_EQ(combo.get_pixel_aspect_ratio(), standards.first());
}
TEST(WidgetStandardCombos, PixelAspectRatioCustomEntryDefaultsToSquare)
{
olive::PixelAspectRatioComboBox combo;
const int last = combo.count() - 1;
EXPECT_EQ(combo.itemText(last), QStringLiteral("Custom..."));
// A null custom ratio is backed by 1:1 so it can never produce a 0 PAR
const olive::Rational data =
combo.itemData(last).value<olive::Rational>();
EXPECT_DOUBLE_EQ(data.to_double(), 1.0);
}
TEST(WidgetStandardCombos, PixelAspectRatioCustomSetRenamesLastEntry)
{
olive::PixelAspectRatioComboBox combo;
const QVector<olive::Rational> &standards =
olive::VideoParams::k_standard_pixel_aspects;
const int last = combo.count() - 1;
const olive::Rational custom(17, 13);
combo.set_pixel_aspect_ratio(custom);
EXPECT_EQ(combo.currentIndex(), last);
EXPECT_TRUE(
combo.itemText(last).startsWith(QStringLiteral("Custom (")));
EXPECT_EQ(combo.itemData(last).value<olive::Rational>(), custom);
// Returning to a standard ratio selects it but keeps the custom label
combo.set_pixel_aspect_ratio(standards.first());
EXPECT_EQ(combo.currentIndex(), 0);
EXPECT_EQ(combo.get_pixel_aspect_ratio(), standards.first());
EXPECT_TRUE(
combo.itemText(last).startsWith(QStringLiteral("Custom (")));
}
TEST(WidgetStandardCombos, PixelAspectRatioSelectingStandardDoesNotPrompt)
{
olive::PixelAspectRatioComboBox combo;
const QVector<olive::Rational> &standards =
olive::VideoParams::k_standard_pixel_aspects;
ASSERT_GE(standards.size(), 2);
// A user-style index change to a standard entry must not open the
// custom-ratio dialog (selecting the last entry would, so it is
// intentionally not exercised under the offscreen platform)
combo.setCurrentIndex(1);
EXPECT_EQ(combo.get_pixel_aspect_ratio(), standards.at(1));
EXPECT_EQ(combo.itemText(combo.count() - 1),
QStringLiteral("Custom..."));
}
TEST(WidgetStandardCombos, PixelFormatEntriesOrderedWithNames)
{
olive::PixelFormatComboBox combo(false);
// Preview formats u8..f32 are added in enum order
ASSERT_EQ(combo.count(), int(olive::core::PixelFormat::count));
for (int i = 0; i < combo.count(); i++) {
EXPECT_EQ(combo.itemData(i).toInt(), i);
const auto fmt = static_cast<olive::core::PixelFormat::Format>(i);
EXPECT_EQ(combo.itemText(i),
olive::VideoParams::get_format_name(fmt));
EXPECT_FALSE(combo.itemText(i).isEmpty());
}
// Default selection is the first (u8) format
EXPECT_EQ(static_cast<olive::core::PixelFormat::Format>(
combo.get_pixel_format()),
olive::core::PixelFormat::u8);
}
TEST(WidgetStandardCombos, PixelFormatSetEmitsIndexChanged)
{
olive::PixelFormatComboBox combo(false);
QSignalSpy *spy = index_spy(&combo);
combo.set_pixel_format(olive::core::PixelFormat::f32);
ASSERT_EQ(spy->count(), 1);
EXPECT_EQ(spy->first().first().toInt(),
int(olive::core::PixelFormat::f32));
delete spy;
}
TEST(WidgetStandardCombos, PixelFormatFloatOnlyIgnoresIntegerFormat)
{
olive::PixelFormatComboBox combo(true);
ASSERT_GT(combo.count(), 0);
// u8 is not a float format, so it cannot be selected here
combo.set_pixel_format(olive::core::PixelFormat::u8);
olive::core::PixelFormat selected =
static_cast<olive::core::PixelFormat::Format>(
combo.get_pixel_format());
EXPECT_TRUE(selected.is_float());
EXPECT_EQ(combo.currentIndex(), 0);
}
TEST(WidgetStandardCombos, SampleFormatStartsEmpty)
{
olive::SampleFormatComboBox combo;
EXPECT_EQ(combo.count(), 0);
}
TEST(WidgetStandardCombos, SampleFormatAvailableFormatsPopulateInOrder)
{
using SampleFormat = olive::core::SampleFormat;
olive::SampleFormatComboBox combo;
const std::vector<SampleFormat> formats = {
SampleFormat::u8, SampleFormat::s16, SampleFormat::f32
};
combo.set_available_formats(formats);
ASSERT_EQ(combo.count(), int(formats.size()));
for (int i = 0; i < combo.count(); i++) {
EXPECT_EQ(combo.itemData(i).toInt(),
int(static_cast<SampleFormat::Format>(formats.at(i))));
EXPECT_EQ(combo.itemText(i),
olive::HumanStrings::format_to_string(formats.at(i)));
}
// The first entry is selected by default
EXPECT_EQ(static_cast<SampleFormat::Format>(combo.get_sample_format()),
SampleFormat::u8);
}
TEST(WidgetStandardCombos, SampleFormatRestoreDisabledReselectsFirst)
{
using Format = olive::core::SampleFormat::Format;
olive::SampleFormatComboBox combo;
combo.set_attempt_to_restore_format(false);
combo.set_packed_formats();
combo.set_sample_format(olive::core::SampleFormat::f32);
ASSERT_EQ(static_cast<Format>(combo.get_sample_format()),
olive::core::SampleFormat::f32);
// With restore disabled, repopulating falls back to the first entry
combo.set_packed_formats();
EXPECT_EQ(combo.currentIndex(), 0);
EXPECT_EQ(static_cast<Format>(combo.get_sample_format()),
static_cast<Format>(olive::core::SampleFormat::packed_start));
}
TEST(WidgetStandardCombos, SampleFormatSetEmitsIndexChanged)
{
olive::SampleFormatComboBox combo;
combo.set_packed_formats();
QSignalSpy *spy = index_spy(&combo);
combo.set_sample_format(olive::core::SampleFormat::f32);
ASSERT_EQ(spy->count(), 1);
EXPECT_EQ(spy->first().first().toInt(),
int(olive::core::SampleFormat::f32) -
int(olive::core::SampleFormat::packed_start));
delete spy;
}
TEST(WidgetStandardCombos, VideoDividerTextsAndDataMatch)
{
olive::VideoDividerComboBox combo;
ASSERT_EQ(combo.count(), olive::VideoParams::k_supported_dividers.size());
for (int i = 0; i < combo.count(); i++) {
const int divider = olive::VideoParams::k_supported_dividers.at(i);
EXPECT_EQ(combo.itemData(i).toInt(), divider);
EXPECT_EQ(combo.itemText(i),
olive::VideoParams::get_name_for_divider(divider));
EXPECT_FALSE(combo.itemText(i).isEmpty());
}
}
TEST(WidgetStandardCombos, VideoDividerSetEmitsIndexChanged)
{
olive::VideoDividerComboBox combo;
combo.set_divider(olive::VideoParams::k_supported_dividers.last());
QSignalSpy *spy = index_spy(&combo);
combo.set_divider(olive::VideoParams::k_supported_dividers.first());
ASSERT_EQ(spy->count(), 1);
EXPECT_EQ(spy->first().first().toInt(), 0);
delete spy;
}
TEST(WidgetStandardCombos, VideoDividerUnknownDividerLeavesSelection)
{
olive::VideoDividerComboBox combo;
combo.set_divider(olive::VideoParams::k_supported_dividers.last());
int sentinel = 3;
while (combo.findData(sentinel) != -1) {
sentinel += 2;
}
const int old_index = combo.currentIndex();
combo.set_divider(sentinel);
EXPECT_EQ(combo.currentIndex(), old_index);
EXPECT_EQ(combo.get_divider(),
olive::VideoParams::k_supported_dividers.last());
}
TEST(WidgetStandardCombos, FrameRateHasTrailingCustomEntry)
{
olive::FrameRateComboBox combo;
QComboBox *inner = combo.findChild<QComboBox *>();
ASSERT_NE(inner, nullptr);
const QVector<olive::Rational> &standards =
olive::VideoParams::k_supported_frame_rates;
ASSERT_EQ(inner->count(), standards.size() + 1);
for (int i = 0; i < standards.size(); i++) {
EXPECT_EQ(inner->itemData(i).value<olive::Rational>(),
standards.at(i));
EXPECT_EQ(inner->itemText(i),
olive::VideoParams::frame_rate_to_string(standards.at(i)));
}
EXPECT_EQ(inner->itemText(standards.size()),
QStringLiteral("Custom..."));
}
TEST(WidgetStandardCombos, FrameRateProgrammaticSetDoesNotEmit)
{
olive::FrameRateComboBox combo;
QSignalSpy spy(&combo, &olive::FrameRateComboBox::frame_rate_changed);
combo.set_frame_rate(olive::VideoParams::k_supported_frame_rates.at(1));
combo.set_frame_rate(olive::Rational(27, 2));
combo.set_frame_rate(olive::VideoParams::k_supported_frame_rates.at(0));
EXPECT_EQ(spy.count(), 0);
}
TEST(WidgetStandardCombos, FrameRateUserSelectionEmitsFrameRateChanged)
{
olive::FrameRateComboBox combo;
QComboBox *inner = combo.findChild<QComboBox *>();
ASSERT_NE(inner, nullptr);
ASSERT_GE(olive::VideoParams::k_supported_frame_rates.size(), 3);
QSignalSpy spy(&combo, &olive::FrameRateComboBox::frame_rate_changed);
// Standard entries emit directly; only the last "Custom..." entry would
// open a modal input dialog, which is not exercised here
inner->setCurrentIndex(2);
ASSERT_EQ(spy.count(), 1);
EXPECT_EQ(spy.first().first().value<olive::Rational>(),
olive::VideoParams::k_supported_frame_rates.at(2));
EXPECT_EQ(combo.get_frame_rate(),
olive::VideoParams::k_supported_frame_rates.at(2));
}
TEST(WidgetStandardCombos, FrameRateCustomSetRenamesLastEntry)
{
olive::FrameRateComboBox combo;
QComboBox *inner = combo.findChild<QComboBox *>();
ASSERT_NE(inner, nullptr);
const int last = inner->count() - 1;
combo.set_frame_rate(olive::Rational(27, 2));
EXPECT_EQ(inner->currentIndex(), last);
EXPECT_TRUE(
inner->itemText(last).startsWith(QStringLiteral("Custom (")));
EXPECT_EQ(combo.get_frame_rate(), olive::Rational(27, 2));
// A second custom rate replaces the label's rate
combo.set_frame_rate(olive::Rational(29, 2));
EXPECT_EQ(inner->currentIndex(), last);
EXPECT_EQ(combo.get_frame_rate(), olive::Rational(29, 2));
// User-selecting a standard rate afterwards emits and returns it
QSignalSpy spy(&combo, &olive::FrameRateComboBox::frame_rate_changed);
inner->setCurrentIndex(0);
ASSERT_EQ(spy.count(), 1);
EXPECT_EQ(spy.first().first().value<olive::Rational>(),
olive::VideoParams::k_supported_frame_rates.first());
EXPECT_EQ(combo.get_frame_rate(),
olive::VideoParams::k_supported_frame_rates.first());
}
TEST(WidgetStandardCombos, FrameRateLanguageChangePreservesCustom)
{
olive::FrameRateComboBox combo;
combo.set_frame_rate(olive::Rational(27, 2));
QEvent ev(QEvent::LanguageChange);
QApplication::sendEvent(&combo, &ev);
QComboBox *inner = combo.findChild<QComboBox *>();
ASSERT_NE(inner, nullptr);
const QVector<olive::Rational> &standards =
olive::VideoParams::k_supported_frame_rates;
ASSERT_EQ(inner->count(), standards.size() + 1);
EXPECT_EQ(inner->currentIndex(), standards.size());
EXPECT_EQ(combo.get_frame_rate(), olive::Rational(27, 2));
EXPECT_TRUE(inner->itemText(standards.size())
.startsWith(QStringLiteral("Custom (")));
}
+870
View File
@@ -0,0 +1,870 @@
#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;
}
+781
View File
@@ -0,0 +1,781 @@
#include <gtest/gtest.h>
#include <QApplication>
#include <QCloseEvent>
#include <QFontComboBox>
#include <QFontDatabase>
#include <QTextCursor>
#include <QImage>
#include <QMatrix4x4>
#include <QMouseEvent>
#include <QPainter>
#include <QPaintEvent>
#include <QPointer>
#include <QPushButton>
#include <QScrollBar>
#include <QSignalSpy>
#include <QTest>
#include "node/output/viewer/viewer.h"
#include "node/project/footage/footage.h"
#include "node/project/sequence/sequence.h"
#include "render/videoparams.h"
#include "widget/viewer/displaybuffer.h"
#include "widget/viewer/vieweroutpututils.h"
#include "widget/viewer/viewerplaybacktimer.h"
#include "widget/viewer/viewerpreventsleep.h"
#include "widget/viewer/viewersizer.h"
#include "widget/viewer/viewertexteditor.h"
namespace
{
// ViewerSizer's scrollbars are created in a fixed order (horizontal first),
// and findChildren preserves child creation order
QList<QScrollBar *> sizer_scrollbars(olive::ViewerSizer *sizer)
{
return sizer->findChildren<QScrollBar *>();
}
// ViewerTextEditorToolBar push buttons in creation order (see
// viewertexteditor.cpp): underline, strikethrough, color, align left/center/
// right/justify, align top/middle/bottom, small caps
QList<QPushButton *> toolbar_buttons(olive::ViewerTextEditorToolBar *toolbar)
{
return toolbar->findChildren<QPushButton *>();
}
enum ToolBarButton {
k_underline = 0,
k_strikethrough = 1,
k_color = 2,
k_align_left = 3,
k_align_center = 4,
k_align_right = 5,
k_align_justify = 6,
k_align_top = 7,
k_align_middle = 8,
k_align_bottom = 9,
k_small_caps = 10,
};
QMatrix4x4 last_matrix(QSignalSpy &spy)
{
return spy.last().first().value<QMatrix4x4>();
}
} // namespace
TEST(ViewerSizer, ScrollBarsStartHiddenAndZoomIsFit)
{
olive::ViewerSizer sizer;
const auto bars = sizer_scrollbars(&sizer);
ASSERT_EQ(bars.size(), 2);
EXPECT_EQ(bars.at(0)->orientation(), Qt::Horizontal);
EXPECT_EQ(bars.at(1)->orientation(), Qt::Vertical);
EXPECT_FALSE(bars.at(0)->isVisibleTo(&sizer));
EXPECT_FALSE(bars.at(1)->isVisibleTo(&sizer));
}
TEST(ViewerSizer, ZeroChildSizeFillsContainerWithoutScaleRequest)
{
olive::ViewerSizer sizer;
sizer.resize(400, 300);
auto *child = new QWidget();
sizer.set_widget(child);
QSignalSpy scale_spy(&sizer, &olive::ViewerSizer::request_scale);
// width_/height_ default to 0, so the child simply fills the container
EXPECT_EQ(child->geometry(), QRect(0, 0, 400, 300));
EXPECT_EQ(scale_spy.count(), 0);
}
TEST(ViewerSizer, SetWidgetDestroysPreviousWidget)
{
olive::ViewerSizer sizer;
sizer.resize(200, 200);
QPointer<QWidget> first = new QWidget();
sizer.set_widget(first);
ASSERT_FALSE(first.isNull());
EXPECT_EQ(first->parentWidget(), &sizer);
auto *second = new QWidget();
sizer.set_widget(second);
// set_widget() deletes the previously installed widget
EXPECT_TRUE(first.isNull());
EXPECT_EQ(second->parentWidget(), &sizer);
}
TEST(ViewerSizer, WideContainerScalesByHeight)
{
olive::ViewerSizer sizer;
sizer.resize(400, 300);
sizer.set_widget(new QWidget());
QSignalSpy scale_spy(&sizer, &olive::ViewerSizer::request_scale);
// Square image in a 4:3 container: container is wider, so the matrix
// compresses X by sequence_aspect / container_aspect = 1 / (4/3)
sizer.set_child_size(100, 100);
ASSERT_GE(scale_spy.count(), 1);
const QMatrix4x4 m = last_matrix(scale_spy);
EXPECT_NEAR(m(0, 0), 0.75, 1e-9);
EXPECT_NEAR(m(1, 1), 1.0, 1e-9);
}
TEST(ViewerSizer, TallContainerScalesByWidth)
{
olive::ViewerSizer sizer;
sizer.resize(300, 300);
sizer.set_widget(new QWidget());
QSignalSpy scale_spy(&sizer, &olive::ViewerSizer::request_scale);
// 2:1 image in a square container: container is taller, so the matrix
// compresses Y by container_aspect / sequence_aspect = 1 / 2
sizer.set_child_size(200, 100);
ASSERT_GE(scale_spy.count(), 1);
const QMatrix4x4 m = last_matrix(scale_spy);
EXPECT_NEAR(m(0, 0), 1.0, 1e-9);
EXPECT_NEAR(m(1, 1), 0.5, 1e-9);
}
TEST(ViewerSizer, PixelAspectRatioFactorsIntoScale)
{
olive::ViewerSizer sizer;
sizer.resize(300, 300);
sizer.set_widget(new QWidget());
sizer.set_child_size(100, 100);
QSignalSpy scale_spy(&sizer, &olive::ViewerSizer::request_scale);
// 2:1 pixel aspect doubles the sequence aspect -> 2:1 in a square container
sizer.set_pixel_aspect_ratio(olive::Rational(2, 1));
ASSERT_GE(scale_spy.count(), 1);
QMatrix4x4 m = last_matrix(scale_spy);
EXPECT_NEAR(m(0, 0), 1.0, 1e-9);
EXPECT_NEAR(m(1, 1), 0.5, 1e-9);
// 1:2 pixel aspect halves it -> 1:2 image, container wider than image
sizer.set_pixel_aspect_ratio(olive::Rational(1, 2));
m = last_matrix(scale_spy);
EXPECT_NEAR(m(0, 0), 0.5, 1e-9);
EXPECT_NEAR(m(1, 1), 1.0, 1e-9);
}
TEST(ViewerSizer, ExplicitZoomShowsScrollBars)
{
olive::ViewerSizer sizer;
sizer.resize(200, 200);
sizer.set_widget(new QWidget());
sizer.set_child_size(100, 100);
sizer.show();
ASSERT_TRUE(QTest::qWaitForWindowExposed(&sizer));
const auto bars = sizer_scrollbars(&sizer);
ASSERT_EQ(bars.size(), 2);
QSignalSpy scale_spy(&sizer, &olive::ViewerSizer::request_scale);
// 100px child at 400% is 400px > 200px container, so both bars appear
sizer.set_zoom(4.0);
EXPECT_TRUE(bars.at(0)->isVisible());
EXPECT_TRUE(bars.at(1)->isVisible());
// The zoom is folded into the requested scale on top of the fit scale
ASSERT_GE(scale_spy.count(), 1);
const QMatrix4x4 m = last_matrix(scale_spy);
EXPECT_GT(m(0, 0), 1.0);
EXPECT_GT(m(1, 1), 1.0);
// Back to fit: scrollbars disappear again
sizer.set_zoom(-1);
EXPECT_FALSE(bars.at(0)->isVisible());
EXPECT_FALSE(bars.at(1)->isVisible());
}
TEST(ViewerSizer, ScrollBarMovementEmitsTranslate)
{
olive::ViewerSizer sizer;
sizer.resize(200, 200);
sizer.set_widget(new QWidget());
sizer.set_child_size(100, 100);
sizer.show();
ASSERT_TRUE(QTest::qWaitForWindowExposed(&sizer));
sizer.set_zoom(4.0);
const auto bars = sizer_scrollbars(&sizer);
ASSERT_EQ(bars.size(), 2);
QScrollBar *horiz = bars.at(0);
ASSERT_TRUE(horiz->isVisible());
ASSERT_GT(horiz->maximum(), 0);
QSignalSpy translate_spy(&sizer, &olive::ViewerSizer::request_translate);
// Scrolled to the far right the offset is negative. Go to the maximum
// first so the subsequent setValue(0) is a real change (QScrollBar only
// emits valueChanged, which drives scroll_bar_moved, on actual changes)
horiz->setValue(horiz->maximum());
ASSERT_GE(translate_spy.count(), 1);
const float at_max = last_matrix(translate_spy)(0, 3);
EXPECT_LT(at_max, 0.0f);
// Back at 0 the view centers on the left edge: positive X offset
translate_spy.clear();
horiz->setValue(0);
ASSERT_GE(translate_spy.count(), 1);
const float at_min = last_matrix(translate_spy)(0, 3);
EXPECT_GT(at_min, 0.0f);
}
TEST(ViewerSizer, HandDragMoveAdjustsVisibleScrollBars)
{
olive::ViewerSizer sizer;
sizer.resize(200, 200);
sizer.set_widget(new QWidget());
sizer.set_child_size(100, 100);
sizer.show();
ASSERT_TRUE(QTest::qWaitForWindowExposed(&sizer));
sizer.set_zoom(4.0);
const auto bars = sizer_scrollbars(&sizer);
ASSERT_EQ(bars.size(), 2);
bars.at(0)->setValue(50);
bars.at(1)->setValue(30);
// A drag of (+10, +5) pulls the content, decreasing both scroll values
sizer.hand_drag_move(10, 5);
EXPECT_EQ(bars.at(0)->value(), 40);
EXPECT_EQ(bars.at(1)->value(), 25);
}
TEST(ViewerSizer, AnchoredZoomOutToFitResetsScrollPositions)
{
olive::ViewerSizer sizer;
sizer.resize(200, 200);
sizer.set_widget(new QWidget());
sizer.set_child_size(100, 100);
sizer.show();
ASSERT_TRUE(QTest::qWaitForWindowExposed(&sizer));
const auto bars = sizer_scrollbars(&sizer);
ASSERT_EQ(bars.size(), 2);
sizer.set_zoom_anchored(4.0, 10.0, 10.0);
ASSERT_TRUE(bars.at(0)->isVisible());
// A non-positive anchored zoom is the "fit" command: zoom clears and the
// scroll offsets reset
sizer.set_zoom_anchored(0.0, 0.0, 0.0);
EXPECT_FALSE(bars.at(0)->isVisible());
EXPECT_FALSE(bars.at(1)->isVisible());
EXPECT_EQ(bars.at(0)->value(), 0);
EXPECT_EQ(bars.at(1)->value(), 0);
}
TEST(ViewerPlaybackTimer, ZeroSpeedKeepsStartTimestamp)
{
olive::ViewerPlaybackTimer timer;
timer.start(42, 0, 1.0 / 30.0);
// No elapsed real time can move the timestamp when the speed is zero
EXPECT_EQ(timer.get_timestamp_now(), 42);
}
TEST(ViewerPlaybackTimer, HugeTimebaseFreezesFrameAdvance)
{
olive::ViewerPlaybackTimer timer;
// 1000-second frames: any sub-second test run still rounds to zero frames
timer.start(7, 3, 1000.0);
EXPECT_EQ(timer.get_timestamp_now(), 7);
}
TEST(ViewerPlaybackTimer, NegativeSpeedAlsoStartsAtTimestamp)
{
olive::ViewerPlaybackTimer timer;
timer.start(100, -2, 1000.0);
EXPECT_EQ(timer.get_timestamp_now(), 100);
}
TEST(ViewerPlaybackTimer, RestartReplacesAnchor)
{
olive::ViewerPlaybackTimer timer;
timer.start(1, 0, 1.0 / 30.0);
EXPECT_EQ(timer.get_timestamp_now(), 1);
timer.start(500, 0, 1.0 / 30.0);
EXPECT_EQ(timer.get_timestamp_now(), 500);
}
TEST(ViewerPreventSleep, ToggleOnOffDoesNotCrash)
{
// No observable state is exposed; on macOS this creates/releases an IOPM
// assertion, elsewhere it talks to the OS sleep-inhibit service. Toggling
// twice exercises the released-assertion no-op path.
olive::prevent_sleep(true);
olive::prevent_sleep(false);
olive::prevent_sleep(false);
SUCCEED();
}
TEST(DisplayBuffer, NullHandlesAreSafeToWrapAndDestroy)
{
auto tex = olive::oak_make_shared_texture(nullptr);
EXPECT_EQ(tex->handle, nullptr);
EXPECT_EQ(tex->type, olive::OakSharedBuffer::k_texture);
auto frame = olive::oak_make_shared_frame(nullptr);
EXPECT_EQ(frame->handle, nullptr);
EXPECT_EQ(frame->type, olive::OakSharedBuffer::k_frame);
// Both facade free functions ignore nullptr, so destroying these is safe
tex.reset();
frame.reset();
SUCCEED();
}
TEST(DisplayBuffer, SharedCopiesExtendHandleLifetime)
{
void *raw = oakengine_codec_frame_create();
ASSERT_NE(raw, nullptr);
auto frame = olive::oak_make_shared_frame(raw);
EXPECT_EQ(frame->handle, raw);
EXPECT_EQ(frame.use_count(), 1);
{
auto copy = frame;
EXPECT_EQ(frame.use_count(), 2);
EXPECT_EQ(copy->handle, raw);
}
// The copy is gone but the original still holds the handle
EXPECT_EQ(frame.use_count(), 1);
}
TEST(DisplayBuffer, RoundTripsThroughVariant)
{
auto frame = olive::oak_make_shared_frame(nullptr);
QVariant v = QVariant::fromValue(frame);
EXPECT_TRUE(v.isValid());
const olive::OakSharedBufferPtr out = v.value<olive::OakSharedBufferPtr>();
EXPECT_EQ(out, frame);
EXPECT_EQ(out.use_count(), frame.use_count());
}
TEST(ViewerOutputUtils, NullViewerReturnsDefaults)
{
const oak::VideoParams vp = olive::viewer_output_video_params(nullptr);
EXPECT_EQ(vp.width(), 0);
EXPECT_EQ(vp.height(), 0);
EXPECT_EQ(vp, olive::empty_video_params());
const olive::AudioParams ap = olive::viewer_output_audio_params(nullptr);
EXPECT_EQ(ap.sample_rate(), 0);
EXPECT_FALSE(ap.is_valid());
EXPECT_DOUBLE_EQ(olive::viewer_output_playhead(nullptr).to_double(), 0.0);
EXPECT_DOUBLE_EQ(olive::viewer_output_length(nullptr).to_double(), 0.0);
EXPECT_DOUBLE_EQ(olive::viewer_output_video_length(nullptr).to_double(), 0.0);
EXPECT_DOUBLE_EQ(olive::viewer_output_audio_length(nullptr).to_double(), 0.0);
EXPECT_DOUBLE_EQ(olive::sequence_timebase(nullptr).to_double(), 0.0);
EXPECT_FALSE(olive::viewer_output_is_sequence(nullptr));
EXPECT_FALSE(olive::viewer_output_is_footage(nullptr));
EXPECT_FALSE(olive::viewer_output_node_type_is(nullptr, "anything"));
}
TEST(ViewerOutputUtils, VideoParamsRoundTripThroughFacade)
{
olive::ViewerOutput viewer;
viewer.set_video_params(olive::VideoParams(320, 240, olive::Rational(1, 25),
olive::PixelFormat::u8,
olive::VideoParams::k_rgba_channel_count));
const oak::VideoParams vp = olive::viewer_output_video_params(&viewer);
EXPECT_EQ(vp.width(), 320);
EXPECT_EQ(vp.height(), 240);
EXPECT_TRUE(vp.is_valid());
// sequence_timebase is the frame duration as a rational
const olive::Rational tb = olive::sequence_timebase(&viewer);
EXPECT_EQ(tb.numerator(), 1);
EXPECT_EQ(tb.denominator(), 25);
EXPECT_EQ(vp.time_base().numerator(), 1);
EXPECT_EQ(vp.time_base().denominator(), 25);
}
TEST(ViewerOutputUtils, PlayheadRoundTripsAndLengthDefaultsToZero)
{
olive::ViewerOutput viewer;
viewer.set_playhead(olive::Rational(3, 2));
const olive::Rational ph = olive::viewer_output_playhead(&viewer);
EXPECT_EQ(ph.numerator(), 3);
EXPECT_EQ(ph.denominator(), 2);
// A fresh viewer has no content, so all lengths are zero
EXPECT_DOUBLE_EQ(olive::viewer_output_length(&viewer).to_double(), 0.0);
EXPECT_DOUBLE_EQ(olive::viewer_output_video_length(&viewer).to_double(), 0.0);
EXPECT_DOUBLE_EQ(olive::viewer_output_audio_length(&viewer).to_double(), 0.0);
}
TEST(ViewerOutputUtils, AudioParamsRoundTripThroughFacade)
{
olive::ViewerOutput viewer;
const uint64_t stereo_mask = 0x3; // front-left | front-right
viewer.set_audio_params(
olive::AudioParams(48000, stereo_mask, olive::SampleFormat::s16));
const olive::AudioParams ap = olive::viewer_output_audio_params(&viewer);
EXPECT_TRUE(ap.is_valid());
EXPECT_EQ(ap.sample_rate(), 48000);
EXPECT_EQ(ap.channel_layout(), stereo_mask);
EXPECT_EQ(olive::SampleFormat::Format(ap.format()), olive::SampleFormat::s16);
}
TEST(ViewerOutputUtils, TypeProbesMatchNodeIds)
{
olive::Sequence sequence;
EXPECT_TRUE(olive::viewer_output_is_sequence(&sequence));
EXPECT_FALSE(olive::viewer_output_is_footage(&sequence));
EXPECT_TRUE(olive::viewer_output_node_type_is(
&sequence, "org.olivevideoeditor.Olive.sequence"));
EXPECT_FALSE(
olive::viewer_output_node_type_is(&sequence, "org.oak.test.nonexistent"));
olive::Footage footage;
EXPECT_TRUE(olive::viewer_output_is_footage(&footage));
EXPECT_FALSE(olive::viewer_output_is_sequence(&footage));
// A plain ViewerOutput is neither a Sequence nor Footage
olive::ViewerOutput viewer;
EXPECT_FALSE(olive::viewer_output_is_sequence(&viewer));
EXPECT_FALSE(olive::viewer_output_is_footage(&viewer));
}
TEST(ViewerTextEditorToolBar, SettersUpdateButtonsWithoutEmitting)
{
olive::ViewerTextEditorToolBar toolbar;
const auto buttons = toolbar_buttons(&toolbar);
ASSERT_EQ(buttons.size(), 11);
QSignalSpy underline_spy(&toolbar,
&olive::ViewerTextEditorToolBar::underline_changed);
QSignalSpy strike_spy(
&toolbar, &olive::ViewerTextEditorToolBar::strikethrough_changed);
QSignalSpy caps_spy(&toolbar,
&olive::ViewerTextEditorToolBar::small_caps_changed);
toolbar.set_underline(true);
EXPECT_TRUE(buttons.at(k_underline)->isChecked());
toolbar.set_strikethrough(true);
EXPECT_TRUE(buttons.at(k_strikethrough)->isChecked());
toolbar.set_small_caps(true);
EXPECT_TRUE(buttons.at(k_small_caps)->isChecked());
// Programmatic sync from the editor must not bounce back as user edits
EXPECT_EQ(underline_spy.count(), 0);
EXPECT_EQ(strike_spy.count(), 0);
EXPECT_EQ(caps_spy.count(), 0);
}
TEST(ViewerTextEditorToolBar, SetAlignmentChecksMatchingButtonOnly)
{
olive::ViewerTextEditorToolBar toolbar;
const auto buttons = toolbar_buttons(&toolbar);
ASSERT_EQ(buttons.size(), 11);
toolbar.set_alignment(Qt::AlignHCenter);
EXPECT_FALSE(buttons.at(k_align_left)->isChecked());
EXPECT_TRUE(buttons.at(k_align_center)->isChecked());
EXPECT_FALSE(buttons.at(k_align_right)->isChecked());
EXPECT_FALSE(buttons.at(k_align_justify)->isChecked());
toolbar.set_alignment(Qt::AlignRight);
EXPECT_FALSE(buttons.at(k_align_left)->isChecked());
EXPECT_FALSE(buttons.at(k_align_center)->isChecked());
EXPECT_TRUE(buttons.at(k_align_right)->isChecked());
EXPECT_FALSE(buttons.at(k_align_justify)->isChecked());
}
TEST(ViewerTextEditorToolBar, SetVerticalAlignmentChecksMatchingButtonOnly)
{
olive::ViewerTextEditorToolBar toolbar;
const auto buttons = toolbar_buttons(&toolbar);
ASSERT_EQ(buttons.size(), 11);
toolbar.set_vertical_alignment(Qt::AlignVCenter);
EXPECT_FALSE(buttons.at(k_align_top)->isChecked());
EXPECT_TRUE(buttons.at(k_align_middle)->isChecked());
EXPECT_FALSE(buttons.at(k_align_bottom)->isChecked());
toolbar.set_vertical_alignment(Qt::AlignBottom);
EXPECT_FALSE(buttons.at(k_align_top)->isChecked());
EXPECT_FALSE(buttons.at(k_align_middle)->isChecked());
EXPECT_TRUE(buttons.at(k_align_bottom)->isChecked());
}
TEST(ViewerTextEditorToolBar, AlignmentButtonsEmitTheirAlignment)
{
olive::ViewerTextEditorToolBar toolbar;
const auto buttons = toolbar_buttons(&toolbar);
ASSERT_EQ(buttons.size(), 11);
// Lambda capture instead of QSignalSpy: Qt::Alignment is not guaranteed
// to be a registered metatype for the spy on every platform
QVector<Qt::Alignment> h_received;
QObject::connect(&toolbar,
&olive::ViewerTextEditorToolBar::alignment_changed,
[&h_received](Qt::Alignment a) { h_received.append(a); });
QVector<Qt::Alignment> v_received;
QObject::connect(
&toolbar, &olive::ViewerTextEditorToolBar::vertical_alignment_changed,
[&v_received](Qt::Alignment a) { v_received.append(a); });
buttons.at(k_align_center)->click();
ASSERT_EQ(h_received.size(), 1);
EXPECT_EQ(h_received.first(), Qt::AlignHCenter);
buttons.at(k_align_justify)->click();
ASSERT_EQ(h_received.size(), 2);
EXPECT_EQ(h_received.last(), Qt::AlignJustify);
buttons.at(k_align_middle)->click();
ASSERT_EQ(v_received.size(), 1);
EXPECT_EQ(v_received.first(), Qt::AlignVCenter);
buttons.at(k_align_bottom)->click();
ASSERT_EQ(v_received.size(), 2);
EXPECT_EQ(v_received.last(), Qt::AlignBottom);
}
TEST(ViewerTextEditorToolBar, ToggleButtonsEmitTheirState)
{
olive::ViewerTextEditorToolBar toolbar;
const auto buttons = toolbar_buttons(&toolbar);
ASSERT_EQ(buttons.size(), 11);
QSignalSpy underline_spy(&toolbar,
&olive::ViewerTextEditorToolBar::underline_changed);
QSignalSpy strike_spy(
&toolbar, &olive::ViewerTextEditorToolBar::strikethrough_changed);
QSignalSpy caps_spy(&toolbar,
&olive::ViewerTextEditorToolBar::small_caps_changed);
buttons.at(k_underline)->click();
ASSERT_EQ(underline_spy.count(), 1);
EXPECT_TRUE(underline_spy.first().first().toBool());
buttons.at(k_strikethrough)->click();
ASSERT_EQ(strike_spy.count(), 1);
EXPECT_TRUE(strike_spy.first().first().toBool());
buttons.at(k_small_caps)->click();
ASSERT_EQ(caps_spy.count(), 1);
EXPECT_TRUE(caps_spy.first().first().toBool());
// Unchecking emits false
buttons.at(k_underline)->click();
ASSERT_EQ(underline_spy.count(), 2);
EXPECT_FALSE(underline_spy.last().first().toBool());
}
TEST(ViewerTextEditorToolBar, SetColorPaintsColorButton)
{
olive::ViewerTextEditorToolBar toolbar;
const auto buttons = toolbar_buttons(&toolbar);
ASSERT_EQ(buttons.size(), 11);
const QColor c(QStringLiteral("#FF0000"));
toolbar.set_color(c);
QPushButton *color_btn = buttons.at(k_color);
EXPECT_EQ(color_btn->property("color").value<QColor>(), c);
EXPECT_TRUE(color_btn->styleSheet().contains(c.name()));
}
TEST(ViewerTextEditorToolBar, SetFontFamilyUpdatesComboWithoutEmitting)
{
olive::ViewerTextEditorToolBar toolbar;
QSignalSpy family_spy(&toolbar,
&olive::ViewerTextEditorToolBar::family_changed);
// Pick a family from the combo's own model: QFontDatabase also lists
// system fonts the QFontComboBox filters out (e.g. ".Apple Color Emoji"
// on macOS), which setCurrentFont can only approximate
auto *combo = toolbar.findChild<QFontComboBox *>();
ASSERT_NE(combo, nullptr);
ASSERT_GT(combo->count(), 1);
const QString family = combo->itemText(combo->count() / 2);
ASSERT_FALSE(family.isEmpty());
toolbar.set_font_family(family);
EXPECT_EQ(toolbar.get_font_family(), family);
// set_font_family blocks the combo's signals while syncing
EXPECT_EQ(family_spy.count(), 0);
}
TEST(ViewerTextEditorToolBar, FirstPaintEmittedOnce)
{
olive::ViewerTextEditorToolBar toolbar;
QSignalSpy spy(&toolbar, &olive::ViewerTextEditorToolBar::first_paint);
// Deliver paint events directly: whether the offscreen QPA paints on
// expose is platform-dependent, but QWidget::event always dispatches a
// QPaintEvent to paintEvent
QPaintEvent first(toolbar.rect());
QApplication::sendEvent(&toolbar, &first);
EXPECT_EQ(spy.count(), 1);
// Later repaints don't re-emit
QPaintEvent second(toolbar.rect());
QApplication::sendEvent(&toolbar, &second);
EXPECT_EQ(spy.count(), 1);
}
TEST(ViewerTextEditorToolBar, CloseEventIsIgnored)
{
olive::ViewerTextEditorToolBar toolbar;
QCloseEvent ev;
QApplication::sendEvent(&toolbar, &ev);
// The toolbar is a floating child of the viewer; closing must never hide it
EXPECT_FALSE(ev.isAccepted());
}
TEST(ViewerTextEditorToolBar, LeftDragMovesToolbarWithinParent)
{
QWidget parent;
parent.resize(600, 400);
olive::ViewerTextEditorToolBar toolbar(&parent);
toolbar.move(100, 100);
parent.show();
ASSERT_TRUE(QTest::qWaitForWindowExposed(&parent));
QTest::mousePress(&toolbar, Qt::LeftButton, Qt::NoModifier, QPoint(10, 10));
// QTest::mouseMove can't report held buttons, so deliver the move manually
QMouseEvent move(QEvent::MouseMove, QPointF(30, 25), QPointF(130, 125),
Qt::NoButton, Qt::LeftButton, Qt::NoModifier);
QApplication::sendEvent(&toolbar, &move);
EXPECT_EQ(toolbar.pos(), QPoint(120, 115));
}
TEST(ViewerTextEditor, CursorWidthScalesWithInverseZoom)
{
olive::ViewerTextEditor at_full(1.0);
EXPECT_EQ(at_full.cursorWidth(), 1);
olive::ViewerTextEditor zoomed_out(0.5);
EXPECT_EQ(zoomed_out.cursorWidth(), 2);
olive::ViewerTextEditor far_out(0.25);
EXPECT_EQ(far_out.cursorWidth(), 4);
}
TEST(ViewerTextEditor, ConstructionDefaults)
{
olive::ViewerTextEditor editor(1.0);
// Rich text paste is disabled; colors default to white on transparent
EXPECT_FALSE(editor.acceptRichText());
EXPECT_EQ(editor.palette().color(QPalette::Text), QColor(Qt::white));
// Scrolling is handled by the viewer gizmo, not the text edit
EXPECT_EQ(editor.horizontalScrollBarPolicy(), Qt::ScrollBarAlwaysOff);
EXPECT_EQ(editor.verticalScrollBarPolicy(), Qt::ScrollBarAlwaysOff);
}
TEST(ViewerTextEditor, ConnectToolBarSyncsCurrentFormat)
{
olive::ViewerTextEditor editor(1.0);
// Set a distinctive format before connecting; connect_tool_bar pushes the
// editor's current format into the toolbar exactly once
editor.setFontUnderline(true);
editor.setAlignment(Qt::AlignRight);
olive::ViewerTextEditorToolBar toolbar;
editor.connect_tool_bar(&toolbar);
const auto buttons = toolbar_buttons(&toolbar);
ASSERT_EQ(buttons.size(), 11);
EXPECT_TRUE(buttons.at(k_underline)->isChecked());
EXPECT_TRUE(buttons.at(k_align_right)->isChecked());
EXPECT_FALSE(buttons.at(k_align_left)->isChecked());
EXPECT_FALSE(buttons.at(k_align_center)->isChecked());
}
TEST(ViewerTextEditor, ToolBarTogglesApplyToEditorFormat)
{
olive::ViewerTextEditor editor(1.0);
olive::ViewerTextEditorToolBar toolbar;
editor.connect_tool_bar(&toolbar);
const auto buttons = toolbar_buttons(&toolbar);
ASSERT_EQ(buttons.size(), 11);
buttons.at(k_underline)->click();
EXPECT_TRUE(editor.currentCharFormat().fontUnderline());
buttons.at(k_strikethrough)->click();
EXPECT_TRUE(editor.currentCharFormat().fontStrikeOut());
buttons.at(k_small_caps)->click();
EXPECT_EQ(editor.currentCharFormat().fontCapitalization(),
QFont::SmallCaps);
buttons.at(k_align_center)->click();
EXPECT_EQ(editor.alignment(), Qt::AlignHCenter);
// The toolbar follows the editor back (same alignment echo)
EXPECT_TRUE(buttons.at(k_align_center)->isChecked());
EXPECT_FALSE(buttons.at(k_align_left)->isChecked());
}
TEST(ViewerTextEditor, PaintNeverRendersTextByDesign)
{
olive::ViewerTextEditor editor(1.0);
editor.resize(200, 80);
editor.setPlainText(QStringLiteral("Hello"));
// document_changed() swaps in a clone whose text is fully transparent (the
// HACK in viewertexteditor.cpp: the gizmo underneath renders the text, the
// editor must not render it a second time). Verify that contract: nothing
// is painted, with or without a selection, at any vertical alignment.
QTextCursor c = editor.textCursor();
c.select(QTextCursor::Document);
editor.setTextCursor(c);
for (const Qt::Alignment valign :
{ Qt::AlignTop, Qt::AlignVCenter, Qt::AlignBottom }) {
QImage img(200, 80, QImage::Format_ARGB32);
img.fill(Qt::transparent);
{
QPainter p(&img);
editor.paint(&p, valign);
EXPECT_TRUE(p.isActive());
}
for (int y = 0; y < img.height(); y++) {
for (int x = 0; x < img.width(); x++) {
EXPECT_EQ(qAlpha(img.pixel(x, y)), 0)
<< "painted pixel at " << x << "," << y;
}
}
}
}