Files
oak-editor/crates/oakundo/tests/undo_safe_test.rs
T
Mike-Solar 194d761ade refactor(oakundo): replace the CHandle vtable layer with owned trait objects
With the C ABI facade (oakengine) retired, the frozen-ABI rationale is
gone. UndoCommand now boxes a Send Command trait (new/from_closures/
multi), dropping OakUndoCommandVtable, the userdata trampolines, the
refcount shell, the handle module, and all undostack_* handle exports.
The global facade loses its raw-pointer out-params (can_undo/can_redo
return bool, command_name returns String). oaktimeline/oaknode/
oakplugin/oaktask construct commands directly via UndoCommand::new.
oakundo src is now free of unsafe; behavior (ordering, idempotence,
done flags, groups, observers, 200-row cap) is unchanged and pinned by
the rewritten tests.
2026-08-19 00:41:49 +08:00

329 lines
9.3 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::sync::{Arc, Mutex};
use oakundo::error::Error;
use oakundo::undocommand::UndoCommand;
use oakundo::undostack::{EmptyCommand, UndoStack, K_MAX_UNDO_COMMANDS};
/// Shared event recorder; commands record `redo:<name>` / `undo:<name>`
/// through their closures.
type Trace = Arc<Mutex<Vec<String>>>;
fn new_trace() -> Trace {
Arc::new(Mutex::new(Vec::new()))
}
/// A closure-backed command whose redo/undo record into `trace`.
fn trace_cmd(name: &'static str, trace: &Trace) -> UndoCommand {
let (t_redo, t_undo) = (trace.clone(), trace.clone());
let (name_redo, name_undo) = (name.to_string(), name.to_string());
UndoCommand::from_closures(
move || t_redo.lock().unwrap().push(format!("redo:{name_redo}")),
move || t_undo.lock().unwrap().push(format!("undo:{name_undo}")),
)
}
fn events(trace: &Trace) -> Vec<String> {
trace.lock().unwrap().clone()
}
/// A `Drop` guard that records `free:<name>` exactly once — the new-API
/// analogue of the C-ABI `free_fn` callback (dropping a command drops its
/// boxed closures, which drop their captures).
struct FreeProbe {
name: &'static str,
trace: Trace,
}
impl Drop for FreeProbe {
fn drop(&mut self) {
self.trace.lock().unwrap().push(format!("free:{}", self.name));
}
}
#[test]
fn command_redo_undo_lifecycle() {
let trace = new_trace();
let mut cmd = trace_cmd("a", &trace);
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 cmd2 = trace_cmd("x", &trace);
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"]);
}
/// Dropping a command runs its destruction exactly once (the C-ABI
/// `free_fn` exactly-once contract, now via `Drop`).
#[test]
fn command_drop_frees_exactly_once() {
let trace = new_trace();
let probe = FreeProbe {
name: "a",
trace: trace.clone(),
};
let cmd = UndoCommand::from_closures(move || {}, move || drop(&probe));
drop(cmd);
assert_eq!(events(&trace), vec!["free:a"]);
// Re-dropping an already-dropped value is impossible by construction;
// a second drop of a clone of the event log stays empty.
assert_eq!(events(&trace), vec!["free: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 = new_trace();
let mut multi = UndoCommand::multi();
multi.multi_add_child(trace_cmd("a", &trace));
multi.multi_add_child(trace_cmd("b", &trace));
multi.multi_add_child(trace_cmd("c", &trace));
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_plain_command_panics() {
let trace = new_trace();
let mut cmd = trace_cmd("a", &trace);
cmd.multi_add_child(trace_cmd("b", &trace));
}
#[test]
fn multi_child_helpers_on_plain_command() {
let trace = new_trace();
let mut cmd = trace_cmd("a", &trace);
assert_eq!(cmd.multi_child_count(), 0);
assert!(matches!(cmd.multi_child(0), Err(Error::Invalid)));
assert!(matches!(cmd.multi_child_mut(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 = new_trace();
let mut s = UndoStack::new();
s.push(trace_cmd("a", &trace), "A");
s.push(trace_cmd("b", &trace), "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("c", &trace), "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 = new_trace();
let mut s = UndoStack::new();
s.push(trace_cmd("a", &trace), "A");
s.push(trace_cmd("b", &trace), "B");
s.push(trace_cmd("c", &trace), "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 = new_trace();
let mut s = UndoStack::new();
s.push_pre_executed(trace_cmd("a", &trace), "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 = new_trace();
let mut s = UndoStack::new();
s.push(trace_cmd("a", &trace), "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 = new_trace();
let mut s = UndoStack::new();
for i in 0..(K_MAX_UNDO_COMMANDS + 20) {
s.push(trace_cmd("x", &trace), &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)));
}