summaryrefslogtreecommitdiff
path: root/rust/crates/commands/tests
diff options
context:
space:
mode:
Diffstat (limited to 'rust/crates/commands/tests')
-rw-r--r--rust/crates/commands/tests/common/mod.rs118
-rw-r--r--rust/crates/commands/tests/framework.rs162
-rw-r--r--rust/crates/commands/tests/freeze_frame.rs155
3 files changed, 435 insertions, 0 deletions
diff --git a/rust/crates/commands/tests/common/mod.rs b/rust/crates/commands/tests/common/mod.rs
new file mode 100644
index 00000000..4cf45a9a
--- /dev/null
+++ b/rust/crates/commands/tests/common/mod.rs
@@ -0,0 +1,118 @@
+#![allow(dead_code)]
+
+use scene::{
+ Background, BaseTimelineElement, CanvasSize, Element, ImageElement, ParamValue,
+ ParamValues, Project, ProjectMetadata, ProjectSettings, Scene, VideoElement,
+};
+use time::{FrameRate, MediaTime};
+
+pub const SPLIT_SECONDS: f64 = 4.0;
+pub const FREEZE_SECONDS: f64 = 3.0;
+pub const VIDEO_SECONDS: f64 = 10.0;
+
+pub fn seconds(value: f64) -> MediaTime {
+ MediaTime::from_seconds_f64(value).expect("finite seconds")
+}
+
+pub fn default_params() -> ParamValues {
+ ParamValues::from([
+ ("transform.positionX".to_string(), ParamValue::Number(0.0)),
+ ("transform.positionY".to_string(), ParamValue::Number(0.0)),
+ ("transform.scaleX".to_string(), ParamValue::Number(1.0)),
+ ("transform.scaleY".to_string(), ParamValue::Number(1.0)),
+ ("transform.rotate".to_string(), ParamValue::Number(0.0)),
+ ("opacity".to_string(), ParamValue::Number(1.0)),
+ ])
+}
+
+pub fn base_element(id: &str, name: &str, duration: MediaTime) -> BaseTimelineElement {
+ BaseTimelineElement {
+ id: id.to_string(),
+ name: name.to_string(),
+ duration,
+ start_time: MediaTime::ZERO,
+ trim_start: MediaTime::ZERO,
+ trim_end: MediaTime::ZERO,
+ source_duration: None,
+ animations: None,
+ params: default_params(),
+ }
+}
+
+pub fn build_video_element() -> Element {
+ Element::Video(VideoElement {
+ base: base_element("video-1", "video-1", seconds(VIDEO_SECONDS)),
+ media_id: "media-video-1".to_string(),
+ is_source_audio_enabled: None,
+ hidden: None,
+ retime: None,
+ effects: None,
+ masks: None,
+ })
+}
+
+pub fn build_freeze_image_element() -> Element {
+ let mut base = base_element("freeze-template", "Freeze frame", seconds(FREEZE_SECONDS));
+ base.start_time = seconds(SPLIT_SECONDS);
+ Element::Image(ImageElement {
+ base,
+ media_id: "media-freeze-1".to_string(),
+ hidden: None,
+ effects: None,
+ masks: None,
+ })
+}
+
+pub fn active_scene(project: &Project) -> &Scene {
+ project
+ .scenes
+ .iter()
+ .find(|scene| scene.id == project.current_scene_id)
+ .expect("active scene")
+}
+
+pub fn seed_project_with_video() -> (Project, String) {
+ let now = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
+ .expect("valid date")
+ .to_utc();
+
+ let mut scene: Scene = timeline::scenes::build_default_scene(
+ "scene-1".to_string(),
+ "main-track-1".to_string(),
+ "Main scene".to_string(),
+ true,
+ now,
+ );
+ scene.tracks.main.elements_mut().push(build_video_element());
+ let track_id = scene.tracks.main.id().to_string();
+
+ let project = Project {
+ metadata: ProjectMetadata {
+ id: "project-1".to_string(),
+ name: "Test project".to_string(),
+ thumbnail: None,
+ duration: seconds(VIDEO_SECONDS),
+ created_at: now,
+ updated_at: now,
+ },
+ scenes: vec![scene],
+ current_scene_id: "scene-1".to_string(),
+ settings: ProjectSettings {
+ fps: FrameRate::new(30, 1),
+ canvas_size: CanvasSize {
+ width: 1920,
+ height: 1080,
+ },
+ canvas_size_mode: None,
+ last_custom_canvas_size: None,
+ original_canvas_size: None,
+ background: Background::Color {
+ color: "#000000".to_string(),
+ },
+ },
+ version: 1,
+ timeline_view_state: None,
+ };
+
+ (project, track_id)
+}
diff --git a/rust/crates/commands/tests/framework.rs b/rust/crates/commands/tests/framework.rs
new file mode 100644
index 00000000..e3defbad
--- /dev/null
+++ b/rust/crates/commands/tests/framework.rs
@@ -0,0 +1,162 @@
+mod common;
+
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use commands::{
+ BatchCommand, Command, CommandError, CommandHistory, CommandResult, DeleteElementsCommand,
+ PreviewTracker,
+};
+use common::{VIDEO_SECONDS, seconds, seed_project_with_video};
+use scene::{ElementRef, Project};
+
+struct RecordingCommand {
+ label: &'static str,
+ log: Rc<RefCell<Vec<&'static str>>>,
+ selection: Option<Vec<ElementRef>>,
+}
+
+impl RecordingCommand {
+ fn new(label: &'static str, log: &Rc<RefCell<Vec<&'static str>>>) -> Self {
+ Self {
+ label,
+ log: Rc::clone(log),
+ selection: None,
+ }
+ }
+
+ fn with_selection(mut self, selection: Vec<ElementRef>) -> Self {
+ self.selection = Some(selection);
+ self
+ }
+}
+
+impl Command for RecordingCommand {
+ fn execute(&mut self, _project: &mut Project) -> Result<Option<CommandResult>, CommandError> {
+ self.log.borrow_mut().push(self.label);
+ Ok(self
+ .selection
+ .clone()
+ .map(CommandResult::element_selection))
+ }
+
+ fn undo(&mut self, _project: &mut Project) {
+ self.log.borrow_mut().push(self.label);
+ }
+}
+
+#[test]
+fn batch_executes_in_order_and_undoes_in_reverse() {
+ let (mut project, _) = seed_project_with_video();
+ let log = Rc::new(RefCell::new(Vec::new()));
+
+ let mut batch = BatchCommand::new(vec![
+ Box::new(RecordingCommand::new("first", &log)),
+ Box::new(RecordingCommand::new("second", &log)),
+ Box::new(RecordingCommand::new("third", &log)),
+ ]);
+
+ batch.execute(&mut project).expect("execute");
+ assert_eq!(*log.borrow(), vec!["first", "second", "third"]);
+
+ log.borrow_mut().clear();
+ batch.undo(&mut project);
+ assert_eq!(*log.borrow(), vec!["third", "second", "first"]);
+}
+
+#[test]
+fn batch_returns_latest_selection_result() {
+ let (mut project, _) = seed_project_with_video();
+ let log = Rc::new(RefCell::new(Vec::new()));
+
+ let selected = vec![ElementRef {
+ track_id: "main-track-1".to_string(),
+ element_id: "video-1".to_string(),
+ }];
+ let mut batch = BatchCommand::new(vec![
+ Box::new(
+ RecordingCommand::new("selecting", &log).with_selection(selected.clone()),
+ ),
+ Box::new(RecordingCommand::new("plain", &log)),
+ ]);
+
+ let result = batch.execute(&mut project).expect("execute");
+ let selection = result
+ .and_then(|result| result.selection)
+ .expect("selection result");
+ assert_eq!(selection.selected_elements, selected);
+}
+
+#[test]
+fn history_undo_redo_roundtrip() {
+ let (mut project, track_id) = seed_project_with_video();
+ let mut history = CommandHistory::new();
+
+ assert!(!history.can_undo());
+ assert!(!history.can_redo());
+
+ history
+ .execute(
+ Box::new(DeleteElementsCommand::new(vec![ElementRef {
+ track_id: track_id.clone(),
+ element_id: "video-1".to_string(),
+ }])),
+ &mut project,
+ )
+ .expect("execute");
+
+ let scene = common::active_scene;
+ assert!(scene(&project).tracks.main.elements().is_empty());
+ assert!(history.can_undo());
+ assert!(!history.can_redo());
+
+ history.undo(&mut project);
+ assert_eq!(scene(&project).tracks.main.elements().len(), 1);
+ assert!(!history.can_undo());
+ assert!(history.can_redo());
+
+ history.redo(&mut project).expect("redo");
+ assert!(scene(&project).tracks.main.elements().is_empty());
+ assert!(history.can_undo());
+ assert!(!history.can_redo());
+}
+
+#[test]
+fn history_new_execute_clears_redo_stack() {
+ let (mut project, track_id) = seed_project_with_video();
+ let mut history = CommandHistory::new();
+
+ let delete = || {
+ Box::new(DeleteElementsCommand::new(vec![ElementRef {
+ track_id: track_id.clone(),
+ element_id: "video-1".to_string(),
+ }])) as Box<dyn Command>
+ };
+
+ history.execute(delete(), &mut project).expect("execute");
+ history.undo(&mut project);
+ assert!(history.can_redo());
+
+ history.execute(delete(), &mut project).expect("execute");
+ assert!(!history.can_redo());
+
+ history.clear();
+ assert!(!history.can_undo());
+ assert!(!history.can_redo());
+}
+
+#[test]
+fn preview_tracker_keeps_first_snapshot_until_end() {
+ let mut tracker = PreviewTracker::new();
+ assert!(!tracker.is_active());
+ assert_eq!(tracker.end(), None);
+
+ tracker.begin(&seconds(VIDEO_SECONDS));
+ tracker.begin(&seconds(1.0));
+ assert!(tracker.is_active());
+ assert_eq!(tracker.snapshot(), Some(&seconds(VIDEO_SECONDS)));
+
+ assert_eq!(tracker.end(), Some(seconds(VIDEO_SECONDS)));
+ assert!(!tracker.is_active());
+ assert_eq!(tracker.snapshot(), None);
+}
diff --git a/rust/crates/commands/tests/freeze_frame.rs b/rust/crates/commands/tests/freeze_frame.rs
new file mode 100644
index 00000000..614b73f7
--- /dev/null
+++ b/rust/crates/commands/tests/freeze_frame.rs
@@ -0,0 +1,155 @@
+mod common;
+
+use commands::{
+ BatchCommand, Command, InsertElementCommand, InsertElementPlacement, RetainSide,
+ ShiftSplitRemainderCommand, SplitElementsCommand,
+};
+use common::{
+ FREEZE_SECONDS, SPLIT_SECONDS, VIDEO_SECONDS, build_freeze_image_element, seconds,
+ seed_project_with_video,
+};
+use scene::{Element, ElementRef, Project};
+use time::MediaTime;
+
+fn build_freeze_batch(track_id: &str) -> BatchCommand {
+ let split = SplitElementsCommand::new(
+ vec![ElementRef {
+ track_id: track_id.to_string(),
+ element_id: "video-1".to_string(),
+ }],
+ seconds(SPLIT_SECONDS),
+ RetainSide::Both,
+ );
+ let right_side = split.right_side_handle();
+ let shift = ShiftSplitRemainderCommand::new(
+ right_side,
+ seconds(SPLIT_SECONDS) + seconds(FREEZE_SECONDS),
+ );
+ let insert = InsertElementCommand::new(
+ build_freeze_image_element(),
+ InsertElementPlacement::Explicit {
+ track_id: track_id.to_string(),
+ },
+ );
+ BatchCommand::new(vec![Box::new(split), Box::new(shift), Box::new(insert)])
+}
+
+fn sorted_main_elements(project: &Project) -> Vec<Element> {
+ let scene = project
+ .scenes
+ .iter()
+ .find(|scene| scene.id == project.current_scene_id)
+ .expect("active scene");
+ let mut elements = scene.tracks.main.elements().to_vec();
+ elements.sort_by_key(|element| element.base().start_time);
+ elements
+}
+
+fn element_type_name(element: &Element) -> &'static str {
+ match element {
+ Element::Video(_) => "video",
+ Element::Image(_) => "image",
+ Element::Text(_) => "text",
+ Element::Audio(_) => "audio",
+ Element::Sticker(_) => "sticker",
+ Element::Graphic(_) => "graphic",
+ Element::Effect(_) => "effect",
+ }
+}
+
+#[test]
+fn splits_shifts_right_side_and_inserts_into_gap() {
+ let (mut project, track_id) = seed_project_with_video();
+
+ build_freeze_batch(&track_id)
+ .execute(&mut project)
+ .expect("execute");
+
+ let elements = sorted_main_elements(&project);
+ let type_names: Vec<&str> = elements.iter().map(element_type_name).collect();
+ assert_eq!(type_names, ["video", "image", "video"]);
+
+ let [left, image, right] = &elements[..] else {
+ panic!("expected three elements");
+ };
+ assert_eq!(left.base().start_time, MediaTime::ZERO);
+ assert_eq!(left.base().duration, seconds(SPLIT_SECONDS));
+ assert_eq!(image.base().start_time, seconds(SPLIT_SECONDS));
+ assert_eq!(image.base().duration, seconds(FREEZE_SECONDS));
+ assert_eq!(
+ right.base().start_time,
+ seconds(SPLIT_SECONDS) + seconds(FREEZE_SECONDS)
+ );
+ assert_eq!(
+ right.base().duration,
+ seconds(VIDEO_SECONDS - SPLIT_SECONDS)
+ );
+}
+
+#[test]
+fn undo_restores_original_element_as_single_step() {
+ let (mut project, track_id) = seed_project_with_video();
+ let mut batch = build_freeze_batch(&track_id);
+
+ batch.execute(&mut project).expect("execute");
+ batch.undo(&mut project);
+
+ let scene = project
+ .scenes
+ .iter()
+ .find(|scene| scene.id == project.current_scene_id)
+ .expect("active scene");
+ let elements = scene.tracks.main.elements();
+ assert_eq!(elements.len(), 1);
+ assert_eq!(elements[0].base().id, "video-1");
+ assert_eq!(elements[0].base().start_time, MediaTime::ZERO);
+ assert_eq!(elements[0].base().duration, seconds(VIDEO_SECONDS));
+}
+
+#[test]
+fn redo_after_undo_reapplies_shift_to_resplit_elements() {
+ let (mut project, track_id) = seed_project_with_video();
+ let mut batch = build_freeze_batch(&track_id);
+
+ batch.execute(&mut project).expect("execute");
+ batch.undo(&mut project);
+ batch.redo(&mut project).expect("redo");
+
+ let elements = sorted_main_elements(&project);
+ let type_names: Vec<&str> = elements.iter().map(element_type_name).collect();
+ assert_eq!(type_names, ["video", "image", "video"]);
+
+ let right = elements.last().expect("right element");
+ assert!(matches!(right, Element::Video(_)));
+ assert_eq!(
+ right.base().start_time,
+ seconds(SPLIT_SECONDS) + seconds(FREEZE_SECONDS)
+ );
+}
+
+#[test]
+fn no_op_when_split_produces_no_right_side() {
+ let (mut project, track_id) = seed_project_with_video();
+
+ let split = SplitElementsCommand::new(
+ vec![ElementRef {
+ track_id: track_id.to_string(),
+ element_id: "video-1".to_string(),
+ }],
+ MediaTime::ZERO,
+ RetainSide::Both,
+ );
+ let right_side = split.right_side_handle();
+ let shift = ShiftSplitRemainderCommand::new(right_side, seconds(FREEZE_SECONDS));
+ let mut batch = BatchCommand::new(vec![Box::new(split), Box::new(shift)]);
+
+ batch.execute(&mut project).expect("execute");
+ batch.undo(&mut project);
+
+ let scene = project
+ .scenes
+ .iter()
+ .find(|scene| scene.id == project.current_scene_id)
+ .expect("active scene");
+ assert_eq!(scene.tracks.main.elements().len(), 1);
+}