test(oak-timeline, oak-plugin): undo commands and plugin host

Ripple/pointer/split/general undo commands with their edge cases, and
plugin host/instance/suite coverage including the built-in test
plugin bundle.
This commit is contained in:
2026-09-22 20:54:04 +08:00
parent e04ce03058
commit fe8fbff8a9
8 changed files with 7099 additions and 45 deletions
+393
View File
@@ -439,4 +439,397 @@ mod tests {
assert!(f16_to_f32(0xFC00).is_infinite() && f16_to_f32(0xFC00) < 0.0);
assert!(f16_to_f32(0x7E00).is_nan());
}
// ---- fetch_image / store_output_image / 协商矩阵 ----------------------
fn cs(s: &str) -> std::ffi::CString {
std::ffi::CString::new(s).unwrap()
}
fn test_clip(name: &str) -> ClipInstance {
ClipInstance::from_descriptor(&crate::descriptor::ClipDescriptor::new(name))
}
fn video_params(w: i32, h: i32, format: i32) -> crate::render::VideoParams {
crate::render::VideoParams {
width: w,
height: h,
format,
..Default::default()
}
}
fn frame_with(
w: i32,
h: i32,
format: oak_core::PixelFormat,
bytes: &[u8],
) -> crate::render::Frame {
let mut frame = crate::render::Frame::new();
frame.set_video_params(video_params(w, h, format as i32));
assert!(frame.allocate(), "测试帧应可分配");
assert_eq!(frame.data.len(), bytes.len(), "测试字节长度");
frame.data.copy_from_slice(bytes);
frame
}
fn f32_frame(w: i32, h: i32, fill: f32) -> crate::render::Frame {
let mut frame = crate::render::Frame::new();
frame.set_video_params(video_params(w, h, crate::render::PIXEL_FORMAT_F32));
assert!(frame.allocate());
for c in frame.data.as_chunks_mut::<4>().0 {
c.copy_from_slice(&fill.to_le_bytes());
}
frame
}
fn f32_image(w: i32, h: i32, fill: f32) -> crate::image::Image {
let mut img = crate::image::Image::allocate(
crate::image::BitDepth::Float,
crate::image::Components::Rgba,
OfxRectD {
x1: 0.0,
y1: 0.0,
x2: w as f64,
y2: h as f64,
},
);
for c in img.pixels_mut().as_chunks_mut::<4>().0 {
c.copy_from_slice(&fill.to_le_bytes());
}
img
}
fn connect(clip: &ClipInstance, frame: crate::render::Frame) {
clip.set_input_texture(Some(crate::render::Texture::wrap_frame(frame)), 0.0);
}
fn samples(image: &crate::image::Image) -> Vec<f32> {
image
.pixels()
.as_chunks::<4>()
.0
.iter()
.map(|c| f32::from_le_bytes(*c))
.collect()
}
/// components_from_props:三种 OFX 分量字符串命中;未知字符串与
/// 非字符串值返回 None。
#[test]
fn components_from_props_matrix() {
use crate::property::Value;
for (s, want) in [
("OfxImageComponentRGBA", Some(crate::image::Components::Rgba)),
("OfxImageComponentRGB", Some(crate::image::Components::Rgb)),
("OfxImageComponentAlpha", Some(crate::image::Components::Alpha)),
("OfxImageComponentBogus", None),
] {
let set = PropertySet::new();
set.set_one(
crate::image::K_IMAGE_EFFECT_PROP_COMPONENTS,
Value::String(cs(s)),
);
assert_eq!(components_from_props(&set), want, "{s}");
}
let set = PropertySet::new();
set.set_one(crate::image::K_IMAGE_EFFECT_PROP_COMPONENTS, Value::Int(1));
assert_eq!(components_from_props(&set), None);
}
/// set_video_params:全部位深号与分量数分支写回 clip props
/// (0/2/3 官方映射 + 兜底 Float;1/3 分量 + 兜底 RGBA)。
#[test]
fn set_video_params_depth_and_component_matrix() {
use crate::property::Value;
for (fmt, want) in [
(0, "OfxBitDepthByte"),
(2, "OfxBitDepthShort"),
(3, "OfxBitDepthHalf"),
(4, "OfxBitDepthFloat"),
(99, "OfxBitDepthFloat"),
] {
let c = test_clip("Source");
c.set_video_params(fmt, 4);
let got = c
.props
.get(crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH, 0);
assert!(
matches!(got, Some(Value::String(s)) if s.to_string_lossy() == want),
"format {fmt}"
);
}
for (ch, want) in [
(1, "OfxImageComponentAlpha"),
(3, "OfxImageComponentRGB"),
(4, "OfxImageComponentRGBA"),
(0, "OfxImageComponentRGBA"),
] {
let c = test_clip("Source");
c.set_video_params(4, ch);
let got = c
.props
.get(crate::image::K_IMAGE_EFFECT_PROP_COMPONENTS, 0);
assert!(
matches!(got, Some(Value::String(s)) if s.to_string_lossy() == want),
"channels {ch}"
);
}
}
/// fetch_image:子区域请求、无输入、dummy 占位都按契约失败。
#[test]
fn fetch_image_rejects_region_and_unusable_input() {
let c = test_clip("Source");
assert!(matches!(
c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, Some(OfxRectD::default())),
Err(crate::error::Error::Failed(_))
));
assert!(matches!(
c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None),
Err(crate::error::Error::NotFound)
));
c.set_input_texture(Some(crate::render::Texture::dummy()), 0.0);
assert!(matches!(
c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None),
Err(crate::error::Error::NotFound)
));
}
/// fetch_image:U8/U16/F16/F32 四路输入转换与 NaN/Inf 清洗。
#[test]
fn fetch_image_converts_all_input_depths() {
// U8:归一化到 [0,1]。
let c = test_clip("Source");
connect(&c, frame_with(1, 1, oak_core::PixelFormat::U8, &[0, 255, 128, 64]));
let got = samples(&c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None).unwrap());
assert_eq!(got[0], 0.0);
assert_eq!(got[1], 1.0);
assert!((got[2] - 128.0 / 255.0).abs() < 1e-7);
assert!((got[3] - 64.0 / 255.0).abs() < 1e-7);
// U16。
let c = test_clip("Source");
let mut bytes = Vec::new();
for v in [0u16, 65535, 32768, 1] {
bytes.extend_from_slice(&v.to_le_bytes());
}
connect(&c, frame_with(1, 1, oak_core::PixelFormat::U16, &bytes));
let got = samples(&c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None).unwrap());
assert_eq!(got[0], 0.0);
assert_eq!(got[1], 1.0);
assert!((got[2] - 32768.0 / 65535.0).abs() < 1e-7);
assert!((got[3] - 1.0 / 65535.0).abs() < 1e-7);
// F16:NaN/Inf 清洗为 0,普通值与 -0 保留。
let c = test_clip("Source");
let mut bytes = Vec::new();
for v in [0x3C00u16, 0x7E00, 0x7C00, 0x8000] {
bytes.extend_from_slice(&v.to_le_bytes());
}
connect(&c, frame_with(1, 1, oak_core::PixelFormat::F16, &bytes));
let got = samples(&c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None).unwrap());
assert_eq!(got[0], 1.0);
assert_eq!(got[1], 0.0, "F16 NaN 应清洗为 0");
assert_eq!(got[2], 0.0, "F16 Inf 应清洗为 0");
assert_eq!(got[3].to_bits(), 0x8000_0000, "-0 应保留");
// F32:NaN/Inf 清洗为 0。
let c = test_clip("Source");
let mut bytes = Vec::new();
for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 1.5] {
bytes.extend_from_slice(&v.to_le_bytes());
}
connect(&c, frame_with(1, 1, oak_core::PixelFormat::F32, &bytes));
let got = samples(&c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None).unwrap());
assert_eq!(got[0], 0.0);
assert_eq!(got[1], 0.0);
assert_eq!(got[2], 0.0);
assert_eq!(got[3], 1.5);
}
/// fetch_image:不支持的输入格式(U10)显式失败。
#[test]
fn fetch_image_rejects_unsupported_format() {
let c = test_clip("Source");
// U10 = 4 字节/通道打包格式;管线仅支持 U8/U16/F16/F32。
connect(&c, frame_with(1, 1, oak_core::PixelFormat::U10, &[0u8; 16]));
let res = c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None);
assert!(matches!(res, Err(crate::error::Error::Failed(_))));
}
/// fetch_image:协商位深从 clip props 读——Byte 降位深;未知串与
/// 非字符串值回退 F32。
#[test]
fn fetch_image_negotiated_depth() {
use crate::property::Value;
let c = test_clip("Source");
connect(&c, f32_frame(1, 1, 0.5));
c.props.set_one(
crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH,
Value::String(cs("OfxBitDepthByte")),
);
let img = c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None).unwrap();
assert_eq!(img.depth(), crate::image::BitDepth::Byte);
let c = test_clip("Source");
connect(&c, f32_frame(1, 1, 0.5));
c.props.set_one(
crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH,
Value::String(cs("OfxBitDepthBogus")),
);
let img = c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None).unwrap();
assert_eq!(img.depth(), crate::image::BitDepth::Float);
let c = test_clip("Source");
connect(&c, f32_frame(1, 1, 0.5));
c.props
.set_one(crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH, Value::Int(4));
let img = c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None).unwrap();
assert_eq!(img.depth(), crate::image::BitDepth::Float);
}
/// store_output_image:无输出/dummy/非 F32/尺寸不符的错误分支与
/// CPU 整帧拷贝。
#[test]
fn store_output_image_cpu_paths() {
let image = f32_image(2, 2, 0.25);
// 未挂输出纹理 → NotFound。
let c = test_clip("Output");
assert!(matches!(
c.store_output_image(&image),
Err(crate::error::Error::NotFound)
));
// dummy 输出纹理 → NotFound。
c.set_output_texture(Some(crate::render::Texture::dummy()), 0.0);
assert!(matches!(
c.store_output_image(&image),
Err(crate::error::Error::NotFound)
));
// 非 F32 输出帧 → Failed。
let c = test_clip("Output");
c.set_output_texture(
Some(crate::render::Texture::wrap_frame(frame_with(
2,
2,
oak_core::PixelFormat::U8,
&[0u8; 16],
))),
0.0,
);
assert!(matches!(
c.store_output_image(&image),
Err(crate::error::Error::Failed(_))
));
// 尺寸不符 → Failed。
let c = test_clip("Output");
c.set_output_texture(
Some(crate::render::Texture::wrap_frame(f32_frame(3, 1, 0.0))),
0.0,
);
assert!(matches!(
c.store_output_image(&image),
Err(crate::error::Error::Failed(_))
));
// 成功:整帧拷贝回输出纹理。
let c = test_clip("Output");
c.set_output_texture(
Some(crate::render::Texture::wrap_frame(f32_frame(2, 2, 0.0))),
0.0,
);
let stored = c.store_output_image(&image).unwrap();
let frame = crate::render::texture_get_frame(&stored).unwrap();
assert_eq!(frame.data.as_slice(), image.pixels());
}
struct FakeGpu {
uploads: std::sync::Mutex<Vec<(u64, crate::render::Frame)>>,
fail_upload: bool,
}
impl oak_core::backend::GpuContextLike for FakeGpu {
fn kind(&self) -> oak_core::backend::BackendKind {
oak_core::backend::BackendKind::Cpu
}
fn destroy_texture(&self, _token: u64) {}
fn upload(&self, token: u64, frame: &crate::render::Frame) -> oak_render::error::Result<()> {
if self.fail_upload {
return Err(oak_render::error::Error::State);
}
self.uploads
.lock()
.unwrap_or_else(|e| e.into_inner())
.push((token, frame.clone()));
Ok(())
}
fn download(&self, _token: u64) -> oak_render::error::Result<crate::render::Frame> {
Ok(f32_frame(2, 2, 0.0))
}
fn blit(
&self,
_src: u64,
_dst: u64,
_processor: Option<&oak_core::color::ColorProcessor>,
) -> oak_render::error::Result<()> {
Ok(())
}
}
/// store_output_image:GPU 输出纹理经后端 upload 回写;上传失败上抛。
#[test]
fn store_output_image_gpu_upload() {
let image = f32_image(2, 2, 0.5);
let ctx = std::sync::Arc::new(FakeGpu {
uploads: std::sync::Mutex::new(Vec::new()),
fail_upload: false,
});
let c = test_clip("Output");
let tex = crate::render::Texture::gpu(ctx.clone(), 7, 2, 2, oak_core::PixelFormat::F32);
c.set_output_texture(Some(tex), 0.0);
c.store_output_image(&image).unwrap();
{
let uploads = ctx.uploads.lock().unwrap();
assert_eq!(uploads.len(), 1);
assert_eq!(uploads[0].0, 7);
assert_eq!(uploads[0].1.data.as_slice(), image.pixels());
}
// 上传失败 → Failed。
let ctx = std::sync::Arc::new(FakeGpu {
uploads: std::sync::Mutex::new(Vec::new()),
fail_upload: true,
});
let c = test_clip("Output");
c.set_output_texture(
Some(crate::render::Texture::gpu(
ctx,
8,
2,
2,
oak_core::PixelFormat::F32,
)),
0.0,
);
assert!(matches!(
c.store_output_image(&image),
Err(crate::error::Error::Failed(_))
));
}
/// frame_range:第 1 期明确不支持(renderer 桥待落地)。
#[test]
fn frame_range_is_unsupported() {
let c = test_clip("Source");
assert!(matches!(
c.frame_range(),
Err(crate::error::Error::Failed(_))
));
}
}
+764
View File
@@ -1655,4 +1655,768 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
// ---- branch coverage batch: loader/cache/host ------------------------
fn temp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"oak-plugin-host-{}-{tag}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn test_cache() -> PluginCache {
PluginCache {
plugins: Mutex::new(Vec::new()),
scanned_paths: Mutex::new(Vec::new()),
binaries: Mutex::new(Vec::new()),
}
}
/// build.rs 编出的最小测试插件共享库。
fn fixture_lib() -> Option<PathBuf> {
let out = PathBuf::from(env!("OUT_DIR"));
let lib = out.join(if cfg!(target_os = "macos") {
"oak_test_plugin.dylib"
} else {
"oak_test_plugin.so"
});
lib.is_file().then_some(lib)
}
/// 现场装配一个含测试插件二进制的 `.ofx.bundle`(平台目录探测)。
fn make_fixture_bundle(tag: &str) -> Option<(PathBuf, PathBuf)> {
let lib = fixture_lib()?;
let platform = if cfg!(target_os = "macos") {
"MacOS"
} else if cfg!(target_os = "windows") {
"Win64"
} else {
"Linux-x86-64"
};
let root = temp_dir(tag);
let bundle = root.join("oak-test-plugin.ofx.bundle");
let bin_dir = bundle.join("Contents").join(platform);
std::fs::create_dir_all(&bin_dir).unwrap();
let target = bin_dir.join(if cfg!(target_os = "windows") {
"plugin.dll"
} else {
"plugin"
});
std::fs::copy(&lib, &target).unwrap();
Some((root, bundle))
}
fn action_is(action: *const c_char, name: &str) -> bool {
!action.is_null()
&& unsafe { CStr::from_ptr(action) }.to_bytes() == name.as_bytes()
}
unsafe extern "C" fn entry_ok(
_: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
status::OK
}
unsafe extern "C" fn entry_reply_default(
_: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
status::REPLY_DEFAULT
}
unsafe extern "C" fn entry_describe_fails(
action: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
if action_is(action, ACTION_DESCRIBE_IN_CONTEXT) {
status::ERR_FATAL
} else {
status::OK
}
}
unsafe extern "C" fn entry_create_fails(
action: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
if action_is(action, ACTION_CREATE_INSTANCE) {
status::ERR_FATAL
} else {
status::OK
}
}
fn fake_plugin(identifier: &str, contexts: Vec<String>, entry: EntryPoint) -> Arc<Plugin> {
Arc::new(Plugin {
identifier: identifier.into(),
version: (1, 0),
bundle_path: PathBuf::new(),
contexts,
descriptor: EffectDescriptor::new(),
lib: std::ptr::null_mut(),
entry,
ofx_plugin: std::ptr::null_mut(),
unloaded: std::sync::atomic::AtomicBool::new(false),
})
}
/// 隔离的 Host(本地 cache/instances,避免触碰进程单例)。
fn local_host(plugins: Vec<Arc<Plugin>>) -> Host {
Host {
props: PropertySet::new(),
cache: PluginCache {
plugins: Mutex::new(plugins),
scanned_paths: Mutex::new(Vec::new()),
binaries: Mutex::new(Vec::new()),
},
instances: Mutex::new(Vec::new()),
}
}
// ---- dlopen/dlsym error paths ----------------------------------------
#[cfg(not(target_os = "windows"))]
#[test]
fn dl_open_and_dl_sym_error_paths_return_none() {
// 不存在的库 → None。
assert!(dl_open(Path::new("/definitely/not/a/library-oak.so")).is_none());
// 可加载的库 + 不存在的符号 → None。
let lib = fixture_lib().expect("build.rs 产出测试插件库");
let handle = dl_open(&lib).expect("测试插件库可加载");
assert!(dl_sym(handle, "oak_definitely_missing_symbol").is_none());
unsafe { dlclose(handle) };
}
// ---- fetchSuite 入口 -------------------------------------------------
#[test]
fn host_fetch_suite_null_args_and_trace_paths() {
use std::ffi::c_void as cv;
static TRACE_LOCK: Mutex<()> = Mutex::new(());
let _g = TRACE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let host_set = PropertySet::new();
let host = &host_set as *const PropertySet as *mut cv;
let name = CString::new("OfxPropertySuite").unwrap();
let bogus = CString::new("OfxBogusSuite").unwrap();
// NULL host / NULL name → None(不触达 suite 分发表)。
assert!(unsafe { host_fetch_suite(std::ptr::null_mut(), name.as_ptr(), 1) }.is_null());
assert!(unsafe { host_fetch_suite(host, std::ptr::null(), 1) }.is_null());
// OAK_OFX_TRACE 打开:hit 与 miss 诊断分支。
let prev = std::env::var_os("OAK_OFX_TRACE");
std::env::set_var("OAK_OFX_TRACE", "1");
assert!(!unsafe { host_fetch_suite(host, name.as_ptr(), 1) }.is_null());
assert!(unsafe { host_fetch_suite(host, bogus.as_ptr(), 1) }.is_null());
// 版本不符也走 miss。
assert!(unsafe { host_fetch_suite(host, name.as_ptr(), 2) }.is_null());
match prev {
Some(v) => std::env::set_var("OAK_OFX_TRACE", v),
None => std::env::remove_var("OAK_OFX_TRACE"),
}
}
// ---- overlay interact 入口声明 ---------------------------------------
#[test]
fn overlay_interact_entry_null_pointer_and_v1_fallback() {
unsafe extern "C" fn overlay(
_: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
status::OK
}
let props = PropertySet::new();
// 属性缺失 → None。
assert!(overlay_interact_entry(&props).is_none());
// 空指针 → 跳过(V2 空、V1 也缺)。
props.set_one(PROP_OVERLAY_INTERACT_V2, Value::Pointer(std::ptr::null_mut()));
assert!(overlay_interact_entry(&props).is_none());
// 类型不是 Pointer → 跳过。
props.set_one(PROP_OVERLAY_INTERACT_V2, Value::Int(1));
assert!(overlay_interact_entry(&props).is_none());
// V1 回退命中。
props.set_one(
PROP_OVERLAY_INTERACT_V1,
Value::Pointer(overlay as *const () as *mut std::ffi::c_void),
);
assert!(overlay_interact_entry(&props).is_some());
// V2 优先。
props.set_one(
PROP_OVERLAY_INTERACT_V2,
Value::Pointer(overlay as *const () as *mut std::ffi::c_void),
);
assert!(overlay_interact_entry(&props).is_some());
}
// ---- 卸载后的入口保护 -------------------------------------------------
#[test]
fn unloaded_plugin_actions_fail_without_touching_entry() {
let plugin = fake_plugin("test.unloaded", vec![], entry_ok);
plugin
.unloaded
.store(true, std::sync::atomic::Ordering::Release);
let empty = PropertySet::new();
let st = unsafe {
plugin.call_action(ACTION_LOAD, std::ptr::null_mut(), &empty, &empty)
};
assert_eq!(st, status::ERR_FATAL);
let st = unsafe {
plugin.call_entry(entry_ok, ACTION_LOAD, std::ptr::null_mut(), &empty, &empty)
};
assert_eq!(st, status::ERR_FATAL);
}
// ---- bundle/二进制探测 -----------------------------------------------
#[test]
fn is_bundle_dir_matches_suffixes_only() {
assert!(is_bundle_dir(Path::new("/tmp/Foo.bundle")));
assert!(is_bundle_dir(Path::new("Bar.plugin")));
assert!(!is_bundle_dir(Path::new("/tmp/NotIt")));
// 无 file_name(根路径)→ false。
assert!(!is_bundle_dir(Path::new("/")));
}
#[test]
fn find_binary_in_bundle_prefers_platform_dir_and_falls_back_to_root() {
let dir = temp_dir("find-bin");
// 空 bundle → None。
assert!(find_binary_in_bundle(&dir).is_none());
// 根目录 .so 回退。
std::fs::write(dir.join("liboak.so"), b"not a real lib").unwrap();
assert_eq!(find_binary_in_bundle(&dir).unwrap(), dir.join("liboak.so"));
// 平台目录优先,且跳过 .plist。
let platform = dir.join("Contents").join("Linux-x86-64");
std::fs::create_dir_all(&platform).unwrap();
std::fs::write(platform.join("Info.plist"), b"meta").unwrap();
std::fs::write(platform.join("plugin"), b"bin").unwrap();
assert_eq!(find_binary_in_bundle(&dir).unwrap(), platform.join("plugin"));
// 平台目录里只有 .plist → 回退根目录。
std::fs::remove_file(platform.join("plugin")).unwrap();
assert_eq!(find_binary_in_bundle(&dir).unwrap(), dir.join("liboak.so"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn walk_dirs_respects_depth_and_read_errors() {
let root = temp_dir("walk");
std::fs::write(root.join("file.txt"), b"x").unwrap();
let a = root.join("a");
let bundle = a.join("One.bundle");
let b = a.join("b");
let c = b.join("c");
std::fs::create_dir_all(&bundle).unwrap();
std::fs::create_dir_all(&c).unwrap();
std::fs::create_dir_all(b.join("Two.bundle")).unwrap();
let mut out = Vec::new();
walk_dirs(&root, 0, 1, &mut out);
assert_eq!(out, vec![bundle.clone()], "深度 1 内只发现 One.bundle(文件与更深目录跳过)");
// 深度上限内全收。
let mut deep = Vec::new();
walk_dirs(&root, 0, 4, &mut deep);
assert_eq!(deep.len(), 2);
// read_dir 失败(路径是文件)→ 空返回。
let mut errs = Vec::new();
walk_dirs(&root.join("file.txt"), 0, 4, &mut errs);
assert!(errs.is_empty());
let _ = std::fs::remove_dir_all(&root);
}
// ---- load_bundle ------------------------------------------------------
#[test]
fn load_bundle_reports_missing_and_invalid_binaries() {
let cache = test_cache();
// 空目录(无二进制)→ 记录并返回。
let empty = temp_dir("load-empty");
cache.load_bundle(&empty);
assert_eq!(cache.count(), 0);
// 根目录垃圾 .so → dlopen 失败,记录并返回。
let bad = temp_dir("load-bad");
std::fs::write(bad.join("garbage.so"), b"definitely not an ELF").unwrap();
cache.load_bundle(&bad);
assert_eq!(cache.count(), 0);
assert!(cache.binaries.lock().unwrap_or_else(|e| e.into_inner()).is_empty());
let _ = std::fs::remove_dir_all(&empty);
let _ = std::fs::remove_dir_all(&bad);
}
#[test]
fn load_bundle_loads_fixture_plugins_and_dedupes() {
let Some((root, bundle)) = make_fixture_bundle("load-ok") else {
eprintln!("SKIP: 测试插件未构建");
return;
};
let cache = test_cache();
cache.load_bundle(&bundle);
assert_eq!(cache.count(), 5, "测试插件导出 5 个效果");
assert!(cache.find("org.oak.test-plugin").is_some());
assert_eq!(
cache.binaries.lock().unwrap_or_else(|e| e.into_inner()).len(),
1
);
// 同一 bundle 二次加载 → 路径去重(直接 dlclose 返回)。
cache.load_bundle(&bundle);
assert_eq!(cache.count(), 5);
assert_eq!(
cache.binaries.lock().unwrap_or_else(|e| e.into_inner()).len(),
1
);
// at()/find() 边界。
assert!(cache.at(0).is_some());
assert!(cache.at(usize::MAX).is_none());
assert!(cache.find("org.oak.test-plugin.nope").is_none());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn load_bundle_ignores_duplicate_plugin_identifiers() {
let Some((root1, bundle1)) = make_fixture_bundle("dup-a") else {
eprintln!("SKIP: 测试插件未构建");
return;
};
let Some((root2, bundle2)) = make_fixture_bundle("dup-b") else {
eprintln!("SKIP: 测试插件未构建");
return;
};
let cache = test_cache();
cache.load_bundle(&bundle1);
assert_eq!(cache.count(), 5);
// 第二个 bundle 的 identifier 全部重复 → 忽略,但二进制登记两份。
cache.load_bundle(&bundle2);
assert_eq!(cache.count(), 5);
assert_eq!(
cache.binaries.lock().unwrap_or_else(|e| e.into_inner()).len(),
2
);
let _ = std::fs::remove_dir_all(&root1);
let _ = std::fs::remove_dir_all(&root2);
}
/// 可加载但无 OFX 导出符号的库 → collect_plugins 空(Linux libc)。
#[cfg(target_os = "linux")]
#[test]
fn collect_plugins_without_ofx_symbols_is_empty() {
let Some(libc) = [
"/lib/x86_64-linux-gnu/libc.so.6",
"/usr/lib/x86_64-linux-gnu/libc.so.6",
"/lib64/libc.so.6",
"/usr/lib64/libc.so.6",
"/lib/libc.so.6",
"/usr/lib/libc.so.6",
]
.iter()
.map(PathBuf::from)
.find(|p| p.is_file()) else {
eprintln!("SKIP: 未找到 libc");
return;
};
let Some(handle) = dl_open(&libc) else {
eprintln!("SKIP: libc 不可 dlopen");
return;
};
let cache = test_cache();
let plugins = unsafe { cache.collect_plugins(handle, Path::new("no-ofx")) };
unsafe { dlclose(handle) };
assert!(plugins.is_empty());
}
/// 可加载但无插件的 bundle → 记录 no usable plugins 并 dlclose。
#[cfg(target_os = "linux")]
#[test]
fn load_bundle_without_ofx_plugins_reports_and_unloads() {
let Some(libc) = [
"/lib/x86_64-linux-gnu/libc.so.6",
"/usr/lib/x86_64-linux-gnu/libc.so.6",
"/lib64/libc.so.6",
"/usr/lib64/libc.so.6",
"/lib/libc.so.6",
"/usr/lib/libc.so.6",
]
.iter()
.map(PathBuf::from)
.find(|p| p.is_file()) else {
eprintln!("SKIP: 未找到 libc");
return;
};
let dir = temp_dir("load-no-plugin");
let platform = dir.join("Contents").join("Linux-x86-64");
std::fs::create_dir_all(&platform).unwrap();
std::os::unix::fs::symlink(&libc, platform.join("plugin")).unwrap();
let cache = test_cache();
cache.load_bundle(&dir);
assert_eq!(cache.count(), 0);
assert!(cache.binaries.lock().unwrap_or_else(|e| e.into_inner()).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
// ---- build_plugin 拒绝与上下文过滤 -----------------------------------
unsafe extern "C" fn noop_set_host(_: *mut OfxHost) {}
fn raw_ofx_plugin(
api: Option<&CString>,
api_version: c_int,
identifier: Option<&CString>,
set_host: Option<unsafe extern "C" fn(*mut OfxHost)>,
main_entry: Option<EntryPoint>,
) -> OfxPlugin {
OfxPlugin {
plugin_api: api.map_or(std::ptr::null(), |c| c.as_ptr()),
api_version,
plugin_identifier: identifier.map_or(std::ptr::null(), |c| c.as_ptr()),
plugin_version_major: 1,
plugin_version_minor: 0,
set_host,
main_entry,
}
}
unsafe extern "C" fn entry_multi_contexts(
_: *const c_char,
handle: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
if !handle.is_null() {
let props = unsafe {
&*crate::suites::tag::strip(handle as *mut c_void)
};
props.define(
PROP_SUPPORTED_CONTEXTS,
vec![
Value::String(CString::new("OfxImageEffectContextGeneral").unwrap()),
Value::String(CString::new("OfxImageEffectContextTransition").unwrap()),
Value::String(CString::new("OfxImageEffectContextGenerator").unwrap()),
Value::String(CString::new("OfxImageEffectContextFilter").unwrap()),
Value::String(CString::new("OfxBogusContext").unwrap()),
],
);
}
status::OK
}
unsafe extern "C" fn entry_bogus_contexts(
_: *const c_char,
handle: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
if !handle.is_null() {
let props = unsafe {
&*crate::suites::tag::strip(handle as *mut c_void)
};
props.define(
PROP_SUPPORTED_CONTEXTS,
vec![Value::String(CString::new("OfxImageEffectContextRetimer").unwrap())],
);
}
status::OK
}
unsafe extern "C" fn entry_load_fails(
action: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
if action_is(action, ACTION_LOAD) {
status::ERR_FATAL
} else {
status::OK
}
}
unsafe extern "C" fn entry_build_describe_fails(
action: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
if action_is(action, ACTION_DESCRIBE) {
status::ERR_FATAL
} else {
status::OK
}
}
#[test]
fn build_plugin_rejects_invalid_headers_and_filters_contexts() {
let cache = test_cache();
let bundle = Path::new("test-build.ofx.bundle");
let api = CString::new("OfxImageEffectPluginAPI").unwrap();
let wrong_api = CString::new("OfxSomethingElsePluginAPI").unwrap();
let identifier = CString::new("test.build").unwrap();
// NULL plugin_api → None。
let mut ofx = raw_ofx_plugin(None, 1, Some(&identifier), None, Some(entry_ok));
assert!(unsafe { cache.build_plugin(&mut ofx, std::ptr::null_mut(), bundle) }.is_none());
// API 名不符 → None。
let mut ofx = raw_ofx_plugin(Some(&wrong_api), 1, Some(&identifier), None, Some(entry_ok));
assert!(unsafe { cache.build_plugin(&mut ofx, std::ptr::null_mut(), bundle) }.is_none());
// API 版本不符 → None。
let mut ofx = raw_ofx_plugin(Some(&api), 2, Some(&identifier), None, Some(entry_ok));
assert!(unsafe { cache.build_plugin(&mut ofx, std::ptr::null_mut(), bundle) }.is_none());
// main_entry 缺 → None。
let mut ofx = raw_ofx_plugin(Some(&api), 1, Some(&identifier), None, None);
assert!(unsafe { cache.build_plugin(&mut ofx, std::ptr::null_mut(), bundle) }.is_none());
// load action 失败 → None(setHost 存在分支也被走到)。
let mut ofx = raw_ofx_plugin(
Some(&api),
1,
Some(&identifier),
Some(noop_set_host),
Some(entry_load_fails),
);
assert!(unsafe { cache.build_plugin(&mut ofx, std::ptr::null_mut(), bundle) }.is_none());
// describe action 失败 → None。
let mut ofx = raw_ofx_plugin(
Some(&api),
1,
Some(&identifier),
None,
Some(entry_build_describe_fails),
);
assert!(unsafe { cache.build_plugin(&mut ofx, std::ptr::null_mut(), bundle) }.is_none());
// describe 后无非标准上下文 → None。
let mut ofx = raw_ofx_plugin(
Some(&api),
1,
Some(&identifier),
None,
Some(entry_bogus_contexts),
);
assert!(unsafe { cache.build_plugin(&mut ofx, std::ptr::null_mut(), bundle) }.is_none());
// 四个标准上下文全收;非标准值被过滤。
let mut ofx = raw_ofx_plugin(
Some(&api),
1,
Some(&identifier),
Some(noop_set_host),
Some(entry_multi_contexts),
);
let plugin = unsafe { cache.build_plugin(&mut ofx, std::ptr::null_mut(), bundle) }
.expect("标准上下文应接受");
assert_eq!(plugin.identifier, "test.build");
assert_eq!(plugin.contexts.len(), 4);
assert!(!plugin.contexts.iter().any(|c| c == "OfxBogusContext"));
assert_eq!(plugin.version, (1, 0));
}
// ---- scan 环境变量 ----------------------------------------------------
#[test]
fn scan_reads_plugin_path_env_vars() {
static ENV_LOCK: Mutex<()> = Mutex::new(());
let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let Some((root, _bundle)) = make_fixture_bundle("scan-env") else {
eprintln!("SKIP: 测试插件未构建");
return;
};
let prev = std::env::var_os("OLIVE_OFX_PLUGIN_PATH");
std::env::set_var("OLIVE_OFX_PLUGIN_PATH", &root);
let cache = test_cache();
let result = cache.scan();
match prev {
Some(v) => std::env::set_var("OLIVE_OFX_PLUGIN_PATH", v),
None => std::env::remove_var("OLIVE_OFX_PLUGIN_PATH"),
}
result.expect("scan");
assert!(
cache.find("org.oak.test-plugin").is_some(),
"环境变量目录里的 bundle 应被发现"
);
let _ = std::fs::remove_dir_all(&root);
}
// ---- create_instance / create_instance_preferred ---------------------
#[test]
fn create_instance_preferred_uses_custom_context_fallback() {
let host = local_host(vec![fake_plugin(
"test.pref.custom",
vec!["OfxImageEffectContextCustom".into()],
entry_ok,
)]);
let (context, inst) = host
.create_instance_preferred("test.pref.custom")
.expect("自定义上下文可实例化");
assert_eq!(context, "OfxImageEffectContextCustom");
assert_eq!(inst.value.context, "OfxImageEffectContextCustom");
// 实例属性表(init_instance_props)已建好。
assert!(inst.value.props.get("OfxPropType", 0).is_some());
// 未登记标识 → NotFound。
assert!(host.create_instance_preferred("nope").is_err());
}
#[test]
fn create_instance_preferred_returns_last_error() {
let host = local_host(vec![fake_plugin(
"test.pref.fail",
vec![
"OfxImageEffectContextFilter".into(),
"OfxImageEffectContextGeneral".into(),
],
entry_describe_fails,
)]);
let err = host
.create_instance_preferred("test.pref.fail")
.err()
.expect("全部上下文失败应返回错误");
assert!(matches!(
err,
crate::error::Error::Failed(ref m) if m.contains("describeInContext")
));
}
#[test]
fn create_instance_rejects_unsupported_context_and_missing_contexts() {
let host = local_host(vec![fake_plugin(
"test.ctx.one",
vec!["OfxImageEffectContextFilter".into()],
entry_ok,
)]);
// 插件不支持请求的上下文。
assert!(host
.create_instance("test.ctx.one", Some("OfxImageEffectContextGenerator"))
.is_err());
// None → 取首个支持上下文并成功。
let inst = host.create_instance("test.ctx.one", None).expect("首个上下文");
assert_eq!(inst.value.context, "OfxImageEffectContextFilter");
// 无支持上下文 → 失败。
let none = local_host(vec![fake_plugin("test.ctx.none", vec![], entry_ok)]);
assert!(none.create_instance("test.ctx.none", None).is_err());
// 未登记标识 → 失败。
assert!(none.create_instance("missing", None).is_err());
// ReplyDefault 的 describeInContext/createInstance 也被接受。
let default = local_host(vec![fake_plugin(
"test.ctx.default",
vec!["OfxImageEffectContextFilter".into()],
entry_reply_default,
)]);
assert!(default.create_instance("test.ctx.default", None).is_ok());
}
#[test]
fn create_instance_action_failure_is_not_registered_alive() {
let host = local_host(vec![fake_plugin(
"test.create.fail",
vec!["OfxImageEffectContextFilter".into()],
entry_create_fails,
)]);
let err = host
.create_instance("test.create.fail", None)
.err()
.expect("createInstance 失败");
assert!(matches!(
err,
crate::error::Error::Failed(ref m) if m.contains("createInstance")
));
assert_eq!(host.alive_count(), 0);
}
#[test]
fn alive_count_prunes_released_instances() {
let host = local_host(vec![fake_plugin(
"test.alive",
vec!["OfxImageEffectContextFilter".into()],
entry_ok,
)]);
let a = host.create_instance("test.alive", None).expect("a");
let b = host.create_instance("test.alive", None).expect("b");
assert_eq!(host.alive_count(), 2);
drop(a);
assert_eq!(host.alive_count(), 1);
drop(b);
assert_eq!(host.alive_count(), 0);
}
/// `register_plugin_nodes` 对无法实例化的缓存项记录原因并跳过,
/// 对可实例化的登记动态节点。
#[test]
fn register_plugin_nodes_skips_uninstantiable_and_registers_ok_plugins() {
static LOCK: Mutex<()> = Mutex::new(());
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let failing_id = "test.node-factory.fail";
let ok_id = "test.node-factory.ok";
{
let mut plugins = Host::global()
.cache
.plugins
.lock()
.unwrap_or_else(|e| e.into_inner());
plugins.push(fake_plugin(failing_id, vec![], entry_ok));
plugins.push(fake_plugin(
ok_id,
vec!["OfxImageEffectContextFilter".into()],
entry_ok,
));
}
let registered = crate::node_factory::register_plugin_nodes();
{
let mut plugins = Host::global()
.cache
.plugins
.lock()
.unwrap_or_else(|e| e.into_inner());
plugins.retain(|p| p.identifier != failing_id && p.identifier != ok_id);
}
assert!(
registered.iter().any(|id| id == ok_id),
"可实例化的插件应登记为动态节点"
);
assert!(
!registered.iter().any(|id| id == failing_id),
"无法实例化的插件应跳过"
);
}
}
+976 -1
View File
@@ -1216,4 +1216,979 @@ pub struct ClipPreferences {
/// field 处理模式(kOfxImageField*)。
pub field: String,
}
/// field 处理模式(kOfxImageField*)。
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::{ClipDescriptor, EffectDescriptor};
use crate::image::{BitDepth, Components, Image};
use crate::param::{ChangeReason, ParamDef, ParamInstance};
use crate::property::Value as PropValue;
use crate::suites::status;
use std::ffi::{c_char, c_int, c_void, CString};
use std::sync::atomic::{AtomicBool, AtomicUsize};
use std::sync::{Arc, Mutex};
fn cs(s: &str) -> CString {
CString::new(s).unwrap()
}
fn action_is(action: *const c_char, name: &str) -> bool {
!action.is_null()
&& unsafe { std::ffi::CStr::from_ptr(action) }.to_bytes() == name.as_bytes()
}
fn prop_string(props: &PropertySet, name: &str) -> Option<String> {
match props.get(name, 0) {
Some(PropValue::String(s)) => Some(s.to_string_lossy().into_owned()),
_ => None,
}
}
unsafe extern "C" fn entry_ok(
_: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
status::OK
}
unsafe extern "C" fn entry_default(
_: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
status::REPLY_DEFAULT
}
unsafe extern "C" fn entry_err(
_: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
status::ERR_FATAL
}
fn param_set(defs: Vec<ParamDef>) -> ParamSetInstance {
ParamSetInstance {
params: defs
.into_iter()
.map(|d| Box::new(ParamInstance::from_def(d)))
.collect(),
}
}
fn clip(name: &str) -> ClipInstance {
ClipInstance::from_descriptor(&ClipDescriptor::new(name))
}
fn make_instance_with(
entry: crate::host::EntryPoint,
params: ParamSetInstance,
clips: Vec<ClipInstance>,
) -> Arc<Instance> {
let plugin = Arc::new(Plugin {
identifier: "test.instance".into(),
version: (1, 0),
bundle_path: std::path::PathBuf::new(),
contexts: vec![],
descriptor: EffectDescriptor::new(),
lib: std::ptr::null_mut(),
entry,
ofx_plugin: std::ptr::null_mut(),
unloaded: AtomicBool::new(false),
});
Arc::new(Instance {
props: PropertySet::new(),
plugin,
context: "OfxImageEffectContextFilter".into(),
params,
clips: clips.into_iter().map(Box::new).collect(),
node_identity: AtomicUsize::new(0),
destroyed: AtomicBool::new(false),
sequence_range: Mutex::new(None),
progress_cb: Mutex::new(None),
cancel: AtomicBool::new(false),
edit: Mutex::new(EditTransaction::new()),
render_lock: Mutex::new(()),
interact: Mutex::new(None),
})
}
fn make_instance(entry: crate::host::EntryPoint, clips: Vec<ClipInstance>) -> Arc<Instance> {
make_instance_with(entry, param_set(vec![]), clips)
}
fn scale() -> RenderScale {
RenderScale { x: 1.0, y: 1.0 }
}
fn window() -> OfxRectD {
OfxRectD {
x1: 0.0,
y1: 0.0,
x2: 2.0,
y2: 2.0,
}
}
fn out_image() -> Arc<Image> {
Arc::new(Image::allocate(
BitDepth::Float,
Components::Rgba,
window(),
))
}
// ---- parameter edit transaction --------------------------------------
#[test]
fn edit_transaction_nesting_and_multi() {
use oak_undo::undocommand::UndoCommand;
let inst = make_instance(entry_ok, vec![]);
assert!(!inst.in_edit());
// 嵌套 begin:首次进入重置事务状态。
inst.edit_begin();
inst.edit_begin();
inst.submit_undo_command(UndoCommand::from_closures(|| {}, || {}), "one");
{
let e = inst.edit.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(e.depth, 2);
assert_eq!(e.param_count, 1);
assert_eq!(e.first_label, "one");
assert_eq!(e.multi.as_ref().expect("multi").multi_child_count(), 1);
}
inst.submit_undo_command(UndoCommand::from_closures(|| {}, || {}), "two");
{
let e = inst.edit.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(e.multi.as_ref().expect("multi").multi_child_count(), 2);
assert_eq!(e.first_label, "one", "首条标签保持");
}
// 内层 end:仍在事务内,multi 保留。
inst.edit_end();
assert!(inst.in_edit());
// 外层 end:redo 并清空。
inst.edit_end();
assert!(!inst.in_edit());
{
let e = inst.edit.lock().unwrap_or_else(|e| e.into_inner());
assert!(e.multi.is_none());
assert_eq!(e.param_count, 0);
assert!(e.first_label.is_empty());
}
// 事务外 end:depth 不越界。
inst.edit_end();
// 新事务重新计数(首进入重置分支)。
inst.edit_begin();
{
let e = inst.edit.lock().unwrap_or_else(|e| e.into_inner());
assert!(e.multi.is_none());
assert_eq!(e.param_count, 0);
}
inst.edit_end();
// 事务外提交 → 立即 redo。
let ran = Arc::new(AtomicBool::new(false));
let flag = ran.clone();
inst.submit_undo_command(
UndoCommand::from_closures(
move || flag.store(true, std::sync::atomic::Ordering::Relaxed),
|| {},
),
"solo",
);
assert!(ran.load(std::sync::atomic::Ordering::Relaxed));
}
// ---- clip preferences -------------------------------------------------
unsafe extern "C" fn entry_prefs_wrong_types(
action: *const c_char,
_: *const c_void,
_: *mut c_void,
out_args: *mut c_void,
) -> c_int {
if action_is(action, crate::host::ACTION_GET_CLIP_PREFERENCES) {
let out = unsafe { &*(out_args as *const PropertySet) };
out.define(
&crate::host::clip_pref_prop(crate::host::CLIP_PREF_COMPONENTS, "Output"),
vec![PropValue::Int(1)],
);
out.define(
&crate::host::clip_pref_prop(crate::host::CLIP_PREF_DEPTH, "Output"),
vec![PropValue::Int(1)],
);
out.define(
&crate::host::clip_pref_prop(crate::host::CLIP_PREF_PAR, "Output"),
vec![PropValue::String(cs("x"))],
);
out.set_one(crate::host::PROP_FRAME_RATE, PropValue::Int(30));
out.set_one(crate::host::PROP_FIELD_ORDER, PropValue::Int(1));
}
status::OK
}
#[test]
fn clip_preferences_default_and_reply_default_paths() {
let inst = make_instance(entry_ok, vec![clip("Source"), clip("Output")]);
let prefs = inst.get_clip_preferences().expect("默认协商");
assert_eq!(prefs.output_components, "OfxImageComponentRGBA");
assert_eq!(prefs.output_bit_depth, "OfxBitDepthFloat");
assert_eq!(prefs.pixel_aspect_ratio, 1.0);
// 插件未回写 → out args 的预置帧率 1.0。
assert_eq!(prefs.frame_rate, 1.0);
assert_eq!(prefs.field, "");
// per-clip 回灌(分量/位深/PAR)。
let source = inst.clips.iter().find(|c| c.name == "Source").unwrap();
assert_eq!(
prop_string(&source.props, crate::image::K_IMAGE_EFFECT_PROP_COMPONENTS).as_deref(),
Some("OfxImageComponentRGBA")
);
assert_eq!(
prop_string(&source.props, crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH).as_deref(),
Some("OfxBitDepthFloat")
);
assert!(matches!(
source.props.get("OfxImagePropPixelAspectRatio", 0),
Some(PropValue::Double(d)) if d == 1.0
));
// ReplyDefault 同样被接受。
let inst = make_instance(entry_default, vec![clip("Output")]);
assert!(inst.get_clip_preferences().is_ok());
// 失败状态 → Err。
let inst = make_instance(entry_err, vec![clip("Output")]);
assert!(inst.get_clip_preferences().is_err());
}
#[test]
fn clip_preferences_wrong_out_types_fall_back() {
let inst = make_instance(entry_prefs_wrong_types, vec![clip("Output")]);
let prefs = inst.get_clip_preferences().expect("类型不符应回退");
assert_eq!(prefs.output_components, "OfxImageComponentRGBA");
assert_eq!(prefs.output_bit_depth, "OfxBitDepthFloat");
assert_eq!(prefs.pixel_aspect_ratio, 1.0);
assert_eq!(prefs.frame_rate, 24.0);
assert_eq!(prefs.field, "");
}
// ---- region of definition / interest ---------------------------------
unsafe extern "C" fn entry_rod_ok(
action: *const c_char,
_: *const c_void,
_: *mut c_void,
out_args: *mut c_void,
) -> c_int {
if action_is(action, crate::host::ACTION_GET_ROD) {
let out = unsafe { &*(out_args as *const PropertySet) };
out.define(
crate::host::PROP_ROD,
vec![
PropValue::Double(1.0),
PropValue::Double(2.0),
PropValue::Double(3.0),
PropValue::Double(4.0),
],
);
}
status::OK
}
unsafe extern "C" fn entry_roi_source(
action: *const c_char,
_: *const c_void,
_: *mut c_void,
out_args: *mut c_void,
) -> c_int {
if action_is(action, crate::host::ACTION_GET_ROI) {
let out = unsafe { &*(out_args as *const PropertySet) };
out.define(
"OfxImageEffectPropRegionOfInterest_Source",
vec![
PropValue::Double(1.0),
PropValue::Double(2.0),
PropValue::Double(3.0),
PropValue::Double(4.0),
],
);
}
status::OK
}
#[test]
fn region_of_definition_and_roi_paths() {
let inst = make_instance(entry_rod_ok, vec![]);
let rod = inst.get_region_of_definition(0.5, scale()).expect("rod");
assert_eq!((rod.x1, rod.y1, rod.x2, rod.y2), (1.0, 2.0, 3.0, 4.0));
let inst = make_instance(entry_err, vec![]);
assert!(inst.get_region_of_definition(0.0, scale()).is_err());
let inst = make_instance(entry_roi_source, vec![clip("Source"), clip("Output")]);
let region = OfxRectD {
x1: 10.0,
y1: 11.0,
x2: 12.0,
y2: 13.0,
};
let rois = inst.get_regions_of_interest(0.0, scale(), region).expect("rois");
assert_eq!(rois.len(), 2);
assert_eq!(
(rois[0].x1, rois[0].y1, rois[0].x2, rois[0].y2),
(1.0, 2.0, 3.0, 4.0)
);
// 插件未回写的 clip 保持 out args 的预置零值(per-clip 预定义)。
assert_eq!(
(rois[1].x1, rois[1].y1, rois[1].x2, rois[1].y2),
(0.0, 0.0, 0.0, 0.0)
);
let inst = make_instance(entry_err, vec![clip("Source")]);
assert!(inst.get_regions_of_interest(0.0, scale(), region).is_err());
}
// ---- isIdentity -------------------------------------------------------
unsafe extern "C" fn entry_identity_source(
action: *const c_char,
_: *const c_void,
_: *mut c_void,
out_args: *mut c_void,
) -> c_int {
if action_is(action, crate::host::ACTION_IS_IDENTITY) {
let out = unsafe { &*(out_args as *const PropertySet) };
out.set_one(crate::host::PROP_IS_IDENTITY, PropValue::String(cs("Source")));
out.set_one(crate::host::PROP_TIME, PropValue::Double(2.5));
}
status::OK
}
unsafe extern "C" fn entry_identity_time_int(
action: *const c_char,
_: *const c_void,
_: *mut c_void,
out_args: *mut c_void,
) -> c_int {
if action_is(action, crate::host::ACTION_IS_IDENTITY) {
let out = unsafe { &*(out_args as *const PropertySet) };
out.set_one(crate::host::PROP_IS_IDENTITY, PropValue::String(cs("Source")));
out.set_one(crate::host::PROP_TIME, PropValue::Int(3));
}
status::OK
}
#[test]
fn is_identity_paths() {
let inst = make_instance(entry_identity_source, vec![]);
let (time, name) = inst.is_identity(0.0).unwrap().expect("identity");
assert_eq!(time, 2.5);
assert_eq!(name, "Source");
// 透传时间非 Double → 回退输入时间。
let inst = make_instance(entry_identity_time_int, vec![]);
let (time, name) = inst.is_identity(1.25).unwrap().expect("identity");
assert_eq!(time, 1.25);
assert_eq!(name, "Source");
// 空 identity → None。
let inst = make_instance(entry_ok, vec![]);
assert!(inst.is_identity(0.0).unwrap().is_none());
// 插件失败 → 视为非透传(不致命)。
let inst = make_instance(entry_err, vec![]);
assert!(inst.is_identity(0.0).unwrap().is_none());
}
// ---- render / render_gl ----------------------------------------------
unsafe extern "C" fn progress_cb(_: f64, _: *mut c_void) -> c_int {
0
}
struct NoopUi;
impl crate::progress::UiProgressReporter for NoopUi {
fn update(&mut self, _progress: f64) -> bool {
true
}
}
struct FakeGpu;
impl oak_core::backend::GpuContextLike for FakeGpu {
fn kind(&self) -> oak_core::backend::BackendKind {
oak_core::backend::BackendKind::Cpu
}
fn destroy_texture(&self, _token: u64) {}
fn upload(&self, _token: u64, _frame: &oak_core::texture::Frame) -> oak_render::error::Result<()> {
Ok(())
}
fn download(&self, _token: u64) -> oak_render::error::Result<oak_core::texture::Frame> {
Ok(oak_core::texture::Frame::new())
}
fn blit(
&self,
_src: u64,
_dst: u64,
_processor: Option<&oak_core::color::ColorProcessor>,
) -> oak_render::error::Result<()> {
Ok(())
}
}
fn fake_renderer() -> crate::render::Renderer {
Arc::new(FakeGpu)
}
#[test]
fn render_paths_and_progress_reporters() {
// facade 取消标记 → 入口即短路。
let inst = make_instance(entry_err, vec![]);
inst.cancel
.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(inst.render(0.0, scale(), window(), out_image()).is_err());
// facade 进度回调分支。
let inst = make_instance(entry_ok, vec![]);
*inst.progress_cb.lock().unwrap_or_else(|e| e.into_inner()) =
Some((progress_cb, 0));
inst.render(0.0, scale(), window(), out_image())
.expect("带回调的 render");
// UI 工厂分支(无回调 + factory 已注册)。
static FACTORY_LOCK: Mutex<()> = Mutex::new(());
let _g = FACTORY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
crate::progress::set_reporter_factory(Some(Arc::new(|_, _| Box::new(NoopUi))));
let inst = make_instance(entry_ok, vec![]);
let result = inst.render(0.0, scale(), window(), out_image());
crate::progress::set_reporter_factory(None);
result.expect("带 UI 工厂的 render");
// 插件 render 失败 → Err。
let inst = make_instance(entry_err, vec![]);
assert!(inst.render(0.0, scale(), window(), out_image()).is_err());
// in-args:GL 开关两态。
let gl = Instance::render_in_args(0.0, scale(), window(), true);
assert!(matches!(
gl.get(crate::host::PROP_GL_ENABLED, 0),
Some(PropValue::Int(1))
));
let cpu = Instance::render_in_args(0.0, scale(), window(), false);
assert!(cpu.get(crate::host::PROP_GL_ENABLED, 0).is_none());
}
unsafe extern "C" fn entry_gl_attach_fails(
action: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
if action_is(action, crate::host::ACTION_GL_CONTEXT_ATTACHED) {
status::ERR_FATAL
} else {
status::OK
}
}
unsafe extern "C" fn entry_gl_render_fails(
action: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
if action_is(action, crate::host::ACTION_RENDER) {
status::ERR_FATAL
} else {
status::OK
}
}
#[test]
fn render_gl_paths() {
// 成功:attach → render → detach;位深列表空 → 默认 F32。
let inst = make_instance(entry_ok, vec![]);
inst.render_gl(
0.0,
scale(),
window(),
fake_renderer(),
crate::render::Texture::dummy(),
Some(7),
)
.expect("GL render");
assert!(crate::suites::gl_ctx().is_none(), "GL 上下文已清理");
// 插件声明 F32 位深列表 → pick 命中分支。
let inst = make_instance(entry_ok, vec![]);
inst.plugin.descriptor.props.define(
crate::host::PROP_GL_PIXEL_DEPTH,
vec![PropValue::String(cs("OfxBitDepthFloat"))],
);
inst.render_gl(
0.0,
scale(),
window(),
fake_renderer(),
crate::render::Texture::dummy(),
None,
)
.expect("声明位深的 GL render");
// attach 失败 → 清理 + Err。
let inst = make_instance(entry_gl_attach_fails, vec![]);
assert!(inst
.render_gl(
0.0,
scale(),
window(),
fake_renderer(),
crate::render::Texture::dummy(),
None,
)
.is_err());
assert!(crate::suites::gl_ctx().is_none());
// render 失败 → detach 仍发 + Err。
let inst = make_instance(entry_gl_render_fails, vec![]);
assert!(inst
.render_gl(
0.0,
scale(),
window(),
fake_renderer(),
crate::render::Texture::dummy(),
None,
)
.is_err());
// 取消 → 入口即短路。
let inst = make_instance(entry_ok, vec![]);
inst.cancel
.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(inst
.render_gl(
0.0,
scale(),
window(),
fake_renderer(),
crate::render::Texture::dummy(),
None,
)
.is_err());
}
// ---- output colourspace ----------------------------------------------
unsafe extern "C" fn entry_colourspace_value(
action: *const c_char,
_: *const c_void,
_: *mut c_void,
out_args: *mut c_void,
) -> c_int {
if action_is(action, crate::host::ACTION_GET_OUTPUT_COLOURSPACE) {
let out = unsafe { &*(out_args as *const PropertySet) };
out.set_one(
crate::host::PROP_CLIP_COLOURSPACE,
PropValue::String(cs("ACEScg")),
);
}
status::OK
}
unsafe extern "C" fn entry_colourspace_cross(
action: *const c_char,
_: *const c_void,
_: *mut c_void,
out_args: *mut c_void,
) -> c_int {
if action_is(action, crate::host::ACTION_GET_OUTPUT_COLOURSPACE) {
let out = unsafe { &*(out_args as *const PropertySet) };
out.set_one(
crate::host::PROP_CLIP_COLOURSPACE,
PropValue::String(cs("OfxColourspace_Source")),
);
}
status::OK
}
unsafe extern "C" fn entry_colourspace_int(
action: *const c_char,
_: *const c_void,
_: *mut c_void,
out_args: *mut c_void,
) -> c_int {
if action_is(action, crate::host::ACTION_GET_OUTPUT_COLOURSPACE) {
let out = unsafe { &*(out_args as *const PropertySet) };
out.set_one(crate::host::PROP_CLIP_COLOURSPACE, PropValue::Int(1));
}
status::OK
}
#[test]
fn output_colourspace_paths() {
// OK + 直值。
let inst = make_instance(entry_colourspace_value, vec![clip("Source"), clip("Output")]);
assert_eq!(
inst.get_output_colourspace(&["sRGB".to_string()]).unwrap(),
"ACEScg"
);
// 写回 Output clip。
inst.set_output_colourspace("sRGB");
assert_eq!(
prop_string(
&inst.clips.iter().find(|c| c.name == "Output").unwrap().props,
crate::host::PROP_CLIP_COLOURSPACE
)
.as_deref(),
Some("sRGB")
);
// 交叉引用解析为所引 clip 的实际色彩空间。
let inst = make_instance(entry_colourspace_cross, vec![clip("Source"), clip("Output")]);
let expected = prop_string(&inst.clips[0].props, crate::host::PROP_CLIP_COLOURSPACE).unwrap();
assert_eq!(inst.get_output_colourspace(&[]).unwrap(), expected);
// 所引 clip 的色彩空间类型不符 → 原样返回引用串。
inst.clips[0]
.props
.set_one(crate::host::PROP_CLIP_COLOURSPACE, PropValue::Int(1));
assert_eq!(
inst.get_output_colourspace(&[]).unwrap(),
"OfxColourspace_Source"
);
// 查无引用 clip / 非引用原样返回。
assert_eq!(
inst.resolve_colourspace("OfxColourspace_Nope".into()),
"OfxColourspace_Nope"
);
assert_eq!(inst.resolve_colourspace("sRGB".into()), "sRGB");
// OK 但空值 → Err;OK 但非 String → Err。
let inst = make_instance(entry_ok, vec![clip("Output")]);
assert!(inst.get_output_colourspace(&[]).is_err());
let inst = make_instance(entry_colourspace_int, vec![clip("Output")]);
assert!(inst.get_output_colourspace(&[]).is_err());
// ReplyDefault:第一个输入 clip 的色彩空间。
let inst = make_instance(entry_default, vec![clip("Source"), clip("Output")]);
let expected = prop_string(&inst.clips[0].props, crate::host::PROP_CLIP_COLOURSPACE).unwrap();
assert_eq!(inst.get_output_colourspace(&[]).unwrap(), expected);
// 无输入 clip → 空串。
let inst = make_instance(entry_default, vec![clip("Output")]);
assert_eq!(inst.get_output_colourspace(&[]).unwrap(), "");
// 输入 clip 无 colourspace 属性 → 空串。
let inst = make_instance(entry_default, vec![clip("Source")]);
inst.clips[0]
.props
.remove(crate::host::PROP_CLIP_COLOURSPACE);
assert_eq!(inst.get_output_colourspace(&[]).unwrap(), "");
// 其它状态码 → Err。
let inst = make_instance(entry_err, vec![clip("Output")]);
assert!(inst.get_output_colourspace(&[]).is_err());
// 无 Output clip → set_output_colourspace no-op。
let inst = make_instance(entry_ok, vec![clip("Source")]);
inst.set_output_colourspace("x");
}
// ---- sequence render --------------------------------------------------
unsafe extern "C" fn entry_begin_fails(
action: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
if action_is(action, crate::host::ACTION_BEGIN_SEQUENCE) {
status::ERR_FATAL
} else {
status::OK
}
}
unsafe extern "C" fn entry_end_fails(
action: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
if action_is(action, crate::host::ACTION_END_SEQUENCE) {
status::ERR_FATAL
} else {
status::OK
}
}
#[test]
fn sequence_render_paths() {
let range = OfxRangeD {
min: 1.0,
max: 10.0,
};
let inst = make_instance(entry_ok, vec![]);
inst.begin_sequence_render(range).expect("begin");
assert_eq!(
inst.sequence_range
.lock()
.unwrap_or_else(|e| e.into_inner())
.map(|r| r.min),
Some(1.0)
);
inst.end_sequence_render(range).expect("end");
assert!(inst
.sequence_range
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_none());
// GL 变体(in args 带 OpenGLEnabled)。
let inst = make_instance(entry_ok, vec![]);
inst.begin_sequence_render_gl(range).expect("gl begin");
inst.end_sequence_render_gl(range).expect("gl end");
// 失败状态 → Err(end 仍清 range)。
let inst = make_instance(entry_begin_fails, vec![]);
assert!(inst.begin_sequence_render(range).is_err());
let inst = make_instance(entry_end_fails, vec![]);
assert!(inst.end_sequence_render(range).is_err());
assert!(inst
.sequence_range
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_none());
// in-args:GL 开关两态。
let gl = Instance::sequence_in_args(range, true);
assert!(matches!(
gl.get(crate::host::PROP_GL_ENABLED, 0),
Some(PropValue::Int(1))
));
let cpu = Instance::sequence_in_args(range, false);
assert!(cpu.get(crate::host::PROP_GL_ENABLED, 0).is_none());
}
// ---- instanceChanged / interact --------------------------------------
static CHANGED_REASONS: Mutex<Vec<String>> = Mutex::new(Vec::new());
unsafe extern "C" fn entry_record_reason(
action: *const c_char,
_: *const c_void,
in_args: *mut c_void,
_: *mut c_void,
) -> c_int {
if action_is(action, crate::host::ACTION_INSTANCE_CHANGED) {
let props = unsafe { &*(in_args as *const PropertySet) };
if let Some(PropValue::String(s)) = props.get(crate::host::PROP_CHANGE_REASON, 0) {
CHANGED_REASONS
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(s.to_string_lossy().into_owned());
}
}
status::OK
}
#[test]
fn instance_changed_reasons_and_failure() {
CHANGED_REASONS
.lock()
.unwrap_or_else(|e| e.into_inner())
.clear();
let inst = make_instance(entry_record_reason, vec![]);
inst.instance_changed("gain", ChangeReason::UserEdited, 0.0, scale())
.unwrap();
inst.instance_changed("gain", ChangeReason::PluginEdited, 0.0, scale())
.unwrap();
inst.instance_changed("gain", ChangeReason::TimeChanged, 0.0, scale())
.unwrap();
assert_eq!(
*CHANGED_REASONS.lock().unwrap_or_else(|e| e.into_inner()),
vec![
"OfxChangeUserEdited".to_string(),
"OfxChangePluginEdited".to_string(),
"OfxChangeTime".to_string(),
]
);
let inst = make_instance(entry_err, vec![]);
assert!(inst
.instance_changed("gain", ChangeReason::UserEdited, 0.0, scale())
.is_err());
}
unsafe extern "C" fn overlay_default(
_: *const c_char,
_: *const c_void,
_: *mut c_void,
_: *mut c_void,
) -> c_int {
status::REPLY_DEFAULT
}
#[test]
fn new_interact_and_describe_paths() {
// 未创建 → interact None / describe BadHandle。
let inst = make_instance(entry_ok, vec![]);
assert!(inst.interact().is_none());
assert_eq!(inst.describe_interact(), status::ERR_BAD_HANDLE);
// ReplyDefault 且未声明 overlay → 无 interact。
let inst = make_instance(entry_default, vec![]);
assert!(inst.new_interact().is_none());
// main entry 接受 NewInteract → 创建;describe 走 main entry。
let inst = make_instance(entry_ok, vec![]);
let interact = inst.new_interact().expect("new_interact");
assert_eq!(inst.describe_interact(), status::OK);
assert!(inst.interact().is_some());
assert_eq!(Arc::strong_count(&interact), 2);
// 声明 overlay 入口 + ReplyDefault → 仍创建(官方 describe/create 序列)。
let inst = make_instance(overlay_default, vec![]);
inst.plugin.descriptor.props.set_one(
crate::host::PROP_OVERLAY_INTERACT_V2,
PropValue::Pointer(overlay_default as *const () as *mut c_void),
);
assert!(inst.new_interact().is_some());
assert_eq!(inst.describe_interact(), status::REPLY_DEFAULT);
}
// ---- read_rect --------------------------------------------------------
#[test]
fn read_rect_rejects_wrong_types_and_missing() {
let props = PropertySet::new();
assert!(read_rect(&props, "missing").is_none());
props.define(
"r",
vec![
PropValue::Int(1),
PropValue::Double(2.0),
PropValue::Double(3.0),
PropValue::Double(4.0),
],
);
assert!(read_rect(&props, "r").is_none());
props.define(
"r",
vec![
PropValue::Double(1.0),
PropValue::Int(2),
PropValue::Double(3.0),
PropValue::Double(4.0),
],
);
assert!(read_rect(&props, "r").is_none());
props.define(
"r",
vec![
PropValue::Double(1.0),
PropValue::Double(2.0),
PropValue::Int(3),
PropValue::Double(4.0),
],
);
assert!(read_rect(&props, "r").is_none());
props.define(
"r",
vec![
PropValue::Double(1.0),
PropValue::Double(2.0),
PropValue::Double(3.0),
PropValue::Int(4),
],
);
assert!(read_rect(&props, "r").is_none());
props.define(
"r",
vec![
PropValue::Double(1.0),
PropValue::Double(2.0),
PropValue::Double(3.0),
PropValue::Double(4.0),
],
);
assert_eq!(read_rect(&props, "r").map(|r| r.x2), Some(3.0));
}
// ---- trace probes -----------------------------------------------------
/// OAK_OFX_TRACE 打开的探针分支(clip prefs/RoD/RoI/isIdentity 与
/// isIdentity 失败路径的日志)。
///
/// The probe's only effect besides the normal action result is a trace
/// line on stderr (no test-installable sink), so each call is pinned by
/// its return value; the env override is restored before the assertions
/// run so a failing expectation cannot leak `OAK_OFX_TRACE` into the
/// next serialized test.
#[test]
fn trace_probe_branches() {
static TRACE_LOCK: Mutex<()> = Mutex::new(());
let _g = TRACE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var_os("OAK_OFX_TRACE");
std::env::set_var("OAK_OFX_TRACE", "1");
let inst = make_instance(entry_ok, vec![clip("Source"), clip("Output")]);
let prefs = inst.get_clip_preferences();
let rod = inst.get_region_of_definition(0.0, scale());
let region = OfxRectD {
x1: 1.0,
y1: 2.0,
x2: 3.0,
y2: 4.0,
};
let rois = inst.get_regions_of_interest(0.0, scale(), region);
let identity = inst.is_identity(0.0);
let bad = make_instance(entry_err, vec![]);
let bad_identity = bad.is_identity(0.0);
match prev {
Some(v) => std::env::set_var("OAK_OFX_TRACE", v),
None => std::env::remove_var("OAK_OFX_TRACE"),
}
// entry_ok writes nothing back: every negotiated value stays at the
// host pre-set default, which is what the probe branch observes.
let prefs = prefs.expect("entry_ok answers OK");
assert_eq!(prefs.output_components, "OfxImageComponentRGBA");
assert_eq!(prefs.output_bit_depth, "OfxBitDepthFloat");
assert_eq!(prefs.pixel_aspect_ratio, 1.0);
assert_eq!(prefs.frame_rate, 1.0);
assert_eq!(prefs.field, "");
let rod = rod.expect("getRoD answers OK");
assert_eq!(
(rod.x1, rod.y1, rod.x2, rod.y2),
(0.0, 0.0, 0.0, 0.0),
"an untouched pre-defined RoD stays at the zero default"
);
// The plugin leaves the pre-defined per-clip ROI properties alone:
// the result is those zero defaults, not the input region.
let rois = rois.expect("getRoI answers OK");
assert_eq!(rois.len(), 2, "one RoI per clip");
assert!(
rois.iter()
.all(|r| (r.x1, r.y1, r.x2, r.y2) == (0.0, 0.0, 0.0, 0.0)),
"untouched RoIs stay at the pre-defined zeros: {rois:?}"
);
// entry_ok leaves kOfxImageEffectPropIsIdentity empty: an OK action
// without an identity declaration means "not an identity".
assert!(
identity.expect("isIdentity answers OK").is_none(),
"an empty identity property means render"
);
// entry_err fails the action: the failure is logged and answered
// None (never propagated as an error).
assert!(
bad_identity.expect("failed isIdentity is tolerated").is_none(),
"a failed isIdentity falls back to render"
);
}
}
File diff suppressed because it is too large Load Diff
+804 -5
View File
@@ -620,11 +620,810 @@ pub(crate) fn write_output_frame(dst: &mut Texture, image: &Image) -> crate::err
ctx.upload(*token, &frame)
.map_err(|e| Error::Failed(format!("输出纹理上传失败:{e}")))?;
}
// 未解析的平面纹理不会成为插件输出目标(解码路径会先解析)。
Texture::Planar(_) => {
return Err(Error::Failed("平面纹理不能作为插件输出目标".into()));
}
}
Ok(())
}
ctx.upload(*token, &frame)
.map_err(|e| Error::Failed(format!("输出纹理上传失败:{e}")))?;
}
+ // 未解析的平面纹理不会成为插件输出目标(解码路径会先解析)。
+ Texture::Planar(_) => {
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::{ClipDescriptor, EffectDescriptor};
use crate::error::Error;
use crate::instance::Instance;
use crate::param::{ParamDef, ParamInstance, ParamSetInstance, ParamValue};
use crate::property::{PropertySet, Value};
fn cs(s: &str) -> std::ffi::CString {
std::ffi::CString::new(s).unwrap()
}
fn f32_frame(w: i32, h: i32, fill: f32) -> crate::render::Frame {
let params = render::VideoParams {
width: w,
height: h,
format: render::PIXEL_FORMAT_F32,
..Default::default()
};
let mut frame = crate::render::Frame::new();
frame.set_video_params(params);
assert!(frame.allocate());
for c in frame.data.as_chunks_mut::<4>().0 {
c.copy_from_slice(&fill.to_le_bytes());
}
frame
}
fn f32_texture(w: i32, h: i32, fill: f32) -> Texture {
Texture::wrap_frame(f32_frame(w, h, fill))
}
fn u8_texture(w: i32, h: i32) -> Texture {
let params = render::VideoParams {
width: w,
height: h,
format: render::PIXEL_FORMAT_U8,
..Default::default()
};
let pixels = vec![0u8; w as usize * h as usize * 4];
render::texture_create(&params, &pixels, 0).expect("U8 纹理应可建")
}
fn f32_image(w: i32, h: i32, fill: f32) -> Image {
let mut img = Image::allocate(
crate::image::BitDepth::Float,
crate::image::Components::Rgba,
OfxRectD {
x1: 0.0,
y1: 0.0,
x2: w as f64,
y2: h as f64,
},
);
for c in img.pixels_mut().as_chunks_mut::<4>().0 {
c.copy_from_slice(&fill.to_le_bytes());
}
img
}
/// 测试入口:按实例 props 里的 test.* 配置扮演插件行为。实例句柄
/// (offset 0)即配置存储,避免测试间共享全局状态。
unsafe extern "C" fn test_entry(
action: *const std::ffi::c_char,
handle: *const std::ffi::c_void,
in_args: *mut std::ffi::c_void,
out_args: *mut std::ffi::c_void,
) -> std::ffi::c_int {
let action_name = unsafe { std::ffi::CStr::from_ptr(action) }
.to_string_lossy()
.into_owned();
let inst_props = unsafe {
&*(crate::suites::tag::strip(handle as *mut std::ffi::c_void))
};
let in_args = unsafe { &*(in_args as *const PropertySet) };
let out_args = unsafe { &*(out_args as *const PropertySet) };
if action_name == crate::host::ACTION_GET_CLIP_PREFERENCES {
if let Some(Value::String(comps)) = inst_props.get("test.components", 0) {
out_args.set_one(
&crate::host::clip_pref_prop(crate::host::CLIP_PREF_COMPONENTS, "Output"),
Value::String(comps.clone()),
);
}
if let Some(Value::String(depth)) = inst_props.get("test.depth", 0) {
out_args.set_one(
&crate::host::clip_pref_prop(crate::host::CLIP_PREF_DEPTH, "Output"),
Value::String(depth.clone()),
);
}
} else if action_name == crate::host::ACTION_IS_IDENTITY {
if let Some(Value::String(clip)) = inst_props.get("test.identity_clip", 0) {
if !clip.to_string_lossy().is_empty() {
out_args.set_one(crate::host::PROP_IS_IDENTITY, Value::String(clip.clone()));
if let Some(Value::Double(t)) = inst_props.get("test.identity_time", 0) {
out_args.set_one(crate::host::PROP_TIME, Value::Double(t));
}
}
}
} else if action_name == crate::host::ACTION_RENDER {
// GL 失败注入:仅 GL 模式的 render(in args 带 OpenGLEnabled=1)。
let gl_enabled = matches!(
in_args.get(crate::host::PROP_GL_ENABLED, 0),
Some(Value::Int(1))
);
if gl_enabled && matches!(inst_props.get("test.gl_fail", 0), Some(Value::Int(1))) {
return crate::suites::status::FAILED;
}
if let Some(Value::Int(st)) = inst_props.get("test.render_status", 0) {
return st;
}
}
crate::suites::status::OK
}
#[allow(clippy::vec_box)] // ParamSetInstance 的字段类型即 Vec<Box<ParamInstance>>。
fn make_instance(
desc_props: Vec<(&'static str, Value)>,
inst_props: Vec<(&'static str, Value)>,
clips: &[&str],
params: Vec<Box<ParamInstance>>,
) -> Instance {
let desc = EffectDescriptor::new();
for (k, v) in desc_props {
desc.props.set_one(k, v);
}
let props = PropertySet::new();
for (k, v) in inst_props {
props.set_one(k, v);
}
Instance {
props,
plugin: std::sync::Arc::new(crate::host::Plugin {
identifier: "org.oak.test-render-driver".into(),
version: (1, 0),
bundle_path: std::path::PathBuf::new(),
contexts: vec![],
descriptor: desc,
lib: std::ptr::null_mut(),
entry: test_entry,
ofx_plugin: std::ptr::null_mut(),
unloaded: std::sync::atomic::AtomicBool::new(false),
}),
context: "OfxImageEffectContextFilter".into(),
params: ParamSetInstance { params },
clips: clips
.iter()
.map(|n| Box::new(crate::clip::ClipInstance::from_descriptor(&ClipDescriptor::new(n))))
.collect(),
node_identity: std::sync::atomic::AtomicUsize::new(0),
// 置位销毁门:测试实例 drop 时不再向假插件发 destroyInstance。
destroyed: std::sync::atomic::AtomicBool::new(true),
sequence_range: std::sync::Mutex::new(None),
progress_cb: std::sync::Mutex::new(None),
cancel: std::sync::atomic::AtomicBool::new(false),
edit: std::sync::Mutex::new(crate::instance::EditTransaction::new()),
render_lock: std::sync::Mutex::new(()),
interact: std::sync::Mutex::new(None),
}
}
fn job(dst: Texture, src: Option<Texture>) -> RenderJob {
RenderJob {
time: 0.0,
dst,
src,
effect_input_id: Some("Source".into()),
inputs: Vec::new(),
values: Vec::new(),
renderer: None,
clear_destination: false,
interactive: false,
}
}
fn window(w: f64, h: f64) -> OfxRectD {
OfxRectD {
x1: 0.0,
y1: 0.0,
x2: w,
y2: h,
}
}
fn first_pixel(tex: &Texture) -> [f32; 4] {
let frame = render::texture_get_frame(tex).unwrap();
let mut out = [0f32; 4];
for (i, v) in out.iter_mut().enumerate() {
*v = f32::from_le_bytes(frame.data[i * 4..i * 4 + 4].try_into().unwrap());
}
out
}
/// RenderJob::default:全字段的默认值。
#[test]
fn render_job_default() {
let j = RenderJob::default();
assert_eq!(j.time, 0.0);
assert!(j.dst.is_dummy());
assert!(j.src.is_none());
assert!(j.effect_input_id.is_none());
assert!(j.inputs.is_empty());
assert!(j.values.is_empty());
assert!(j.renderer.is_none());
assert!(!j.clear_destination);
assert!(!j.interactive);
}
/// read_dst:非 F32 拒绝;0 尺寸 Invalid;正常返回 w/h。
#[test]
fn read_dst_validation() {
let u8tex = u8_texture(2, 2);
assert!(matches!(read_dst(&u8tex), Err(Error::Failed(_))));
let params = render::VideoParams {
format: render::PIXEL_FORMAT_F32,
..Default::default()
};
let mut frame = crate::render::Frame::new();
frame.set_video_params(params);
let zero = Texture::wrap_frame(frame);
assert!(matches!(read_dst(&zero), Err(Error::Invalid)));
let (p, w, h) = read_dst(&f32_texture(3, 2, 0.0)).unwrap();
assert_eq!((p.width, p.height), (3, 2));
assert_eq!((w, h), (3.0, 2.0));
}
/// pixel_aspect:den == 0 回退 1.0,否则比值。
#[test]
fn pixel_aspect_zero_den_falls_back() {
let mut p = render::VideoParams {
pixel_aspect_num: 4,
pixel_aspect_den: 0,
..Default::default()
};
assert_eq!(pixel_aspect(&p), 1.0);
p.pixel_aspect_num = 3;
p.pixel_aspect_den = 2;
assert_eq!(pixel_aspect(&p), 1.5);
}
/// plugin_supports_opengl:"true"/"needed" 为真;其余/缺失/非字符串
/// 为假。
#[test]
fn plugin_supports_opengl_matrix() {
let inst = make_instance(vec![], vec![], &[], vec![]);
assert!(!plugin_supports_opengl(&inst), "缺失属性 → false");
inst.plugin.descriptor.props.set_one(
crate::host::PROP_GL_RENDER_SUPPORTED,
Value::String(cs("true")),
);
assert!(plugin_supports_opengl(&inst));
inst.plugin.descriptor.props.set_one(
crate::host::PROP_GL_RENDER_SUPPORTED,
Value::String(cs("needed")),
);
assert!(plugin_supports_opengl(&inst));
inst.plugin.descriptor.props.set_one(
crate::host::PROP_GL_RENDER_SUPPORTED,
Value::String(cs("false")),
);
assert!(!plugin_supports_opengl(&inst));
inst.plugin
.descriptor
.props
.set_one(crate::host::PROP_GL_RENDER_SUPPORTED, Value::Int(1));
assert!(!plugin_supports_opengl(&inst), "非字符串 → false");
}
/// pick_input:effect_input_id 命中 src;命中但 src 缺失继续;
/// inputs 表命中;Source 回退;未知 clip → None。
#[test]
fn pick_input_selection_rules() {
let src = f32_texture(1, 1, 0.25);
let matte = f32_texture(1, 1, 0.5);
let mut j = job(f32_texture(1, 1, 0.0), Some(src.clone()));
assert!(pick_input("Source", &j).is_some());
// effect_input_id 命中但 src 缺失、inputs 表有同名 → 用表。
j.src = None;
j.inputs = vec![("Source".into(), matte.clone())];
assert!(pick_input("Source", &j).is_some());
// inputs 表命中非 Source clip。
j.inputs.push(("Matte".into(), matte.clone()));
assert!(pick_input("Matte", &j).is_some());
// effect_input_id 命中但 src 缺失、无 inputs、非 Source → None。
let j2 = job(f32_texture(1, 1, 0.0), None);
let mut j2 = j2;
j2.effect_input_id = Some("Matte".into());
assert!(pick_input("Matte", &j2).is_none());
// Source 回退(effect_input_id 不是 Source、无 inputs、src 有)。
j2.src = Some(src);
assert!(pick_input("Source", &j2).is_some());
// 未知 clip → None。
assert!(pick_input("Nope", &j2).is_none());
}
/// begin/endSequenceRender:CPU 路径走非 GL 变体;GL 路径在无 GL
/// 环境下明确失败(有 GL 环境走 GL 变体)。
#[test]
fn begin_end_sequence_cpu_and_gl() {
let inst = make_instance(vec![], vec![], &[], vec![]);
let range = OfxRangeD {
min: 0.0,
max: 10.0,
};
assert!(begin_sequence(&inst, range, None).is_ok());
assert!(end_sequence(&inst, range, None).is_ok());
let renderer: Renderer = std::sync::Arc::new(crate::render::GlKindMarker);
let began = begin_sequence(&inst, range, Some(renderer.clone()));
if began.is_ok() {
assert!(end_sequence(&inst, range, Some(renderer)).is_ok());
} else {
println!("SKIP: GL 上下文不可用——GL 序列括号分支在无 GL 环境不可达");
assert!(end_sequence(&inst, range, Some(renderer)).is_err());
}
}
/// render_frame CPU 全链路:输入收集、协商、RoD/RoI、render 与
/// 输出装配;返回的 RoI 列表与输入 clip 配对(不含 Output)。
#[test]
fn render_frame_cpu_happy_path() {
let inst = make_instance(vec![], vec![], &["Source", "Output"], vec![]);
let j = job(f32_texture(2, 2, 0.25), Some(f32_texture(2, 2, 0.75)));
let (out, rois) = render_frame(&inst, &j).expect("CPU render_frame 应成功");
assert_eq!(rois.len(), 1, "Output clip 不参与 RoI 列表");
assert_eq!(rois[0].0, "Source");
// 输入 clip 已连接(property suite 可读)。
assert!(matches!(
inst.clips[0].props.get(crate::host::PROP_CLIP_CONNECTED, 0),
Some(Value::Int(1))
));
// 假插件 render 未写像素 → 输出为零填充(Image::allocate 零初始化)。
assert_eq!(first_pixel(&out), [0.0, 0.0, 0.0, 0.0]);
}
/// render_frame:取消标记在任何插件调用前短路。
#[test]
fn render_frame_cancel_short_circuits() {
let inst = make_instance(vec![], vec![], &["Source", "Output"], vec![]);
inst.cancel
.store(true, std::sync::atomic::Ordering::Relaxed);
let j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5)));
assert!(matches!(render_frame(&inst, &j), Err(Error::Failed(_))));
}
/// render_frame:目标纹理校验失败时在插件调用前返回。
#[test]
fn render_frame_rejects_bad_destination() {
let inst = make_instance(vec![], vec![], &["Source", "Output"], vec![]);
let j = job(u8_texture(2, 2), None);
assert!(matches!(render_frame(&inst, &j), Err(Error::Failed(_))));
let params = render::VideoParams {
format: render::PIXEL_FORMAT_F32,
..Default::default()
};
let mut frame = crate::render::Frame::new();
frame.set_video_params(params);
let j = job(Texture::wrap_frame(frame), None);
assert!(matches!(render_frame(&inst, &j), Err(Error::Invalid)));
}
/// render_frame:实例无 Output clip → 明确失败。
#[test]
fn render_frame_requires_output_clip() {
let inst = make_instance(vec![], vec![], &["Source"], vec![]);
let j = job(f32_texture(2, 2, 0.0), None);
assert!(matches!(render_frame(&inst, &j), Err(Error::Failed(_))));
}
/// render_frame:协商分量 RGBA/RGB/Alpha 与未知值。
#[test]
fn render_frame_component_negotiation() {
for comps in [
"OfxImageComponentRGB",
"OfxImageComponentAlpha",
"OfxImageComponentRGBA",
] {
let inst = make_instance(
vec![],
vec![("test.components", Value::String(cs(comps)))],
&["Source", "Output"],
vec![],
);
let j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5)));
render_frame(&inst, &j).expect("已知分量应可渲染");
let got = inst.clips[1]
.props
.get(crate::image::K_IMAGE_EFFECT_PROP_COMPONENTS, 0);
assert!(
matches!(&got, Some(Value::String(s)) if s.to_string_lossy() == comps),
"{comps} 应写回 Output clip"
);
}
// 未知分量 → 协商失败。
let inst = make_instance(
vec![],
vec![("test.components", Value::String(cs("OfxImageComponentBogus")))],
&["Source", "Output"],
vec![],
);
let j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5)));
assert!(matches!(render_frame(&inst, &j), Err(Error::Failed(_))));
}
/// render_frame:协商位深 Byte/Short/Half/Float 与未知值。低位深
/// 输出经 convert_depth 转回 F32 后装配;未知位深在 render 前失败。
#[test]
fn render_frame_depth_negotiation() {
for depth in [
"OfxBitDepthByte",
"OfxBitDepthShort",
"OfxBitDepthHalf",
"OfxBitDepthFloat",
] {
let inst = make_instance(
vec![],
vec![("test.depth", Value::String(cs(depth)))],
&["Source", "Output"],
vec![],
);
let j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5)));
render_frame(&inst, &j).unwrap_or_else(|e| panic!("{depth} 应可渲染:{e}"));
// 协商结果写回 Output clip(Format 号经 set_video_params 再现)。
let got = inst.clips[1]
.props
.get(crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH, 0);
assert!(
matches!(&got, Some(Value::String(s)) if s.to_string_lossy() == depth),
"{depth} 应写回 Output clip"
);
// 返回纹理恒为 F32(管线工作格式)。
assert_eq!(
render::texture_get_params(&j.dst).format,
render::PIXEL_FORMAT_F32
);
}
let inst = make_instance(
vec![],
vec![("test.depth", Value::String(cs("OfxBitDepthBogus")))],
&["Source", "Output"],
vec![],
);
let j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5)));
assert!(matches!(render_frame(&inst, &j), Err(Error::Failed(_))));
}
/// render_frame:render action 失败原样上抛。
#[test]
fn render_frame_propagates_render_failure() {
let inst = make_instance(
vec![],
vec![("test.render_status", Value::Int(crate::suites::status::FAILED))],
&["Source", "Output"],
vec![],
);
let j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5)));
assert!(matches!(render_frame(&inst, &j), Err(Error::Failed(_))));
}
/// render_frame:isIdentity 命中 → 透传所引输入 clip 的帧;引用
/// 未知 clip → 明确失败。
#[test]
fn render_frame_identity_passthrough() {
let inst = make_instance(
vec![],
vec![
("test.identity_clip", Value::String(cs("Source"))),
("test.identity_time", Value::Double(7.0)),
],
&["Source", "Output"],
vec![],
);
let src_tex = f32_texture(2, 2, 0.5);
inst.clips[0].set_input_texture(Some(src_tex.clone()), 0.0);
let j = job(f32_texture(2, 2, 0.25), Some(src_tex));
let (out, _) = render_frame(&inst, &j).expect("透传应成功");
assert_eq!(first_pixel(&out), [0.5, 0.5, 0.5, 0.5]);
// 引用未知 clip → passthrough 失败。
let inst = make_instance(
vec![],
vec![("test.identity_clip", Value::String(cs("Nope")))],
&["Source", "Output"],
vec![],
);
let j = job(
f32_texture(2, 2, 0.25),
Some(f32_texture(2, 2, 0.5)),
);
assert!(matches!(render_frame(&inst, &j), Err(Error::Failed(_))));
}
/// render_frame:GL 渲染器 + 插件声明 GL 支持。无 GL 环境回退 CPU
/// (决策链的 gl 名门失败);有 GL 环境走真实 GL 路径。
#[test]
fn render_frame_gl_decision_and_path() {
let inst = make_instance(
vec![(crate::host::PROP_GL_RENDER_SUPPORTED, Value::String(cs("true")))],
vec![],
&["Source", "Output"],
vec![],
);
let mut j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5)));
j.renderer = Some(std::sync::Arc::new(crate::render::GlKindMarker));
if crate::gl_bridge::gl_available() {
// 真实 GL 路径:回读内容未清屏(未定义像素值)——只断言
// 装配成功与输出格式/尺寸。
let (out, _) = render_frame(&inst, &j).expect("GL 路径应成功");
let params = render::texture_get_params(&out);
assert_eq!(
(params.width, params.height, params.format),
(2, 2, render::PIXEL_FORMAT_F32)
);
} else {
println!("SKIP: GL 不可用(Linux/Windows stub)——仅覆盖 use_opengl 回退决策");
let (out, _) = render_frame(&inst, &j).expect("GL 不可用应回退 CPU");
assert_eq!(first_pixel(&out), [0.0, 0.0, 0.0, 0.0]);
}
}
/// render_frame:GL render action 失败 → 回退 CPU(GL 环境下)。
#[test]
fn render_frame_gl_failure_falls_back_to_cpu() {
if !crate::gl_bridge::gl_available() {
println!("SKIP: GL 不可用——GL 失败回退 CPU 分支不可达");
return;
}
let inst = make_instance(
vec![(crate::host::PROP_GL_RENDER_SUPPORTED, Value::String(cs("true")))],
vec![("test.gl_fail", Value::Int(1))],
&["Source", "Output"],
vec![],
);
let mut j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5)));
j.renderer = Some(std::sync::Arc::new(crate::render::GlKindMarker));
let (out, _) = render_frame(&inst, &j).expect("GL 失败应回退 CPU 成功");
assert_eq!(first_pixel(&out), [0.0, 0.0, 0.0, 0.0]);
}
/// render_gl_frame:无 GL 上下文时明确失败(Linux/Windows stub 或
/// CGL 创建失败);本机 GL 可用时跳过(该用例只验证失败路径)。
#[test]
fn render_gl_frame_reports_unavailable_context() {
if crate::gl_bridge::gl_available() {
println!("SKIP: 本机 GL 可用——该用例只验证无 GL 时的明确失败");
return;
}
let inst = make_instance(vec![], vec![], &["Source", "Output"], vec![]);
let j = job(f32_texture(2, 2, 0.0), None);
let params = read_dst(&j.dst).unwrap().0;
let res = render_gl_frame(
&inst,
&j,
RenderScale { x: 1.0, y: 1.0 },
window(2.0, 2.0),
2.0,
2.0,
&params,
);
assert!(res.is_err(), "无 GL 环境 render_gl_frame 应失败");
}
/// apply_param_overrides:未知参数名与类型不符跳过;Double 标量
/// 的 NaN/Inf 清洗与 Min/Max 钳制。
#[test]
fn apply_param_overrides_scrubs_and_clamps() {
let def = ParamDef::new("gain", crate::param::TYPE_DOUBLE);
def.props
.set_one(crate::param::P_DEFAULT, Value::Double(0.25));
def.props.set_one(crate::param::P_MIN, Value::Double(-1.0));
def.props.set_one(crate::param::P_MAX, Value::Double(1.0));
let inst = make_instance(
vec![],
vec![],
&[],
vec![Box::new(ParamInstance::from_def(def))],
);
let gain = || inst.params.find("gain").unwrap().get();
// 未知 key + 类型不符的 key → 都跳过,值不变。
apply_param_overrides(
&inst,
&[
("nope".into(), crate::node::Value::float(2.0)),
("gain".into(), crate::node::Value::int(3)),
],
);
assert_eq!(gain(), ParamValue::Double([0.0, 0.0, 0.0], 1));
// 超上限 → 钳到 max。
apply_param_overrides(&inst, &[("gain".into(), crate::node::Value::float(5.0))]);
assert_eq!(gain(), ParamValue::Double([1.0, 0.0, 0.0], 1));
// 超下限 → 钳到 min。
apply_param_overrides(&inst, &[("gain".into(), crate::node::Value::float(-5.0))]);
assert_eq!(gain(), ParamValue::Double([-1.0, 0.0, 0.0], 1));
// NaN → 回退默认(0.25),再走钳制。
apply_param_overrides(&inst, &[("gain".into(), crate::node::Value::float(f64::NAN))]);
assert_eq!(gain(), ParamValue::Double([0.25, 0.0, 0.0], 1));
// +Inf → 同样回退默认。
apply_param_overrides(&inst, &[("gain".into(), crate::node::Value::float(f64::INFINITY))]);
assert_eq!(gain(), ParamValue::Double([0.25, 0.0, 0.0], 1));
// 多维 Double 不经标量清洗路径:Double2D 直接写入。
let def2 = ParamDef::new("pos", crate::param::TYPE_DOUBLE2D);
let inst = make_instance(
vec![],
vec![],
&[],
vec![Box::new(ParamInstance::from_def(def2))],
);
apply_param_overrides(
&inst,
&[("pos".into(), crate::node::Value::vec(&[1.0, 2.0]))],
);
assert_eq!(
inst.params.find("pos").unwrap().get(),
ParamValue::Double([1.0, 2.0, 0.0], 2)
);
}
/// prop_double:Double/Int 提升/缺失默认 0。
#[test]
fn prop_double_reads_double_int_and_default() {
let set = PropertySet::new();
set.set_one("d", Value::Double(2.5));
set.set_one("i", Value::Int(3));
set.set_one("s", Value::String(cs("x")));
assert_eq!(prop_double(&set, "d", 0), 2.5);
assert_eq!(prop_double(&set, "i", 0), 3.0);
assert_eq!(prop_double(&set, "s", 0), 0.0);
assert_eq!(prop_double(&set, "missing", 0), 0.0);
}
/// write_output_frame:非 F32/尺寸不符错误;CPU 就地写回;GPU 经
/// upload 回写;Planar 在 readback 阶段即失败(Planar 分支防御性
/// 不可达,见测试注释)。
#[test]
fn write_output_frame_validation_and_writeback() {
let image = f32_image(2, 2, 0.5);
let mut u8dst = u8_texture(2, 2);
assert!(matches!(
write_output_frame(&mut u8dst, &image),
Err(Error::Failed(_))
));
let mut baddst = f32_texture(3, 1, 0.0);
assert!(matches!(
write_output_frame(&mut baddst, &image),
Err(Error::Failed(_))
));
let mut cpudst = f32_texture(2, 2, 0.0);
write_output_frame(&mut cpudst, &image).unwrap();
let frame = render::texture_get_frame(&cpudst).unwrap();
assert_eq!(frame.data.as_slice(), image.pixels());
// GPU:download 帧命中尺寸校验后经 upload 回写。
let ctx = std::sync::Arc::new(FakeUpload {
uploads: std::sync::Mutex::new(Vec::new()),
fail: false,
});
let mut gpudst = Texture::gpu(ctx.clone(), 11, 2, 2, oak_core::PixelFormat::F32);
write_output_frame(&mut gpudst, &image).unwrap();
{
let uploads = ctx.uploads.lock().unwrap();
assert_eq!(uploads.len(), 1);
assert_eq!(uploads[0].0, 11);
assert_eq!(uploads[0].1.data.as_slice(), image.pixels());
}
// 上传失败 → Failed。
let ctx = std::sync::Arc::new(FakeUpload {
uploads: std::sync::Mutex::new(Vec::new()),
fail: true,
});
let mut gpudst = Texture::gpu(ctx, 12, 2, 2, oak_core::PixelFormat::F32);
assert!(matches!(
write_output_frame(&mut gpudst, &image),
Err(Error::Failed(_))
));
// Planar:texture_get_frame 在到达 Planar match 分支前即失败
// (to_frame 对未解析平面纹理返回 State)——该防御分支当前
// 不可达,这里锁定"平面目标被拒绝"的行为。
let planar = Texture::wrap_planar(oak_core::texture::PlanarTexture::new(
std::sync::Arc::new(FakeUpload {
uploads: std::sync::Mutex::new(Vec::new()),
fail: false,
}),
oak_core::texture::PlanarFormat::Nv12,
(2, 2),
(1, 2),
oak_core::backend::YuvTransform::bt601_limited(),
(1, 1),
));
let mut planardst = planar;
assert!(matches!(
write_output_frame(&mut planardst, &image),
Err(Error::Failed(_))
));
}
/// zip_rois:跳过 Output、按 clips 顺序配对、RoI 不足时默认零矩形。
#[test]
fn zip_rois_pairs_inputs_only() {
let inst = make_instance(vec![], vec![], &["Source", "Matte", "Output"], vec![]);
let rect = OfxRectD {
x1: 1.0,
y1: 2.0,
x2: 3.0,
y2: 4.0,
};
let zipped = zip_rois(&inst, &[rect]);
assert_eq!(zipped.len(), 2, "Output 不计入");
assert_eq!(zipped[0].0, "Source");
assert_eq!(zipped[0].1.x2, 3.0);
assert_eq!(zipped[1].0, "Matte");
assert_eq!(zipped[1].1.x1, 0.0, "RoI 不足时默认矩形");
let empty = zip_rois(&inst, &[]);
assert_eq!(empty.len(), 2);
assert_eq!(empty[0].1.y2, 0.0);
}
/// OAK_OFX_TRACE 开启时 use_opengl 决策与 CPU 输出 dump 的 trace
/// 分支(打印点覆盖)。串行化并还原环境变量。
#[test]
fn trace_branches_in_render_frame() {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var_os("OAK_OFX_TRACE");
std::env::set_var("OAK_OFX_TRACE", "1");
let body = std::panic::catch_unwind(|| {
// 插件未声明 GL → use_opengl 决策为 false,但 trace 分支
// 在 renderer 是 GL 时执行(打印 raw GL prop)。
let inst = make_instance(vec![], vec![], &["Source", "Output"], vec![]);
let mut j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5)));
j.renderer = Some(std::sync::Arc::new(crate::render::GlKindMarker));
render_frame(&inst, &j).expect("CPU 回退应成功");
});
match prev {
Some(v) => std::env::set_var("OAK_OFX_TRACE", v),
None => std::env::remove_var("OAK_OFX_TRACE"),
}
if let Err(p) = body {
std::panic::resume_unwind(p);
}
}
struct FakeUpload {
uploads: std::sync::Mutex<Vec<(u64, crate::render::Frame)>>,
fail: bool,
}
impl oak_core::backend::GpuContextLike for FakeUpload {
fn kind(&self) -> oak_core::backend::BackendKind {
oak_core::backend::BackendKind::Cpu
}
fn destroy_texture(&self, _token: u64) {}
fn upload(&self, token: u64, frame: &crate::render::Frame) -> oak_render::error::Result<()> {
if self.fail {
return Err(oak_render::error::Error::State);
}
self.uploads
.lock()
.unwrap_or_else(|e| e.into_inner())
.push((token, frame.clone()));
Ok(())
}
fn download(&self, _token: u64) -> oak_render::error::Result<crate::render::Frame> {
Ok(f32_frame(2, 2, 0.0))
}
fn blit(
&self,
_src: u64,
_dst: u64,
_processor: Option<&oak_core::color::ColorProcessor>,
) -> oak_render::error::Result<()> {
Ok(())
}
}
}
+673
View File
@@ -506,6 +506,106 @@ mod tests {
CString::new(s).unwrap()
}
/// GL 渲染 suite 的存活表是进程全局状态:本模块所有触碰它的用例
/// 经此锁串行(否则 purge_leftovers 会踩其他用例的登记条目)。
fn suite_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn clip(name: &str) -> ClipInstance {
ClipInstance::from_descriptor(&crate::descriptor::ClipDescriptor::new(name))
}
fn clip_handle(c: &ClipInstance) -> *mut c_void {
tag::make(&c.props as *const PropertySet, tag::CLIP)
}
fn f32_frame(w: i32, h: i32, fill: f32) -> crate::render::Frame {
let params = crate::render::VideoParams {
width: w,
height: h,
format: crate::render::PIXEL_FORMAT_F32,
..Default::default()
};
let mut frame = crate::render::Frame::new();
frame.set_video_params(params);
assert!(frame.allocate());
for c in frame.data.as_chunks_mut::<4>().0 {
c.copy_from_slice(&fill.to_le_bytes());
}
frame
}
fn f32_texture(w: i32, h: i32, fill: f32) -> crate::render::Texture {
crate::render::Texture::wrap_frame(f32_frame(w, h, fill))
}
struct FakeGpu;
impl oak_core::backend::GpuContextLike for FakeGpu {
fn kind(&self) -> oak_core::backend::BackendKind {
oak_core::backend::BackendKind::Cpu
}
fn destroy_texture(&self, _token: u64) {}
fn upload(
&self,
_token: u64,
_frame: &oak_core::texture::Frame,
) -> oak_render::error::Result<()> {
Ok(())
}
fn download(&self, _token: u64) -> oak_render::error::Result<oak_core::texture::Frame> {
Ok(oak_core::texture::Frame::new())
}
fn blit(
&self,
_src: u64,
_dst: u64,
_processor: Option<&oak_core::color::ColorProcessor>,
) -> oak_render::error::Result<()> {
Ok(())
}
}
fn gl_ctx(output: crate::render::Texture, index: Option<i32>) -> crate::suites::GlCtx {
crate::suites::GlCtx {
renderer: std::sync::Arc::new(FakeGpu),
output_texture: output,
gl_pixel_depth: "OfxBitDepthFloat",
output_gl_texture: index,
}
}
/// 从 clipLoadTexture 写出的句柄读属性集。
fn handle_props(handle: *mut c_void) -> PropertySet {
assert_eq!(tag::kind(handle), tag::PROPERTY_SET, "纹理句柄标签 0");
unsafe { (*(tag::strip(handle))).clone() }
}
// `Value` 未实现 PartialEq(Pointer 无法比较)——按种类解包断言。
fn prop_int(props: &PropertySet, name: &str, index: usize) -> i32 {
match props.get(name, index) {
Some(Value::Int(i)) => i,
other => panic!("{name}[{index}] 应为 Int,got {other:?}"),
}
}
fn prop_double(props: &PropertySet, name: &str, index: usize) -> f64 {
match props.get(name, index) {
Some(Value::Double(d)) => d,
other => panic!("{name}[{index}] 应为 Double,got {other:?}"),
}
}
fn prop_string(props: &PropertySet, name: &str, index: usize) -> String {
match props.get(name, index) {
Some(Value::String(s)) => s.to_string_lossy().into_owned(),
other => panic!("{name}[{index}] 应为 String,got {other:?}"),
}
}
/// GL 像素深度协商矩阵(ofxGPURender.h kOfxOpenGLPropPixelDepth):
/// 未声明/含 Float → 管线 F32 可行;声明且不含 Float → None
/// (GL 模式不可行,回退 CPU)。
@@ -540,5 +640,578 @@ mod tests {
vec![Value::String(cs("OfxBitDepthFloat"))],
);
assert_eq!(pick_gl_pixel_depth(&props4), Some("OfxBitDepthFloat"));
// 列表存在但元素非字符串 → 跳过,不含 Float → None。
let props5 = PropertySet::new();
props5.define(crate::host::PROP_GL_PIXEL_DEPTH, vec![Value::Int(4)]);
assert_eq!(pick_gl_pixel_depth(&props5), None);
}
/// 纹理请求格式 → 期望分量矩阵(NULL/未知 → 宿主自选)。
#[test]
fn format_components_matrix() {
use crate::image::Components;
assert_eq!(format_components(Some(GL_FORMAT_RGBA)), Some(Components::Rgba));
assert_eq!(format_components(None), Some(Components::Rgba));
assert_eq!(format_components(Some(GL_FORMAT_RGB)), Some(Components::Rgb));
assert_eq!(format_components(Some(GL_FORMAT_ALPHA)), Some(Components::Alpha));
assert_eq!(format_components(Some(GL_FORMAT_LUMINANCE)), None);
assert_eq!(format_components(Some(GL_FORMAT_LUMINANCE_ALPHA)), None);
assert_eq!(format_components(Some("bogus")), None);
}
/// make_texture_props:ofxGPURender.h 规定的 12 个属性齐全且值正确。
#[test]
fn make_texture_props_fields() {
let props = make_texture_props(
4.0,
2.0,
crate::image::Components::Rgb,
"OfxBitDepthFloat",
PREMULT_NONE,
1.5,
crate::instance::RenderScale { x: 2.0, y: 3.0 },
48,
7,
);
assert_eq!(prop_int(&props, GL_TEXTURE_INDEX, 0), 7);
assert_eq!(prop_int(&props, GL_TEXTURE_TARGET, 0), GL_TEXTURE_2D);
assert_eq!(
prop_string(&props, crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH, 0),
"OfxBitDepthFloat"
);
assert_eq!(
prop_string(&props, crate::image::K_IMAGE_EFFECT_PROP_COMPONENTS, 0),
"OfxImageComponentRGB"
);
assert_eq!(
prop_string(&props, "OfxImageEffectPropPreMultiplication", 0),
PREMULT_NONE
);
assert_eq!(prop_double(&props, crate::host::PROP_RENDER_SCALE, 0), 2.0);
assert_eq!(prop_double(&props, crate::host::PROP_RENDER_SCALE, 1), 3.0);
assert_eq!(
prop_double(&props, "OfxImagePropPixelAspectRatio", 0),
1.5
);
for (i, want) in [0, 0, 4, 2].iter().enumerate() {
assert_eq!(
prop_int(&props, crate::image::K_IMAGE_PROP_BOUNDS, i),
*want,
"Bounds[{i}]"
);
assert_eq!(
prop_int(&props, crate::image::K_IMAGE_PROP_ROD, i),
*want,
"RoD[{i}]"
);
}
assert_eq!(prop_int(&props, crate::image::K_IMAGE_PROP_ROW_BYTES, 0), 48);
assert_eq!(
prop_string(&props, "OfxImagePropField", 0),
"OfxFieldNone"
);
assert!(matches!(
props.get(crate::image::K_IMAGE_PROP_UNIQUE_ID, 0),
Some(Value::String(_))
));
}
/// rect_props:Double 矩形四舍五入为 Int×4(负半轴远离零)。
#[test]
fn rect_props_rounds_to_int() {
let props = PropertySet::new();
rect_props(
&props,
"r",
crate::instance::OfxRectD {
x1: 1.4,
y1: -0.6,
x2: 3.5,
y2: 2.49,
},
);
for (i, want) in [1, -1, 4, 2].iter().enumerate() {
assert_eq!(prop_int(&props, "r", i), *want, "r[{i}]");
}
}
/// clip_string / clip_par:字符串与非字符串回退、PAR 缺失默认 1.0。
#[test]
fn clip_string_and_par_fallbacks() {
let c = clip("Source");
assert_eq!(clip_string(&c, "missing", "def"), "def");
assert_eq!(clip_par(&c), 1.0);
c.props.set_one("s", Value::String(cs("xyz")));
assert_eq!(clip_string(&c, "s", "def"), "xyz");
c.props.set_one("s2", Value::Int(3));
assert_eq!(clip_string(&c, "s2", "def"), "def");
c.props
.set_one("OfxImagePropPixelAspectRatio", Value::Double(2.0));
assert_eq!(clip_par(&c), 2.0);
c.props
.set_one("OfxImagePropPixelAspectRatio", Value::Int(2));
assert_eq!(clip_par(&c), 1.0, "非 Double 回退 1.0");
}
/// texture_size / image_width / image_height。
#[test]
fn texture_size_and_image_dims() {
assert_eq!(texture_size(&crate::render::Texture::dummy()), (0.0, 0.0));
assert_eq!(texture_size(&f32_texture(3, 2, 0.0)), (3.0, 2.0));
let img = Image::allocate(
crate::image::BitDepth::Float,
crate::image::Components::Rgba,
crate::instance::OfxRectD {
x1: 0.0,
y1: 0.0,
x2: 5.0,
y2: 4.0,
},
);
assert_eq!(image_width(&img), 5.0);
assert_eq!(image_height(&img), 4.0);
}
/// resolve_clip:空句柄/非 CLIP 标签 → BadHandle;CLIP 标签有效。
#[test]
fn resolve_clip_handle_rules() {
assert!(matches!(
resolve_clip(std::ptr::null_mut()),
Err(status::ERR_BAD_HANDLE)
));
let props = PropertySet::new();
let wrong = tag::make(&props as *const PropertySet, tag::PROPERTY_SET);
assert!(matches!(resolve_clip(wrong), Err(status::ERR_BAD_HANDLE)));
let c = clip("Source");
assert!(resolve_clip(clip_handle(&c)).is_ok());
}
/// clipLoadTexture:入口防御(空 out / 空 clip / 非 clip 句柄 /
/// 无 GL 上下文)。
#[test]
fn clip_load_texture_entry_guards() {
let _lock = suite_lock();
crate::suites::set_gl_ctx(None);
let s = suite_v1();
let c = clip("Source");
let name = cs(GL_FORMAT_RGBA);
let mut sentinel = 0u8;
let mut out: *mut c_void = &mut sentinel as *mut u8 as *mut c_void;
unsafe {
// 空 out → BadHandle(且不触碰返回值)。
assert_eq!(
(s.clip_load_texture)(
clip_handle(&c),
0.0,
name.as_ptr(),
std::ptr::null(),
std::ptr::null_mut(),
),
status::ERR_BAD_HANDLE
);
// 空 clip 句柄 → BadHandle。
assert_eq!(
(s.clip_load_texture)(
std::ptr::null_mut(),
0.0,
name.as_ptr(),
std::ptr::null(),
&mut out,
),
status::ERR_BAD_HANDLE
);
assert!(out.is_null(), "入口先把 out 置空");
// 非 clip 标签 → BadHandle。
let wrong = tag::make(&c.props as *const PropertySet, tag::PROPERTY_SET);
assert_eq!(
(s.clip_load_texture)(wrong, 0.0, name.as_ptr(), std::ptr::null(), &mut out),
status::ERR_BAD_HANDLE
);
// 无 GL 上下文 → MissingHostFeature(GL suite 只在 GL 渲染期可达)。
assert_eq!(
(s.clip_load_texture)(
clip_handle(&c),
0.0,
name.as_ptr(),
std::ptr::null(),
&mut out,
),
status::ERR_MISSING_HOST_FEATURE
);
}
}
/// clipLoadTexture(Output):忽略 format,返回已附着的输出纹理句柄
/// (真实 GL 名经 OpenGLTextureIndex 上报);free 只摘句柄。
#[test]
fn clip_load_texture_output_clip() {
let _lock = suite_lock();
let s = suite_v1();
let out_clip = clip("Output");
let tex = f32_texture(4, 2, 0.25);
crate::suites::set_gl_ctx(Some(gl_ctx(tex, Some(9))));
let rgb = cs(GL_FORMAT_RGB); // Output 忽略请求格式。
let mut handle: *mut c_void = std::ptr::null_mut();
unsafe {
assert_eq!(
(s.clip_load_texture)(
clip_handle(&out_clip),
0.0,
rgb.as_ptr(),
std::ptr::null(),
&mut handle,
),
status::OK
);
}
let props = handle_props(handle);
assert_eq!(prop_int(&props, GL_TEXTURE_INDEX, 0), 9);
assert_eq!(prop_int(&props, GL_TEXTURE_TARGET, 0), GL_TEXTURE_2D);
assert_eq!(
prop_string(&props, crate::image::K_IMAGE_EFFECT_PROP_COMPONENTS, 0),
"OfxImageComponentRGBA"
);
assert_eq!(prop_int(&props, crate::image::K_IMAGE_PROP_BOUNDS, 2), 4);
// 行字节 = w × RGBA × F32;OpenGLTextureIndex=0 变体(CPU 回退)。
assert_eq!(
prop_int(&props, crate::image::K_IMAGE_PROP_ROW_BYTES, 0),
4 * 4 * 4
);
assert!(LIVE_TEXTURES.lock().unwrap().contains_key(&(tag::strip(handle) as usize)));
// free:输出 clip 只释放句柄、不删纹理(宿主还要回读)。
unsafe {
assert_eq!((s.clip_free_texture)(handle), status::OK);
// 二次释放 / 空句柄 → BadHandle。
assert_eq!((s.clip_free_texture)(handle), status::ERR_BAD_HANDLE);
assert_eq!(
(s.clip_free_texture)(std::ptr::null_mut()),
status::ERR_BAD_HANDLE
);
}
assert!(!LIVE_TEXTURES.lock().unwrap().contains_key(&(tag::strip(handle) as usize)));
// output_gl_texture = None → 索引 0(CPU 回退语义)。
crate::suites::set_gl_ctx(Some(gl_ctx(f32_texture(2, 2, 0.0), None)));
let mut h0: *mut c_void = std::ptr::null_mut();
unsafe {
assert_eq!(
(s.clip_load_texture)(
clip_handle(&out_clip),
0.0,
std::ptr::null(),
std::ptr::null(),
&mut h0,
),
status::OK
);
}
assert_eq!(prop_int(&handle_props(h0), GL_TEXTURE_INDEX, 0), 0);
unsafe {
assert_eq!((s.clip_free_texture)(h0), status::OK);
}
crate::suites::set_gl_ctx(None);
}
/// clipLoadTexture(Output):附着纹理为 dummy/0 尺寸 → BadHandle。
#[test]
fn clip_load_texture_output_rejects_empty_texture() {
let _lock = suite_lock();
let s = suite_v1();
let out_clip = clip("Output");
crate::suites::set_gl_ctx(Some(gl_ctx(crate::render::Texture::dummy(), None)));
let mut handle: *mut c_void = std::ptr::null_mut();
unsafe {
assert_eq!(
(s.clip_load_texture)(
clip_handle(&out_clip),
0.0,
std::ptr::null(),
std::ptr::null(),
&mut handle,
),
status::ERR_BAD_HANDLE
);
}
crate::suites::set_gl_ctx(None);
}
/// clipLoadTexture(输入 clip):整帧限制、格式匹配、协商属性与
/// 释放;GL 不可用时 OpenGLTextureIndex=0(CPU 回退)。
#[test]
fn clip_load_texture_input_clip() {
let _lock = suite_lock();
let s = suite_v1();
let c = clip("Source");
c.set_input_texture(Some(f32_texture(2, 2, 0.5)), 0.0);
crate::suites::set_gl_ctx(Some(gl_ctx(crate::render::Texture::dummy(), None)));
// 子区域请求(region 非空)→ Failed(Phase 2 仅整帧)。
let region = crate::instance::OfxRectD::default();
unsafe {
let mut h: *mut c_void = std::ptr::null_mut();
assert_eq!(
(s.clip_load_texture)(
clip_handle(&c),
0.0,
std::ptr::null(),
&region as *const crate::instance::OfxRectD as *const c_void,
&mut h,
),
status::FAILED
);
}
// 未挂输入纹理 → Failed(fetch_image NotFound 映射)。
let empty = clip("Source");
unsafe {
let mut h: *mut c_void = std::ptr::null_mut();
assert_eq!(
(s.clip_load_texture)(
clip_handle(&empty),
0.0,
std::ptr::null(),
std::ptr::null(),
&mut h,
),
status::FAILED
);
}
// 分量不符:请求 RGB 但图像 RGBA(协商默认)→ Failed。
let rgb = cs(GL_FORMAT_RGB);
unsafe {
let mut h: *mut c_void = std::ptr::null_mut();
assert_eq!(
(s.clip_load_texture)(
clip_handle(&c),
0.0,
rgb.as_ptr(),
std::ptr::null(),
&mut h,
),
status::FAILED
);
}
// 成功(format=NULL):纹理句柄属性齐全。
let mut handle: *mut c_void = std::ptr::null_mut();
unsafe {
assert_eq!(
(s.clip_load_texture)(
clip_handle(&c),
0.0,
std::ptr::null(),
std::ptr::null(),
&mut handle,
),
status::OK
);
}
let props = handle_props(handle);
assert_eq!(
prop_string(&props, crate::image::K_IMAGE_EFFECT_PROP_COMPONENTS, 0),
"OfxImageComponentRGBA"
);
assert_eq!(
prop_string(&props, crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH, 0),
"OfxBitDepthFloat"
);
assert_eq!(
prop_int(&props, crate::image::K_IMAGE_PROP_ROW_BYTES, 0),
2 * 4 * 4
);
assert_eq!(prop_int(&props, GL_TEXTURE_TARGET, 0), GL_TEXTURE_2D);
// 非 GL(Linux/Windows stub)→ 索引 0;macOS 有 GL 时 > 0。
let index = match props.get(GL_TEXTURE_INDEX, 0) {
Some(Value::Int(i)) => i,
other => panic!("OpenGLTextureIndex 应为 Int,got {other:?}"),
};
if crate::gl_bridge::gl_available() {
assert!(index > 0, "GL 可用时输入纹理应有真实 GL 名");
} else {
assert_eq!(index, 0, "无 GL 时索引为 0");
}
// 请求 RGBA(显式)与请求 Luminance 族(None = 宿主自选)都成功。
let rgba = cs(GL_FORMAT_RGBA);
let lum = cs(GL_FORMAT_LUMINANCE);
let lum_a = cs(GL_FORMAT_LUMINANCE_ALPHA);
let mut h2: *mut c_void = std::ptr::null_mut();
let mut h3: *mut c_void = std::ptr::null_mut();
let mut h4: *mut c_void = std::ptr::null_mut();
unsafe {
assert_eq!(
(s.clip_load_texture)(clip_handle(&c), 0.0, rgba.as_ptr(), std::ptr::null(), &mut h2),
status::OK
);
assert_eq!(
(s.clip_load_texture)(clip_handle(&c), 0.0, lum.as_ptr(), std::ptr::null(), &mut h3),
status::OK
);
assert_eq!(
(s.clip_load_texture)(clip_handle(&c), 0.0, lum_a.as_ptr(), std::ptr::null(), &mut h4),
status::OK
);
// 非 UTF-8 format 串按 None 处理(宿主自选)→ OK。
let bad = CString::new(vec![0xFFu8]).unwrap();
let mut h5: *mut c_void = std::ptr::null_mut();
assert_eq!(
(s.clip_load_texture)(
clip_handle(&c),
0.0,
bad.as_ptr(),
std::ptr::null(),
&mut h5,
),
status::OK
);
// free 输入纹理:摘表(GL 模式同时删真实 GL 纹理)。
assert_eq!((s.clip_free_texture)(h2), status::OK);
assert_eq!((s.clip_free_texture)(h3), status::OK);
assert_eq!((s.clip_free_texture)(h4), status::OK);
assert_eq!((s.clip_free_texture)(h5), status::OK);
assert_eq!((s.clip_free_texture)(handle), status::OK);
}
crate::suites::set_gl_ctx(None);
}
/// clipLoadTexture:渲染上下文的 scale 进入纹理属性;0 尺寸图像
/// → Failed。
#[test]
fn clip_load_texture_uses_render_scale_and_rejects_zero_image() {
let _lock = suite_lock();
let s = suite_v1();
// 有 render ctx:scale 写入纹理句柄的 RenderScale 属性。
let c = clip("Source");
c.set_input_texture(Some(f32_texture(2, 2, 0.0)), 0.0);
crate::suites::set_render_ctx(Some(crate::suites::RenderCtx {
time: 1.0,
scale: crate::instance::RenderScale { x: 2.0, y: 2.0 },
range: crate::instance::OfxRangeD { min: 0.0, max: 1.0 },
}));
crate::suites::set_gl_ctx(Some(gl_ctx(crate::render::Texture::dummy(), None)));
let mut handle: *mut c_void = std::ptr::null_mut();
unsafe {
assert_eq!(
(s.clip_load_texture)(
clip_handle(&c),
0.0,
std::ptr::null(),
std::ptr::null(),
&mut handle,
),
status::OK
);
}
let props = handle_props(handle);
assert_eq!(prop_double(&props, crate::host::PROP_RENDER_SCALE, 0), 2.0);
unsafe {
assert_eq!((s.clip_free_texture)(handle), status::OK);
}
crate::suites::set_render_ctx(None);
// 0×0 帧(非 dummy:data 非空)→ image_width == 0 → Failed。
let zero = clip("Source");
let params = crate::render::VideoParams {
format: crate::render::PIXEL_FORMAT_F32,
..Default::default()
};
let mut frame = crate::render::Frame::new();
frame.set_video_params(params);
frame.data = vec![0u8; 16];
zero.set_input_texture(Some(crate::render::Texture::wrap_frame(frame)), 0.0);
let mut h: *mut c_void = std::ptr::null_mut();
unsafe {
assert_eq!(
(s.clip_load_texture)(
clip_handle(&zero),
0.0,
std::ptr::null(),
std::ptr::null(),
&mut h,
),
status::FAILED
);
}
crate::suites::set_gl_ctx(None);
}
/// purge_leftovers:释放残留输入句柄、保留输出句柄(宿主还要回读)。
#[test]
fn purge_leftovers_keeps_outputs() {
let _lock = suite_lock();
let s = suite_v1();
let input = clip("Source");
input.set_input_texture(Some(f32_texture(2, 2, 0.0)), 0.0);
let out_clip = clip("Output");
crate::suites::set_gl_ctx(Some(gl_ctx(f32_texture(2, 2, 0.0), Some(1))));
let mut input_handle: *mut c_void = std::ptr::null_mut();
let mut out_handle: *mut c_void = std::ptr::null_mut();
unsafe {
assert_eq!(
(s.clip_load_texture)(
clip_handle(&input),
0.0,
std::ptr::null(),
std::ptr::null(),
&mut input_handle,
),
status::OK
);
assert_eq!(
(s.clip_load_texture)(
clip_handle(&out_clip),
0.0,
std::ptr::null(),
std::ptr::null(),
&mut out_handle,
),
status::OK
);
}
assert!(LIVE_TEXTURES.lock().unwrap().contains_key(&(tag::strip(input_handle) as usize)));
assert!(LIVE_TEXTURES.lock().unwrap().contains_key(&(tag::strip(out_handle) as usize)));
purge_leftovers();
let live = LIVE_TEXTURES.lock().unwrap();
assert!(
!live.contains_key(&(tag::strip(input_handle) as usize)),
"输入句柄应被兜底释放"
);
assert!(
live.contains_key(&(tag::strip(out_handle) as usize)),
"输出句柄应保留"
);
drop(live);
unsafe {
assert_eq!((s.clip_free_texture)(out_handle), status::OK);
}
crate::suites::set_gl_ctx(None);
}
/// delete_gl_texture_if_gl:无 GL 上下文 no-op;有上下文时经桥删除
/// (GL 不可用环境下桥为 stub,仅验证不 panic)。
#[test]
fn delete_gl_texture_if_gl_branches() {
let _lock = suite_lock();
crate::suites::set_gl_ctx(None);
delete_gl_texture_if_gl(3);
crate::suites::set_gl_ctx(Some(gl_ctx(crate::render::Texture::dummy(), None)));
delete_gl_texture_if_gl(4);
crate::suites::set_gl_ctx(None);
}
/// flushResources:宿主不缓存 GPU 资源 → ReplyDefault。
#[test]
fn flush_resources_replies_default() {
unsafe {
assert_eq!((suite_v1().flush_resources)(), status::REPLY_DEFAULT);
}
}
}
+787 -1
View File
@@ -769,4 +769,790 @@ pub fn suite_v1() -> &'static PropertySuiteV1 {
get_dimension: prop_get_dimension,
})
}
get_dimension: prop_get_dimension,
#[cfg(test)]
mod tests {
use super::*;
fn handle(set: &PropertySet) -> *mut c_void {
set as *const PropertySet as *mut c_void
}
fn cs(s: &str) -> CString {
CString::new(s).unwrap()
}
/// `Value` 未实现 PartialEq(Pointer 无法比较)——用 Debug 串比较。
fn val_eq(a: &Value, b: &Value) -> bool {
format!("{a:?}") == format!("{b:?}")
}
fn assert_value(set: &PropertySet, name: &str, index: usize, want: &Value) {
assert!(
val_eq(set.get(name, index).as_ref().expect("属性应存在"), want),
"{name}[{index}] 值不符"
);
}
/// 空句柄 → BadHandle;空/非 UTF-8 属性名 → ErrValue。
#[test]
fn bad_handles_and_bad_names() {
let s = suite_v1();
let set = PropertySet::new();
let name = cs("a");
let mut iv = 0;
let mut dv = 0.0;
let mut sv: *mut c_char = std::ptr::null_mut();
let mut pv: *mut c_void = std::ptr::null_mut();
unsafe {
assert_eq!(
(s.set_int)(std::ptr::null_mut(), name.as_ptr(), 0, 1),
status::ERR_BAD_HANDLE
);
assert_eq!(
(s.get_int)(std::ptr::null_mut(), name.as_ptr(), 0, &mut iv),
status::ERR_BAD_HANDLE
);
// 空属性名。
assert_eq!((s.set_int)(handle(&set), std::ptr::null(), 0, 1), status::ERR_VALUE);
assert_eq!(
(s.get_int)(handle(&set), std::ptr::null(), 0, &mut iv),
status::ERR_VALUE
);
assert_eq!(
(s.set_double)(handle(&set), std::ptr::null(), 0, 1.0),
status::ERR_VALUE
);
assert_eq!(
(s.set_string)(handle(&set), std::ptr::null(), 0, name.as_ptr()),
status::ERR_VALUE
);
assert_eq!(
(s.get_double)(handle(&set), std::ptr::null(), 0, &mut dv),
status::ERR_VALUE
);
assert_eq!(
(s.get_string)(handle(&set), std::ptr::null(), 0, &mut sv),
status::ERR_VALUE
);
assert_eq!(
(s.get_pointer)(handle(&set), std::ptr::null(), 0, &mut pv),
status::ERR_VALUE
);
assert_eq!(
(s.get_dimension)(handle(&set), std::ptr::null(), &mut iv),
status::ERR_VALUE
);
assert_eq!(
(s.reset)(handle(&set), std::ptr::null()),
status::ERR_VALUE
);
// 非 UTF-8 属性名(0xFF 后接 NUL)。
let bad: [c_char; 2] = [0xFFu8 as c_char, 0];
assert_eq!(
(s.set_int)(handle(&set), bad.as_ptr(), 0, 1),
status::ERR_VALUE
);
assert_eq!(
(s.get_int)(handle(&set), bad.as_ptr(), 0, &mut iv),
status::ERR_VALUE
);
}
}
/// 未定义属性 get → ErrUnknown;define 后空数组维度 0 且越界读
/// → BadIndex;负索引 → BadIndex。
#[test]
fn get_missing_empty_and_bounds() {
let s = suite_v1();
let set = PropertySet::new();
let n = cs("a");
let mut iv = 0;
unsafe {
assert_eq!(
(s.get_int)(handle(&set), n.as_ptr(), 0, &mut iv),
status::ERR_UNKNOWN
);
assert_eq!(
(s.get_int)(handle(&set), n.as_ptr(), -1, &mut iv),
status::ERR_UNKNOWN,
"未定义属性先报 Unknown(HS 先查类型)"
);
}
set.define("a", vec![]);
unsafe {
assert_eq!(
(s.get_int)(handle(&set), n.as_ptr(), 0, &mut iv),
status::ERR_BAD_INDEX
);
}
set.define("a", vec![Value::Int(3)]);
unsafe {
assert_eq!(
(s.get_int)(handle(&set), n.as_ptr(), -1, &mut iv),
status::ERR_BAD_INDEX
);
assert_eq!(
(s.get_int)(handle(&set), n.as_ptr(), 1, &mut iv),
status::ERR_BAD_INDEX
);
}
}
/// propSet 隐式创建:index 2 建出维度 3 的属性且前导槽位同值;
/// 已定义数组 index == 维度追加、越界 → BadIndex。
#[test]
fn set_creates_and_appends() {
let s = suite_v1();
let set = PropertySet::new();
let n = cs("a");
unsafe {
assert_eq!((s.set_int)(handle(&set), n.as_ptr(), 2, 7), 0);
}
assert_eq!(set.dimension("a"), 3);
assert_value(&set, "a", 0, &Value::Int(7));
assert_value(&set, "a", 2, &Value::Int(7));
// index == 维度 → 追加(协商属性逐位增长语义)。
unsafe {
assert_eq!((s.set_int)(handle(&set), n.as_ptr(), 3, 9), 0);
}
assert_eq!(set.dimension("a"), 4);
assert_value(&set, "a", 3, &Value::Int(9));
// 越界(跳过追加位)→ BadIndex。
unsafe {
assert_eq!(
(s.set_int)(handle(&set), n.as_ptr(), 9, 1),
status::ERR_BAD_INDEX
);
}
assert_eq!(set.dimension("a"), 4);
}
/// 空预定义数组:index 0 推入;index > 0 → BadIndex。
#[test]
fn set_into_empty_predefined_array() {
let s = suite_v1();
let set = PropertySet::new();
set.define("empty", vec![]);
let n = cs("empty");
unsafe {
assert_eq!(
(s.set_int)(handle(&set), n.as_ptr(), 1, 1),
status::ERR_BAD_INDEX
);
assert_eq!((s.set_int)(handle(&set), n.as_ptr(), 0, 5), 0);
}
assert_value(&set, "empty", 0, &Value::Int(5));
}
/// propSet 类型不符 → Unknown;数值 Int/Double 不互转(set 严格)。
#[test]
fn set_type_mismatch() {
let s = suite_v1();
let set = PropertySet::new();
set.define("i", vec![Value::Int(0)]);
set.define("d", vec![Value::Double(0.0)]);
let ni = cs("i");
let nd = cs("d");
unsafe {
assert_eq!(
(s.set_double)(handle(&set), ni.as_ptr(), 0, 1.0),
status::ERR_UNKNOWN
);
assert_eq!(
(s.set_int)(handle(&set), nd.as_ptr(), 0, 1),
status::ERR_UNKNOWN
);
}
}
/// propGet:Int ↔ Double 数值协随;混合数组元素类型不符 → Failed。
#[test]
fn get_numeric_coercion_and_mixed_arrays() {
let s = suite_v1();
let set = PropertySet::new();
set.define("i", vec![Value::Int(3)]);
set.define("d", vec![Value::Double(1.5)]);
let ni = cs("i");
let nd = cs("d");
let mut dv = 0.0;
let mut iv = 0;
unsafe {
assert_eq!((s.get_double)(handle(&set), ni.as_ptr(), 0, &mut dv), 0);
}
assert_eq!(dv, 3.0);
unsafe {
assert_eq!((s.get_int)(handle(&set), nd.as_ptr(), 0, &mut iv), 0);
}
assert_eq!(iv, 1);
// 混合数组(首元素定类型,后续元素类型不符)→ 单元素读 Failed。
let mix = PropertySet::new();
mix.define(
"m",
vec![Value::Double(1.0), Value::String(cs("x"))],
);
let nm = cs("m");
unsafe {
assert_eq!(
(s.get_double)(handle(&mix), nm.as_ptr(), 1, &mut dv),
status::FAILED
);
}
let mix2 = PropertySet::new();
mix2.define("m", vec![Value::Int(1), Value::String(cs("x"))]);
unsafe {
assert_eq!(
(s.get_int)(handle(&mix2), nm.as_ptr(), 1, &mut iv),
status::FAILED
);
}
}
/// 分量不足/超出的类型读:get_string 读数值属性 → Unknown(类型
/// 不符),get_double 读字符串 → Unknown。
#[test]
fn get_kind_mismatch() {
let s = suite_v1();
let set = PropertySet::new();
set.define("i", vec![Value::Int(1)]);
set.define("str", vec![Value::String(cs("s"))]);
let ni = cs("i");
let ns = cs("str");
let mut sv: *mut c_char = std::ptr::null_mut();
let mut dv = 0.0;
unsafe {
assert_eq!(
(s.get_string)(handle(&set), ni.as_ptr(), 0, &mut sv),
status::ERR_UNKNOWN
);
assert_eq!(
(s.get_double)(handle(&set), ns.as_ptr(), 0, &mut dv),
status::ERR_UNKNOWN
);
}
}
/// propGet 空 out 指针 → ErrValue(四种标量读取)。
#[test]
fn get_null_out_pointers() {
let s = suite_v1();
let set = PropertySet::new();
set.define("i", vec![Value::Int(1)]);
set.define("d", vec![Value::Double(1.0)]);
set.define("s", vec![Value::String(cs("x"))]);
set.define("p", vec![Value::Pointer(std::ptr::null_mut())]);
let n = cs("i");
unsafe {
assert_eq!(
(s.get_int)(handle(&set), n.as_ptr(), 0, std::ptr::null_mut()),
status::ERR_VALUE
);
assert_eq!(
(s.get_double)(handle(&set), n.as_ptr(), 0, std::ptr::null_mut()),
status::ERR_VALUE
);
assert_eq!(
(s.get_string)(handle(&set), n.as_ptr(), 0, std::ptr::null_mut()),
status::ERR_VALUE
);
assert_eq!(
(s.get_pointer)(handle(&set), n.as_ptr(), 0, std::ptr::null_mut()),
status::ERR_VALUE
);
}
}
/// propGetPointer:成功写出指针;混合数组类型不符 → Failed。
#[test]
fn get_pointer_paths() {
let s = suite_v1();
let raw = 0x1234usize as *mut c_void;
let set = PropertySet::new();
set.define("p", vec![Value::Pointer(raw)]);
let np = cs("p");
let mut out: *mut c_void = std::ptr::null_mut();
unsafe {
assert_eq!((s.get_pointer)(handle(&set), np.as_ptr(), 0, &mut out), 0);
}
assert_eq!(out, raw);
let mix = PropertySet::new();
mix.define(
"p",
vec![Value::Pointer(raw), Value::Int(1)],
);
unsafe {
assert_eq!(
(s.get_pointer)(handle(&mix), np.as_ptr(), 1, &mut out),
status::FAILED
);
}
}
/// propGetString:写出内驻指针且内容正确;混合数组 → Failed。
#[test]
fn get_string_paths() {
let s = suite_v1();
let set = PropertySet::new();
set.define("s", vec![Value::String(cs("hello"))]);
let ns = cs("s");
let mut out: *mut c_char = std::ptr::null_mut();
unsafe {
assert_eq!((s.get_string)(handle(&set), ns.as_ptr(), 0, &mut out), 0);
assert_eq!(CStr::from_ptr(out).to_bytes(), b"hello");
}
let mix = PropertySet::new();
mix.define(
"s",
vec![Value::String(cs("x")), Value::Int(1)],
);
unsafe {
assert_eq!(
(s.get_string)(handle(&mix), ns.as_ptr(), 1, &mut out),
status::FAILED
);
}
}
/// propSetString:NULL 值 → ErrValue;截断到首个 NUL。
#[test]
fn set_string_null_and_truncation() {
let s = suite_v1();
let set = PropertySet::new();
let n = cs("s");
unsafe {
assert_eq!(
(s.set_string)(handle(&set), n.as_ptr(), 0, std::ptr::null()),
status::ERR_VALUE
);
}
}
/// propSetN 家族:隐式创建、维度替换/逐位写、空数组与 NULL 参数。
#[test]
fn set_n_families() {
let s = suite_v1();
let set = PropertySet::new();
// set_int_n:隐式创建 2 维。
let ni = cs("ints");
let ivals = [1 as c_int, 2];
unsafe {
assert_eq!(
(s.set_int_n)(handle(&set), ni.as_ptr(), 2, ivals.as_ptr()),
0
);
}
assert_eq!(set.dimension("ints"), 2);
assert_value(&set, "ints", 1, &Value::Int(2));
// count 与现有维度相同 → 逐位覆盖。
let ivals2 = [7 as c_int, 8];
unsafe {
assert_eq!(
(s.set_int_n)(handle(&set), ni.as_ptr(), 2, ivals2.as_ptr()),
0
);
}
assert_value(&set, "ints", 0, &Value::Int(7));
// 维度变化 → 整体替换。
let ivals3 = [9 as c_int];
unsafe {
assert_eq!(
(s.set_int_n)(handle(&set), ni.as_ptr(), 1, ivals3.as_ptr()),
0
);
}
assert_eq!(set.dimension("ints"), 1);
// count 0 + NULL:合法(空维度替换)。
unsafe {
assert_eq!(
(s.set_int_n)(handle(&set), ni.as_ptr(), 0, std::ptr::null()),
0
);
}
assert_eq!(set.dimension("ints"), 0);
// count > 0 + NULL → ErrValue;负 count → BadIndex。
unsafe {
assert_eq!(
(s.set_int_n)(handle(&set), ni.as_ptr(), 1, std::ptr::null()),
status::ERR_VALUE
);
assert_eq!(
(s.set_int_n)(handle(&set), ni.as_ptr(), -1, ivals.as_ptr()),
status::ERR_BAD_INDEX
);
}
// set_double_n:隐式创建 + NULL 分支。
let nd = cs("dbls");
let dvals = [1.5 as c_double, 2.5];
unsafe {
assert_eq!(
(s.set_double_n)(handle(&set), nd.as_ptr(), 2, dvals.as_ptr()),
0
);
assert_eq!(
(s.set_double_n)(handle(&set), nd.as_ptr(), 1, std::ptr::null()),
status::ERR_VALUE
);
}
assert_value(&set, "dbls", 1, &Value::Double(2.5));
// set_pointer_n:单元素与 count 0 + NULL。
let np = cs("ptrs");
let pvals = [0x55usize as *mut c_void];
unsafe {
assert_eq!(
(s.set_pointer_n)(handle(&set), np.as_ptr(), 1, pvals.as_ptr()),
0
);
assert_eq!(
(s.set_pointer_n)(handle(&set), np.as_ptr(), 1, std::ptr::null()),
status::ERR_VALUE
);
assert_eq!(
(s.set_pointer_n)(handle(&set), np.as_ptr(), 0, std::ptr::null()),
0
);
}
assert_eq!(set.dimension("ptrs"), 0);
// set_string_n:成功、NULL 数组、数组内 NULL 元素。
let nstr = cs("strs");
let sa = cs("a");
let sb = cs("b");
let svals = [sa.as_ptr(), sb.as_ptr()];
unsafe {
assert_eq!(
(s.set_string_n)(handle(&set), nstr.as_ptr(), 2, svals.as_ptr()),
0
);
assert_eq!(
(s.set_string_n)(handle(&set), nstr.as_ptr(), 1, std::ptr::null()),
status::ERR_VALUE
);
let with_null = [sa.as_ptr(), std::ptr::null()];
assert_eq!(
(s.set_string_n)(handle(&set), nstr.as_ptr(), 2, with_null.as_ptr()),
status::ERR_VALUE
);
}
assert_eq!(set.dimension("strs"), 2);
}
/// set_n 类型不符 → Unknown;已有数组 count 0 替换为空。
#[test]
fn set_n_type_mismatch_and_empty_replace() {
let s = suite_v1();
let set = PropertySet::new();
set.define("i", vec![Value::Int(1)]);
let ni = cs("i");
let dvals = [2.0 as c_double];
unsafe {
assert_eq!(
(s.set_double_n)(handle(&set), ni.as_ptr(), 1, dvals.as_ptr()),
status::ERR_UNKNOWN
);
// values 为空(count 0)→ 无类型探针,整体替换为空。
assert_eq!(
(s.set_double_n)(handle(&set), ni.as_ptr(), 0, dvals.as_ptr()),
0
);
}
assert_eq!(set.dimension("i"), 0);
}
/// propGetN:Int ↔ Double 协随、min(count, dim) 截断、混合数组写
/// 跳过不符元素。
#[test]
fn get_n_families() {
let s = suite_v1();
let set = PropertySet::new();
set.define("ints", vec![Value::Int(1), Value::Int(2)]);
set.define("dbls", vec![Value::Double(1.5), Value::Double(2.5)]);
// get_double_n 读 Int 属性 → 协随为 f64。
let ni = cs("ints");
let mut dout = [0.0 as c_double; 2];
unsafe {
assert_eq!(
(s.get_double_n)(handle(&set), ni.as_ptr(), 2, dout.as_mut_ptr()),
0
);
}
assert_eq!(dout, [1.0, 2.0]);
// get_int_n 读 Double 属性 → 截断为 i32。
let nd = cs("dbls");
let mut iout = [0 as c_int; 2];
unsafe {
assert_eq!(
(s.get_int_n)(handle(&set), nd.as_ptr(), 2, iout.as_mut_ptr()),
0
);
}
assert_eq!(iout, [1, 2]);
// count > 维度:只拷贝 min(count, dim)。
let mut one = [0 as c_int; 1];
unsafe {
assert_eq!(
(s.get_int_n)(handle(&set), ni.as_ptr(), 5, one.as_mut_ptr()),
0
);
}
assert_eq!(one, [1]);
// count 0:不写。
let mut zero = [7 as c_int; 1];
unsafe {
assert_eq!(
(s.get_int_n)(handle(&set), ni.as_ptr(), 0, zero.as_mut_ptr()),
0
);
}
assert_eq!(zero, [7]);
// get_pointer_n / get_string_n 成功路径。
let ptrs = PropertySet::new();
let raw = 0x99usize as *mut c_void;
ptrs.define("p", vec![Value::Pointer(raw)]);
let np = cs("p");
let mut pout = [std::ptr::null_mut(); 1];
unsafe {
assert_eq!(
(s.get_pointer_n)(handle(&ptrs), np.as_ptr(), 1, pout.as_mut_ptr()),
0
);
}
assert_eq!(pout[0], raw);
let strs = PropertySet::new();
strs.define("s", vec![Value::String(cs("hi"))]);
let ns = cs("s");
let mut sout = [std::ptr::null_mut(); 1];
unsafe {
assert_eq!(
(s.get_string_n)(handle(&strs), ns.as_ptr(), 1, sout.as_mut_ptr()),
0
);
assert_eq!(CStr::from_ptr(sout[0]).to_bytes(), b"hi");
}
}
/// propGetN 错误路径:空 out、未定义、空数组、类型不符、负 count。
#[test]
fn get_n_error_paths() {
let s = suite_v1();
let set = PropertySet::new();
set.define("ints", vec![Value::Int(1)]);
set.define("strs", vec![Value::String(cs("s"))]);
set.define("empty", vec![]);
let ni = cs("ints");
let ns = cs("strs");
let ne = cs("empty");
let nmissing = cs("missing");
let mut iout = [0 as c_int; 2];
let mut dout = [0.0 as c_double; 2];
let mut pout = [std::ptr::null_mut(); 2];
let mut sout = [std::ptr::null_mut(); 2];
unsafe {
// 空 out(count > 0)。
assert_eq!(
(s.get_int_n)(handle(&set), ni.as_ptr(), 1, std::ptr::null_mut()),
status::ERR_VALUE
);
assert_eq!(
(s.get_double_n)(handle(&set), ni.as_ptr(), 1, std::ptr::null_mut()),
status::ERR_VALUE
);
assert_eq!(
(s.get_pointer_n)(handle(&set), ni.as_ptr(), 1, std::ptr::null_mut()),
status::ERR_VALUE
);
assert_eq!(
(s.get_string_n)(handle(&set), ni.as_ptr(), 1, std::ptr::null_mut()),
status::ERR_VALUE
);
// 未定义 → Unknown;空数组 → Unknown(无类型探针)。
assert_eq!(
(s.get_int_n)(handle(&set), nmissing.as_ptr(), 1, iout.as_mut_ptr()),
status::ERR_UNKNOWN
);
assert_eq!(
(s.get_int_n)(handle(&set), ne.as_ptr(), 1, iout.as_mut_ptr()),
status::ERR_UNKNOWN
);
// 类型不符 → Unknown(数值族之间协随,其余严格)。
assert_eq!(
(s.get_int_n)(handle(&set), ns.as_ptr(), 1, iout.as_mut_ptr()),
status::ERR_UNKNOWN
);
assert_eq!(
(s.get_double_n)(handle(&set), ns.as_ptr(), 1, dout.as_mut_ptr()),
status::ERR_UNKNOWN
);
assert_eq!(
(s.get_pointer_n)(handle(&set), ni.as_ptr(), 1, pout.as_mut_ptr()),
status::ERR_UNKNOWN
);
assert_eq!(
(s.get_string_n)(handle(&set), ni.as_ptr(), 1, sout.as_mut_ptr()),
status::ERR_UNKNOWN
);
// 负 count → BadIndex(在 min 之前 idx 转换失败)。
assert_eq!(
(s.get_int_n)(handle(&set), ni.as_ptr(), -1, iout.as_mut_ptr()),
status::ERR_BAD_INDEX
);
}
}
/// propReset 明确不支持;propGetDimension 空 out/未定义/空数组。
#[test]
fn reset_and_dimension() {
let s = suite_v1();
let set = PropertySet::new();
set.define("a", vec![Value::Int(1)]);
set.define("empty", vec![]);
let na = cs("a");
let ne = cs("empty");
let nmissing = cs("missing");
let mut dim = -1;
unsafe {
assert_eq!(
(s.reset)(handle(&set), na.as_ptr()),
status::ERR_UNSUPPORTED
);
assert_eq!(
(s.get_dimension)(handle(&set), na.as_ptr(), std::ptr::null_mut()),
status::ERR_VALUE
);
assert_eq!(
(s.get_dimension)(handle(&set), nmissing.as_ptr(), &mut dim),
status::ERR_UNKNOWN
);
assert_eq!((s.get_dimension)(handle(&set), na.as_ptr(), &mut dim), 0);
assert_eq!(dim, 1);
assert_eq!((s.get_dimension)(handle(&set), ne.as_ptr(), &mut dim), 0);
assert_eq!(dim, 0, "已定义空数组是合法维度 0");
}
}
/// OAK_OFX_TRACE 开启时的诊断分支:所有 trace 打印点都被走到
/// (错误路径与成功路径)。测试串行化并还原环境变量。
#[test]
fn trace_diagnostic_branches() {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev = std::env::var_os("OAK_OFX_TRACE");
// set_var/remove_var 在本 crate 的 2021 edition 中是安全函数。
std::env::set_var("OAK_OFX_TRACE", "1");
let body = std::panic::catch_unwind(|| {
let s = suite_v1();
let set = PropertySet::new();
let ni = cs("i");
let nstr = cs("str");
let nmissing = cs("missing");
set.define("i", vec![Value::Int(1)]);
set.define("str", vec![Value::String(cs("s"))]);
let mut iv = 0;
let mut dv = 0.0;
let mut sv: *mut c_char = std::ptr::null_mut();
let mut pv: *mut c_void = std::ptr::null_mut();
let mut dim = 0;
unsafe {
// caught 的 trace:非 OK 状态码。
assert_eq!(
(s.get_int)(handle(&set), nmissing.as_ptr(), 0, &mut iv),
status::ERR_UNKNOWN
);
// get_value trace:成功 + 失败。
assert_eq!((s.get_int)(handle(&set), ni.as_ptr(), 0, &mut iv), 0);
assert_eq!(
(s.get_int)(handle(&set), nmissing.as_ptr(), 0, &mut iv),
status::ERR_UNKNOWN
);
// 类型不符 trace。
assert_eq!(
(s.get_string)(handle(&set), ni.as_ptr(), 0, &mut sv),
status::ERR_UNKNOWN
);
// set_value:空数组越界 trace;越界 trace。
let empty = PropertySet::new();
empty.define("e", vec![]);
let ne = cs("e");
assert_eq!(
(s.set_int)(handle(&empty), ne.as_ptr(), 1, 1),
status::ERR_BAD_INDEX
);
assert_eq!(
(s.set_int)(handle(&set), ni.as_ptr(), 5, 2),
status::ERR_BAD_INDEX
);
// propGetPointer trace(成功)。
let ptrs = PropertySet::new();
ptrs.define("p", vec![Value::Pointer(0x11usize as *mut c_void)]);
let np = cs("p");
assert_eq!((s.get_pointer)(handle(&ptrs), np.as_ptr(), 0, &mut pv), 0);
// propGetString trace:成功 + 失败。
assert_eq!((s.get_string)(handle(&set), nstr.as_ptr(), 0, &mut sv), 0);
assert_eq!(
(s.get_string)(handle(&set), nmissing.as_ptr(), 0, &mut sv),
status::ERR_UNKNOWN
);
// propGetInt inspect_err trace。
assert_eq!(
(s.get_int)(handle(&set), nmissing.as_ptr(), 0, &mut iv),
status::ERR_UNKNOWN
);
// get_n trace:成功 dump + 未命中 inspect_err。
let mut iout = [0 as c_int; 2];
assert_eq!(
(s.get_int_n)(handle(&set), ni.as_ptr(), 2, iout.as_mut_ptr()),
0
);
assert_eq!(
(s.get_int_n)(handle(&set), nmissing.as_ptr(), 1, iout.as_mut_ptr()),
status::ERR_UNKNOWN
);
// dump 的 Double 与未知元素分支。
let mixed = PropertySet::new();
mixed.define(
"m",
vec![Value::Double(1.5), Value::String(cs("x"))],
);
let nm = cs("m");
let mut dout = [0.0 as c_double; 2];
assert_eq!(
(s.get_double_n)(handle(&mixed), nm.as_ptr(), 2, dout.as_mut_ptr()),
0
);
// 空 out 的 get_double trace 分支(Err 路径渲染)。
assert_eq!(
(s.get_double)(handle(&set), ni.as_ptr(), 0, &mut dv),
0
);
// propGetDimension 未定义 trace。
assert_eq!(
(s.get_dimension)(handle(&set), nmissing.as_ptr(), &mut dim),
status::ERR_UNKNOWN
);
}
});
match prev {
Some(v) => std::env::set_var("OAK_OFX_TRACE", v),
None => std::env::remove_var("OAK_OFX_TRACE"),
}
if let Err(p) = body {
std::panic::resume_unwind(p);
}
}
}
File diff suppressed because it is too large Load Diff