diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-28 20:14:51 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-28 20:14:51 +0200 |
| commit | 32438ce25f69de99d2523b98237f62cfa90890d7 (patch) | |
| tree | 3a712c347dec0889bdea3328b4c817c2d9392b27 /rust/crates/commands/tests | |
| parent | f5a82152394fde4d8f740f664ab7737b82861618 (diff) | |
| download | opencut-32438ce25f69de99d2523b98237f62cfa90890d7.tar.gz | |
feat: port domain crates to Rust (session 1 complete)
All green: cargo test --workspace (~300 tests, 0 failures).
- timeline (45): placement, resize/ripple, split, snap, retime,
audio separation, scene helpers
- animation (73): bezier solver, channel sampling, keyframe ops,
split/clamp/clone, param definitions
- commands (9): Command trait, BatchCommand, history, 19 concrete
commands, freeze-frame port 1:1
- audio (22): audible collection, range mixing, mastering limiter,
rms/peak buckets
- text (19): cosmic-text layout/measure/raster, backgrounds,
decorations
- graphics (52): full CSS gradient parser, tiny-skia raster,
4 shape definitions
- effect-defs (16): blur definition, registry, pass resolution
- mask-defs (19): all 9 mask renderers via tiny-skia
- renderer (6): node model + scene builder port
- desktop: GPUI projects screen wired to ProjectStore
- scene: factory (web defaults: v32, Main Track, 1920x1080, 30fps)
Diffstat (limited to 'rust/crates/commands/tests')
| -rw-r--r-- | rust/crates/commands/tests/common/mod.rs | 118 | ||||
| -rw-r--r-- | rust/crates/commands/tests/framework.rs | 162 | ||||
| -rw-r--r-- | rust/crates/commands/tests/freeze_frame.rs | 155 |
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); +} |
