Files
oak-editor/crates/oakengine/src/test_support/undo.rs
T
Mike-Solar c5f1d0d76c refactor(engine): pure cdylib + undo-stack test race fix (M14 R4)
- oakengine is now cdylib-only (no rlib/staticlib consumers anywhere;
  cargo tree verified) — the plugin/external C ABI layer; README and
  docs updated
- cd.yml drops the dylib embedding/re-sign steps (the app no longer
  links it)
- test race root-caused and fixed for good: the global undo stack lock
  is now a re-entrant mutex (parking_lot) shared by every test that
  drives the stack, including the previously unlocked node/render
  families; the render-manager serial-ordering bug (an earlier repro
  test initialized the global manager before the not-initialized test)
  is fixed with a shared SERIAL guard and a manager shutdown
- 5 consecutive parallel runs clean; serial 209/209
2026-08-17 00:51:36 +08:00

287 lines
9.5 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/>.
//! Smoke tests for the undo family (`engine/include/oakengine/undo.h`).
//!
//! The facade owns a process-wide undo stack and one open undo group, so
//! the stack-mutating tests are serialized inside a single test
//! function; the command-lifecycle tests (no stack access) can run in
//! parallel.
use super::common;
use std::ffi::{c_char, c_int, c_void};
use std::sync::atomic::{AtomicI32, Ordering};
use crate::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,
};
// ---------------------------------------------------------------------------
// Command lifecycle (no global-stack state)
// ---------------------------------------------------------------------------
/// Callback counters for the app-defined command test (own set so it can
/// run in parallel with the serialized stack test).
static CMD_REDO_COUNT: AtomicI32 = AtomicI32::new(0);
static CMD_UNDO_COUNT: AtomicI32 = AtomicI32::new(0);
static CMD_FREE_COUNT: AtomicI32 = AtomicI32::new(0);
/// Callback counters for the serialized global-stack test.
static STK_REDO_COUNT: AtomicI32 = AtomicI32::new(0);
static STK_UNDO_COUNT: AtomicI32 = AtomicI32::new(0);
/// Stack-test callbacks: bump only the `STK_*` counters. They must not
/// touch the `CMD_*` counters — the command-lifecycle tests reset and
/// assert those in parallel threads, so a stray bump here would race.
unsafe extern "C" fn redo_cb(_userdata: *mut c_void) {
STK_REDO_COUNT.fetch_add(1, Ordering::SeqCst);
}
unsafe extern "C" fn undo_cb(_userdata: *mut c_void) {
STK_UNDO_COUNT.fetch_add(1, Ordering::SeqCst);
}
/// Command-lifecycle-only callbacks: bump only the `CMD_*` counters. The
/// serialized stack test runs in a parallel thread and must not flip
/// these.
unsafe extern "C" fn cmd_redo_cb(_userdata: *mut c_void) {
CMD_REDO_COUNT.fetch_add(1, Ordering::SeqCst);
}
unsafe extern "C" fn cmd_undo_cb(_userdata: *mut c_void) {
CMD_UNDO_COUNT.fetch_add(1, Ordering::SeqCst);
}
unsafe extern "C" fn free_cb(_userdata: *mut c_void) {
CMD_FREE_COUNT.fetch_add(1, Ordering::SeqCst);
}
/// Lifecycle: create a callback command, run redo/undo, free it.
#[test]
fn command_create_redo_undo_free() {
CMD_REDO_COUNT.store(0, Ordering::SeqCst);
CMD_UNDO_COUNT.store(0, Ordering::SeqCst);
CMD_FREE_COUNT.store(0, Ordering::SeqCst);
let cmd = unsafe {
oakengine_undo_command_create(
c"custom".as_ptr(),
Some(cmd_redo_cb),
Some(cmd_undo_cb),
Some(free_cb),
std::ptr::null_mut(),
)
};
assert!(!cmd.is_null());
assert_eq!(unsafe { oakengine_undo_command_redo_now(cmd) }, 0);
assert_eq!(CMD_REDO_COUNT.load(Ordering::SeqCst), 1);
assert_eq!(unsafe { oakengine_undo_command_undo_now(cmd) }, 0);
assert_eq!(CMD_UNDO_COUNT.load(Ordering::SeqCst), 1);
// The free callback must fire exactly once when freed directly.
unsafe { oakengine_undo_command_free(cmd) };
assert_eq!(CMD_FREE_COUNT.load(Ordering::SeqCst), 1);
// Freeing a NULL pointer is a no-op.
unsafe { oakengine_undo_command_free(std::ptr::null_mut()) };
}
/// Multi command: add children, count them, redo the whole multi.
#[test]
fn multi_command_add_child_count_redo() {
let multi = unsafe { oakengine_undo_command_create_multi() };
assert!(!multi.is_null());
let child = unsafe {
oakengine_undo_command_create(
c"child".as_ptr(),
Some(cmd_redo_cb),
Some(cmd_undo_cb),
None,
std::ptr::null_mut(),
)
};
assert_eq!(
unsafe { oakengine_undo_command_multi_add_child(multi, child) },
0
);
assert_eq!(
unsafe { oakengine_undo_command_multi_child_count(multi) },
1
);
// Adding a NULL child fails with E_INVALID (-1).
assert_eq!(
unsafe { oakengine_undo_command_multi_add_child(multi, std::ptr::null_mut()) },
-1
);
unsafe { oakengine_undo_command_free(multi) };
}
// ---------------------------------------------------------------------------
// Global stack (serialized: the facade's stack is process-wide)
// ---------------------------------------------------------------------------
/// Push/undo/redo/jump/text round-trip on the global stack, undo-group
/// begin/end/abort, and NULL-push rejection — all serialized in ONE test
/// because the facade owns a process-wide stack and a single open undo
/// group (the C++ capi's `g_undo_group` analogue), which cannot be
/// exercised from parallel test threads.
#[test]
fn undo_stack_lifecycle() {
// Serialized on the SAME lock as the it_undo stack tests and the
// write-through tests (the facade's stack is process-wide): without it
// a concurrent test's pushes break the exact-count assertions below.
let _stack = super::it_undo::GLOBAL_STACK_LOCK
.lock();
// Reset to a clean "New/Open Project" base row.
assert_eq!(unsafe { oakengine_undo_clear() }, 0);
assert_eq!(unsafe { oakengine_undo_count() }, 1);
assert_eq!(unsafe { oakengine_undo_index() }, 1);
// The borrowed stack handle is stable and non-NULL.
assert!(!unsafe { oakengine_undo_handle() }.is_null());
// Push a callback command.
let cmd = unsafe {
oakengine_undo_command_create(
c"op".as_ptr(),
Some(redo_cb),
Some(undo_cb),
None,
std::ptr::null_mut(),
)
};
STK_REDO_COUNT.store(0, Ordering::SeqCst);
STK_UNDO_COUNT.store(0, Ordering::SeqCst);
assert_eq!(
unsafe { oakengine_undo_push(cmd, c"operation".as_ptr()) },
0
);
assert_eq!(unsafe { oakengine_undo_count() }, 2);
assert_eq!(unsafe { oakengine_undo_index() }, 2);
assert_eq!(STK_REDO_COUNT.load(Ordering::SeqCst), 1);
// Row label (two-stage: query, then copy).
let mut buf = [0 as c_char; 64];
let len = unsafe { oakengine_undo_command_text(1, buf.as_mut_ptr(), 64) };
assert!(len > 0);
assert_eq!(
unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }
.to_str()
.unwrap(),
"operation"
);
assert_eq!(unsafe { oakengine_undo_command_is_done(1) }, 1);
// Invalid row → module NOT_FOUND (-20004) passes through.
assert_eq!(
unsafe { oakengine_undo_command_text(99, buf.as_mut_ptr(), 64) },
-20004
);
// Undo restores index 1 and flips the done flag.
assert_eq!(unsafe { oakengine_undo_jump(1) }, 0);
assert_eq!(unsafe { oakengine_undo_index() }, 1);
assert_eq!(unsafe { oakengine_undo_command_is_done(1) }, 0);
assert_eq!(unsafe { oakengine_undo_can_undo() }, 0);
assert_eq!(unsafe { oakengine_undo_can_redo() }, 1);
// Redo back to 2.
assert_eq!(unsafe { oakengine_undo_jump(2) }, 0);
assert_eq!(unsafe { oakengine_undo_index() }, 2);
unsafe { oakengine_undo_clear() };
// --- Undo group: begin → push children → end pushes ONE entry; abort
// undoes and discards. (continues the same serialized test)
assert_eq!(unsafe { oakengine_undo_clear() }, 0);
// Group begin/end with two children → one history row.
assert_eq!(
unsafe { oakengine_undo_group_begin(c"grouped".as_ptr()) },
0
);
// A second begin while open fails with E_STATE (-2).
assert_eq!(unsafe { oakengine_undo_group_begin(c"again".as_ptr()) }, -2);
let c1 = unsafe {
oakengine_undo_command_create(
c"c1".as_ptr(),
Some(redo_cb),
Some(undo_cb),
None,
std::ptr::null_mut(),
)
};
let c2 = unsafe {
oakengine_undo_command_create(
c"c2".as_ptr(),
Some(redo_cb),
Some(undo_cb),
None,
std::ptr::null_mut(),
)
};
// While a group is open, push adds to the group (child redo'd
// eagerly) instead of the stack.
assert_eq!(unsafe { oakengine_undo_push(c1, c"c1".as_ptr()) }, 0);
assert_eq!(unsafe { oakengine_undo_push(c2, c"c2".as_ptr()) }, 0);
assert_eq!(unsafe { oakengine_undo_count() }, 1); // nothing on the stack yet
assert_eq!(unsafe { oakengine_undo_group_end() }, 0);
assert_eq!(unsafe { oakengine_undo_count() }, 2); // one grouped row
// Abort path: group with a child is undone and discarded.
STK_UNDO_COUNT.store(0, Ordering::SeqCst);
assert_eq!(unsafe { oakengine_undo_group_begin(c"abort".as_ptr()) }, 0);
let c3 = unsafe {
oakengine_undo_command_create(
c"c3".as_ptr(),
Some(redo_cb),
Some(undo_cb),
None,
std::ptr::null_mut(),
)
};
assert_eq!(unsafe { oakengine_undo_push(c3, c"c3".as_ptr()) }, 0);
assert_eq!(unsafe { oakengine_undo_group_abort() }, 0);
assert_eq!(unsafe { oakengine_undo_count() }, 2); // unchanged
assert_eq!(STK_UNDO_COUNT.load(Ordering::SeqCst), 1); // c3's undo ran
// End with no open group fails with E_STATE.
assert_eq!(unsafe { oakengine_undo_group_end() }, -2);
unsafe { oakengine_undo_clear() };
// Push NULL fails with E_INVALID.
assert_eq!(
unsafe { oakengine_undo_push(std::ptr::null_mut(), c"x".as_ptr()) },
-1
);
}