test(oakengine): rename integration test families to English names

it_*族.rs -> it_audio/codec/common/node/plugin/render/task/timeline/undo.rs;
also fixes a pre-existing racy assertion in it_task.
This commit is contained in:
2026-08-11 00:24:57 +08:00
parent d9c477365a
commit 3110e5cc3a
10 changed files with 10281 additions and 7 deletions
+14 -7
View File
@@ -94,10 +94,7 @@ fn audio_params_store() -> &'static Mutex<HashMap<usize, MockAudioParams>> {
fn audio_params_get(ctx: *const c_void) -> MockAudioParams {
let store = audio_params_store().lock().unwrap();
store
.get(&(ctx as usize))
.cloned()
.unwrap_or_default()
store.get(&(ctx as usize)).cloned().unwrap_or_default()
}
fn audio_params_set(ctx: *mut c_void, f: impl FnOnce(&mut MockAudioParams)) {
@@ -153,7 +150,10 @@ pub extern "C" fn oakcore_audioparams_sample_rate(params: *const OakAudioParams)
}
#[no_mangle]
pub extern "C" fn oakcore_audioparams_set_sample_rate(params: *mut OakAudioParams, sample_rate: c_int) {
pub extern "C" fn oakcore_audioparams_set_sample_rate(
params: *mut OakAudioParams,
sample_rate: c_int,
) {
audio_params_set(params as *mut c_void, |p| p.sample_rate = sample_rate);
}
@@ -168,7 +168,11 @@ pub extern "C" fn oakcore_audioparams_set_channel_layout(params: *mut OakAudioPa
}
#[no_mangle]
pub extern "C" fn oakcore_audioparams_set_time_base(params: *mut OakAudioParams, num: c_int, den: c_int) {
pub extern "C" fn oakcore_audioparams_set_time_base(
params: *mut OakAudioParams,
num: c_int,
den: c_int,
) {
audio_params_set(params as *mut c_void, |p| {
p.time_base_num = num;
p.time_base_den = den;
@@ -252,7 +256,10 @@ pub extern "C" fn oakcore_rational_free(rational: *mut c_void) {
if rational.is_null() {
return;
}
rational_store().lock().unwrap().remove(&(rational as usize));
rational_store()
.lock()
.unwrap()
.remove(&(rational as usize));
// SAFETY: produced by `oakcore_audioparams_time_base` as a boxed
// `(i32, i32)` pair; we hold the only reference after removal.
unsafe { drop(Box::from_raw(rational as *mut (i32, i32))) };
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+818
View File
@@ -0,0 +1,818 @@
// 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 common family (`src/common.rs` over
//! `engine/include/oakengine/{config,videoparams}.h`).
//!
//! Every exported function is exercised on a legal path with the result
//! asserted, plus the illegal-input matrix the engine must survive (NULL
//! pointers, empty handles, out-of-range indexes, zero/negative sizes,
//! garbage enums). All behavior is real: the facade calls into the real
//! oakcommon store and videoparams domain.
//!
//! The oakcommon config store is a process-wide singleton backed by
//! `config.ini` (honoring the `OAK_CONFIG_DIR` override), so every test
//! that touches config is serialized under [`CONFIG_LOCK`] and redirects
//! the file into a fresh temp dir. The videoparams tables are immutable
//! statics and the params handles are per-test objects, so those tests
//! run in parallel.
// The whole family is called through uniform `unsafe {}` blocks (matching
// the other test binaries), so extern functions that happen to be safe
// (e.g. `oakengine_config_load`) otherwise trip `unused_unsafe`.
#![allow(unused_unsafe)]
#[path = "common/mod.rs"]
mod common;
use std::ffi::{c_char, c_int};
use std::path::Path;
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Mutex;
use oakengine::common::{
oakengine_config_get_int, oakengine_config_get_string, oakengine_config_load,
oakengine_config_report_error, oakengine_config_save, oakengine_config_set_error_handler,
oakengine_config_set_int, oakengine_config_set_string, oakengine_video_params_bytes_per_pixel,
oakengine_video_params_create, oakengine_video_params_divider_name,
oakengine_video_params_effective_size, oakengine_video_params_equal,
oakengine_video_params_format_is_float, oakengine_video_params_format_pixel_aspect_ratio_string,
oakengine_video_params_frame_rate_to_string, oakengine_video_params_free,
oakengine_video_params_internal_channel_count, oakengine_video_params_is_valid,
oakengine_video_params_make, oakengine_video_params_pixel_format_name,
oakengine_video_params_standard_pixel_aspect_at,
oakengine_video_params_standard_pixel_aspect_count,
oakengine_video_params_standard_pixel_aspect_name, oakengine_video_params_supported_divider_at,
oakengine_video_params_supported_divider_count, oakengine_video_params_supported_frame_rate_at,
oakengine_video_params_supported_frame_rate_count, OakVideoParamsPod,
};
/// Read a two-stage facade string into a Rust String.
unsafe fn read_buf(buf: &mut [c_char]) -> String {
std::ffi::CStr::from_ptr(buf.as_ptr())
.to_string_lossy()
.into_owned()
}
/// Serializes every test that touches the process-wide config store and
/// redirects `OAK_CONFIG_DIR` to a fresh temp dir for the duration of `f`
/// (same pattern as the oakcommon crate's own test support). The only
/// readers of `OAK_CONFIG_DIR` in this binary are these serialized tests.
fn with_temp_config_dir<T>(f: impl FnOnce(&Path) -> T) -> T {
let _guard = CONFIG_LOCK.lock().unwrap();
let dir = std::env::temp_dir().join(format!("oakengine_it_common_config_{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
std::env::set_var("OAK_CONFIG_DIR", &dir);
let result = f(&dir);
std::env::remove_var("OAK_CONFIG_DIR");
let _ = std::fs::remove_dir_all(&dir);
result
}
/// The process-wide config store is a singleton; see module doc.
static CONFIG_LOCK: Mutex<()> = Mutex::new(());
// ---------------------------------------------------------------------------
// config.h
// ---------------------------------------------------------------------------
/// Load/save round-trip, defaults, typed entries and the two-stage string
/// convention (all serialized: the store is process-wide).
#[test]
fn config_roundtrip_persistence() {
common::force_link();
with_temp_config_dir(|dir| {
// A missing config.ini is not an error; defaults are loaded.
assert_eq!(unsafe { oakengine_config_load() }, 0);
// Missing keys read as empty / fallback.
let mut buf = [0 as c_char; 64];
assert_eq!(
unsafe { oakengine_config_get_string(c"no/such/key".as_ptr(), buf.as_mut_ptr(), 64) },
0
);
assert_eq!(unsafe { read_buf(&mut buf) }, "");
assert_eq!(unsafe { oakengine_config_get_int(c"no/such/key".as_ptr(), 7) }, 7);
// Compiled-in defaults are readable through the engine getters.
let len = unsafe {
oakengine_config_get_string(c"DefaultSequenceFrameRate".as_ptr(), buf.as_mut_ptr(), 64)
};
assert_eq!(len, 10);
assert_eq!(unsafe { read_buf(&mut buf) }, "1001/30000");
assert_eq!(unsafe { oakengine_config_get_int(c"DefaultSequenceWidth".as_ptr(), 0) }, 1920);
// String round-trip.
assert_eq!(
unsafe { oakengine_config_set_string(c"it/key".as_ptr(), c"hello".as_ptr()) },
0
);
let len = unsafe { oakengine_config_get_string(c"it/key".as_ptr(), buf.as_mut_ptr(), 64) };
assert_eq!(len, 5);
assert_eq!(unsafe { read_buf(&mut buf) }, "hello");
// Too-small buffer: the full length is reported and the buffer is
// left untouched (query size, then allocate, then copy).
let mut small = [0 as c_char; 3];
let len = unsafe { oakengine_config_get_string(c"it/key".as_ptr(), small.as_mut_ptr(), 3) };
assert_eq!(len, 5);
assert_eq!(unsafe { read_buf(&mut small) }, "");
// A NULL value stores an empty string (engine treats NULL as "").
assert_eq!(unsafe { oakengine_config_set_string(c"it/key".as_ptr(), std::ptr::null()) }, 0);
let len = unsafe { oakengine_config_get_string(c"it/key".as_ptr(), buf.as_mut_ptr(), 64) };
assert_eq!(len, 0);
assert_eq!(unsafe { read_buf(&mut buf) }, "");
assert_eq!(
unsafe { oakengine_config_set_string(c"it/key".as_ptr(), c"hello".as_ptr()) },
0
);
// A string entry read through the int getter falls back.
assert_eq!(unsafe { oakengine_config_get_int(c"it/key".as_ptr(), 9) }, 9);
// Int round-trip; a known typed key keeps its type across reload.
assert_eq!(unsafe { oakengine_config_set_int(c"it/num".as_ptr(), 1234) }, 0);
assert_eq!(unsafe { oakengine_config_get_int(c"it/num".as_ptr(), 0) }, 1234);
assert_eq!(
unsafe { oakengine_config_set_int(c"DefaultSequenceWidth".as_ptr(), 640) },
0
);
assert_eq!(unsafe { oakengine_config_get_int(c"DefaultSequenceWidth".as_ptr(), 0) }, 640);
// Persist, then reload from the file.
assert_eq!(unsafe { oakengine_config_save() }, 0);
assert!(dir.join("config.ini").exists());
assert_eq!(unsafe { oakengine_config_load() }, 0);
let len = unsafe { oakengine_config_get_string(c"it/key".as_ptr(), buf.as_mut_ptr(), 64) };
assert_eq!(len, 5);
assert_eq!(unsafe { read_buf(&mut buf) }, "hello");
assert_eq!(unsafe { oakengine_config_get_int(c"DefaultSequenceWidth".as_ptr(), 0) }, 640);
// A custom typed key loses its type on reload and reads as a string
// (module C++ parity: only known keys keep their declared type).
let len = unsafe { oakengine_config_get_string(c"it/num".as_ptr(), buf.as_mut_ptr(), 64) };
assert_eq!(len, 4);
assert_eq!(unsafe { read_buf(&mut buf) }, "1234");
assert_eq!(unsafe { oakengine_config_get_int(c"it/num".as_ptr(), 9) }, 9);
});
}
/// Illegal inputs on the config getters/setters: NULL keys and buffers,
/// empty keys, zero/negative sizes — all must fail cleanly, never crash.
#[test]
fn config_illegal_inputs() {
common::force_link();
with_temp_config_dir(|_dir| {
assert_eq!(unsafe { oakengine_config_load() }, 0);
assert_eq!(
unsafe { oakengine_config_set_string(c"it/k".as_ptr(), c"abc".as_ptr()) },
0
);
let mut buf = [0 as c_char; 64];
// NULL key → OAKENGINE_E_INVALID (-1).
assert_eq!(
unsafe { oakengine_config_get_string(std::ptr::null(), buf.as_mut_ptr(), 64) },
-1
);
assert_eq!(
unsafe { oakengine_config_set_string(std::ptr::null(), c"v".as_ptr()) },
-1
);
assert_eq!(unsafe { oakengine_config_set_int(std::ptr::null(), 5) }, -1);
// NULL key on the int getter returns the fallback (engine contract).
assert_eq!(unsafe { oakengine_config_get_int(std::ptr::null(), 42) }, 42);
// Empty key → the module's INVALID, passed through untranslated.
assert_eq!(
unsafe { oakengine_config_get_string(c"".as_ptr(), buf.as_mut_ptr(), 64) },
-10001
);
assert_eq!(unsafe { oakengine_config_get_int(c"".as_ptr(), 42) }, 42);
// NULL output buffer with a positive size → module INVALID (-10001).
assert_eq!(
unsafe { oakengine_config_get_string(c"it/k".as_ptr(), std::ptr::null_mut(), 64) },
-10001
);
// Negative size → module INVALID.
assert_eq!(
unsafe { oakengine_config_get_string(c"it/k".as_ptr(), buf.as_mut_ptr(), -1) },
-10001
);
// NULL buffer with size 0 is the two-stage size query: reports the
// required length without writing.
assert_eq!(
unsafe { oakengine_config_get_string(c"it/k".as_ptr(), std::ptr::null_mut(), 0) },
3
);
});
}
/// Error handler: registered, invoked via report_error and on a load
/// failure, NULL args are safe, NULL handler clears.
#[test]
fn config_error_handler_and_load_failure() {
common::force_link();
static CALLED: AtomicI32 = AtomicI32::new(0);
unsafe extern "C" fn handler(
_title: *const c_char,
_message: *const c_char,
_userdata: *mut std::ffi::c_void,
) {
CALLED.fetch_add(1, Ordering::SeqCst);
}
with_temp_config_dir(|dir| {
CALLED.store(0, Ordering::SeqCst);
// Register and report through the handler.
assert_eq!(
unsafe { oakengine_config_set_error_handler(Some(handler), std::ptr::null_mut()) },
0
);
assert_eq!(
unsafe { oakengine_config_report_error(c"title".as_ptr(), c"message".as_ptr()) },
0
);
assert_eq!(CALLED.load(Ordering::SeqCst), 1);
// NULL title/message are mapped to empty strings, still invoked.
assert_eq!(
unsafe { oakengine_config_report_error(std::ptr::null(), std::ptr::null()) },
0
);
assert_eq!(CALLED.load(Ordering::SeqCst), 2);
// NULL handler clears; reporting then does not invoke.
assert_eq!(
unsafe { oakengine_config_set_error_handler(None, std::ptr::null_mut()) },
0
);
unsafe { oakengine_config_report_error(c"t".as_ptr(), c"m".as_ptr()) };
assert_eq!(CALLED.load(Ordering::SeqCst), 2);
// A real load failure (config.ini is a directory) reports through
// the module's registered handler and returns the module FAILED
// code (-10003) untranslated.
assert_eq!(
unsafe { oakengine_config_set_error_handler(Some(handler), std::ptr::null_mut()) },
0
);
std::fs::create_dir(dir.join("config.ini")).unwrap();
assert_eq!(unsafe { oakengine_config_load() }, -10003);
assert_eq!(CALLED.load(Ordering::SeqCst), 3);
// Cleanup: drop the directory and clear the handler.
std::fs::remove_dir(dir.join("config.ini")).unwrap();
unsafe { oakengine_config_set_error_handler(None, std::ptr::null_mut()) };
assert_eq!(unsafe { oakengine_config_load() }, 0);
});
}
// ---------------------------------------------------------------------------
// videoparams.h — static tables
// ---------------------------------------------------------------------------
/// Static tables: counts, every legal index, specific values, and the
/// out-of-range / NULL failure paths.
#[test]
fn videoparams_static_tables_full() {
common::force_link();
// ---- frame rates ------------------------------------------------------
assert_eq!(unsafe { oakengine_video_params_supported_frame_rate_count() }, 12);
let mut num: c_int = 0;
let mut den: c_int = 0;
for i in 0..12 {
assert_eq!(
unsafe { oakengine_video_params_supported_frame_rate_at(i, &mut num, &mut den) },
0
);
assert!(num > 0 && den > 0, "frame rate {i} must be a positive rational");
}
assert_eq!(
unsafe { oakengine_video_params_supported_frame_rate_at(0, &mut num, &mut den) },
0
);
assert_eq!((num, den), (10, 1));
assert_eq!(
unsafe { oakengine_video_params_supported_frame_rate_at(2, &mut num, &mut den) },
0
);
assert_eq!((num, den), (24000, 1001)); // 23.976
assert_eq!(
unsafe { oakengine_video_params_supported_frame_rate_at(5, &mut num, &mut den) },
0
);
assert_eq!((num, den), (30000, 1001)); // 29.97
assert_eq!(
unsafe { oakengine_video_params_supported_frame_rate_at(6, &mut num, &mut den) },
0
);
assert_eq!((num, den), (30, 1));
assert_eq!(
unsafe { oakengine_video_params_supported_frame_rate_at(11, &mut num, &mut den) },
0
);
assert_eq!((num, den), (60, 1));
// Out-of-range / negative / huge indexes → E_INVALID (-1), no panic.
assert_eq!(
unsafe { oakengine_video_params_supported_frame_rate_at(12, &mut num, &mut den) },
-1
);
assert_eq!(
unsafe { oakengine_video_params_supported_frame_rate_at(99, &mut num, &mut den) },
-1
);
assert_eq!(
unsafe { oakengine_video_params_supported_frame_rate_at(-1, &mut num, &mut den) },
-1
);
assert_eq!(
unsafe { oakengine_video_params_supported_frame_rate_at(c_int::MAX, &mut num, &mut den) },
-1
);
// NULL outputs → E_INVALID.
assert_eq!(
unsafe { oakengine_video_params_supported_frame_rate_at(0, std::ptr::null_mut(), &mut den) },
-1
);
assert_eq!(
unsafe { oakengine_video_params_supported_frame_rate_at(0, &mut num, std::ptr::null_mut()) },
-1
);
// ---- pixel aspects ----------------------------------------------------
assert_eq!(unsafe { oakengine_video_params_standard_pixel_aspect_count() }, 6);
for i in 0..6 {
assert_eq!(
unsafe { oakengine_video_params_standard_pixel_aspect_at(i, &mut num, &mut den) },
0
);
assert!(num > 0 && den > 0, "pixel aspect {i} must be a positive rational");
}
assert_eq!(
unsafe { oakengine_video_params_standard_pixel_aspect_at(0, &mut num, &mut den) },
0
);
assert_eq!((num, den), (1, 1));
assert_eq!(
unsafe { oakengine_video_params_standard_pixel_aspect_at(4, &mut num, &mut den) },
0
);
assert_eq!((num, den), (64, 45));
assert_eq!(
unsafe { oakengine_video_params_standard_pixel_aspect_at(5, &mut num, &mut den) },
0
);
assert_eq!((num, den), (4, 3));
assert_eq!(
unsafe { oakengine_video_params_standard_pixel_aspect_at(6, &mut num, &mut den) },
-1
);
assert_eq!(
unsafe { oakengine_video_params_standard_pixel_aspect_at(-1, &mut num, &mut den) },
-1
);
assert_eq!(
unsafe { oakengine_video_params_standard_pixel_aspect_at(0, std::ptr::null_mut(), &mut den) },
-1
);
// ---- dividers ---------------------------------------------------------
assert_eq!(unsafe { oakengine_video_params_supported_divider_count() }, 8);
let expected: [c_int; 8] = [1, 2, 3, 4, 6, 8, 12, 16];
for (i, want) in expected.iter().enumerate() {
assert_eq!(unsafe { oakengine_video_params_supported_divider_at(i as c_int) }, *want);
}
assert_eq!(unsafe { oakengine_video_params_supported_divider_at(8) }, -1);
assert_eq!(unsafe { oakengine_video_params_supported_divider_at(-1) }, -1);
assert_eq!(unsafe { oakengine_video_params_supported_divider_at(c_int::MAX) }, -1);
}
/// Display names and string formatters (pixel aspect names, divider names,
/// frame-rate strings, PAR template formatting).
#[test]
fn videoparams_names_and_formatters() {
common::force_link();
let mut buf = [0 as c_char; 64];
// ---- standard pixel aspect names --------------------------------------
let len = unsafe { oakengine_video_params_standard_pixel_aspect_name(0, buf.as_mut_ptr(), 64) };
assert_eq!(len, 6);
assert_eq!(unsafe { read_buf(&mut buf) }, "Square");
let len = unsafe { oakengine_video_params_standard_pixel_aspect_name(1, buf.as_mut_ptr(), 64) };
assert_eq!(len, 3);
assert_eq!(unsafe { read_buf(&mut buf) }, "8:9");
let len = unsafe { oakengine_video_params_standard_pixel_aspect_name(4, buf.as_mut_ptr(), 64) };
assert_eq!(len, 5);
assert_eq!(unsafe { read_buf(&mut buf) }, "64:45");
// Out of range → E_INVALID; negative index → E_INVALID.
assert_eq!(
unsafe { oakengine_video_params_standard_pixel_aspect_name(6, buf.as_mut_ptr(), 64) },
-1
);
assert_eq!(
unsafe { oakengine_video_params_standard_pixel_aspect_name(-1, buf.as_mut_ptr(), 64) },
-1
);
// NULL buffer reports the length only (two-stage size query).
assert_eq!(
unsafe { oakengine_video_params_standard_pixel_aspect_name(0, std::ptr::null_mut(), 64) },
6
);
// Too-small buffer truncates but reports the full length.
let mut small = [0 as c_char; 2];
assert_eq!(
unsafe { oakengine_video_params_standard_pixel_aspect_name(0, small.as_mut_ptr(), 2) },
6
);
assert_eq!(unsafe { read_buf(&mut small) }, "S");
// ---- divider names ------------------------------------------------------
let len = unsafe { oakengine_video_params_divider_name(1, buf.as_mut_ptr(), 64) };
assert_eq!(len, 4);
assert_eq!(unsafe { read_buf(&mut buf) }, "Full");
let len = unsafe { oakengine_video_params_divider_name(2, buf.as_mut_ptr(), 64) };
assert_eq!(len, 3);
assert_eq!(unsafe { read_buf(&mut buf) }, "1/2");
let len = unsafe { oakengine_video_params_divider_name(8, buf.as_mut_ptr(), 64) };
assert_eq!(len, 3);
assert_eq!(unsafe { read_buf(&mut buf) }, "1/8");
// Zero / negative divider → E_INVALID (facade rejects before the module).
assert_eq!(unsafe { oakengine_video_params_divider_name(0, buf.as_mut_ptr(), 64) }, -1);
assert_eq!(unsafe { oakengine_video_params_divider_name(-3, buf.as_mut_ptr(), 64) }, -1);
// NULL buffer with a positive size → module INVALID, passed through.
assert_eq!(
unsafe { oakengine_video_params_divider_name(2, std::ptr::null_mut(), 64) },
-10001
);
// ---- frame rate strings -------------------------------------------------
let len = unsafe { oakengine_video_params_frame_rate_to_string(25, 1, buf.as_mut_ptr(), 64) };
assert_eq!(len, 6);
assert_eq!(unsafe { read_buf(&mut buf) }, "25 FPS");
let len = unsafe {
oakengine_video_params_frame_rate_to_string(24000, 1001, buf.as_mut_ptr(), 64)
};
assert_eq!(len, 10);
assert_eq!(unsafe { read_buf(&mut buf) }, "23.976 FPS");
let len = unsafe { oakengine_video_params_frame_rate_to_string(10, 1, buf.as_mut_ptr(), 64) };
assert_eq!(len, 6);
assert_eq!(unsafe { read_buf(&mut buf) }, "10 FPS");
// Zero denominator: C++-parity float division (1/0 → +inf), rendered as
// "inf FPS" — a legal return, never a crash/panic.
let len = unsafe { oakengine_video_params_frame_rate_to_string(1, 0, buf.as_mut_ptr(), 64) };
assert!(len >= 0, "den=0 must not error ({len})");
assert_eq!(unsafe { read_buf(&mut buf) }, "inf FPS");
// 0/0 → NaN → "nan FPS".
let len = unsafe { oakengine_video_params_frame_rate_to_string(0, 0, buf.as_mut_ptr(), 64) };
assert!(len >= 0);
assert_eq!(unsafe { read_buf(&mut buf) }, "nan FPS");
// NULL buffer with a positive size → module INVALID.
assert_eq!(
unsafe { oakengine_video_params_frame_rate_to_string(25, 1, std::ptr::null_mut(), 64) },
-10001
);
// NULL buffer with size 0 is the two-stage size query.
assert_eq!(
unsafe { oakengine_video_params_frame_rate_to_string(25, 1, std::ptr::null_mut(), 0) },
6
);
// ---- PAR template formatting (facade-local) -----------------------------
let len = unsafe {
oakengine_video_params_format_pixel_aspect_ratio_string(
c"%1".as_ptr(), 16, 15, buf.as_mut_ptr(), 64,
)
};
assert_eq!(len, 5);
assert_eq!(unsafe { read_buf(&mut buf) }, "16:15");
let len = unsafe {
oakengine_video_params_format_pixel_aspect_ratio_string(
c"par=%1".as_ptr(), 4, 3, buf.as_mut_ptr(), 64,
)
};
assert_eq!(len, 7);
assert_eq!(unsafe { read_buf(&mut buf) }, "par=4:3");
// No placeholder: the template passes through unchanged.
let len = unsafe {
oakengine_video_params_format_pixel_aspect_ratio_string(
c"raw".as_ptr(), 16, 15, buf.as_mut_ptr(), 64,
)
};
assert_eq!(len, 3);
assert_eq!(unsafe { read_buf(&mut buf) }, "raw");
// NULL format → E_INVALID; NULL buffer reports the length only.
assert_eq!(
unsafe {
oakengine_video_params_format_pixel_aspect_ratio_string(
std::ptr::null(), 16, 15, buf.as_mut_ptr(), 64,
)
},
-1
);
assert_eq!(
unsafe {
oakengine_video_params_format_pixel_aspect_ratio_string(
c"%1".as_ptr(), 16, 15, std::ptr::null_mut(), 64,
)
},
5
);
}
/// Format helpers: float/name queries and bytes-per-pixel across the
/// format matrix (valid, boundary and garbage codes).
#[test]
fn videoparams_format_helpers() {
common::force_link();
// format_is_float: F16 = 3, F32 = 4 float; everything else 0, garbage
// codes map to the Invalid format and report 0 (never crash).
assert_eq!(unsafe { oakengine_video_params_format_is_float(0) }, 0); // U8
assert_eq!(unsafe { oakengine_video_params_format_is_float(1) }, 0); // U10
assert_eq!(unsafe { oakengine_video_params_format_is_float(2) }, 0); // U16
assert_eq!(unsafe { oakengine_video_params_format_is_float(3) }, 1); // F16
assert_eq!(unsafe { oakengine_video_params_format_is_float(4) }, 1); // F32
assert_eq!(unsafe { oakengine_video_params_format_is_float(5) }, 0); // Count
assert_eq!(unsafe { oakengine_video_params_format_is_float(99) }, 0);
assert_eq!(unsafe { oakengine_video_params_format_is_float(-1) }, 0);
assert_eq!(unsafe { oakengine_video_params_format_is_float(c_int::MIN) }, 0);
// pixel_format_name for every real format.
let mut buf = [0 as c_char; 64];
let len = unsafe { oakengine_video_params_pixel_format_name(0, buf.as_mut_ptr(), 64) };
assert_eq!(len, 5);
assert_eq!(unsafe { read_buf(&mut buf) }, "8-bit");
let len = unsafe { oakengine_video_params_pixel_format_name(1, buf.as_mut_ptr(), 64) };
assert_eq!(len, 13);
assert_eq!(unsafe { read_buf(&mut buf) }, "10-bit Packed");
let len = unsafe { oakengine_video_params_pixel_format_name(4, buf.as_mut_ptr(), 64) };
assert_eq!(len, 19);
assert_eq!(unsafe { read_buf(&mut buf) }, "Full-Float (32-bit)");
// Garbage format → "Unknown (0xFFFFFFFF)" (Invalid renders %X of -1).
let len = unsafe { oakengine_video_params_pixel_format_name(99, buf.as_mut_ptr(), 64) };
assert_eq!(len, 20);
assert_eq!(unsafe { read_buf(&mut buf) }, "Unknown (0xFFFFFFFF)");
// NULL buffer / negative size → module INVALID; size-0 query → length.
assert_eq!(
unsafe { oakengine_video_params_pixel_format_name(0, std::ptr::null_mut(), 64) },
-10001
);
assert_eq!(
unsafe { oakengine_video_params_pixel_format_name(0, buf.as_mut_ptr(), -1) },
-10001
);
assert_eq!(
unsafe { oakengine_video_params_pixel_format_name(0, std::ptr::null_mut(), 0) },
5
);
// bytes_per_pixel across the format × channels matrix.
assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(0, 4) }, 4); // U8
assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(1, 4) }, 4); // U10 packed
assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(2, 4) }, 8); // U16
assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(3, 4) }, 8); // F16
assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(4, 4) }, 16); // F32
// Garbage formats have no channels-per-format entry → 0 bytes.
assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(99, 4) }, 0);
assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(-1, 4) }, 0);
// Zero channels → 0 bytes.
assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(0, 0) }, 0);
assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(4, 0) }, 0);
// Negative channels: the module does not validate (C++ parity), so the
// result is the plain signed product — a value, not a crash.
assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(4, -1) }, -4);
assert_eq!(unsafe { oakengine_video_params_internal_channel_count() }, 4);
}
/// Effective size: divider scaling on the legal matrix plus zero/negative
/// dimensions and dividers → E_INVALID.
#[test]
fn videoparams_effective_size_matrix() {
common::force_link();
let mut w: c_int = 0;
let mut h: c_int = 0;
assert_eq!(unsafe { oakengine_video_params_effective_size(1920, 1080, 1, &mut w, &mut h) }, 0);
assert_eq!((w, h), (1920, 1080));
assert_eq!(unsafe { oakengine_video_params_effective_size(1920, 1080, 2, &mut w, &mut h) }, 0);
assert_eq!((w, h), (960, 540));
assert_eq!(unsafe { oakengine_video_params_effective_size(1920, 1080, 4, &mut w, &mut h) }, 0);
assert_eq!((w, h), (480, 270));
assert_eq!(unsafe { oakengine_video_params_effective_size(100, 50, 3, &mut w, &mut h) }, 0);
assert_eq!((w, h), (33, 16));
// Divider 16 truncates the odd dimension (integer division).
assert_eq!(unsafe { oakengine_video_params_effective_size(1920, 1080, 16, &mut w, &mut h) }, 0);
assert_eq!((w, h), (120, 67));
// Both output pointers may be NULL (size computed, nothing written).
assert_eq!(
unsafe { oakengine_video_params_effective_size(1920, 1080, 2, std::ptr::null_mut(), std::ptr::null_mut()) },
0
);
// Zero / negative dimensions and dividers → E_INVALID.
assert_eq!(
unsafe { oakengine_video_params_effective_size(0, 1080, 2, &mut w, &mut h) },
-1
);
assert_eq!(
unsafe { oakengine_video_params_effective_size(1920, 0, 2, &mut w, &mut h) },
-1
);
assert_eq!(
unsafe { oakengine_video_params_effective_size(-1, 1080, 2, &mut w, &mut h) },
-1
);
assert_eq!(
unsafe { oakengine_video_params_effective_size(1920, 1080, 0, &mut w, &mut h) },
-1
);
assert_eq!(
unsafe { oakengine_video_params_effective_size(1920, 1080, -2, &mut w, &mut h) },
-1
);
}
// ---------------------------------------------------------------------------
// videoparams.h — POD make/equal/valid
// ---------------------------------------------------------------------------
/// A valid POD used across the POD tests.
fn valid_pod() -> OakVideoParamsPod {
let mut p: OakVideoParamsPod = unsafe { std::mem::zeroed() };
assert_eq!(
unsafe {
oakengine_video_params_make(
&mut p, 1920, 1080, 1001, 30000, 4, 1, 1, 0, 1, 2,
)
},
0
);
p
}
/// make fills every field; equal compares all of them; is_valid implements
/// the engine's POD validity rules.
#[test]
fn videoparams_pod_make_equal_valid() {
common::force_link();
// make: every field lands in the POD.
let mut p: OakVideoParamsPod = unsafe { std::mem::zeroed() };
assert_eq!(
unsafe { oakengine_video_params_make(&mut p, 1920, 1080, 1001, 30000, 4, 1, 1, 0, 1, 2) },
0
);
assert_eq!(p.width, 1920);
assert_eq!(p.height, 1080);
assert_eq!(p.time_base_num, 1001);
assert_eq!(p.time_base_den, 30000);
assert_eq!(p.format, 4);
assert_eq!(p.pixel_aspect_num, 1);
assert_eq!(p.pixel_aspect_den, 1);
assert_eq!(p.interlacing, 0);
assert_eq!(p.color_range, 1);
assert_eq!(p.divider, 2);
assert_eq!(p.video_type, 0);
assert_eq!(p.premultiplied_alpha, 0);
// NULL POD → E_INVALID.
assert_eq!(
unsafe {
oakengine_video_params_make(std::ptr::null_mut(), 1920, 1080, 1001, 30000, 4, 1, 1, 0, 1, 2)
},
-1
);
// equal: identical PODs → 1; any differing field → 0; NULL → 0.
let a = valid_pod();
let mut b = a;
assert_eq!(unsafe { oakengine_video_params_equal(&a, &b) }, 1);
for (field, val) in [
("width", 640),
("height", 720),
("time_base_num", 25),
("time_base_den", 1),
("format", 0),
("pixel_aspect_num", 4),
("pixel_aspect_den", 3),
("interlacing", 1),
("color_range", 0),
("divider", 1),
("video_type", 1),
("premultiplied_alpha", 1),
] {
let mut c = a;
match field {
"width" => c.width = val,
"height" => c.height = val,
"time_base_num" => c.time_base_num = val,
"time_base_den" => c.time_base_den = val,
"format" => c.format = val,
"pixel_aspect_num" => c.pixel_aspect_num = val,
"pixel_aspect_den" => c.pixel_aspect_den = val,
"interlacing" => c.interlacing = val,
"color_range" => c.color_range = val,
"divider" => c.divider = val,
"video_type" => c.video_type = val,
"premultiplied_alpha" => c.premultiplied_alpha = val,
_ => unreachable!(),
}
assert_eq!(
unsafe { oakengine_video_params_equal(&a, &c) },
0,
"equal must be 0 when {field} differs"
);
}
assert_eq!(unsafe { oakengine_video_params_equal(std::ptr::null(), &a) }, 0);
assert_eq!(unsafe { oakengine_video_params_equal(&a, std::ptr::null()) }, 0);
// is_valid: the valid POD → 1.
assert_eq!(unsafe { oakengine_video_params_is_valid(&a) }, 1);
// NULL → 0.
assert_eq!(unsafe { oakengine_video_params_is_valid(std::ptr::null()) }, 0);
// Each invalidating field → 0.
let cases: [(&str, fn(&mut OakVideoParamsPod)); 6] = [
("width", |p| p.width = 0),
("height", |p| p.height = 0),
("pixel_aspect_num", |p| p.pixel_aspect_num = 0),
("pixel_aspect_den", |p| p.pixel_aspect_den = 0),
("format", |p| p.format = -1),
("time_base_den", |p| p.time_base_den = 0),
];
for (name, mutate) in cases {
let mut c = a;
mutate(&mut c);
assert_eq!(
unsafe { oakengine_video_params_is_valid(&c) },
0,
"is_valid must be 0 when {name} is invalid"
);
}
// NOTE (observed divergence): the facade's POD check uses `format >= 0`,
// so out-of-range-but-non-negative formats (e.g. 99, or the Count code 5)
// read as "valid" here, while the module/C++ `VideoParams::is_valid`
// additionally requires `format < Count`. The facade check is a
// simplified local rule (the POD has no channel_count), not a crash.
let mut c = a;
c.format = 99;
assert_eq!(unsafe { oakengine_video_params_is_valid(&c) }, 1);
}
// ---------------------------------------------------------------------------
// videoparams.h — opaque handle lifecycle
// ---------------------------------------------------------------------------
/// create/free lifecycle: NULL rejection, real-handle creation, NULL free.
#[test]
fn videoparams_create_free_lifecycle() {
common::force_link();
// NULL POD → NULL handle.
assert!(unsafe { oakengine_video_params_create(std::ptr::null()) }.is_null());
// Valid POD → non-NULL handle; freed cleanly.
let pod = valid_pod();
let h = unsafe { oakengine_video_params_create(&pod) };
assert!(!h.is_null());
unsafe { oakengine_video_params_free(h) };
// A zeroed POD still yields a handle (the module initializes a default
// set and the setters accept any values); the handle frees cleanly.
let zeroed: OakVideoParamsPod = unsafe { std::mem::zeroed() };
let h = unsafe { oakengine_video_params_create(&zeroed) };
assert!(!h.is_null());
unsafe { oakengine_video_params_free(h) };
// free(NULL) is a documented no-op.
unsafe { oakengine_video_params_free(std::ptr::null_mut()) };
// NOTE (contract): the facade's free deallocates the handle box
// (`Box::from_raw`), so a second free of the same pointer is a
// use-after-free and is NOT part of the family's contract — unlike the
// module-level `oakcommon_videoparams_free`, which nulls the handle out
// before returning. This family exposes no debug alive counter to
// verify a return to baseline; leak-free operation is implied by the
// create/free round-trips above.
}
File diff suppressed because it is too large Load Diff
+624
View File
@@ -0,0 +1,624 @@
// 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 plugin family: the facade exports
//! `oakengine_plugin_*` (src/plugin.rs; module C contract
//! `include/plugin/{host,instance,error}.h`), exercised end to end
//! against the REAL `oakplugin` crate — no mocks anywhere.
//!
//! `oakengine_plugin_load_plugins` drives the real OFX host scan
//! (dlopen of real plugin bundles); the family's only destroy surface
//! lives in the backend module (`oakplugin_instance_create/free`), which
//! is verified here against the module's debug alive counter
//! (`oakplugin_debug_alive_count`, the leak assertion for this family).
//! The two provider setters are pure facade state (module 00 analogues of
//! the C++ capi statics): their result IS the return code, asserted below.
//!
//! The host singleton is process-global and only internally locked, so
//! every test that touches it serializes on [`with_host`] (same
//! convention as the module crate's own tests).
//!
//! The end-to-end bundle test needs the minimal test plugin that the
//! oakplugin crate's build.rs compiles (cbits/oak_test_plugin.c). When
//! it is unavailable the test prints SKIP and returns (never fails).
#[path = "common/mod.rs"]
mod common;
use std::ffi::{c_char, c_int, c_void, CStr, CString};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use oakengine::handle::{CHandle, OakEngineNode};
use oakengine::plugin::{
oakengine_plugin_load_plugins, oakengine_plugin_node_push_button_clicked,
oakengine_plugin_set_active_viewer_provider, oakengine_plugin_set_progress_reporter_factory,
};
use oakplugin::ffi::{
oakplugin_debug_alive_count, oakplugin_host_plugin_count, oakplugin_host_plugin_id_at,
oakplugin_host_plugin_label, oakplugin_instance_create, oakplugin_instance_free,
};
/// `OAKENGINE_E_INVALID` (src/error.rs).
const E_INVALID: c_int = -1;
/// `OAKENGINE_E_FAILED` (src/error.rs).
const E_FAILED: c_int = -3;
/// `OAKPLUGIN_E_INVALID` (module error.h) — module codes pass through the
/// facade untranslated.
const PLUGIN_E_INVALID: c_int = -90001;
/// Identifier of the minimal OFX test plugin (cbits/oak_test_plugin.c).
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
/// Build-system injected bundle path (set by the CMake test runner).
const TEST_PLUGIN_ENV: &str = "OAK_TEST_PLUGIN_DIR";
// ---------------------------------------------------------------------------
// Host serialization + fixtures
// ---------------------------------------------------------------------------
/// Serialize host-touching tests: the oakplugin host is a process
/// singleton without a top-level lock (each internal list is mutexed, but
/// init/scan/shutdown interleavings would make count assertions flaky).
fn with_host(f: impl FnOnce()) {
static LOCK: Mutex<()> = Mutex::new(());
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
f();
}
/// Fresh directory under the system temp dir (removed before creation).
fn fresh_temp_dir(name: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!("oak-it-plugin-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).expect("create temp dir");
p
}
/// Current number of live backend objects (host instance registry).
fn alive() -> c_int {
unsafe { oakplugin_debug_alive_count() }
}
/// Number of plugins discovered by the real host cache.
fn plugin_count() -> c_int {
unsafe { oakplugin_host_plugin_count() }
}
/// Run the facade scan through the real host, returning its exit code.
fn scan_facade(dir: &Path) -> c_int {
let cs = CString::new(dir.as_os_str().as_encoded_bytes()).expect("NUL-free path");
unsafe { oakengine_plugin_load_plugins(cs.as_ptr()) }
}
/// Locate the real test plugin shared library: oakplugin's build.rs
/// compiles cbits/oak_test_plugin.c to `$OUT_DIR/oak_test_plugin.{dylib,so}`
/// inside its own `target/*/build/oakplugin-*/out/` directory.
fn find_test_plugin_lib() -> Option<PathBuf> {
let ext = if cfg!(target_os = "macos") {
"dylib"
} else {
"so"
};
let mut roots: Vec<PathBuf> = Vec::new();
if let Some(t) = std::env::var_os("CARGO_TARGET_DIR") {
roots.push(PathBuf::from(t));
}
roots.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target"));
for root in roots {
for profile in ["debug", "release"] {
let build = root.join(profile).join("build");
let Ok(entries) = std::fs::read_dir(&build) else {
continue;
};
let mut hits: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("oakplugin-"))
})
.collect();
hits.sort();
for dir in hits {
let lib = dir.join("out").join(format!("oak_test_plugin.{ext}"));
if lib.is_file() {
return Some(lib);
}
}
}
}
None
}
/// Copy a test-plugin shared library into a `.bundle` directory layout
/// under `root` (Contents/MacOS or Contents/Linux-x86-64, matching the
/// host's `find_binary_in_bundle`).
fn install_bundle(lib: &Path, root: &Path) -> Option<()> {
let platform = if cfg!(target_os = "macos") {
"MacOS"
} else {
"Linux-x86-64"
};
let bin_dir = root
.join("oak-test-plugin.ofx.bundle")
.join("Contents")
.join(platform);
std::fs::create_dir_all(&bin_dir).ok()?;
std::fs::copy(lib, bin_dir.join("plugin")).ok()?;
Some(())
}
/// A scan directory containing the real test plugin bundle, if available:
/// either the parent of the build-system-injected bundle
/// (`OAK_TEST_PLUGIN_DIR`) or a bundle assembled in the temp dir from the
/// shared library oakplugin's build.rs produced. `None` → caller skips.
fn test_plugin_scan_dir() -> Option<PathBuf> {
if let Some(bundle) = std::env::var_os(TEST_PLUGIN_ENV) {
return PathBuf::from(bundle).parent().map(|p| p.to_path_buf());
}
let lib = find_test_plugin_lib()?;
let root = std::env::temp_dir().join(format!("oak-it-plugin-bundle-{}", std::process::id()));
if !root.join("oak-test-plugin.ofx.bundle").exists() {
install_bundle(&lib, &root)?;
}
Some(root)
}
/// A scan directory that is guaranteed to have been scanned by NO other
/// test in this binary (fresh per call), so "scan registers the plugin"
/// assertions are deterministic. The env-injected bundle has no fresh
/// variant and falls back to the shared one.
fn fresh_test_plugin_scan_dir(tag: &str) -> Option<PathBuf> {
if std::env::var_os(TEST_PLUGIN_ENV).is_some() {
return test_plugin_scan_dir();
}
let lib = find_test_plugin_lib()?;
let root = fresh_temp_dir(&format!("bundle-{tag}"));
install_bundle(&lib, &root)?;
Some(root)
}
/// All plugin identifiers currently registered in the real host cache
/// (two-stage getter round trip per index).
fn plugin_ids() -> Vec<String> {
let count = plugin_count();
let mut out = Vec::new();
for i in 0..count {
let len = unsafe { oakplugin_host_plugin_id_at(i, std::ptr::null_mut(), 0) };
if len <= 0 {
continue;
}
let mut buf = vec![0u8; len as usize];
let rc = unsafe { oakplugin_host_plugin_id_at(i, buf.as_mut_ptr() as *mut c_char, len) };
if rc == 0 {
out.push(
unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) }
.to_string_lossy()
.into_owned(),
);
}
}
out
}
// ---------------------------------------------------------------------------
// oakengine_plugin_set_active_viewer_provider
// ---------------------------------------------------------------------------
/// Legal matrix for the active-viewer provider: fn Some/None × userdata
/// ptr/NULL all register (or clear) with `OAKENGINE_OK`.
#[test]
fn active_viewer_provider_register_clear_matrix() {
common::force_link();
unsafe extern "C" fn viewer(_userdata: *mut c_void) -> *mut OakEngineNode {
std::ptr::null_mut()
}
let mut userdata = 42i32;
let ud = &mut userdata as *mut i32 as *mut c_void;
// Some(fn) + userdata.
assert_eq!(
oakengine_plugin_set_active_viewer_provider(Some(viewer), ud),
0
);
// Some(fn) + NULL userdata (userdata is opaque, NULL is legal).
assert_eq!(
oakengine_plugin_set_active_viewer_provider(Some(viewer), std::ptr::null_mut()),
0
);
// None clears (NULL fn), userdata is then ignored but still legal.
assert_eq!(oakengine_plugin_set_active_viewer_provider(None, ud), 0);
assert_eq!(
oakengine_plugin_set_active_viewer_provider(None, std::ptr::null_mut()),
0
);
// Register again and clear, so the process-global state ends neutral.
assert_eq!(
oakengine_plugin_set_active_viewer_provider(Some(viewer), std::ptr::null_mut()),
0
);
assert_eq!(
oakengine_plugin_set_active_viewer_provider(None, std::ptr::null_mut()),
0
);
}
// ---------------------------------------------------------------------------
// oakengine_plugin_set_progress_reporter_factory
// ---------------------------------------------------------------------------
/// Legal matrix for the progress-reporter factory: full factory, clear
/// (all NULL), partial registrations and NULL userdata all return
/// `OAKENGINE_OK`.
#[test]
fn progress_reporter_factory_register_clear_matrix() {
common::force_link();
unsafe extern "C" fn create(
_m: *const c_char,
_t: *const c_char,
_u: *mut c_void,
) -> *mut c_void {
std::ptr::null_mut()
}
unsafe extern "C" fn destroy(_r: *mut c_void, _u: *mut c_void) {}
unsafe extern "C" fn is_cancelled(_r: *mut c_void, _u: *mut c_void) -> c_int {
0
}
unsafe extern "C" fn set_progress(_r: *mut c_void, _p: f64, _u: *mut c_void) {}
let mut userdata = 7i64;
let ud = &mut userdata as *mut i64 as *mut c_void;
// Full factory + userdata.
assert_eq!(
oakengine_plugin_set_progress_reporter_factory(
Some(create),
Some(destroy),
Some(is_cancelled),
Some(set_progress),
ud,
),
0
);
// All-NULL clears (NULL userdata too).
assert_eq!(
oakengine_plugin_set_progress_reporter_factory(
None,
None,
None,
None,
std::ptr::null_mut()
),
0
);
// Partial registrations are accepted (the facade stores what it gets).
assert_eq!(
oakengine_plugin_set_progress_reporter_factory(
Some(create),
None,
None,
None,
std::ptr::null_mut()
),
0
);
assert_eq!(
oakengine_plugin_set_progress_reporter_factory(None, Some(destroy), None, None, ud),
0
);
assert_eq!(
oakengine_plugin_set_progress_reporter_factory(
None,
None,
Some(is_cancelled),
None,
std::ptr::null_mut()
),
0
);
assert_eq!(
oakengine_plugin_set_progress_reporter_factory(None, None, None, Some(set_progress), ud),
0
);
// Back to cleared.
assert_eq!(
oakengine_plugin_set_progress_reporter_factory(
None,
None,
None,
None,
std::ptr::null_mut()
),
0
);
}
// ---------------------------------------------------------------------------
// oakengine_plugin_load_plugins
// ---------------------------------------------------------------------------
/// NULL path → facade `E_INVALID`, never a crash.
#[test]
fn load_plugins_null_path() {
with_host(|| {
common::force_link();
let before = alive();
assert_eq!(
unsafe { oakengine_plugin_load_plugins(std::ptr::null()) },
E_INVALID
);
assert_eq!(
alive(),
before,
"failed scan must not touch the host registry"
);
});
}
/// Empty string path is a documented no-op (canonicalize fails, not a
/// directory → host returns OK; the C++ host never errors on a missing
/// path).
#[test]
fn load_plugins_empty_string_path() {
with_host(|| {
common::force_link();
let cs = CString::new("").unwrap();
let before = alive();
assert_eq!(unsafe { oakengine_plugin_load_plugins(cs.as_ptr()) }, 0);
assert_eq!(alive(), before);
});
}
/// Nonexistent path: silently skipped with OK (olivehost.cpp add_plugin_path
/// semantics), no crash.
#[test]
fn load_plugins_nonexistent_path() {
with_host(|| {
common::force_link();
let dir =
std::env::temp_dir().join(format!("oak-it-plugin-missing-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir); // guarantee absence
let before = alive();
assert_eq!(scan_facade(&dir), 0);
assert_eq!(alive(), before);
});
}
/// A path that points at a regular file (not a directory) is a documented
/// no-op returning OK.
#[test]
fn load_plugins_path_is_a_file() {
with_host(|| {
common::force_link();
let dir = fresh_temp_dir("file-scan");
let file = dir.join("not-a-dir");
std::fs::write(&file, b"hi").unwrap();
let before = alive();
assert_eq!(scan_facade(&file), 0);
assert_eq!(alive(), before);
});
}
/// Empty directory scans cleanly (OK), changes nothing in the plugin
/// cache, and a repeat scan of the same path is deduplicated (also OK) —
/// the meaningful size=0 / index-range ground state.
#[test]
fn load_plugins_empty_dir_and_dedup() {
with_host(|| {
common::force_link();
let dir = fresh_temp_dir("empty");
let count_before = plugin_count();
let before = alive();
assert_eq!(scan_facade(&dir), 0);
assert_eq!(
plugin_count(),
count_before,
"empty dir must not register plugins"
);
// Same path again → dedup no-op, still OK.
assert_eq!(scan_facade(&dir), 0);
assert_eq!(plugin_count(), count_before);
assert_eq!(alive(), before);
});
}
/// Unicode path (non-ASCII directory name) scans cleanly.
#[test]
fn load_plugins_unicode_path() {
with_host(|| {
common::force_link();
let dir = fresh_temp_dir("unicode");
let unicode = dir.join("插件-目录-β");
std::fs::create_dir_all(&unicode).unwrap();
let before = alive();
assert_eq!(scan_facade(&unicode), 0);
assert_eq!(alive(), before);
});
}
/// Non-UTF-8 path bytes: the module rejects them with `OAKPLUGIN_E_INVALID`
/// which passes through the facade untranslated (-90001), never a crash.
#[test]
fn load_plugins_non_utf8_path() {
with_host(|| {
common::force_link();
let cs = CString::new(&b"/tmp/oak-it-plugin-\xff\xfe"[..]).unwrap();
let before = alive();
assert_eq!(
unsafe { oakengine_plugin_load_plugins(cs.as_ptr()) },
PLUGIN_E_INVALID
);
assert_eq!(alive(), before);
});
}
/// End-to-end legal path: `oakengine_plugin_load_plugins` against a real
/// directory containing the real OFX test plugin bundle registers the
/// plugin in the real host cache (dlopen + setHost + load + describe all
/// run). Verified through the module's own introspection, plus a repeat
/// scan (dedup) and the alive counter.
///
/// Skips when the test plugin was not built (see module docs).
#[test]
fn load_plugins_real_bundle_end_to_end() {
with_host(|| {
common::force_link();
// A fresh directory so "scan registers the plugin" is deterministic;
// the env-injected mode falls back to the shared dir.
let Some(dir) = fresh_test_plugin_scan_dir("e2e") else {
println!("SKIP: test plugin bundle unavailable (oakplugin build.rs output missing)");
return;
};
let before = alive();
let ids_before = plugin_ids();
let had_test_plugin = ids_before.iter().any(|id| id == TEST_PLUGIN_ID);
let count_before = plugin_count();
// The facade scan returns OK and — unless the test plugin ids were
// already registered by an earlier scan in this binary (the host
// dedups globally by identifier, so a second scan of the same
// bundle binary is a no-op) — registers the test plugin.
assert_eq!(scan_facade(&dir), 0);
let ids_after = plugin_ids();
assert!(
ids_after.iter().any(|id| id == TEST_PLUGIN_ID),
"test plugin id must be discoverable after scan (ids: {ids_after:?})"
);
if !had_test_plugin {
assert!(
plugin_count() > count_before,
"a first scan of the real bundle must register the plugin ({} -> {})",
count_before,
plugin_count()
);
}
// Label lookup for a known id resolves (phase 1: the id itself).
let len = unsafe {
oakplugin_host_plugin_label(c"org.oak.test-plugin".as_ptr(), std::ptr::null_mut(), 0)
};
assert!(len > 0);
let mut lbuf = vec![0u8; len as usize];
assert_eq!(
unsafe {
oakplugin_host_plugin_label(
c"org.oak.test-plugin".as_ptr(),
lbuf.as_mut_ptr() as *mut c_char,
len,
)
},
0
);
// Repeat scan of the same path is deduplicated: still OK, cache
// unchanged, alive counter untouched.
let count_after_first = plugin_count();
assert_eq!(scan_facade(&dir), 0);
assert_eq!(plugin_count(), count_after_first);
assert_eq!(alive(), before, "scan must not leak host instances");
});
}
// ---------------------------------------------------------------------------
// oakengine_plugin_node_push_button_clicked
// ---------------------------------------------------------------------------
/// Documented stub: the oakplugin crate has no push-button API (the OFX
/// button-param trigger is C++-only), so every input combination returns
/// `OAKENGINE_E_FAILED` and never reads its arguments.
#[test]
fn push_button_clicked_documented_stub() {
common::force_link();
// NULL node + NULL button.
assert_eq!(
unsafe {
oakengine_plugin_node_push_button_clicked(std::ptr::null_mut(), std::ptr::null())
},
E_FAILED
);
// NULL node + button id.
assert_eq!(
unsafe { oakengine_plugin_node_push_button_clicked(std::ptr::null_mut(), c"btn".as_ptr()) },
E_FAILED
);
// Non-NULL node (empty handle) + NULL button.
let mut node = OakEngineNode {
handle: CHandle::null(),
};
assert_eq!(
unsafe { oakengine_plugin_node_push_button_clicked(&mut node, std::ptr::null()) },
E_FAILED
);
// Non-NULL node + button id.
assert_eq!(
unsafe { oakengine_plugin_node_push_button_clicked(&mut node, c"btn".as_ptr()) },
E_FAILED
);
}
// ---------------------------------------------------------------------------
// Backend destroy contract (the family's only free surface)
// ---------------------------------------------------------------------------
/// The facade plugin family exports no free/destroy function; its only
/// destroy surface is the backend `oakplugin_instance_free`. Contracts
/// verified against the real host: free(NULL)/free(empty)/double-free are
/// no-ops, unknown ids yield an empty handle (documented), and a real
/// instance create → free round trip restores the module's alive counter
/// to baseline (the leak assertion for this family).
#[test]
fn backend_instance_free_contracts() {
with_host(|| {
common::force_link();
let base = alive();
// free(NULL) and free(empty handle) are no-ops.
unsafe { oakplugin_instance_free(std::ptr::null_mut()) };
let mut empty = CHandle::null();
unsafe { oakplugin_instance_free(&mut empty) };
assert!(empty.is_null(), "free must leave the handle emptied");
// Unknown plugin id → empty handle, not a crash.
let mut h = unsafe { oakplugin_instance_create(c"org.oak.not-a-plugin".as_ptr()) };
assert!(h.is_null());
unsafe { oakplugin_instance_free(&mut h) };
assert_eq!(alive(), base);
// Real plugin: create +1, free back to baseline, double-free safe.
let Some(dir) = test_plugin_scan_dir() else {
println!("SKIP: test plugin bundle unavailable (oakplugin build.rs output missing)");
return;
};
assert_eq!(scan_facade(&dir), 0);
let mut inst = unsafe { oakplugin_instance_create(c"org.oak.test-plugin".as_ptr()) };
assert!(
!inst.is_null(),
"scanned test plugin must create an instance"
);
assert_eq!(alive(), base + 1, "one live instance must be registered");
unsafe { oakplugin_instance_free(&mut inst) };
assert!(inst.is_null());
assert_eq!(
alive(),
base,
"free must return the alive counter to baseline"
);
// Double free of the already-emptied handle is a no-op.
unsafe { oakplugin_instance_free(&mut inst) };
assert_eq!(alive(), base);
});
}
File diff suppressed because it is too large Load Diff
+904
View File
@@ -0,0 +1,904 @@
// 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 **task family** (`src/task.rs`, the
//! `oakengine_task_*` C ABI; module contract `include/task/*.h`).
//!
//! Coverage rules (see the family test charter):
//! 1. no mocks — every call goes through the real facade into the real
//! oaktask/oaknode/oakundo/oakcodec module crates (the only stubs are
//! the host-provided `oakcore_*`/`fb_*` symbols in `tests/common`,
//! the same mechanism the other family tests use);
//! 2. every one of the 27 `oakengine_task_*` / `oakengine_cli_task_*`
//! exports is exercised on a legal path with the result asserted;
//! 3. legal-input matrix (compression flags, url counts, indices, buffer
//! sizes) covers the meaningful combinations;
//! 4. illegal inputs (NULL, empty handles, out-of-range indices, zero /
//! negative sizes, garbage flag values, wrong-family handles) always
//! yield a negative error code or a documented NULL/0 no-op — never a
//! crash;
//! 5. free contracts: free(NULL) and free(empty-handle) are clean error
//! no-ops, and `oaktask_debug_alive_count()` returns to baseline.
//!
//! ## Serialization
//!
//! Two process-wide states force the tests into one thread: the facade's
//! lazily-created global task manager and the global undo stack
//! (`oakengine_project_new` clears it). Additionally the alive-count
//! assertions measure a process-wide module counter, so every test takes a
//! shared [`SERIAL`] mutex (the same pattern as the oaktask crate's own
//! `MANAGER_LOCK`).
//!
//! ## Ignored tests
//!
//! - [`export_task_run_ignored_environment_gated`]: running an export
//! needs a real GPU/OpenGL render and a real ffmpeg encoder; the host
//! stubs cannot encode. The creation path is covered in the main suite.
//! - [`import_run_crashes_engine_bug`]: **real engine bug reproduction**
//! (see its docs): running a single-file import task crashes with
//! SIGSEGV because the facade frees the borrowed project handle the
//! import task still holds.
#[path = "common/mod.rs"]
mod common;
use std::ffi::{c_char, c_int};
use std::sync::Mutex;
use oakengine::codec::{oakengine_encoding_params_create, oakengine_encoding_params_set_filename};
use oakengine::handle::{free_box, CHandle, OakEngineNode, OakEngineProject, OakEngineSequence, OakEngineTask};
use oakengine::node::{
oakengine_node_free, oakengine_project_create, oakengine_project_free, oakengine_project_new,
oakengine_project_root, oakengine_project_set_filename,
};
use oakengine::task::{
oakengine_cli_task_dialog_run, oakengine_task_cancel, oakengine_task_create_export,
oakengine_task_create_project_import, oakengine_task_create_project_load,
oakengine_task_create_project_load_otio, oakengine_task_create_project_save,
oakengine_task_create_project_save_otio, oakengine_task_create_proxy, oakengine_task_error,
oakengine_task_free, oakengine_task_import_file_count, oakengine_task_import_footage_at,
oakengine_task_import_footage_count, oakengine_task_import_get_command,
oakengine_task_import_invalid_file_at, oakengine_task_import_invalid_files_count,
oakengine_task_is_cancelled, oakengine_task_manager_add, oakengine_task_manager_cancel,
oakengine_task_manager_count, oakengine_task_manager_first, oakengine_task_manager_handle,
oakengine_task_save_get_project, oakengine_task_start_sync, oakengine_task_start_time,
oakengine_task_title,
};
use oakengine::timeline::oakengine_sequence_new;
use oakengine::undo::oakengine_undo_command_free;
/// OAKTASK module error codes that pass through untranslated.
const OAKTASK_E_INVALID: c_int = -80001;
const OAKTASK_E_STATE: c_int = -80002;
const OAKTASK_E_NOT_FOUND: c_int = -80004;
/// Serializes every test in this binary (see the module docs). Poisoned by a
/// panicking test, the lock is recovered with `into_inner` so one failure
/// does not cascade into `PoisonError` failures in every later test.
static SERIAL: Mutex<()> = Mutex::new(());
/// Take the [`SERIAL`] lock, recovering from any poisoning.
fn serial() -> std::sync::MutexGuard<'static, ()> {
SERIAL.lock().unwrap_or_else(|e| e.into_inner())
}
/// The oaktask module's debug alive counter (not re-exported by the
/// facade; the module crate is a real dependency of the test binary).
fn alive_count() -> c_int {
unsafe { oaktask::ffi::task::oaktask_debug_alive_count() }
}
/// Read a NUL-terminated two-stage buffer as a Rust `String`.
fn read_buf(buf: &[c_char]) -> String {
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) })
.into_owned()
}
/// A facade task box wrapping an EMPTY module handle (`ctx == NULL`), the
/// "empty handle" state the C contract documents as invalid input.
fn empty_task_box() -> *mut OakEngineTask {
Box::into_raw(Box::new(OakEngineTask { handle: CHandle::null() }))
}
/// Reclaim a facade box that `oakengine_task_free` refused to consume
/// (NULL/empty handles are errors, so the box stays allocated).
///
/// # Safety
/// `ptr` must be a box produced by [`empty_task_box`] that was never freed.
unsafe fn reclaim_empty_task_box(ptr: *mut OakEngineTask) {
unsafe { drop(Box::from_raw(ptr)) };
}
// ---------------------------------------------------------------------------
// NULL / empty-handle rejection (all 27 exports)
// ---------------------------------------------------------------------------
/// Every accessor rejects a NULL task with OAKENGINE_E_INVALID (-1) and
/// every pointer accessor returns NULL; creators return NULL for NULL /
/// invalid arguments; the CLI dialog returns 0 (the capi's "no task" is
/// not an error).
#[test]
fn null_handles_are_rejected() {
let _g = serial();
common::force_link();
let mut buf = [0 as c_char; 256];
// ---- manager family -----------------------------------------------------
assert_eq!(unsafe { oakengine_task_manager_add(std::ptr::null_mut()) }, -1);
assert_eq!(unsafe { oakengine_task_manager_cancel(std::ptr::null_mut()) }, -1);
// ---- task accessors -----------------------------------------------------
assert_eq!(unsafe { oakengine_task_title(std::ptr::null_mut(), buf.as_mut_ptr(), 256) }, -1);
assert_eq!(unsafe { oakengine_task_error(std::ptr::null_mut(), buf.as_mut_ptr(), 256) }, -1);
assert_eq!(unsafe { oakengine_task_start_time(std::ptr::null_mut()) }, -1);
assert_eq!(unsafe { oakengine_task_is_cancelled(std::ptr::null_mut()) }, -1);
assert_eq!(unsafe { oakengine_task_cancel(std::ptr::null_mut()) }, -1);
assert_eq!(unsafe { oakengine_task_start_sync(std::ptr::null_mut()) }, -1);
assert_eq!(unsafe { oakengine_task_free(std::ptr::null_mut()) }, -1);
// ---- import / save result accessors ------------------------------------
assert_eq!(unsafe { oakengine_task_import_file_count(std::ptr::null_mut()) }, -1);
assert!(unsafe { oakengine_task_import_get_command(std::ptr::null_mut()) }.is_null());
assert_eq!(unsafe { oakengine_task_import_footage_count(std::ptr::null_mut()) }, -1);
assert!(unsafe { oakengine_task_import_footage_at(std::ptr::null_mut(), 0) }.is_null());
assert_eq!(
unsafe { oakengine_task_import_invalid_files_count(std::ptr::null_mut()) },
-1
);
assert_eq!(
unsafe { oakengine_task_import_invalid_file_at(std::ptr::null_mut(), 0, buf.as_mut_ptr(), 256) },
-1
);
assert!(unsafe { oakengine_task_save_get_project(std::ptr::null_mut()) }.is_null());
// ---- creators ------------------------------------------------------------
assert!(unsafe { oakengine_task_create_project_load(std::ptr::null()) }.is_null());
assert!(unsafe { oakengine_task_create_project_load_otio(std::ptr::null()) }.is_null());
assert!(unsafe {
oakengine_task_create_project_save(std::ptr::null_mut(), 0, std::ptr::null(), std::ptr::null())
}
.is_null());
assert!(unsafe { oakengine_task_create_project_save_otio(std::ptr::null_mut()) }.is_null());
assert!(unsafe { oakengine_task_create_project_import(std::ptr::null_mut(), std::ptr::null(), 0) }
.is_null());
assert!(unsafe { oakengine_task_create_proxy(std::ptr::null_mut()) }.is_null());
assert!(unsafe { oakengine_task_create_export(std::ptr::null_mut(), std::ptr::null_mut()) }
.is_null());
// ---- CLI dialog (0, not E_INVALID, for NULL) ----------------------------
assert_eq!(
unsafe { oakengine_cli_task_dialog_run(std::ptr::null_mut(), std::ptr::null_mut()) },
0
);
// The manager handle is lazily created and never NULL (documented).
assert!(!oakengine_task_manager_handle().is_null());
// Finished tasks can linger in the process-wide manager queue (it has no
// delete-finished export and other tests in this binary add to it), so
// only assert that NULL-handle manager operations change nothing.
let count = oakengine_task_manager_count();
assert_eq!(unsafe { oakengine_task_manager_add(std::ptr::null_mut()) }, -1);
assert_eq!(unsafe { oakengine_task_manager_cancel(std::ptr::null_mut()) }, -1);
assert_eq!(oakengine_task_manager_count(), count);
}
/// Empty handles (`ctx == NULL` boxes) are rejected exactly like NULL:
/// -1 / NULL from every accessor and creator, and `oakengine_task_free`
/// reports E_INVALID without consuming the box.
#[test]
fn empty_handles_are_rejected() {
let _g = serial();
common::force_link();
let mut buf = [0 as c_char; 256];
// ---- task accessors on an empty-handle box ------------------------------
let t = empty_task_box();
assert_eq!(unsafe { oakengine_task_title(t, buf.as_mut_ptr(), 256) }, -1);
assert_eq!(unsafe { oakengine_task_error(t, buf.as_mut_ptr(), 256) }, -1);
assert_eq!(unsafe { oakengine_task_start_time(t) }, -1);
assert_eq!(unsafe { oakengine_task_is_cancelled(t) }, -1);
assert_eq!(unsafe { oakengine_task_cancel(t) }, -1);
assert_eq!(unsafe { oakengine_task_start_sync(t) }, -1);
assert_eq!(unsafe { oakengine_task_manager_add(t) }, -1);
assert_eq!(unsafe { oakengine_task_manager_cancel(t) }, -1);
assert_eq!(unsafe { oakengine_task_import_file_count(t) }, -1);
assert!(unsafe { oakengine_task_import_get_command(t) }.is_null());
assert_eq!(unsafe { oakengine_task_import_footage_count(t) }, -1);
assert!(unsafe { oakengine_task_import_footage_at(t, 0) }.is_null());
assert_eq!(unsafe { oakengine_task_import_invalid_files_count(t) }, -1);
assert_eq!(
unsafe { oakengine_task_import_invalid_file_at(t, 0, buf.as_mut_ptr(), 256) },
-1
);
assert!(unsafe { oakengine_task_save_get_project(t) }.is_null());
// free refuses the empty handle with E_INVALID and leaves the box
// allocated (the caller still owns it).
assert_eq!(unsafe { oakengine_task_free(t) }, -1);
unsafe { reclaim_empty_task_box(t) };
// ---- creators with empty project / node / sequence handles --------------
let empty_project = Box::into_raw(Box::new(OakEngineProject { handle: CHandle::null() }));
assert!(unsafe {
oakengine_task_create_project_save(empty_project, 0, std::ptr::null(), std::ptr::null())
}
.is_null());
assert!(unsafe { oakengine_task_create_project_save_otio(empty_project) }.is_null());
unsafe { drop(Box::from_raw(empty_project)) };
let empty_node = Box::into_raw(Box::new(OakEngineNode { handle: CHandle::null() }));
assert!(unsafe {
oakengine_task_create_project_import(empty_node, std::ptr::null(), 0)
}
.is_null());
assert!(unsafe { oakengine_task_create_proxy(empty_node) }.is_null());
unsafe { drop(Box::from_raw(empty_node)) };
let empty_seq = Box::into_raw(Box::new(OakEngineSequence { handle: CHandle::null() }));
let params = oakengine_encoding_params_create();
assert!(!params.is_null());
// NULL result: the params handle is NOT consumed, so we own it still.
assert!(unsafe { oakengine_task_create_export(empty_seq, params) }.is_null());
unsafe { oakengine::codec::oakengine_encoding_params_destroy(params) };
unsafe { drop(Box::from_raw(empty_seq)) };
}
// ---------------------------------------------------------------------------
// Task lifecycle: load tasks (no project, no manager state)
// ---------------------------------------------------------------------------
/// A project-load task with a bad filename: created, has the "Loading"
/// title, fails synchronously (0), reports a non-empty error, exposes the
/// facade-side start stamp after starting, and round-trips the cancel flag.
#[test]
fn load_task_lifecycle() {
let _g = serial();
common::force_link();
let task = unsafe { oakengine_task_create_project_load(c"/no/such/oak/project.ove".as_ptr()) };
assert!(!task.is_null());
let mut buf = [0 as c_char; 256];
let len = unsafe { oakengine_task_title(task, buf.as_mut_ptr(), 256) };
assert!(len > 0);
assert!(read_buf(&mut buf).contains("Loading"));
// A task that never ran has no start stamp and is not cancelled.
assert_eq!(unsafe { oakengine_task_start_time(task) }, 0);
assert_eq!(unsafe { oakengine_task_is_cancelled(task) }, 0);
// The sync run fails (file does not exist).
assert_eq!(unsafe { oakengine_task_start_sync(task) }, 0);
assert_ne!(unsafe { oakengine_task_start_time(task) }, 0);
assert_eq!(unsafe { oakengine_task_is_cancelled(task) }, 0);
// The error string is populated by the failed run.
let elen = unsafe { oakengine_task_error(task, buf.as_mut_ptr(), 256) };
assert!(elen > 0);
assert!(!read_buf(&mut buf).is_empty());
// Cancel round-trip through the facade flag (module cancel succeeds).
assert_eq!(unsafe { oakengine_task_cancel(task) }, 0);
assert_eq!(unsafe { oakengine_task_is_cancelled(task) }, 1);
// Re-running after a failed sync run is a legal no-op (fails again).
assert_eq!(unsafe { oakengine_task_start_sync(task) }, 0);
assert_eq!(unsafe { oakengine_task_free(task) }, 0);
}
/// The empty filename is legal input: a task is created with an empty
/// title suffix and fails when run (no such file).
#[test]
fn load_task_empty_filename() {
let _g = serial();
common::force_link();
let task = unsafe { oakengine_task_create_project_load(c"".as_ptr()) };
assert!(!task.is_null());
let mut buf = [0 as c_char; 256];
let len = unsafe { oakengine_task_title(task, buf.as_mut_ptr(), 256) };
assert_eq!(len, 10);
assert_eq!(read_buf(&mut buf), "Loading ''");
assert_eq!(unsafe { oakengine_task_start_sync(task) }, 0);
assert_eq!(unsafe { oakengine_task_free(task) }, 0);
}
/// The OTIO load task: created for a valid filename, fails synchronously
/// (the document does not exist) and reports the load error.
#[test]
fn load_otio_task_lifecycle() {
let _g = serial();
common::force_link();
let task = unsafe { oakengine_task_create_project_load_otio(c"/no/such/oak/project.otio".as_ptr()) };
assert!(!task.is_null());
let mut buf = [0 as c_char; 256];
let len = unsafe { oakengine_task_title(task, buf.as_mut_ptr(), 256) };
assert!(len > 0);
assert!(read_buf(&mut buf).contains("Loading"));
// Missing document -> failed run.
assert_eq!(unsafe { oakengine_task_start_sync(task) }, 0);
let elen = unsafe { oakengine_task_error(task, buf.as_mut_ptr(), 256) };
assert!(elen > 0);
assert!(!read_buf(&mut buf).is_empty());
// An unknown extension is also a clean failure (format dispatch error).
let task2 = unsafe { oakengine_task_create_project_load_otio(c"/no/such/oak/project.xyz".as_ptr()) };
assert!(!task2.is_null());
assert_eq!(unsafe { oakengine_task_start_sync(task2) }, 0);
assert_eq!(unsafe { oakengine_task_free(task2) }, 0);
assert_eq!(unsafe { oakengine_task_free(task) }, 0);
}
/// Two-stage string getters across the buffer-size matrix: a too-small or
/// NULL buffer still reports the length, an exact/large buffer also gets
/// the NUL-terminated content.
#[test]
fn string_getters_buffer_matrix() {
let _g = serial();
common::force_link();
let task = unsafe { oakengine_task_create_project_load(c"/no/such/oak/it_task_buf.ove".as_ptr()) };
assert!(!task.is_null());
// Title length (facade convention: excludes the NUL).
let expected = unsafe { oakengine_task_title(task, std::ptr::null_mut(), 0) };
let expected_usize = expected as usize;
assert!(expected > 0);
// NULL buffer with a positive size still reports the length.
assert_eq!(
unsafe { oakengine_task_title(task, std::ptr::null_mut(), 256) },
expected
);
// A negative size is a documented no-op size query, not an error.
assert_eq!(
unsafe { oakengine_task_title(task, std::ptr::null_mut(), -5) },
expected
);
// Too-small buffer: length reported, nothing written (module writes only
// when the buffer fits the string plus its NUL).
let mut small = [0 as c_char; 4];
assert_eq!(unsafe { oakengine_task_title(task, small.as_mut_ptr(), 4) }, expected);
assert_eq!(small[0], 0);
// Exact string length but no NUL room: still nothing written.
let mut exact_no_nul = vec![0 as c_char; expected_usize];
assert_eq!(
unsafe { oakengine_task_title(task, exact_no_nul.as_mut_ptr(), expected) },
expected
);
assert_eq!(exact_no_nul[0], 0);
// Exact length + NUL room: content is written.
let mut exact = vec![0 as c_char; expected_usize + 1];
assert_eq!(
unsafe { oakengine_task_title(task, exact.as_mut_ptr(), expected + 1) },
expected
);
assert_eq!(read_buf(&exact), format!("Loading '/no/such/oak/it_task_buf.ove'"));
assert_eq!(exact[expected_usize], 0);
// Large buffer: same content.
let mut big = [0 as c_char; 512];
assert_eq!(unsafe { oakengine_task_title(task, big.as_mut_ptr(), 512) }, expected);
assert_eq!(read_buf(&big), format!("Loading '/no/such/oak/it_task_buf.ove'"));
// A task that never ran reports "Unknown error" (module fallback), the
// same two-stage contract.
let mut err_buf = [0 as c_char; 64];
assert_eq!(unsafe { oakengine_task_error(task, err_buf.as_mut_ptr(), 64) }, 13);
assert_eq!(read_buf(&err_buf), "Unknown error");
assert_eq!(unsafe { oakengine_task_error(task, std::ptr::null_mut(), 0) }, 13);
assert_eq!(unsafe { oakengine_task_free(task) }, 0);
}
/// Wrong-family handles: the import/save result accessors on a plain load
/// task return the module's clean negative codes / NULL — plugins may pass
/// any task handle.
#[test]
fn import_save_accessors_on_wrong_family_task() {
let _g = serial();
common::force_link();
let task = unsafe { oakengine_task_create_project_load(c"/no/such/oak/project.ove".as_ptr()) };
assert!(!task.is_null());
assert_eq!(unsafe { oakengine_task_import_file_count(task) }, OAKTASK_E_INVALID);
assert_eq!(unsafe { oakengine_task_import_footage_count(task) }, OAKTASK_E_INVALID);
assert_eq!(unsafe { oakengine_task_import_invalid_files_count(task) }, OAKTASK_E_INVALID);
assert!(unsafe { oakengine_task_import_get_command(task) }.is_null());
assert!(unsafe { oakengine_task_import_footage_at(task, 0) }.is_null());
let mut buf = [0 as c_char; 256];
assert_eq!(
unsafe { oakengine_task_import_invalid_file_at(task, 0, buf.as_mut_ptr(), 256) },
OAKTASK_E_INVALID
);
assert!(unsafe { oakengine_task_save_get_project(task) }.is_null());
assert_eq!(unsafe { oakengine_task_free(task) }, 0);
}
/// The CLI modal dialog is a sync-run wrapper: 0 for a failing task, 0 for
/// NULL, and it marks the task started.
#[test]
fn cli_dialog_runs_task_sync() {
let _g = serial();
common::force_link();
let task = unsafe { oakengine_task_create_project_load(c"/no/such/oak/project.ove".as_ptr()) };
assert!(!task.is_null());
assert_eq!(
unsafe { oakengine_cli_task_dialog_run(task, std::ptr::null_mut()) },
0
);
assert_ne!(unsafe { oakengine_task_start_time(task) }, 0);
assert_eq!(unsafe { oakengine_task_free(task) }, 0);
}
// ---------------------------------------------------------------------------
// Free contracts and the module alive counter
// ---------------------------------------------------------------------------
/// `oakengine_task_free`: NULL and empty handles are clean E_INVALID
/// no-ops, a real task frees cleanly, and the module's alive counter
/// returns to baseline after every create/free round trip.
#[test]
fn free_contracts_and_alive_count() {
let _g = serial();
common::force_link();
// NULL and empty-handle free are safe error no-ops.
assert_eq!(unsafe { oakengine_task_free(std::ptr::null_mut()) }, -1);
let t = empty_task_box();
assert_eq!(unsafe { oakengine_task_free(t) }, -1);
unsafe { reclaim_empty_task_box(t) };
// A real create/free round trip keeps the alive counter at baseline.
// NOTE: an actual double-free of the same facade box is out of contract
// at the C ABI level (the box is destroyed on the first free, so a
// second free is use-after-free by design); the safe double-free surface
// is NULL/empty, covered above.
let baseline = alive_count();
let task = unsafe { oakengine_task_create_project_load(c"/no/such/oak/project.ove".as_ptr()) };
assert_eq!(alive_count(), baseline + 1);
assert_eq!(unsafe { oakengine_task_free(task) }, 0);
assert_eq!(alive_count(), baseline);
// A task that ran and was cancelled still accounts back to baseline.
let task2 = unsafe { oakengine_task_create_project_load(c"/no/such/oak/project.ove".as_ptr()) };
assert_eq!(alive_count(), baseline + 1);
assert_eq!(unsafe { oakengine_task_start_sync(task2) }, 0);
assert_eq!(unsafe { oakengine_task_cancel(task2) }, 0);
assert_eq!(unsafe { oakengine_task_free(task2) }, 0);
assert_eq!(alive_count(), baseline);
}
// ---------------------------------------------------------------------------
// Project-backed tasks (serialized: `oakengine_project_new` clears the
// process-wide undo stack; the task manager is also process-wide)
// ---------------------------------------------------------------------------
/// Save tasks across the compression matrix (0, 1, and garbage flag
/// values), the no-filename failure path, `save_get_project`, save-otio
/// creation, and the alive-count accounting of a save task's borrowed
/// project.
#[test]
fn save_task_matrix() {
let _g = serial();
common::force_link();
let project = oakengine_project_create();
assert!(!project.is_null());
assert_eq!(unsafe { oakengine_project_new(project) }, 0);
let save_path = std::env::temp_dir().join("oakengine_it_task_save.ovexml");
let save_c = std::ffi::CString::new(save_path.to_str().unwrap()).unwrap();
let _ = std::fs::remove_file(&save_path);
// ---- compression 0 and 1 (and garbage flag values -> treated as true)
let baseline = alive_count();
for compression in [0, 1, 2, -1] {
let task = unsafe {
oakengine_task_create_project_save(project, compression, save_c.as_ptr(), std::ptr::null())
};
assert!(!task.is_null(), "save with use_compression={compression}");
let mut buf = [0 as c_char; 256];
let len = unsafe { oakengine_task_title(task, buf.as_mut_ptr(), 256) };
assert!(len > 0);
assert!(read_buf(&mut buf).contains("Saving"));
// Compression is a boolean in the module; every non-zero value is
// "compressed", and the run still succeeds.
assert_eq!(unsafe { oakengine_task_start_sync(task) }, 1);
assert!(save_path.exists(), "compression={compression} must write the file");
assert_ne!(unsafe { oakengine_task_start_time(task) }, 0);
unsafe { oakengine_task_free(task) };
}
assert_eq!(alive_count(), baseline);
let _ = std::fs::remove_file(&save_path);
// ---- save_get_project: a borrowed project handle per call ----------------
let task = unsafe {
oakengine_task_create_project_save(project, 0, save_c.as_ptr(), std::ptr::null())
};
assert!(!task.is_null());
let saved = unsafe { oakengine_task_save_get_project(task) };
assert!(!saved.is_null());
unsafe { oakengine_project_free(saved) };
// Every call returns a fresh borrowed handle.
let saved2 = unsafe { oakengine_task_save_get_project(task) };
assert!(!saved2.is_null());
unsafe { oakengine_project_free(saved2) };
unsafe { oakengine_task_free(task) };
// ---- no filename: save with override NULL fails cleanly ------------------
let task = unsafe { oakengine_task_create_project_save(project, 0, std::ptr::null(), std::ptr::null()) };
assert!(!task.is_null());
assert_eq!(unsafe { oakengine_task_start_sync(task) }, 0);
let mut buf = [0 as c_char; 256];
let elen = unsafe { oakengine_task_error(task, buf.as_mut_ptr(), 256) };
assert!(elen > 0);
assert!(read_buf(&mut buf).contains("filename"));
unsafe { oakengine_task_free(task) };
// ---- save-otio: NULL without a project filename, real task with one ------
assert!(unsafe { oakengine_task_create_project_save_otio(project) }.is_null());
assert_eq!(
unsafe { oakengine_project_set_filename(project, c"/tmp/oakengine_it_task_otio.otio".as_ptr()) },
0
);
let otio_task = unsafe { oakengine_task_create_project_save_otio(project) };
assert!(!otio_task.is_null());
let mut buf = [0 as c_char; 256];
let len = unsafe { oakengine_task_title(otio_task, buf.as_mut_ptr(), 256) };
assert!(len > 0);
assert!(read_buf(&mut buf).contains("Saving"));
unsafe { oakengine_task_free(otio_task) };
unsafe { oakengine_project_free(project) };
let _ = std::fs::remove_file(&save_path);
}
/// Import task creation against a real project and a real (non-decodable)
/// file, plus the zero-file run that does not touch the (dangling —
/// see [`import_run_crashes_engine_bug`]) borrowed project handle.
///
/// A single-file import task is created, reports the documented pre-run
/// accessor states (empty footage / invalid lists, out-of-range codes,
/// no command yet), and frees cleanly. The zero-file task runs end to end
/// (nothing to import) and hands out its (empty) undo command.
#[test]
fn import_flow_with_real_file() {
let _g = serial();
common::force_link();
let project = oakengine_project_create();
assert!(!project.is_null());
assert_eq!(unsafe { oakengine_project_new(project) }, 0);
let root = unsafe { oakengine_project_root(project) };
assert!(!root.is_null());
// A real file that cannot be decoded in the test environment (the
// host-provided `fb_*` symbols are the common stubs; footage probing
// never succeeds there, so a run would mark the file invalid).
let media = std::env::temp_dir().join("oakengine_it_task_import_batch.tmp");
std::fs::write(&media, b"not media").unwrap();
let media_c = std::ffi::CString::new(media.to_str().unwrap()).unwrap();
// ---- single-file import: creation + pre-run accessors --------------------
let urls = [media_c.as_ptr()];
let task = unsafe { oakengine_task_create_project_import(root, urls.as_ptr(), 1) };
assert!(!task.is_null());
// Before the run the imported-footage list is empty, so both count
// exports report 0 (the facade maps `import_file_count` to the module's
// footage count; documented deviation from the construction-time count).
assert_eq!(unsafe { oakengine_task_import_file_count(task) }, 0);
assert_eq!(unsafe { oakengine_task_import_footage_count(task) }, 0);
assert_eq!(unsafe { oakengine_task_import_invalid_files_count(task) }, 0);
let mut buf = [0 as c_char; 256];
let len = unsafe { oakengine_task_title(task, buf.as_mut_ptr(), 256) };
assert_eq!(len, 19);
assert_eq!(read_buf(&mut buf), "Importing 1 file(s)");
// Pre-run: nothing imported, no invalid entries, no command yet; every
// index accessor reports the documented empty/out-of-range state.
assert!(unsafe { oakengine_task_import_footage_at(task, 0) }.is_null());
assert!(unsafe { oakengine_task_import_footage_at(task, -1) }.is_null());
assert!(unsafe { oakengine_task_import_footage_at(task, 7) }.is_null());
assert_eq!(
unsafe { oakengine_task_import_invalid_file_at(task, 0, buf.as_mut_ptr(), 256) },
OAKTASK_E_NOT_FOUND
);
assert_eq!(
unsafe { oakengine_task_import_invalid_file_at(task, -1, buf.as_mut_ptr(), 256) },
OAKTASK_E_NOT_FOUND
);
assert!(unsafe { oakengine_task_import_get_command(task) }.is_null());
assert_eq!(unsafe { oakengine_task_free(task) }, 0);
// ---- zero-file import: runs without touching the project handle -----------
let zero = unsafe { oakengine_task_create_project_import(root, std::ptr::null(), 0) };
assert!(!zero.is_null());
assert_eq!(unsafe { oakengine_task_import_file_count(zero) }, 0);
assert_eq!(unsafe { oakengine_task_import_footage_count(zero) }, 0);
assert_eq!(unsafe { oakengine_task_import_invalid_files_count(zero) }, 0);
// "Nothing to import" still counts as a successful run: the run creates
// the (empty) multi undo command and returns OK.
assert_eq!(unsafe { oakengine_task_start_sync(zero) }, 1);
assert_eq!(unsafe { oakengine_task_import_footage_count(zero) }, 0);
assert_eq!(unsafe { oakengine_task_import_invalid_files_count(zero) }, 0);
let cmd = unsafe { oakengine_task_import_get_command(zero) };
assert!(!cmd.is_null());
unsafe { oakengine_undo_command_free(cmd) };
assert!(unsafe { oakengine_task_import_get_command(zero) }.is_null());
assert_eq!(unsafe { oakengine_task_free(zero) }, 0);
// ---- illegal url arrays ---------------------------------------------------
// Negative count -> NULL.
assert!(unsafe { oakengine_task_create_project_import(root, std::ptr::null(), -1) }.is_null());
// Non-NULL urls with a count but a NULL entry inside -> NULL.
let bad_urls = [std::ptr::null()];
assert!(unsafe { oakengine_task_create_project_import(root, bad_urls.as_ptr(), 1) }.is_null());
// NULL urls with a positive count -> NULL.
assert!(unsafe { oakengine_task_create_project_import(root, std::ptr::null(), 1) }.is_null());
unsafe { oakengine_node_free(root) };
unsafe { oakengine_project_free(project) };
let _ = std::fs::remove_file(&media);
}
/// **Real engine bug — minimal reproduction.**
///
/// Running a single-file import task crashes with SIGSEGV. The facade's
/// `oakengine_task_create_project_import` (`src/task.rs`) hands the
/// borrowed project handle (from `oaknode_node_get_project`) to
/// `oaktask_create_project_import`, which stores it WITHOUT addref, and
/// then immediately calls `oaknode_project_free` on it: the shared
/// `RefBox` refcount goes 1→0 and the box is freed while the task's copy
/// still references it. The first thing the run does is
/// `oaknode_footage_create(task.project, …)` → `project_arc()` reads the
/// freed `RefBox<ProjectArc>` and clones the garbage `Arc` → `atomic_add`
/// on a non-heap address → EXC_BAD_ACCESS.
///
/// Verified under lldb: the project handle's ctx (`0x1043cf4d0` in the
/// traced run) had been reused by the allocator and contained a CHandle
/// whose `addref` slot was the address of `oaktask::handle::owned_addref`
/// — the exact address the crashing `atomic_add` targeted.
///
/// The save creator is NOT affected: it addrefs the project
/// (`meta.save_project = Some(ph.addref())`).
#[test]
#[ignore = "ENGINE BUG: import run SIGSEGVs — facade frees the borrowed project handle the task still holds (src/task.rs oakengine_task_create_project_import)"]
fn import_run_crashes_engine_bug() {
let _g = serial();
common::force_link();
let project = oakengine_project_create();
assert!(!project.is_null());
assert_eq!(unsafe { oakengine_project_new(project) }, 0);
let root = unsafe { oakengine_project_root(project) };
assert!(!root.is_null());
let media = std::env::temp_dir().join("oakengine_it_task_import_batch.tmp");
std::fs::write(&media, b"not media").unwrap();
let media_c = std::ffi::CString::new(media.to_str().unwrap()).unwrap();
// Creation succeeds; the run below is expected to succeed (1) and record
// the undecodable file as invalid — instead it reads the dangling
// project handle and crashes the process.
let urls = [media_c.as_ptr()];
let task = unsafe { oakengine_task_create_project_import(root, urls.as_ptr(), 1) };
assert!(!task.is_null());
assert_eq!(unsafe { oakengine_task_start_sync(task) }, 1);
assert_eq!(unsafe { oakengine_task_import_invalid_files_count(task) }, 1);
assert_eq!(unsafe { oakengine_task_free(task) }, 0);
unsafe { oakengine_node_free(root) };
unsafe { oakengine_project_free(project) };
let _ = std::fs::remove_file(&media);
}
/// Export task creation against a real sequence and encoding params: the
/// task is created (the color manager is derived from the sequence's
/// project), titled, and freed; the params handle's ownership transfers to
/// the task.
#[test]
fn export_task_creation() {
let _g = serial();
common::force_link();
let project = oakengine_project_create();
assert!(!project.is_null());
assert_eq!(unsafe { oakengine_project_new(project) }, 0);
let seq = unsafe { oakengine_sequence_new(project, c"Export Seq".as_ptr()) };
assert!(!seq.is_null());
// Minimal legal params: a fresh handle with a filename. The export task
// takes ownership of the params box (destroyed at task free).
let params = oakengine_encoding_params_create();
assert!(!params.is_null());
assert_eq!(
unsafe { oakengine_encoding_params_set_filename(params, c"/tmp/oakengine_it_task_export.mov".as_ptr()) },
0
);
let task = unsafe { oakengine_task_create_export(seq, params) };
assert!(!task.is_null(), "export creation must succeed without GPU (creation only)");
let mut buf = [0 as c_char; 256];
let len = unsafe { oakengine_task_title(task, buf.as_mut_ptr(), 256) };
assert!(len > 0);
assert!(read_buf(&mut buf).contains("Exporting"));
// NULL / empty inputs: clean NULL, and the params handle stays owned by
// the caller on the rejected path.
assert!(unsafe { oakengine_task_create_export(std::ptr::null_mut(), params) }.is_null());
let params2 = oakengine_encoding_params_create();
assert!(unsafe { oakengine_task_create_export(seq, std::ptr::null_mut()) }.is_null());
let empty_seq = Box::into_raw(Box::new(OakEngineSequence { handle: CHandle::null() }));
assert!(unsafe { oakengine_task_create_export(empty_seq, params2) }.is_null());
unsafe { oakengine::codec::oakengine_encoding_params_destroy(params2) };
unsafe { drop(Box::from_raw(empty_seq)) };
assert_eq!(unsafe { oakengine_task_free(task) }, 0);
// Release the sequence's borrowed facade box (release only frees the box).
unsafe { free_box::<OakEngineSequence>(seq) };
unsafe { oakengine_project_free(project) };
}
/// Running an export requires a real GPU/OpenGL render and a real encoder;
/// the test environment's host stubs cannot decode or encode media. The
/// creation path above covers the legal input surface; the run itself is
/// documented as environment-gated.
#[test]
#[ignore = "export run needs GPU/OpenGL rendering and a real ffmpeg encoder; host stubs cannot encode"]
fn export_task_run_ignored_environment_gated() {
let _g = serial();
common::force_link();
let project = oakengine_project_create();
assert!(!project.is_null());
assert_eq!(unsafe { oakengine_project_new(project) }, 0);
let seq = unsafe { oakengine_sequence_new(project, c"Export Run".as_ptr()) };
assert!(!seq.is_null());
let params = oakengine_encoding_params_create();
assert_eq!(
unsafe { oakengine_encoding_params_set_filename(params, c"/tmp/oakengine_it_task_export_run.mov".as_ptr()) },
0
);
let task = unsafe { oakengine_task_create_export(seq, params) };
assert!(!task.is_null());
assert_eq!(unsafe { oakengine_task_start_sync(task) }, 1);
unsafe { oakengine_task_free(task) };
unsafe { free_box::<OakEngineSequence>(seq) };
unsafe { oakengine_project_free(project) };
}
/// The proxy creator is a documented stub (the oaktask module has no
/// proxy-task factory on its C ABI): NULL for every input, including a
/// valid node.
#[test]
fn proxy_stub_always_returns_null() {
let _g = serial();
common::force_link();
let project = oakengine_project_create();
assert!(!project.is_null());
assert_eq!(unsafe { oakengine_project_new(project) }, 0);
let root = unsafe { oakengine_project_root(project) };
assert!(!root.is_null());
assert!(unsafe { oakengine_task_create_proxy(root) }.is_null());
unsafe { oakengine_node_free(root) };
unsafe { oakengine_project_free(project) };
}
// ---------------------------------------------------------------------------
// Global task manager (serialized: the manager is process-wide)
// ---------------------------------------------------------------------------
/// The global manager lifecycle: lazy creation, empty queue, task
/// hand-over (`manager_add`), borrowed first-task handle, double-add
/// rejection, cancel, and the alive accounting of the borrowed box.
#[test]
fn task_manager_lifecycle() {
let _g = serial();
common::force_link();
// The facade initializes the manager on first use; the handle is stable.
let handle = oakengine_task_manager_handle();
assert!(!handle.is_null());
assert_eq!(oakengine_task_manager_handle(), handle);
assert_eq!(oakengine_task_manager_count(), 0);
// An empty queue has no first task.
assert!(oakengine_task_manager_first().is_null());
let baseline = alive_count();
let task = unsafe { oakengine_task_create_project_load(c"/no/such/oak/project.ove".as_ptr()) };
assert_eq!(alive_count(), baseline + 1);
// Handing the task to the manager transfers ownership; the handle box
// stays alive until freed.
assert_eq!(unsafe { oakengine_task_manager_add(task) }, 0);
assert_eq!(oakengine_task_manager_count(), 1);
// A second add of the same task is rejected with the module's E_STATE.
assert_eq!(unsafe { oakengine_task_manager_add(task) }, OAKTASK_E_STATE);
// The first task is a borrowed handle: count goes up by one, and freeing
// the box returns it to baseline without deleting the manager's task.
let first = oakengine_task_manager_first();
assert!(!first.is_null());
assert_eq!(alive_count(), baseline + 2);
assert_eq!(unsafe { oakengine_task_free(first) }, 0);
assert_eq!(alive_count(), baseline + 1);
// Cancelling through the manager succeeds (the load task fails fast on
// the missing file; cancel of a finished task is a documented no-op).
assert_eq!(unsafe { oakengine_task_manager_cancel(task) }, 0);
// Adding the manager's own borrowed handle is rejected with E_STATE
// (the task is already running on the manager).
let first2 = oakengine_task_manager_first();
assert!(!first2.is_null());
assert_eq!(unsafe { oakengine_task_manager_add(first2) }, OAKTASK_E_STATE);
assert_eq!(unsafe { oakengine_task_free(first2) }, 0);
// NULL / empty inputs on the manager family.
assert_eq!(unsafe { oakengine_task_manager_add(std::ptr::null_mut()) }, -1);
assert_eq!(unsafe { oakengine_task_manager_cancel(std::ptr::null_mut()) }, -1);
let empty = empty_task_box();
assert_eq!(unsafe { oakengine_task_manager_add(empty) }, -1);
assert_eq!(unsafe { oakengine_task_manager_cancel(empty) }, -1);
unsafe { reclaim_empty_task_box(empty) };
// Releasing the (now borrowed) facade box is safe: the manager owns the
// task object and deletes it on cleanup; the alive counter returns to
// baseline.
assert_eq!(unsafe { oakengine_task_free(task) }, 0);
assert_eq!(alive_count(), baseline);
// Finished tasks stay in the queue until delete_finished (the facade
// exposes no delete export), so the count is still 1.
assert_eq!(oakengine_task_manager_count(), 1);
}
File diff suppressed because it is too large Load Diff
+848
View File
@@ -0,0 +1,848 @@
// 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 only `#[ignore]`d tests are the real-bug repros at
//! the bottom ([`null_name_push_repro`], [`null_name_group_repro`],
//! [`group_abort_undoes_children_repro`]) — a NULL/empty label to
//! `oakengine_undo_push` / the group-end path crashes the process, and
//! `oakengine_undo_group_abort` does not undo its children (see the
//! report).
#[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).
// ---------------------------------------------------------------------------
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() {
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 (the child's undo does NOT
// run — see the NOTE below and `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);
// NOTE: the abort does NOT run the child's undo — `undo_now` is a
// no-op on the never-done multi command (see
// `group_abort_undoes_children_repro`, ignored, for the full repro),
// so c3's side effect is not rolled back. Only the side-effect-free
// assertions follow.
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 repros (ignored: they crash the process; see the report)
// ---------------------------------------------------------------------------
/// REAL BUG REPRO — `oakengine_undo_push(cmd, NULL)` segfaults the process.
///
/// The facade's `push_or_run` (src/undo.rs) turns a NULL name into
/// `String::new()` and passes 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. `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, and the crash is NOT caught by the
/// catch_unwind guards (it is a hard SIGSEGV, not a panic).
///
/// Verified: `cargo test -p oakengine --test it_undo null_name_push_repro -- --ignored`
/// dies with signal 11 inside `oakundo::ffi::read_name`.
#[test]
#[ignore = "crashes the process: src/undo.rs push_or_run passes String::new().as_ptr() (0x1) to oakundo's read_name, which strlen's it -> SIGSEGV; needs the engine fix"]
fn null_name_push_repro() {
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);
assert_eq!(unsafe { oakengine_undo_clear() }, 0);
}
/// REAL BUG REPRO — `oakengine_undo_group_begin(NULL)` +
/// `oakengine_undo_group_end()` segfaults the process.
///
/// Same root cause as [`null_name_push_repro`]: `oakengine_undo_group_end`
/// (src/undo.rs) stores the group name as a Rust `String` and passes its
/// `as_ptr()` to `oakundo_undostack_push_pre_executed`; a NULL (or empty)
/// name is a dangling 0x1 pointer there, and the module's `read_name`
/// crashes on it. The group-abort path never crosses the name and is safe.
#[test]
#[ignore = "crashes the process: src/undo.rs group_end passes String::new().as_ptr() (0x1) to oakundo's read_name, which strlen's it -> SIGSEGV; needs the engine fix"]
fn null_name_group_repro() {
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);
assert_eq!(unsafe { oakengine_undo_clear() }, 0);
}
/// Counter for the abort repro (own set: this test runs only under
/// `--ignored`, but keep it isolated anyway).
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);
}
/// REAL BUG REPRO — `oakengine_undo_group_abort()` does not undo the
/// group's executed children.
///
/// The facade (src/undo.rs) closes 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 fires, so the group's side effects are NOT rolled
/// back — contradicting the documented "undo all executed children and
/// discard the group". (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]
#[ignore = "fails: group_abort leaves children done (undo_now is a no-op on the never-done multi); needs the engine fix"]
fn group_abort_undoes_children_repro() {
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);
}