Files
oak-editor/crates/oakengine/tests/it_undo.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

929 lines
33 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/>.
//! Integration tests for the undo family (`engine/include/oakengine/undo.h`,
//! implemented by `src/undo.rs` on top of the real oakundo module crate) —
//! the "real behavior, end to end" complement to the smoke tests in
//! `tests/undo.rs`. No mocks: every call goes through the facade exports
//! into the real oakundo crate.
//!
//! The facade owns a process-wide undo stack and a single open undo group
//! (the module 00 analogue of `EngineCore::undo_stack()` / `g_undo_group`),
//! so every stack- and group-mutating assertion lives in ONE serialized
//! test function ([`undo_stack_integration`]). The command-lifecycle tests
//! only touch local state and run in parallel.
//!
//! Coverage: all 23 `oakengine_undo_*` exports are called on a legal path
//! with asserted results, plus the illegal-input matrix (NULL pointers,
//! empty `CHandle::null()` boxes, out-of-range rows, zero/negative buffer
//! sizes) and the free/destroy contracts. No function in this family needs
//! GPU/app state. The regression tests at the bottom ([`null_name_push_repro`],
//! [`null_name_group_repro`], [`group_abort_undoes_children_repro`]) lock
//! three fixed facade bugs: a NULL/empty label to `oakengine_undo_push` /
//! the group-end path used to hand the module a dangling
//! `String::new().as_ptr()` (0x1) and SIGSEGV, and
//! `oakengine_undo_group_abort` used to leave its executed children
//! un-undone.
#[path = "common/mod.rs"]
mod common;
use std::ffi::{c_char, c_void};
use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering};
use std::sync::Mutex;
use oakengine::handle::{CHandle, OakEngineClipboard};
use oakengine::undo::{
oakengine_undo_can_redo, oakengine_undo_can_undo, oakengine_undo_clear,
oakengine_undo_command_create, oakengine_undo_command_create_multi,
oakengine_undo_command_free, oakengine_undo_command_is_done,
oakengine_undo_command_multi_add_child, oakengine_undo_command_multi_child_count,
oakengine_undo_command_redo_now, oakengine_undo_command_text, oakengine_undo_command_undo_now,
oakengine_undo_count, oakengine_undo_group_abort, oakengine_undo_group_begin,
oakengine_undo_group_end, oakengine_undo_handle, oakengine_undo_index, oakengine_undo_jump,
oakengine_undo_push, oakengine_undo_redo_action, oakengine_undo_undo_action,
oakengine_undo_update_actions,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Box a `CHandle::null()` inside an `OakEngineClipboard` — a VALID box
/// whose module handle is empty (what a plugin would hold after its own
/// handle object went away). The facade must reject it with a clean error
/// code, never crash.
fn empty_engine_ptr() -> *mut c_void {
Box::into_raw(Box::new(OakEngineClipboard {
handle: CHandle::null(),
}))
.cast()
}
/// Read back the NUL-terminated string the facade wrote into `buf`.
unsafe fn read_str(buf: *const c_char) -> String {
unsafe { std::ffi::CStr::from_ptr(buf) }
.to_str()
.unwrap()
.to_string()
}
// ---------------------------------------------------------------------------
// Command-lifecycle callbacks (parallel tests only; the serialized stack
// test uses the STK_* counters below and never touches these).
// ---------------------------------------------------------------------------
/// Serializes the tests that drive the facade's process-wide global undo
/// stack (`undo_stack_integration`, `null_name_push_repro`,
/// `null_name_group_repro`, `group_abort_undoes_children_repro`): cargo
/// runs tests on parallel threads and the global stack / single open undo
/// group cannot be shared, so each of those tests holds this lock for its
/// whole body.
static GLOBAL_STACK_LOCK: Mutex<()> = Mutex::new(());
static LIFECYCLE_REDO: AtomicI32 = AtomicI32::new(0);
static LIFECYCLE_UNDO: AtomicI32 = AtomicI32::new(0);
static LIFECYCLE_FREE: AtomicI32 = AtomicI32::new(0);
static LIFECYCLE_FREED_PTR: AtomicUsize = AtomicUsize::new(0);
/// Own counter set for `command_create_variants` (the lifecycle tests run
/// in parallel, so they must not share atomics).
static VARIANTS_FREE: AtomicI32 = AtomicI32::new(0);
unsafe extern "C" fn lifecycle_redo(_ud: *mut c_void) {
LIFECYCLE_REDO.fetch_add(1, Ordering::SeqCst);
}
unsafe extern "C" fn lifecycle_undo(_ud: *mut c_void) {
LIFECYCLE_UNDO.fetch_add(1, Ordering::SeqCst);
}
unsafe extern "C" fn lifecycle_free(_ud: *mut c_void) {
LIFECYCLE_FREE.fetch_add(1, Ordering::SeqCst);
}
/// No-op callback for `command_create_variants` (avoids touching the
/// lifecycle counters, which run in a parallel test).
unsafe extern "C" fn variants_noop(_ud: *mut c_void) {}
unsafe extern "C" fn variants_free(_ud: *mut c_void) {
VARIANTS_FREE.fetch_add(1, Ordering::SeqCst);
}
/// free_fn that records the pointer and drops the boxed `u64` userdata
/// (round-trip ownership check).
unsafe extern "C" fn lifecycle_free_userdata(ud: *mut c_void) {
LIFECYCLE_FREED_PTR.store(ud as usize, Ordering::SeqCst);
LIFECYCLE_FREE.fetch_add(1, Ordering::SeqCst);
drop(Box::from_raw(ud as *mut u64));
}
/// Child redo/undo callbacks that log their id (encoded in userdata) — used
/// to verify multi redo order (insertion) and undo order (reverse).
static MULTI_LOG: Mutex<Vec<i32>> = Mutex::new(Vec::new());
unsafe extern "C" fn multi_redo(ud: *mut c_void) {
MULTI_LOG.lock().unwrap().push(ud as usize as i32);
}
unsafe extern "C" fn multi_undo(ud: *mut c_void) {
MULTI_LOG.lock().unwrap().push(ud as usize as i32);
}
static MULTI_FREE: AtomicI32 = AtomicI32::new(0);
unsafe extern "C" fn multi_free(_ud: *mut c_void) {
MULTI_FREE.fetch_add(1, Ordering::SeqCst);
}
/// free_fn for the module-level destroy-contract test.
static MOD_FREE: AtomicI32 = AtomicI32::new(0);
unsafe extern "C" fn mod_free_cb(_ud: *mut c_void) {
MOD_FREE.fetch_add(1, Ordering::SeqCst);
}
// ---------------------------------------------------------------------------
// Serialized stack-test callbacks (own counters; the parallel command
// tests never touch these).
// ---------------------------------------------------------------------------
static STK_REDO: AtomicI32 = AtomicI32::new(0);
static STK_UNDO: AtomicI32 = AtomicI32::new(0);
unsafe extern "C" fn stk_redo(_ud: *mut c_void) {
STK_REDO.fetch_add(1, Ordering::SeqCst);
}
unsafe extern "C" fn stk_undo(_ud: *mut c_void) {
STK_UNDO.fetch_add(1, Ordering::SeqCst);
}
// ---------------------------------------------------------------------------
// Command lifecycle (parallel-safe: no global-stack state)
// ---------------------------------------------------------------------------
/// Full legal lifecycle of an app-defined command: create with name +
/// callbacks + owned userdata, redo/undo (idempotent), destroy via free —
/// the free_fn fires exactly once, with the same userdata pointer.
#[test]
fn command_lifecycle_roundtrip() {
common::force_link();
LIFECYCLE_REDO.store(0, Ordering::SeqCst);
LIFECYCLE_UNDO.store(0, Ordering::SeqCst);
LIFECYCLE_FREE.store(0, Ordering::SeqCst);
LIFECYCLE_FREED_PTR.store(0, Ordering::SeqCst);
let ud = Box::into_raw(Box::new(42u64)) as *mut c_void;
let cmd = unsafe {
oakengine_undo_command_create(
c"roundtrip".as_ptr(),
Some(lifecycle_redo),
Some(lifecycle_undo),
Some(lifecycle_free_userdata),
ud,
)
};
assert!(!cmd.is_null());
assert_eq!(unsafe { oakengine_undo_command_redo_now(cmd) }, 0);
assert_eq!(LIFECYCLE_REDO.load(Ordering::SeqCst), 1);
// redo on a done command is a no-op (olive semantics).
assert_eq!(unsafe { oakengine_undo_command_redo_now(cmd) }, 0);
assert_eq!(LIFECYCLE_REDO.load(Ordering::SeqCst), 1);
assert_eq!(unsafe { oakengine_undo_command_undo_now(cmd) }, 0);
assert_eq!(LIFECYCLE_UNDO.load(Ordering::SeqCst), 1);
// undo on an undone command is a no-op.
assert_eq!(unsafe { oakengine_undo_command_undo_now(cmd) }, 0);
assert_eq!(LIFECYCLE_UNDO.load(Ordering::SeqCst), 1);
assert_eq!(unsafe { oakengine_undo_command_redo_now(cmd) }, 0);
assert_eq!(LIFECYCLE_REDO.load(Ordering::SeqCst), 2);
unsafe { oakengine_undo_command_free(cmd) };
assert_eq!(LIFECYCLE_FREE.load(Ordering::SeqCst), 1);
assert_eq!(LIFECYCLE_FREED_PTR.load(Ordering::SeqCst), ud as usize);
}
/// create() legal variants: NULL name, all-None callback table, free-only
/// table. All must produce a usable command.
#[test]
fn command_create_variants() {
common::force_link();
VARIANTS_FREE.store(0, Ordering::SeqCst);
// NULL name is legal (the label is read as empty).
let c1 = unsafe {
oakengine_undo_command_create(
std::ptr::null(),
Some(variants_noop),
Some(variants_noop),
None,
std::ptr::null_mut(),
)
};
assert!(!c1.is_null());
assert_eq!(unsafe { oakengine_undo_command_redo_now(c1) }, 0);
assert_eq!(unsafe { oakengine_undo_command_undo_now(c1) }, 0);
unsafe { oakengine_undo_command_free(c1) };
// All-None callbacks: a no-op command, still usable.
let c2 = unsafe {
oakengine_undo_command_create(c"noop".as_ptr(), None, None, None, std::ptr::null_mut())
};
assert!(!c2.is_null());
assert_eq!(unsafe { oakengine_undo_command_redo_now(c2) }, 0);
assert_eq!(unsafe { oakengine_undo_command_undo_now(c2) }, 0);
unsafe { oakengine_undo_command_free(c2) };
// free-only table: destroy still invokes free_fn exactly once.
let c3 = unsafe {
oakengine_undo_command_create(
c"freeonly".as_ptr(),
None,
None,
Some(variants_free),
std::ptr::null_mut(),
)
};
assert!(!c3.is_null());
unsafe { oakengine_undo_command_free(c3) };
assert_eq!(VARIANTS_FREE.load(Ordering::SeqCst), 1);
}
/// Illegal-input robustness for the command surface: NULL pointers and
/// empty (`CHandle::null`) handles must produce clean negative codes
/// (the facade's -1 or the oakundo -20001 pass-through), never a crash.
#[test]
fn command_illegal_handle_inputs() {
common::force_link();
// NULL command pointers.
assert_eq!(
unsafe { oakengine_undo_command_redo_now(std::ptr::null_mut()) },
-1
);
assert_eq!(
unsafe { oakengine_undo_command_undo_now(std::ptr::null_mut()) },
-1
);
assert_eq!(
unsafe { oakengine_undo_command_multi_child_count(std::ptr::null_mut()) },
-1
);
assert_eq!(
unsafe {
oakengine_undo_command_multi_add_child(std::ptr::null_mut(), std::ptr::null_mut())
},
-1
);
// Empty (CHandle::null) handles inside a valid box.
let eb = empty_engine_ptr();
assert_eq!(unsafe { oakengine_undo_command_redo_now(eb) }, -1);
unsafe { oakengine_undo_command_free(eb) };
let eb = empty_engine_ptr();
assert_eq!(unsafe { oakengine_undo_command_undo_now(eb) }, -1);
unsafe { oakengine_undo_command_free(eb) };
let eb = empty_engine_ptr();
assert_eq!(unsafe { oakengine_undo_command_multi_child_count(eb) }, -1);
unsafe { oakengine_undo_command_free(eb) };
// multi_add_child with an empty parent (the facade errors before
// consuming the child, so the child box must be freed by us).
let eb = empty_engine_ptr();
let child = unsafe {
oakengine_undo_command_create(c"child".as_ptr(), None, None, None, std::ptr::null_mut())
};
assert_eq!(
unsafe { oakengine_undo_command_multi_add_child(eb, child) },
-1
);
unsafe { oakengine_undo_command_free(eb) };
unsafe { oakengine_undo_command_free(child) };
// multi_add_child with an empty child (parent untouched).
let multi = unsafe { oakengine_undo_command_create_multi() };
let eb = empty_engine_ptr();
assert_eq!(
unsafe { oakengine_undo_command_multi_add_child(multi, eb) },
-1
);
unsafe { oakengine_undo_command_free(eb) };
assert_eq!(
unsafe { oakengine_undo_command_multi_child_count(multi) },
0
);
unsafe { oakengine_undo_command_free(multi) };
// A plain (non-multi) command as the "multi" parent: the module rejects
// with -20001 and the facade still consumes the child's box.
let parent = unsafe {
oakengine_undo_command_create(c"parent".as_ptr(), None, None, None, std::ptr::null_mut())
};
let child = unsafe {
oakengine_undo_command_create(c"child".as_ptr(), None, None, None, std::ptr::null_mut())
};
assert_eq!(
unsafe { oakengine_undo_command_multi_add_child(parent, child) },
-20001
);
assert_eq!(
unsafe { oakengine_undo_command_multi_child_count(parent) },
-20001
);
unsafe { oakengine_undo_command_free(parent) };
}
/// Legal-input matrix for multi commands: child counts 0→N, redo in
/// insertion order, undo in reverse order, nested multis, idempotent
/// redo/undo.
#[test]
fn multi_command_lifecycle() {
common::force_link();
let multi = unsafe { oakengine_undo_command_create_multi() };
assert!(!multi.is_null());
assert_eq!(
unsafe { oakengine_undo_command_multi_child_count(multi) },
0
);
for id in [1, 2, 3] {
let child = unsafe {
oakengine_undo_command_create(
c"child".as_ptr(),
Some(multi_redo),
Some(multi_undo),
None,
id as *mut c_void,
)
};
assert_eq!(
unsafe { oakengine_undo_command_multi_add_child(multi, child) },
0
);
}
assert_eq!(
unsafe { oakengine_undo_command_multi_child_count(multi) },
3
);
*MULTI_LOG.lock().unwrap() = Vec::new();
assert_eq!(unsafe { oakengine_undo_command_redo_now(multi) }, 0);
assert_eq!(*MULTI_LOG.lock().unwrap(), [1, 2, 3]);
// redo of a done multi is a no-op.
assert_eq!(unsafe { oakengine_undo_command_redo_now(multi) }, 0);
assert_eq!(*MULTI_LOG.lock().unwrap(), [1, 2, 3]);
assert_eq!(unsafe { oakengine_undo_command_undo_now(multi) }, 0);
assert_eq!(*MULTI_LOG.lock().unwrap(), [1, 2, 3, 3, 2, 1]);
unsafe { oakengine_undo_command_free(multi) };
// Nested multi: outer = [c10, inner([c21])]; undo runs children in
// reverse order, inner included.
let outer = unsafe { oakengine_undo_command_create_multi() };
let inner = unsafe { oakengine_undo_command_create_multi() };
let c10 = unsafe {
oakengine_undo_command_create(
c"c10".as_ptr(),
Some(multi_redo),
Some(multi_undo),
None,
10 as *mut c_void,
)
};
let c21 = unsafe {
oakengine_undo_command_create(
c"c21".as_ptr(),
Some(multi_redo),
Some(multi_undo),
None,
21 as *mut c_void,
)
};
assert_eq!(
unsafe { oakengine_undo_command_multi_add_child(inner, c21) },
0
);
assert_eq!(
unsafe { oakengine_undo_command_multi_child_count(inner) },
1
);
assert_eq!(
unsafe { oakengine_undo_command_multi_add_child(outer, c10) },
0
);
assert_eq!(
unsafe { oakengine_undo_command_multi_add_child(outer, inner) },
0
);
assert_eq!(
unsafe { oakengine_undo_command_multi_child_count(outer) },
2
);
*MULTI_LOG.lock().unwrap() = Vec::new();
assert_eq!(unsafe { oakengine_undo_command_redo_now(outer) }, 0);
assert_eq!(*MULTI_LOG.lock().unwrap(), [10, 21]);
assert_eq!(unsafe { oakengine_undo_command_undo_now(outer) }, 0);
assert_eq!(*MULTI_LOG.lock().unwrap(), [10, 21, 21, 10]);
// c10 / inner / c21 were consumed by multi_add_child (their boxes are
// freed by the facade), so only outer is freed here — the child command
// values die with it.
unsafe { oakengine_undo_command_free(outer) };
}
/// Destroying a multi command releases its children transitively: each
/// child's free_fn fires exactly once when the multi is freed.
#[test]
fn multi_command_free_frees_children() {
common::force_link();
MULTI_FREE.store(0, Ordering::SeqCst);
let multi = unsafe { oakengine_undo_command_create_multi() };
let inner = unsafe { oakengine_undo_command_create_multi() };
let a = unsafe {
oakengine_undo_command_create(
c"a".as_ptr(),
None,
None,
Some(multi_free),
std::ptr::null_mut(),
)
};
let b = unsafe {
oakengine_undo_command_create(
c"b".as_ptr(),
None,
None,
Some(multi_free),
std::ptr::null_mut(),
)
};
let c = unsafe {
oakengine_undo_command_create(
c"c".as_ptr(),
None,
None,
Some(multi_free),
std::ptr::null_mut(),
)
};
assert_eq!(
unsafe { oakengine_undo_command_multi_add_child(inner, c) },
0
);
assert_eq!(
unsafe { oakengine_undo_command_multi_add_child(multi, a) },
0
);
assert_eq!(
unsafe { oakengine_undo_command_multi_add_child(multi, b) },
0
);
assert_eq!(
unsafe { oakengine_undo_command_multi_add_child(multi, inner) },
0
);
assert_eq!(MULTI_FREE.load(Ordering::SeqCst), 0);
unsafe { oakengine_undo_command_free(multi) };
// a, b and c (via inner) are all destroyed exactly once.
assert_eq!(MULTI_FREE.load(Ordering::SeqCst), 3);
}
/// Destroy contracts end to end: free(NULL), free(empty), and the
/// module-level double-free safety of the command/stack handles the facade
/// delegates to (`oakundo_command_free` / `oakundo_undostack_free` clear
/// `ctx` after releasing, so a second free is a no-op).
///
/// NOTE: the facade's own `oakengine_undo_command_free` frees the wrapper
/// box and is documented as "must not be freed twice"; the double-free-safe
/// contract lives on the module handle level, exercised here through the
/// real oakundo C ABI. The oakundo family has no debug alive counter, so
/// there is no alive-count-baseline to assert.
#[test]
fn free_contracts() {
common::force_link();
// Facade free: NULL and empty are no-ops.
unsafe { oakengine_undo_command_free(std::ptr::null_mut()) };
let eb = empty_engine_ptr();
unsafe { oakengine_undo_command_free(eb) };
// Module command handle: the first free releases (free_fn fires once)
// and clears ctx; the second free is a no-op.
MOD_FREE.store(0, Ordering::SeqCst);
let vtable = oakundo::undocommand::OakUndoCommandVtable {
redo: None,
undo: None,
free_fn: Some(mod_free_cb),
};
let mut h =
unsafe { oakundo::ffi::command::oakundo_command_init(&vtable, std::ptr::null_mut()) };
assert!(!h.ctx.is_null());
unsafe { oakundo::ffi::command::oakundo_command_free(&mut h) };
assert_eq!(MOD_FREE.load(Ordering::SeqCst), 1);
assert!(h.ctx.is_null());
unsafe { oakundo::ffi::command::oakundo_command_free(&mut h) };
assert_eq!(MOD_FREE.load(Ordering::SeqCst), 1);
// Module stack handle: double free is a no-op; NULL value and NULL
// pointer are no-ops too.
let mut s = unsafe { oakundo::ffi::undostack::oakundo_undostack_init() };
assert!(!s.ctx.is_null());
unsafe { oakundo::ffi::undostack::oakundo_undostack_free(&mut s) };
assert!(s.ctx.is_null());
unsafe { oakundo::ffi::undostack::oakundo_undostack_free(&mut s) };
let mut null_h = CHandle::null();
unsafe { oakundo::ffi::command::oakundo_command_free(&mut null_h) };
unsafe { oakundo::ffi::command::oakundo_command_free(std::ptr::null_mut()) };
unsafe { oakundo::ffi::undostack::oakundo_undostack_free(std::ptr::null_mut()) };
}
// ---------------------------------------------------------------------------
// Global stack + undo group (serialized: the facade's stack and open group
// are process-wide)
// ---------------------------------------------------------------------------
/// The full global-stack and undo-group matrix, serialized in one test
/// because the facade owns the process-wide stack and a single open undo
/// group. Covers every stack-scoped export: handle, clear, count, index,
/// jump, command_text, command_is_done, can_undo/can_redo, push, and the
/// group begin/end/abort lifecycle.
#[test]
fn undo_stack_integration() {
let _lock = GLOBAL_STACK_LOCK.lock().unwrap();
common::force_link();
// --- Baseline: clear() resets to the single "New/Open Project" row.
assert_eq!(unsafe { oakengine_undo_clear() }, 0);
assert_eq!(unsafe { oakengine_undo_count() }, 1);
assert_eq!(unsafe { oakengine_undo_index() }, 1);
assert_eq!(unsafe { oakengine_undo_can_undo() }, 0);
assert_eq!(unsafe { oakengine_undo_can_redo() }, 0);
// --- Borrowed handle + Qt-leftover actions.
let h1 = unsafe { oakengine_undo_handle() };
let h2 = unsafe { oakengine_undo_handle() };
assert!(!h1.is_null());
assert_eq!(h1, h2); // stable token
assert_eq!(unsafe { oakengine_undo_update_actions() }, 0);
assert!(unsafe { oakengine_undo_undo_action() }.is_null());
assert!(unsafe { oakengine_undo_redo_action() }.is_null());
// --- command_text / command_is_done on the base row. The two-stage
// getter reports the length WITHOUT the trailing NUL.
let mut buf = [0 as c_char; 64];
assert_eq!(
unsafe { oakengine_undo_command_text(0, buf.as_mut_ptr(), 64) },
16
);
assert_eq!(unsafe { read_str(buf.as_ptr()) }, "New/Open Project");
// NULL buf / zero / negative sizes only report the length.
assert_eq!(
unsafe { oakengine_undo_command_text(0, std::ptr::null_mut(), 64) },
16
);
assert_eq!(
unsafe { oakengine_undo_command_text(0, buf.as_mut_ptr(), 0) },
16
);
assert_eq!(
unsafe { oakengine_undo_command_text(0, buf.as_mut_ptr(), -1) },
16
);
// Out-of-range rows → oakundo NOT_FOUND (-20004) passes through.
assert_eq!(
unsafe { oakengine_undo_command_text(-1, buf.as_mut_ptr(), 64) },
-20004
);
assert_eq!(
unsafe { oakengine_undo_command_text(1, buf.as_mut_ptr(), 64) },
-20004
);
assert_eq!(
unsafe { oakengine_undo_command_text(i64::MAX, buf.as_mut_ptr(), 64) },
-20004
);
assert_eq!(
unsafe { oakengine_undo_command_text(i64::MIN, buf.as_mut_ptr(), 64) },
-20004
);
assert_eq!(unsafe { oakengine_undo_command_is_done(0) }, 1);
assert_eq!(unsafe { oakengine_undo_command_is_done(-1) }, -20004);
assert_eq!(unsafe { oakengine_undo_command_is_done(1) }, -20004);
assert_eq!(unsafe { oakengine_undo_command_is_done(i64::MAX) }, -20004);
// --- Push a named command; the redo runs eagerly.
STK_REDO.store(0, Ordering::SeqCst);
STK_UNDO.store(0, Ordering::SeqCst);
let a = unsafe {
oakengine_undo_command_create(
c"alpha".as_ptr(),
Some(stk_redo),
Some(stk_undo),
None,
std::ptr::null_mut(),
)
};
assert_eq!(unsafe { oakengine_undo_push(a, c"alpha".as_ptr()) }, 0);
assert_eq!(unsafe { oakengine_undo_count() }, 2);
assert_eq!(unsafe { oakengine_undo_index() }, 2);
assert_eq!(STK_REDO.load(Ordering::SeqCst), 1);
assert_eq!(unsafe { oakengine_undo_can_undo() }, 1);
assert_eq!(
unsafe { oakengine_undo_command_text(1, buf.as_mut_ptr(), 64) },
5
);
assert_eq!(unsafe { read_str(buf.as_ptr()) }, "alpha");
assert_eq!(unsafe { oakengine_undo_command_is_done(1) }, 1);
// --- Push a second named command.
let b = unsafe {
oakengine_undo_command_create(
c"beta".as_ptr(),
Some(stk_redo),
Some(stk_undo),
None,
std::ptr::null_mut(),
)
};
assert_eq!(unsafe { oakengine_undo_push(b, c"beta".as_ptr()) }, 0);
assert_eq!(unsafe { oakengine_undo_count() }, 3);
assert_eq!(unsafe { oakengine_undo_index() }, 3);
assert_eq!(STK_REDO.load(Ordering::SeqCst), 2);
assert_eq!(
unsafe { oakengine_undo_command_text(2, buf.as_mut_ptr(), 64) },
4
);
assert_eq!(unsafe { read_str(buf.as_ptr()) }, "beta");
// Tiny buffer: truncated copy, full length still reported.
let mut small = [0 as c_char; 2];
assert_eq!(
unsafe { oakengine_undo_command_text(1, small.as_mut_ptr(), 2) },
5
);
assert_eq!(unsafe { read_str(small.as_ptr()) }, "a");
// --- jump() legal matrix. jump(1) from index 3 undoes BOTH beta and
// alpha (the stack undoes back-to-front until the done-count is 1).
assert_eq!(unsafe { oakengine_undo_jump(1) }, 0);
assert_eq!(unsafe { oakengine_undo_index() }, 1);
assert_eq!(STK_UNDO.load(Ordering::SeqCst), 2);
assert_eq!(unsafe { oakengine_undo_can_undo() }, 0);
assert_eq!(unsafe { oakengine_undo_can_redo() }, 1);
assert_eq!(unsafe { oakengine_undo_command_is_done(1) }, 0);
assert_eq!(unsafe { oakengine_undo_command_is_done(2) }, 0);
assert_eq!(unsafe { oakengine_undo_jump(0) }, 0);
// The base "New/Open Project" row is never undoable, so the index
// bottoms out at 1 rather than 0.
assert_eq!(unsafe { oakengine_undo_index() }, 1);
assert_eq!(STK_UNDO.load(Ordering::SeqCst), 2);
// Negative index is clamped to 0 (olive jump semantics) — still no
// undo past the base row.
assert_eq!(unsafe { oakengine_undo_jump(-5) }, 0);
assert_eq!(unsafe { oakengine_undo_index() }, 1);
// Oversized index is clamped to the done-command count.
assert_eq!(unsafe { oakengine_undo_jump(999) }, 0);
assert_eq!(unsafe { oakengine_undo_index() }, 3);
assert_eq!(STK_REDO.load(Ordering::SeqCst), 4);
assert_eq!(unsafe { oakengine_undo_can_undo() }, 1);
assert_eq!(unsafe { oakengine_undo_can_redo() }, 0);
// --- Undo groups.
// A second begin while a group is open fails with E_STATE (-2);
// ending an empty group discards it (no new row).
assert_eq!(unsafe { oakengine_undo_group_begin(c"anon".as_ptr()) }, 0);
assert_eq!(unsafe { oakengine_undo_group_begin(c"again".as_ptr()) }, -2);
assert_eq!(unsafe { oakengine_undo_group_end() }, 0);
assert_eq!(unsafe { oakengine_undo_count() }, 3);
assert_eq!(unsafe { oakengine_undo_index() }, 3);
// begin → push children → end pushes ONE grouped row.
assert_eq!(
unsafe { oakengine_undo_group_begin(c"grouped".as_ptr()) },
0
);
let c1 = unsafe {
oakengine_undo_command_create(
c"c1".as_ptr(),
Some(stk_redo),
Some(stk_undo),
None,
std::ptr::null_mut(),
)
};
let c2 = unsafe {
oakengine_undo_command_create(
c"c2".as_ptr(),
Some(stk_redo),
Some(stk_undo),
None,
std::ptr::null_mut(),
)
};
assert_eq!(unsafe { oakengine_undo_push(c1, c"c1".as_ptr()) }, 0);
assert_eq!(unsafe { oakengine_undo_push(c2, c"c2".as_ptr()) }, 0);
// Both children were redo'd eagerly into the group, not the stack.
assert_eq!(STK_REDO.load(Ordering::SeqCst), 6);
assert_eq!(unsafe { oakengine_undo_count() }, 3);
assert_eq!(unsafe { oakengine_undo_group_end() }, 0);
assert_eq!(unsafe { oakengine_undo_count() }, 4);
assert_eq!(unsafe { oakengine_undo_index() }, 4);
assert_eq!(
unsafe { oakengine_undo_command_text(3, buf.as_mut_ptr(), 64) },
7
);
assert_eq!(unsafe { read_str(buf.as_ptr()) }, "grouped");
assert_eq!(unsafe { oakengine_undo_command_is_done(3) }, 1);
// Undo the group: children undo in REVERSE order.
assert_eq!(unsafe { oakengine_undo_jump(3) }, 0);
assert_eq!(unsafe { oakengine_undo_index() }, 3);
assert_eq!(STK_UNDO.load(Ordering::SeqCst), 4);
assert_eq!(unsafe { oakengine_undo_command_is_done(3) }, 0);
assert_eq!(unsafe { oakengine_undo_can_redo() }, 1);
// Redo the group: children redo in INSERTION order.
assert_eq!(unsafe { oakengine_undo_jump(4) }, 0);
assert_eq!(unsafe { oakengine_undo_index() }, 4);
assert_eq!(STK_REDO.load(Ordering::SeqCst), 8);
// Undo again for the abort phase.
assert_eq!(unsafe { oakengine_undo_jump(3) }, 0);
assert_eq!(STK_UNDO.load(Ordering::SeqCst), 6);
// begin → push → abort discards the group and undoes the executed
// child (see `group_abort_undoes_children_repro`).
assert_eq!(unsafe { oakengine_undo_group_begin(c"abort".as_ptr()) }, 0);
let c3 = unsafe {
oakengine_undo_command_create(
c"c3".as_ptr(),
Some(stk_redo),
Some(stk_undo),
None,
std::ptr::null_mut(),
)
};
assert_eq!(unsafe { oakengine_undo_push(c3, c"c3".as_ptr()) }, 0);
assert_eq!(STK_REDO.load(Ordering::SeqCst), 9);
assert_eq!(unsafe { oakengine_undo_group_abort() }, 0);
// The abort rolls the executed child back: c3's undo ran exactly once.
// The group itself is discarded (no undo row), so count/index are
// unchanged.
assert_eq!(STK_UNDO.load(Ordering::SeqCst), 7);
assert_eq!(unsafe { oakengine_undo_count() }, 4); // unchanged
assert_eq!(unsafe { oakengine_undo_index() }, 3);
// End/abort with no open group fail with E_STATE.
assert_eq!(unsafe { oakengine_undo_group_end() }, -2);
assert_eq!(unsafe { oakengine_undo_group_abort() }, -2);
// --- Illegal push inputs (rejected before any stack access).
assert_eq!(
unsafe { oakengine_undo_push(std::ptr::null_mut(), c"x".as_ptr()) },
-1
);
let eb = empty_engine_ptr();
assert_eq!(unsafe { oakengine_undo_push(eb, c"x".as_ptr()) }, -1);
unsafe { oakengine_undo_command_free(eb) };
// Cleanup: back to baseline.
assert_eq!(unsafe { oakengine_undo_clear() }, 0);
assert_eq!(unsafe { oakengine_undo_count() }, 1);
assert_eq!(unsafe { oakengine_undo_index() }, 1);
}
// ---------------------------------------------------------------------------
// Real-bug regressions (previously `#[ignore]`d repros of facade bugs,
// now fixed; kept as regression tests)
// ---------------------------------------------------------------------------
/// REGRESSION — `oakengine_undo_push(cmd, NULL)` (and an empty-string
/// label) must not crash.
///
/// The facade's `push_or_run` (src/undo.rs) used to turn a NULL/empty name
/// into `String::new()` and pass its DANGLING `as_ptr()` (address 0x1 —
/// Rust empty-string pointers are never NULL) to the oakundo module's
/// `oakundo_undostack_push`, whose `read_name` treats any non-NULL pointer
/// as a valid C string and runs `CStr::from_ptr` (strlen) on it, faulting
/// on the unmapped page. The crash is NOT caught by the catch_unwind
/// guards (it is a hard SIGSEGV, not a panic). Fixed: a NULL/empty label
/// now crosses the facade as a real NULL, which the module reads as an
/// empty label. `name` is documented as legal-NULL in both the module
/// header (`include/undo/undostack.h`: "NULL behaves like an empty
/// label") and the facade docs.
#[test]
fn null_name_push_repro() {
let _lock = GLOBAL_STACK_LOCK.lock().unwrap();
common::force_link();
assert_eq!(unsafe { oakengine_undo_clear() }, 0);
let cmd = unsafe {
oakengine_undo_command_create(c"x".as_ptr(), None, None, None, std::ptr::null_mut())
};
// NULL name is a documented-legal label; this must not crash.
assert_eq!(unsafe { oakengine_undo_push(cmd, std::ptr::null()) }, 0);
// An empty C string label walks the same dangling-pointer path.
let cmd = unsafe {
oakengine_undo_command_create(c"x".as_ptr(), None, None, None, std::ptr::null_mut())
};
assert_eq!(unsafe { oakengine_undo_push(cmd, c"".as_ptr()) }, 0);
assert_eq!(unsafe { oakengine_undo_clear() }, 0);
}
/// REGRESSION — `oakengine_undo_group_begin(NULL)` +
/// `oakengine_undo_group_end()` (and empty-string group names) must not
/// crash.
///
/// Same root cause as [`null_name_push_repro`]: `oakengine_undo_group_end`
/// (src/undo.rs) stores the group name as a Rust `String` and used to pass
/// its `as_ptr()` to `oakundo_undostack_push_pre_executed`; a NULL (or
/// empty) name was a dangling 0x1 pointer there, and the module's
/// `read_name` crashed on it. Fixed: the empty label now crosses the
/// facade as a real NULL. The group-abort path never crosses the name and
/// is safe.
#[test]
fn null_name_group_repro() {
let _lock = GLOBAL_STACK_LOCK.lock().unwrap();
common::force_link();
assert_eq!(unsafe { oakengine_undo_clear() }, 0);
assert_eq!(unsafe { oakengine_undo_group_begin(std::ptr::null()) }, 0);
// End of a NULL-named (empty) group must not crash.
assert_eq!(unsafe { oakengine_undo_group_end() }, 0);
// Same path with an empty C string name.
assert_eq!(unsafe { oakengine_undo_group_begin(c"".as_ptr()) }, 0);
assert_eq!(unsafe { oakengine_undo_group_end() }, 0);
assert_eq!(unsafe { oakengine_undo_clear() }, 0);
}
/// Counter for the abort repro (own set: kept isolated from the parallel
/// tests' counters).
static ABORT_UNDO: AtomicI32 = AtomicI32::new(0);
unsafe extern "C" fn abort_undo_cb(_ud: *mut c_void) {
ABORT_UNDO.fetch_add(1, Ordering::SeqCst);
}
/// REGRESSION — `oakengine_undo_group_abort()` must undo the group's
/// executed children.
///
/// The facade (src/undo.rs) used to close the abort with
/// `oakundo_command_undo_now(open.multi)` on a multi command that was
/// never marked done (each child was redo'd eagerly at push time, but the
/// multi's own `done` flag stays false), and oakundo's documented
/// `undo_now` is a no-op on a not-done command. Net effect: the child's
/// undo callback never fired, so the group's side effects were NOT rolled
/// back — contradicting the documented "undo all executed children and
/// discard the group". Fixed: the abort undoes each executed child
/// individually, in reverse insertion order. (The smoke test in
/// tests/undo.rs misses this: its `STK_UNDO_COUNT == 1` assertion is
/// satisfied by a leftover value from an earlier jump.)
#[test]
fn group_abort_undoes_children_repro() {
let _lock = GLOBAL_STACK_LOCK.lock().unwrap();
common::force_link();
assert_eq!(unsafe { oakengine_undo_clear() }, 0);
ABORT_UNDO.store(0, Ordering::SeqCst);
assert_eq!(unsafe { oakengine_undo_group_begin(c"abort".as_ptr()) }, 0);
let c = unsafe {
oakengine_undo_command_create(
c"c".as_ptr(),
None,
Some(abort_undo_cb),
None,
std::ptr::null_mut(),
)
};
assert_eq!(unsafe { oakengine_undo_push(c, c"c".as_ptr()) }, 0);
assert_eq!(unsafe { oakengine_undo_group_abort() }, 0);
// Documented behavior: the executed child's undo must run.
assert_eq!(ABORT_UNDO.load(Ordering::SeqCst), 1);
}