Files
oak-editor/crates/oaktask/tests/ffi_contract_test.rs
T
Mike-Solar 18ff60f147 feat(engine): clip move, clip effect_input, mandatory static FFmpeg
- oakengine_sequence_move_clip implemented for real (oaktimeline
  TrackMoveBlockCommand; fixes the graph-ownership/gap-anchor/ripple
  trim bugs the stub was hiding); same-track via the frozen C ABI,
  cross-track supported by the module command
- oaknode clip blocks now declare a tex_in texture input and set
  effect_input to it, so timeline clips can host effect chains; facade
  test covers effect insert/remove on a real clip
- oakffmpeg-link: FFMPEG_DIR is now mandatory with a clear panic (a
  Homebrew upgrade left the system ffmpeg .pc pointing at a deleted
  dav1d Cellar path, breaking links); reads a git-ignored workspace
  .env for IDEs that cannot inject env vars (RustRover); links the C++
  stdlib for C++ codec libs (svt-av1)
- oakengine re-exports oaknode so tests share one crate instance;
  it_node uses the direct instance's value type where it calls the
  module FFI (the --workspace dev-dependency feature split builds
  oaknode twice)
2026-08-11 23:04:48 +08:00

336 lines
12 KiB
Rust

// Oak Video Editor - Non-Linear Video Editor
// Copyright (C) 2026 Oak Team
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
//! C ABI contract tests (ffi.rs). One normal + one error path per
//! export family; the exhaustive matrix is driven from the existing
//! C++ gtest suite (`src/task/tests`, unchanged) running against this
//! crate — these tests only pin Rust-side specifics.
mod common;
use common::*;
use std::ffi::{c_char, c_int, c_void};
use std::sync::atomic::{AtomicI32, Ordering};
use oaktask::error::{OAKTASK_E_INVALID, OAKTASK_E_STATE, OAKTASK_OK};
use oaktask::ffi::manager::{
oaktask_manager_at, oaktask_manager_count, oaktask_manager_delete_finished,
oaktask_manager_init, oaktask_manager_shutdown,
};
use oaktask::ffi::project::oaktask_create_project_save;
use oaktask::ffi::task::{
oaktask_debug_alive_count, oaktask_task_cancel, oaktask_task_error, oaktask_task_free,
oaktask_task_is_finished, oaktask_task_start, oaktask_task_start_sync, oaktask_task_subscribe,
oaktask_task_succeeded, oaktask_task_title, oaktask_task_wait,
};
use oaktask::handle::CHandle;
fn free_task(t: &mut CHandle) {
unsafe {
oaktask_task_free(t);
}
}
/// Every exported handle-returning function returns ctx==NULL on
/// failure and a valid refcounted handle on success (abi_version
/// stamped).
#[test]
fn handle_contract_all_exports() {
let _guard = MANAGER_LOCK.lock().unwrap();
// create_project_save with a null project -> empty.
let empty = unsafe { oaktask_create_project_save(CHandle::null(), std::ptr::null(), 0) };
assert!(empty.is_null());
// create_project_save with a valid project -> valid handle with the
// stamped ABI version.
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-contract-save.oakproj".to_string();
let task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 1) };
assert!(!task.ctx.is_null());
assert_eq!(task.abi_version, 1);
// Title getter round-trip (two-stage).
let mut buf = [0i8; 512];
let needed = unsafe { oaktask_task_title(task, buf.as_mut_ptr(), buf.len() as c_int) };
assert!(needed > 0);
assert!(needed <= buf.len() as c_int);
// Exact-size query with a null buffer returns the same needed size.
let query = unsafe { oaktask_task_title(task, std::ptr::null_mut(), 0) };
assert_eq!(query, needed);
// The save title embeds the project filename.
assert!(needed > 10);
let mut t = task;
free_task(&mut t);
assert!(t.is_null());
}
/// free(NULL)/free(empty) are no-ops across every free export.
#[test]
fn free_null_noop_all_exports() {
unsafe {
oaktask_task_free(std::ptr::null_mut());
}
let mut empty = CHandle::null();
unsafe {
oaktask_task_free(&mut empty);
}
assert!(empty.ctx.is_null());
}
/// Two-stage string functions: size query, short buffer truncation
/// rule, and exact-fit write — for every string getter
/// (task_title, task_error, import_invalid_at).
#[test]
fn two_stage_string_contract() {
let _guard = MANAGER_LOCK.lock().unwrap();
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-two-stage.oakproj".to_string();
let task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
assert!(!task.ctx.is_null());
// Null buffer -> needed size only.
let needed = unsafe { oaktask_task_title(task, std::ptr::null_mut(), 0) };
assert!(needed > 0);
// Too-small buffer -> needed size, no write.
let mut small = [0i8; 4];
let r = unsafe { oaktask_task_title(task, small.as_mut_ptr(), small.len() as c_int) };
assert_eq!(r, needed);
// C++ only writes when the buffer fits; a short buffer stays untouched.
assert_ne!(small[0], b'S' as i8);
// Exact-fit write.
let mut buf = vec![0i8; needed as usize];
let r = unsafe { oaktask_task_title(task, buf.as_mut_ptr(), needed) };
assert_eq!(r, needed);
assert_eq!(buf[0], b'S' as i8);
assert_eq!(buf[needed as usize - 1], 0);
// Error getter on a fresh task reports the default.
let err_needed = unsafe { oaktask_task_error(task, std::ptr::null_mut(), 0) };
assert!(err_needed > 0);
// Error-path: empty handle -> E_INVALID.
assert_eq!(
unsafe { oaktask_task_title(CHandle::null(), std::ptr::null_mut(), 0) },
OAKTASK_E_INVALID
);
assert_eq!(
unsafe { oaktask_task_error(CHandle::null(), std::ptr::null_mut(), 0) },
OAKTASK_E_INVALID
);
let mut t = task;
free_task(&mut t);
}
/// Task manager singleton: init → create/start → wait → delete_finished
/// round-trips; alive-count accounting returns to baseline.
#[test]
fn identity_registry_roundtrip() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() }; // ensure clean slate
assert_eq!(unsafe { oaktask_manager_init() }, OAKTASK_OK);
assert_eq!(unsafe { oaktask_manager_count() }, 0);
// Async start of a save task; the worker writes the file.
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-identity-roundtrip.oakproj".to_string();
let _ = std::fs::remove_file("/tmp/oak-identity-roundtrip.oakproj");
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
assert_eq!(unsafe { oaktask_task_start(task) }, OAKTASK_OK);
assert_eq!(unsafe { oaktask_task_start(task) }, OAKTASK_E_STATE); // already running
assert_eq!(unsafe { oaktask_task_wait(task) }, OAKTASK_OK);
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
assert_eq!(unsafe { oaktask_task_succeeded(task) }, 1);
assert!(std::path::Path::new("/tmp/oak-identity-roundtrip.oakproj").exists());
unsafe { oaktask_manager_delete_finished() };
assert_eq!(unsafe { oaktask_manager_count() }, 0);
free_task(&mut task);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
unsafe { oaktask_manager_shutdown() };
}
/// alive count: task create/destroy moves oaktask_debug_alive_count
/// predictably and returns to baseline.
#[test]
fn alive_count_accounting() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() };
let baseline = unsafe { oaktask_debug_alive_count() };
assert_eq!(baseline, 0);
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-alive-count.oakproj".to_string();
let mut t1 = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
let mut t2 = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
assert_eq!(unsafe { oaktask_debug_alive_count() }, baseline + 2);
free_task(&mut t1);
assert_eq!(unsafe { oaktask_debug_alive_count() }, baseline + 1);
free_task(&mut t2);
assert_eq!(unsafe { oaktask_debug_alive_count() }, baseline);
}
/// subscribe + async start: the callback observes STARTED (with a start
/// time), then FINISHED (1.0 on success); the subscription is one-shot.
#[test]
fn subscribe_records_started_and_finished() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() };
unsafe { oaktask_manager_init() };
static STARTED: AtomicI32 = AtomicI32::new(0);
static FINISHED: AtomicI32 = AtomicI32::new(0);
static FINISHED_VALUE: AtomicI32 = AtomicI32::new(-1);
unsafe extern "C" fn recorder(event_id: c_int, value: f64, _ud: *mut c_void) {
match event_id {
0 => {
STARTED.fetch_add(1, Ordering::SeqCst);
assert!(value > 0.0); // start time in ms
}
2 => {
FINISHED.fetch_add(1, Ordering::SeqCst);
FINISHED_VALUE.store(value as i32, Ordering::SeqCst);
}
_ => {}
}
}
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-subscribe.oakproj".to_string();
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
// Null callback -> E_INVALID.
assert_eq!(
unsafe { oaktask_task_subscribe(task, None, std::ptr::null_mut()) },
OAKTASK_E_INVALID as i64
);
// Empty handle -> E_INVALID.
assert_eq!(
unsafe { oaktask_task_subscribe(CHandle::null(), Some(recorder), std::ptr::null_mut()) },
OAKTASK_E_INVALID as i64
);
assert_eq!(
unsafe { oaktask_task_subscribe(task, Some(recorder), std::ptr::null_mut()) },
0
);
assert_eq!(unsafe { oaktask_task_start(task) }, OAKTASK_OK);
assert_eq!(unsafe { oaktask_task_wait(task) }, OAKTASK_OK);
assert_eq!(STARTED.load(Ordering::SeqCst), 1);
assert_eq!(FINISHED.load(Ordering::SeqCst), 1);
assert_eq!(FINISHED_VALUE.load(Ordering::SeqCst), 1);
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
unsafe { oaktask_manager_delete_finished() };
free_task(&mut task);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
unsafe { oaktask_manager_shutdown() };
}
/// start_sync on a task with a failing serializer reports failure (0) with
/// an error message, and the error getter returns it (two-stage).
#[test]
fn start_sync_failure_paths() {
let _guard = MANAGER_LOCK.lock().unwrap();
*PROJECT_FILENAME.lock().unwrap() = "".to_string(); // no filename
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
assert_eq!(unsafe { oaktask_task_succeeded(task) }, 0);
let needed = unsafe { oaktask_task_error(task, std::ptr::null_mut(), 0) };
assert!(needed > 0);
let mut buf = vec![0i8; needed as usize];
unsafe { oaktask_task_error(task, buf.as_mut_ptr(), needed) };
let msg = cstr_read(&buf);
assert!(msg.contains("no filename"), "error was: {msg}");
// Null handle -> E_INVALID.
assert_eq!(
unsafe { oaktask_task_start_sync(CHandle::null()) },
OAKTASK_E_INVALID
);
assert_eq!(
unsafe { oaktask_task_cancel(CHandle::null()) },
OAKTASK_E_INVALID
);
assert_eq!(
unsafe { oaktask_task_wait(CHandle::null()) },
OAKTASK_E_INVALID
);
assert_eq!(
unsafe { oaktask_task_is_finished(CHandle::null()) },
OAKTASK_E_INVALID
);
assert_eq!(
unsafe { oaktask_task_succeeded(CHandle::null()) },
OAKTASK_E_INVALID
);
free_task(&mut task);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
}
/// cancel on a fresh task is a no-op success; is_finished stays 0.
#[test]
fn cancel_is_noop_before_start() {
let _guard = MANAGER_LOCK.lock().unwrap();
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-cancel.oakproj".to_string();
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
assert_eq!(unsafe { oaktask_task_cancel(task) }, OAKTASK_OK);
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 0);
free_task(&mut task);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
}
/// start without an initialized manager -> E_STATE.
#[test]
fn start_without_manager_is_state_error() {
let _guard = MANAGER_LOCK.lock().unwrap();
reset_stubs();
unsafe { oaktask_manager_shutdown() };
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-no-manager.oakproj".to_string();
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
assert_eq!(unsafe { oaktask_task_start(task) }, OAKTASK_E_STATE);
// Manager accessors also report E_STATE / empty without a manager.
assert_eq!(unsafe { oaktask_manager_count() }, OAKTASK_E_STATE);
assert!(unsafe { oaktask_manager_at(0) }.is_null());
unsafe { oaktask_manager_delete_finished() }; // no-op
// start_sync still works without a manager.
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
free_task(&mut task);
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
}
/// cstr_read helper: read a NUL-terminated c_char buffer.
fn cstr_read(buf: &[i8]) -> String {
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
let bytes = unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) };
String::from_utf8_lossy(bytes).into_owned()
}