- 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)
252 lines
8.1 KiB
Rust
252 lines
8.1 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/>.
|
||
|
||
//! 生命周期测试:实例创建/销毁配对、重复扫描、渲染中取消、
|
||
//! alive 泄漏断言。对应 M11 §3.5 验收线。
|
||
//!
|
||
//! 宿主单例无锁:全部用例经 [`common::with_host`] 串行化。
|
||
|
||
mod common;
|
||
|
||
use std::ffi::{c_char, c_int, c_void, CString};
|
||
|
||
use oakplugin::ffi::{
|
||
oakplugin_debug_alive_count, oakplugin_host_init, oakplugin_host_plugin_count,
|
||
oakplugin_host_scan, oakplugin_host_shutdown, oakplugin_instance_cancel,
|
||
oakplugin_instance_create, oakplugin_instance_free, oakplugin_instance_render,
|
||
oakplugin_instance_set_progress_cb,
|
||
};
|
||
use oakplugin::handle::CHandle;
|
||
|
||
const OK: i32 = 0;
|
||
const E_FAILED: i32 = -90003;
|
||
|
||
fn cs(s: &str) -> CString {
|
||
CString::new(s).unwrap()
|
||
}
|
||
|
||
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
|
||
|
||
fn fake_texture(ctx: usize) -> CHandle {
|
||
CHandle {
|
||
ctx: ctx as *mut c_void,
|
||
addref: None,
|
||
release: None,
|
||
abi_version: 1,
|
||
}
|
||
}
|
||
|
||
/// 扫描并创建实例;不可用返回空句柄。
|
||
fn create_instance() -> CHandle {
|
||
if common::test_plugin_scan_dir().is_none() {
|
||
common::skip("最小测试插件未构建");
|
||
return CHandle::null();
|
||
}
|
||
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
|
||
let dirs = [dir.as_ptr()];
|
||
unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) };
|
||
let id = cs(TEST_PLUGIN_ID);
|
||
unsafe { oakplugin_instance_create(id.as_ptr()) }
|
||
}
|
||
|
||
/// createInstance → destroyInstance 严格配对:alive 计数回到基线。
|
||
#[test]
|
||
fn instance_create_destroy_pairing() {
|
||
common::with_host(|| {
|
||
unsafe { oakplugin_host_init() };
|
||
let base = unsafe { oakplugin_debug_alive_count() };
|
||
let mut h = create_instance();
|
||
if h.is_null() {
|
||
unsafe { oakplugin_host_shutdown() };
|
||
return;
|
||
}
|
||
assert_eq!(unsafe { oakplugin_debug_alive_count() }, base + 1);
|
||
unsafe { oakplugin_instance_free(&mut h) };
|
||
assert_eq!(
|
||
unsafe { oakplugin_debug_alive_count() },
|
||
base,
|
||
"destroy 后 alive 必须回基线"
|
||
);
|
||
unsafe { oakplugin_host_shutdown() };
|
||
});
|
||
}
|
||
|
||
/// 重复 scan 同一路径:插件列表不重复、顺序稳定(缓存去重语义,
|
||
/// HS: ofxhPluginCache.cpp)。
|
||
#[test]
|
||
fn rescan_is_idempotent() {
|
||
common::with_host(|| {
|
||
unsafe { oakplugin_host_init() };
|
||
if common::test_plugin_scan_dir().is_none() {
|
||
common::skip("最小测试插件未构建");
|
||
unsafe { oakplugin_host_shutdown() };
|
||
return;
|
||
}
|
||
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
|
||
let dirs = [dir.as_ptr()];
|
||
assert_eq!(unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) }, OK);
|
||
let first = unsafe { oakplugin_host_plugin_count() };
|
||
assert!(first >= 1);
|
||
// 再扫两次:数量不变。
|
||
assert_eq!(unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) }, OK);
|
||
assert_eq!(unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) }, OK);
|
||
assert_eq!(
|
||
unsafe { oakplugin_host_plugin_count() },
|
||
first,
|
||
"重复扫描不得重复加载"
|
||
);
|
||
unsafe { oakplugin_host_shutdown() };
|
||
});
|
||
}
|
||
|
||
/// render 中途取消:进度回调返回非 0 后,插件的 progressUpdate 收到
|
||
/// 取消并中止 render;输出纹理不写半帧(测试插件在取消路径返回
|
||
/// Failed,宿主不落帧)。
|
||
#[cfg(feature = "test-stubs")]
|
||
#[test]
|
||
fn render_cancellation_is_atomic() {
|
||
common::with_host(|| {
|
||
use oakplugin::bridge::render::stub;
|
||
unsafe { oakplugin_host_init() };
|
||
let mut h = create_instance();
|
||
if h.is_null() {
|
||
unsafe { oakplugin_host_shutdown() };
|
||
return;
|
||
}
|
||
unsafe extern "C" fn abort_cb(_p: f64, _u: *mut c_void) -> c_int {
|
||
1
|
||
}
|
||
unsafe { oakplugin_instance_set_progress_cb(h, Some(abort_cb), std::ptr::null_mut()) };
|
||
|
||
// 预置输出帧并填 0xAA 哨兵:取消路径不得写半帧。
|
||
stub::setup_dst(64, 64, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||
let before = stub::dst_pixels();
|
||
assert!(!before.is_empty());
|
||
let dst = fake_texture(0xA1);
|
||
let r = unsafe { oakplugin_instance_render(h, dst, CHandle::null(), 0.0) };
|
||
assert_eq!(r, E_FAILED, "取消应使 render 失败");
|
||
// 全有或全无:失败时不写。
|
||
assert_eq!(stub::dst_pixels(), before, "取消后输出不得被写");
|
||
unsafe { oakplugin_instance_free(&mut h) };
|
||
unsafe { oakplugin_host_shutdown() };
|
||
});
|
||
}
|
||
|
||
/// 压测:同一插件 256 次 create/render/destroy 循环后 alive 回到
|
||
/// 基线(当年内存问题的回归防线)。
|
||
#[cfg(feature = "test-stubs")]
|
||
#[test]
|
||
fn create_render_destroy_loop_no_leak() {
|
||
common::with_host(|| {
|
||
use oakplugin::bridge::render::stub;
|
||
unsafe { oakplugin_host_init() };
|
||
let base = unsafe { oakplugin_debug_alive_count() };
|
||
if common::test_plugin_scan_dir().is_none() {
|
||
common::skip("最小测试插件未构建");
|
||
unsafe { oakplugin_host_shutdown() };
|
||
return;
|
||
}
|
||
stub::setup_dst(16, 16, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||
let dst = fake_texture(0xA1);
|
||
for i in 0..256 {
|
||
let mut h = create_instance();
|
||
assert!(!h.is_null());
|
||
let r = unsafe { oakplugin_instance_render(h, dst, CHandle::null(), i as f64) };
|
||
assert_eq!(r, OK);
|
||
unsafe { oakplugin_instance_free(&mut h) };
|
||
assert_eq!(
|
||
unsafe { oakplugin_debug_alive_count() },
|
||
base,
|
||
"第 {i} 次循环后泄漏"
|
||
);
|
||
}
|
||
unsafe { oakplugin_host_shutdown() };
|
||
});
|
||
}
|
||
|
||
/// host_shutdown 时仍有活实例:宿主发 destroy 通知并卸载 bundle,
|
||
/// 之后该实例的任何 render 立即取消(不碰已卸载入口);再 create
|
||
/// 返回空句柄(缓存已清空——声明原期望 E_STATE,本实现的 create
|
||
/// 契约是"失败返回空句柄",以实例化为准)。
|
||
#[test]
|
||
fn shutdown_with_live_instances() {
|
||
common::with_host(|| {
|
||
unsafe { oakplugin_host_init() };
|
||
let mut h = create_instance();
|
||
if h.is_null() {
|
||
unsafe { oakplugin_host_shutdown() };
|
||
return;
|
||
}
|
||
// 带活实例 shutdown:不崩;实例被打取消标记。
|
||
unsafe { oakplugin_host_shutdown() };
|
||
// C 侧句柄仍持 Arc:实例内存存活,但 render 必须失败。
|
||
let dst = fake_texture(0xA1);
|
||
assert_eq!(
|
||
unsafe { oakplugin_instance_render(h, dst, CHandle::null(), 0.0) },
|
||
E_FAILED
|
||
);
|
||
unsafe { oakplugin_instance_free(&mut h) };
|
||
assert_eq!(
|
||
unsafe { oakplugin_debug_alive_count() },
|
||
0,
|
||
"句柄释放后归零"
|
||
);
|
||
// 之后再 create:插件已卸载 → 空句柄。
|
||
let id = cs(TEST_PLUGIN_ID);
|
||
let h2 = unsafe { oakplugin_instance_create(id.as_ptr()) };
|
||
assert!(h2.is_null());
|
||
});
|
||
}
|
||
|
||
/// 多实例并发:同一插件 16 实例并发 render(宿主侧不串——插件
|
||
/// 全局状态由插件自保)。
|
||
#[cfg(feature = "test-stubs")]
|
||
#[test]
|
||
fn concurrent_instances_render() {
|
||
common::with_host(|| {
|
||
use oakplugin::bridge::render::stub;
|
||
unsafe { oakplugin_host_init() };
|
||
if common::test_plugin_scan_dir().is_none() {
|
||
common::skip("最小测试插件未构建");
|
||
unsafe { oakplugin_host_shutdown() };
|
||
return;
|
||
}
|
||
// 16 个实例(句柄数组;经线程并发 render)。
|
||
let mut handles: Vec<CHandle> = (0..16).map(|_| create_instance()).collect();
|
||
assert!(handles.iter().all(|h| !h.is_null()));
|
||
stub::setup_dst(32, 32, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||
let dst = fake_texture(0xA1);
|
||
let threads: Vec<_> = handles
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(i, h)| {
|
||
let h = *h;
|
||
std::thread::spawn(move || unsafe {
|
||
oakplugin_instance_render(h, dst, CHandle::null(), i as f64)
|
||
})
|
||
})
|
||
.collect();
|
||
for t in threads {
|
||
assert_eq!(t.join().unwrap(), OK);
|
||
}
|
||
for h in handles.iter_mut() {
|
||
unsafe { oakplugin_instance_free(h) };
|
||
}
|
||
assert_eq!(unsafe { oakplugin_debug_alive_count() }, 0);
|
||
unsafe { oakplugin_host_shutdown() };
|
||
});
|
||
}
|