Files
oak-editor/crates/oakundo/tests/undo_safe_test.rs
T
Mike-Solar 18ff60f147 feat(engine): clip move, clip effect_input, mandatory static FFmpeg
- 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)
2026-08-11 23:04:48 +08:00

332 lines
9.2 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/>.
//! Safe-layer behavior matrix for `UndoCommand` / `UndoStack`
//! (mirrors `src/undo/src/undostack.cpp` / `undocommand.cpp`).
use std::cell::RefCell;
use std::ffi::c_void;
use oakundo::error::Error;
use oakundo::undocommand::{OakUndoCommandVtable, UndoCommand};
use oakundo::undostack::{EmptyCommand, UndoStack, K_MAX_UNDO_COMMANDS};
/// Shared event recorder driven through vtable callbacks.
struct Trace {
events: RefCell<Vec<String>>,
}
/// Per-command callback payload: a name and a shared trace.
struct Probe {
name: &'static str,
trace: *const Trace,
}
unsafe extern "C" fn probe_redo(u: *mut c_void) {
let p = unsafe { &mut *(u as *mut Probe) };
let trace = unsafe { &*p.trace };
trace.events.borrow_mut().push(format!("redo:{}", p.name));
}
unsafe extern "C" fn probe_undo(u: *mut c_void) {
let p = unsafe { &mut *(u as *mut Probe) };
let trace = unsafe { &*p.trace };
trace.events.borrow_mut().push(format!("undo:{}", p.name));
}
/// A vtable-backed command whose callbacks record into `probe`.
fn trace_cmd(probe: &mut Probe) -> UndoCommand {
let vtable = OakUndoCommandVtable {
redo: Some(probe_redo),
undo: Some(probe_undo),
free_fn: None,
};
UndoCommand::from_vtable(vtable, probe as *mut Probe as *mut c_void)
}
fn events(trace: &Trace) -> Vec<String> {
trace.events.borrow().clone()
}
fn setup() -> (Box<Trace>, Vec<Probe>) {
let trace = Box::new(Trace {
events: RefCell::new(Vec::new()),
});
let ptr = &*trace as *const Trace;
let mut probes = Vec::new();
for name in ["a", "b", "c"] {
probes.push(Probe { name, trace: ptr });
}
(trace, probes)
}
#[test]
fn command_redo_undo_lifecycle() {
let (trace, mut probes) = setup();
let mut cmd = trace_cmd(&mut probes[0]);
assert!(!cmd.is_done());
assert!(cmd.has_prepared());
assert_eq!(events(&trace), Vec::<String>::new());
cmd.redo_now();
assert!(cmd.is_done());
assert_eq!(events(&trace), vec!["redo:a"]);
// Idempotent: a second redo does nothing.
cmd.redo_now();
assert_eq!(events(&trace), vec!["redo:a"]);
cmd.undo_now();
assert!(!cmd.is_done());
assert_eq!(events(&trace), vec!["redo:a", "undo:a"]);
// Idempotent undo.
cmd.undo_now();
assert_eq!(events(&trace), vec!["redo:a", "undo:a"]);
// redo_and_set_modified / undo_and_set_modified alias the *_now forms.
cmd.redo_and_set_modified();
cmd.undo_and_set_modified();
assert_eq!(events(&trace), vec!["redo:a", "undo:a", "redo:a", "undo:a"]);
// set_done marks executed without running anything.
let mut probe = Probe {
name: "x",
trace: probes[0].trace,
};
let mut cmd2 = trace_cmd(&mut probe);
cmd2.set_done(true);
assert!(cmd2.is_done());
cmd2.redo_now(); // no-op (already done)
assert_eq!(events(&trace), vec!["redo:a", "undo:a", "redo:a", "undo:a"]);
}
#[test]
fn empty_command_is_noop() {
let mut cmd = EmptyCommand::new();
assert!(cmd.has_prepared());
cmd.redo_now();
assert!(cmd.is_done());
cmd.undo_now();
assert!(!cmd.is_done());
// No panics, no side effects.
}
#[test]
fn multi_redo_undo_ordering() {
let (trace, mut probes) = setup();
let mut multi = UndoCommand::multi();
multi.multi_add_child(trace_cmd(&mut probes[0]));
multi.multi_add_child(trace_cmd(&mut probes[1]));
multi.multi_add_child(trace_cmd(&mut probes[2]));
assert_eq!(multi.multi_child_count(), 3);
// Children are reachable and named.
assert_eq!(multi.multi_child(0).unwrap().is_done(), false);
assert_eq!(multi.multi_child(2).unwrap().is_done(), false);
assert!(matches!(multi.multi_child(3), Err(Error::NotFound)));
// Redo in insertion order; undo in reverse.
multi.redo_now();
assert!(multi.is_done());
assert_eq!(events(&trace), vec!["redo:a", "redo:b", "redo:c"]);
multi.undo_now();
assert_eq!(
events(&trace),
vec!["redo:a", "redo:b", "redo:c", "undo:c", "undo:b", "undo:a"]
);
}
#[test]
#[should_panic]
fn multi_add_child_on_vtable_panics() {
let (_trace, mut probes) = setup();
let mut vtable_cmd = trace_cmd(&mut probes[0]);
vtable_cmd.multi_add_child(trace_cmd(&mut probes[1]));
}
#[test]
fn multi_child_helpers_on_non_multi() {
let (_trace, mut probes) = setup();
let mut vtable_cmd = trace_cmd(&mut probes[0]);
assert_eq!(vtable_cmd.multi_child_count(), 0);
assert!(matches!(vtable_cmd.multi_child(0), Err(Error::Invalid)));
}
#[test]
fn stack_new_has_empty_bottom() {
let s = UndoStack::new();
assert_eq!(s.command_count(), 1);
assert_eq!(s.done_count(), 1);
assert!(!s.can_undo());
assert!(!s.can_redo());
assert_eq!(s.command_name(0).unwrap(), "New/Open Project");
assert_eq!(s.command_is_done(0).unwrap(), true);
}
#[test]
fn stack_push_undo_redo_branch() {
let (trace, mut probes) = setup();
let mut s = UndoStack::new();
s.push(trace_cmd(&mut probes[0]), "A");
s.push(trace_cmd(&mut probes[1]), "B");
assert_eq!(s.command_count(), 3);
assert!(s.can_undo());
assert!(!s.can_redo());
assert_eq!(events(&trace), vec!["redo:a", "redo:b"]);
// Undo the top.
s.undo().unwrap();
assert_eq!(events(&trace), vec!["redo:a", "redo:b", "undo:b"]);
assert!(s.can_redo());
assert_eq!(s.done_count(), 2);
assert_eq!(s.command_name(2).unwrap(), "B"); // undone row is still labeled
assert_eq!(s.command_is_done(2).unwrap(), false);
// Redo restores.
s.redo().unwrap();
assert_eq!(events(&trace), vec!["redo:a", "redo:b", "undo:b", "redo:b"]);
assert_eq!(s.done_count(), 3);
// Undo then push drops the redoable tail.
s.undo().unwrap();
assert!(s.can_redo());
s.push(trace_cmd(&mut probes[2]), "C");
assert!(!s.can_redo(), "pushing drops redoable tail");
assert_eq!(s.command_count(), 3);
assert_eq!(s.command_name(2).unwrap(), "C");
}
#[test]
fn stack_undo_redo_noop_when_invalid() {
let mut s = UndoStack::new();
// Undo on a stack with only the bottom empty command is a no-op.
s.undo().unwrap();
assert_eq!(s.command_count(), 1);
assert!(!s.can_redo());
s.redo().unwrap();
assert_eq!(s.command_count(), 1);
}
#[test]
fn stack_jump_clamps_and_lands() {
let (trace, mut probes) = setup();
let mut s = UndoStack::new();
s.push(trace_cmd(&mut probes[0]), "A");
s.push(trace_cmd(&mut probes[1]), "B");
s.push(trace_cmd(&mut probes[2]), "C");
assert_eq!(s.done_count(), 4); // empty + A + B + C
// Jump back to just the empty command (clamps negative to 0).
s.jump(0);
assert_eq!(s.done_count(), 1);
assert!(!s.can_undo());
assert!(s.can_redo());
assert_eq!(
events(&trace),
vec!["redo:a", "redo:b", "redo:c", "undo:c", "undo:b", "undo:a"]
);
// Jump forward redoes in order.
s.jump(3);
assert_eq!(s.done_count(), 3);
assert_eq!(
events(&trace),
vec!["redo:a", "redo:b", "redo:c", "undo:c", "undo:b", "undo:a", "redo:a", "redo:b"]
);
// Jump beyond the top redoes up to the top (matches the C++ `jump`:
// the undone C is redoable, so the second loop runs).
s.jump(100);
assert_eq!(s.done_count(), 4);
}
#[test]
fn stack_discards_empty_multi() {
let mut s = UndoStack::new();
let empty_multi = UndoCommand::multi();
s.push(empty_multi, "empty");
assert_eq!(
s.command_count(),
1,
"empty multi command is dropped on push"
);
s.push_pre_executed(UndoCommand::multi(), "empty2");
assert_eq!(s.command_count(), 1);
}
#[test]
fn stack_push_pre_executed_skips_redo() {
let (trace, mut probes) = setup();
let mut s = UndoStack::new();
s.push_pre_executed(trace_cmd(&mut probes[0]), "A");
assert_eq!(
events(&trace),
Vec::<String>::new(),
"push_pre_executed does not run redo"
);
assert_eq!(s.command_count(), 2);
assert!(s.can_undo());
assert!(s.command_is_done(1).unwrap());
// Undoing still runs the undo callback.
s.undo().unwrap();
assert_eq!(events(&trace), vec!["undo:a"]);
}
#[test]
fn stack_clear_resets_to_bottom() {
let (trace, mut probes) = setup();
let mut s = UndoStack::new();
s.push(trace_cmd(&mut probes[0]), "A");
s.undo().unwrap();
assert_eq!(s.command_count(), 2);
s.clear();
assert_eq!(s.command_count(), 1);
assert_eq!(s.command_name(0).unwrap(), "New/Open Project");
assert!(!s.can_undo());
assert!(!s.can_redo());
}
#[test]
fn stack_caps_at_k_max() {
let (_trace, mut probes) = setup();
let mut s = UndoStack::new();
for i in 0..(K_MAX_UNDO_COMMANDS + 20) {
let mut probe = Probe {
name: "x",
trace: probes[0].trace,
};
let _ = &mut probe;
s.push(trace_cmd(&mut probes[i % 3]), &format!("cmd{i}"));
}
assert_eq!(s.command_count() as usize, K_MAX_UNDO_COMMANDS);
}
#[test]
fn stack_query_bounds_errors() {
let mut s = UndoStack::new();
assert!(matches!(s.command_name(-1), Err(Error::NotFound)));
assert!(matches!(s.command_name(1), Err(Error::NotFound)));
assert!(matches!(s.command_is_done(-1), Err(Error::NotFound)));
assert!(matches!(s.command_is_done(1), Err(Error::NotFound)));
}