// 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 .
//! Contract tests for the single-lib domain commands: the undo commands
//! drive a real oaknode project graph through `oaktimeline::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 oakcore_rs::{Rational, TimeRange};
use oaknode::block::{ClipBlockBehavior, GapBlockBehavior};
use oaknode::project::Project;
use oaknode::sequence::SequenceBehavior;
use oaknode::track::{TrackBehavior, TrackListBehavior, TrackType};
use oaktimeline::undocommon::Command;
use oaktimeline::undogeneral::{
BlockResizeCommand, TimelineAddTrackCommand, TimelineRemoveTrackCommand,
TrackReplaceBlockWithGapCommand,
};
use oaktimeline::undopointer::TrackMoveBlockCommand;
use oaktimeline::undoripple::TrackRippleRemoveAreaCommand;
use oaktimeline::undosplit::BlockSplitCommand;
use oaktimeline::util::{
block_clip_create, block_gap_create, block_in, block_length, block_out, block_track,
track_append_block, track_block_at, track_block_count, track_length, track_ripple_remove_block,
tracklist_track_count, NodeRef,
};
/// A project with one sequence owning one (video) track list.
fn make_project() -> Arc> {
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::() {
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::() {
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>,
) -> (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::())
.is_some()
{
seq = Some(NodeRef::new(project.clone(), id));
} else if e
.behavior
.as_any()
.and_then(|a| a.downcast_ref::())
.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::() {
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::() {
g.core.range = TimeRange::new(in_, out);
}
}
}
}
track_append_block(track, &gap);
gap
}
/// 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::().map(|b| &b.core),
a.downcast_ref::().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::())
.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.
#[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 mut cmd = TrackReplaceBlockWithGapCommand::new(track.clone(), clip.clone(), false);
cmd.redo();
// The clip is gone; the gap absorbed its length (in-anchored at 50).
assert_eq!(track_block_count(&track), 1);
let remaining = track_block_at(&track, 0).unwrap();
assert_eq!(remaining.id, gap.id);
assert_eq!(block_in(&remaining), Rational::new(50, 1));
assert_eq!(block_length(&remaining), Rational::new(100, 1));
assert!(block_track(&clip).is_none());
cmd.undo();
assert_eq!(track_block_count(&track), 2);
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));
}
/// `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 out-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));
// Out-anchored: the in point shifts so the out stays at 50.
assert_eq!(block_out(&clip), Rational::new(50, 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 vtable wrappers)
/// 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 = oaktimeline::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)))
);
}
/// `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 =
oaktimeline::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)))
);
}