Dropping a new clip whose in-point landed in empty track space (the stored-range model allows holes between blocks; the C++ layout is contiguous) left the overlapped clip untouched AND inserted the new clip before it in track order, so it slid UNDER the clip it covered. Starting on a clip already overwrote correctly, so the behavior depended on where the in-point happened to fall. TrackRippleRemoveAreaCommand::prepare now handles the hole case (no block spans the range start): nothing is trimmed on the left, the insertion anchor is the last block ending at/before the range, and the shared trailing scan removes/head-trims the blocks the range covers. Regression tests: domain_test (command level) and graphops (the app's place_footage_clip path).
769 lines
26 KiB
Rust
769 lines
26 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 single-lib domain commands: the undo commands
|
|
//! drive a real oaknode project graph through `oak_timeline::util::NodeRef`
|
|
//! (the CHandle-based bridge signatures left with the deleted C ABI).
|
|
//!
|
|
//! Each test builds a project with a sequence/track-list/track, applies a
|
|
//! command through `redo`, checks the graph state, and `undo`s back to the
|
|
//! original state.
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use oak_core::{Rational, TimeRange};
|
|
use oak_node::block::{
|
|
transition_input::{IN_BLOCK, OUT_BLOCK},
|
|
ClipBlockBehavior, GapBlockBehavior, TransitionBlockBehavior,
|
|
};
|
|
use oak_node::project::Project;
|
|
use oak_node::sequence::SequenceBehavior;
|
|
use oak_node::track::{TrackBehavior, TrackListBehavior, TrackType};
|
|
use oak_timeline::undocommon::Command;
|
|
use oak_timeline::undogeneral::{
|
|
BlockResizeCommand, TimelineAddTrackCommand, TimelineRemoveTrackCommand,
|
|
TrackReplaceBlockWithGapCommand, TransitionRemoveCommand, TransitionSetOffsetsCommand,
|
|
};
|
|
use oak_timeline::undopointer::TrackMoveBlockCommand;
|
|
use oak_timeline::undoripple::TrackRippleRemoveAreaCommand;
|
|
use oak_timeline::undosplit::BlockSplitCommand;
|
|
use oak_timeline::util::{
|
|
block_clip_create, block_connect, block_connected_input, block_gap_create, block_in,
|
|
block_length, block_out, block_range, block_track, track_append_block, track_block_at,
|
|
track_block_count, track_length, track_ripple_remove_block, tracklist_track_count,
|
|
transition_offsets, transition_set_offsets, NodeRef,
|
|
};
|
|
|
|
/// A project with one sequence owning one (video) track list.
|
|
fn make_project() -> Arc<Mutex<Project>> {
|
|
let project = Project::new();
|
|
let (seq_id, list_id) = {
|
|
let mut p = project.lock().unwrap();
|
|
let (core, behavior) = SequenceBehavior::create();
|
|
let seq_id = p.graph.add_node(core, behavior);
|
|
let (core, behavior) = TrackListBehavior::create();
|
|
let list_id = p.graph.add_node(core, behavior);
|
|
if let Some(e) = p.graph.get_mut(seq_id) {
|
|
if let Some(a) = e.behavior.as_any_mut() {
|
|
if let Some(s) = a.downcast_mut::<SequenceBehavior>() {
|
|
s.track_lists.push(list_id);
|
|
}
|
|
}
|
|
}
|
|
if let Some(e) = p.graph.get_mut(list_id) {
|
|
if let Some(a) = e.behavior.as_any_mut() {
|
|
if let Some(l) = a.downcast_mut::<TrackListBehavior>() {
|
|
l.sequence = Some(seq_id);
|
|
}
|
|
}
|
|
}
|
|
(seq_id, list_id)
|
|
};
|
|
let _ = (seq_id, list_id);
|
|
project
|
|
}
|
|
|
|
/// The sequence and its video track list of `project`.
|
|
fn sequence_and_list(
|
|
project: &Arc<Mutex<Project>>,
|
|
) -> (NodeRef, NodeRef) {
|
|
let p = project.lock().unwrap();
|
|
let mut seq = None;
|
|
let mut list = None;
|
|
for id in p.graph.node_ids() {
|
|
if let Some(e) = p.graph.get(id) {
|
|
if e.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
|
|
.is_some()
|
|
{
|
|
seq = Some(NodeRef::new(project.clone(), id));
|
|
} else if e
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<TrackListBehavior>())
|
|
.is_some()
|
|
{
|
|
list = Some(NodeRef::new(project.clone(), id));
|
|
}
|
|
}
|
|
}
|
|
(seq.unwrap(), list.unwrap())
|
|
}
|
|
|
|
/// Add a clip block spanning `[in, out)` to `track`.
|
|
fn add_clip(track: &NodeRef, in_: Rational, out: Rational) -> NodeRef {
|
|
let clip = block_clip_create(&track.project);
|
|
{
|
|
let mut p = track.project.lock().unwrap();
|
|
if let Some(e) = p.graph.get_mut(clip.id) {
|
|
if let Some(a) = e.behavior.as_any_mut() {
|
|
if let Some(c) = a.downcast_mut::<ClipBlockBehavior>() {
|
|
c.core.range = TimeRange::new(in_, out);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
track_append_block(track, &clip);
|
|
clip
|
|
}
|
|
|
|
/// Add a gap block spanning `[in, out)` to `track`.
|
|
fn add_gap(track: &NodeRef, in_: Rational, out: Rational) -> NodeRef {
|
|
let gap = block_gap_create(&track.project);
|
|
{
|
|
let mut p = track.project.lock().unwrap();
|
|
if let Some(e) = p.graph.get_mut(gap.id) {
|
|
if let Some(a) = e.behavior.as_any_mut() {
|
|
if let Some(g) = a.downcast_mut::<GapBlockBehavior>() {
|
|
g.core.range = TimeRange::new(in_, out);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
track_append_block(track, &gap);
|
|
gap
|
|
}
|
|
|
|
/// Add a transition block spanning `[in, out)` with the given wedge
|
|
/// offsets to `track`.
|
|
fn add_transition(
|
|
track: &NodeRef,
|
|
in_: Rational,
|
|
out: Rational,
|
|
in_offset: Rational,
|
|
out_offset: Rational,
|
|
) -> NodeRef {
|
|
let (core, behavior) = oak_node::block::transition_create();
|
|
let transition = {
|
|
let mut p = track.project.lock().unwrap();
|
|
let id = p.graph.add_node(core, behavior);
|
|
if let Some(a) = p.graph.get_mut(id).and_then(|e| e.behavior.as_any_mut()) {
|
|
if let Some(t) = a.downcast_mut::<TransitionBlockBehavior>() {
|
|
t.core.range = TimeRange::new(in_, out);
|
|
t.in_offset = in_offset;
|
|
t.out_offset = out_offset;
|
|
}
|
|
}
|
|
NodeRef::new(track.project.clone(), id)
|
|
};
|
|
track_append_block(track, &transition);
|
|
transition
|
|
}
|
|
|
|
/// The block span of `block` (`None` for a stale node).
|
|
fn span_of(block: &NodeRef) -> Option<(Rational, Rational)> {
|
|
let p = block.project.lock().unwrap();
|
|
p.graph.get(block.id).and_then(|e| e.behavior.as_any()).and_then(|a| {
|
|
[
|
|
a.downcast_ref::<ClipBlockBehavior>().map(|b| &b.core),
|
|
a.downcast_ref::<GapBlockBehavior>().map(|b| &b.core),
|
|
]
|
|
.into_iter()
|
|
.flatten()
|
|
.next()
|
|
.map(|core| (core.in_(), core.out()))
|
|
})
|
|
}
|
|
|
|
/// `TimelineAddTrackCommand` redo appends a track to the list; undo removes
|
|
/// it again, and a second redo restores the same track node.
|
|
#[test]
|
|
fn add_track_command_redo_undo_cycle() {
|
|
let project = make_project();
|
|
let (_seq, list) = sequence_and_list(&project);
|
|
|
|
assert_eq!(tracklist_track_count(&list), 0);
|
|
let track = TimelineAddTrackCommand::run_immediately(list.clone());
|
|
assert_eq!(tracklist_track_count(&list), 1);
|
|
assert_eq!(track_length(&track), Rational::new(0, 1));
|
|
|
|
let mut cmd = TimelineRemoveTrackCommand::new(track.clone());
|
|
cmd.redo();
|
|
assert_eq!(tracklist_track_count(&list), 0);
|
|
cmd.undo();
|
|
assert_eq!(tracklist_track_count(&list), 1);
|
|
cmd.redo();
|
|
assert_eq!(tracklist_track_count(&list), 0);
|
|
}
|
|
|
|
/// `TimelineAddTrackCommand` creates the track with the list's media type.
|
|
#[test]
|
|
fn add_track_command_track_type() {
|
|
let project = make_project();
|
|
let (_seq, list) = sequence_and_list(&project);
|
|
|
|
let track = TimelineAddTrackCommand::run_immediately(list);
|
|
{
|
|
let p = project.lock().unwrap();
|
|
let entry = p.graph.get(track.id).unwrap();
|
|
let behavior = entry
|
|
.behavior
|
|
.as_any()
|
|
.and_then(|a| a.downcast_ref::<TrackBehavior>())
|
|
.unwrap();
|
|
assert_eq!(behavior.kind, TrackType::Video);
|
|
}
|
|
}
|
|
|
|
/// `BlockSplitCommand` splits a clip at a point: the original becomes the
|
|
/// first half `[in, point)` and the clone the second half `[point, out)`;
|
|
/// undo restores the single original block.
|
|
#[test]
|
|
fn split_command_redo_undo() {
|
|
let project = make_project();
|
|
let (_seq, list) = sequence_and_list(&project);
|
|
let track = TimelineAddTrackCommand::run_immediately(list.clone());
|
|
let clip = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1));
|
|
|
|
let mut split = BlockSplitCommand::new(clip.clone(), Rational::new(40, 1));
|
|
split.prepare();
|
|
split.redo();
|
|
|
|
assert_eq!(block_length(&clip), Rational::new(40, 1));
|
|
assert_eq!(block_in(&clip), Rational::new(0, 1));
|
|
let second = split.new_block().unwrap();
|
|
assert_eq!(block_in(&second), Rational::new(40, 1));
|
|
assert_eq!(block_out(&second), Rational::new(100, 1));
|
|
assert_eq!(track_block_count(&track), 2);
|
|
|
|
split.undo();
|
|
assert_eq!(block_length(&clip), Rational::new(100, 1));
|
|
assert_eq!(block_out(&clip), Rational::new(100, 1));
|
|
assert_eq!(track_block_count(&track), 1);
|
|
|
|
// The undo detached the second half from the graph; the next redo
|
|
// re-attaches it with the same identity.
|
|
split.redo();
|
|
assert_eq!(block_length(&clip), Rational::new(40, 1));
|
|
assert_eq!(split.new_block().unwrap().id, second.id);
|
|
assert_eq!(track_block_count(&track), 2);
|
|
}
|
|
|
|
/// `TrackReplaceBlockWithGapCommand` merges a clip into a following gap on
|
|
/// redo and restores both blocks on undo. The gap grows LEFTWARD over the
|
|
/// clip's span (out anchored): growing it at the out instead would swallow
|
|
/// the clip that follows the gap — the "dragging one clip moved unrelated
|
|
/// clips" regression.
|
|
#[test]
|
|
fn replace_block_with_gap_round_trip() {
|
|
let project = make_project();
|
|
let (_seq, list) = sequence_and_list(&project);
|
|
let track = TimelineAddTrackCommand::run_immediately(list);
|
|
let clip = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1));
|
|
let gap = add_gap(&track, Rational::new(50, 1), Rational::new(100, 1));
|
|
let tail = add_clip(&track, Rational::new(100, 1), Rational::new(150, 1));
|
|
|
|
let mut cmd = TrackReplaceBlockWithGapCommand::new(track.clone(), clip.clone(), false);
|
|
cmd.redo();
|
|
// The clip is gone; the FOLLOWING gap grew leftward over its span.
|
|
assert_eq!(track_block_count(&track), 2);
|
|
let remaining = track_block_at(&track, 0).unwrap();
|
|
assert_eq!(remaining.id, gap.id);
|
|
assert_eq!(block_in(&remaining), Rational::new(0, 1));
|
|
assert_eq!(block_length(&remaining), Rational::new(100, 1));
|
|
assert!(block_track(&clip).is_none());
|
|
assert_eq!(track_block_at(&track, 1).unwrap().id, tail.id);
|
|
assert_eq!(block_in(&tail), Rational::new(100, 1));
|
|
assert_eq!(block_out(&tail), Rational::new(150, 1), "the clip after the gap is untouched");
|
|
|
|
cmd.undo();
|
|
assert_eq!(track_block_count(&track), 3);
|
|
let first = track_block_at(&track, 0).unwrap();
|
|
assert_eq!(first.id, clip.id);
|
|
assert_eq!(block_in(&first), Rational::new(0, 1));
|
|
assert_eq!(block_length(&first), Rational::new(50, 1));
|
|
let second = track_block_at(&track, 1).unwrap();
|
|
assert_eq!(second.id, gap.id);
|
|
assert_eq!(block_in(&second), Rational::new(50, 1));
|
|
assert_eq!(block_length(&second), Rational::new(50, 1));
|
|
assert_eq!(track_block_at(&track, 2).unwrap().id, tail.id);
|
|
assert_eq!(block_in(&tail), Rational::new(100, 1));
|
|
assert_eq!(block_out(&tail), Rational::new(150, 1));
|
|
}
|
|
|
|
/// `TrackReplaceBlockWithGapCommand` creates its own gap when no
|
|
/// neighbouring gap exists, and swaps it back out on undo.
|
|
#[test]
|
|
fn replace_block_with_gap_creates_gap() {
|
|
let project = make_project();
|
|
let (_seq, list) = sequence_and_list(&project);
|
|
let track = TimelineAddTrackCommand::run_immediately(list);
|
|
let clip = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1));
|
|
let tail = add_clip(&track, Rational::new(50, 1), Rational::new(100, 1));
|
|
|
|
let mut cmd = TrackReplaceBlockWithGapCommand::new(track.clone(), clip.clone(), false);
|
|
cmd.redo();
|
|
// clip replaced by a fresh gap of the same length.
|
|
assert_eq!(track_block_count(&track), 2);
|
|
let first = track_block_at(&track, 0).unwrap();
|
|
assert_ne!(first.id, clip.id);
|
|
assert_eq!(block_in(&first), Rational::new(0, 1));
|
|
assert_eq!(block_length(&first), Rational::new(50, 1));
|
|
assert_eq!(track_block_at(&track, 1).unwrap().id, tail.id);
|
|
|
|
cmd.undo();
|
|
assert_eq!(track_block_count(&track), 2);
|
|
let restored = track_block_at(&track, 0).unwrap();
|
|
assert_eq!(restored.id, clip.id);
|
|
assert_eq!(block_length(&restored), Rational::new(50, 1));
|
|
}
|
|
|
|
/// `TrackRippleRemoveAreaCommand` trims the boundary blocks and removes
|
|
/// the blocks fully inside the range; undo restores everything.
|
|
#[test]
|
|
fn ripple_remove_area_round_trip() {
|
|
let project = make_project();
|
|
let (_seq, list) = sequence_and_list(&project);
|
|
let track = TimelineAddTrackCommand::run_immediately(list);
|
|
let c1 = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1));
|
|
let c2 = add_clip(&track, Rational::new(50, 1), Rational::new(100, 1));
|
|
let c3 = add_clip(&track, Rational::new(100, 1), Rational::new(150, 1));
|
|
|
|
let mut cmd = TrackRippleRemoveAreaCommand::new(
|
|
track.clone(),
|
|
TimeRange::new(Rational::new(25, 1), Rational::new(125, 1)),
|
|
);
|
|
cmd.prepare();
|
|
cmd.redo();
|
|
|
|
// c2 removed; c1 trimmed to [0,25); c3 out-anchored to [125,150).
|
|
assert_eq!(track_block_count(&track), 2);
|
|
let first = track_block_at(&track, 0).unwrap();
|
|
assert_eq!(first.id, c1.id);
|
|
assert_eq!(span_of(&first), Some((Rational::new(0, 1), Rational::new(25, 1))));
|
|
let second = track_block_at(&track, 1).unwrap();
|
|
assert_eq!(second.id, c3.id);
|
|
assert_eq!(
|
|
span_of(&second),
|
|
Some((Rational::new(125, 1), Rational::new(150, 1)))
|
|
);
|
|
|
|
cmd.undo();
|
|
assert_eq!(track_block_count(&track), 3);
|
|
assert_eq!(track_block_at(&track, 0).unwrap().id, c1.id);
|
|
assert_eq!(track_block_at(&track, 1).unwrap().id, c2.id);
|
|
assert_eq!(track_block_at(&track, 2).unwrap().id, c3.id);
|
|
assert_eq!(span_of(&c1), Some((Rational::new(0, 1), Rational::new(50, 1))));
|
|
assert_eq!(
|
|
span_of(&c2),
|
|
Some((Rational::new(50, 1), Rational::new(100, 1)))
|
|
);
|
|
assert_eq!(
|
|
span_of(&c3),
|
|
Some((Rational::new(100, 1), Rational::new(150, 1)))
|
|
);
|
|
}
|
|
|
|
/// `BlockResizeCommand` changes a block's length in-anchored and restores
|
|
/// it on undo.
|
|
#[test]
|
|
fn block_resize_round_trip() {
|
|
let project = make_project();
|
|
let (_seq, list) = sequence_and_list(&project);
|
|
let track = TimelineAddTrackCommand::run_immediately(list);
|
|
let clip = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1));
|
|
|
|
let mut cmd = BlockResizeCommand::new(clip.clone(), Rational::new(30, 1));
|
|
cmd.redo();
|
|
assert_eq!(block_length(&clip), Rational::new(30, 1));
|
|
// In-anchored: the in point stays so the out follows the length.
|
|
assert_eq!(block_in(&clip), Rational::new(0, 1));
|
|
assert_eq!(block_out(&clip), Rational::new(30, 1));
|
|
cmd.undo();
|
|
assert_eq!(block_length(&clip), Rational::new(50, 1));
|
|
assert_eq!(block_in(&clip), Rational::new(0, 1));
|
|
}
|
|
|
|
/// `TrackMoveBlockCommand` replaces the source spot with a gap and places
|
|
/// the block at the destination; undo restores the original layout.
|
|
#[test]
|
|
fn move_block_round_trip() {
|
|
let project = make_project();
|
|
let (_seq, list) = sequence_and_list(&project);
|
|
let track = TimelineAddTrackCommand::run_immediately(list.clone());
|
|
let clip = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1));
|
|
let tail = add_clip(&track, Rational::new(50, 1), Rational::new(100, 1));
|
|
|
|
let mut cmd = TrackMoveBlockCommand::new(
|
|
list,
|
|
0,
|
|
clip.clone(),
|
|
Rational::new(150, 1),
|
|
);
|
|
cmd.redo();
|
|
// Layout: a gap fills the clip's old spot [0,50), the tail stays at
|
|
// [50,100), a bridging gap covers [100,150) and the moved clip is
|
|
// appended at [150,200).
|
|
assert_eq!(track_block_count(&track), 4);
|
|
assert_eq!(block_in(&clip), Rational::new(150, 1));
|
|
assert_eq!(block_length(&clip), Rational::new(50, 1));
|
|
let first = track_block_at(&track, 0).unwrap();
|
|
assert_eq!(span_of(&first), Some((Rational::new(0, 1), Rational::new(50, 1))));
|
|
assert_eq!(track_block_at(&track, 1).unwrap().id, tail.id);
|
|
let third = track_block_at(&track, 2).unwrap();
|
|
assert_eq!(
|
|
span_of(&third),
|
|
Some((Rational::new(100, 1), Rational::new(150, 1)))
|
|
);
|
|
assert_eq!(track_block_at(&track, 3).unwrap().id, clip.id);
|
|
|
|
cmd.undo();
|
|
assert_eq!(track_block_count(&track), 2);
|
|
assert_eq!(track_block_at(&track, 0).unwrap().id, clip.id);
|
|
assert_eq!(track_block_at(&track, 1).unwrap().id, tail.id);
|
|
assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(50, 1))));
|
|
assert_eq!(span_of(&tail), Some((Rational::new(50, 1), Rational::new(100, 1))));
|
|
}
|
|
|
|
/// The `Command` trait dispatch (used by the oakundo command values)
|
|
/// routes through the same redo/undo bodies as the inherent methods.
|
|
#[test]
|
|
fn commands_trait_dispatch() {
|
|
let project = make_project();
|
|
let (_seq, list) = sequence_and_list(&project);
|
|
let track = TimelineAddTrackCommand::run_immediately(list);
|
|
let clip = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1));
|
|
|
|
let mut resize = BlockResizeCommand::new(clip.clone(), Rational::new(10, 1));
|
|
Command::redo(&mut resize);
|
|
assert_eq!(block_length(&clip), Rational::new(10, 1));
|
|
Command::undo(&mut resize);
|
|
assert_eq!(block_length(&clip), Rational::new(50, 1));
|
|
}
|
|
|
|
/// `TrackPlaceBlockCommand` splices a clip into the middle of another:
|
|
/// the host block is split at the range in, the remainder is trimmed to
|
|
/// the range out, and the placed block lands in between; undo restores
|
|
/// the single host block.
|
|
#[test]
|
|
fn place_block_splice_round_trip() {
|
|
let project = make_project();
|
|
let (_seq, list) = sequence_and_list(&project);
|
|
let track = TimelineAddTrackCommand::run_immediately(list.clone());
|
|
let host = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1));
|
|
let placed = add_clip(&track, Rational::new(25, 1), Rational::new(35, 1));
|
|
track_ripple_remove_block(&track, &placed);
|
|
|
|
let mut cmd = oak_timeline::undopointer::TrackPlaceBlockCommand::new(
|
|
list.clone(),
|
|
0,
|
|
placed.clone(),
|
|
Rational::new(25, 1),
|
|
);
|
|
cmd.redo();
|
|
// host split to [0,25), remainder trimmed to [25,90), placed at [25,35).
|
|
assert_eq!(track_block_count(&track), 3);
|
|
assert_eq!(track_block_at(&track, 0).unwrap().id, host.id);
|
|
assert_eq!(track_block_at(&track, 1).unwrap().id, placed.id);
|
|
assert_eq!(
|
|
span_of(&host),
|
|
Some((Rational::new(0, 1), Rational::new(25, 1)))
|
|
);
|
|
|
|
cmd.undo();
|
|
assert_eq!(track_block_count(&track), 1);
|
|
assert_eq!(track_block_at(&track, 0).unwrap().id, host.id);
|
|
assert_eq!(
|
|
span_of(&host),
|
|
Some((Rational::new(0, 1), Rational::new(100, 1)))
|
|
);
|
|
}
|
|
|
|
/// `TrackPlaceBlockCommand` starting in EMPTY SPACE between blocks (the
|
|
/// stored model allows holes; the C++ layout is contiguous) must still
|
|
/// overwrite what it covers: the following clip is head-trimmed at the
|
|
/// placed block's out point and the placed block lands BEFORE it in track
|
|
/// order. Missing this used to leave the overlapped clip untouched and
|
|
/// insert the placed block before it, so the drop slid UNDER the clip it
|
|
/// covered instead of overwriting it.
|
|
#[test]
|
|
fn place_block_from_empty_space_overwrites_the_overlapped_clip() {
|
|
let project = make_project();
|
|
let (_seq, list) = sequence_and_list(&project);
|
|
let track = TimelineAddTrackCommand::run_immediately(list.clone());
|
|
let head = add_clip(&track, Rational::new(0, 1), Rational::new(40, 1));
|
|
let tail = add_clip(&track, Rational::new(100, 1), Rational::new(200, 1));
|
|
let placed = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1));
|
|
track_ripple_remove_block(&track, &placed);
|
|
|
|
let mut cmd = oak_timeline::undopointer::TrackPlaceBlockCommand::new(
|
|
list.clone(),
|
|
0,
|
|
placed.clone(),
|
|
Rational::new(50, 1),
|
|
);
|
|
cmd.redo();
|
|
assert_eq!(track_block_count(&track), 3);
|
|
assert_eq!(track_block_at(&track, 0).unwrap().id, head.id);
|
|
assert_eq!(
|
|
track_block_at(&track, 1).unwrap().id,
|
|
placed.id,
|
|
"the placed block lands before the clip it covers"
|
|
);
|
|
assert_eq!(
|
|
span_of(&placed),
|
|
Some((Rational::new(50, 1), Rational::new(150, 1)))
|
|
);
|
|
assert_eq!(
|
|
span_of(&tail),
|
|
Some((Rational::new(150, 1), Rational::new(200, 1))),
|
|
"the covered head is removed"
|
|
);
|
|
assert_eq!(
|
|
span_of(&head),
|
|
Some((Rational::new(0, 1), Rational::new(40, 1))),
|
|
"the block before the hole is untouched"
|
|
);
|
|
|
|
cmd.undo();
|
|
assert_eq!(track_block_count(&track), 2);
|
|
assert_eq!(span_of(&head), Some((Rational::new(0, 1), Rational::new(40, 1))));
|
|
assert_eq!(
|
|
span_of(&tail),
|
|
Some((Rational::new(100, 1), Rational::new(200, 1)))
|
|
);
|
|
}
|
|
|
|
/// `TrackListInsertGaps` splits a block at the insertion point and
|
|
/// inserts a gap of the requested length after it; undo removes the gap
|
|
/// and re-joins the block.
|
|
#[test]
|
|
fn insert_gaps_round_trip() {
|
|
let project = make_project();
|
|
let (_seq, list) = sequence_and_list(&project);
|
|
let track = TimelineAddTrackCommand::run_immediately(list.clone());
|
|
let clip = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1));
|
|
|
|
let mut cmd =
|
|
oak_timeline::undogeneral::TrackListInsertGaps::new(
|
|
list.clone(),
|
|
Rational::new(40, 1),
|
|
Rational::new(20, 1),
|
|
);
|
|
cmd.prepare();
|
|
cmd.redo();
|
|
// clip split to [0,40) + [40,100), gap [40,60) inserted after the
|
|
// first half.
|
|
assert_eq!(track_block_count(&track), 3);
|
|
let first = track_block_at(&track, 0).unwrap();
|
|
assert_eq!(first.id, clip.id);
|
|
assert_eq!(span_of(&first), Some((Rational::new(0, 1), Rational::new(40, 1))));
|
|
let gap = track_block_at(&track, 1).unwrap();
|
|
assert_eq!(
|
|
span_of(&gap),
|
|
Some((Rational::new(40, 1), Rational::new(60, 1)))
|
|
);
|
|
|
|
cmd.undo();
|
|
assert_eq!(track_block_count(&track), 1);
|
|
assert_eq!(track_block_at(&track, 0).unwrap().id, clip.id);
|
|
assert_eq!(
|
|
span_of(&clip),
|
|
Some((Rational::new(0, 1), Rational::new(100, 1)))
|
|
);
|
|
}
|
|
|
|
/// `TransitionSetOffsetsCommand` rewrites the wedge offsets and moves the
|
|
/// stored range with them so the block stays pinned to its seam; undo
|
|
/// restores both.
|
|
#[test]
|
|
fn transition_set_offsets_round_trip() {
|
|
let project = make_project();
|
|
let (_seq, list) = sequence_and_list(&project);
|
|
let track = TimelineAddTrackCommand::run_immediately(list);
|
|
add_clip(&track, Rational::new(0, 1), Rational::new(50, 1));
|
|
// The transition overlaps both neighbours around the seam at 50.
|
|
let transition = add_transition(
|
|
&track,
|
|
Rational::new(40, 1),
|
|
Rational::new(60, 1),
|
|
Rational::new(10, 1),
|
|
Rational::new(10, 1),
|
|
);
|
|
add_clip(&track, Rational::new(50, 1), Rational::new(100, 1));
|
|
|
|
assert_eq!(
|
|
transition_offsets(&transition),
|
|
Some((Rational::new(10, 1), Rational::new(10, 1)))
|
|
);
|
|
|
|
// The helper writes both offsets directly.
|
|
transition_set_offsets(&transition, Rational::new(12, 1), Rational::new(8, 1));
|
|
assert_eq!(
|
|
transition_offsets(&transition),
|
|
Some((Rational::new(12, 1), Rational::new(8, 1)))
|
|
);
|
|
transition_set_offsets(&transition, Rational::new(10, 1), Rational::new(10, 1));
|
|
|
|
let mut cmd = TransitionSetOffsetsCommand::new(
|
|
transition.clone(),
|
|
Some(Rational::new(5, 1)),
|
|
Some(Rational::new(20, 1)),
|
|
);
|
|
cmd.redo();
|
|
assert_eq!(
|
|
transition_offsets(&transition),
|
|
Some((Rational::new(5, 1), Rational::new(20, 1)))
|
|
);
|
|
// Range follows the offsets: [seam - in, seam + out), seam = 40 + 10.
|
|
assert_eq!(
|
|
block_range(&transition),
|
|
Some(TimeRange::new(Rational::new(45, 1), Rational::new(70, 1)))
|
|
);
|
|
|
|
cmd.undo();
|
|
assert_eq!(
|
|
transition_offsets(&transition),
|
|
Some((Rational::new(10, 1), Rational::new(10, 1)))
|
|
);
|
|
assert_eq!(
|
|
block_range(&transition),
|
|
Some(TimeRange::new(Rational::new(40, 1), Rational::new(60, 1)))
|
|
);
|
|
|
|
// `None` keeps the offset on that side.
|
|
let mut partial =
|
|
TransitionSetOffsetsCommand::new(transition.clone(), Some(Rational::new(7, 1)), None);
|
|
partial.redo();
|
|
assert_eq!(
|
|
transition_offsets(&transition),
|
|
Some((Rational::new(7, 1), Rational::new(10, 1)))
|
|
);
|
|
assert_eq!(
|
|
block_range(&transition),
|
|
Some(TimeRange::new(Rational::new(43, 1), Rational::new(60, 1)))
|
|
);
|
|
|
|
partial.undo();
|
|
assert_eq!(
|
|
transition_offsets(&transition),
|
|
Some((Rational::new(10, 1), Rational::new(10, 1)))
|
|
);
|
|
assert_eq!(
|
|
block_range(&transition),
|
|
Some(TimeRange::new(Rational::new(40, 1), Rational::new(60, 1)))
|
|
);
|
|
}
|
|
|
|
/// `TransitionRemoveCommand` unlinks the transition from its neighbours and
|
|
/// its two node inputs; undo puts it back between them with the offsets,
|
|
/// the range and both edges restored.
|
|
#[test]
|
|
fn transition_remove_restores_offsets_and_edges() {
|
|
let project = make_project();
|
|
let (_seq, list) = sequence_and_list(&project);
|
|
let track = TimelineAddTrackCommand::run_immediately(list);
|
|
let a = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1));
|
|
let transition = add_transition(
|
|
&track,
|
|
Rational::new(40, 1),
|
|
Rational::new(60, 1),
|
|
Rational::new(10, 1),
|
|
Rational::new(10, 1),
|
|
);
|
|
let b = add_clip(&track, Rational::new(50, 1), Rational::new(100, 1));
|
|
block_connect(&a, &transition, OUT_BLOCK);
|
|
block_connect(&b, &transition, IN_BLOCK);
|
|
assert_eq!(
|
|
block_connected_input(&transition, OUT_BLOCK).map(|n| n.id),
|
|
Some(a.id)
|
|
);
|
|
assert_eq!(track_block_count(&track), 3);
|
|
|
|
let mut cmd = TransitionRemoveCommand::new(transition.clone(), false);
|
|
cmd.redo();
|
|
// The transition left the track and both clips remain; the edges are
|
|
// gone too.
|
|
assert_eq!(track_block_count(&track), 2);
|
|
assert_eq!(track_block_at(&track, 0).unwrap().id, a.id);
|
|
assert_eq!(track_block_at(&track, 1).unwrap().id, b.id);
|
|
assert!(block_track(&transition).is_none());
|
|
assert!(block_connected_input(&transition, OUT_BLOCK).is_none());
|
|
assert!(block_connected_input(&transition, IN_BLOCK).is_none());
|
|
|
|
cmd.undo();
|
|
assert_eq!(track_block_count(&track), 3);
|
|
assert_eq!(track_block_at(&track, 0).unwrap().id, a.id);
|
|
assert_eq!(track_block_at(&track, 1).unwrap().id, transition.id);
|
|
assert_eq!(track_block_at(&track, 2).unwrap().id, b.id);
|
|
assert_eq!(block_track(&transition).map(|t| t.id), Some(track.id));
|
|
assert_eq!(
|
|
transition_offsets(&transition),
|
|
Some((Rational::new(10, 1), Rational::new(10, 1)))
|
|
);
|
|
assert_eq!(
|
|
block_range(&transition),
|
|
Some(TimeRange::new(Rational::new(40, 1), Rational::new(60, 1)))
|
|
);
|
|
assert_eq!(
|
|
block_connected_input(&transition, OUT_BLOCK).map(|n| n.id),
|
|
Some(a.id)
|
|
);
|
|
assert_eq!(
|
|
block_connected_input(&transition, IN_BLOCK).map(|n| n.id),
|
|
Some(b.id)
|
|
);
|
|
}
|
|
|
|
/// `TransitionRemoveCommand` with `remove_from_graph` detaches the node
|
|
/// from the project graph as well; undo re-attaches it with the same
|
|
/// identity, track slot, offsets and edges.
|
|
#[test]
|
|
fn transition_remove_from_graph_round_trip() {
|
|
let project = make_project();
|
|
let (_seq, list) = sequence_and_list(&project);
|
|
let track = TimelineAddTrackCommand::run_immediately(list);
|
|
let a = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1));
|
|
let transition = add_transition(
|
|
&track,
|
|
Rational::new(40, 1),
|
|
Rational::new(60, 1),
|
|
Rational::new(10, 1),
|
|
Rational::new(10, 1),
|
|
);
|
|
let b = add_clip(&track, Rational::new(50, 1), Rational::new(100, 1));
|
|
block_connect(&a, &transition, OUT_BLOCK);
|
|
block_connect(&b, &transition, IN_BLOCK);
|
|
|
|
let mut cmd = TransitionRemoveCommand::new(transition.clone(), true);
|
|
cmd.redo();
|
|
assert_eq!(track_block_count(&track), 2);
|
|
assert!(block_track(&transition).is_none());
|
|
assert!(project.lock().unwrap().graph.get(transition.id).is_none());
|
|
|
|
cmd.undo();
|
|
assert!(project.lock().unwrap().graph.get(transition.id).is_some());
|
|
assert_eq!(track_block_count(&track), 3);
|
|
assert_eq!(track_block_at(&track, 1).unwrap().id, transition.id);
|
|
assert_eq!(
|
|
transition_offsets(&transition),
|
|
Some((Rational::new(10, 1), Rational::new(10, 1)))
|
|
);
|
|
assert_eq!(
|
|
block_range(&transition),
|
|
Some(TimeRange::new(Rational::new(40, 1), Rational::new(60, 1)))
|
|
);
|
|
assert_eq!(
|
|
block_connected_input(&transition, OUT_BLOCK).map(|n| n.id),
|
|
Some(a.id)
|
|
);
|
|
assert_eq!(
|
|
block_connected_input(&transition, IN_BLOCK).map(|n| n.id),
|
|
Some(b.id)
|
|
);
|
|
}
|