refactor: workspace layout — crates/, app at root, legacy C++ removed
Single mechanical restructure commit: - root Cargo.toml = oakapp bin + workspace; one cargo build produces oakapp, oak-cli, oak-worker, liboakengine.dylib - app/rust/src -> src/ (app at repo root, no rust/ nesting) - src/<mod>/rust -> crates/oak<mod>; src/oakcore-rs -> crates/oakcore; src/bindings/oakotio -> crates/oakotio; src/engine/rust -> crates/oakengine (keeps cdylib+staticlib+rlib) - public C headers include/<mod>/ -> crates/oakengine/include/<mod>/ - OFX SDK headers vendored into crates/oakplugin/ofx/ (HostSupport gone) - legacy deleted: old src/ C++ modules, engine/, core/, ffmpeg_bridge/, app/ (Qt), cli/worker C++, root CMakeLists, third_party/KDDockWidgets submodule, otio-install, all build-* output (~40GB) - oakstorage kept but excluded from the workspace (skeleton w/ todos); gpui excluded (own workspace) - verified: cargo build green, cargo test --workspace 1845/0 (with the documented OCIO_RS_* env override for the homebrew OCIO)
This commit is contained in:
@@ -0,0 +1,535 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! bridge 层测试:param↔oaknode、clip↔oakrender、undo 打包。
|
||||
//!
|
||||
//! 依赖 liboaknode/liboakrender/liboakundo 的用例(standalone 树
|
||||
//! 环境,外层 ctest 驱动)在 cargo 内以库内桩替代
|
||||
//! (`--features test-stubs`):桥调用面一致,桩提供节点值/命令/
|
||||
//! 帧状态访问器(见 `bridge::{node,render,undo}::stub`)。未启用
|
||||
//! 桩时同用例走 dlsym 缺失的降级路径(可解释错误 / no-op)。
|
||||
//!
|
||||
//! 系统级冒烟:[`system_misc_ofx_bundle_smoke`] 在
|
||||
//! `/Library/OFX/Plugins/Misc.ofx.bundle` 存在时扫描真实 OFX 插件
|
||||
//! 并 create_instance(absent 时 skip)。
|
||||
|
||||
mod common;
|
||||
|
||||
use std::ffi::{c_int, c_void, CString};
|
||||
|
||||
use oakplugin::clip::ClipInstance;
|
||||
use oakplugin::descriptor::ClipDescriptor;
|
||||
use oakplugin::handle::CHandle;
|
||||
use oakplugin::image::{BitDepth, Components, Image};
|
||||
use oakplugin::instance::{Instance, OfxRectD, RenderScale};
|
||||
use oakplugin::param::ParamInstance;
|
||||
use oakplugin::property::PropertySet;
|
||||
use oakplugin::suites::param::suite_v1 as param_suite;
|
||||
use oakplugin::suites::tag;
|
||||
|
||||
const OK: c_int = 0;
|
||||
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
|
||||
|
||||
fn cs(s: &str) -> CString {
|
||||
CString::new(s).unwrap()
|
||||
}
|
||||
|
||||
/// 简易纹理句柄(桩 ctx 约定,见 bridge::render::stub:src=0xA2、
|
||||
/// dst=0xA1)。
|
||||
fn fake_texture(ctx: usize) -> CHandle {
|
||||
CHandle {
|
||||
ctx: ctx as *mut c_void,
|
||||
addref: None,
|
||||
release: None,
|
||||
abi_version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// 用 param suite 的 paramDefine 造一个指定 OFX 类型的参数实例
|
||||
/// (describe 产物 → createInstance)。
|
||||
fn make_param(ofx_type: &str) -> ParamInstance {
|
||||
let mut desc = oakplugin::descriptor::EffectDescriptor::new();
|
||||
let s = param_suite();
|
||||
let dhandle = tag::make(&desc.props as *const PropertySet, tag::DESCRIPTOR);
|
||||
let t = cs(ofx_type);
|
||||
let n = cs("p");
|
||||
let mut ph: *mut c_void = std::ptr::null_mut();
|
||||
let r = unsafe { (s.param_define)(dhandle, t.as_ptr(), n.as_ptr(), &mut ph) };
|
||||
assert_eq!(r, OK, "paramDefine {ofx_type} 失败: {r}");
|
||||
ParamInstance::from_def(*desc.params.pop().expect("刚 define 的参数"))
|
||||
}
|
||||
|
||||
/// 简易 clip 实例。
|
||||
fn make_clip(name: &str) -> ClipInstance {
|
||||
ClipInstance::from_descriptor(&ClipDescriptor {
|
||||
props: PropertySet::new(),
|
||||
name: name.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 扫描测试插件并创建实例,绑定 `identity`(0 = 不绑定),返回
|
||||
/// (实例, gain 参数句柄)。插件不可用时 skip 并返回 None。
|
||||
fn bound_inst(identity: usize) -> Option<(std::sync::Arc<oakplugin::handle::RefBox<Instance>>, *mut c_void)> {
|
||||
if common::test_plugin_scan_dir().is_none() {
|
||||
common::skip("最小测试插件未构建");
|
||||
return None;
|
||||
}
|
||||
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
|
||||
let dirs = [dir.as_ptr()];
|
||||
unsafe { oakplugin::ffi::oakplugin_host_scan(dirs.as_ptr(), 1) };
|
||||
let inst = oakplugin::host::Host::global()
|
||||
.create_instance(TEST_PLUGIN_ID, None)
|
||||
.ok()?;
|
||||
inst.value.bind_node(identity);
|
||||
let ih = tag::make(&inst.value.props as *const PropertySet, tag::INSTANCE);
|
||||
let name = cs("gain");
|
||||
let mut ph: *mut c_void = std::ptr::null_mut();
|
||||
let s = param_suite();
|
||||
let r = unsafe { (s.param_get_handle)(ih, name.as_ptr(), &mut ph, std::ptr::null_mut()) };
|
||||
assert_eq!(r, OK, "paramGetHandle(gain) 失败: {r}");
|
||||
Some((inst, ph))
|
||||
}
|
||||
|
||||
/// 节点→插件:oaknode 输入值变化经桥写入 OFX 参数(类型映射表
|
||||
/// 逐行:int/float/bool/color/vec2/vec3/combo/str_combo)。
|
||||
#[test]
|
||||
fn node_to_param_type_mapping() {
|
||||
use oakplugin::bridge::node::{node_value_type as T, Value};
|
||||
|
||||
// Integer ← INT(num 载荷)。
|
||||
let p = make_param(oakplugin::param::TYPE_INTEGER);
|
||||
let mut v = Value::default();
|
||||
v.r#type = T::INT;
|
||||
v.num = 7;
|
||||
p.set_from_node(&v);
|
||||
assert_eq!(p.get(), oakplugin::param::ParamValue::Int([7, 0, 0], 1));
|
||||
|
||||
// Double ← FLOAT(f[0] 载荷)。
|
||||
let p = make_param(oakplugin::param::TYPE_DOUBLE);
|
||||
p.set_from_node(&Value::float(1.5));
|
||||
assert_eq!(p.get(), oakplugin::param::ParamValue::Double([1.5, 0.0, 0.0], 1));
|
||||
|
||||
// Boolean ← BOOL(num 0/1)。
|
||||
let p = make_param(oakplugin::param::TYPE_BOOLEAN);
|
||||
p.set_from_node(&Value::bool_(true));
|
||||
assert_eq!(p.get(), oakplugin::param::ParamValue::Bool(true));
|
||||
|
||||
// Choice ← COMBO。
|
||||
let p = make_param(oakplugin::param::TYPE_CHOICE);
|
||||
p.set_from_node(&Value::combo(2));
|
||||
assert_eq!(p.get(), oakplugin::param::ParamValue::Choice(2));
|
||||
|
||||
// RGBA ← COLOR(r,g,b,a)。
|
||||
let p = make_param(oakplugin::param::TYPE_RGBA);
|
||||
p.set_from_node(&Value::color(0.1, 0.2, 0.3, 0.9));
|
||||
assert_eq!(
|
||||
p.get(),
|
||||
oakplugin::param::ParamValue::Color([0.1, 0.2, 0.3, 0.9], 4)
|
||||
);
|
||||
|
||||
// RGB ← COLOR(alpha 忽略,恒 0 占位)。
|
||||
let p = make_param(oakplugin::param::TYPE_RGB);
|
||||
p.set_from_node(&Value::color(0.1, 0.2, 0.3, 0.5));
|
||||
assert_eq!(p.get(), oakplugin::param::ParamValue::Color([0.1, 0.2, 0.3, 0.0], 3));
|
||||
|
||||
// Double2D ← VEC2;Double3D ← VEC3。
|
||||
let p = make_param(oakplugin::param::TYPE_DOUBLE2D);
|
||||
p.set_from_node(&Value::vec(&[1.0, 2.0]));
|
||||
assert_eq!(p.get(), oakplugin::param::ParamValue::Double([1.0, 2.0, 0.0], 2));
|
||||
let p = make_param(oakplugin::param::TYPE_DOUBLE3D);
|
||||
p.set_from_node(&Value::vec(&[1.0, 2.0, 3.0]));
|
||||
assert_eq!(p.get(), oakplugin::param::ParamValue::Double([1.0, 2.0, 3.0], 3));
|
||||
|
||||
// Integer2D ← VEC2 / Integer3D ← VEC3(浮点截断为 int)。
|
||||
let p = make_param(oakplugin::param::TYPE_INTEGER2D);
|
||||
p.set_from_node(&Value::vec(&[1.5, 2.5]));
|
||||
assert_eq!(p.get(), oakplugin::param::ParamValue::Int([1, 2, 0], 2));
|
||||
let p = make_param(oakplugin::param::TYPE_INTEGER3D);
|
||||
p.set_from_node(&Value::vec(&[1.5, 2.5, 3.5]));
|
||||
assert_eq!(p.get(), oakplugin::param::ParamValue::Int([1, 2, 3], 3));
|
||||
|
||||
// String 参数 + STRING 节点类型:POD 不携带数据 → 值不变
|
||||
// (保持默认空串;字符串值走 facade 的字符串 API)。
|
||||
let p = make_param(oakplugin::param::TYPE_STRING);
|
||||
p.set_from_node(&Value::string());
|
||||
assert!(
|
||||
matches!(p.get(), oakplugin::param::ParamValue::String(s) if s.to_bytes().is_empty())
|
||||
);
|
||||
|
||||
// 类型不匹配 → 忽略(保持现值)。
|
||||
let p = make_param(oakplugin::param::TYPE_DOUBLE);
|
||||
p.set_ofx(oakplugin::param::ParamValue::Double([5.0, 0.0, 0.0], 1));
|
||||
p.set_from_node(&Value::int(9)); // INT 不是 FLOAT
|
||||
assert_eq!(p.get(), oakplugin::param::ParamValue::Double([5.0, 0.0, 0.0], 1));
|
||||
}
|
||||
|
||||
/// 插件→节点:paramSetValue(插件自改)经 instanceChanged 桥回写
|
||||
/// oaknode,且打包为一条 undo 命令(undo/redo 后值正确)。
|
||||
#[test]
|
||||
fn param_to_node_undoable_writeback() {
|
||||
common::with_host(|| {
|
||||
let Some((inst, gain_h)) = bound_inst(42) else { return };
|
||||
let s = param_suite();
|
||||
|
||||
#[cfg(feature = "test-stubs")]
|
||||
{
|
||||
use oakplugin::bridge::{node, undo};
|
||||
node::stub::reset();
|
||||
undo::stub::reset();
|
||||
node::stub::register_node(42);
|
||||
node::stub::set_input(42, "gain", node::Value::float(0.5));
|
||||
|
||||
// 插件自改 → 回写 + 单命令立即 redo。
|
||||
let r = unsafe { (s.param_set_value)(gain_h, 0.75) };
|
||||
assert_eq!(r, OK, "paramSetValue 失败: {r}");
|
||||
let cur = node::stub::input(42, "gain").expect("已回写节点输入");
|
||||
assert_eq!(cur.value.f[0], 0.75);
|
||||
|
||||
// undoable:命令捕获 prev/next 且已应用(redo 语义)。
|
||||
let rec = undo::stub::last_command().expect("回写产生命令");
|
||||
assert_eq!(rec.node, 42);
|
||||
assert_eq!(rec.input, "gain");
|
||||
assert!(!rec.is_multi);
|
||||
assert!(rec.applied);
|
||||
assert_eq!(rec.prev.f[0], 0.5, "prev = 修改前节点值");
|
||||
assert_eq!(rec.next.f[0], 0.75, "next = 修改后值");
|
||||
|
||||
// undo → 恢复旧值;redo → 再应用。
|
||||
undo::stub::undo(rec.id);
|
||||
assert_eq!(node::stub::input(42, "gain").unwrap().value.f[0], 0.5);
|
||||
undo::stub::redo(rec.id);
|
||||
assert_eq!(node::stub::input(42, "gain").unwrap().value.f[0], 0.75);
|
||||
|
||||
// 字符串参数:POD 无数据 → 经字符串桥回写。
|
||||
node::stub::set_input_string(42, "label", "old");
|
||||
let name = cs("label");
|
||||
let mut lh: *mut c_void = std::ptr::null_mut();
|
||||
let ih = tag::make(&inst.value.props as *const PropertySet, tag::INSTANCE);
|
||||
assert_eq!(
|
||||
unsafe { (s.param_get_handle)(ih, name.as_ptr(), &mut lh, std::ptr::null_mut()) },
|
||||
OK
|
||||
);
|
||||
let hello = cs("hello");
|
||||
assert_eq!(unsafe { (s.param_set_value)(lh, hello.as_ptr()) }, OK);
|
||||
let cur = node::stub::input(42, "label").expect("字符串已回写");
|
||||
assert_eq!(cur.string.as_deref(), Some("hello"));
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "test-stubs"))]
|
||||
{
|
||||
// 无 liboaknode:桥符号缺失 → node_from_identity 空句柄,
|
||||
// 回写 no-op;paramSetValue 仍成功且值本地生效。
|
||||
let r = unsafe { (s.param_set_value)(gain_h, 0.75) };
|
||||
assert_eq!(r, OK);
|
||||
let mut out = 0.0;
|
||||
assert_eq!(unsafe { (s.param_get_value)(gain_h, &mut out) }, OK);
|
||||
assert_eq!(out, 0.75);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 未绑定节点的实例收到 instanceChanged:no-op 不崩(身份注册表
|
||||
/// 查无此项的路径)。
|
||||
#[test]
|
||||
fn writeback_without_bound_node_is_noop() {
|
||||
common::with_host(|| {
|
||||
let Some((inst, gain_h)) = bound_inst(0) else { return };
|
||||
let s = param_suite();
|
||||
|
||||
// identity 0(未绑定):回写直接短路,值只写本地参数。
|
||||
assert_eq!(unsafe { (s.param_set_value)(gain_h, 0.5) }, OK);
|
||||
let mut out = 0.0;
|
||||
assert_eq!(unsafe { (s.param_get_value)(gain_h, &mut out) }, OK);
|
||||
assert_eq!(out, 0.5);
|
||||
|
||||
#[cfg(feature = "test-stubs")]
|
||||
{
|
||||
use oakplugin::bridge::{node, undo};
|
||||
node::stub::reset();
|
||||
undo::stub::reset();
|
||||
node::stub::register_node(9);
|
||||
|
||||
// 已绑定但身份在注册表查无 → node_from_identity 空句柄。
|
||||
inst.value.bind_node(9999);
|
||||
assert_eq!(unsafe { (s.param_set_value)(gain_h, 0.6) }, OK);
|
||||
assert!(
|
||||
undo::stub::records().is_empty(),
|
||||
"身份查无不应产生命令:{:?}",
|
||||
undo::stub::records()
|
||||
);
|
||||
|
||||
// 已登记身份但输入名不存在 → 桥返回错误,同样 no-op。
|
||||
inst.value.bind_node(9);
|
||||
assert_eq!(unsafe { (s.param_set_value)(gain_h, 0.7) }, OK);
|
||||
assert!(undo::stub::records().is_empty(), "输入不存在不应产生命令");
|
||||
|
||||
// 节点输入值保持未回写(本地参数值仍有效)。
|
||||
let mut out = 0.0;
|
||||
assert_eq!(unsafe { (s.param_get_value)(gain_h, &mut out) }, OK);
|
||||
assert_eq!(out, 0.7);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// clip 输入:oakrender 纹理挂接后 fetch_image 读回的像素与
|
||||
/// 纹理内容一致(F32 格式不丢精度)。
|
||||
#[test]
|
||||
fn clip_texture_roundtrip_f32() {
|
||||
let clip = make_clip("Source");
|
||||
let scale = RenderScale { x: 1.0, y: 1.0 };
|
||||
|
||||
// 未挂纹理 → NotFound(两模式一致)。
|
||||
assert!(matches!(
|
||||
clip.fetch_image(0.0, scale, None),
|
||||
Err(oakplugin::error::Error::NotFound)
|
||||
));
|
||||
// 子区域第 1 期不支持 → Failed。
|
||||
assert!(matches!(
|
||||
clip.fetch_image(
|
||||
0.0,
|
||||
scale,
|
||||
Some(OfxRectD { x1: 0.0, y1: 0.0, x2: 2.0, y2: 2.0 })
|
||||
),
|
||||
Err(oakplugin::error::Error::Failed(_))
|
||||
));
|
||||
|
||||
#[cfg(feature = "test-stubs")]
|
||||
{
|
||||
use oakplugin::bridge::render::{self, PIXEL_FORMAT_F32, PIXEL_FORMAT_U8};
|
||||
render::stub::reset();
|
||||
|
||||
// 构造可识别的 F32 RGBA 帧(r=行, g=列, b=0, a=1)。
|
||||
let (w, h) = (8usize, 4usize);
|
||||
let mut pixels = Vec::with_capacity(w * h * 16);
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
for v in [y as f32, x as f32, 0.0, 1.0] {
|
||||
pixels.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
render::stub::setup_src(w as i32, h as i32, PIXEL_FORMAT_F32, pixels.clone());
|
||||
let tex = fake_texture(0xA2);
|
||||
clip.set_input_texture(tex, 0.0);
|
||||
let img = clip.fetch_image(0.0, scale, None).expect("fetch_image 成功");
|
||||
assert_eq!(img.pixels(), &pixels, "F32 像素 round-trip 不丢精度");
|
||||
assert_eq!(img.components(), Components::Rgba);
|
||||
assert_eq!(img.depth(), BitDepth::Float);
|
||||
|
||||
// dummy 纹理输入 → 空输入(NotFound)。
|
||||
render::stub::set_dummy(0xA2, true);
|
||||
assert!(matches!(
|
||||
clip.fetch_image(0.0, scale, None),
|
||||
Err(oakplugin::error::Error::NotFound)
|
||||
));
|
||||
render::stub::set_dummy(0xA2, false);
|
||||
|
||||
// 非 F32 输入帧 → 明确失败。
|
||||
render::stub::setup_src(w as i32, h as i32, PIXEL_FORMAT_U8, vec![0u8; w * h * 4]);
|
||||
assert!(matches!(
|
||||
clip.fetch_image(0.0, scale, None),
|
||||
Err(oakplugin::error::Error::Failed(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// 输出:store_output_image 产出的纹理在 oakrender 侧可读且
|
||||
/// 像素一致;dummy 纹理输入按空输入处理。
|
||||
#[test]
|
||||
fn output_image_to_texture() {
|
||||
let clip = make_clip("Output");
|
||||
let img = Image::allocate(
|
||||
BitDepth::Float,
|
||||
Components::Rgba,
|
||||
OfxRectD { x1: 0.0, y1: 0.0, x2: 8.0, y2: 4.0 },
|
||||
);
|
||||
|
||||
// 未挂输出纹理 → NotFound(两模式一致)。
|
||||
assert!(matches!(
|
||||
clip.store_output_image(&img),
|
||||
Err(oakplugin::error::Error::NotFound)
|
||||
));
|
||||
|
||||
#[cfg(feature = "test-stubs")]
|
||||
{
|
||||
use oakplugin::bridge::render::{self, PIXEL_FORMAT_F32, PIXEL_FORMAT_U8};
|
||||
render::stub::reset();
|
||||
render::stub::setup_dst(8, 4, PIXEL_FORMAT_F32);
|
||||
let tex = fake_texture(0xA1);
|
||||
clip.set_output_texture(tex, 0.0);
|
||||
|
||||
// 填非平凡像素后回写 → oakrender 侧读到的与图像一致。
|
||||
let mut img = img;
|
||||
for (i, b) in img.pixels_mut().iter_mut().enumerate() {
|
||||
*b = (i % 256) as u8;
|
||||
}
|
||||
let tex2 = clip.store_output_image(&img).expect("store_output_image 成功");
|
||||
assert_eq!(tex2.ctx, tex.ctx, "返回挂入的纹理句柄");
|
||||
assert_eq!(render::stub::dst_pixels(), img.pixels(), "输出像素一致");
|
||||
|
||||
// dummy 输出纹理 → NotFound。
|
||||
render::stub::set_dummy(0xA1, true);
|
||||
assert!(matches!(
|
||||
clip.store_output_image(&img),
|
||||
Err(oakplugin::error::Error::NotFound)
|
||||
));
|
||||
render::stub::set_dummy(0xA1, false);
|
||||
|
||||
// 帧尺寸与图像不一致 → 明确失败。
|
||||
render::stub::setup_dst(16, 4, PIXEL_FORMAT_F32);
|
||||
assert!(matches!(
|
||||
clip.store_output_image(&img),
|
||||
Err(oakplugin::error::Error::Failed(_))
|
||||
));
|
||||
|
||||
// 非 F32 输出帧 → 明确失败。
|
||||
render::stub::setup_dst(8, 4, PIXEL_FORMAT_U8);
|
||||
assert!(matches!(
|
||||
clip.store_output_image(&img),
|
||||
Err(oakplugin::error::Error::Failed(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// undo 打包:一次编辑事务(paramEditBegin/End)内的多次参数
|
||||
/// 修改合并为一条命令。
|
||||
#[test]
|
||||
fn edit_transaction_coalescing() {
|
||||
common::with_host(|| {
|
||||
let Some((_inst, gain_h)) = bound_inst(42) else { return };
|
||||
let s = param_suite();
|
||||
|
||||
#[cfg(feature = "test-stubs")]
|
||||
{
|
||||
use oakplugin::bridge::{node, undo};
|
||||
node::stub::reset();
|
||||
undo::stub::reset();
|
||||
node::stub::register_node(42);
|
||||
node::stub::set_input(42, "gain", node::Value::float(0.0));
|
||||
|
||||
// 编辑事务内两次回写 → 一条 multi。
|
||||
assert_eq!(unsafe { (s.param_edit_begin)(gain_h) }, OK);
|
||||
assert_eq!(unsafe { (s.param_set_value)(gain_h, 0.25) }, OK);
|
||||
assert_eq!(unsafe { (s.param_set_value)(gain_h, 0.5) }, OK);
|
||||
assert_eq!(unsafe { (s.param_edit_end)(gain_h) }, OK);
|
||||
|
||||
let recs = undo::stub::records();
|
||||
let multi = recs.iter().find(|r| r.is_multi).expect("存在 multi 命令");
|
||||
assert_eq!(multi.children.len(), 2, "两次修改合并为一条 multi");
|
||||
for c in &multi.children {
|
||||
let child = recs.iter().find(|r| r.id == *c).expect("子命令在表");
|
||||
assert!(!child.is_multi);
|
||||
assert!(child.applied, "子命令已 redo 生效");
|
||||
assert_eq!(child.node, 42);
|
||||
assert_eq!(child.input, "gain");
|
||||
}
|
||||
// 值即时生效(事务内子命令 redo;editEnd 的 multi redo 幂等)。
|
||||
assert_eq!(node::stub::input(42, "gain").unwrap().value.f[0], 0.5);
|
||||
|
||||
// 事务外回写 → 独立单命令(不并入 multi)。
|
||||
let before = undo::stub::records().len();
|
||||
assert_eq!(unsafe { (s.param_set_value)(gain_h, 0.75) }, OK);
|
||||
let after = undo::stub::records().len();
|
||||
assert_eq!(after, before + 1, "事务外每次回写一条独立命令");
|
||||
let last = undo::stub::last_command().unwrap();
|
||||
assert!(!last.is_multi);
|
||||
assert_eq!(node::stub::input(42, "gain").unwrap().value.f[0], 0.75);
|
||||
|
||||
// 嵌套 editBegin/End:内层结束不提交(multi 保留未 free),
|
||||
// 最外层结束才提交(redo + free)。
|
||||
assert_eq!(unsafe { (s.param_edit_begin)(gain_h) }, OK);
|
||||
assert_eq!(unsafe { (s.param_edit_begin)(gain_h) }, OK);
|
||||
assert_eq!(unsafe { (s.param_set_value)(gain_h, 1.0) }, OK);
|
||||
assert_eq!(unsafe { (s.param_edit_end)(gain_h) }, OK);
|
||||
let newest_multi = || {
|
||||
undo::stub::records()
|
||||
.iter()
|
||||
.filter(|r| r.is_multi)
|
||||
.max_by_key(|r| r.id)
|
||||
.cloned()
|
||||
.expect("存在 multi")
|
||||
};
|
||||
let m = newest_multi();
|
||||
assert_eq!(m.children.len(), 1, "事务内回写并入新 multi");
|
||||
assert!(!m.freed, "内层 editEnd 不提交");
|
||||
assert_eq!(unsafe { (s.param_edit_end)(gain_h) }, OK);
|
||||
assert!(newest_multi().freed, "最外层 editEnd 提交(redo+free)");
|
||||
assert_eq!(node::stub::input(42, "gain").unwrap().value.f[0], 1.0);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "test-stubs"))]
|
||||
{
|
||||
// 无桥:editBegin/End 与回写 no-op;参数值本地生效。
|
||||
assert_eq!(unsafe { (s.param_edit_begin)(gain_h) }, OK);
|
||||
assert_eq!(unsafe { (s.param_set_value)(gain_h, 0.25) }, OK);
|
||||
assert_eq!(unsafe { (s.param_edit_end)(gain_h) }, OK);
|
||||
let mut out = 0.0;
|
||||
assert_eq!(unsafe { (s.param_get_value)(gain_h, &mut out) }, OK);
|
||||
assert_eq!(out, 0.25);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 系统级冒烟:真实 OFX 插件 bundle(/Library/OFX/Plugins/
|
||||
/// Misc.ofx.bundle,本机安装的标准 openfx-misc)在存在时扫描 →
|
||||
/// describe → create_instance → 协商;absent 时 skip。
|
||||
///
|
||||
/// 这是 0 期 golden master 的轻量替代(0 期基建可能尚未落地——见
|
||||
/// README「与 M11 §3.5 的对照」):只验"能加载、能建实例、协商
|
||||
/// 有结果",不比对快照。
|
||||
#[test]
|
||||
fn system_misc_ofx_bundle_smoke() {
|
||||
let bundle = std::path::Path::new("/Library/OFX/Plugins/Misc.ofx.bundle");
|
||||
if !bundle.is_dir() {
|
||||
common::skip("/Library/OFX/Plugins/Misc.ofx.bundle 不存在");
|
||||
return;
|
||||
}
|
||||
common::with_host(|| {
|
||||
use oakplugin::host::Host;
|
||||
let host = Host::global();
|
||||
// 只扫 Misc:临时目录内建软链(scan_path 会 canonicalize)。
|
||||
let tmp = std::env::temp_dir().join(format!("oak-misc-scan-{}", std::process::id()));
|
||||
let link = tmp.join("Misc.ofx.bundle");
|
||||
if !link.exists() {
|
||||
std::fs::create_dir_all(&tmp).ok();
|
||||
std::os::unix::fs::symlink(bundle, &link).expect("建软链");
|
||||
}
|
||||
host.cache.scan_path(&tmp).expect("扫描 Misc.ofx.bundle");
|
||||
std::fs::remove_dir_all(&tmp).ok();
|
||||
|
||||
// bundle 内的插件已 describe 入缓存。
|
||||
let ids: Vec<String> = (0..host.cache.count())
|
||||
.filter_map(|i| host.cache.at(i).map(|p| p.identifier.clone()))
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
// 本机 bundle 若为 Natron 分支构建(describe 返回
|
||||
// MissingHostFeature),扫描必然为空——README 已记录该
|
||||
// 不兼容;兼容 bundle 的机器上此处照常断言。
|
||||
common::skip("/Library/OFX/Plugins/Misc.ofx.bundle 为 Natron 分支构建(describe 返回 MissingHostFeature)或空 bundle");
|
||||
return;
|
||||
}
|
||||
println!("Misc.ofx.bundle 扫描到 {} 个插件:{ids:?}", ids.len());
|
||||
|
||||
// create_instance 冒烟(首个插件的首选上下文)。
|
||||
let first = host.cache.at(0).expect("首个插件").identifier.clone();
|
||||
let inst = host.create_instance(&first, None);
|
||||
assert!(inst.is_ok(), "create_instance({first}) 应成功");
|
||||
// 协商冒烟(输出 clip 的分量/位深/帧率应返回)。
|
||||
let inst = inst.unwrap();
|
||||
let prefs = inst.value.get_clip_preferences();
|
||||
assert!(prefs.is_ok(), "getClipPreferences({first}) 应成功: {prefs:?}");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! ofxColour(M11 §4):宿主 OCIO 能力宣告、实例协商属性、输入 clip
|
||||
//! 工作空间(ACEScg)、GetOutputColourspace 往返(偏好采纳 +
|
||||
//! 交叉引用解析 + 输出写回)。
|
||||
|
||||
mod common;
|
||||
|
||||
use std::ffi::{c_void, CString};
|
||||
|
||||
use oakplugin::ffi::{oakplugin_host_scan, oakplugin_instance_create, oakplugin_instance_free};
|
||||
use oakplugin::handle::{get, CHandle};
|
||||
use oakplugin::host::Host;
|
||||
use oakplugin::property::Value;
|
||||
|
||||
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
|
||||
|
||||
fn cs(s: &str) -> CString {
|
||||
CString::new(s).unwrap()
|
||||
}
|
||||
|
||||
fn scan_and_create(id: &str) -> CHandle {
|
||||
if common::test_plugin_scan_dir().is_none() {
|
||||
common::skip("最小测试插件未构建");
|
||||
return CHandle::null();
|
||||
}
|
||||
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
|
||||
let dirs = [dir.as_ptr()];
|
||||
unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) };
|
||||
let id = cs(id);
|
||||
unsafe { oakplugin_instance_create(id.as_ptr()) }
|
||||
}
|
||||
|
||||
fn instance_of(h: &CHandle) -> Option<std::sync::Arc<oakplugin::handle::RefBox<oakplugin::instance::Instance>>> {
|
||||
unsafe { get::<std::sync::Arc<oakplugin::handle::RefBox<oakplugin::instance::Instance>>>(h) }
|
||||
.cloned()
|
||||
}
|
||||
|
||||
fn prop_str(props: &oakplugin::property::PropertySet, name: &str) -> Option<String> {
|
||||
match props.get(name, 0)? {
|
||||
Value::String(s) => Some(s.to_string_lossy().into_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 宿主能力宣告:OCIO 模式 + native 配置列表 + GL 支持。
|
||||
#[test]
|
||||
fn host_declares_ocio_capability() {
|
||||
let host = Host::global();
|
||||
assert_eq!(
|
||||
prop_str(&host.props, "OfxImageEffectPropColourManagementStyle").as_deref(),
|
||||
Some("OfxImageEffectColourManagementOCIO")
|
||||
);
|
||||
assert_eq!(
|
||||
host.props.dimension("OfxImageEffectPropColourManagementAvailableConfigs"),
|
||||
1,
|
||||
"可用配置至少含 ofx-native-v1.5_aces-v1.3_ocio-v2.3"
|
||||
);
|
||||
assert_eq!(
|
||||
prop_str(&host.props, "OfxImageEffectPropOpenGLRenderSupported").as_deref(),
|
||||
Some("true")
|
||||
);
|
||||
}
|
||||
|
||||
/// 实例期协商属性:style/config/OCIOConfig 已写;输入 clip 色彩空间
|
||||
/// = 工作空间 ACEScg;输出 clip 未设(待 GetOutputColourspace)。
|
||||
#[test]
|
||||
fn instance_and_clip_colourspace_props() {
|
||||
common::with_host(|| {
|
||||
let mut h = scan_and_create(TEST_PLUGIN_ID);
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
let inst = instance_of(&h).expect("句柄应可解析");
|
||||
assert_eq!(
|
||||
prop_str(&inst.value.props, "OfxImageEffectPropColourManagementStyle").as_deref(),
|
||||
Some("OfxImageEffectColourManagementOCIO")
|
||||
);
|
||||
assert_eq!(
|
||||
prop_str(&inst.value.props, "OfxImageEffectPropColourManagementConfig").as_deref(),
|
||||
Some("ofx-native-v1.5_aces-v1.3_ocio-v2.3")
|
||||
);
|
||||
assert_eq!(
|
||||
prop_str(&inst.value.props, "OfxImageEffectPropOCIOConfig").as_deref(),
|
||||
Some("ocio://default")
|
||||
);
|
||||
let source = inst.value.clips.iter().find(|c| c.name == "Source").unwrap();
|
||||
assert_eq!(
|
||||
prop_str(&source.props, "OfxImageClipPropColourspace").as_deref(),
|
||||
Some("ACEScg"),
|
||||
"输入 clip 色彩空间 = 工作空间"
|
||||
);
|
||||
let output = inst.value.clips.iter().find(|c| c.name == "Output").unwrap();
|
||||
assert!(
|
||||
prop_str(&output.props, "OfxImageClipPropColourspace")
|
||||
.map(|s| s.is_empty())
|
||||
.unwrap_or(true),
|
||||
"输出 clip 色彩空间在 GetOutputColourspace 前未设"
|
||||
);
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// GetOutputColourspace 往返:无偏好 → 插件回 "OfxColourspace_Source"
|
||||
/// 交叉引用 → 宿主解析为 Source 的实际色彩空间(ACEScg)并写回输出。
|
||||
#[test]
|
||||
fn get_output_colourspace_cross_reference() {
|
||||
common::with_host(|| {
|
||||
let mut h = scan_and_create(TEST_PLUGIN_ID);
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
let inst = instance_of(&h).expect("句柄应可解析");
|
||||
let cs = inst
|
||||
.value
|
||||
.get_output_colourspace(&[])
|
||||
.expect("GetOutputColourspace 应成功");
|
||||
assert_eq!(cs, "ACEScg", "交叉引用 OfxColourspace_Source → ACEScg");
|
||||
inst.value.set_output_colourspace(&cs);
|
||||
let output = inst.value.clips.iter().find(|c| c.name == "Output").unwrap();
|
||||
assert_eq!(
|
||||
prop_str(&output.props, "OfxImageClipPropColourspace").as_deref(),
|
||||
Some("ACEScg"),
|
||||
"输出 clip 已写回解析后的色彩空间"
|
||||
);
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// GetOutputColourspace 偏好采纳:插件优先取宿主偏好的第一个色彩
|
||||
/// 空间。
|
||||
#[test]
|
||||
fn get_output_colourspace_preferred() {
|
||||
common::with_host(|| {
|
||||
let mut h = scan_and_create(TEST_PLUGIN_ID);
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
let inst = instance_of(&h).expect("句柄应可解析");
|
||||
let cs = inst
|
||||
.value
|
||||
.get_output_colourspace(&["ACEScg".to_string(), "linear".to_string()])
|
||||
.expect("GetOutputColourspace 应成功");
|
||||
assert_eq!(cs, "ACEScg", "偏好列表第一个被采纳");
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// 交叉引用解析的边界:未知 clip 引用原样返回;非交叉引用原样返回。
|
||||
#[test]
|
||||
fn resolve_colourspace_edge_cases() {
|
||||
common::with_host(|| {
|
||||
let mut h = scan_and_create(TEST_PLUGIN_ID);
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
let inst = instance_of(&h).expect("句柄应可解析");
|
||||
assert_eq!(
|
||||
inst.value.resolve_colourspace("OfxColourspace_NoSuchClip".to_string()),
|
||||
"OfxColourspace_NoSuchClip",
|
||||
"未知 clip 引用保持原样"
|
||||
);
|
||||
assert_eq!(
|
||||
inst.value.resolve_colourspace("ACEScg".to_string()),
|
||||
"ACEScg",
|
||||
"非交叉引用原样返回"
|
||||
);
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 测试公共件:最小测试插件定位、快照/golden 文件路径、夹具。
|
||||
//!
|
||||
//! 最小测试插件(cbits/oak_test_plugin.c,build.rs 编译为共享库,
|
||||
//! 运行时装配成 oak-test-plugin.ofx.bundle):filter 上下文、
|
||||
//! Double 参数 gain、双 clip(Source/Output)。插件未构建时相关
|
||||
//! 用例经 [`skip`] 提前返回。
|
||||
//! 测试桩在库内(bridge::render::stub,`--features test-stubs`):
|
||||
//! 全链路像素路径可在 cargo test 跑通;真实链路在 standalone
|
||||
//! ctest。本文档的 run 命令见 crate README。
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 构建系统注入插件路径的环境变量名。
|
||||
pub const TEST_PLUGIN_ENV: &str = "OAK_TEST_PLUGIN_DIR";
|
||||
|
||||
/// 测试插件 bundle 的绝对路径(由构建系统经环境变量注入;
|
||||
/// 未注入时用 build.rs 编的共享库现场装配 bundle 目录;均不可用
|
||||
/// 时返回 None,调用方 skip)。
|
||||
pub fn test_plugin_dir() -> Option<PathBuf> {
|
||||
if let Some(p) = std::env::var_os(TEST_PLUGIN_ENV) {
|
||||
return Some(PathBuf::from(p));
|
||||
}
|
||||
static BUNDLE: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
|
||||
BUNDLE
|
||||
.get_or_init(|| {
|
||||
let out = PathBuf::from(env!("OUT_DIR"));
|
||||
let lib = if cfg!(target_os = "macos") {
|
||||
out.join("oak_test_plugin.dylib")
|
||||
} else {
|
||||
out.join("oak_test_plugin.so")
|
||||
};
|
||||
if !lib.is_file() {
|
||||
return None;
|
||||
}
|
||||
let bundle = std::env::temp_dir()
|
||||
.join(format!("oak-test-plugin-{}", std::process::id()))
|
||||
.join("oak-test-plugin.ofx.bundle");
|
||||
let platform = if cfg!(target_os = "macos") { "MacOS" } else { "Linux-x86-64" };
|
||||
let bin_dir = bundle.join("Contents").join(platform);
|
||||
std::fs::create_dir_all(&bin_dir).ok()?;
|
||||
let target = bin_dir.join("plugin");
|
||||
if !target.exists() {
|
||||
std::fs::copy(&lib, &target).ok()?;
|
||||
}
|
||||
Some(bundle)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 测试插件 bundle 的父目录(host_scan 的入参)。
|
||||
pub fn test_plugin_scan_dir() -> Option<PathBuf> {
|
||||
let bundle = test_plugin_dir()?;
|
||||
let parent = bundle.parent()?;
|
||||
// scan_path 会 canonicalize;bundle 在临时目录下,直接给父目录。
|
||||
Some(parent.to_path_buf())
|
||||
}
|
||||
|
||||
/// golden master 目录(tests/ofx/)。描述符快照 JSON 与 EXR 帧都在这。
|
||||
pub fn golden_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/ofx")
|
||||
}
|
||||
|
||||
/// 当前平台是否有 GPU(GL golden 用例的门:无 GPU 一律 skip)。
|
||||
///
|
||||
/// 第 1 期无 GL 用例:显式环境变量开启(GL 验收需人工本机确认,
|
||||
/// CI 一律跳过)。
|
||||
pub fn gpu_available() -> bool {
|
||||
std::env::var_os("OAK_GPU_TESTS").is_some()
|
||||
}
|
||||
|
||||
/// 通用 skip 宏的函数形态:前置条件不满足时打印原因并提前返回。
|
||||
/// (cargo 没有 GTEST_SKIP,约定为 `return`,并在输出里打印 SKIP。)
|
||||
pub fn skip(reason: &str) {
|
||||
println!("SKIP: {reason}");
|
||||
}
|
||||
|
||||
/// 宿主单例测试串行化:同一二进制的测试并行跑会互相踩
|
||||
/// init/shutdown/scan(进程单例无锁);所有触碰宿主面的用例经它。
|
||||
pub fn with_host(f: impl FnOnce()) {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
f();
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! FFI 出口层(ffi.rs)契约测试:host.h 面。
|
||||
//!
|
||||
//! 每个导出函数至少一正常一错误路径(项目铁律)。
|
||||
//! 宿主单例无锁:全部用例经 [`common::with_host`] 串行化。
|
||||
//! 需要最小测试插件(build.rs 编,common::test_plugin_dir 装配);
|
||||
//! 不可用时 skip。
|
||||
|
||||
mod common;
|
||||
|
||||
use std::ffi::{c_char, c_int, CStr, CString};
|
||||
|
||||
use oakplugin::ffi::{
|
||||
oakplugin_host_init, oakplugin_host_plugin_count, oakplugin_host_plugin_id_at,
|
||||
oakplugin_host_plugin_label, oakplugin_host_scan, oakplugin_host_set_message_handler,
|
||||
oakplugin_host_shutdown,
|
||||
};
|
||||
|
||||
const E_INVALID: i32 = -90001;
|
||||
const E_NOT_FOUND: i32 = -90004;
|
||||
const OK: i32 = 0;
|
||||
|
||||
fn cs(s: &str) -> CString {
|
||||
CString::new(s).unwrap()
|
||||
}
|
||||
|
||||
/// 扫描测试插件;失败/缺失返回 false(调用方 skip)。
|
||||
fn scan_test_plugin() -> bool {
|
||||
let Some(dir) = common::test_plugin_scan_dir() else {
|
||||
common::skip("最小测试插件未构建");
|
||||
return false;
|
||||
};
|
||||
let dir = cs(dir.to_str().unwrap());
|
||||
let dirs = [dir.as_ptr()];
|
||||
unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) == OK }
|
||||
}
|
||||
|
||||
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
|
||||
|
||||
/// init/shutdown 幂等:重复 init 无副作用;shutdown 后再 init 可用。
|
||||
#[test]
|
||||
fn host_init_shutdown_idempotent() {
|
||||
common::with_host(|| {
|
||||
assert_eq!(unsafe { oakplugin_host_init() }, OK);
|
||||
assert_eq!(unsafe { oakplugin_host_init() }, OK);
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
assert_eq!(unsafe { oakplugin_host_init() }, OK);
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
|
||||
/// scan 默认路径(NULL/0):返回 OAKPLUGIN_OK 或 E_FAILED(无插件
|
||||
/// 目录的机器),二者之外不允许别的码。
|
||||
#[test]
|
||||
fn host_scan_default_paths() {
|
||||
common::with_host(|| {
|
||||
unsafe { oakplugin_host_init() };
|
||||
let r = unsafe { oakplugin_host_scan(std::ptr::null(), 0) };
|
||||
assert!(r == OK || r == -90003, "scan(NULL,0) = {r}");
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
|
||||
/// scan 指定目录(测试插件所在):插件计数 ≥1 且能找到测试插件
|
||||
/// 标识;目录不存在返回 E_FAILED 而不崩。
|
||||
#[test]
|
||||
fn host_scan_explicit_dir() {
|
||||
common::with_host(|| {
|
||||
unsafe { oakplugin_host_init() };
|
||||
assert!(scan_test_plugin(), "测试插件扫描失败");
|
||||
assert!(unsafe { oakplugin_host_plugin_count() } >= 1);
|
||||
|
||||
// 标识可见(两段式第一段)。
|
||||
let len = unsafe { oakplugin_host_plugin_id_at(0, std::ptr::null_mut(), 0) };
|
||||
assert!(len > 0);
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
let r = unsafe { oakplugin_host_plugin_id_at(0, buf.as_mut_ptr() as *mut c_char, len) };
|
||||
assert_eq!(r, OK);
|
||||
assert_eq!(
|
||||
unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) }.to_str().unwrap(),
|
||||
TEST_PLUGIN_ID
|
||||
);
|
||||
|
||||
// 目录不存在:静默跳过返回 OK 而不崩(C++ olivehost.cpp:59-62
|
||||
// 的 add_plugin_path 语义——声明原期望 E_FAILED,与参照系
|
||||
// 不符,以 C++ 行为为准)。
|
||||
let nope = cs("/nonexistent/ofx/plugins");
|
||||
let dirs = [nope.as_ptr()];
|
||||
assert_eq!(unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) }, OK);
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
|
||||
/// plugin_id_at:两段式(先问长度再取内容);越界索引返回
|
||||
/// E_NOT_FOUND;缓冲区不足时返回所需长度且不越界写。
|
||||
#[test]
|
||||
fn host_plugin_id_at_two_stage() {
|
||||
common::with_host(|| {
|
||||
unsafe { oakplugin_host_init() };
|
||||
if !scan_test_plugin() {
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
return;
|
||||
}
|
||||
// 越界 → E_NOT_FOUND。
|
||||
assert_eq!(
|
||||
unsafe { oakplugin_host_plugin_id_at(99, std::ptr::null_mut(), 0) },
|
||||
E_NOT_FOUND
|
||||
);
|
||||
|
||||
// 缓冲区不足:返回所需长度(> 0),不越界写(哨兵在 [4]——
|
||||
// [3] 是 4 字节缓冲内的 NUL 位)。
|
||||
let mut small = [0x7fu8; 5];
|
||||
let r = unsafe { oakplugin_host_plugin_id_at(0, small.as_mut_ptr() as *mut c_char, 4) };
|
||||
assert!(r > 4);
|
||||
assert_eq!(small[4], 0x7f, "缓冲区外不应被写");
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
|
||||
/// plugin_label:已知插件返回非空 label;未知标识 E_NOT_FOUND;
|
||||
/// NULL 参数 E_INVALID。
|
||||
#[test]
|
||||
fn host_plugin_label_paths() {
|
||||
common::with_host(|| {
|
||||
unsafe { oakplugin_host_init() };
|
||||
if !scan_test_plugin() {
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
return;
|
||||
}
|
||||
let id = cs(TEST_PLUGIN_ID);
|
||||
let len = unsafe { oakplugin_host_plugin_label(id.as_ptr(), std::ptr::null_mut(), 0) };
|
||||
assert!(len > 0);
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
assert_eq!(
|
||||
unsafe { oakplugin_host_plugin_label(id.as_ptr(), buf.as_mut_ptr() as *mut c_char, len) },
|
||||
OK
|
||||
);
|
||||
assert!(!unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) }.to_bytes().is_empty());
|
||||
|
||||
// 未知标识。
|
||||
let nope = cs("org.oak.nope");
|
||||
assert_eq!(
|
||||
unsafe { oakplugin_host_plugin_label(nope.as_ptr(), std::ptr::null_mut(), 0) },
|
||||
E_NOT_FOUND
|
||||
);
|
||||
// NULL 参数。
|
||||
assert_eq!(
|
||||
unsafe { oakplugin_host_plugin_label(std::ptr::null(), std::ptr::null_mut(), 0) },
|
||||
E_INVALID
|
||||
);
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
|
||||
/// message handler:注册后测试插件的 message 回调到达(级别与
|
||||
/// 文本透传,含 %d 格式化);注册 NULL 后消息被丢弃而不崩。
|
||||
#[test]
|
||||
fn host_message_handler_dispatch() {
|
||||
common::with_host(|| {
|
||||
use oakplugin::ffi::{oakplugin_instance_create, oakplugin_instance_free};
|
||||
|
||||
unsafe extern "C" fn capture(
|
||||
type_: *const c_char,
|
||||
message: *const c_char,
|
||||
userdata: *mut std::ffi::c_void,
|
||||
) -> c_int {
|
||||
let v = unsafe { &mut *(userdata as *mut Vec<(String, String)>) };
|
||||
v.push((
|
||||
unsafe { CStr::from_ptr(type_) }.to_string_lossy().into_owned(),
|
||||
unsafe { CStr::from_ptr(message) }.to_string_lossy().into_owned(),
|
||||
));
|
||||
0
|
||||
}
|
||||
|
||||
unsafe { oakplugin_host_init() };
|
||||
if !scan_test_plugin() {
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
return;
|
||||
}
|
||||
|
||||
// 注册捕获器 → 创建实例(插件在 createInstance 发 message)。
|
||||
let mut captured: Vec<(String, String)> = Vec::new();
|
||||
unsafe {
|
||||
oakplugin_host_set_message_handler(
|
||||
Some(capture),
|
||||
&mut captured as *mut _ as *mut std::ffi::c_void,
|
||||
)
|
||||
};
|
||||
let id = cs(TEST_PLUGIN_ID);
|
||||
let mut h = unsafe { oakplugin_instance_create(id.as_ptr()) };
|
||||
assert!(!h.is_null());
|
||||
assert_eq!(captured.len(), 1);
|
||||
assert_eq!(captured[0].0, "OfxMessageMessage");
|
||||
assert_eq!(captured[0].1, "created id=7", "v1 变长格式化应生效");
|
||||
|
||||
// 注销 → 再创建不崩、无捕获。
|
||||
unsafe { oakplugin_host_set_message_handler(None, std::ptr::null_mut()) };
|
||||
captured.clear();
|
||||
let mut h2 = unsafe { oakplugin_instance_create(id.as_ptr()) };
|
||||
assert!(!h2.is_null());
|
||||
assert!(captured.is_empty());
|
||||
unsafe { oakplugin_instance_free(&mut h2) };
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! FFI 出口层契约测试:instance.h 面(含 M11 §2.1 内省族)。
|
||||
//!
|
||||
//! 宿主单例无锁:全部用例经 [`common::with_host`] 串行化。
|
||||
//! render 路径经 oakrender 测试桩(`--features test-stubs`);
|
||||
//! 未启用时相关用例 skip。
|
||||
|
||||
mod common;
|
||||
|
||||
use std::ffi::{c_char, c_int, c_void, CStr, CString};
|
||||
|
||||
use oakplugin::ffi::{
|
||||
node_value_type, oakplugin_debug_alive_count, oakplugin_instance_cancel,
|
||||
oakplugin_instance_clip_count, oakplugin_instance_clip_info, oakplugin_instance_clip_name,
|
||||
oakplugin_instance_create, oakplugin_instance_free, oakplugin_instance_get_param,
|
||||
oakplugin_instance_get_param_string, oakplugin_instance_param_choice_count,
|
||||
oakplugin_instance_param_choice_label, oakplugin_instance_param_count,
|
||||
oakplugin_instance_param_default_double, oakplugin_instance_param_default_string,
|
||||
oakplugin_instance_param_display_range, oakplugin_instance_param_hint,
|
||||
oakplugin_instance_param_label, oakplugin_instance_param_name, oakplugin_instance_param_parent,
|
||||
oakplugin_instance_param_secret, oakplugin_instance_param_type, oakplugin_instance_render,
|
||||
oakplugin_instance_set_param, oakplugin_instance_set_param_string,
|
||||
oakplugin_instance_set_progress_cb, OakNodeValue,
|
||||
};
|
||||
use oakplugin::handle::CHandle;
|
||||
|
||||
const E_INVALID: i32 = -90001;
|
||||
const E_NOT_FOUND: i32 = -90004;
|
||||
const OK: i32 = 0;
|
||||
|
||||
fn cs(s: &str) -> CString {
|
||||
CString::new(s).unwrap()
|
||||
}
|
||||
|
||||
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
|
||||
|
||||
/// 扫描测试插件并创建实例;不可用返回空句柄。
|
||||
fn create_instance() -> CHandle {
|
||||
if common::test_plugin_scan_dir().is_none() {
|
||||
common::skip("最小测试插件未构建");
|
||||
return CHandle::null();
|
||||
}
|
||||
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
|
||||
let dirs = [dir.as_ptr()];
|
||||
unsafe { oakplugin_host_scan_import(dirs.as_ptr(), 1) };
|
||||
let id = cs(TEST_PLUGIN_ID);
|
||||
unsafe { oakplugin_instance_create(id.as_ptr()) }
|
||||
}
|
||||
|
||||
// 避免重复 import host_scan(ffi 模块内路径统一)。
|
||||
use oakplugin::ffi::oakplugin_host_scan as oakplugin_host_scan_import;
|
||||
|
||||
/// 两段式读字符串。
|
||||
fn two_stage(get: unsafe extern "C" fn(CHandle, c_int, *mut c_char, c_int) -> c_int, h: CHandle, index: c_int) -> Option<String> {
|
||||
let len = unsafe { get(h, index, std::ptr::null_mut(), 0) };
|
||||
if len <= 0 {
|
||||
return None;
|
||||
}
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
let r = unsafe { get(h, index, buf.as_mut_ptr() as *mut c_char, len) };
|
||||
if r != OK {
|
||||
return None;
|
||||
}
|
||||
Some(unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) }.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// 简易实例句柄(测试桩纹理用)。
|
||||
fn fake_texture(ctx: usize) -> CHandle {
|
||||
CHandle {
|
||||
ctx: ctx as *mut c_void,
|
||||
addref: None,
|
||||
release: None,
|
||||
abi_version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// create/free:已知插件返回非空句柄;free 后 alive 回基线;
|
||||
/// free(NULL)/free(空) no-op。
|
||||
#[test]
|
||||
fn instance_create_free() {
|
||||
common::with_host(|| {
|
||||
unsafe { oakplugin_host_scan_import(std::ptr::null(), 0) };
|
||||
let base = unsafe { oakplugin_debug_alive_count() };
|
||||
let mut h = create_instance();
|
||||
assert!(!h.is_null());
|
||||
assert_eq!(unsafe { oakplugin_debug_alive_count() }, base + 1);
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
assert_eq!(unsafe { oakplugin_debug_alive_count() }, base);
|
||||
// free(NULL)/free(空) no-op。
|
||||
unsafe { oakplugin_instance_free(std::ptr::null_mut()) };
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// create 未知标识:返回空句柄(init 失败返回空,契约 §a)。
|
||||
#[test]
|
||||
fn instance_create_unknown_id() {
|
||||
common::with_host(|| {
|
||||
let id = cs("org.oak.does-not-exist");
|
||||
let h = unsafe { oakplugin_instance_create(id.as_ptr()) };
|
||||
assert!(h.is_null());
|
||||
});
|
||||
}
|
||||
|
||||
/// set/get_param(double):round-trip 一致;未知参数名
|
||||
/// E_NOT_FOUND;空句柄 E_INVALID;out 为 NULL 时 E_INVALID。
|
||||
#[test]
|
||||
fn instance_param_double_roundtrip() {
|
||||
common::with_host(|| {
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
let name = cs("gain");
|
||||
let mut v = OakNodeValue::default();
|
||||
v.r#type = node_value_type::FLOAT;
|
||||
v.f = [1.25, 0.0, 0.0, 0.0];
|
||||
assert_eq!(unsafe { oakplugin_instance_set_param(h, name.as_ptr(), &v) }, OK);
|
||||
let mut out = OakNodeValue::default();
|
||||
assert_eq!(unsafe { oakplugin_instance_get_param(h, name.as_ptr(), &mut out) }, OK);
|
||||
assert_eq!(out.r#type, node_value_type::FLOAT);
|
||||
assert_eq!(out.f[0], 1.25);
|
||||
|
||||
// 未知参数名 → E_NOT_FOUND。
|
||||
let nope = cs("nope");
|
||||
assert_eq!(unsafe { oakplugin_instance_set_param(h, nope.as_ptr(), &v) }, E_NOT_FOUND);
|
||||
// 空句柄 → E_INVALID。
|
||||
assert_eq!(unsafe { oakplugin_instance_set_param(CHandle::null(), name.as_ptr(), &v) }, E_INVALID);
|
||||
// out NULL → E_INVALID。
|
||||
assert_eq!(unsafe { oakplugin_instance_get_param(h, name.as_ptr(), std::ptr::null_mut()) }, E_INVALID);
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// set/get_param_string:round-trip 一致(含空串与长串两段式)。
|
||||
#[test]
|
||||
fn instance_param_string_roundtrip() {
|
||||
common::with_host(|| {
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
let name = cs("label");
|
||||
let value = cs("hello 你好");
|
||||
assert_eq!(unsafe { oakplugin_instance_set_param_string(h, name.as_ptr(), value.as_ptr()) }, OK);
|
||||
// 两段式。
|
||||
let len = unsafe { oakplugin_instance_get_param_string(h, name.as_ptr(), std::ptr::null_mut(), 0) };
|
||||
assert!(len > 0);
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
assert_eq!(unsafe { oakplugin_instance_get_param_string(h, name.as_ptr(), buf.as_mut_ptr() as *mut c_char, len) }, OK);
|
||||
assert_eq!(
|
||||
unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) }.to_string_lossy(),
|
||||
"hello 你好"
|
||||
);
|
||||
// 空串。
|
||||
let empty = cs("");
|
||||
assert_eq!(unsafe { oakplugin_instance_set_param_string(h, name.as_ptr(), empty.as_ptr()) }, OK);
|
||||
assert_eq!(unsafe { oakplugin_instance_get_param_string(h, name.as_ptr(), std::ptr::null_mut(), 0) }, 1);
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// render:对测试插件渲一帧,输出纹理像素符合插件的常量填充
|
||||
/// (0.5 RGBA F32,alpha=1)。time 为 NaN 时 E_INVALID。
|
||||
#[cfg(feature = "test-stubs")]
|
||||
#[test]
|
||||
fn instance_render_one_frame() {
|
||||
common::with_host(|| {
|
||||
use oakplugin::bridge::render::stub;
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
// 输出帧 320×240 F32。
|
||||
stub::setup_dst(320, 240, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
let dst = fake_texture(0xA1);
|
||||
let src = CHandle::null(); // 无输入(插件只写常量)
|
||||
assert_eq!(unsafe { oakplugin_instance_render(h, dst, src, 0.0) }, OK);
|
||||
|
||||
// 断言:常量 0.5,alpha 1.0(每像素 4 通道 × 4 字节 F32)。
|
||||
let pixels = stub::dst_pixels();
|
||||
let n = pixels.len() / 16;
|
||||
assert_eq!(n, 320 * 240, "像素数 = len/16 = {n}");
|
||||
for i in 0..n {
|
||||
// 像素基址 = i * 16(4 通道 × 4 字节)。
|
||||
let f = |o: usize| f32::from_le_bytes(pixels[i * 16 + o * 4..i * 16 + o * 4 + 4].try_into().unwrap());
|
||||
assert_eq!(f(0), 0.5, "pixel {i} r");
|
||||
assert_eq!(f(1), 0.5, "pixel {i} g");
|
||||
assert_eq!(f(2), 0.5, "pixel {i} b");
|
||||
assert_eq!(f(3), 1.0, "pixel {i} a");
|
||||
}
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// render 默认构建(真实 oakrender 链接):NULL 输出纹理在触达
|
||||
/// oakrender 之前被插件层校验拒绝(E_INVALID),不崩溃。真实纹理
|
||||
/// 路径需要 GL/GPU,属集成测试范畴。
|
||||
#[cfg(not(feature = "test-stubs"))]
|
||||
#[test]
|
||||
fn instance_render_one_frame() {
|
||||
common::with_host(|| {
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
let dst = CHandle::null();
|
||||
assert_eq!(unsafe { oakplugin_instance_render(h, dst, CHandle::null(), 0.0) }, -90001);
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// progress 回调:render 期间被调用且进度单调不减;回调返回非 0
|
||||
/// 时 render 以取消码结束(插件检查 progressUpdate 结果并中止)。
|
||||
#[cfg(feature = "test-stubs")]
|
||||
#[test]
|
||||
fn instance_progress_callback() {
|
||||
common::with_host(|| {
|
||||
use oakplugin::bridge::render::stub;
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
let mut seen: Vec<f64> = Vec::new();
|
||||
unsafe extern "C" fn capture(p: f64, userdata: *mut c_void) -> c_int {
|
||||
let v = unsafe { &mut *(userdata as *mut Vec<f64>) };
|
||||
v.push(p);
|
||||
0
|
||||
}
|
||||
unsafe {
|
||||
oakplugin_instance_set_progress_cb(
|
||||
h,
|
||||
Some(capture),
|
||||
&mut seen as *mut _ as *mut c_void,
|
||||
)
|
||||
};
|
||||
stub::setup_dst(64, 64, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
let dst = fake_texture(0xA1);
|
||||
assert_eq!(unsafe { oakplugin_instance_render(h, dst, CHandle::null(), 0.0) }, OK);
|
||||
assert_eq!(seen, vec![0.5], "插件在 render 内报一次 0.5");
|
||||
|
||||
// 取消回调(非 0 = 中止)。
|
||||
unsafe extern "C" fn abort_cb(_p: f64, _u: *mut c_void) -> c_int {
|
||||
1
|
||||
}
|
||||
unsafe { oakplugin_instance_set_progress_cb(h, Some(abort_cb), std::ptr::null_mut()) };
|
||||
stub::setup_dst(64, 64, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
let r = unsafe { oakplugin_instance_render(h, dst, CHandle::null(), 0.0) };
|
||||
assert_eq!(r, -90003, "取消应使 render 失败");
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// cancel:无活动渲染时 no-op;置位后 render 立即以取消码返回。
|
||||
#[test]
|
||||
fn instance_cancel_semantics() {
|
||||
common::with_host(|| {
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
// 无活动渲染:OK no-op。
|
||||
assert_eq!(unsafe { oakplugin_instance_cancel(h) }, OK);
|
||||
// 置位后 render 入口即取消(先备好合法输出帧,确保走到
|
||||
// render 的取消检查而不是帧校验)。
|
||||
assert_eq!(unsafe { oakplugin_instance_cancel(h) }, OK);
|
||||
#[cfg(feature = "test-stubs")]
|
||||
oakplugin::bridge::render::stub::setup_dst(64, 64, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
let dst = fake_texture(0xA1);
|
||||
assert_eq!(unsafe { oakplugin_instance_render(h, dst, CHandle::null(), 0.0) }, -90003);
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// 内省:param_count > 0;name/type/label/hint/parent 与插件定义
|
||||
/// 一致;index 越界 E_NOT_FOUND。
|
||||
#[test]
|
||||
fn introspection_param_fields() {
|
||||
common::with_host(|| {
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
let count = unsafe { oakplugin_instance_param_count(h) };
|
||||
assert!(count >= 4, "gain/mode/debug/label,count={count}");
|
||||
|
||||
// gain 的字段。
|
||||
assert_eq!(two_stage(oakplugin_instance_param_name, h, 0), Some("gain".into()));
|
||||
assert_eq!(two_stage(oakplugin_instance_param_type, h, 0), Some("OfxParamTypeDouble".into()));
|
||||
assert_eq!(two_stage(oakplugin_instance_param_label, h, 0), Some("Gain".into()));
|
||||
assert_eq!(two_stage(oakplugin_instance_param_hint, h, 0), Some(String::new()));
|
||||
assert_eq!(two_stage(oakplugin_instance_param_parent, h, 0), Some(String::new()));
|
||||
|
||||
// 越界 → E_NOT_FOUND。
|
||||
assert_eq!(unsafe { oakplugin_instance_param_name(h, count, std::ptr::null_mut(), 0) }, E_NOT_FOUND);
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// 内省:secret/display_range/choice 三族在测试插件的对应参数上
|
||||
/// 逐字段断言。
|
||||
#[test]
|
||||
fn introspection_secret_range_choice() {
|
||||
common::with_host(|| {
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
let count = unsafe { oakplugin_instance_param_count(h) };
|
||||
// 按名定位索引。
|
||||
let mut gain = -1;
|
||||
let mut mode = -1;
|
||||
let mut debug = -1;
|
||||
for i in 0..count {
|
||||
match two_stage(oakplugin_instance_param_name, h, i).as_deref() {
|
||||
Some("gain") => gain = i,
|
||||
Some("mode") => mode = i,
|
||||
Some("debug") => debug = i,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(gain >= 0 && mode >= 0 && debug >= 0);
|
||||
|
||||
// display range(gain:-2..2)。
|
||||
let (mut min, mut max) = (0.0, 0.0);
|
||||
assert_eq!(unsafe { oakplugin_instance_param_display_range(h, gain, &mut min, &mut max) }, OK);
|
||||
assert_eq!((min, max), (-2.0, 2.0));
|
||||
|
||||
// secret(debug = 1;gain = 0)。
|
||||
let mut s = 0;
|
||||
assert_eq!(unsafe { oakplugin_instance_param_secret(h, debug, &mut s) }, OK);
|
||||
assert_eq!(s, 1);
|
||||
assert_eq!(unsafe { oakplugin_instance_param_secret(h, gain, &mut s) }, OK);
|
||||
assert_eq!(s, 0);
|
||||
|
||||
// choice(mode:2 个选项;选项内容见 introspection_choice_labels)。
|
||||
assert_eq!(unsafe { oakplugin_instance_param_choice_count(h, mode) }, 2);
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// choice label/value 的带选项两段式。
|
||||
#[test]
|
||||
fn introspection_choice_labels() {
|
||||
common::with_host(|| {
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
let count = unsafe { oakplugin_instance_param_count(h) };
|
||||
let mut mode = -1;
|
||||
for i in 0..count {
|
||||
if two_stage(oakplugin_instance_param_name, h, i).as_deref() == Some("mode") {
|
||||
mode = i;
|
||||
}
|
||||
}
|
||||
assert!(mode >= 0);
|
||||
assert_eq!(unsafe { oakplugin_instance_param_choice_count(h, mode) }, 2);
|
||||
for c in 0..2 {
|
||||
let len = unsafe { oakplugin_instance_param_choice_label(h, mode, c, std::ptr::null_mut(), 0) };
|
||||
assert!(len > 0);
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
assert_eq!(unsafe { oakplugin_instance_param_choice_label(h, mode, c, buf.as_mut_ptr() as *mut c_char, len) }, OK);
|
||||
let s = unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) }.to_string_lossy().into_owned();
|
||||
assert!(s == "Fast" || s == "High", "选项 {c} = {s}");
|
||||
}
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// 内省:默认值(double 族与字符串)。
|
||||
#[test]
|
||||
fn introspection_defaults() {
|
||||
common::with_host(|| {
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
let count = unsafe { oakplugin_instance_param_count(h) };
|
||||
let mut gain = -1;
|
||||
let mut label = -1;
|
||||
for i in 0..count {
|
||||
match two_stage(oakplugin_instance_param_name, h, i).as_deref() {
|
||||
Some("gain") => gain = i,
|
||||
Some("label") => label = i,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut d = 99.0;
|
||||
assert_eq!(unsafe { oakplugin_instance_param_default_double(h, gain, 0, &mut d) }, OK);
|
||||
assert_eq!(d, 0.0);
|
||||
let s = two_stage(oakplugin_instance_param_default_string, h, label);
|
||||
assert_eq!(s, Some(String::new()));
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// 内省:clip_count/name/info(含 Output clip;optional 标记)。
|
||||
#[test]
|
||||
fn introspection_clips() {
|
||||
common::with_host(|| {
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
assert_eq!(unsafe { oakplugin_instance_clip_count(h) }, 2);
|
||||
let names: Vec<String> = (0..2)
|
||||
.map(|i| two_stage(oakplugin_instance_clip_name, h, i).unwrap_or_default())
|
||||
.collect();
|
||||
assert!(names.contains(&"Source".into()), "{names:?}");
|
||||
assert!(names.contains(&"Output".into()), "{names:?}");
|
||||
|
||||
for i in 0..2 {
|
||||
let mut optional = -1;
|
||||
let len = unsafe { oakplugin_instance_clip_info(h, i, &mut optional, std::ptr::null_mut(), 0) };
|
||||
assert!(len > 0);
|
||||
let mut buf = vec![0u8; len as usize];
|
||||
assert_eq!(unsafe { oakplugin_instance_clip_info(h, i, &mut optional, buf.as_mut_ptr() as *mut c_char, len) }, OK);
|
||||
assert!(optional == 0, "测试插件 clip 非 optional");
|
||||
}
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// debug_alive_count:create 增、free 减;全部释放后回零基线。
|
||||
#[test]
|
||||
fn alive_count_accounting() {
|
||||
common::with_host(|| {
|
||||
unsafe { oakplugin_host_scan_import(std::ptr::null(), 0) };
|
||||
let base = unsafe { oakplugin_debug_alive_count() };
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
assert_eq!(unsafe { oakplugin_debug_alive_count() }, base + 1);
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
assert_eq!(unsafe { oakplugin_debug_alive_count() }, base);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! OpenGL 渲染路径测试(M11 §4)。
|
||||
//!
|
||||
//! GL 测试策略:
|
||||
//! - `--features test-stubs`:库内桩 GL 渲染器(renderer_is_open_gl /
|
||||
//! texture_create / texture_id 等)模拟 GPU——suite 往返、GL render
|
||||
//! 路径、错误路径全链路可跑(无需真实 GPU/liboakrender);
|
||||
//! - 默认模式(无 liboakrender 符号):GL 决策回退 CPU、GL suite 在
|
||||
//! 无上下文时返回 kOfxStatErrMissingHostFeature——用例断言该优雅
|
||||
//! 降级("无 GPU 优雅跳过")。
|
||||
//! - 真实 GPU golden(EXR 容差比对)为 M11 0 期基建 + 本机人工确认
|
||||
//! 项:`OAK_GPU_TESTS` 环境变量门,见 common::gpu_available。
|
||||
|
||||
mod common;
|
||||
|
||||
use std::ffi::{c_char, c_void, CString};
|
||||
|
||||
use oakplugin::ffi::{
|
||||
oakplugin_host_set_message_handler, oakplugin_host_scan, oakplugin_instance_create,
|
||||
oakplugin_instance_free, oakplugin_instance_render_begin_sequence,
|
||||
oakplugin_instance_render_end_sequence, oakplugin_instance_render_job,
|
||||
};
|
||||
use oakplugin::handle::CHandle;
|
||||
use oakplugin::suites::gl_render::GlRenderSuiteV1;
|
||||
use oakplugin::suites::{fetch_suite, status, tag};
|
||||
|
||||
const OK: i32 = 0;
|
||||
const GL_PLUGIN_ID: &str = "org.oak.test-plugin.gl";
|
||||
|
||||
fn cs(s: &str) -> CString {
|
||||
CString::new(s).unwrap()
|
||||
}
|
||||
|
||||
/// 简易句柄(桩纹理/渲染器)。
|
||||
fn fake_handle(ctx: usize) -> CHandle {
|
||||
CHandle {
|
||||
ctx: ctx as *mut c_void,
|
||||
addref: None,
|
||||
release: None,
|
||||
abi_version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// 扫描测试插件并创建 GL 变体实例;不可用返回空句柄。
|
||||
fn create_gl_instance() -> CHandle {
|
||||
if common::test_plugin_scan_dir().is_none() {
|
||||
common::skip("最小测试插件未构建");
|
||||
return CHandle::null();
|
||||
}
|
||||
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
|
||||
let dirs = [dir.as_ptr()];
|
||||
unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) };
|
||||
let id = cs(GL_PLUGIN_ID);
|
||||
unsafe { oakplugin_instance_create(id.as_ptr()) }
|
||||
}
|
||||
|
||||
/// GL suite 表(fetchSuite 分发表)。
|
||||
fn gl_suite() -> Option<&'static GlRenderSuiteV1> {
|
||||
unsafe { fetch_suite("OfxImageEffectOpenGLRenderSuite", 1) }
|
||||
.map(|p| unsafe { &*(p as *const GlRenderSuiteV1) })
|
||||
}
|
||||
|
||||
/// 构造一个带输入纹理的 Source clip(实例期 clip 句柄)。
|
||||
fn source_clip_handle(tex: CHandle) -> (*mut c_void, std::sync::Arc<oakplugin::clip::ClipInstance>) {
|
||||
let desc = oakplugin::descriptor::ClipDescriptor::new("Source");
|
||||
let clip = std::sync::Arc::new(oakplugin::clip::ClipInstance::from_descriptor(&desc));
|
||||
clip.set_input_texture(tex, 0.0);
|
||||
let h = tag::make(&clip.props as *const oakplugin::property::PropertySet, tag::CLIP);
|
||||
(h, clip)
|
||||
}
|
||||
|
||||
/// 当前渲染/GL 上下文注入(suite 的 TLS 依赖)。
|
||||
fn inject_gl_ctx(renderer: CHandle, output: CHandle) {
|
||||
oakplugin::suites::set_render_ctx(Some(oakplugin::suites::RenderCtx {
|
||||
time: 0.0,
|
||||
scale: oakplugin::instance::RenderScale { x: 1.0, y: 1.0 },
|
||||
range: oakplugin::instance::OfxRangeD { min: 0.0, max: 1.0 },
|
||||
}));
|
||||
oakplugin::suites::set_gl_ctx(Some(oakplugin::suites::GlCtx {
|
||||
renderer,
|
||||
output_texture: output,
|
||||
gl_pixel_depth: "OfxBitDepthFloat",
|
||||
}));
|
||||
}
|
||||
|
||||
/// 读纹理句柄(属性集)的 int 属性。
|
||||
fn tex_int(props: &oakplugin::property::PropertySet, name: &str) -> Option<i32> {
|
||||
match props.get(name, 0)? {
|
||||
oakplugin::property::Value::Int(i) => Some(i),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn tex_string(props: &oakplugin::property::PropertySet, name: &str) -> Option<String> {
|
||||
match props.get(name, 0)? {
|
||||
oakplugin::property::Value::String(s) => Some(s.to_string_lossy().into_owned()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理 TLS(每个用例结尾)。
|
||||
fn clear_ctxs() {
|
||||
oakplugin::suites::set_gl_ctx(None);
|
||||
oakplugin::suites::set_render_ctx(None);
|
||||
}
|
||||
|
||||
/// clipLoadTexture 往返:输入 clip 建纹理、属性齐全、clipFreeTexture
|
||||
/// 释放;Output clip 返回附着输出纹理;flushResources → ReplyDefault。
|
||||
#[cfg(feature = "test-stubs")]
|
||||
#[test]
|
||||
fn clip_load_texture_roundtrip() {
|
||||
use oakplugin::bridge::render::stub;
|
||||
common::with_host(|| {
|
||||
stub::reset();
|
||||
stub::set_gl_available(true);
|
||||
let renderer = stub::make_gl_renderer();
|
||||
let dst = stub::make_gl_texture(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
let src = stub::make_gl_texture(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
inject_gl_ctx(renderer, dst);
|
||||
let suite = gl_suite().expect("GL suite 已注册");
|
||||
|
||||
// 输入 clip。
|
||||
let (clip_h, _clip) = source_clip_handle(src);
|
||||
let before = stub::gl_texture_ids();
|
||||
let mut tex: *mut c_void = std::ptr::null_mut();
|
||||
unsafe {
|
||||
assert_eq!((suite.clip_load_texture)(clip_h, 0.0, std::ptr::null(), std::ptr::null(), &mut tex), 0);
|
||||
}
|
||||
assert!(!tex.is_null(), "应返回纹理属性集句柄");
|
||||
let props = unsafe { &*(tag::strip(tex) as *const oakplugin::property::PropertySet) };
|
||||
let idx = tex_int(props, "OfxImageEffectPropOpenGLTextureIndex").expect("texture index");
|
||||
assert!(idx > 0, "新建纹理 id 为正:{idx}");
|
||||
assert_eq!(tex_int(props, "OfxImageEffectPropOpenGLTextureTarget"), Some(0x0DE1), "GL_TEXTURE_2D");
|
||||
assert_eq!(tex_string(props, "OfxImageEffectPropPixelDepth").as_deref(), Some("OfxBitDepthFloat"));
|
||||
assert_eq!(tex_string(props, "OfxImageEffectPropComponents").as_deref(), Some("OfxImageComponentRGBA"));
|
||||
assert!(props.get("OfxImagePropBounds", 0).is_some(), "Bounds");
|
||||
assert!(props.get("OfxImagePropRowBytes", 0).is_some(), "RowBytes");
|
||||
assert!(props.get("OfxImagePropUniqueIdentifier", 0).is_some(), "UniqueIdentifier");
|
||||
// 注册表新增一个纹理。
|
||||
let after = stub::gl_texture_ids();
|
||||
assert_eq!(after.len(), before.len() + 1, "clipLoadTexture 建新纹理");
|
||||
assert!(after.contains(&idx), "新纹理 id 在注册表");
|
||||
|
||||
// clipFreeTexture:删输入纹理。
|
||||
unsafe { assert_eq!((suite.clip_free_texture)(tex), 0); }
|
||||
assert!(!stub::gl_texture_ids().contains(&idx), "输入纹理已删除");
|
||||
// 重复释放 → BadHandle。
|
||||
unsafe { assert_eq!((suite.clip_free_texture)(tex), status::ERR_BAD_HANDLE); }
|
||||
|
||||
// Output clip:返回附着输出纹理;free 不删纹理(宿主读)。
|
||||
let mut out: *mut c_void = std::ptr::null_mut();
|
||||
let out_clip_instance =
|
||||
oakplugin::clip::ClipInstance::from_descriptor(&oakplugin::descriptor::ClipDescriptor::new("Output"));
|
||||
let out_clip = tag::make(
|
||||
&out_clip_instance.props as *const oakplugin::property::PropertySet,
|
||||
tag::CLIP,
|
||||
);
|
||||
unsafe {
|
||||
assert_eq!((suite.clip_load_texture)(out_clip, 0.0, std::ptr::null(), std::ptr::null(), &mut out), 0);
|
||||
}
|
||||
assert!(!out.is_null());
|
||||
let props = unsafe { &*(tag::strip(out) as *const oakplugin::property::PropertySet) };
|
||||
let dst_id = tex_int(props, "OfxImageEffectPropOpenGLTextureIndex").unwrap();
|
||||
assert!(stub::gl_texture_ids().contains(&dst_id), "输出纹理 id 来自附着目标");
|
||||
unsafe { assert_eq!((suite.clip_free_texture)(out), 0); }
|
||||
assert!(stub::gl_texture_ids().contains(&dst_id), "Output free 不删纹理");
|
||||
|
||||
// flushResources:无宿主缓存 → ReplyDefault。
|
||||
unsafe { assert_eq!((suite.flush_resources)(), status::REPLY_DEFAULT); }
|
||||
|
||||
clear_ctxs();
|
||||
});
|
||||
}
|
||||
|
||||
/// 错误路径:无 GL 上下文 → MissingHostFeature;空 out/BadHandle;
|
||||
/// 子区域 → Failed;请求分量不匹配 → Failed。
|
||||
#[cfg(feature = "test-stubs")]
|
||||
#[test]
|
||||
fn clip_load_texture_error_paths() {
|
||||
use oakplugin::bridge::render::stub;
|
||||
common::with_host(|| {
|
||||
stub::reset();
|
||||
stub::set_gl_available(true);
|
||||
let renderer = stub::make_gl_renderer();
|
||||
let dst = stub::make_gl_texture(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
let src = stub::make_gl_texture(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
let suite = gl_suite().expect("GL suite 已注册");
|
||||
|
||||
// 无 GL 上下文(非 GL 渲染期)→ MissingHostFeature。
|
||||
let (clip_h, _clip) = source_clip_handle(src);
|
||||
let mut tex: *mut c_void = std::ptr::null_mut();
|
||||
unsafe {
|
||||
assert_eq!((suite.clip_load_texture)(clip_h, 0.0, std::ptr::null(), std::ptr::null(), &mut tex), status::ERR_MISSING_HOST_FEATURE);
|
||||
// 空 out → BadHandle;空 clip → BadHandle。
|
||||
assert_eq!((suite.clip_load_texture)(clip_h, 0.0, std::ptr::null(), std::ptr::null(), std::ptr::null_mut()), status::ERR_BAD_HANDLE);
|
||||
assert_eq!((suite.clip_load_texture)(std::ptr::null_mut(), 0.0, std::ptr::null(), std::ptr::null(), &mut tex), status::ERR_BAD_HANDLE);
|
||||
// clipFreeTexture 空句柄 → BadHandle。
|
||||
assert_eq!((suite.clip_free_texture)(std::ptr::null_mut()), status::ERR_BAD_HANDLE);
|
||||
}
|
||||
|
||||
// 有 GL 上下文后:子区域(Phase 2 不支持)→ Failed。
|
||||
inject_gl_ctx(renderer, dst);
|
||||
let region = oakplugin::instance::OfxRectD { x1: 0.0, y1: 0.0, x2: 2.0, y2: 2.0 };
|
||||
unsafe {
|
||||
assert_eq!((suite.clip_load_texture)(clip_h, 0.0, std::ptr::null(), ®ion as *const _ as *const c_void, &mut tex), status::FAILED);
|
||||
}
|
||||
|
||||
// 请求分量不匹配(RGBA 输入 + GLFormatRGB)→ Failed。
|
||||
let fmt = cs("OfxImageEffectGLFormatRGB");
|
||||
unsafe {
|
||||
assert_eq!((suite.clip_load_texture)(clip_h, 0.0, fmt.as_ptr(), std::ptr::null(), &mut tex), status::FAILED);
|
||||
}
|
||||
|
||||
// 匹配请求(GLFormatRGBA)→ OK。
|
||||
let fmt = cs("OfxImageEffectGLFormatRGBA");
|
||||
unsafe {
|
||||
assert_eq!((suite.clip_load_texture)(clip_h, 0.0, fmt.as_ptr(), std::ptr::null(), &mut tex), 0);
|
||||
assert_eq!((suite.clip_free_texture)(tex), 0);
|
||||
}
|
||||
clear_ctxs();
|
||||
});
|
||||
}
|
||||
|
||||
/// 无桩模式(默认构建):GL suite 在无上下文时返回 MissingHostFeature
|
||||
/// (优雅降级,不崩);render_job 带渲染器句柄时回退 CPU 路径。
|
||||
#[cfg(not(feature = "test-stubs"))]
|
||||
#[test]
|
||||
fn gl_path_graceful_without_gpu() {
|
||||
common::with_host(|| {
|
||||
let suite = gl_suite().expect("GL suite 已注册");
|
||||
let desc = oakplugin::descriptor::ClipDescriptor::new("Source");
|
||||
let clip = oakplugin::clip::ClipInstance::from_descriptor(&desc);
|
||||
let clip_h = tag::make(&clip.props as *const _, tag::CLIP);
|
||||
let mut tex: *mut c_void = std::ptr::null_mut();
|
||||
unsafe {
|
||||
// 无 GL 上下文 → MissingHostFeature(规范语义)。
|
||||
assert_eq!((suite.clip_load_texture)(clip_h, 0.0, std::ptr::null(), std::ptr::null(), &mut tex), status::ERR_MISSING_HOST_FEATURE);
|
||||
// flushResources → ReplyDefault。
|
||||
assert_eq!((suite.flush_resources)(), status::REPLY_DEFAULT);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// GL render 路径端到端(test-stubs):render_job 带 GL 渲染器 →
|
||||
/// 插件收到 OpenGLEnabled=1、attach/detach 配对、clipLoadTexture
|
||||
/// 取 Source/Output 纹理(插件经 message suite 上报索引)。
|
||||
#[cfg(feature = "test-stubs")]
|
||||
#[test]
|
||||
fn gl_render_path_drives_plugin() {
|
||||
use oakplugin::bridge::render::stub;
|
||||
common::with_host(|| {
|
||||
stub::reset();
|
||||
let mut h = create_gl_instance();
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
// 消息捕获(插件 GL 行为的上报通道)。
|
||||
let captured: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
|
||||
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
{
|
||||
let cap = captured.clone();
|
||||
unsafe extern "C" fn capture(
|
||||
_type: *const c_char,
|
||||
message: *const c_char,
|
||||
userdata: *mut c_void,
|
||||
) -> std::ffi::c_int {
|
||||
let v = unsafe { &mut *(userdata as *mut std::sync::Mutex<Vec<String>>) };
|
||||
if !message.is_null() {
|
||||
let m = unsafe { std::ffi::CStr::from_ptr(message) }
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
v.lock().unwrap_or_else(|e| e.into_inner()).push(m);
|
||||
}
|
||||
0
|
||||
}
|
||||
unsafe {
|
||||
oakplugin_host_set_message_handler(
|
||||
Some(capture),
|
||||
&*cap as *const _ as *mut c_void,
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
// GL 环境:渲染器可用;dst/src 均为 GL 纹理(2×2 F32)。
|
||||
stub::set_gl_available(true);
|
||||
let renderer = stub::make_gl_renderer();
|
||||
let dst = stub::make_gl_texture(2, 2, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
let src = stub::make_gl_texture(2, 2, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
|
||||
// begin → job → end(序列括号)。
|
||||
assert_eq!(unsafe { oakplugin_instance_render_begin_sequence(h, 0.0, 1.0, 0) }, OK);
|
||||
let r = unsafe {
|
||||
oakplugin_instance_render_job(
|
||||
h,
|
||||
dst,
|
||||
0.0,
|
||||
0,
|
||||
0,
|
||||
cs("Source").as_ptr(),
|
||||
src,
|
||||
std::ptr::null(),
|
||||
0,
|
||||
std::ptr::null(),
|
||||
0,
|
||||
renderer,
|
||||
)
|
||||
};
|
||||
assert_eq!(r, OK, "GL render_job 应成功");
|
||||
assert_eq!(unsafe { oakplugin_instance_render_end_sequence(h, 0.0, 1.0, 0) }, OK);
|
||||
|
||||
// 插件上报:attach/detach 配对、Source 纹理索引、Output 纹理索引。
|
||||
let msgs = captured.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
let joined = msgs.join("|");
|
||||
assert!(joined.contains("gl-attached"), "attach 应调用:{joined}");
|
||||
assert!(joined.contains("gl-detached"), "detach 应调用:{joined}");
|
||||
assert!(joined.contains("gl-source-index="), "Source 纹理索引:{joined}");
|
||||
assert!(joined.contains("gl-output-index="), "Output 纹理索引:{joined}");
|
||||
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
unsafe { oakplugin_host_set_message_handler(None, std::ptr::null_mut()) };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Golden master:描述符快照 diff 与渲染帧比对(M11 §2.2/§2.3)。
|
||||
//!
|
||||
//! 快照在 0 期由**现行 C++ 实现**抓取入库(tests/ofx/snapshots/ 与
|
||||
//! tests/ofx/frames/);本文件的测试断言 Rust 实现与之逐字段/逐像素
|
||||
//! 一致。**0 期基建尚未落地**(tests/ofx/ 目录为空)——依赖快照/
|
||||
//! 帧/GL 的用例一律 `#[ignore]`,落地后摘掉并补全断言(见
|
||||
//! [`descriptor_snapshots_match`] 的说明)。无真实 bundle 的环境
|
||||
//! (CI)整文件 skip。
|
||||
//!
|
||||
//! [`cimg_full_describe_smoke`] 不依赖快照:真实 CImg bundle 存在时
|
||||
//! 全量 describe + 协商冒烟(健壮性,不比对),是本次唯一实际执行
|
||||
//! 的用例。
|
||||
|
||||
mod common;
|
||||
|
||||
/// 描述符快照:对快照清单中的每个插件,字段级 diff——标识、版本、
|
||||
/// 上下文集合、参数矩阵(名称/类型/默认值/标签/hint/父组/secret/
|
||||
/// display range/choice labels+values+排序结果)、clip 表。任何 diff
|
||||
/// 打印成 unified diff 供定位。
|
||||
///
|
||||
/// # ignore 原因(M11 0 期基建缺失)
|
||||
///
|
||||
/// `tests/ofx/snapshots/`(C++ 实现抓取的描述符 JSON 库)尚未生成。
|
||||
/// 快照落地后摘掉本注解并实现:扫描快照清单 → 每插件 describe →
|
||||
/// 与 JSON 逐字段比对。当前无快照可比对,硬跑必假红。
|
||||
#[test]
|
||||
#[ignore = "M11 0 期快照库(tests/ofx/snapshots/)尚未生成;落地后实现并摘除"]
|
||||
fn descriptor_snapshots_match() {
|
||||
todo!("0 期快照落地后实现:扫描快照清单 → describe → 逐字段 diff")
|
||||
}
|
||||
|
||||
/// clip 协商快照:getClipPreferences 之后的输出分量/位深/像素比/
|
||||
/// 帧率/field 与快照一致(隐式行为的主要回归防线)。
|
||||
///
|
||||
/// # ignore 原因
|
||||
///
|
||||
/// 同 [`descriptor_snapshots_match`]:协商快照(含 getClipPreferences
|
||||
/// 协商后结果)在 `tests/ofx/snapshots/`,尚未生成。
|
||||
#[test]
|
||||
#[ignore = "M11 0 期协商快照尚未生成;落地后实现并摘除"]
|
||||
fn negotiated_clip_preferences_match() {
|
||||
todo!("0 期协商快照落地后实现")
|
||||
}
|
||||
|
||||
/// CImg 全量插件冒烟:每个插件 describe + 协商不崩、不泄漏
|
||||
/// (不比对快照,只验健壮性——覆盖快照集之外的怪癖)。
|
||||
///
|
||||
/// # ignore 原因(本机 CImg bundle 的宿主兼容缺口)
|
||||
///
|
||||
/// 本机安装的 CImg.ofx.bundle 是 Natron 分支构建:describe 期间写
|
||||
/// 专属属性 `NatronOfxImageEffectPropDeprecated`,且其 OFX support
|
||||
/// 的 `Property::Set` 句柄语义与本 crate 的打标句柄约定不兼容
|
||||
/// (插件侧属性写入在进入本宿主属性套件前失败 → describe 返回
|
||||
/// kOfxStatErrMissingHostFeature)。真实插件的端到端加载/建实例/
|
||||
/// 协商路径由 [`crate::bridge_test` 的 `system_misc_ofx_bundle_smoke`]
|
||||
/// (Misc.ofx.bundle,标准 openfx-misc 构建)覆盖。CImg 兼容属
|
||||
/// M11 §3.5「CImg 全量 describe/协商冒烟」的宿主保真缺口——属性
|
||||
/// 套件适配插件侧 `Property::Set` 句柄后摘除本注解。
|
||||
#[test]
|
||||
#[ignore = "本机 CImg.ofx.bundle 为 Natron 分支构建,属性套件句柄语义不兼容(describe 返回 MissingHostFeature);Misc 系统冒烟已覆盖真实插件路径"]
|
||||
fn cimg_full_describe_smoke() {
|
||||
todo!("属性套件适配 Natron 分支的 Property::Set 句柄后实现:全量 describe + 协商不崩、不泄漏")
|
||||
}
|
||||
|
||||
/// CPU 渲染 golden:代表性效果集逐帧 SHA256 一致(bit 级)。
|
||||
/// 输入帧为合成渐变+色块(确定性生成器在 common 里)。
|
||||
///
|
||||
/// # ignore 原因
|
||||
///
|
||||
/// `tests/ofx/frames/`(C++ 实现抓取的 EXR + SHA256 库)尚未生成;
|
||||
/// 且 CPU 渲染链路依赖 renderer 桥(cargo 内无 liboakrender 真实现,
|
||||
/// 只有测试桩)。快照与真桥都落地后实现并摘除。
|
||||
#[test]
|
||||
#[ignore = "M11 0 期渲染 golden(tests/ofx/frames/)+ 真 liboakrender 缺失;落地后实现并摘除"]
|
||||
fn render_golden_cpu_bitexact() {
|
||||
todo!("0 期渲染 golden 落地后实现")
|
||||
}
|
||||
|
||||
/// GL 渲染 golden:1e-4 容差(驱动差异);无 GPU 环境 skip。
|
||||
///
|
||||
/// # ignore 原因
|
||||
///
|
||||
/// GL 路径属 M11 第 2 期(OpenGLRender suite 未实现),帧库亦未生成。
|
||||
#[test]
|
||||
#[ignore = "GL 路径属 M11 第 2 期,且帧库未生成;2 期后实现并摘除"]
|
||||
fn render_golden_gl_tolerant() {
|
||||
todo!("M11 第 2 期 GL 路径落地后实现")
|
||||
}
|
||||
|
||||
/// F32+ACEScg 链路断言:golden 帧的像素格式为 F32,且经 OCIO
|
||||
/// display transform 后与参考值在容差内(色彩链路没被偷换成 8-bit
|
||||
/// 的回归防线)。
|
||||
///
|
||||
/// # ignore 原因
|
||||
///
|
||||
/// 同 [`render_golden_cpu_bitexact`]:依赖帧库与 OCIO 链路
|
||||
/// (ofxColour 属 M11 第 2 期)。
|
||||
#[test]
|
||||
#[ignore = "M11 0 期帧库 + 第 2 期 ofxColour/OCIO 链路缺失;落地后实现并摘除"]
|
||||
fn pipeline_is_f32_acescg() {
|
||||
todo!("帧库与 OCIO 链路落地后实现")
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! handle.rs 的契约测试:引用计数语义、free 容错、借用盒、Registry。
|
||||
//!
|
||||
//! 对应实现:crate::handle。每个测试只验一条规则,命名即规约。
|
||||
|
||||
mod common;
|
||||
|
||||
use std::ptr;
|
||||
use std::sync::atomic::{AtomicU32, AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use oakplugin::error::{Error, OAKPLUGIN_E_FAILED, OAKPLUGIN_E_NOT_FOUND, OAKPLUGIN_OK};
|
||||
use oakplugin::handle::{
|
||||
get, guard, guard_handle, make_borrowed, make_owned, RefBox, Registry, CHandle,
|
||||
};
|
||||
|
||||
/// 析构标志:以"被析构次数"断言对象的销毁时机(引用计数语义的
|
||||
/// 行为探针)。
|
||||
struct DropFlag(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for DropFlag {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// 模拟 C 侧 addref(头文件契约:复制句柄时先 addref)。
|
||||
fn addref(h: &CHandle) {
|
||||
unsafe { (h.addref.expect("addref fn 缺失"))(h.ctx) };
|
||||
}
|
||||
|
||||
/// 模拟 C 侧 free(`oakplugin_instance_free` 语义:ctx 非空才调
|
||||
/// release,随后清空 ctx;空句柄/已清空句柄是 no-op)。
|
||||
fn free(h: &mut CHandle) {
|
||||
if !h.is_null() {
|
||||
unsafe { (h.release.expect("release fn 缺失"))(h.ctx) };
|
||||
}
|
||||
h.ctx = ptr::null_mut();
|
||||
}
|
||||
|
||||
/// 拥有型句柄:创建计数为 1;addref 后 release 一次对象仍活;
|
||||
/// 再 release 对象销毁(用析构标志位断言)。
|
||||
#[test]
|
||||
fn owned_handle_refcount_lifecycle() {
|
||||
let drops = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
// 按值传句柄的位级复制(C 侧 `OakPluginInstance` 结构体拷贝),
|
||||
// 复制方必须先 addref:refs 1 -> 2。
|
||||
let h1 = make_owned(DropFlag(drops.clone()));
|
||||
let mut h2 = unsafe { ptr::read(&h1) };
|
||||
addref(&h2);
|
||||
|
||||
// 释放一份:2 -> 1,对象仍活。
|
||||
let mut h1 = h1;
|
||||
free(&mut h1);
|
||||
assert_eq!(drops.load(Ordering::Relaxed), 0);
|
||||
|
||||
// 释放最后一份:1 -> 0,对象恰好销毁一次。
|
||||
free(&mut h2);
|
||||
assert_eq!(drops.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
/// free(NULL)/free(空句柄)/重复 free 同一个已清空句柄:全部 no-op,
|
||||
/// 不崩、不计数变化(alive 计数前后一致)。
|
||||
#[test]
|
||||
fn free_null_and_empty_is_noop() {
|
||||
let drops = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
// free(空句柄):no-op,不崩。
|
||||
let mut null = CHandle::null();
|
||||
free(&mut null);
|
||||
assert!(null.is_null());
|
||||
|
||||
// 正常销毁后句柄已清空;重复 free 是 no-op,计数不再变化。
|
||||
let mut h = make_owned(DropFlag(drops.clone()));
|
||||
free(&mut h);
|
||||
assert_eq!(drops.load(Ordering::Relaxed), 1);
|
||||
free(&mut h);
|
||||
free(&mut h);
|
||||
assert_eq!(drops.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
/// 借用句柄:release 只释放盒子,被借用的对象仍然存活
|
||||
/// (用外部栈对象的析构标志断言)。
|
||||
#[test]
|
||||
fn borrowed_handle_never_destroys_object() {
|
||||
let drops = Arc::new(AtomicUsize::new(0));
|
||||
let mut obj = DropFlag(drops.clone());
|
||||
|
||||
let mut h = unsafe { make_borrowed(&mut obj as *mut DropFlag) };
|
||||
assert!(!h.is_null());
|
||||
|
||||
free(&mut h);
|
||||
// 盒子释放了,但对象归调用方:析构不在 release 时发生。
|
||||
assert_eq!(drops.load(Ordering::Relaxed), 0);
|
||||
assert!(h.is_null());
|
||||
|
||||
// 对象最终由调用方析构,恰好一次。
|
||||
drop(obj);
|
||||
assert_eq!(drops.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
|
||||
/// 空句柄的 get::<T>() 返回 None;类型不符的 get 是调用方责任
|
||||
/// (文档约定),此处只验空句柄路径。
|
||||
#[test]
|
||||
fn get_on_empty_handle_is_none() {
|
||||
assert!(unsafe { get::<u32>(&CHandle::null()) }.is_none());
|
||||
|
||||
// 对照:正常句柄能取回引用。
|
||||
let mut h = make_owned(42u32);
|
||||
assert_eq!(unsafe { get::<u32>(&h) }, Some(&42));
|
||||
free(&mut h);
|
||||
}
|
||||
|
||||
/// guard:闭包 panic 被捕获并映射为 OAKPLUGIN_E_FAILED,
|
||||
/// 不 unwind 出 FFI;Err 映射为对应负码;Ok 映射为 OAKPLUGIN_OK。
|
||||
#[test]
|
||||
fn guard_maps_panic_err_ok() {
|
||||
// Ok -> OAKPLUGIN_OK。
|
||||
assert_eq!(guard(|| Ok(())), OAKPLUGIN_OK);
|
||||
|
||||
// Err -> 对应负码(错误码与 include/plugin/error.h 一致,
|
||||
// 项目 -MMCCCC 方案:-90001..-90005)。
|
||||
assert_eq!(guard(|| Err(Error::NotFound)), OAKPLUGIN_E_NOT_FOUND);
|
||||
assert_eq!(
|
||||
guard(|| Err(Error::Invalid)),
|
||||
oakplugin::error::OAKPLUGIN_E_INVALID
|
||||
);
|
||||
assert_eq!(guard(|| Err(Error::State)), oakplugin::error::OAKPLUGIN_E_STATE);
|
||||
assert_eq!(
|
||||
guard(|| Err(Error::Failed("x".into()))),
|
||||
oakplugin::error::OAKPLUGIN_E_FAILED
|
||||
);
|
||||
assert_eq!(guard(|| Err(Error::NoMem)), oakplugin::error::OAKPLUGIN_E_NOMEM);
|
||||
|
||||
// panic -> OAKPLUGIN_E_FAILED,且 panic 不越过 guard 边界
|
||||
// (catch_unwind 语义:本测试线程存活即证明未 unwind)。
|
||||
assert_eq!(guard(|| panic!("boom")), OAKPLUGIN_E_FAILED);
|
||||
}
|
||||
|
||||
/// guard_handle:panic/Err 返回空句柄;Ok 透传非空句柄。
|
||||
#[test]
|
||||
fn guard_handle_maps_to_null_on_failure() {
|
||||
let mut ok = guard_handle(|| Ok(make_owned(7u32)));
|
||||
assert!(!ok.is_null());
|
||||
free(&mut ok);
|
||||
|
||||
let err = guard_handle(|| Err::<CHandle, _>(Error::State));
|
||||
assert!(err.is_null());
|
||||
|
||||
let panicked: CHandle = guard_handle(|| panic!("boom"));
|
||||
assert!(panicked.is_null());
|
||||
}
|
||||
|
||||
/// Registry:register 返回唯一身份;lookup 命中;对象销毁后
|
||||
/// lookup 返回 None(弱引用语义);unregister 未知身份 no-op。
|
||||
#[test]
|
||||
fn registry_register_lookup_unregister() {
|
||||
let reg: Registry<u32> = Registry::new();
|
||||
|
||||
let arc = Arc::new(RefBox {
|
||||
refs: AtomicU32::new(1),
|
||||
value: 7u32,
|
||||
});
|
||||
let id = reg.register(&arc);
|
||||
assert_eq!(id, Arc::as_ptr(&arc) as *const () as usize);
|
||||
assert_eq!(reg.lookup(id).unwrap().value, 7);
|
||||
|
||||
// 同一对象重复登记:身份稳定(地址语义),值覆盖。
|
||||
let id2 = reg.register(&arc);
|
||||
assert_eq!(id2, id);
|
||||
|
||||
// 摘除后 lookup 命中失败;再摘除未知身份是 no-op。
|
||||
reg.unregister(id);
|
||||
assert!(reg.lookup(id).is_none());
|
||||
reg.unregister(id);
|
||||
|
||||
// 对象销毁后弱引用失效:lookup 返回 None。
|
||||
let dying = Arc::new(RefBox {
|
||||
refs: AtomicU32::new(1),
|
||||
value: 9u32,
|
||||
});
|
||||
let dying_id = reg.register(&dying);
|
||||
drop(dying);
|
||||
assert!(reg.lookup(dying_id).is_none());
|
||||
|
||||
drop(arc);
|
||||
}
|
||||
|
||||
/// 并发:64 线程对同一句柄 addref/release 各一千次,最终计数正确、
|
||||
/// 对象恰好销毁一次(线程模型是 multithread suite 的直接投影)。
|
||||
#[test]
|
||||
fn refcount_is_thread_safe() {
|
||||
let drops = Arc::new(AtomicUsize::new(0));
|
||||
let h = make_owned(DropFlag(drops.clone()));
|
||||
|
||||
// 每个线程持有 ctx + 函数指针的拷贝(对应 C 侧各线程各持一份
|
||||
// 句柄值),对同一对象做 1000 次 addref/release 配对。
|
||||
// 裸指针不可 Send,测试侧经 usize 搬运(C 侧本来也是整数传递)。
|
||||
let threads: Vec<_> = (0..64)
|
||||
.map(|_| {
|
||||
let ctx = h.ctx as usize;
|
||||
let addref = h.addref.expect("addref fn 缺失");
|
||||
let release = h.release.expect("release fn 缺失");
|
||||
std::thread::spawn(move || {
|
||||
for _ in 0..1000 {
|
||||
unsafe { addref(ctx as *mut std::ffi::c_void) };
|
||||
unsafe { release(ctx as *mut std::ffi::c_void) };
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for t in threads {
|
||||
t.join().unwrap();
|
||||
}
|
||||
|
||||
// 全部配对完成:计数回到创建值,对象存活。
|
||||
assert_eq!(drops.load(Ordering::Relaxed), 0);
|
||||
|
||||
// 最终一次 release 恰好销毁。
|
||||
let mut h = h;
|
||||
free(&mut h);
|
||||
assert_eq!(drops.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 生命周期测试:实例创建/销毁配对、重复扫描、渲染中取消、
|
||||
//! alive 泄漏断言。对应 M11 §3.5 验收线。
|
||||
//!
|
||||
//! 宿主单例无锁:全部用例经 [`common::with_host`] 串行化。
|
||||
|
||||
mod common;
|
||||
|
||||
use std::ffi::{c_char, c_int, c_void, CString};
|
||||
|
||||
use oakplugin::ffi::{
|
||||
oakplugin_debug_alive_count, oakplugin_host_init, oakplugin_host_plugin_count,
|
||||
oakplugin_host_scan, oakplugin_host_shutdown, oakplugin_instance_cancel,
|
||||
oakplugin_instance_create, oakplugin_instance_free, oakplugin_instance_render,
|
||||
oakplugin_instance_set_progress_cb,
|
||||
};
|
||||
use oakplugin::handle::CHandle;
|
||||
|
||||
const OK: i32 = 0;
|
||||
const E_FAILED: i32 = -90003;
|
||||
|
||||
fn cs(s: &str) -> CString {
|
||||
CString::new(s).unwrap()
|
||||
}
|
||||
|
||||
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
|
||||
|
||||
fn fake_texture(ctx: usize) -> CHandle {
|
||||
CHandle {
|
||||
ctx: ctx as *mut c_void,
|
||||
addref: None,
|
||||
release: None,
|
||||
abi_version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// 扫描并创建实例;不可用返回空句柄。
|
||||
fn create_instance() -> CHandle {
|
||||
if common::test_plugin_scan_dir().is_none() {
|
||||
common::skip("最小测试插件未构建");
|
||||
return CHandle::null();
|
||||
}
|
||||
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
|
||||
let dirs = [dir.as_ptr()];
|
||||
unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) };
|
||||
let id = cs(TEST_PLUGIN_ID);
|
||||
unsafe { oakplugin_instance_create(id.as_ptr()) }
|
||||
}
|
||||
|
||||
/// createInstance → destroyInstance 严格配对:alive 计数回到基线。
|
||||
#[test]
|
||||
fn instance_create_destroy_pairing() {
|
||||
common::with_host(|| {
|
||||
unsafe { oakplugin_host_init() };
|
||||
let base = unsafe { oakplugin_debug_alive_count() };
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
return;
|
||||
}
|
||||
assert_eq!(unsafe { oakplugin_debug_alive_count() }, base + 1);
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
assert_eq!(unsafe { oakplugin_debug_alive_count() }, base, "destroy 后 alive 必须回基线");
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
|
||||
/// 重复 scan 同一路径:插件列表不重复、顺序稳定(缓存去重语义,
|
||||
/// HS: ofxhPluginCache.cpp)。
|
||||
#[test]
|
||||
fn rescan_is_idempotent() {
|
||||
common::with_host(|| {
|
||||
unsafe { oakplugin_host_init() };
|
||||
if common::test_plugin_scan_dir().is_none() {
|
||||
common::skip("最小测试插件未构建");
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
return;
|
||||
}
|
||||
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
|
||||
let dirs = [dir.as_ptr()];
|
||||
assert_eq!(unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) }, OK);
|
||||
let first = unsafe { oakplugin_host_plugin_count() };
|
||||
assert!(first >= 1);
|
||||
// 再扫两次:数量不变。
|
||||
assert_eq!(unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) }, OK);
|
||||
assert_eq!(unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) }, OK);
|
||||
assert_eq!(unsafe { oakplugin_host_plugin_count() }, first, "重复扫描不得重复加载");
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
|
||||
/// render 中途取消:进度回调返回非 0 后,插件的 progressUpdate 收到
|
||||
/// 取消并中止 render;输出纹理不写半帧(测试插件在取消路径返回
|
||||
/// Failed,宿主不落帧)。
|
||||
#[cfg(feature = "test-stubs")]
|
||||
#[test]
|
||||
fn render_cancellation_is_atomic() {
|
||||
common::with_host(|| {
|
||||
use oakplugin::bridge::render::stub;
|
||||
unsafe { oakplugin_host_init() };
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
return;
|
||||
}
|
||||
unsafe extern "C" fn abort_cb(_p: f64, _u: *mut c_void) -> c_int {
|
||||
1
|
||||
}
|
||||
unsafe { oakplugin_instance_set_progress_cb(h, Some(abort_cb), std::ptr::null_mut()) };
|
||||
|
||||
// 预置输出帧并填 0xAA 哨兵:取消路径不得写半帧。
|
||||
stub::setup_dst(64, 64, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
let before = stub::dst_pixels();
|
||||
assert!(!before.is_empty());
|
||||
let dst = fake_texture(0xA1);
|
||||
let r = unsafe { oakplugin_instance_render(h, dst, CHandle::null(), 0.0) };
|
||||
assert_eq!(r, E_FAILED, "取消应使 render 失败");
|
||||
// 全有或全无:失败时不写。
|
||||
assert_eq!(stub::dst_pixels(), before, "取消后输出不得被写");
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
|
||||
/// 压测:同一插件 256 次 create/render/destroy 循环后 alive 回到
|
||||
/// 基线(当年内存问题的回归防线)。
|
||||
#[cfg(feature = "test-stubs")]
|
||||
#[test]
|
||||
fn create_render_destroy_loop_no_leak() {
|
||||
common::with_host(|| {
|
||||
use oakplugin::bridge::render::stub;
|
||||
unsafe { oakplugin_host_init() };
|
||||
let base = unsafe { oakplugin_debug_alive_count() };
|
||||
if common::test_plugin_scan_dir().is_none() {
|
||||
common::skip("最小测试插件未构建");
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
return;
|
||||
}
|
||||
stub::setup_dst(16, 16, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
let dst = fake_texture(0xA1);
|
||||
for i in 0..256 {
|
||||
let mut h = create_instance();
|
||||
assert!(!h.is_null());
|
||||
let r = unsafe { oakplugin_instance_render(h, dst, CHandle::null(), i as f64) };
|
||||
assert_eq!(r, OK);
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
assert_eq!(
|
||||
unsafe { oakplugin_debug_alive_count() },
|
||||
base,
|
||||
"第 {i} 次循环后泄漏"
|
||||
);
|
||||
}
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
|
||||
/// host_shutdown 时仍有活实例:宿主发 destroy 通知并卸载 bundle,
|
||||
/// 之后该实例的任何 render 立即取消(不碰已卸载入口);再 create
|
||||
/// 返回空句柄(缓存已清空——声明原期望 E_STATE,本实现的 create
|
||||
/// 契约是"失败返回空句柄",以实例化为准)。
|
||||
#[test]
|
||||
fn shutdown_with_live_instances() {
|
||||
common::with_host(|| {
|
||||
unsafe { oakplugin_host_init() };
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
return;
|
||||
}
|
||||
// 带活实例 shutdown:不崩;实例被打取消标记。
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
// C 侧句柄仍持 Arc:实例内存存活,但 render 必须失败。
|
||||
let dst = fake_texture(0xA1);
|
||||
assert_eq!(unsafe { oakplugin_instance_render(h, dst, CHandle::null(), 0.0) }, E_FAILED);
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
assert_eq!(unsafe { oakplugin_debug_alive_count() }, 0, "句柄释放后归零");
|
||||
// 之后再 create:插件已卸载 → 空句柄。
|
||||
let id = cs(TEST_PLUGIN_ID);
|
||||
let h2 = unsafe { oakplugin_instance_create(id.as_ptr()) };
|
||||
assert!(h2.is_null());
|
||||
});
|
||||
}
|
||||
|
||||
/// 多实例并发:同一插件 16 实例并发 render(宿主侧不串——插件
|
||||
/// 全局状态由插件自保)。
|
||||
#[cfg(feature = "test-stubs")]
|
||||
#[test]
|
||||
fn concurrent_instances_render() {
|
||||
common::with_host(|| {
|
||||
use oakplugin::bridge::render::stub;
|
||||
unsafe { oakplugin_host_init() };
|
||||
if common::test_plugin_scan_dir().is_none() {
|
||||
common::skip("最小测试插件未构建");
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
return;
|
||||
}
|
||||
// 16 个实例(句柄数组;经线程并发 render)。
|
||||
let mut handles: Vec<CHandle> = (0..16)
|
||||
.map(|_| create_instance())
|
||||
.collect();
|
||||
assert!(handles.iter().all(|h| !h.is_null()));
|
||||
stub::setup_dst(32, 32, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
let dst = fake_texture(0xA1);
|
||||
let threads: Vec<_> = handles
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, h)| {
|
||||
let h = *h;
|
||||
std::thread::spawn(move || unsafe {
|
||||
oakplugin_instance_render(h, dst, CHandle::null(), i as f64)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for t in threads {
|
||||
assert_eq!(t.join().unwrap(), OK);
|
||||
}
|
||||
for h in handles.iter_mut() {
|
||||
unsafe { oakplugin_instance_free(h) };
|
||||
}
|
||||
assert_eq!(unsafe { oakplugin_debug_alive_count() }, 0);
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! 协商语义专项:clip 偏好协商矩阵、RoD/RoI、isIdentity、field
|
||||
//! 透传。当年调试重灾区,每条对照 HostSupport 行为(注释标行号)。
|
||||
//!
|
||||
//! 最小测试插件固定声明 RGBA/F32、实现 RoD(1920×1080)与 RoI
|
||||
//! (回写 region)、isIdentity 恒非透传、无 field——需要插件变体
|
||||
//! 的矩阵案(回退链、身份透传、field)标记 `// TODO(plugin)`,
|
||||
//! 随 M11 §2.4 测试插件的变体落地。
|
||||
|
||||
mod common;
|
||||
|
||||
use std::ffi::{c_char, c_void, CString};
|
||||
|
||||
use oakplugin::ffi::{
|
||||
oakplugin_host_init, oakplugin_host_scan, oakplugin_host_shutdown, oakplugin_instance_create,
|
||||
oakplugin_instance_free,
|
||||
};
|
||||
use oakplugin::handle::{get, CHandle};
|
||||
|
||||
const OK: i32 = 0;
|
||||
|
||||
fn cs(s: &str) -> CString {
|
||||
CString::new(s).unwrap()
|
||||
}
|
||||
|
||||
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
|
||||
|
||||
/// 扫描并创建实例;不可用返回空句柄。
|
||||
fn create_instance() -> CHandle {
|
||||
if common::test_plugin_scan_dir().is_none() {
|
||||
common::skip("最小测试插件未构建");
|
||||
return CHandle::null();
|
||||
}
|
||||
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
|
||||
let dirs = [dir.as_ptr()];
|
||||
unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) };
|
||||
let id = cs(TEST_PLUGIN_ID);
|
||||
unsafe { oakplugin_instance_create(id.as_ptr()) }
|
||||
}
|
||||
|
||||
/// 句柄 → Instance 引用。
|
||||
fn instance_of(h: &CHandle) -> Option<std::sync::Arc<oakplugin::handle::RefBox<oakplugin::instance::Instance>>> {
|
||||
unsafe { get::<std::sync::Arc<oakplugin::handle::RefBox<oakplugin::instance::Instance>>>(h) }
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// 协商顺序:测试插件声明 RGBA/F32、帧率 24——协商结果必须
|
||||
/// 原样采纳(HS: ofxhImageEffect.cpp:1686-1740 的回灌路径)。
|
||||
/// 全组合矩阵(RGBA/RGB/Alpha × Float/Half/Byte)需要插件变体:
|
||||
/// `// TODO(plugin)`。
|
||||
#[test]
|
||||
fn clip_preferences_component_depth_matrix() {
|
||||
common::with_host(|| {
|
||||
unsafe { oakplugin_host_init() };
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
return;
|
||||
}
|
||||
let inst = instance_of(&h).expect("句柄应可解析");
|
||||
let prefs = inst.value.get_clip_preferences().expect("协商应成功");
|
||||
assert_eq!(prefs.output_components, "OfxImageComponentRGBA");
|
||||
assert_eq!(prefs.output_bit_depth, "OfxBitDepthFloat");
|
||||
assert_eq!(prefs.frame_rate, 24.0);
|
||||
// 回灌:clip 实例属性带上了协商结果(clipGetPropertySet 读)。
|
||||
let output = inst.value.clips.iter().find(|c| c.name == "Output").unwrap();
|
||||
assert_eq!(
|
||||
output
|
||||
.props
|
||||
.get("OfxImageEffectPropComponents", 0)
|
||||
.map(|v| format!("{v:?}")),
|
||||
Some("String(\"OfxImageComponentRGBA\")".into())
|
||||
);
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
|
||||
/// 位深回退链(Half→Byte):宿主当前只宣告 F32 支持、插件声明
|
||||
/// F32——直通。回退链的插件侧变体 `// TODO(plugin)`。
|
||||
#[test]
|
||||
fn bit_depth_fallback_chain() {
|
||||
common::with_host(|| {
|
||||
unsafe { oakplugin_host_init() };
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
return;
|
||||
}
|
||||
let inst = instance_of(&h).expect("句柄应可解析");
|
||||
let prefs = inst.value.get_clip_preferences().unwrap();
|
||||
assert_eq!(prefs.output_bit_depth, "OfxBitDepthFloat");
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
|
||||
/// RoD:插件实现 getRegionOfDefinition → 用插件值(1920×1080,
|
||||
/// HS: ofxhImageEffect.cpp:1087-1130 的 out args 读取)。
|
||||
/// "未实现时用输入 RoD 并集"的默认语义需要插件变体:`// TODO(plugin)`。
|
||||
#[test]
|
||||
fn region_of_definition_default_and_override() {
|
||||
common::with_host(|| {
|
||||
unsafe { oakplugin_host_init() };
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
return;
|
||||
}
|
||||
let inst = instance_of(&h).expect("句柄应可解析");
|
||||
let rod = inst
|
||||
.value
|
||||
.get_region_of_definition(0.0, oakplugin::instance::RenderScale { x: 1.0, y: 1.0 })
|
||||
.expect("getRoD 应成功");
|
||||
assert_eq!((rod.x1, rod.y1, rod.x2, rod.y2), (0.0, 0.0, 1920.0, 1080.0));
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
|
||||
/// RoI:插件把 region 原样写回 Source 的 per-clip 属性
|
||||
/// ("OfxImageEffectPropRegionOfInterest_Source");返回值与
|
||||
/// 请求 region 一致(HS: getRegionsOfInterest 的 per-clip 前缀)。
|
||||
#[test]
|
||||
fn regions_of_interest_writeback() {
|
||||
common::with_host(|| {
|
||||
unsafe { oakplugin_host_init() };
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
return;
|
||||
}
|
||||
let inst = instance_of(&h).expect("句柄应可解析");
|
||||
let region = oakplugin::instance::OfxRectD { x1: 10.0, y1: 20.0, x2: 100.0, y2: 200.0 };
|
||||
let rois = inst
|
||||
.value
|
||||
.get_regions_of_interest(
|
||||
0.0,
|
||||
oakplugin::instance::RenderScale { x: 1.0, y: 1.0 },
|
||||
region,
|
||||
)
|
||||
.expect("getRoI 应成功");
|
||||
assert_eq!(rois.len(), 2, "clips 数与 RoI 数一致");
|
||||
// RoI 只对输入 clip 有意义:Source 回写 region;Output 是
|
||||
// 宿主预定义的默认(零),渲染驱动只用输入条目。
|
||||
let source = rois
|
||||
.get(inst.value.clips.iter().position(|c| c.name == "Source").unwrap())
|
||||
.unwrap();
|
||||
assert_eq!((source.x1, source.y1, source.x2, source.y2), (10.0, 20.0, 100.0, 200.0));
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
|
||||
/// isIdentity:测试插件恒非透传 → None(render 必须真正执行)。
|
||||
/// 透传短路(Some)需要插件变体:`// TODO(plugin)`。
|
||||
#[test]
|
||||
fn is_identity_shortcircuit() {
|
||||
common::with_host(|| {
|
||||
unsafe { oakplugin_host_init() };
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
return;
|
||||
}
|
||||
let inst = instance_of(&h).expect("句柄应可解析");
|
||||
let identity = inst.value.is_identity(1.5).expect("isIdentity 应成功");
|
||||
assert!(identity.is_none(), "测试插件恒非透传:{identity:?}");
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
|
||||
/// field 透传:插件声明的 field order(OfxFieldNone)原样透传到
|
||||
/// 协商结果(宿主只透传不处理)。多值矩阵 `// TODO(plugin)`。
|
||||
#[test]
|
||||
fn field_passthrough() {
|
||||
common::with_host(|| {
|
||||
unsafe { oakplugin_host_init() };
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
return;
|
||||
}
|
||||
let inst = instance_of(&h).expect("句柄应可解析");
|
||||
let prefs = inst.value.get_clip_preferences().unwrap();
|
||||
assert_eq!(prefs.field, "OfxFieldNone", "插件声明的 field 应透传");
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
|
||||
/// begin/endSequenceRender:配对调用成功;begin 后 timeline
|
||||
/// getTimeBounds 返回该序列范围(sequence_range 接线)。
|
||||
#[test]
|
||||
fn sequence_render_brackets() {
|
||||
common::with_host(|| {
|
||||
use oakplugin::instance::OfxRangeD;
|
||||
unsafe { oakplugin_host_init() };
|
||||
let mut h = create_instance();
|
||||
if h.is_null() {
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
return;
|
||||
}
|
||||
let inst = instance_of(&h).expect("句柄应可解析");
|
||||
let range = OfxRangeD { min: 10.0, max: 200.0 };
|
||||
assert!(inst.value.begin_sequence_render(range).is_ok());
|
||||
assert!(inst.value.end_sequence_render(range).is_ok());
|
||||
|
||||
// begin → timeline 上下文带范围(经 render 设置;单测直达
|
||||
// RenderCtx 的接线见 suites::timeline 测试)。
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
unsafe { oakplugin_host_shutdown() };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! property.rs 的契约测试:OFX 属性集语义。
|
||||
//!
|
||||
//! 参照:HS: ofxhProperty.cpp。语义要点:多维数组、越界/缺失/
|
||||
/// 类型不符的行为、define 替换语义。
|
||||
|
||||
mod common;
|
||||
|
||||
use std::ffi::{c_void, CString};
|
||||
use std::sync::Arc;
|
||||
|
||||
use oakplugin::error::Error;
|
||||
use oakplugin::property::{PropertySet, Value};
|
||||
|
||||
/// `Value` 未实现 PartialEq(Pointer 无法比较),测试用 Debug 串
|
||||
/// 比较做值相等断言(同进程内指针的 Debug 输出确定)。
|
||||
fn val_eq(a: &Value, b: &Value) -> bool {
|
||||
format!("{a:?}") == format!("{b:?}")
|
||||
}
|
||||
|
||||
/// define 后 get 命中;同名 define 整体替换值数组(维度随之变化)。
|
||||
#[test]
|
||||
fn define_and_replace() {
|
||||
let s = PropertySet::new();
|
||||
s.define("a", vec![Value::Int(1), Value::Int(2)]);
|
||||
assert_eq!(s.dimension("a"), 2);
|
||||
assert!(val_eq(&s.get("a", 0).unwrap(), &Value::Int(1)));
|
||||
|
||||
// 同名整体替换:旧值数组连同维度一起被覆盖。
|
||||
s.define("a", vec![Value::Int(9)]);
|
||||
assert_eq!(s.dimension("a"), 1);
|
||||
assert!(val_eq(&s.get("a", 0).unwrap(), &Value::Int(9)));
|
||||
assert!(s.get("a", 1).is_none());
|
||||
}
|
||||
|
||||
/// 未定义属性的 get 返回 None;dimension 为 0。
|
||||
#[test]
|
||||
fn missing_property() {
|
||||
let s = PropertySet::new();
|
||||
assert!(s.get("nope", 0).is_none());
|
||||
assert!(s.get("nope", 42).is_none());
|
||||
assert_eq!(s.dimension("nope"), 0);
|
||||
}
|
||||
|
||||
/// 越界读取(index >= dimension)返回 None。
|
||||
#[test]
|
||||
fn out_of_bounds_read() {
|
||||
let s = PropertySet::new();
|
||||
s.define("a", vec![Value::Int(1)]);
|
||||
assert!(s.get("a", 1).is_none());
|
||||
assert!(s.get("a", 100).is_none());
|
||||
}
|
||||
|
||||
/// set_at 覆盖指定下标且不改维度;对缺失属性返回 NotFound 错误。
|
||||
#[test]
|
||||
fn set_at_semantics() {
|
||||
let s = PropertySet::new();
|
||||
s.define("a", vec![Value::Int(1), Value::Int(2)]);
|
||||
|
||||
// 覆盖命中下标,维度不变。
|
||||
assert!(s.set_at("a", 0, Value::Int(9)).is_ok());
|
||||
assert!(val_eq(&s.get("a", 0).unwrap(), &Value::Int(9)));
|
||||
assert_eq!(s.dimension("a"), 2);
|
||||
|
||||
// 越界写:维度固定语义(不自动扩容)→ NotFound。
|
||||
assert!(matches!(s.set_at("a", 2, Value::Int(3)), Err(Error::NotFound)));
|
||||
assert_eq!(s.dimension("a"), 2);
|
||||
|
||||
// 缺失属性:NotFound。
|
||||
assert!(matches!(s.set_at("b", 0, Value::Int(1)), Err(Error::NotFound)));
|
||||
}
|
||||
|
||||
/// 四种值类型(Int/Double/String/Pointer)各自的存取 round-trip;
|
||||
/// 字符串含空串与 UTF-8 非 ASCII。
|
||||
#[test]
|
||||
fn typed_roundtrip() {
|
||||
let s = PropertySet::new();
|
||||
let non_ascii = CString::new("你好,wörld ✓").unwrap();
|
||||
let raw = 0x1234usize as *mut c_void;
|
||||
|
||||
s.define("ints", vec![Value::Int(-7)]);
|
||||
s.define("dbls", vec![Value::Double(1.5)]);
|
||||
s.define("strs", vec![Value::String(non_ascii.clone())]);
|
||||
s.define("ptrs", vec![Value::Pointer(raw)]);
|
||||
|
||||
assert!(val_eq(&s.get("ints", 0).unwrap(), &Value::Int(-7)));
|
||||
assert!(val_eq(&s.get("dbls", 0).unwrap(), &Value::Double(1.5)));
|
||||
assert!(val_eq(&s.get("strs", 0).unwrap(), &Value::String(non_ascii)));
|
||||
assert!(val_eq(&s.get("ptrs", 0).unwrap(), &Value::Pointer(raw)));
|
||||
|
||||
// 空串也是合法属性值。
|
||||
s.set_one("empty", Value::String(CString::new("").unwrap()));
|
||||
assert!(val_eq(
|
||||
&s.get("empty", 0).unwrap(),
|
||||
&Value::String(CString::new("").unwrap())
|
||||
));
|
||||
}
|
||||
|
||||
/// remove 后属性消失;remove 缺失属性 no-op。
|
||||
#[test]
|
||||
fn remove_semantics() {
|
||||
let s = PropertySet::new();
|
||||
s.set_one("a", Value::Int(1));
|
||||
s.remove("a");
|
||||
assert!(s.get("a", 0).is_none());
|
||||
assert_eq!(s.dimension("a"), 0);
|
||||
|
||||
// 缺失属性 remove:no-op,不崩。
|
||||
s.remove("a");
|
||||
s.remove("never-existed");
|
||||
}
|
||||
|
||||
/// snapshot 返回全量快照且与后续修改隔离(深拷贝)。
|
||||
#[test]
|
||||
fn snapshot_is_isolated() {
|
||||
let s = PropertySet::new();
|
||||
s.define("a", vec![Value::Int(1)]);
|
||||
|
||||
let snap = s.snapshot();
|
||||
assert_eq!(snap.len(), 1);
|
||||
assert_eq!(snap[0].name, "a");
|
||||
|
||||
// 后续对属性集的修改不影响快照。
|
||||
s.set_at("a", 0, Value::Int(2)).unwrap();
|
||||
s.set_one("b", Value::Int(3));
|
||||
assert_eq!(snap.len(), 1);
|
||||
assert!(val_eq(&snap[0].values[0], &Value::Int(1)));
|
||||
|
||||
// 反向隔离:改快照不回写属性集(深拷贝验证)。
|
||||
let mut snap = snap;
|
||||
snap[0].values[0] = Value::Int(99);
|
||||
assert!(val_eq(&s.get("a", 0).unwrap(), &Value::Int(2)));
|
||||
}
|
||||
|
||||
/// 并发读写(32 线程 define/get/set_at 交错)不崩、数据自洽
|
||||
/// (属性集被所有 suite 共享,必须线程安全)。
|
||||
#[test]
|
||||
fn concurrent_access() {
|
||||
// 线程要求 'static:经 Arc 共享属性集(对应插件线程经句柄共享)。
|
||||
let s = Arc::new(PropertySet::new());
|
||||
let names: Vec<&'static str> = (0..32).map(leak_name).collect();
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
s.set_one(name, Value::Int(i as i32));
|
||||
}
|
||||
|
||||
let threads: Vec<_> = (0..32usize)
|
||||
.map(|i| {
|
||||
let s = Arc::clone(&s);
|
||||
// &'static str 是 Copy:拷贝进闭包,线程各自持名。
|
||||
let name = names[i];
|
||||
std::thread::spawn(move || {
|
||||
for k in 0..500 {
|
||||
// 读永远合法(属性始终存在、维度恒为 1)。
|
||||
assert!(s.get(name, 0).is_some());
|
||||
if i % 2 == 0 {
|
||||
// 偶数线程:set_at 覆盖(不改维度)。
|
||||
s.set_at(name, 0, Value::Int((i + k) as i32)).unwrap();
|
||||
} else {
|
||||
// 奇数线程:define 整体替换(维度同样恒为 1)。
|
||||
s.define(name, vec![Value::Int((i as i32) - k as i32)]);
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for t in threads {
|
||||
t.join().unwrap();
|
||||
}
|
||||
|
||||
// 数据自洽:32 个属性全部存活、维度未被撑大。
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
assert_eq!(s.dimension(name), 1);
|
||||
assert!(s.get(name, 0).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// 泄露一个 `&'static str` 属性名(并发测试用;测试进程结束即回收)。
|
||||
fn leak_name(i: usize) -> &'static str {
|
||||
Box::leak(format!("p{i}").into_boxed_str())
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! render 驱动测试(M11 §4):pluginrenderer.cpp 语义收编的 CPU 路径
|
||||
//! ——序列括号、render_job 多输入/参数覆盖、isIdentity 短路、输出
|
||||
//! 装配像素断言。GL 路径见 gl_render_test.rs。
|
||||
|
||||
mod common;
|
||||
|
||||
use std::ffi::{c_char, c_int, c_void, CString};
|
||||
|
||||
use oakplugin::ffi::{
|
||||
oakplugin_host_scan, oakplugin_instance_create, oakplugin_instance_free,
|
||||
oakplugin_instance_get_param, oakplugin_instance_render_begin_sequence,
|
||||
oakplugin_instance_render_end_sequence, oakplugin_instance_render_job,
|
||||
OakPluginJobTexture, OakPluginJobValue, OakNodeValue, node_value_type,
|
||||
};
|
||||
use oakplugin::handle::CHandle;
|
||||
|
||||
const OK: i32 = 0;
|
||||
const TEST_PLUGIN_ID: &str = "org.oak.test-plugin";
|
||||
const IDENTITY_PLUGIN_ID: &str = "org.oak.test-plugin.identity";
|
||||
|
||||
fn cs(s: &str) -> CString {
|
||||
CString::new(s).unwrap()
|
||||
}
|
||||
|
||||
fn fake_texture(ctx: usize) -> CHandle {
|
||||
CHandle {
|
||||
ctx: ctx as *mut c_void,
|
||||
addref: None,
|
||||
release: None,
|
||||
abi_version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_and_create(id: &str) -> CHandle {
|
||||
if common::test_plugin_scan_dir().is_none() {
|
||||
common::skip("最小测试插件未构建");
|
||||
return CHandle::null();
|
||||
}
|
||||
let dir = cs(common::test_plugin_scan_dir().unwrap().to_str().unwrap());
|
||||
let dirs = [dir.as_ptr()];
|
||||
unsafe { oakplugin_host_scan(dirs.as_ptr(), 1) };
|
||||
let id = cs(id);
|
||||
unsafe { oakplugin_instance_create(id.as_ptr()) }
|
||||
}
|
||||
|
||||
/// 序列括号 + render_job(CPU):begin → job → end;输出像素为测试
|
||||
/// 插件常量填充(0.5 RGBA F32,alpha=1);RoI 计算成功。
|
||||
#[cfg(feature = "test-stubs")]
|
||||
#[test]
|
||||
fn render_job_cpu_path_and_sequence_brackets() {
|
||||
use oakplugin::bridge::render::stub;
|
||||
common::with_host(|| {
|
||||
stub::reset();
|
||||
let mut h = scan_and_create(TEST_PLUGIN_ID);
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
stub::setup_dst(8, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
let dst = fake_texture(0xA1);
|
||||
|
||||
// 未 begin 序列直接 job 也可用(单帧;序列括号是优化语义)。
|
||||
assert_eq!(
|
||||
unsafe { oakplugin_instance_render_job(h, dst, 0.0, 0, 0, std::ptr::null(), CHandle::null(), std::ptr::null(), 0, std::ptr::null(), 0, CHandle::null()) },
|
||||
OK
|
||||
);
|
||||
// begin/end 括号配对。
|
||||
assert_eq!(unsafe { oakplugin_instance_render_begin_sequence(h, 0.0, 10.0, 0) }, OK);
|
||||
assert_eq!(unsafe { oakplugin_instance_render_end_sequence(h, 0.0, 10.0, 0) }, OK);
|
||||
|
||||
// 像素断言:8×4 常量 0.5 / alpha 1。
|
||||
let pixels = stub::dst_pixels();
|
||||
let n = pixels.len() / 16;
|
||||
assert_eq!(n, 32);
|
||||
for i in 0..n {
|
||||
let f = |o: usize| f32::from_le_bytes(pixels[i * 16 + o * 4..i * 16 + o * 4 + 4].try_into().unwrap());
|
||||
assert_eq!(f(0), 0.5, "pixel {i} r");
|
||||
assert_eq!(f(3), 1.0, "pixel {i} a");
|
||||
}
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// 多输入 + 参数覆盖:inputs 表按 clip 名挂输入纹理;values 覆盖
|
||||
/// gain 参数(驱动注入实例参数,插件经 param 读取——测试插件 render
|
||||
/// 不读 gain,故断言回写成功即可)。
|
||||
#[cfg(feature = "test-stubs")]
|
||||
#[test]
|
||||
fn render_job_multi_input_and_param_overrides() {
|
||||
use oakplugin::bridge::render::stub;
|
||||
common::with_host(|| {
|
||||
stub::reset();
|
||||
let mut h = scan_and_create(TEST_PLUGIN_ID);
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
stub::setup_dst(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
stub::setup_src(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32, vec![0u8; 4 * 4 * 16]);
|
||||
let dst = fake_texture(0xA1);
|
||||
let src = fake_texture(0xA2);
|
||||
|
||||
// 参数覆盖:gain = 1.5(FLOAT)。
|
||||
let mut value = OakNodeValue::default();
|
||||
value.r#type = node_value_type::FLOAT;
|
||||
value.f = [1.5, 0.0, 0.0, 0.0];
|
||||
let gain = cs("gain");
|
||||
let job_values = [OakPluginJobValue {
|
||||
key: gain.as_ptr(),
|
||||
value,
|
||||
}];
|
||||
|
||||
// 多输入表:Source → src 纹理。
|
||||
let source = cs("Source");
|
||||
let job_inputs = [OakPluginJobTexture {
|
||||
clip: source.as_ptr(),
|
||||
texture: src,
|
||||
}];
|
||||
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
oakplugin_instance_render_job(
|
||||
h,
|
||||
dst,
|
||||
0.0,
|
||||
0,
|
||||
0,
|
||||
std::ptr::null(),
|
||||
CHandle::null(),
|
||||
job_inputs.as_ptr(),
|
||||
1,
|
||||
job_values.as_ptr(),
|
||||
1,
|
||||
CHandle::null(),
|
||||
)
|
||||
},
|
||||
OK
|
||||
);
|
||||
|
||||
// 覆盖已生效:经 C ABI 读回 gain。
|
||||
let mut out = OakNodeValue::default();
|
||||
let g = cs("gain");
|
||||
assert_eq!(unsafe { oakplugin_instance_get_param(h, g.as_ptr(), &mut out) }, OK);
|
||||
assert_eq!(out.f[0], 1.5, "gain 覆盖应已注入实例参数");
|
||||
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// isIdentity 短路:identity 变体插件返回 "Source" → render_job 不调
|
||||
/// render action,直接把 Source 的帧拷入输出(像素与输入一致)。
|
||||
#[cfg(feature = "test-stubs")]
|
||||
#[test]
|
||||
fn render_job_is_identity_shortcircuit() {
|
||||
use oakplugin::bridge::render::stub;
|
||||
common::with_host(|| {
|
||||
stub::reset();
|
||||
let mut h = scan_and_create(IDENTITY_PLUGIN_ID);
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
// 输入帧:每像素 r=0.1,g=0.2,b=0.3,a=1.0。
|
||||
let mut pixels = Vec::new();
|
||||
for _ in 0..4 * 4 {
|
||||
for v in [0.1f32, 0.2, 0.3, 1.0] {
|
||||
pixels.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
stub::setup_dst(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
stub::setup_src(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32, pixels.clone());
|
||||
let dst = fake_texture(0xA1);
|
||||
let src = fake_texture(0xA2);
|
||||
|
||||
assert_eq!(
|
||||
unsafe { oakplugin_instance_render_job(h, dst, 0.0, 0, 0, cs("Source").as_ptr(), src, std::ptr::null(), 0, std::ptr::null(), 0, CHandle::null()) },
|
||||
OK
|
||||
);
|
||||
|
||||
// 输出 == 输入(透传帧)。
|
||||
let out_pixels = stub::dst_pixels();
|
||||
assert_eq!(out_pixels, pixels, "isIdentity 透传应逐字节拷贝输入帧");
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// 默认构建(无桩):render_job 缺桥符号 → 明确失败码而非崩溃
|
||||
/// (优雅降级;GL 用例同)。
|
||||
#[cfg(not(feature = "test-stubs"))]
|
||||
#[test]
|
||||
fn render_job_graceful_without_bridge() {
|
||||
common::with_host(|| {
|
||||
let mut h = scan_and_create(TEST_PLUGIN_ID);
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
// NULL 输出纹理在触达 oakrender 前被插件层校验拒绝(E_INVALID)。
|
||||
let dst = CHandle::null();
|
||||
let r = unsafe {
|
||||
oakplugin_instance_render_job(h, dst, 0.0, 0, 0, std::ptr::null(), CHandle::null(), std::ptr::null(), 0, std::ptr::null(), 0, CHandle::null())
|
||||
};
|
||||
assert_eq!(r, -90001, "NULL 输出纹理 → E_INVALID(不崩溃)");
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
|
||||
/// 驱动错误路径:非 F32 输出 → 明确失败;取消标记 → 明确失败。
|
||||
#[cfg(feature = "test-stubs")]
|
||||
#[test]
|
||||
fn render_job_error_paths() {
|
||||
use oakplugin::bridge::render::stub;
|
||||
use oakplugin::ffi::{oakplugin_instance_cancel, oakplugin_instance_render_job};
|
||||
common::with_host(|| {
|
||||
stub::reset();
|
||||
let mut h = scan_and_create(TEST_PLUGIN_ID);
|
||||
if h.is_null() {
|
||||
return;
|
||||
}
|
||||
// 非 F32 输出(U8)→ read_dst 拒绝。
|
||||
stub::setup_dst(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_U8);
|
||||
let dst = fake_texture(0xA1);
|
||||
let r = unsafe {
|
||||
oakplugin_instance_render_job(h, dst, 0.0, 0, 0, std::ptr::null(), CHandle::null(), std::ptr::null(), 0, std::ptr::null(), 0, CHandle::null())
|
||||
};
|
||||
assert_eq!(r, -90003, "非 F32 输出 → E_FAILED");
|
||||
|
||||
// 取消标记 → 驱动入口短路。
|
||||
stub::setup_dst(4, 4, oakplugin::bridge::render::PIXEL_FORMAT_F32);
|
||||
assert_eq!(unsafe { oakplugin_instance_cancel(h) }, OK);
|
||||
let r = unsafe {
|
||||
oakplugin_instance_render_job(h, dst, 0.0, 0, 0, std::ptr::null(), CHandle::null(), std::ptr::null(), 0, std::ptr::null(), 0, CHandle::null())
|
||||
};
|
||||
assert_eq!(r, -90003, "已取消 → E_FAILED");
|
||||
unsafe { oakplugin_instance_free(&mut h) };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! suite 层 round-trip 测试:宿主 suite 表 ⇄ 最小测试插件。
|
||||
//!
|
||||
//! 声明文档原计划"每个用例让测试插件在 describe/render 期间真实
|
||||
//! 回调对应 suite"("插件视角"的 HostSupport 兼容性背书)。最小
|
||||
//! 测试插件(tests/fixtures/oak-test-plugin.ofx.bundle)是第 0 期
|
||||
//! 交付物,尚未构建——插件无关的断言直接执行;依赖插件的用例经
|
||||
//! [`common::skip`] 门,插件落地后补全(用例内 `// TODO(plugin)`)。
|
||||
|
||||
mod common;
|
||||
|
||||
use std::ffi::{c_char, c_double, c_int, c_uint, c_void, CStr, CString};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use oakplugin::descriptor::EffectDescriptor;
|
||||
use oakplugin::instance::Instance;
|
||||
use oakplugin::param::{ParamInstance, ParamSetInstance};
|
||||
use oakplugin::property::{PropertySet, Value};
|
||||
use oakplugin::suites::fetch_suite;
|
||||
use oakplugin::suites::image_effect::suite_v1 as image_effect_suite;
|
||||
use oakplugin::suites::memory::suite_v1 as memory_suite;
|
||||
use oakplugin::suites::message::suite_v1 as message_suite_v1;
|
||||
use oakplugin::suites::multithread::suite_v1 as multithread_suite;
|
||||
use oakplugin::suites::param::suite_v1 as param_suite;
|
||||
use oakplugin::suites::progress::suite_v1 as progress_suite;
|
||||
use oakplugin::suites::property::suite_v1 as property_suite;
|
||||
use oakplugin::suites::timeline::suite_v1 as timeline_suite;
|
||||
use oakplugin::suites::tag;
|
||||
|
||||
/// OFX 状态码的本地别名(SDK ofxCore.h)。
|
||||
const OK: c_int = 0;
|
||||
const UNKNOWN: c_int = 3;
|
||||
const UNSUPPORTED: c_int = 5;
|
||||
const BAD_HANDLE: c_int = 9;
|
||||
const BAD_INDEX: c_int = 10;
|
||||
|
||||
fn cs(s: &str) -> CString {
|
||||
CString::new(s).unwrap()
|
||||
}
|
||||
|
||||
/// 未打标属性集句柄(宿主内部直用路径)。
|
||||
fn props_handle(s: &PropertySet) -> *mut c_void {
|
||||
s as *const PropertySet as *mut c_void
|
||||
}
|
||||
|
||||
/// 假插件(仅喂给 Instance;describe 之外的字段不被触碰)。
|
||||
fn dummy_plugin(descriptor: EffectDescriptor) -> std::sync::Arc<oakplugin::host::Plugin> {
|
||||
unsafe extern "C" fn dummy_entry(
|
||||
_: *const c_char,
|
||||
_: *const c_void,
|
||||
_: *mut c_void,
|
||||
_: *mut c_void,
|
||||
) -> c_int {
|
||||
OK
|
||||
}
|
||||
std::sync::Arc::new(oakplugin::host::Plugin {
|
||||
identifier: "test.plugin".into(),
|
||||
version: (1, 0),
|
||||
bundle_path: std::path::PathBuf::new(),
|
||||
contexts: vec![],
|
||||
descriptor,
|
||||
lib: std::ptr::null_mut(),
|
||||
entry: dummy_entry,
|
||||
ofx_plugin: std::ptr::null_mut(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 用 param suite 的 paramDefine 造一个含 6 参数的实例。
|
||||
fn make_instance() -> (std::sync::Arc<Instance>, *mut c_void) {
|
||||
let mut desc = EffectDescriptor::new();
|
||||
let s = param_suite();
|
||||
let dhandle = tag::make(&desc.props as *const PropertySet, tag::DESCRIPTOR);
|
||||
unsafe {
|
||||
for (t, n) in [
|
||||
("OfxParamTypeInteger", "gain"),
|
||||
("OfxParamTypeDouble", "opacity"),
|
||||
("OfxParamTypeDouble2D", "pos"),
|
||||
("OfxParamTypeRGB", "color"),
|
||||
("OfxParamTypeBoolean", "enabled"),
|
||||
("OfxParamTypeString", "label"),
|
||||
] {
|
||||
let t = cs(t);
|
||||
let n = cs(n);
|
||||
let mut ph: *mut c_void = std::ptr::null_mut();
|
||||
assert_eq!((s.param_define)(dhandle, t.as_ptr(), n.as_ptr(), &mut ph), OK);
|
||||
}
|
||||
}
|
||||
let params = ParamSetInstance {
|
||||
params: desc
|
||||
.params
|
||||
.iter()
|
||||
.map(|d| Box::new(ParamInstance::from_def((**d).clone())))
|
||||
.collect(),
|
||||
};
|
||||
let plugin = dummy_plugin(desc);
|
||||
let inst = std::sync::Arc::new(Instance {
|
||||
props: PropertySet::new(),
|
||||
plugin,
|
||||
context: "OfxImageEffectContextFilter".into(),
|
||||
params,
|
||||
clips: vec![],
|
||||
node_identity: std::sync::atomic::AtomicUsize::new(0),
|
||||
destroyed: std::sync::atomic::AtomicBool::new(false),
|
||||
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(oakplugin::instance::EditTransaction::new()),
|
||||
render_lock: std::sync::Mutex::new(()),
|
||||
});
|
||||
let h = tag::make(&inst.props as *const PropertySet, tag::INSTANCE);
|
||||
(inst, h)
|
||||
}
|
||||
|
||||
/// fetch_suite:第 1 期承诺的八张表按名按版本可取(v 不符返回
|
||||
/// 空);不认识的 suite 返回空。覆盖 M11 §3.1 清单逐个断言。
|
||||
#[test]
|
||||
fn fetch_suite_registry() {
|
||||
for (name, version) in [
|
||||
("OfxPropertySuite", 1),
|
||||
("OfxMemorySuite", 1),
|
||||
("OfxImageEffectSuite", 1),
|
||||
("OfxParameterSuite", 1),
|
||||
("OfxMessageSuite", 1),
|
||||
("OfxMessageSuite", 2),
|
||||
("OfxProgressSuite", 1),
|
||||
("OfxProgressSuite", 2),
|
||||
("OfxTimeLineSuite", 1),
|
||||
("OfxMultiThreadSuite", 1),
|
||||
] {
|
||||
assert!(
|
||||
fetch_suite(name, version).is_some(),
|
||||
"{name} v{version} 应可取"
|
||||
);
|
||||
}
|
||||
assert!(fetch_suite("OfxPropertySuite", 2).is_none());
|
||||
assert!(fetch_suite("OfxMessageSuite", 3).is_none());
|
||||
assert!(fetch_suite("OfxBogusSuite", 1).is_none());
|
||||
}
|
||||
|
||||
/// property suite:define→set→get→getDimension→reset 全链路;
|
||||
/// 越界读返回 kOfxStatErrBadIndex;未定义属性 → ErrUnknown。
|
||||
///
|
||||
/// 声明原含"写只读宿主能力属性 → kOfxStatErrReadOnly":SDK 无此
|
||||
/// 状态码、HostSupport 也不在 suite 层拦截写(见 suites/property.rs
|
||||
/// 模块文档的偏差说明)——本测试随之改为断言 reset 的明确行为。
|
||||
#[test]
|
||||
fn property_suite_roundtrip() {
|
||||
let set = PropertySet::new();
|
||||
set.define("width", vec![Value::Int(0)]);
|
||||
let h = props_handle(&set);
|
||||
let s = property_suite();
|
||||
let name = cs("width");
|
||||
|
||||
// set → get round-trip。
|
||||
unsafe {
|
||||
assert_eq!((s.set_int)(h, name.as_ptr(), 0, 1920), OK);
|
||||
}
|
||||
let mut v = 0;
|
||||
unsafe {
|
||||
assert_eq!((s.get_int)(h, name.as_ptr(), 0, &mut v), OK);
|
||||
}
|
||||
assert_eq!(v, 1920);
|
||||
|
||||
// getDimension。
|
||||
let mut dim = 0;
|
||||
unsafe {
|
||||
assert_eq!((s.get_dimension)(h, name.as_ptr(), &mut dim), OK);
|
||||
}
|
||||
assert_eq!(dim, 1);
|
||||
|
||||
// 越界读 → BadIndex;未定义 → Unknown。
|
||||
unsafe {
|
||||
assert_eq!((s.get_int)(h, name.as_ptr(), 5, &mut v), BAD_INDEX);
|
||||
}
|
||||
let nope = cs("nope");
|
||||
unsafe {
|
||||
assert_eq!((s.get_int)(h, nope.as_ptr(), 0, &mut v), UNKNOWN);
|
||||
}
|
||||
|
||||
// propReset:第 1 期明确不支持(无默认值快照)→ ErrUnsupported。
|
||||
unsafe {
|
||||
assert_eq!((s.reset)(h, name.as_ptr()), UNSUPPORTED);
|
||||
}
|
||||
|
||||
// 空 handle → BadHandle。
|
||||
unsafe {
|
||||
assert_eq!((s.get_int)(std::ptr::null_mut(), name.as_ptr(), 0, &mut v), BAD_HANDLE);
|
||||
}
|
||||
}
|
||||
|
||||
/// memory suite:alloc/free 正常;free(NULL) no-op;未知指针
|
||||
/// free → BadHandle;零尺寸分配可往返。
|
||||
///
|
||||
/// 声明原含"实例销毁后账本无残留":sweep 是 pub(crate) 钩子
|
||||
/// (destroyInstance 路径),属 host 生命周期测试(lifecycle_test)
|
||||
/// ——此处只验 suite 契约。
|
||||
#[test]
|
||||
fn memory_suite_ledger() {
|
||||
let s = memory_suite();
|
||||
let mut a: *mut c_void = std::ptr::null_mut();
|
||||
unsafe {
|
||||
assert_eq!((s.alloc)(std::ptr::null_mut(), 256, &mut a), OK);
|
||||
}
|
||||
assert!(!a.is_null());
|
||||
// 内存可用且对齐(f32 写读)。
|
||||
unsafe {
|
||||
let f = a as *mut f32;
|
||||
*f = 1.5f32;
|
||||
assert_eq!(*f, 1.5f32);
|
||||
assert_eq!(a as usize % 16, 0);
|
||||
}
|
||||
// 释放;free(NULL) no-op;重复/未知 → BadHandle。
|
||||
unsafe {
|
||||
assert_eq!((s.free)(a), OK);
|
||||
assert_eq!((s.free)(std::ptr::null_mut()), OK);
|
||||
assert_eq!((s.free)(a), BAD_HANDLE);
|
||||
}
|
||||
// 零尺寸。
|
||||
let mut z: *mut c_void = std::ptr::null_mut();
|
||||
unsafe {
|
||||
assert_eq!((s.alloc)(std::ptr::null_mut(), 0, &mut z), OK);
|
||||
assert_eq!((s.free)(z), OK);
|
||||
}
|
||||
}
|
||||
|
||||
/// image effect suite:describe 期 clipDefine/clipGetPropertySet 与
|
||||
/// 属性读写;实例期 clipGetHandle。
|
||||
///
|
||||
/// clipGetImage/clipReleaseImage 配对依赖
|
||||
/// [`oakplugin::clip::ClipInstance::fetch_image`](bridge::render 帧
|
||||
/// 访问 C ABI 未冻结)——随最小测试插件落地补全(`// TODO(plugin)`)。
|
||||
#[test]
|
||||
fn image_effect_clip_image_pairing() {
|
||||
let mut desc = EffectDescriptor::new();
|
||||
let s = image_effect_suite();
|
||||
let h = tag::make(&desc.props as *const PropertySet, tag::DESCRIPTOR);
|
||||
|
||||
// clipDefine + 属性读写(props 在偏移 0)。
|
||||
let mut clip: *mut c_void = std::ptr::null_mut();
|
||||
let name = cs("Source");
|
||||
unsafe {
|
||||
assert_eq!((s.clip_define)(h, name.as_ptr(), &mut clip), OK);
|
||||
}
|
||||
assert_eq!(tag::kind(clip), tag::CLIP);
|
||||
let ps = property_suite();
|
||||
let label_prop = cs("OfxPropLabel");
|
||||
let optional_prop = cs("OfxImageClipPropOptional");
|
||||
let label = cs("SourceLabel");
|
||||
unsafe {
|
||||
assert_eq!((ps.set_string)(clip, label_prop.as_ptr(), 0, label.as_ptr()), OK);
|
||||
assert_eq!((ps.set_int)(clip, optional_prop.as_ptr(), 0, 1), OK);
|
||||
}
|
||||
let mut out: *mut c_char = std::ptr::null_mut();
|
||||
unsafe {
|
||||
assert_eq!((ps.get_string)(clip, label_prop.as_ptr(), 0, &mut out), OK);
|
||||
assert_eq!(CStr::from_ptr(out).to_bytes(), b"SourceLabel");
|
||||
}
|
||||
|
||||
// getPropertySet/getParamSet:返回 effect 本体。
|
||||
let mut props: *mut c_void = std::ptr::null_mut();
|
||||
unsafe {
|
||||
assert_eq!((s.get_property_set)(h, &mut props), OK);
|
||||
assert_eq!(props, h);
|
||||
assert_eq!((s.get_param_set)(h, &mut props), OK);
|
||||
assert_eq!(props, h);
|
||||
}
|
||||
|
||||
// 实例期 clipGetHandle(手工构造实例 + clip 实例)。
|
||||
let clip_desc = oakplugin::descriptor::ClipDescriptor {
|
||||
props: PropertySet::new(),
|
||||
name: "Source".into(),
|
||||
};
|
||||
let inst = std::sync::Arc::new(Instance {
|
||||
props: PropertySet::new(),
|
||||
plugin: dummy_plugin(EffectDescriptor::new()),
|
||||
context: "OfxImageEffectContextFilter".into(),
|
||||
params: ParamSetInstance { params: vec![] },
|
||||
clips: vec![Box::new(oakplugin::clip::ClipInstance::from_descriptor(
|
||||
&clip_desc,
|
||||
))],
|
||||
node_identity: std::sync::atomic::AtomicUsize::new(0),
|
||||
destroyed: std::sync::atomic::AtomicBool::new(false),
|
||||
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(oakplugin::instance::EditTransaction::new()),
|
||||
render_lock: std::sync::Mutex::new(()),
|
||||
});
|
||||
let ih = tag::make(&inst.props as *const PropertySet, tag::INSTANCE);
|
||||
let mut clip_h: *mut c_void = std::ptr::null_mut();
|
||||
unsafe {
|
||||
assert_eq!((s.clip_get_handle)(ih, name.as_ptr(), &mut clip_h, std::ptr::null_mut()), OK);
|
||||
}
|
||||
assert_eq!(tag::kind(clip_h), tag::CLIP);
|
||||
// 未找到 → BadHandle(HS:2067-2070)。
|
||||
let nope = cs("Nope");
|
||||
unsafe {
|
||||
assert_eq!((s.clip_get_handle)(ih, nope.as_ptr(), &mut clip_h, std::ptr::null_mut()), BAD_HANDLE);
|
||||
}
|
||||
|
||||
// clipGetImage/clipReleaseImage 配对:依赖 clip fetch_image
|
||||
// (bridge::render 未冻结)——插件落地后补全。
|
||||
if common::test_plugin_dir().is_none() {
|
||||
common::skip("clipGetImage 配对随最小测试插件落地(M11 §2.4)");
|
||||
return;
|
||||
}
|
||||
// TODO(plugin):插件驱动 clipGetImage → clipReleaseImage 配对 +
|
||||
// 不配对时的销毁记账断言。
|
||||
}
|
||||
|
||||
/// param suite:describe 期 define→getHandle→getValue(默认值);
|
||||
/// 实例期 int/double/bool/choice/string/RGBA/2D/3D 的
|
||||
/// setValue/getValue round-trip;AtTime == 当前值。
|
||||
///
|
||||
/// 声明原含"paramSetValue 触发 instanceChanged":通知走
|
||||
/// [`oakplugin::param::notify_instance_changed`](bridge 期实现)——
|
||||
/// 随插件+桥落地补全(`// TODO(bridge)`)。
|
||||
#[test]
|
||||
fn param_suite_roundtrip_and_change_action() {
|
||||
// describe 期。
|
||||
let mut desc = EffectDescriptor::new();
|
||||
let s = param_suite();
|
||||
let dhandle = tag::make(&desc.props as *const PropertySet, tag::DESCRIPTOR);
|
||||
let t = cs("OfxParamTypeDouble");
|
||||
let n = cs("opacity");
|
||||
let mut ph: *mut c_void = std::ptr::null_mut();
|
||||
unsafe {
|
||||
assert_eq!((s.param_define)(dhandle, t.as_ptr(), n.as_ptr(), &mut ph), OK);
|
||||
}
|
||||
assert_eq!(tag::kind(ph), tag::PARAM_DEF);
|
||||
let mut v = 99.0;
|
||||
unsafe {
|
||||
assert_eq!((s.param_get_value)(ph, &mut v), OK);
|
||||
}
|
||||
assert_eq!(v, 0.0, "describe 期 getValue 应为默认值");
|
||||
// describe 期 set → BadHandle(HS verifyMagic 语义)。
|
||||
unsafe {
|
||||
assert_eq!((s.param_set_value)(ph, 42.0), BAD_HANDLE);
|
||||
}
|
||||
// paramGetHandle 按名取;未找到 → Unknown。
|
||||
let mut ph2: *mut c_void = std::ptr::null_mut();
|
||||
unsafe {
|
||||
assert_eq!((s.param_get_handle)(dhandle, n.as_ptr(), &mut ph2, std::ptr::null_mut()), OK);
|
||||
assert_eq!(ph, ph2);
|
||||
}
|
||||
let nope = cs("nope");
|
||||
unsafe {
|
||||
assert_eq!((s.param_get_handle)(dhandle, nope.as_ptr(), &mut ph2, std::ptr::null_mut()), UNKNOWN);
|
||||
}
|
||||
|
||||
// 实例期 round-trip。
|
||||
let (_inst, ih) = make_instance();
|
||||
let mut gain: *mut c_void = std::ptr::null_mut();
|
||||
let mut pos: *mut c_void = std::ptr::null_mut();
|
||||
let mut color: *mut c_void = std::ptr::null_mut();
|
||||
let mut enabled: *mut c_void = std::ptr::null_mut();
|
||||
let mut label: *mut c_void = std::ptr::null_mut();
|
||||
unsafe {
|
||||
for (n, out) in [
|
||||
("gain", &mut gain),
|
||||
("pos", &mut pos),
|
||||
("color", &mut color),
|
||||
("enabled", &mut enabled),
|
||||
("label", &mut label),
|
||||
] {
|
||||
let n = cs(n);
|
||||
assert_eq!((s.param_get_handle)(ih, n.as_ptr(), out, std::ptr::null_mut()), OK);
|
||||
assert_eq!(tag::kind(*out), tag::PARAM_INSTANCE);
|
||||
}
|
||||
|
||||
// Integer。
|
||||
assert_eq!((s.param_set_value)(gain, 7), OK);
|
||||
let mut iv = 0;
|
||||
assert_eq!((s.param_get_value)(gain, &mut iv), OK);
|
||||
assert_eq!(iv, 7);
|
||||
|
||||
// Double2D。
|
||||
assert_eq!((s.param_set_value)(pos, 1.5, -2.5), OK);
|
||||
let (mut x, mut y) = (0.0, 0.0);
|
||||
assert_eq!((s.param_get_value)(pos, &mut x, &mut y), OK);
|
||||
assert_eq!((x, y), (1.5, -2.5));
|
||||
|
||||
// RGB。
|
||||
assert_eq!((s.param_set_value)(color, 0.1, 0.2, 0.3), OK);
|
||||
let (mut r, mut g, mut b) = (0.0, 0.0, 0.0);
|
||||
assert_eq!((s.param_get_value)(color, &mut r, &mut g, &mut b), OK);
|
||||
assert_eq!((r, g, b), (0.1, 0.2, 0.3));
|
||||
|
||||
// Boolean。
|
||||
assert_eq!((s.param_set_value)(enabled, 1), OK);
|
||||
let mut e = 0;
|
||||
assert_eq!((s.param_get_value)(enabled, &mut e), OK);
|
||||
assert_eq!(e, 1);
|
||||
|
||||
// String(get 返回内驻指针)。
|
||||
let hello = cs("hello");
|
||||
assert_eq!((s.param_set_value)(label, hello.as_ptr()), OK);
|
||||
let mut p: *mut c_char = std::ptr::null_mut();
|
||||
assert_eq!((s.param_get_value)(label, &mut p), OK);
|
||||
assert_eq!(CStr::from_ptr(p).to_bytes(), b"hello");
|
||||
|
||||
// AtTime == 当前值(无动画)。
|
||||
let mut v2 = 0.0;
|
||||
assert_eq!((s.param_get_value_at_time)(gain, 12.0, &mut iv), OK);
|
||||
assert_eq!(iv, 7);
|
||||
assert_eq!((s.param_set_value_at_time)(gain, 12.0, 9), OK);
|
||||
let mut iv2 = 0;
|
||||
assert_eq!((s.param_get_value)(gain, &mut iv2), OK);
|
||||
assert_eq!(iv2, 9);
|
||||
}
|
||||
|
||||
// TODO(bridge):paramSetValue → instanceChanged 断言随
|
||||
// notify_instance_changed(bridge 期)落地。
|
||||
}
|
||||
|
||||
/// paramGetValueAtTime/paramSetValueAtTime 与关键帧族
|
||||
/// (GetNumKeys/GetKeyTime/GetKeyIndex/DeleteKey/DeleteAllKeys):
|
||||
/// 第 1 期无动画 → keys 恒 0、key 查询 BadIndex、删除 no-op OK。
|
||||
#[test]
|
||||
fn param_keyframe_family() {
|
||||
let (_inst, ih) = make_instance();
|
||||
let s = param_suite();
|
||||
let mut opacity: *mut c_void = std::ptr::null_mut();
|
||||
let name = cs("opacity");
|
||||
unsafe {
|
||||
assert_eq!((s.param_get_handle)(ih, name.as_ptr(), &mut opacity, std::ptr::null_mut()), OK);
|
||||
}
|
||||
|
||||
let mut nkeys = -1;
|
||||
unsafe {
|
||||
assert_eq!((s.param_get_num_keys)(opacity, &mut nkeys), OK);
|
||||
}
|
||||
assert_eq!(nkeys, 0, "无动画支持:恒 0 个关键帧");
|
||||
|
||||
let mut kt = 0.0;
|
||||
unsafe {
|
||||
assert_eq!((s.param_get_key_time)(opacity, 0, &mut kt), BAD_INDEX);
|
||||
}
|
||||
let mut ki = 0;
|
||||
unsafe {
|
||||
assert_eq!((s.param_get_key_index)(opacity, 1.0, 0, &mut ki), BAD_INDEX);
|
||||
// 删除与拷贝:无关键帧 → no-op OK。
|
||||
assert_eq!((s.param_delete_key)(opacity, 1.0), OK);
|
||||
assert_eq!((s.param_delete_all_keys)(opacity), OK);
|
||||
assert_eq!((s.param_copy)(opacity, opacity, 1.0, 1.0, std::ptr::null()), OK);
|
||||
// editBegin/End 括号。
|
||||
assert_eq!((s.param_edit_begin)(opacity), OK);
|
||||
assert_eq!((s.param_edit_end)(opacity), OK);
|
||||
}
|
||||
}
|
||||
|
||||
/// message suite:v1 变长参数经 C shim 格式化后落到注册的捕获器
|
||||
/// (%d/%s/%f 三种格式逐字一致);headless 默认(question 答"否")。
|
||||
///
|
||||
/// v2 的 va_list 入口无法从 Rust 构造调用(platform ABI),格式化
|
||||
/// 路径与 v1 共用同一 C 函数(forward)——由测试插件从 C 侧覆盖
|
||||
/// (`// TODO(plugin)`)。
|
||||
#[test]
|
||||
fn message_suite_v1_v2() {
|
||||
use oakplugin::suites::message::set_handler;
|
||||
|
||||
unsafe extern "C" fn capture(
|
||||
type_: *const c_char,
|
||||
message: *const c_char,
|
||||
userdata: *mut c_void,
|
||||
) -> c_int {
|
||||
let v = unsafe { &mut *(userdata as *mut Vec<(String, String)>) };
|
||||
v.push((
|
||||
unsafe { CStr::from_ptr(type_) }.to_string_lossy().into_owned(),
|
||||
unsafe { CStr::from_ptr(message) }.to_string_lossy().into_owned(),
|
||||
));
|
||||
1
|
||||
}
|
||||
|
||||
let mut captured: Vec<(String, String)> = Vec::new();
|
||||
set_handler(Some(capture), &mut captured as *mut _ as *mut c_void);
|
||||
let s = message_suite_v1();
|
||||
let t = cs("OfxMessageError");
|
||||
let id = cs("test-id");
|
||||
let fmt = cs("v=%d s=%s f=%f");
|
||||
let str_arg = cs("oak");
|
||||
unsafe {
|
||||
// 答复 YES → kOfxStatReplyYes(12)。
|
||||
assert_eq!(
|
||||
(s.message)(std::ptr::null_mut(), t.as_ptr(), id.as_ptr(), fmt.as_ptr(), 42, str_arg.as_ptr(), 1.5f64),
|
||||
12
|
||||
);
|
||||
}
|
||||
assert_eq!(captured.len(), 1);
|
||||
assert_eq!(captured[0].0, "OfxMessageError");
|
||||
assert!(captured[0].1.starts_with("v=42 s=oak f="), "got {:?}", captured[0].1);
|
||||
|
||||
// NULL format → Failed(C++ olivehost.cpp:267)。
|
||||
unsafe {
|
||||
assert_eq!((s.message)(std::ptr::null_mut(), t.as_ptr(), id.as_ptr(), std::ptr::null()), 1);
|
||||
}
|
||||
|
||||
// 注销出口 → headless 默认:普通消息 OK。
|
||||
set_handler(None, std::ptr::null_mut());
|
||||
let msg = cs("ask");
|
||||
unsafe {
|
||||
assert_eq!(
|
||||
(s.message)(std::ptr::null_mut(), t.as_ptr(), id.as_ptr(), msg.as_ptr()),
|
||||
OK
|
||||
);
|
||||
}
|
||||
// question 类型 + 无出口 → REPLY_NO(13)。
|
||||
let q = cs("OfxMessageQuestion");
|
||||
unsafe {
|
||||
assert_eq!(
|
||||
(s.message)(std::ptr::null_mut(), q.as_ptr(), id.as_ptr(), fmt.as_ptr(), 1),
|
||||
13
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// progress suite:start/update/end 序列转发到 ProgressReporter;
|
||||
/// 回调返回 false 时 update 向插件返回取消状态。
|
||||
#[test]
|
||||
fn progress_suite_forwarding() {
|
||||
use oakplugin::progress::ProgressReporter;
|
||||
use oakplugin::suites::progress::set_current;
|
||||
|
||||
unsafe extern "C" fn capture_progress(p: f64, userdata: *mut c_void) -> c_int {
|
||||
let v = unsafe { &mut *(userdata as *mut Vec<f64>) };
|
||||
v.push(p);
|
||||
0
|
||||
}
|
||||
unsafe extern "C" fn cancel_progress(_p: f64, _userdata: *mut c_void) -> c_int {
|
||||
1
|
||||
}
|
||||
|
||||
let s = progress_suite();
|
||||
let mut seen: Vec<f64> = Vec::new();
|
||||
set_current(Some(unsafe { ProgressReporter::new(capture_progress, &mut seen as *mut _ as *mut c_void) }));
|
||||
|
||||
unsafe {
|
||||
assert_eq!((s.start)(std::ptr::null_mut(), std::ptr::null()), OK);
|
||||
assert_eq!((s.update)(std::ptr::null_mut(), 0.25), OK);
|
||||
assert_eq!((s.update)(std::ptr::null_mut(), 0.75), OK);
|
||||
assert_eq!((s.end)(std::ptr::null_mut()), OK);
|
||||
}
|
||||
assert_eq!(seen, vec![0.25, 0.75]);
|
||||
|
||||
// 取消回调:update → REPLY_NO。
|
||||
set_current(Some(unsafe { ProgressReporter::new(cancel_progress, std::ptr::null_mut()) }));
|
||||
unsafe {
|
||||
assert_eq!((s.update)(std::ptr::null_mut(), 0.5), 13);
|
||||
}
|
||||
set_current(None);
|
||||
}
|
||||
|
||||
/// timeline suite:getTime/getTimeBounds 返回渲染上下文注入的值;
|
||||
/// 无上下文 → 0 / (0,0) 的 headless 默认。
|
||||
#[test]
|
||||
fn timeline_suite_values() {
|
||||
use oakplugin::instance::{OfxRangeD, RenderScale};
|
||||
use oakplugin::suites::{RenderCtx, set_render_ctx};
|
||||
|
||||
let s = timeline_suite();
|
||||
let handle = 0x10usize as *mut c_void;
|
||||
let mut t = 0.0;
|
||||
let mut min = 0.0;
|
||||
let mut max = 0.0;
|
||||
|
||||
// 无上下文。
|
||||
unsafe {
|
||||
assert_eq!((s.get_time)(handle, &mut t), OK);
|
||||
assert_eq!(t, 0.0);
|
||||
assert_eq!((s.get_time_bounds)(handle, &mut min, &mut max), OK);
|
||||
assert_eq!((min, max), (0.0, 0.0));
|
||||
}
|
||||
|
||||
// 注入上下文。
|
||||
set_render_ctx(Some(RenderCtx {
|
||||
time: 42.5,
|
||||
scale: RenderScale { x: 1.0, y: 1.0 },
|
||||
range: OfxRangeD { min: 10.0, max: 200.0 },
|
||||
}));
|
||||
unsafe {
|
||||
assert_eq!((s.get_time)(handle, &mut t), OK);
|
||||
assert_eq!(t, 42.5);
|
||||
assert_eq!((s.get_time_bounds)(handle, &mut min, &mut max), OK);
|
||||
assert_eq!((min, max), (10.0, 200.0));
|
||||
assert_eq!((s.goto_time)(handle, 99.0), OK);
|
||||
}
|
||||
set_render_ctx(None);
|
||||
}
|
||||
|
||||
/// multithread suite:插件(此处以测试自身的工作线程模拟)起 8 线程
|
||||
/// 各回调 property suite 一千次;无数据竞争、index/isSpawnedThread
|
||||
/// 语义正确、join 完整。
|
||||
#[test]
|
||||
fn multithread_suite_spawned_callbacks() {
|
||||
|
||||
let s = multithread_suite();
|
||||
let ps = property_suite();
|
||||
|
||||
// 共享属性集:每线程一个计数器属性,各写 1000 次。
|
||||
// (测试自身即"插件":工作线程经 multiThread 起,回调 property
|
||||
// suite——与插件场景同构。)
|
||||
let set = std::sync::Arc::new(PropertySet::new());
|
||||
for i in 0..8 {
|
||||
let name = Box::leak(format!("ctr{i}").into_boxed_str());
|
||||
set.set_one(name, Value::Int(0));
|
||||
}
|
||||
|
||||
unsafe extern "C" fn counter_worker(index: c_uint, _count: c_uint, arg: *mut c_void) {
|
||||
eprintln!("DBG worker {index} arg={:x}", arg as usize);
|
||||
let set = unsafe { &*(arg as *const PropertySet) };
|
||||
let name = Box::leak(format!("ctr{index}").into_boxed_str());
|
||||
let mut v = 0;
|
||||
unsafe {
|
||||
let _ = (property_suite().get_int)(
|
||||
set as *const _ as *mut c_void,
|
||||
cs(name).as_ptr(),
|
||||
0,
|
||||
&mut v,
|
||||
);
|
||||
}
|
||||
for _ in 0..1000 {
|
||||
unsafe {
|
||||
let _ = (property_suite().set_int)(
|
||||
set as *const _ as *mut c_void,
|
||||
cs(name).as_ptr(),
|
||||
0,
|
||||
v + 1,
|
||||
);
|
||||
let _ = (property_suite().get_int)(
|
||||
set as *const _ as *mut c_void,
|
||||
cs(name).as_ptr(),
|
||||
0,
|
||||
&mut v,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 宿主线程身份。
|
||||
let mut idx = 0;
|
||||
let mut spawned = 1;
|
||||
unsafe {
|
||||
assert_eq!((s.index)(&mut idx), OK);
|
||||
assert_eq!(idx, -1);
|
||||
assert_eq!((s.is_spawned)(&mut spawned), OK);
|
||||
assert_eq!(spawned, 0);
|
||||
}
|
||||
|
||||
// 8 线程各 1000 次增量:最终值恰为 1000(无丢写 = 无竞争)。
|
||||
// 注意传 `&*set`(Arc 负载)而非 `&set`(Arc 结构体)。
|
||||
let raw: *const PropertySet = &*set;
|
||||
unsafe {
|
||||
assert_eq!((s.multi_thread)(counter_worker, 8, raw as *mut c_void), OK);
|
||||
}
|
||||
for i in 0..8 {
|
||||
let name = Box::leak(format!("ctr{i}").into_boxed_str());
|
||||
let mut v = 0;
|
||||
// 注意 `&*set`(Arc 负载)而非 `&set`(Arc 结构体)。
|
||||
unsafe {
|
||||
assert_eq!((ps.get_int)(&*set as *const _ as *mut c_void, name.as_ptr() as *const c_char, 0, &mut v), OK);
|
||||
}
|
||||
assert_eq!(v, 1000, "线程 {i} 的计数应无丢失");
|
||||
}
|
||||
|
||||
// 0 线程:no-op。
|
||||
unsafe {
|
||||
assert_eq!((s.multi_thread)(counter_worker, 0, std::ptr::null_mut()), OK);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user