- oakengine_sequence_move_clip implemented for real (oaktimeline TrackMoveBlockCommand; fixes the graph-ownership/gap-anchor/ripple trim bugs the stub was hiding); same-track via the frozen C ABI, cross-track supported by the module command - oaknode clip blocks now declare a tex_in texture input and set effect_input to it, so timeline clips can host effect chains; facade test covers effect insert/remove on a real clip - oakffmpeg-link: FFMPEG_DIR is now mandatory with a clear panic (a Homebrew upgrade left the system ffmpeg .pc pointing at a deleted dav1d Cellar path, breaking links); reads a git-ignored workspace .env for IDEs that cannot inject env vars (RustRover); links the C++ stdlib for C++ codec libs (svt-av1) - oakengine re-exports oaknode so tests share one crate instance; it_node uses the direct instance's value type where it calls the module FFI (the --workspace dev-dependency feature split builds oaknode twice)
983 lines
27 KiB
Rust
983 lines
27 KiB
Rust
// Oak Video Editor - Non-Linear Video Editor
|
|
// Copyright (C) 2026 Oak Team
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
//! Contract tests for the edit-family undo commands
|
|
//! (`src/undogeneral.rs`, `src/undopointer.rs`, `src/undoripple.rs`,
|
|
//! `src/undosplit.rs`, `src/undotrack.rs`). These mirror
|
|
//! `include/timeline/edit.h`; each command boxes into a CHandle via
|
|
//! the bridge undo vtable instead of the C++ `UndoCommand` hierarchy.
|
|
#![cfg(feature = "test-stubs")]
|
|
|
|
use oakcore_rs::{Rational, TimeRange};
|
|
use oaktimeline::bridge::node::{
|
|
oaknode_block_clip_create, oaknode_block_gap_create, oaknode_track_create,
|
|
oaknode_track_get_block_at, oaknode_track_get_block_count, oaknode_track_prepend_block,
|
|
};
|
|
use oaktimeline::bridge::teststubs::{MockKind, MockNode};
|
|
use oaktimeline::common::MovementMode;
|
|
use oaktimeline::handle::{get, get_mut, make_owned, CHandle, OAKTIMELINE_ABI_VERSION};
|
|
use oaktimeline::undocommon::Command;
|
|
use oaktimeline::undogeneral::{
|
|
BlockEnableDisableCommand, BlockResizeCommand, BlockResizeWithMediaInCommand,
|
|
BlockSetMediaInCommand, TimelineAddDefaultTransitionCommand, TimelineAddTrackCommand,
|
|
TimelineRemoveTrackCommand, TrackListInsertGaps, TrackReplaceBlockWithGapCommand,
|
|
TransitionRemoveCommand,
|
|
};
|
|
use oaktimeline::undopointer::{
|
|
BlockTrimCommand, TrackMoveBlockCommand, TrackPlaceBlockCommand, TrackSlideCommand,
|
|
};
|
|
use oaktimeline::undoripple::{
|
|
TimelineRippleDeleteGapsAtRegionsCommand, TimelineRippleRemoveAreaCommand,
|
|
TrackListRippleRemoveAreaCommand, TrackRippleRemoveAreaCommand,
|
|
};
|
|
use oaktimeline::undosplit::{
|
|
BlockSplitCommand, BlockSplitPreservingLinksCommand, TrackSplitAtTimeCommand,
|
|
};
|
|
use oaktimeline::undotrack::{
|
|
TrackInsertBlockAfterCommand, TrackPrependBlockCommand, TrackReplaceBlockCommand,
|
|
};
|
|
|
|
/// `TimelineAddTrackCommand` redo adds a track; undo removes it. The
|
|
/// newly-created track is exposed via `track()`.
|
|
#[test]
|
|
fn add_track_command_redo_undo() {
|
|
let list = make_list();
|
|
let mut cmd = TimelineAddTrackCommand::new(list.clone());
|
|
|
|
cmd.redo();
|
|
let t = cmd.track();
|
|
assert!(!t.is_null());
|
|
assert!(matches!(kind_of(&t), MockKind::Track));
|
|
assert_eq!(track_type_of(&t), 0);
|
|
|
|
cmd.undo();
|
|
}
|
|
|
|
/// `TimelineAddTrackCommand` with `automerge` uses the same add/remove
|
|
/// path but performs an automerge on redo.
|
|
#[test]
|
|
fn add_track_command_with_automerge() {
|
|
let list = make_list();
|
|
let mut cmd = TimelineAddTrackCommand::with_automerge(list.clone(), true);
|
|
|
|
cmd.redo();
|
|
let t = cmd.track();
|
|
assert!(!t.is_null());
|
|
assert!(matches!(kind_of(&t), MockKind::Track));
|
|
|
|
cmd.undo();
|
|
}
|
|
|
|
/// `TimelineRemoveTrackCommand` redo removes the track; undo restores
|
|
/// it.
|
|
#[test]
|
|
fn remove_track_command_redo_undo() {
|
|
let t = make_track();
|
|
let mut cmd = TimelineRemoveTrackCommand::new(t.clone());
|
|
|
|
cmd.prepare();
|
|
cmd.redo();
|
|
cmd.undo();
|
|
|
|
assert_eq!(count(&t), 0);
|
|
}
|
|
|
|
/// `TrackPlaceBlockCommand` redo places the block at a track index and
|
|
/// in-point; undo returns the track to its prior state.
|
|
#[test]
|
|
fn place_block_command_redo_undo() {
|
|
let list = make_list();
|
|
let t = make_track();
|
|
list_add_track(&list, &t);
|
|
let b = mk_clip();
|
|
set_times(&b, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
let mut cmd = TrackPlaceBlockCommand::new(list.clone(), 0, b.clone(), Rational::new(0, 1));
|
|
|
|
cmd.redo();
|
|
assert_eq!(count(&t), 1);
|
|
|
|
cmd.undo();
|
|
assert_eq!(count(&t), 0);
|
|
}
|
|
|
|
/// `TrackMoveBlockCommand` redo gaps the block's old spot and places it at
|
|
/// the new in point (keeping the length); undo restores the original
|
|
/// position.
|
|
#[test]
|
|
fn track_move_block_redo_undo() {
|
|
let list = make_list();
|
|
let t = make_track();
|
|
list_add_track(&list, &t);
|
|
let b = mk_clip();
|
|
set_times(&b, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), b.clone());
|
|
}
|
|
|
|
let mut cmd = TrackMoveBlockCommand::new(list.clone(), 0, b.clone(), Rational::new(20, 1));
|
|
cmd.redo();
|
|
// The old spot is a gap; the block sits at the new in point, length kept.
|
|
assert_eq!(count(&t), 2);
|
|
assert_eq!(b_in(&b), (20, 1));
|
|
assert_eq!(blen(&b), (10, 1));
|
|
|
|
cmd.undo();
|
|
assert_eq!(count(&t), 1);
|
|
assert_eq!(b_in(&b), (0, 1));
|
|
|
|
cmd.redo();
|
|
assert_eq!(count(&t), 2);
|
|
assert_eq!(b_in(&b), (20, 1));
|
|
}
|
|
|
|
/// `TrackMoveBlockCommand` moves the block onto another track (the
|
|
/// destination track index need not match the source); undo moves it back.
|
|
#[test]
|
|
fn track_move_block_cross_track_redo_undo() {
|
|
let list = make_list();
|
|
let t0 = make_track();
|
|
let t1 = make_track();
|
|
list_add_track(&list, &t0);
|
|
list_add_track(&list, &t1);
|
|
let b = mk_clip();
|
|
set_times(&b, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t0.clone(), b.clone());
|
|
}
|
|
|
|
let mut cmd = TrackMoveBlockCommand::new(list.clone(), 1, b.clone(), Rational::new(20, 1));
|
|
cmd.redo();
|
|
// t0's only block moved away (at-end gap: nothing left behind); t1 holds
|
|
// a lead-in gap plus the block.
|
|
assert_eq!(count(&t0), 0);
|
|
assert_eq!(count(&t1), 2);
|
|
assert_eq!(b_in(&b), (20, 1));
|
|
|
|
cmd.undo();
|
|
assert_eq!(count(&t0), 1);
|
|
assert_eq!(count(&t1), 0);
|
|
assert_eq!(b_in(&b), (0, 1));
|
|
}
|
|
|
|
/// `BlockResizeCommand` redo resizes the block; undo restores its
|
|
/// original length (media out follows the resize).
|
|
#[test]
|
|
fn block_resize_command_redo_undo() {
|
|
let b = mk_clip();
|
|
set_times(&b, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
let mut cmd = BlockResizeCommand::new(b.clone(), Rational::new(6, 1));
|
|
|
|
cmd.redo();
|
|
assert_eq!(blen(&b), (6, 1));
|
|
|
|
cmd.undo();
|
|
assert_eq!(blen(&b), (10, 1));
|
|
}
|
|
|
|
/// `BlockResizeWithMediaInCommand` resizes while keeping media out
|
|
/// fixed, shifting media in instead.
|
|
#[test]
|
|
fn block_resize_with_media_in_redo_undo() {
|
|
let b = mk_clip();
|
|
set_times(&b, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
let mut cmd = BlockResizeWithMediaInCommand::new(b.clone(), Rational::new(6, 1));
|
|
|
|
cmd.redo();
|
|
assert_eq!(blen(&b), (6, 1));
|
|
assert_eq!(media_in(&b), (4, 1));
|
|
|
|
cmd.undo();
|
|
assert_eq!(blen(&b), (10, 1));
|
|
assert_eq!(media_in(&b), (0, 1));
|
|
}
|
|
|
|
/// `BlockSetMediaInCommand` redo sets the media-in point; undo
|
|
/// restores the original.
|
|
#[test]
|
|
fn block_set_media_in_redo_undo() {
|
|
let b = mk_clip();
|
|
set_times(&b, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
let mut cmd = BlockSetMediaInCommand::new(b.clone(), Rational::new(4, 1));
|
|
|
|
cmd.redo();
|
|
assert_eq!(media_in(&b), (4, 1));
|
|
|
|
cmd.undo();
|
|
assert_eq!(media_in(&b), (0, 1));
|
|
}
|
|
|
|
/// `BlockTrimCommand` trims to a new length under a given movement
|
|
/// mode; undo restores the original length. Trim flags are settable
|
|
/// before execution.
|
|
#[test]
|
|
fn block_trim_command_redo_undo() {
|
|
let t = make_track();
|
|
let g1 = mk_gap();
|
|
let b = mk_clip();
|
|
let c = mk_clip();
|
|
set_times(&g1, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
set_times(&b, (10, 1), (20, 1), (10, 1), (0, 1));
|
|
set_times(&c, (20, 1), (30, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), c.clone());
|
|
}
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), b.clone());
|
|
}
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), g1.clone());
|
|
}
|
|
|
|
let mut cmd = BlockTrimCommand::new(
|
|
t.clone(),
|
|
b.clone(),
|
|
Rational::new(6, 1),
|
|
MovementMode::TrimOut,
|
|
);
|
|
cmd.prepare();
|
|
|
|
cmd.redo();
|
|
assert_eq!(count(&t), 4);
|
|
assert_eq!(blen(&b), (6, 1));
|
|
|
|
cmd.undo();
|
|
assert_eq!(count(&t), 3);
|
|
assert_eq!(blen(&b), (10, 1));
|
|
}
|
|
|
|
/// `TrackReplaceBlockWithGapCommand` redo swaps the block for a gap;
|
|
/// undo restores the block.
|
|
#[test]
|
|
fn replace_block_with_gap_redo_undo() {
|
|
let t = make_track();
|
|
let g = mk_gap();
|
|
let blk = mk_clip();
|
|
let nxt = mk_clip();
|
|
set_times(&g, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
set_times(&blk, (10, 1), (15, 1), (5, 1), (0, 1));
|
|
set_times(&nxt, (15, 1), (25, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), nxt.clone());
|
|
}
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), blk.clone());
|
|
}
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), g.clone());
|
|
}
|
|
|
|
let mut cmd = TrackReplaceBlockWithGapCommand::new(t.clone(), blk.clone(), false);
|
|
|
|
cmd.redo();
|
|
assert_eq!(count(&t), 2);
|
|
assert_eq!(blen(&g), (15, 1));
|
|
|
|
cmd.undo();
|
|
assert_eq!(count(&t), 3);
|
|
assert_eq!(blen(&g), (10, 1));
|
|
}
|
|
|
|
/// `BlockEnableDisableCommand` redo toggles enabled; undo restores.
|
|
#[test]
|
|
fn block_enable_disable_redo_undo() {
|
|
let b = mk_clip();
|
|
set_times(&b, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
let mut cmd = BlockEnableDisableCommand::new(b.clone(), false);
|
|
|
|
cmd.redo();
|
|
assert!(!enabled(&b));
|
|
|
|
cmd.undo();
|
|
assert!(enabled(&b));
|
|
}
|
|
|
|
/// `TrackPrependBlockCommand` redo inserts at the front; undo removes.
|
|
#[test]
|
|
fn track_prepend_block_redo_undo() {
|
|
let t = make_track();
|
|
let b = mk_clip();
|
|
set_times(&b, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), b.clone());
|
|
}
|
|
|
|
let b2 = mk_clip();
|
|
set_times(&b2, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
let mut cmd = TrackPrependBlockCommand::new(t.clone(), b2.clone());
|
|
|
|
cmd.redo();
|
|
assert_eq!(count(&t), 2);
|
|
|
|
cmd.undo();
|
|
assert_eq!(count(&t), 1);
|
|
}
|
|
|
|
/// `TrackInsertBlockAfterCommand` redo inserts after a sibling; undo
|
|
/// removes.
|
|
#[test]
|
|
fn track_insert_block_after_redo_undo() {
|
|
let t = make_track();
|
|
let b = mk_clip();
|
|
set_times(&b, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), b.clone());
|
|
}
|
|
|
|
let b2 = mk_clip();
|
|
set_times(&b2, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
let mut cmd = TrackInsertBlockAfterCommand::new(t.clone(), b2.clone(), b.clone());
|
|
|
|
cmd.redo();
|
|
assert_eq!(count(&t), 2);
|
|
|
|
cmd.undo();
|
|
assert_eq!(count(&t), 1);
|
|
}
|
|
|
|
/// `TrackReplaceBlockCommand` redo swaps the block; undo restores the
|
|
/// original.
|
|
#[test]
|
|
fn track_replace_block_redo_undo() {
|
|
let t = make_track();
|
|
let b = mk_clip();
|
|
set_times(&b, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), b.clone());
|
|
}
|
|
|
|
let b2 = mk_clip();
|
|
set_times(&b2, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
let mut cmd = TrackReplaceBlockCommand::new(t.clone(), b.clone(), b2.clone());
|
|
|
|
cmd.redo();
|
|
assert_eq!(count(&t), 1);
|
|
// `at()` returns a borrowed handle (raw value pointer), while `b2.ctx`
|
|
// is the owned `RefBox` pointer — compare the underlying value address.
|
|
assert_eq!(at(&t, 0).ctx as usize, addr(&b2) as usize);
|
|
assert!(track_ptr(&b).is_null());
|
|
|
|
cmd.undo();
|
|
assert_eq!(at(&t, 0).ctx as usize, addr(&b) as usize);
|
|
assert!(track_ptr(&b2).is_null());
|
|
}
|
|
|
|
/// The track commands dispatch through the `Command` trait, as the undo
|
|
/// stack's vtable invokes them.
|
|
#[test]
|
|
fn track_commands_trait_dispatch() {
|
|
let t = make_track();
|
|
let b = mk_clip();
|
|
set_times(&b, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
|
|
let mut prepend = TrackPrependBlockCommand::new(t.clone(), b.clone());
|
|
Command::redo(&mut prepend);
|
|
assert_eq!(count(&t), 1);
|
|
Command::undo(&mut prepend);
|
|
assert_eq!(count(&t), 0);
|
|
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), b.clone());
|
|
}
|
|
let b2 = mk_clip();
|
|
set_times(&b2, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
let mut ins = TrackInsertBlockAfterCommand::new(t.clone(), b2.clone(), b.clone());
|
|
Command::redo(&mut ins);
|
|
assert_eq!(count(&t), 2);
|
|
Command::undo(&mut ins);
|
|
assert_eq!(count(&t), 1);
|
|
|
|
let b3 = mk_clip();
|
|
set_times(&b3, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
let mut rep = TrackReplaceBlockCommand::new(t.clone(), b.clone(), b3.clone());
|
|
Command::redo(&mut rep);
|
|
assert_eq!(at(&t, 0).ctx as usize, addr(&b3) as usize);
|
|
Command::undo(&mut rep);
|
|
assert_eq!(at(&t, 0).ctx as usize, addr(&b) as usize);
|
|
}
|
|
|
|
/// `BlockSplitCommand` redo splits at a point; undo joins the pieces
|
|
/// back. `new_block` exposes the created half after redo.
|
|
#[test]
|
|
fn block_split_command_redo_undo() {
|
|
let t = make_track();
|
|
let b = mk_clip();
|
|
set_times(&b, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), b.clone());
|
|
}
|
|
|
|
let mut cmd = BlockSplitCommand::new(b.clone(), Rational::new(5, 1));
|
|
cmd.prepare();
|
|
|
|
cmd.redo();
|
|
assert_eq!(count(&t), 2);
|
|
assert_eq!(blen(&b), (5, 1));
|
|
assert!(!cmd.new_block().is_null());
|
|
|
|
cmd.undo();
|
|
assert_eq!(count(&t), 1);
|
|
assert_eq!(blen(&b), (10, 1));
|
|
}
|
|
|
|
/// `BlockSplitPreservingLinksCommand` splits many blocks at matching
|
|
/// times and keeps linked-media relations; undo rejoins them.
|
|
#[test]
|
|
fn block_split_preserving_links_redo_undo() {
|
|
let t = make_track();
|
|
let clip = mk_clip();
|
|
set_times(&clip, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), clip.clone());
|
|
}
|
|
|
|
let mut cmd =
|
|
BlockSplitPreservingLinksCommand::new(vec![clip.clone()], vec![Rational::new(5, 1)]);
|
|
cmd.prepare();
|
|
assert_eq!(count(&t), 2);
|
|
assert!(cmd.get_split(clip.clone(), 0).is_some());
|
|
|
|
cmd.undo();
|
|
assert_eq!(count(&t), 1);
|
|
assert_eq!(blen(&clip), (10, 1));
|
|
}
|
|
|
|
/// `TrackSplitAtTimeCommand` redo splits the block under the point;
|
|
/// undo rejoins.
|
|
#[test]
|
|
fn track_split_at_time_redo_undo() {
|
|
let t = make_track();
|
|
let mut cmd = TrackSplitAtTimeCommand::new(t.clone(), Rational::new(5, 1));
|
|
|
|
cmd.prepare();
|
|
cmd.redo();
|
|
cmd.undo();
|
|
|
|
assert_eq!(count(&t), 0);
|
|
}
|
|
|
|
/// `BlockSplitCommand::redo` without a prior `prepare` still creates the
|
|
/// second half (the C ABI command path may call redo directly).
|
|
#[test]
|
|
fn block_split_redo_without_prepare() {
|
|
let t = make_track();
|
|
let b = mk_clip();
|
|
set_times(&b, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), b.clone());
|
|
}
|
|
|
|
let mut cmd = BlockSplitCommand::new(b.clone(), Rational::new(4, 1));
|
|
cmd.redo();
|
|
assert_eq!(count(&t), 2);
|
|
assert_eq!(blen(&b), (4, 1));
|
|
assert!(!cmd.new_block().is_null());
|
|
}
|
|
|
|
/// Split commands dispatch through the `Command` trait, and
|
|
/// `get_split` reports `None` for unknown blocks / out-of-range indices.
|
|
#[test]
|
|
fn split_commands_trait_dispatch_and_get_split() {
|
|
let t = make_track();
|
|
let clip = mk_clip();
|
|
set_times(&clip, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), clip.clone());
|
|
}
|
|
|
|
// `BlockSplitCommand` via the trait.
|
|
let mut s = BlockSplitCommand::new(clip.clone(), Rational::new(5, 1));
|
|
s.prepare();
|
|
assert_eq!(count(&t), 1);
|
|
Command::redo(&mut s);
|
|
assert_eq!(count(&t), 2);
|
|
Command::undo(&mut s);
|
|
assert_eq!(count(&t), 1);
|
|
|
|
// `BlockSplitPreservingLinksCommand`: prepare applies, undo unsplits,
|
|
// redo re-applies; `get_split` None paths.
|
|
let other = mk_clip();
|
|
let mut p =
|
|
BlockSplitPreservingLinksCommand::new(vec![clip.clone()], vec![Rational::new(5, 1)]);
|
|
p.prepare();
|
|
assert_eq!(count(&t), 2);
|
|
assert!(p.get_split(clip.clone(), 0).is_some());
|
|
assert!(p.get_split(clip.clone(), 5).is_none());
|
|
assert!(p.get_split(other, 0).is_none());
|
|
|
|
Command::undo(&mut p);
|
|
assert_eq!(count(&t), 1);
|
|
Command::redo(&mut p);
|
|
assert_eq!(count(&t), 2);
|
|
|
|
// `TrackSplitAtTimeCommand` via the trait (no inner command built).
|
|
let mut ts = TrackSplitAtTimeCommand::new(t.clone(), Rational::new(5, 1));
|
|
Command::redo(&mut ts);
|
|
Command::undo(&mut ts);
|
|
}
|
|
|
|
/// `TrackRippleRemoveAreaCommand` redo ripples out a range (splicing
|
|
/// surrounding blocks); undo re-inserts the removed material. The
|
|
/// spliced block and insertion index are queryable afterwards.
|
|
#[test]
|
|
fn track_ripple_remove_area_redo_undo() {
|
|
let t = make_track();
|
|
let b = mk_clip();
|
|
set_times(&b, (10, 1), (20, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), b.clone());
|
|
}
|
|
|
|
let mut cmd = TrackRippleRemoveAreaCommand::new(
|
|
t.clone(),
|
|
TimeRange::new(Rational::new(10, 1), Rational::new(15, 1)),
|
|
);
|
|
cmd.set_allow_splitting_gaps(true);
|
|
cmd.prepare();
|
|
|
|
cmd.redo();
|
|
assert_eq!(blen(&b), (5, 1));
|
|
assert_eq!(count(&t), 1);
|
|
|
|
cmd.undo();
|
|
assert_eq!(blen(&b), (10, 1));
|
|
assert_eq!(count(&t), 1);
|
|
}
|
|
|
|
/// `TrackListRippleRemoveAreaCommand` removes the same area across
|
|
/// every track in a list.
|
|
#[test]
|
|
fn track_list_ripple_remove_area_redo_undo() {
|
|
let list = make_list();
|
|
let t = make_track();
|
|
list_add_track(&list, &t);
|
|
let b = mk_clip();
|
|
set_times(&b, (10, 1), (20, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), b.clone());
|
|
}
|
|
|
|
let mut cmd = TrackListRippleRemoveAreaCommand::new(
|
|
list.clone(),
|
|
Rational::new(10, 1),
|
|
Rational::new(15, 1),
|
|
);
|
|
cmd.prepare();
|
|
|
|
cmd.redo();
|
|
assert_eq!(blen(&b), (5, 1));
|
|
|
|
cmd.undo();
|
|
assert_eq!(blen(&b), (10, 1));
|
|
}
|
|
|
|
/// `TimelineRippleRemoveAreaCommand` removes an area across all
|
|
/// tracks in a timeline.
|
|
#[test]
|
|
fn timeline_ripple_remove_area_redo_undo() {
|
|
let seq = make_sequence();
|
|
let list = make_list();
|
|
seq_add_list(&seq, &list);
|
|
let t = make_track();
|
|
list_add_track(&list, &t);
|
|
let b = mk_clip();
|
|
set_times(&b, (10, 1), (20, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), b.clone());
|
|
}
|
|
|
|
let mut cmd = TimelineRippleRemoveAreaCommand::new(
|
|
seq.clone(),
|
|
Rational::new(10, 1),
|
|
Rational::new(15, 1),
|
|
);
|
|
|
|
cmd.redo();
|
|
assert_eq!(blen(&b), (5, 1));
|
|
|
|
cmd.undo();
|
|
assert_eq!(blen(&b), (10, 1));
|
|
}
|
|
|
|
/// `TrackListInsertGaps` redo inserts a gap at a point of a given
|
|
/// length across a track list; undo removes it.
|
|
#[test]
|
|
fn track_list_insert_gaps_redo_undo() {
|
|
let list = make_list();
|
|
let t = make_track();
|
|
list_add_track(&list, &t);
|
|
let clip = mk_clip();
|
|
set_times(&clip, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), clip.clone());
|
|
}
|
|
|
|
let mut cmd = TrackListInsertGaps::new(list.clone(), Rational::new(5, 1), Rational::new(3, 1));
|
|
cmd.prepare();
|
|
|
|
cmd.redo();
|
|
assert_eq!(count(&t), 2);
|
|
|
|
cmd.undo();
|
|
assert_eq!(count(&t), 1);
|
|
}
|
|
|
|
/// `TimelineRippleDeleteGapsAtRegionsCommand` redo deletes gaps at the
|
|
/// given regions; undo re-inserts them. `has_commands` reports whether
|
|
/// any deletion was planned during `prepare`.
|
|
#[test]
|
|
fn ripple_delete_gaps_redo_undo() {
|
|
let seq = make_sequence();
|
|
let list = make_list();
|
|
seq_add_list(&seq, &list);
|
|
let t = make_track();
|
|
list_add_track(&list, &t);
|
|
let gap = mk_gap();
|
|
set_times(&gap, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), gap.clone());
|
|
}
|
|
|
|
let mut cmd = TimelineRippleDeleteGapsAtRegionsCommand::new(
|
|
seq.clone(),
|
|
vec![(
|
|
t.clone(),
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(10, 1)),
|
|
)],
|
|
);
|
|
cmd.prepare();
|
|
assert!(cmd.has_commands());
|
|
|
|
cmd.redo();
|
|
assert_eq!(count(&t), 0);
|
|
|
|
// `oaknode_track_insert_block_after` prepends on a null predecessor, so
|
|
// the gap removed on redo is restored by undo.
|
|
cmd.undo();
|
|
assert_eq!(count(&t), 1);
|
|
}
|
|
|
|
/// `TrackSlideCommand` redo slides a block range by a movement;
|
|
/// undo restores the original positions.
|
|
#[test]
|
|
fn track_slide_command_redo_undo() {
|
|
let t = make_track();
|
|
let g1 = mk_gap();
|
|
let b = mk_clip();
|
|
let g2 = mk_gap();
|
|
set_times(&g1, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
set_times(&b, (10, 1), (20, 1), (10, 1), (0, 1));
|
|
set_times(&g2, (20, 1), (30, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), g2.clone());
|
|
}
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), b.clone());
|
|
}
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), g1.clone());
|
|
}
|
|
|
|
let mut cmd = TrackSlideCommand::new(
|
|
t.clone(),
|
|
vec![b.clone()],
|
|
g1.clone(),
|
|
g2.clone(),
|
|
Rational::new(5, 1),
|
|
);
|
|
cmd.prepare();
|
|
|
|
cmd.redo();
|
|
assert_eq!(blen(&g1), (15, 1));
|
|
assert_eq!(blen(&g2), (5, 1));
|
|
|
|
cmd.undo();
|
|
assert_eq!(blen(&g1), (10, 1));
|
|
assert_eq!(blen(&g2), (10, 1));
|
|
}
|
|
|
|
/// `TimelineAddDefaultTransitionCommand` redo inserts default
|
|
/// transitions between clips at a timebase; undo removes them.
|
|
#[test]
|
|
fn add_default_transition_redo_undo() {
|
|
let mut cmd = TimelineAddDefaultTransitionCommand::new(vec![], Rational::new(30, 1));
|
|
|
|
cmd.prepare();
|
|
cmd.redo();
|
|
cmd.undo();
|
|
}
|
|
|
|
/// `TransitionRemoveCommand` redo removes a transition; undo restores
|
|
/// it (optionally keeping it in the graph).
|
|
#[test]
|
|
fn transition_remove_redo_undo() {
|
|
let t = make_track();
|
|
let b = mk_clip();
|
|
let c = mk_clip();
|
|
set_times(&b, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
set_times(&c, (10, 1), (20, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), c.clone());
|
|
}
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), b.clone());
|
|
}
|
|
|
|
let mut cmd = TransitionRemoveCommand::new(b.clone(), false);
|
|
|
|
cmd.redo();
|
|
assert_eq!(count(&t), 1);
|
|
assert!(track_ptr(&b).is_null());
|
|
|
|
// The stub's undo is a no-op and does not re-insert the removed
|
|
// block; the block count therefore stays unchanged.
|
|
cmd.undo();
|
|
assert_eq!(count(&t), 1);
|
|
}
|
|
|
|
/// Every edit command `to_command` produces a CHandle suitable for the
|
|
/// undo stack, and every `prepare` is idempotent for repeated redo.
|
|
#[test]
|
|
fn all_edit_commands_box_to_chandle() {
|
|
let seq = make_sequence();
|
|
let list = make_list();
|
|
seq_add_list(&seq, &list);
|
|
let t = make_track();
|
|
list_add_track(&list, &t);
|
|
let b = mk_clip();
|
|
set_times(&b, (0, 1), (10, 1), (10, 1), (0, 1));
|
|
unsafe {
|
|
oaknode_track_prepend_block(t.clone(), b.clone());
|
|
}
|
|
let b2 = mk_clip();
|
|
|
|
let mut handles: Vec<CHandle> = Vec::new();
|
|
handles.push(BlockResizeCommand::new(b.clone(), Rational::new(6, 1)).to_command());
|
|
handles.push(BlockResizeWithMediaInCommand::new(b.clone(), Rational::new(6, 1)).to_command());
|
|
handles.push(BlockSetMediaInCommand::new(b.clone(), Rational::new(4, 1)).to_command());
|
|
handles.push(TimelineAddTrackCommand::new(list.clone()).to_command());
|
|
handles.push(TimelineAddTrackCommand::with_automerge(list.clone(), true).to_command());
|
|
handles.push(TimelineRemoveTrackCommand::new(t.clone()).to_command());
|
|
handles.push(TransitionRemoveCommand::new(b.clone(), false).to_command());
|
|
handles.push(TrackReplaceBlockWithGapCommand::new(t.clone(), b.clone(), false).to_command());
|
|
handles.push(BlockEnableDisableCommand::new(b.clone(), false).to_command());
|
|
handles.push(
|
|
TrackListInsertGaps::new(list.clone(), Rational::new(5, 1), Rational::new(3, 1))
|
|
.to_command(),
|
|
);
|
|
handles
|
|
.push(TimelineAddDefaultTransitionCommand::new(vec![], Rational::new(30, 1)).to_command());
|
|
handles.push(
|
|
BlockTrimCommand::new(
|
|
t.clone(),
|
|
b.clone(),
|
|
Rational::new(6, 1),
|
|
MovementMode::TrimOut,
|
|
)
|
|
.to_command(),
|
|
);
|
|
handles.push(
|
|
TrackSlideCommand::new(
|
|
t.clone(),
|
|
vec![b.clone()],
|
|
b.clone(),
|
|
b2.clone(),
|
|
Rational::new(5, 1),
|
|
)
|
|
.to_command(),
|
|
);
|
|
handles.push(
|
|
TrackPlaceBlockCommand::new(list.clone(), 0, b.clone(), Rational::new(0, 1)).to_command(),
|
|
);
|
|
handles.push(
|
|
TrackRippleRemoveAreaCommand::new(
|
|
t.clone(),
|
|
TimeRange::new(Rational::new(10, 1), Rational::new(15, 1)),
|
|
)
|
|
.to_command(),
|
|
);
|
|
handles.push(
|
|
TrackListRippleRemoveAreaCommand::new(
|
|
list.clone(),
|
|
Rational::new(10, 1),
|
|
Rational::new(15, 1),
|
|
)
|
|
.to_command(),
|
|
);
|
|
handles.push(
|
|
TimelineRippleRemoveAreaCommand::new(
|
|
seq.clone(),
|
|
Rational::new(10, 1),
|
|
Rational::new(15, 1),
|
|
)
|
|
.to_command(),
|
|
);
|
|
handles.push(
|
|
TimelineRippleDeleteGapsAtRegionsCommand::new(
|
|
seq.clone(),
|
|
vec![(
|
|
t.clone(),
|
|
TimeRange::new(Rational::new(0, 1), Rational::new(10, 1)),
|
|
)],
|
|
)
|
|
.to_command(),
|
|
);
|
|
handles.push(BlockSplitCommand::new(b.clone(), Rational::new(5, 1)).to_command());
|
|
handles.push(
|
|
BlockSplitPreservingLinksCommand::new(vec![b.clone()], vec![Rational::new(5, 1)])
|
|
.to_command(),
|
|
);
|
|
handles.push(TrackSplitAtTimeCommand::new(t.clone(), Rational::new(5, 1)).to_command());
|
|
handles.push(TrackPrependBlockCommand::new(t.clone(), b2.clone()).to_command());
|
|
handles.push(TrackInsertBlockAfterCommand::new(t.clone(), b2.clone(), b.clone()).to_command());
|
|
handles.push(TrackReplaceBlockCommand::new(t.clone(), b.clone(), b2.clone()).to_command());
|
|
|
|
for h in handles {
|
|
assert!(!h.is_null());
|
|
assert_eq!(h.abi_version, OAKTIMELINE_ABI_VERSION);
|
|
}
|
|
}
|
|
|
|
/// `MovementMode` round-trips through its C integer and
|
|
/// `is_a_trim_mode` distinguishes trim modes.
|
|
#[test]
|
|
fn movement_mode_c_round_trip() {
|
|
assert_eq!(MovementMode::None.to_c_int(), 0);
|
|
assert_eq!(MovementMode::Move.to_c_int(), 1);
|
|
assert_eq!(MovementMode::TrimIn.to_c_int(), 2);
|
|
assert_eq!(MovementMode::TrimOut.to_c_int(), 3);
|
|
|
|
assert!(!MovementMode::None.is_a_trim_mode());
|
|
assert!(!MovementMode::Move.is_a_trim_mode());
|
|
assert!(MovementMode::TrimIn.is_a_trim_mode());
|
|
assert!(MovementMode::TrimOut.is_a_trim_mode());
|
|
|
|
for v in 0..=3 {
|
|
assert_eq!(MovementMode::from_c_int(v).map(|m| m.to_c_int()), Some(v));
|
|
}
|
|
assert!(MovementMode::from_c_int(9).is_none());
|
|
}
|
|
|
|
// ---- helpers ---------------------------------------------------------
|
|
|
|
/// A new detached track of video type (0).
|
|
fn make_track() -> CHandle {
|
|
unsafe { oaknode_track_create(0) }
|
|
}
|
|
|
|
/// Raw pointer to the node boxed behind a handle.
|
|
fn addr(h: &CHandle) -> *mut MockNode {
|
|
unsafe { get_mut::<MockNode>(h).unwrap() as *mut MockNode }
|
|
}
|
|
|
|
/// Number of blocks on a track.
|
|
fn count(track: &CHandle) -> i32 {
|
|
let mut c = 0;
|
|
unsafe { oaknode_track_get_block_count(track.clone(), &mut c) };
|
|
c
|
|
}
|
|
|
|
/// Borrowed block at `index` on a track (for `.ctx` comparison only).
|
|
fn at(track: &CHandle, idx: i32) -> CHandle {
|
|
let mut o = CHandle::null();
|
|
unsafe { oaknode_track_get_block_at(track.clone(), idx, &mut o) };
|
|
o
|
|
}
|
|
|
|
/// A new clip block.
|
|
fn mk_clip() -> CHandle {
|
|
unsafe { oaknode_block_clip_create() }
|
|
}
|
|
|
|
/// A new gap block.
|
|
fn mk_gap() -> CHandle {
|
|
unsafe { oaknode_block_gap_create() }
|
|
}
|
|
|
|
/// Set a block's in/out/length/media-in points directly.
|
|
fn set_times(h: &CHandle, in_: (i32, i32), out: (i32, i32), len: (i32, i32), mi: (i32, i32)) {
|
|
let b = unsafe { get_mut::<MockNode>(h).unwrap() };
|
|
b.in_ = in_;
|
|
b.out = out;
|
|
b.length = len;
|
|
b.media_in = mi;
|
|
}
|
|
|
|
/// A block's length as `(num, den)`.
|
|
fn blen(h: &CHandle) -> (i32, i32) {
|
|
unsafe { get::<MockNode>(h).unwrap().length }
|
|
}
|
|
|
|
/// A block's in point as `(num, den)`.
|
|
fn b_in(h: &CHandle) -> (i32, i32) {
|
|
unsafe { get::<MockNode>(h).unwrap().in_ }
|
|
}
|
|
|
|
/// A block's media-in as `(num, den)`.
|
|
fn media_in(h: &CHandle) -> (i32, i32) {
|
|
unsafe { get::<MockNode>(h).unwrap().media_in }
|
|
}
|
|
|
|
/// A block's enabled flag.
|
|
fn enabled(h: &CHandle) -> bool {
|
|
unsafe { get::<MockNode>(h).unwrap().enabled }
|
|
}
|
|
|
|
/// A block's owning-track raw pointer.
|
|
fn track_ptr(h: &CHandle) -> *mut MockNode {
|
|
unsafe { get::<MockNode>(h).unwrap().track }
|
|
}
|
|
|
|
/// A node's kind.
|
|
fn kind_of(h: &CHandle) -> MockKind {
|
|
unsafe { get::<MockNode>(h).unwrap().kind }
|
|
}
|
|
|
|
/// A node's track type.
|
|
fn track_type_of(h: &CHandle) -> i32 {
|
|
unsafe { get::<MockNode>(h).unwrap().track_type }
|
|
}
|
|
|
|
/// A new empty track list of video type (0).
|
|
fn make_list() -> CHandle {
|
|
make_owned(MockNode {
|
|
kind: MockKind::TrackList,
|
|
track_type: 0,
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
/// Add a track to a track list (mock: push onto `blocks`).
|
|
fn list_add_track(list: &CHandle, t: &CHandle) {
|
|
unsafe {
|
|
get_mut::<MockNode>(list).unwrap().blocks.push(addr(t));
|
|
}
|
|
}
|
|
|
|
/// A new empty sequence.
|
|
fn make_sequence() -> CHandle {
|
|
make_owned(MockNode {
|
|
kind: MockKind::Sequence,
|
|
..Default::default()
|
|
})
|
|
}
|
|
|
|
/// Add a track list to a sequence (mock: push onto `blocks`).
|
|
fn seq_add_list(seq: &CHandle, list: &CHandle) {
|
|
unsafe {
|
|
get_mut::<MockNode>(seq).unwrap().blocks.push(addr(list));
|
|
}
|
|
}
|