undo: - NULL/empty label no longer crosses to oakundo as a dangling 0x1 pointer (push, group_begin/end) — fixed SIGSEGV - group_abort now undoes each executed child in reverse order task: - create_project_import addrefs the borrowed project handle instead of freeing it under the async task — fixed UAF/SIGSEGV timeline: - toggle_enabled/delete_clips guard NULL+0 slices — fixed SIGABRT - BlockSplitCommand halves placed correctly (oaktimeline undosplit) - PreservingLinks / ripple remove / ripple delete-gaps commands self-prepare on first redo — fixes silent no-op split/ripple - trim_clips_to targets the block containing the point, not the track - delete_empty_tracks applies the live track removal - ripple facades no longer free borrowed track handles still referenced by commands — fixed UAF node: - project_add_node releases the factory handle — fixes per-call leak - inputs_from(recursive=0) matches direct feeders (BFS off-by-one) - group passthrough id/resolve treat two-stage string length as success - node_connect(_command) reject duplicate connects with E_STATE - folder_add_child enforces one-folder-per-node - value_split_to_tracks splits vector/color per component - set_context_position/expanded establish the first entry - node_get_flags on an empty box returns 0, not u64::MAX - footage_borrow addrefs its wrapper — fixes double-free render: - renderer_create rejects invalid pixel formats (real range check) - render_frame forwards renderer width/height to the ticket tests: repro #[ignore]s removed, bug-behavior assertions corrected, it_undo global-stack tests serialized with a shared lock
260 lines
9.2 KiB
Rust
260 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/>.
|
|
|
|
//! 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.
|
|
|
|
#[path = "common/mod.rs"]
|
|
mod common;
|
|
|
|
|
|
use std::ffi::{c_char, c_int, c_void};
|
|
use std::sync::atomic::{AtomicI32, Ordering};
|
|
|
|
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_multi_add_child,
|
|
oakengine_undo_command_multi_child_count, oakengine_undo_command_redo_now,
|
|
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_command_is_done, oakengine_undo_command_text,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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() {
|
|
// 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
|
|
);
|
|
}
|