From fe8fbff8a917dc5e9da377937904acc9205d0c28 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Tue, 22 Sep 2026 20:54:04 +0800 Subject: [PATCH] test(oak-timeline, oak-plugin): undo commands and plugin host Ripple/pointer/split/general undo commands with their edge cases, and plugin host/instance/suite coverage including the built-in test plugin bundle. --- crates/oak-plugin/src/clip.rs | 393 ++++ crates/oak-plugin/src/host.rs | 764 ++++++++ crates/oak-plugin/src/instance.rs | 977 +++++++++- crates/oak-plugin/src/node_factory.rs | 1113 ++++++++++- crates/oak-plugin/src/render_driver.rs | 809 +++++++- crates/oak-plugin/src/suites/gl_render.rs | 673 +++++++ crates/oak-plugin/src/suites/property.rs | 788 +++++++- .../oak-timeline/tests/undocommands_test.rs | 1627 +++++++++++++++++ 8 files changed, 7099 insertions(+), 45 deletions(-) create mode 100644 crates/oak-timeline/tests/undocommands_test.rs diff --git a/crates/oak-plugin/src/clip.rs b/crates/oak-plugin/src/clip.rs index 9ca94eec5..56965b039 100644 --- a/crates/oak-plugin/src/clip.rs +++ b/crates/oak-plugin/src/clip.rs @@ -439,4 +439,397 @@ mod tests { assert!(f16_to_f32(0xFC00).is_infinite() && f16_to_f32(0xFC00) < 0.0); assert!(f16_to_f32(0x7E00).is_nan()); } + + // ---- fetch_image / store_output_image / 协商矩阵 ---------------------- + + fn cs(s: &str) -> std::ffi::CString { + std::ffi::CString::new(s).unwrap() + } + + fn test_clip(name: &str) -> ClipInstance { + ClipInstance::from_descriptor(&crate::descriptor::ClipDescriptor::new(name)) + } + + fn video_params(w: i32, h: i32, format: i32) -> crate::render::VideoParams { + crate::render::VideoParams { + width: w, + height: h, + format, + ..Default::default() + } + } + + fn frame_with( + w: i32, + h: i32, + format: oak_core::PixelFormat, + bytes: &[u8], + ) -> crate::render::Frame { + let mut frame = crate::render::Frame::new(); + frame.set_video_params(video_params(w, h, format as i32)); + assert!(frame.allocate(), "测试帧应可分配"); + assert_eq!(frame.data.len(), bytes.len(), "测试字节长度"); + frame.data.copy_from_slice(bytes); + frame + } + + fn f32_frame(w: i32, h: i32, fill: f32) -> crate::render::Frame { + let mut frame = crate::render::Frame::new(); + frame.set_video_params(video_params(w, h, crate::render::PIXEL_FORMAT_F32)); + assert!(frame.allocate()); + for c in frame.data.as_chunks_mut::<4>().0 { + c.copy_from_slice(&fill.to_le_bytes()); + } + frame + } + + fn f32_image(w: i32, h: i32, fill: f32) -> crate::image::Image { + let mut img = crate::image::Image::allocate( + crate::image::BitDepth::Float, + crate::image::Components::Rgba, + OfxRectD { + x1: 0.0, + y1: 0.0, + x2: w as f64, + y2: h as f64, + }, + ); + for c in img.pixels_mut().as_chunks_mut::<4>().0 { + c.copy_from_slice(&fill.to_le_bytes()); + } + img + } + + fn connect(clip: &ClipInstance, frame: crate::render::Frame) { + clip.set_input_texture(Some(crate::render::Texture::wrap_frame(frame)), 0.0); + } + + fn samples(image: &crate::image::Image) -> Vec { + image + .pixels() + .as_chunks::<4>() + .0 + .iter() + .map(|c| f32::from_le_bytes(*c)) + .collect() + } + + /// components_from_props:三种 OFX 分量字符串命中;未知字符串与 + /// 非字符串值返回 None。 + #[test] + fn components_from_props_matrix() { + use crate::property::Value; + for (s, want) in [ + ("OfxImageComponentRGBA", Some(crate::image::Components::Rgba)), + ("OfxImageComponentRGB", Some(crate::image::Components::Rgb)), + ("OfxImageComponentAlpha", Some(crate::image::Components::Alpha)), + ("OfxImageComponentBogus", None), + ] { + let set = PropertySet::new(); + set.set_one( + crate::image::K_IMAGE_EFFECT_PROP_COMPONENTS, + Value::String(cs(s)), + ); + assert_eq!(components_from_props(&set), want, "{s}"); + } + let set = PropertySet::new(); + set.set_one(crate::image::K_IMAGE_EFFECT_PROP_COMPONENTS, Value::Int(1)); + assert_eq!(components_from_props(&set), None); + } + + /// set_video_params:全部位深号与分量数分支写回 clip props + /// (0/2/3 官方映射 + 兜底 Float;1/3 分量 + 兜底 RGBA)。 + #[test] + fn set_video_params_depth_and_component_matrix() { + use crate::property::Value; + for (fmt, want) in [ + (0, "OfxBitDepthByte"), + (2, "OfxBitDepthShort"), + (3, "OfxBitDepthHalf"), + (4, "OfxBitDepthFloat"), + (99, "OfxBitDepthFloat"), + ] { + let c = test_clip("Source"); + c.set_video_params(fmt, 4); + let got = c + .props + .get(crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH, 0); + assert!( + matches!(got, Some(Value::String(s)) if s.to_string_lossy() == want), + "format {fmt}" + ); + } + for (ch, want) in [ + (1, "OfxImageComponentAlpha"), + (3, "OfxImageComponentRGB"), + (4, "OfxImageComponentRGBA"), + (0, "OfxImageComponentRGBA"), + ] { + let c = test_clip("Source"); + c.set_video_params(4, ch); + let got = c + .props + .get(crate::image::K_IMAGE_EFFECT_PROP_COMPONENTS, 0); + assert!( + matches!(got, Some(Value::String(s)) if s.to_string_lossy() == want), + "channels {ch}" + ); + } + } + + /// fetch_image:子区域请求、无输入、dummy 占位都按契约失败。 + #[test] + fn fetch_image_rejects_region_and_unusable_input() { + let c = test_clip("Source"); + assert!(matches!( + c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, Some(OfxRectD::default())), + Err(crate::error::Error::Failed(_)) + )); + assert!(matches!( + c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None), + Err(crate::error::Error::NotFound) + )); + c.set_input_texture(Some(crate::render::Texture::dummy()), 0.0); + assert!(matches!( + c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None), + Err(crate::error::Error::NotFound) + )); + } + + /// fetch_image:U8/U16/F16/F32 四路输入转换与 NaN/Inf 清洗。 + #[test] + fn fetch_image_converts_all_input_depths() { + // U8:归一化到 [0,1]。 + let c = test_clip("Source"); + connect(&c, frame_with(1, 1, oak_core::PixelFormat::U8, &[0, 255, 128, 64])); + let got = samples(&c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None).unwrap()); + assert_eq!(got[0], 0.0); + assert_eq!(got[1], 1.0); + assert!((got[2] - 128.0 / 255.0).abs() < 1e-7); + assert!((got[3] - 64.0 / 255.0).abs() < 1e-7); + + // U16。 + let c = test_clip("Source"); + let mut bytes = Vec::new(); + for v in [0u16, 65535, 32768, 1] { + bytes.extend_from_slice(&v.to_le_bytes()); + } + connect(&c, frame_with(1, 1, oak_core::PixelFormat::U16, &bytes)); + let got = samples(&c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None).unwrap()); + assert_eq!(got[0], 0.0); + assert_eq!(got[1], 1.0); + assert!((got[2] - 32768.0 / 65535.0).abs() < 1e-7); + assert!((got[3] - 1.0 / 65535.0).abs() < 1e-7); + + // F16:NaN/Inf 清洗为 0,普通值与 -0 保留。 + let c = test_clip("Source"); + let mut bytes = Vec::new(); + for v in [0x3C00u16, 0x7E00, 0x7C00, 0x8000] { + bytes.extend_from_slice(&v.to_le_bytes()); + } + connect(&c, frame_with(1, 1, oak_core::PixelFormat::F16, &bytes)); + let got = samples(&c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None).unwrap()); + assert_eq!(got[0], 1.0); + assert_eq!(got[1], 0.0, "F16 NaN 应清洗为 0"); + assert_eq!(got[2], 0.0, "F16 Inf 应清洗为 0"); + assert_eq!(got[3].to_bits(), 0x8000_0000, "-0 应保留"); + + // F32:NaN/Inf 清洗为 0。 + let c = test_clip("Source"); + let mut bytes = Vec::new(); + for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 1.5] { + bytes.extend_from_slice(&v.to_le_bytes()); + } + connect(&c, frame_with(1, 1, oak_core::PixelFormat::F32, &bytes)); + let got = samples(&c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None).unwrap()); + assert_eq!(got[0], 0.0); + assert_eq!(got[1], 0.0); + assert_eq!(got[2], 0.0); + assert_eq!(got[3], 1.5); + } + + /// fetch_image:不支持的输入格式(U10)显式失败。 + #[test] + fn fetch_image_rejects_unsupported_format() { + let c = test_clip("Source"); + // U10 = 4 字节/通道打包格式;管线仅支持 U8/U16/F16/F32。 + connect(&c, frame_with(1, 1, oak_core::PixelFormat::U10, &[0u8; 16])); + let res = c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None); + assert!(matches!(res, Err(crate::error::Error::Failed(_)))); + } + + /// fetch_image:协商位深从 clip props 读——Byte 降位深;未知串与 + /// 非字符串值回退 F32。 + #[test] + fn fetch_image_negotiated_depth() { + use crate::property::Value; + + let c = test_clip("Source"); + connect(&c, f32_frame(1, 1, 0.5)); + c.props.set_one( + crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH, + Value::String(cs("OfxBitDepthByte")), + ); + let img = c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None).unwrap(); + assert_eq!(img.depth(), crate::image::BitDepth::Byte); + + let c = test_clip("Source"); + connect(&c, f32_frame(1, 1, 0.5)); + c.props.set_one( + crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH, + Value::String(cs("OfxBitDepthBogus")), + ); + let img = c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None).unwrap(); + assert_eq!(img.depth(), crate::image::BitDepth::Float); + + let c = test_clip("Source"); + connect(&c, f32_frame(1, 1, 0.5)); + c.props + .set_one(crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH, Value::Int(4)); + let img = c.fetch_image(0.0, RenderScale { x: 1.0, y: 1.0 }, None).unwrap(); + assert_eq!(img.depth(), crate::image::BitDepth::Float); + } + + /// store_output_image:无输出/dummy/非 F32/尺寸不符的错误分支与 + /// CPU 整帧拷贝。 + #[test] + fn store_output_image_cpu_paths() { + let image = f32_image(2, 2, 0.25); + + // 未挂输出纹理 → NotFound。 + let c = test_clip("Output"); + assert!(matches!( + c.store_output_image(&image), + Err(crate::error::Error::NotFound) + )); + + // dummy 输出纹理 → NotFound。 + c.set_output_texture(Some(crate::render::Texture::dummy()), 0.0); + assert!(matches!( + c.store_output_image(&image), + Err(crate::error::Error::NotFound) + )); + + // 非 F32 输出帧 → Failed。 + let c = test_clip("Output"); + c.set_output_texture( + Some(crate::render::Texture::wrap_frame(frame_with( + 2, + 2, + oak_core::PixelFormat::U8, + &[0u8; 16], + ))), + 0.0, + ); + assert!(matches!( + c.store_output_image(&image), + Err(crate::error::Error::Failed(_)) + )); + + // 尺寸不符 → Failed。 + let c = test_clip("Output"); + c.set_output_texture( + Some(crate::render::Texture::wrap_frame(f32_frame(3, 1, 0.0))), + 0.0, + ); + assert!(matches!( + c.store_output_image(&image), + Err(crate::error::Error::Failed(_)) + )); + + // 成功:整帧拷贝回输出纹理。 + let c = test_clip("Output"); + c.set_output_texture( + Some(crate::render::Texture::wrap_frame(f32_frame(2, 2, 0.0))), + 0.0, + ); + let stored = c.store_output_image(&image).unwrap(); + let frame = crate::render::texture_get_frame(&stored).unwrap(); + assert_eq!(frame.data.as_slice(), image.pixels()); + } + + struct FakeGpu { + uploads: std::sync::Mutex>, + fail_upload: bool, + } + + impl oak_core::backend::GpuContextLike for FakeGpu { + fn kind(&self) -> oak_core::backend::BackendKind { + oak_core::backend::BackendKind::Cpu + } + fn destroy_texture(&self, _token: u64) {} + fn upload(&self, token: u64, frame: &crate::render::Frame) -> oak_render::error::Result<()> { + if self.fail_upload { + return Err(oak_render::error::Error::State); + } + self.uploads + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push((token, frame.clone())); + Ok(()) + } + fn download(&self, _token: u64) -> oak_render::error::Result { + Ok(f32_frame(2, 2, 0.0)) + } + fn blit( + &self, + _src: u64, + _dst: u64, + _processor: Option<&oak_core::color::ColorProcessor>, + ) -> oak_render::error::Result<()> { + Ok(()) + } + } + + /// store_output_image:GPU 输出纹理经后端 upload 回写;上传失败上抛。 + #[test] + fn store_output_image_gpu_upload() { + let image = f32_image(2, 2, 0.5); + + let ctx = std::sync::Arc::new(FakeGpu { + uploads: std::sync::Mutex::new(Vec::new()), + fail_upload: false, + }); + let c = test_clip("Output"); + let tex = crate::render::Texture::gpu(ctx.clone(), 7, 2, 2, oak_core::PixelFormat::F32); + c.set_output_texture(Some(tex), 0.0); + c.store_output_image(&image).unwrap(); + { + let uploads = ctx.uploads.lock().unwrap(); + assert_eq!(uploads.len(), 1); + assert_eq!(uploads[0].0, 7); + assert_eq!(uploads[0].1.data.as_slice(), image.pixels()); + } + + // 上传失败 → Failed。 + let ctx = std::sync::Arc::new(FakeGpu { + uploads: std::sync::Mutex::new(Vec::new()), + fail_upload: true, + }); + let c = test_clip("Output"); + c.set_output_texture( + Some(crate::render::Texture::gpu( + ctx, + 8, + 2, + 2, + oak_core::PixelFormat::F32, + )), + 0.0, + ); + assert!(matches!( + c.store_output_image(&image), + Err(crate::error::Error::Failed(_)) + )); + } + + /// frame_range:第 1 期明确不支持(renderer 桥待落地)。 + #[test] + fn frame_range_is_unsupported() { + let c = test_clip("Source"); + assert!(matches!( + c.frame_range(), + Err(crate::error::Error::Failed(_)) + )); + } } diff --git a/crates/oak-plugin/src/host.rs b/crates/oak-plugin/src/host.rs index f1a3a2ee4..2ce110205 100644 --- a/crates/oak-plugin/src/host.rs +++ b/crates/oak-plugin/src/host.rs @@ -1655,4 +1655,768 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + + // ---- branch coverage batch: loader/cache/host ------------------------ + + fn temp_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "oak-plugin-host-{}-{tag}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn test_cache() -> PluginCache { + PluginCache { + plugins: Mutex::new(Vec::new()), + scanned_paths: Mutex::new(Vec::new()), + binaries: Mutex::new(Vec::new()), + } + } + + /// build.rs 编出的最小测试插件共享库。 + fn fixture_lib() -> Option { + let out = PathBuf::from(env!("OUT_DIR")); + let lib = out.join(if cfg!(target_os = "macos") { + "oak_test_plugin.dylib" + } else { + "oak_test_plugin.so" + }); + lib.is_file().then_some(lib) + } + + /// 现场装配一个含测试插件二进制的 `.ofx.bundle`(平台目录探测)。 + fn make_fixture_bundle(tag: &str) -> Option<(PathBuf, PathBuf)> { + let lib = fixture_lib()?; + let platform = if cfg!(target_os = "macos") { + "MacOS" + } else if cfg!(target_os = "windows") { + "Win64" + } else { + "Linux-x86-64" + }; + let root = temp_dir(tag); + let bundle = root.join("oak-test-plugin.ofx.bundle"); + let bin_dir = bundle.join("Contents").join(platform); + std::fs::create_dir_all(&bin_dir).unwrap(); + let target = bin_dir.join(if cfg!(target_os = "windows") { + "plugin.dll" + } else { + "plugin" + }); + std::fs::copy(&lib, &target).unwrap(); + Some((root, bundle)) + } + + fn action_is(action: *const c_char, name: &str) -> bool { + !action.is_null() + && unsafe { CStr::from_ptr(action) }.to_bytes() == name.as_bytes() + } + + unsafe extern "C" fn entry_ok( + _: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + status::OK + } + + unsafe extern "C" fn entry_reply_default( + _: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + status::REPLY_DEFAULT + } + + unsafe extern "C" fn entry_describe_fails( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + if action_is(action, ACTION_DESCRIBE_IN_CONTEXT) { + status::ERR_FATAL + } else { + status::OK + } + } + + unsafe extern "C" fn entry_create_fails( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + if action_is(action, ACTION_CREATE_INSTANCE) { + status::ERR_FATAL + } else { + status::OK + } + } + + fn fake_plugin(identifier: &str, contexts: Vec, entry: EntryPoint) -> Arc { + Arc::new(Plugin { + identifier: identifier.into(), + version: (1, 0), + bundle_path: PathBuf::new(), + contexts, + descriptor: EffectDescriptor::new(), + lib: std::ptr::null_mut(), + entry, + ofx_plugin: std::ptr::null_mut(), + unloaded: std::sync::atomic::AtomicBool::new(false), + }) + } + + /// 隔离的 Host(本地 cache/instances,避免触碰进程单例)。 + fn local_host(plugins: Vec>) -> Host { + Host { + props: PropertySet::new(), + cache: PluginCache { + plugins: Mutex::new(plugins), + scanned_paths: Mutex::new(Vec::new()), + binaries: Mutex::new(Vec::new()), + }, + instances: Mutex::new(Vec::new()), + } + } + + // ---- dlopen/dlsym error paths ---------------------------------------- + + #[cfg(not(target_os = "windows"))] + #[test] + fn dl_open_and_dl_sym_error_paths_return_none() { + // 不存在的库 → None。 + assert!(dl_open(Path::new("/definitely/not/a/library-oak.so")).is_none()); + // 可加载的库 + 不存在的符号 → None。 + let lib = fixture_lib().expect("build.rs 产出测试插件库"); + let handle = dl_open(&lib).expect("测试插件库可加载"); + assert!(dl_sym(handle, "oak_definitely_missing_symbol").is_none()); + unsafe { dlclose(handle) }; + } + + // ---- fetchSuite 入口 ------------------------------------------------- + + #[test] + fn host_fetch_suite_null_args_and_trace_paths() { + use std::ffi::c_void as cv; + static TRACE_LOCK: Mutex<()> = Mutex::new(()); + let _g = TRACE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + let host_set = PropertySet::new(); + let host = &host_set as *const PropertySet as *mut cv; + let name = CString::new("OfxPropertySuite").unwrap(); + let bogus = CString::new("OfxBogusSuite").unwrap(); + + // NULL host / NULL name → None(不触达 suite 分发表)。 + assert!(unsafe { host_fetch_suite(std::ptr::null_mut(), name.as_ptr(), 1) }.is_null()); + assert!(unsafe { host_fetch_suite(host, std::ptr::null(), 1) }.is_null()); + + // OAK_OFX_TRACE 打开:hit 与 miss 诊断分支。 + let prev = std::env::var_os("OAK_OFX_TRACE"); + std::env::set_var("OAK_OFX_TRACE", "1"); + assert!(!unsafe { host_fetch_suite(host, name.as_ptr(), 1) }.is_null()); + assert!(unsafe { host_fetch_suite(host, bogus.as_ptr(), 1) }.is_null()); + // 版本不符也走 miss。 + assert!(unsafe { host_fetch_suite(host, name.as_ptr(), 2) }.is_null()); + match prev { + Some(v) => std::env::set_var("OAK_OFX_TRACE", v), + None => std::env::remove_var("OAK_OFX_TRACE"), + } + } + + // ---- overlay interact 入口声明 --------------------------------------- + + #[test] + fn overlay_interact_entry_null_pointer_and_v1_fallback() { + unsafe extern "C" fn overlay( + _: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + status::OK + } + let props = PropertySet::new(); + // 属性缺失 → None。 + assert!(overlay_interact_entry(&props).is_none()); + // 空指针 → 跳过(V2 空、V1 也缺)。 + props.set_one(PROP_OVERLAY_INTERACT_V2, Value::Pointer(std::ptr::null_mut())); + assert!(overlay_interact_entry(&props).is_none()); + // 类型不是 Pointer → 跳过。 + props.set_one(PROP_OVERLAY_INTERACT_V2, Value::Int(1)); + assert!(overlay_interact_entry(&props).is_none()); + // V1 回退命中。 + props.set_one( + PROP_OVERLAY_INTERACT_V1, + Value::Pointer(overlay as *const () as *mut std::ffi::c_void), + ); + assert!(overlay_interact_entry(&props).is_some()); + // V2 优先。 + props.set_one( + PROP_OVERLAY_INTERACT_V2, + Value::Pointer(overlay as *const () as *mut std::ffi::c_void), + ); + assert!(overlay_interact_entry(&props).is_some()); + } + + // ---- 卸载后的入口保护 ------------------------------------------------- + + #[test] + fn unloaded_plugin_actions_fail_without_touching_entry() { + let plugin = fake_plugin("test.unloaded", vec![], entry_ok); + plugin + .unloaded + .store(true, std::sync::atomic::Ordering::Release); + let empty = PropertySet::new(); + let st = unsafe { + plugin.call_action(ACTION_LOAD, std::ptr::null_mut(), &empty, &empty) + }; + assert_eq!(st, status::ERR_FATAL); + let st = unsafe { + plugin.call_entry(entry_ok, ACTION_LOAD, std::ptr::null_mut(), &empty, &empty) + }; + assert_eq!(st, status::ERR_FATAL); + } + + // ---- bundle/二进制探测 ----------------------------------------------- + + #[test] + fn is_bundle_dir_matches_suffixes_only() { + assert!(is_bundle_dir(Path::new("/tmp/Foo.bundle"))); + assert!(is_bundle_dir(Path::new("Bar.plugin"))); + assert!(!is_bundle_dir(Path::new("/tmp/NotIt"))); + // 无 file_name(根路径)→ false。 + assert!(!is_bundle_dir(Path::new("/"))); + } + + #[test] + fn find_binary_in_bundle_prefers_platform_dir_and_falls_back_to_root() { + let dir = temp_dir("find-bin"); + + // 空 bundle → None。 + assert!(find_binary_in_bundle(&dir).is_none()); + + // 根目录 .so 回退。 + std::fs::write(dir.join("liboak.so"), b"not a real lib").unwrap(); + assert_eq!(find_binary_in_bundle(&dir).unwrap(), dir.join("liboak.so")); + + // 平台目录优先,且跳过 .plist。 + let platform = dir.join("Contents").join("Linux-x86-64"); + std::fs::create_dir_all(&platform).unwrap(); + std::fs::write(platform.join("Info.plist"), b"meta").unwrap(); + std::fs::write(platform.join("plugin"), b"bin").unwrap(); + assert_eq!(find_binary_in_bundle(&dir).unwrap(), platform.join("plugin")); + + // 平台目录里只有 .plist → 回退根目录。 + std::fs::remove_file(platform.join("plugin")).unwrap(); + assert_eq!(find_binary_in_bundle(&dir).unwrap(), dir.join("liboak.so")); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn walk_dirs_respects_depth_and_read_errors() { + let root = temp_dir("walk"); + std::fs::write(root.join("file.txt"), b"x").unwrap(); + let a = root.join("a"); + let bundle = a.join("One.bundle"); + let b = a.join("b"); + let c = b.join("c"); + std::fs::create_dir_all(&bundle).unwrap(); + std::fs::create_dir_all(&c).unwrap(); + std::fs::create_dir_all(b.join("Two.bundle")).unwrap(); + + let mut out = Vec::new(); + walk_dirs(&root, 0, 1, &mut out); + assert_eq!(out, vec![bundle.clone()], "深度 1 内只发现 One.bundle(文件与更深目录跳过)"); + + // 深度上限内全收。 + let mut deep = Vec::new(); + walk_dirs(&root, 0, 4, &mut deep); + assert_eq!(deep.len(), 2); + + // read_dir 失败(路径是文件)→ 空返回。 + let mut errs = Vec::new(); + walk_dirs(&root.join("file.txt"), 0, 4, &mut errs); + assert!(errs.is_empty()); + + let _ = std::fs::remove_dir_all(&root); + } + + // ---- load_bundle ------------------------------------------------------ + + #[test] + fn load_bundle_reports_missing_and_invalid_binaries() { + let cache = test_cache(); + + // 空目录(无二进制)→ 记录并返回。 + let empty = temp_dir("load-empty"); + cache.load_bundle(&empty); + assert_eq!(cache.count(), 0); + + // 根目录垃圾 .so → dlopen 失败,记录并返回。 + let bad = temp_dir("load-bad"); + std::fs::write(bad.join("garbage.so"), b"definitely not an ELF").unwrap(); + cache.load_bundle(&bad); + assert_eq!(cache.count(), 0); + assert!(cache.binaries.lock().unwrap_or_else(|e| e.into_inner()).is_empty()); + + let _ = std::fs::remove_dir_all(&empty); + let _ = std::fs::remove_dir_all(&bad); + } + + #[test] + fn load_bundle_loads_fixture_plugins_and_dedupes() { + let Some((root, bundle)) = make_fixture_bundle("load-ok") else { + eprintln!("SKIP: 测试插件未构建"); + return; + }; + let cache = test_cache(); + cache.load_bundle(&bundle); + assert_eq!(cache.count(), 5, "测试插件导出 5 个效果"); + assert!(cache.find("org.oak.test-plugin").is_some()); + assert_eq!( + cache.binaries.lock().unwrap_or_else(|e| e.into_inner()).len(), + 1 + ); + + // 同一 bundle 二次加载 → 路径去重(直接 dlclose 返回)。 + cache.load_bundle(&bundle); + assert_eq!(cache.count(), 5); + assert_eq!( + cache.binaries.lock().unwrap_or_else(|e| e.into_inner()).len(), + 1 + ); + + // at()/find() 边界。 + assert!(cache.at(0).is_some()); + assert!(cache.at(usize::MAX).is_none()); + assert!(cache.find("org.oak.test-plugin.nope").is_none()); + + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn load_bundle_ignores_duplicate_plugin_identifiers() { + let Some((root1, bundle1)) = make_fixture_bundle("dup-a") else { + eprintln!("SKIP: 测试插件未构建"); + return; + }; + let Some((root2, bundle2)) = make_fixture_bundle("dup-b") else { + eprintln!("SKIP: 测试插件未构建"); + return; + }; + let cache = test_cache(); + cache.load_bundle(&bundle1); + assert_eq!(cache.count(), 5); + // 第二个 bundle 的 identifier 全部重复 → 忽略,但二进制登记两份。 + cache.load_bundle(&bundle2); + assert_eq!(cache.count(), 5); + assert_eq!( + cache.binaries.lock().unwrap_or_else(|e| e.into_inner()).len(), + 2 + ); + let _ = std::fs::remove_dir_all(&root1); + let _ = std::fs::remove_dir_all(&root2); + } + + /// 可加载但无 OFX 导出符号的库 → collect_plugins 空(Linux libc)。 + #[cfg(target_os = "linux")] + #[test] + fn collect_plugins_without_ofx_symbols_is_empty() { + let Some(libc) = [ + "/lib/x86_64-linux-gnu/libc.so.6", + "/usr/lib/x86_64-linux-gnu/libc.so.6", + "/lib64/libc.so.6", + "/usr/lib64/libc.so.6", + "/lib/libc.so.6", + "/usr/lib/libc.so.6", + ] + .iter() + .map(PathBuf::from) + .find(|p| p.is_file()) else { + eprintln!("SKIP: 未找到 libc"); + return; + }; + let Some(handle) = dl_open(&libc) else { + eprintln!("SKIP: libc 不可 dlopen"); + return; + }; + let cache = test_cache(); + let plugins = unsafe { cache.collect_plugins(handle, Path::new("no-ofx")) }; + unsafe { dlclose(handle) }; + assert!(plugins.is_empty()); + } + + /// 可加载但无插件的 bundle → 记录 no usable plugins 并 dlclose。 + #[cfg(target_os = "linux")] + #[test] + fn load_bundle_without_ofx_plugins_reports_and_unloads() { + let Some(libc) = [ + "/lib/x86_64-linux-gnu/libc.so.6", + "/usr/lib/x86_64-linux-gnu/libc.so.6", + "/lib64/libc.so.6", + "/usr/lib64/libc.so.6", + "/lib/libc.so.6", + "/usr/lib/libc.so.6", + ] + .iter() + .map(PathBuf::from) + .find(|p| p.is_file()) else { + eprintln!("SKIP: 未找到 libc"); + return; + }; + let dir = temp_dir("load-no-plugin"); + let platform = dir.join("Contents").join("Linux-x86-64"); + std::fs::create_dir_all(&platform).unwrap(); + std::os::unix::fs::symlink(&libc, platform.join("plugin")).unwrap(); + + let cache = test_cache(); + cache.load_bundle(&dir); + assert_eq!(cache.count(), 0); + assert!(cache.binaries.lock().unwrap_or_else(|e| e.into_inner()).is_empty()); + + let _ = std::fs::remove_dir_all(&dir); + } + + // ---- build_plugin 拒绝与上下文过滤 ----------------------------------- + + unsafe extern "C" fn noop_set_host(_: *mut OfxHost) {} + + fn raw_ofx_plugin( + api: Option<&CString>, + api_version: c_int, + identifier: Option<&CString>, + set_host: Option, + main_entry: Option, + ) -> OfxPlugin { + OfxPlugin { + plugin_api: api.map_or(std::ptr::null(), |c| c.as_ptr()), + api_version, + plugin_identifier: identifier.map_or(std::ptr::null(), |c| c.as_ptr()), + plugin_version_major: 1, + plugin_version_minor: 0, + set_host, + main_entry, + } + } + + unsafe extern "C" fn entry_multi_contexts( + _: *const c_char, + handle: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + if !handle.is_null() { + let props = unsafe { + &*crate::suites::tag::strip(handle as *mut c_void) + }; + props.define( + PROP_SUPPORTED_CONTEXTS, + vec![ + Value::String(CString::new("OfxImageEffectContextGeneral").unwrap()), + Value::String(CString::new("OfxImageEffectContextTransition").unwrap()), + Value::String(CString::new("OfxImageEffectContextGenerator").unwrap()), + Value::String(CString::new("OfxImageEffectContextFilter").unwrap()), + Value::String(CString::new("OfxBogusContext").unwrap()), + ], + ); + } + status::OK + } + + unsafe extern "C" fn entry_bogus_contexts( + _: *const c_char, + handle: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + if !handle.is_null() { + let props = unsafe { + &*crate::suites::tag::strip(handle as *mut c_void) + }; + props.define( + PROP_SUPPORTED_CONTEXTS, + vec![Value::String(CString::new("OfxImageEffectContextRetimer").unwrap())], + ); + } + status::OK + } + + unsafe extern "C" fn entry_load_fails( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + if action_is(action, ACTION_LOAD) { + status::ERR_FATAL + } else { + status::OK + } + } + + unsafe extern "C" fn entry_build_describe_fails( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + if action_is(action, ACTION_DESCRIBE) { + status::ERR_FATAL + } else { + status::OK + } + } + + #[test] + fn build_plugin_rejects_invalid_headers_and_filters_contexts() { + let cache = test_cache(); + let bundle = Path::new("test-build.ofx.bundle"); + let api = CString::new("OfxImageEffectPluginAPI").unwrap(); + let wrong_api = CString::new("OfxSomethingElsePluginAPI").unwrap(); + let identifier = CString::new("test.build").unwrap(); + + // NULL plugin_api → None。 + let mut ofx = raw_ofx_plugin(None, 1, Some(&identifier), None, Some(entry_ok)); + assert!(unsafe { cache.build_plugin(&mut ofx, std::ptr::null_mut(), bundle) }.is_none()); + + // API 名不符 → None。 + let mut ofx = raw_ofx_plugin(Some(&wrong_api), 1, Some(&identifier), None, Some(entry_ok)); + assert!(unsafe { cache.build_plugin(&mut ofx, std::ptr::null_mut(), bundle) }.is_none()); + + // API 版本不符 → None。 + let mut ofx = raw_ofx_plugin(Some(&api), 2, Some(&identifier), None, Some(entry_ok)); + assert!(unsafe { cache.build_plugin(&mut ofx, std::ptr::null_mut(), bundle) }.is_none()); + + // main_entry 缺 → None。 + let mut ofx = raw_ofx_plugin(Some(&api), 1, Some(&identifier), None, None); + assert!(unsafe { cache.build_plugin(&mut ofx, std::ptr::null_mut(), bundle) }.is_none()); + + // load action 失败 → None(setHost 存在分支也被走到)。 + let mut ofx = raw_ofx_plugin( + Some(&api), + 1, + Some(&identifier), + Some(noop_set_host), + Some(entry_load_fails), + ); + assert!(unsafe { cache.build_plugin(&mut ofx, std::ptr::null_mut(), bundle) }.is_none()); + + // describe action 失败 → None。 + let mut ofx = raw_ofx_plugin( + Some(&api), + 1, + Some(&identifier), + None, + Some(entry_build_describe_fails), + ); + assert!(unsafe { cache.build_plugin(&mut ofx, std::ptr::null_mut(), bundle) }.is_none()); + + // describe 后无非标准上下文 → None。 + let mut ofx = raw_ofx_plugin( + Some(&api), + 1, + Some(&identifier), + None, + Some(entry_bogus_contexts), + ); + assert!(unsafe { cache.build_plugin(&mut ofx, std::ptr::null_mut(), bundle) }.is_none()); + + // 四个标准上下文全收;非标准值被过滤。 + let mut ofx = raw_ofx_plugin( + Some(&api), + 1, + Some(&identifier), + Some(noop_set_host), + Some(entry_multi_contexts), + ); + let plugin = unsafe { cache.build_plugin(&mut ofx, std::ptr::null_mut(), bundle) } + .expect("标准上下文应接受"); + assert_eq!(plugin.identifier, "test.build"); + assert_eq!(plugin.contexts.len(), 4); + assert!(!plugin.contexts.iter().any(|c| c == "OfxBogusContext")); + assert_eq!(plugin.version, (1, 0)); + } + + // ---- scan 环境变量 ---------------------------------------------------- + + #[test] + fn scan_reads_plugin_path_env_vars() { + static ENV_LOCK: Mutex<()> = Mutex::new(()); + let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let Some((root, _bundle)) = make_fixture_bundle("scan-env") else { + eprintln!("SKIP: 测试插件未构建"); + return; + }; + + let prev = std::env::var_os("OLIVE_OFX_PLUGIN_PATH"); + std::env::set_var("OLIVE_OFX_PLUGIN_PATH", &root); + let cache = test_cache(); + let result = cache.scan(); + match prev { + Some(v) => std::env::set_var("OLIVE_OFX_PLUGIN_PATH", v), + None => std::env::remove_var("OLIVE_OFX_PLUGIN_PATH"), + } + result.expect("scan"); + assert!( + cache.find("org.oak.test-plugin").is_some(), + "环境变量目录里的 bundle 应被发现" + ); + let _ = std::fs::remove_dir_all(&root); + } + + // ---- create_instance / create_instance_preferred --------------------- + + #[test] + fn create_instance_preferred_uses_custom_context_fallback() { + let host = local_host(vec![fake_plugin( + "test.pref.custom", + vec!["OfxImageEffectContextCustom".into()], + entry_ok, + )]); + let (context, inst) = host + .create_instance_preferred("test.pref.custom") + .expect("自定义上下文可实例化"); + assert_eq!(context, "OfxImageEffectContextCustom"); + assert_eq!(inst.value.context, "OfxImageEffectContextCustom"); + // 实例属性表(init_instance_props)已建好。 + assert!(inst.value.props.get("OfxPropType", 0).is_some()); + // 未登记标识 → NotFound。 + assert!(host.create_instance_preferred("nope").is_err()); + } + + #[test] + fn create_instance_preferred_returns_last_error() { + let host = local_host(vec![fake_plugin( + "test.pref.fail", + vec![ + "OfxImageEffectContextFilter".into(), + "OfxImageEffectContextGeneral".into(), + ], + entry_describe_fails, + )]); + let err = host + .create_instance_preferred("test.pref.fail") + .err() + .expect("全部上下文失败应返回错误"); + assert!(matches!( + err, + crate::error::Error::Failed(ref m) if m.contains("describeInContext") + )); + } + + #[test] + fn create_instance_rejects_unsupported_context_and_missing_contexts() { + let host = local_host(vec![fake_plugin( + "test.ctx.one", + vec!["OfxImageEffectContextFilter".into()], + entry_ok, + )]); + // 插件不支持请求的上下文。 + assert!(host + .create_instance("test.ctx.one", Some("OfxImageEffectContextGenerator")) + .is_err()); + // None → 取首个支持上下文并成功。 + let inst = host.create_instance("test.ctx.one", None).expect("首个上下文"); + assert_eq!(inst.value.context, "OfxImageEffectContextFilter"); + + // 无支持上下文 → 失败。 + let none = local_host(vec![fake_plugin("test.ctx.none", vec![], entry_ok)]); + assert!(none.create_instance("test.ctx.none", None).is_err()); + // 未登记标识 → 失败。 + assert!(none.create_instance("missing", None).is_err()); + + // ReplyDefault 的 describeInContext/createInstance 也被接受。 + let default = local_host(vec![fake_plugin( + "test.ctx.default", + vec!["OfxImageEffectContextFilter".into()], + entry_reply_default, + )]); + assert!(default.create_instance("test.ctx.default", None).is_ok()); + } + + #[test] + fn create_instance_action_failure_is_not_registered_alive() { + let host = local_host(vec![fake_plugin( + "test.create.fail", + vec!["OfxImageEffectContextFilter".into()], + entry_create_fails, + )]); + let err = host + .create_instance("test.create.fail", None) + .err() + .expect("createInstance 失败"); + assert!(matches!( + err, + crate::error::Error::Failed(ref m) if m.contains("createInstance") + )); + assert_eq!(host.alive_count(), 0); + } + + #[test] + fn alive_count_prunes_released_instances() { + let host = local_host(vec![fake_plugin( + "test.alive", + vec!["OfxImageEffectContextFilter".into()], + entry_ok, + )]); + let a = host.create_instance("test.alive", None).expect("a"); + let b = host.create_instance("test.alive", None).expect("b"); + assert_eq!(host.alive_count(), 2); + drop(a); + assert_eq!(host.alive_count(), 1); + drop(b); + assert_eq!(host.alive_count(), 0); + } + + /// `register_plugin_nodes` 对无法实例化的缓存项记录原因并跳过, + /// 对可实例化的登记动态节点。 + #[test] + fn register_plugin_nodes_skips_uninstantiable_and_registers_ok_plugins() { + static LOCK: Mutex<()> = Mutex::new(()); + let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + let failing_id = "test.node-factory.fail"; + let ok_id = "test.node-factory.ok"; + { + let mut plugins = Host::global() + .cache + .plugins + .lock() + .unwrap_or_else(|e| e.into_inner()); + plugins.push(fake_plugin(failing_id, vec![], entry_ok)); + plugins.push(fake_plugin( + ok_id, + vec!["OfxImageEffectContextFilter".into()], + entry_ok, + )); + } + + let registered = crate::node_factory::register_plugin_nodes(); + + { + let mut plugins = Host::global() + .cache + .plugins + .lock() + .unwrap_or_else(|e| e.into_inner()); + plugins.retain(|p| p.identifier != failing_id && p.identifier != ok_id); + } + + assert!( + registered.iter().any(|id| id == ok_id), + "可实例化的插件应登记为动态节点" + ); + assert!( + !registered.iter().any(|id| id == failing_id), + "无法实例化的插件应跳过" + ); + } } diff --git a/crates/oak-plugin/src/instance.rs b/crates/oak-plugin/src/instance.rs index 6527fc898..11167000a 100644 --- a/crates/oak-plugin/src/instance.rs +++ b/crates/oak-plugin/src/instance.rs @@ -1216,4 +1216,979 @@ pub struct ClipPreferences { /// field 处理模式(kOfxImageField*)。 pub field: String, } - /// field 处理模式(kOfxImageField*)。 + +#[cfg(test)] +mod tests { + use super::*; + use crate::descriptor::{ClipDescriptor, EffectDescriptor}; + use crate::image::{BitDepth, Components, Image}; + use crate::param::{ChangeReason, ParamDef, ParamInstance}; + use crate::property::Value as PropValue; + use crate::suites::status; + use std::ffi::{c_char, c_int, c_void, CString}; + use std::sync::atomic::{AtomicBool, AtomicUsize}; + use std::sync::{Arc, Mutex}; + + fn cs(s: &str) -> CString { + CString::new(s).unwrap() + } + + fn action_is(action: *const c_char, name: &str) -> bool { + !action.is_null() + && unsafe { std::ffi::CStr::from_ptr(action) }.to_bytes() == name.as_bytes() + } + + fn prop_string(props: &PropertySet, name: &str) -> Option { + match props.get(name, 0) { + Some(PropValue::String(s)) => Some(s.to_string_lossy().into_owned()), + _ => None, + } + } + + unsafe extern "C" fn entry_ok( + _: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + status::OK + } + + unsafe extern "C" fn entry_default( + _: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + status::REPLY_DEFAULT + } + + unsafe extern "C" fn entry_err( + _: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + status::ERR_FATAL + } + + fn param_set(defs: Vec) -> ParamSetInstance { + ParamSetInstance { + params: defs + .into_iter() + .map(|d| Box::new(ParamInstance::from_def(d))) + .collect(), + } + } + + fn clip(name: &str) -> ClipInstance { + ClipInstance::from_descriptor(&ClipDescriptor::new(name)) + } + + fn make_instance_with( + entry: crate::host::EntryPoint, + params: ParamSetInstance, + clips: Vec, + ) -> Arc { + let plugin = Arc::new(Plugin { + identifier: "test.instance".into(), + version: (1, 0), + bundle_path: std::path::PathBuf::new(), + contexts: vec![], + descriptor: EffectDescriptor::new(), + lib: std::ptr::null_mut(), + entry, + ofx_plugin: std::ptr::null_mut(), + unloaded: AtomicBool::new(false), + }); + Arc::new(Instance { + props: PropertySet::new(), + plugin, + context: "OfxImageEffectContextFilter".into(), + params, + clips: clips.into_iter().map(Box::new).collect(), + node_identity: AtomicUsize::new(0), + destroyed: AtomicBool::new(false), + sequence_range: Mutex::new(None), + progress_cb: Mutex::new(None), + cancel: AtomicBool::new(false), + edit: Mutex::new(EditTransaction::new()), + render_lock: Mutex::new(()), + interact: Mutex::new(None), + }) + } + + fn make_instance(entry: crate::host::EntryPoint, clips: Vec) -> Arc { + make_instance_with(entry, param_set(vec![]), clips) + } + + fn scale() -> RenderScale { + RenderScale { x: 1.0, y: 1.0 } + } + + fn window() -> OfxRectD { + OfxRectD { + x1: 0.0, + y1: 0.0, + x2: 2.0, + y2: 2.0, + } + } + + fn out_image() -> Arc { + Arc::new(Image::allocate( + BitDepth::Float, + Components::Rgba, + window(), + )) + } + + // ---- parameter edit transaction -------------------------------------- + + #[test] + fn edit_transaction_nesting_and_multi() { + use oak_undo::undocommand::UndoCommand; + let inst = make_instance(entry_ok, vec![]); + assert!(!inst.in_edit()); + + // 嵌套 begin:首次进入重置事务状态。 + inst.edit_begin(); + inst.edit_begin(); + inst.submit_undo_command(UndoCommand::from_closures(|| {}, || {}), "one"); + { + let e = inst.edit.lock().unwrap_or_else(|e| e.into_inner()); + assert_eq!(e.depth, 2); + assert_eq!(e.param_count, 1); + assert_eq!(e.first_label, "one"); + assert_eq!(e.multi.as_ref().expect("multi").multi_child_count(), 1); + } + inst.submit_undo_command(UndoCommand::from_closures(|| {}, || {}), "two"); + { + let e = inst.edit.lock().unwrap_or_else(|e| e.into_inner()); + assert_eq!(e.multi.as_ref().expect("multi").multi_child_count(), 2); + assert_eq!(e.first_label, "one", "首条标签保持"); + } + // 内层 end:仍在事务内,multi 保留。 + inst.edit_end(); + assert!(inst.in_edit()); + // 外层 end:redo 并清空。 + inst.edit_end(); + assert!(!inst.in_edit()); + { + let e = inst.edit.lock().unwrap_or_else(|e| e.into_inner()); + assert!(e.multi.is_none()); + assert_eq!(e.param_count, 0); + assert!(e.first_label.is_empty()); + } + // 事务外 end:depth 不越界。 + inst.edit_end(); + + // 新事务重新计数(首进入重置分支)。 + inst.edit_begin(); + { + let e = inst.edit.lock().unwrap_or_else(|e| e.into_inner()); + assert!(e.multi.is_none()); + assert_eq!(e.param_count, 0); + } + inst.edit_end(); + + // 事务外提交 → 立即 redo。 + let ran = Arc::new(AtomicBool::new(false)); + let flag = ran.clone(); + inst.submit_undo_command( + UndoCommand::from_closures( + move || flag.store(true, std::sync::atomic::Ordering::Relaxed), + || {}, + ), + "solo", + ); + assert!(ran.load(std::sync::atomic::Ordering::Relaxed)); + } + + // ---- clip preferences ------------------------------------------------- + + unsafe extern "C" fn entry_prefs_wrong_types( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + out_args: *mut c_void, + ) -> c_int { + if action_is(action, crate::host::ACTION_GET_CLIP_PREFERENCES) { + let out = unsafe { &*(out_args as *const PropertySet) }; + out.define( + &crate::host::clip_pref_prop(crate::host::CLIP_PREF_COMPONENTS, "Output"), + vec![PropValue::Int(1)], + ); + out.define( + &crate::host::clip_pref_prop(crate::host::CLIP_PREF_DEPTH, "Output"), + vec![PropValue::Int(1)], + ); + out.define( + &crate::host::clip_pref_prop(crate::host::CLIP_PREF_PAR, "Output"), + vec![PropValue::String(cs("x"))], + ); + out.set_one(crate::host::PROP_FRAME_RATE, PropValue::Int(30)); + out.set_one(crate::host::PROP_FIELD_ORDER, PropValue::Int(1)); + } + status::OK + } + + #[test] + fn clip_preferences_default_and_reply_default_paths() { + let inst = make_instance(entry_ok, vec![clip("Source"), clip("Output")]); + let prefs = inst.get_clip_preferences().expect("默认协商"); + assert_eq!(prefs.output_components, "OfxImageComponentRGBA"); + assert_eq!(prefs.output_bit_depth, "OfxBitDepthFloat"); + assert_eq!(prefs.pixel_aspect_ratio, 1.0); + // 插件未回写 → out args 的预置帧率 1.0。 + assert_eq!(prefs.frame_rate, 1.0); + assert_eq!(prefs.field, ""); + // per-clip 回灌(分量/位深/PAR)。 + let source = inst.clips.iter().find(|c| c.name == "Source").unwrap(); + assert_eq!( + prop_string(&source.props, crate::image::K_IMAGE_EFFECT_PROP_COMPONENTS).as_deref(), + Some("OfxImageComponentRGBA") + ); + assert_eq!( + prop_string(&source.props, crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH).as_deref(), + Some("OfxBitDepthFloat") + ); + assert!(matches!( + source.props.get("OfxImagePropPixelAspectRatio", 0), + Some(PropValue::Double(d)) if d == 1.0 + )); + + // ReplyDefault 同样被接受。 + let inst = make_instance(entry_default, vec![clip("Output")]); + assert!(inst.get_clip_preferences().is_ok()); + // 失败状态 → Err。 + let inst = make_instance(entry_err, vec![clip("Output")]); + assert!(inst.get_clip_preferences().is_err()); + } + + #[test] + fn clip_preferences_wrong_out_types_fall_back() { + let inst = make_instance(entry_prefs_wrong_types, vec![clip("Output")]); + let prefs = inst.get_clip_preferences().expect("类型不符应回退"); + assert_eq!(prefs.output_components, "OfxImageComponentRGBA"); + assert_eq!(prefs.output_bit_depth, "OfxBitDepthFloat"); + assert_eq!(prefs.pixel_aspect_ratio, 1.0); + assert_eq!(prefs.frame_rate, 24.0); + assert_eq!(prefs.field, ""); + } + + // ---- region of definition / interest --------------------------------- + + unsafe extern "C" fn entry_rod_ok( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + out_args: *mut c_void, + ) -> c_int { + if action_is(action, crate::host::ACTION_GET_ROD) { + let out = unsafe { &*(out_args as *const PropertySet) }; + out.define( + crate::host::PROP_ROD, + vec![ + PropValue::Double(1.0), + PropValue::Double(2.0), + PropValue::Double(3.0), + PropValue::Double(4.0), + ], + ); + } + status::OK + } + + unsafe extern "C" fn entry_roi_source( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + out_args: *mut c_void, + ) -> c_int { + if action_is(action, crate::host::ACTION_GET_ROI) { + let out = unsafe { &*(out_args as *const PropertySet) }; + out.define( + "OfxImageEffectPropRegionOfInterest_Source", + vec![ + PropValue::Double(1.0), + PropValue::Double(2.0), + PropValue::Double(3.0), + PropValue::Double(4.0), + ], + ); + } + status::OK + } + + #[test] + fn region_of_definition_and_roi_paths() { + let inst = make_instance(entry_rod_ok, vec![]); + let rod = inst.get_region_of_definition(0.5, scale()).expect("rod"); + assert_eq!((rod.x1, rod.y1, rod.x2, rod.y2), (1.0, 2.0, 3.0, 4.0)); + let inst = make_instance(entry_err, vec![]); + assert!(inst.get_region_of_definition(0.0, scale()).is_err()); + + let inst = make_instance(entry_roi_source, vec![clip("Source"), clip("Output")]); + let region = OfxRectD { + x1: 10.0, + y1: 11.0, + x2: 12.0, + y2: 13.0, + }; + let rois = inst.get_regions_of_interest(0.0, scale(), region).expect("rois"); + assert_eq!(rois.len(), 2); + assert_eq!( + (rois[0].x1, rois[0].y1, rois[0].x2, rois[0].y2), + (1.0, 2.0, 3.0, 4.0) + ); + // 插件未回写的 clip 保持 out args 的预置零值(per-clip 预定义)。 + assert_eq!( + (rois[1].x1, rois[1].y1, rois[1].x2, rois[1].y2), + (0.0, 0.0, 0.0, 0.0) + ); + let inst = make_instance(entry_err, vec![clip("Source")]); + assert!(inst.get_regions_of_interest(0.0, scale(), region).is_err()); + } + + // ---- isIdentity ------------------------------------------------------- + + unsafe extern "C" fn entry_identity_source( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + out_args: *mut c_void, + ) -> c_int { + if action_is(action, crate::host::ACTION_IS_IDENTITY) { + let out = unsafe { &*(out_args as *const PropertySet) }; + out.set_one(crate::host::PROP_IS_IDENTITY, PropValue::String(cs("Source"))); + out.set_one(crate::host::PROP_TIME, PropValue::Double(2.5)); + } + status::OK + } + + unsafe extern "C" fn entry_identity_time_int( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + out_args: *mut c_void, + ) -> c_int { + if action_is(action, crate::host::ACTION_IS_IDENTITY) { + let out = unsafe { &*(out_args as *const PropertySet) }; + out.set_one(crate::host::PROP_IS_IDENTITY, PropValue::String(cs("Source"))); + out.set_one(crate::host::PROP_TIME, PropValue::Int(3)); + } + status::OK + } + + #[test] + fn is_identity_paths() { + let inst = make_instance(entry_identity_source, vec![]); + let (time, name) = inst.is_identity(0.0).unwrap().expect("identity"); + assert_eq!(time, 2.5); + assert_eq!(name, "Source"); + + // 透传时间非 Double → 回退输入时间。 + let inst = make_instance(entry_identity_time_int, vec![]); + let (time, name) = inst.is_identity(1.25).unwrap().expect("identity"); + assert_eq!(time, 1.25); + assert_eq!(name, "Source"); + + // 空 identity → None。 + let inst = make_instance(entry_ok, vec![]); + assert!(inst.is_identity(0.0).unwrap().is_none()); + // 插件失败 → 视为非透传(不致命)。 + let inst = make_instance(entry_err, vec![]); + assert!(inst.is_identity(0.0).unwrap().is_none()); + } + + // ---- render / render_gl ---------------------------------------------- + + unsafe extern "C" fn progress_cb(_: f64, _: *mut c_void) -> c_int { + 0 + } + + struct NoopUi; + impl crate::progress::UiProgressReporter for NoopUi { + fn update(&mut self, _progress: f64) -> bool { + true + } + } + + struct FakeGpu; + impl oak_core::backend::GpuContextLike for FakeGpu { + fn kind(&self) -> oak_core::backend::BackendKind { + oak_core::backend::BackendKind::Cpu + } + fn destroy_texture(&self, _token: u64) {} + fn upload(&self, _token: u64, _frame: &oak_core::texture::Frame) -> oak_render::error::Result<()> { + Ok(()) + } + fn download(&self, _token: u64) -> oak_render::error::Result { + Ok(oak_core::texture::Frame::new()) + } + fn blit( + &self, + _src: u64, + _dst: u64, + _processor: Option<&oak_core::color::ColorProcessor>, + ) -> oak_render::error::Result<()> { + Ok(()) + } + } + + fn fake_renderer() -> crate::render::Renderer { + Arc::new(FakeGpu) + } + + #[test] + fn render_paths_and_progress_reporters() { + // facade 取消标记 → 入口即短路。 + let inst = make_instance(entry_err, vec![]); + inst.cancel + .store(true, std::sync::atomic::Ordering::Relaxed); + assert!(inst.render(0.0, scale(), window(), out_image()).is_err()); + + // facade 进度回调分支。 + let inst = make_instance(entry_ok, vec![]); + *inst.progress_cb.lock().unwrap_or_else(|e| e.into_inner()) = + Some((progress_cb, 0)); + inst.render(0.0, scale(), window(), out_image()) + .expect("带回调的 render"); + + // UI 工厂分支(无回调 + factory 已注册)。 + static FACTORY_LOCK: Mutex<()> = Mutex::new(()); + let _g = FACTORY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + crate::progress::set_reporter_factory(Some(Arc::new(|_, _| Box::new(NoopUi)))); + let inst = make_instance(entry_ok, vec![]); + let result = inst.render(0.0, scale(), window(), out_image()); + crate::progress::set_reporter_factory(None); + result.expect("带 UI 工厂的 render"); + + // 插件 render 失败 → Err。 + let inst = make_instance(entry_err, vec![]); + assert!(inst.render(0.0, scale(), window(), out_image()).is_err()); + + // in-args:GL 开关两态。 + let gl = Instance::render_in_args(0.0, scale(), window(), true); + assert!(matches!( + gl.get(crate::host::PROP_GL_ENABLED, 0), + Some(PropValue::Int(1)) + )); + let cpu = Instance::render_in_args(0.0, scale(), window(), false); + assert!(cpu.get(crate::host::PROP_GL_ENABLED, 0).is_none()); + } + + unsafe extern "C" fn entry_gl_attach_fails( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + if action_is(action, crate::host::ACTION_GL_CONTEXT_ATTACHED) { + status::ERR_FATAL + } else { + status::OK + } + } + + unsafe extern "C" fn entry_gl_render_fails( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + if action_is(action, crate::host::ACTION_RENDER) { + status::ERR_FATAL + } else { + status::OK + } + } + + #[test] + fn render_gl_paths() { + // 成功:attach → render → detach;位深列表空 → 默认 F32。 + let inst = make_instance(entry_ok, vec![]); + inst.render_gl( + 0.0, + scale(), + window(), + fake_renderer(), + crate::render::Texture::dummy(), + Some(7), + ) + .expect("GL render"); + assert!(crate::suites::gl_ctx().is_none(), "GL 上下文已清理"); + + // 插件声明 F32 位深列表 → pick 命中分支。 + let inst = make_instance(entry_ok, vec![]); + inst.plugin.descriptor.props.define( + crate::host::PROP_GL_PIXEL_DEPTH, + vec![PropValue::String(cs("OfxBitDepthFloat"))], + ); + inst.render_gl( + 0.0, + scale(), + window(), + fake_renderer(), + crate::render::Texture::dummy(), + None, + ) + .expect("声明位深的 GL render"); + + // attach 失败 → 清理 + Err。 + let inst = make_instance(entry_gl_attach_fails, vec![]); + assert!(inst + .render_gl( + 0.0, + scale(), + window(), + fake_renderer(), + crate::render::Texture::dummy(), + None, + ) + .is_err()); + assert!(crate::suites::gl_ctx().is_none()); + + // render 失败 → detach 仍发 + Err。 + let inst = make_instance(entry_gl_render_fails, vec![]); + assert!(inst + .render_gl( + 0.0, + scale(), + window(), + fake_renderer(), + crate::render::Texture::dummy(), + None, + ) + .is_err()); + + // 取消 → 入口即短路。 + let inst = make_instance(entry_ok, vec![]); + inst.cancel + .store(true, std::sync::atomic::Ordering::Relaxed); + assert!(inst + .render_gl( + 0.0, + scale(), + window(), + fake_renderer(), + crate::render::Texture::dummy(), + None, + ) + .is_err()); + } + + // ---- output colourspace ---------------------------------------------- + + unsafe extern "C" fn entry_colourspace_value( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + out_args: *mut c_void, + ) -> c_int { + if action_is(action, crate::host::ACTION_GET_OUTPUT_COLOURSPACE) { + let out = unsafe { &*(out_args as *const PropertySet) }; + out.set_one( + crate::host::PROP_CLIP_COLOURSPACE, + PropValue::String(cs("ACEScg")), + ); + } + status::OK + } + + unsafe extern "C" fn entry_colourspace_cross( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + out_args: *mut c_void, + ) -> c_int { + if action_is(action, crate::host::ACTION_GET_OUTPUT_COLOURSPACE) { + let out = unsafe { &*(out_args as *const PropertySet) }; + out.set_one( + crate::host::PROP_CLIP_COLOURSPACE, + PropValue::String(cs("OfxColourspace_Source")), + ); + } + status::OK + } + + unsafe extern "C" fn entry_colourspace_int( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + out_args: *mut c_void, + ) -> c_int { + if action_is(action, crate::host::ACTION_GET_OUTPUT_COLOURSPACE) { + let out = unsafe { &*(out_args as *const PropertySet) }; + out.set_one(crate::host::PROP_CLIP_COLOURSPACE, PropValue::Int(1)); + } + status::OK + } + + #[test] + fn output_colourspace_paths() { + // OK + 直值。 + let inst = make_instance(entry_colourspace_value, vec![clip("Source"), clip("Output")]); + assert_eq!( + inst.get_output_colourspace(&["sRGB".to_string()]).unwrap(), + "ACEScg" + ); + // 写回 Output clip。 + inst.set_output_colourspace("sRGB"); + assert_eq!( + prop_string( + &inst.clips.iter().find(|c| c.name == "Output").unwrap().props, + crate::host::PROP_CLIP_COLOURSPACE + ) + .as_deref(), + Some("sRGB") + ); + + // 交叉引用解析为所引 clip 的实际色彩空间。 + let inst = make_instance(entry_colourspace_cross, vec![clip("Source"), clip("Output")]); + let expected = prop_string(&inst.clips[0].props, crate::host::PROP_CLIP_COLOURSPACE).unwrap(); + assert_eq!(inst.get_output_colourspace(&[]).unwrap(), expected); + // 所引 clip 的色彩空间类型不符 → 原样返回引用串。 + inst.clips[0] + .props + .set_one(crate::host::PROP_CLIP_COLOURSPACE, PropValue::Int(1)); + assert_eq!( + inst.get_output_colourspace(&[]).unwrap(), + "OfxColourspace_Source" + ); + // 查无引用 clip / 非引用原样返回。 + assert_eq!( + inst.resolve_colourspace("OfxColourspace_Nope".into()), + "OfxColourspace_Nope" + ); + assert_eq!(inst.resolve_colourspace("sRGB".into()), "sRGB"); + + // OK 但空值 → Err;OK 但非 String → Err。 + let inst = make_instance(entry_ok, vec![clip("Output")]); + assert!(inst.get_output_colourspace(&[]).is_err()); + let inst = make_instance(entry_colourspace_int, vec![clip("Output")]); + assert!(inst.get_output_colourspace(&[]).is_err()); + + // ReplyDefault:第一个输入 clip 的色彩空间。 + let inst = make_instance(entry_default, vec![clip("Source"), clip("Output")]); + let expected = prop_string(&inst.clips[0].props, crate::host::PROP_CLIP_COLOURSPACE).unwrap(); + assert_eq!(inst.get_output_colourspace(&[]).unwrap(), expected); + // 无输入 clip → 空串。 + let inst = make_instance(entry_default, vec![clip("Output")]); + assert_eq!(inst.get_output_colourspace(&[]).unwrap(), ""); + // 输入 clip 无 colourspace 属性 → 空串。 + let inst = make_instance(entry_default, vec![clip("Source")]); + inst.clips[0] + .props + .remove(crate::host::PROP_CLIP_COLOURSPACE); + assert_eq!(inst.get_output_colourspace(&[]).unwrap(), ""); + + // 其它状态码 → Err。 + let inst = make_instance(entry_err, vec![clip("Output")]); + assert!(inst.get_output_colourspace(&[]).is_err()); + + // 无 Output clip → set_output_colourspace no-op。 + let inst = make_instance(entry_ok, vec![clip("Source")]); + inst.set_output_colourspace("x"); + } + + // ---- sequence render -------------------------------------------------- + + unsafe extern "C" fn entry_begin_fails( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + if action_is(action, crate::host::ACTION_BEGIN_SEQUENCE) { + status::ERR_FATAL + } else { + status::OK + } + } + + unsafe extern "C" fn entry_end_fails( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + if action_is(action, crate::host::ACTION_END_SEQUENCE) { + status::ERR_FATAL + } else { + status::OK + } + } + + #[test] + fn sequence_render_paths() { + let range = OfxRangeD { + min: 1.0, + max: 10.0, + }; + + let inst = make_instance(entry_ok, vec![]); + inst.begin_sequence_render(range).expect("begin"); + assert_eq!( + inst.sequence_range + .lock() + .unwrap_or_else(|e| e.into_inner()) + .map(|r| r.min), + Some(1.0) + ); + inst.end_sequence_render(range).expect("end"); + assert!(inst + .sequence_range + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_none()); + + // GL 变体(in args 带 OpenGLEnabled)。 + let inst = make_instance(entry_ok, vec![]); + inst.begin_sequence_render_gl(range).expect("gl begin"); + inst.end_sequence_render_gl(range).expect("gl end"); + + // 失败状态 → Err(end 仍清 range)。 + let inst = make_instance(entry_begin_fails, vec![]); + assert!(inst.begin_sequence_render(range).is_err()); + let inst = make_instance(entry_end_fails, vec![]); + assert!(inst.end_sequence_render(range).is_err()); + assert!(inst + .sequence_range + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_none()); + + // in-args:GL 开关两态。 + let gl = Instance::sequence_in_args(range, true); + assert!(matches!( + gl.get(crate::host::PROP_GL_ENABLED, 0), + Some(PropValue::Int(1)) + )); + let cpu = Instance::sequence_in_args(range, false); + assert!(cpu.get(crate::host::PROP_GL_ENABLED, 0).is_none()); + } + + // ---- instanceChanged / interact -------------------------------------- + + static CHANGED_REASONS: Mutex> = Mutex::new(Vec::new()); + + unsafe extern "C" fn entry_record_reason( + action: *const c_char, + _: *const c_void, + in_args: *mut c_void, + _: *mut c_void, + ) -> c_int { + if action_is(action, crate::host::ACTION_INSTANCE_CHANGED) { + let props = unsafe { &*(in_args as *const PropertySet) }; + if let Some(PropValue::String(s)) = props.get(crate::host::PROP_CHANGE_REASON, 0) { + CHANGED_REASONS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push(s.to_string_lossy().into_owned()); + } + } + status::OK + } + + #[test] + fn instance_changed_reasons_and_failure() { + CHANGED_REASONS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clear(); + let inst = make_instance(entry_record_reason, vec![]); + inst.instance_changed("gain", ChangeReason::UserEdited, 0.0, scale()) + .unwrap(); + inst.instance_changed("gain", ChangeReason::PluginEdited, 0.0, scale()) + .unwrap(); + inst.instance_changed("gain", ChangeReason::TimeChanged, 0.0, scale()) + .unwrap(); + assert_eq!( + *CHANGED_REASONS.lock().unwrap_or_else(|e| e.into_inner()), + vec![ + "OfxChangeUserEdited".to_string(), + "OfxChangePluginEdited".to_string(), + "OfxChangeTime".to_string(), + ] + ); + + let inst = make_instance(entry_err, vec![]); + assert!(inst + .instance_changed("gain", ChangeReason::UserEdited, 0.0, scale()) + .is_err()); + } + + unsafe extern "C" fn overlay_default( + _: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> c_int { + status::REPLY_DEFAULT + } + + #[test] + fn new_interact_and_describe_paths() { + // 未创建 → interact None / describe BadHandle。 + let inst = make_instance(entry_ok, vec![]); + assert!(inst.interact().is_none()); + assert_eq!(inst.describe_interact(), status::ERR_BAD_HANDLE); + + // ReplyDefault 且未声明 overlay → 无 interact。 + let inst = make_instance(entry_default, vec![]); + assert!(inst.new_interact().is_none()); + + // main entry 接受 NewInteract → 创建;describe 走 main entry。 + let inst = make_instance(entry_ok, vec![]); + let interact = inst.new_interact().expect("new_interact"); + assert_eq!(inst.describe_interact(), status::OK); + assert!(inst.interact().is_some()); + assert_eq!(Arc::strong_count(&interact), 2); + + // 声明 overlay 入口 + ReplyDefault → 仍创建(官方 describe/create 序列)。 + let inst = make_instance(overlay_default, vec![]); + inst.plugin.descriptor.props.set_one( + crate::host::PROP_OVERLAY_INTERACT_V2, + PropValue::Pointer(overlay_default as *const () as *mut c_void), + ); + assert!(inst.new_interact().is_some()); + assert_eq!(inst.describe_interact(), status::REPLY_DEFAULT); + } + + // ---- read_rect -------------------------------------------------------- + + #[test] + fn read_rect_rejects_wrong_types_and_missing() { + let props = PropertySet::new(); + assert!(read_rect(&props, "missing").is_none()); + props.define( + "r", + vec![ + PropValue::Int(1), + PropValue::Double(2.0), + PropValue::Double(3.0), + PropValue::Double(4.0), + ], + ); + assert!(read_rect(&props, "r").is_none()); + props.define( + "r", + vec![ + PropValue::Double(1.0), + PropValue::Int(2), + PropValue::Double(3.0), + PropValue::Double(4.0), + ], + ); + assert!(read_rect(&props, "r").is_none()); + props.define( + "r", + vec![ + PropValue::Double(1.0), + PropValue::Double(2.0), + PropValue::Int(3), + PropValue::Double(4.0), + ], + ); + assert!(read_rect(&props, "r").is_none()); + props.define( + "r", + vec![ + PropValue::Double(1.0), + PropValue::Double(2.0), + PropValue::Double(3.0), + PropValue::Int(4), + ], + ); + assert!(read_rect(&props, "r").is_none()); + props.define( + "r", + vec![ + PropValue::Double(1.0), + PropValue::Double(2.0), + PropValue::Double(3.0), + PropValue::Double(4.0), + ], + ); + assert_eq!(read_rect(&props, "r").map(|r| r.x2), Some(3.0)); + } + + // ---- trace probes ----------------------------------------------------- + + /// OAK_OFX_TRACE 打开的探针分支(clip prefs/RoD/RoI/isIdentity 与 + /// isIdentity 失败路径的日志)。 + /// + /// The probe's only effect besides the normal action result is a trace + /// line on stderr (no test-installable sink), so each call is pinned by + /// its return value; the env override is restored before the assertions + /// run so a failing expectation cannot leak `OAK_OFX_TRACE` into the + /// next serialized test. + #[test] + fn trace_probe_branches() { + static TRACE_LOCK: Mutex<()> = Mutex::new(()); + let _g = TRACE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev = std::env::var_os("OAK_OFX_TRACE"); + std::env::set_var("OAK_OFX_TRACE", "1"); + + let inst = make_instance(entry_ok, vec![clip("Source"), clip("Output")]); + let prefs = inst.get_clip_preferences(); + let rod = inst.get_region_of_definition(0.0, scale()); + let region = OfxRectD { + x1: 1.0, + y1: 2.0, + x2: 3.0, + y2: 4.0, + }; + let rois = inst.get_regions_of_interest(0.0, scale(), region); + let identity = inst.is_identity(0.0); + let bad = make_instance(entry_err, vec![]); + let bad_identity = bad.is_identity(0.0); + + match prev { + Some(v) => std::env::set_var("OAK_OFX_TRACE", v), + None => std::env::remove_var("OAK_OFX_TRACE"), + } + + // entry_ok writes nothing back: every negotiated value stays at the + // host pre-set default, which is what the probe branch observes. + let prefs = prefs.expect("entry_ok answers OK"); + assert_eq!(prefs.output_components, "OfxImageComponentRGBA"); + assert_eq!(prefs.output_bit_depth, "OfxBitDepthFloat"); + assert_eq!(prefs.pixel_aspect_ratio, 1.0); + assert_eq!(prefs.frame_rate, 1.0); + assert_eq!(prefs.field, ""); + + let rod = rod.expect("getRoD answers OK"); + assert_eq!( + (rod.x1, rod.y1, rod.x2, rod.y2), + (0.0, 0.0, 0.0, 0.0), + "an untouched pre-defined RoD stays at the zero default" + ); + + // The plugin leaves the pre-defined per-clip ROI properties alone: + // the result is those zero defaults, not the input region. + let rois = rois.expect("getRoI answers OK"); + assert_eq!(rois.len(), 2, "one RoI per clip"); + assert!( + rois.iter() + .all(|r| (r.x1, r.y1, r.x2, r.y2) == (0.0, 0.0, 0.0, 0.0)), + "untouched RoIs stay at the pre-defined zeros: {rois:?}" + ); + + // entry_ok leaves kOfxImageEffectPropIsIdentity empty: an OK action + // without an identity declaration means "not an identity". + assert!( + identity.expect("isIdentity answers OK").is_none(), + "an empty identity property means render" + ); + + // entry_err fails the action: the failure is logged and answered + // None (never propagated as an error). + assert!( + bad_identity.expect("failed isIdentity is tolerated").is_none(), + "a failed isIdentity falls back to render" + ); + } +} diff --git a/crates/oak-plugin/src/node_factory.rs b/crates/oak-plugin/src/node_factory.rs index 264a27939..41f0baa8b 100644 --- a/crates/oak-plugin/src/node_factory.rs +++ b/crates/oak-plugin/src/node_factory.rs @@ -967,6 +967,41 @@ mod tests { /// assert the kOfxActionInstanceChanged routing and its inArgs. static PUSH_ENTRY_CALLS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + /// PUSH_ENTRY_CALLS 是进程级静态:触碰它的测试必须串行。 + static PUSH_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Mock 插件入口:instanceChanged 记录 action 与 change reason; + /// 其它 action/空 action 静默成功。直接调它以覆盖缺失/类型不符的 + /// reason 分支(push_button_clicked 只会写 String reason)。 + unsafe extern "C" fn recording_entry( + action: *const c_char, + _: *const c_void, + in_args: *mut c_void, + _: *mut c_void, + ) -> i32 { + if !action.is_null() { + let action = unsafe { + std::ffi::CStr::from_ptr(action).to_string_lossy().into_owned() + }; + if action == crate::host::ACTION_INSTANCE_CHANGED { + let mut calls = PUSH_ENTRY_CALLS.lock().unwrap_or_else(|e| e.into_inner()); + calls.push(action); + // The instanceChanged inArgs contract (ofxCore.h:405-435): + // kOfxPropChangeReason = kOfxChangeUserEdited. + let props = unsafe { &*(in_args as *const crate::property::PropertySet) }; + let reason = props.get(crate::host::PROP_CHANGE_REASON, 0); + let reason = match reason { + Some(crate::property::Value::String(s)) => { + s.to_string_lossy().into_owned() + } + _ => String::new(), + }; + calls.push(reason); + } + } + 0 + } + #[test] fn input_type_table_covers_all_ofx_kinds() { assert_eq!( @@ -1100,6 +1135,11 @@ mod tests { PropValue::String(CString::new("Matte").unwrap()), ); assert_eq!(clip_label_for_name("Overlay", Some(&props)), "Matte"); + // 有 props 但 label 为空(或类型不符)→ 回退 clip 名。 + let no_label = PropertySet::new(); + assert_eq!(clip_label_for_name("Overlay", Some(&no_label)), "Overlay"); + no_label.set_one(ofx::PROP_LABEL, PropValue::Int(1)); + assert_eq!(clip_label_for_name("Overlay", Some(&no_label)), "Overlay"); } #[test] @@ -1195,37 +1235,8 @@ mod tests { use crate::handle::RefBox; use crate::host::Plugin; use crate::param::{ParamDef, ParamInstance, ParamSetInstance}; - use std::ffi::{c_char, c_void}; use std::sync::atomic::AtomicU32; - unsafe extern "C" fn dummy_entry( - action: *const c_char, - _: *const c_void, - in_args: *mut c_void, - _: *mut c_void, - ) -> i32 { - if !action.is_null() { - let action = unsafe { - std::ffi::CStr::from_ptr(action).to_string_lossy().into_owned() - }; - if action == crate::host::ACTION_INSTANCE_CHANGED { - let mut calls = PUSH_ENTRY_CALLS.lock().unwrap_or_else(|e| e.into_inner()); - calls.push(action); - // The instanceChanged inArgs contract (ofxCore.h:405-435): - // kOfxPropChangeReason = kOfxChangeUserEdited. - let props = unsafe { &*(in_args as *const crate::property::PropertySet) }; - let reason = props.get(crate::host::PROP_CHANGE_REASON, 0); - let reason = match reason { - Some(crate::property::Value::String(s)) => { - s.to_string_lossy().into_owned() - } - _ => String::new(), - }; - calls.push(reason); - } - } - 0 - } let plugin = Arc::new(Plugin { identifier: "test.plugin".into(), version: (1, 0), @@ -1233,7 +1244,7 @@ mod tests { contexts: vec![], descriptor: EffectDescriptor::new(), lib: std::ptr::null_mut(), - entry: dummy_entry, + entry: recording_entry, ofx_plugin: std::ptr::null_mut(), unloaded: std::sync::atomic::AtomicBool::new(false), }); @@ -1273,6 +1284,7 @@ mod tests { /// render scale)。 #[test] fn push_button_clicked_routes_instance_changed() { + let _g = PUSH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); *PUSH_ENTRY_CALLS.lock().unwrap_or_else(|e| e.into_inner()) = Vec::new(); let id = instance_with_push_button(); assert!(push_button_clicked(id, "button")); @@ -1383,10 +1395,13 @@ mod tests { let expected = crate::param_curve::curves_to_json(&[crate::param_curve::Curve::identity( 0.0, 1.0, )]); - match &input.default { - NodeValue::Text(s) => assert_eq!(s, &expected, "默认值应为曲线 JSON"), - other => panic!("预期 Text(JSON),实际 {other:?}"), - } + assert_eq!( + text_of(&input.default).as_deref(), + Some(expected.as_str()), + "默认值应为曲线 JSON" + ); + // 非 Text 值走 None 臂(helper 自身两臂都有执行)。 + assert_eq!(text_of(&NodeValue::Int(1)), None); // 标准值 = 默认(工程序列化从这里走)。 assert_eq!(core.standard_value("curve", -1), input.default); @@ -1425,12 +1440,12 @@ mod tests { // 有效 JSON(双维,第二维单点)→ 曲线写回,求值即实例曲线。 let json = r#"{"curves":[[{"key":0,"value":0,"slope":1},{"key":0.5,"value":0.7,"slope":1}],[{"key":0,"value":0,"slope":1}]]}"#; set_text_param(&inst.value, "curve", json); - let curves = match inst.value.params.find("curve").unwrap().get() { - ParamValue::Parametric(c) => c, - other => panic!("应写回 Parametric,实际 {other:?}"), - }; + let curves = curves_of(inst.value.params.find("curve").unwrap().get()) + .expect("应写回 Parametric"); assert_eq!(curves[0].evaluate(0.5), 0.7); assert_eq!(curves[1].len(), 1); + // 非 Parametric 值走 None 臂(helper 自身两臂都有执行)。 + assert!(curves_of(ParamValue::PushButton).is_none()); // 坏 JSON → 不改值(与字符串族类型不匹配静默一致)。 let before = inst.value.params.find("curve").unwrap().get(); @@ -1445,4 +1460,1026 @@ mod tests { )); unregister_instance(id); } + + // ---- default values / property helpers (coverage batch) -------------- + + fn def_of(ofx_type: &str) -> ParamDef { + ParamDef { + props: PropertySet::new(), + name: "p".into(), + ofx_type: ofx_type.into(), + default: ParamValue::Container, + } + } + + #[test] + fn property_helpers_read_scalars_and_fall_back() { + let props = PropertySet::new(); + props.define( + ofx::P_DEFAULT, + vec![PropValue::Double(2.5), PropValue::Int(7)], + ); + assert_eq!(prop_double(&props, ofx::P_DEFAULT, 0), 2.5); + assert_eq!(prop_double(&props, ofx::P_DEFAULT, 1), 7.0); + assert_eq!(prop_int(&props, ofx::P_DEFAULT, 0), 2); + assert_eq!(prop_int(&props, ofx::P_DEFAULT, 1), 7); + props.set_one("s", PropValue::String(CString::new("hello").unwrap())); + assert_eq!(prop_str(&props, "s", 0), "hello"); + + // Missing / out-of-range / wrong-type values fall back quietly. + assert_eq!(prop_str(&props, "missing", 0), ""); + assert_eq!(prop_double(&props, "missing", 0), 0.0); + assert_eq!(prop_int(&props, "missing", 0), 0); + assert_eq!(prop_double(&props, ofx::P_DEFAULT, 9), 0.0); + assert_eq!(prop_str(&props, ofx::P_DEFAULT, 0), ""); + } + + #[test] + fn default_value_for_every_scalar_param_type() { + let def = def_of(ofx::TYPE_INTEGER); + def.props.set_one(ofx::P_DEFAULT, PropValue::Int(3)); + assert!(matches!( + default_value_for_param(&def), + Some(NodeValue::Int(3)) + )); + + let def = def_of(ofx::TYPE_CHOICE); + def.props.set_one(ofx::P_DEFAULT, PropValue::Int(1)); + assert!(matches!( + default_value_for_param(&def), + Some(NodeValue::Combo(1)) + )); + + let def = def_of(ofx::TYPE_BOOLEAN); + def.props.set_one(ofx::P_DEFAULT, PropValue::Int(1)); + assert!(matches!( + default_value_for_param(&def), + Some(NodeValue::Boolean(true)) + )); + def.props.set_one(ofx::P_DEFAULT, PropValue::Int(0)); + assert!(matches!( + default_value_for_param(&def), + Some(NodeValue::Boolean(false)) + )); + + let def = def_of(ofx::TYPE_DOUBLE); + def.props.set_one(ofx::P_DEFAULT, PropValue::Double(0.25)); + assert!(matches!( + default_value_for_param(&def), + Some(NodeValue::Float(v)) if (v - 0.25).abs() < 1e-9 + )); + + for kind in [ofx::TYPE_STRING, ofx::TYPE_STRCHOICE] { + let def = def_of(kind); + def.props + .set_one(ofx::P_DEFAULT, PropValue::String(CString::new("hi").unwrap())); + assert!(matches!( + default_value_for_param(&def), + Some(NodeValue::Text(ref t)) if t == "hi" + )); + } + + // CUSTOM keeps the raw bytes; BYTES is always empty. + let def = def_of(ofx::TYPE_CUSTOM); + def.props + .set_one(ofx::P_DEFAULT, PropValue::String(CString::new("raw").unwrap())); + assert!(matches!( + default_value_for_param(&def), + Some(NodeValue::Binary(ref b)) if b == b"raw" + )); + let def = def_of(ofx::TYPE_BYTES); + def.props + .set_one(ofx::P_DEFAULT, PropValue::String(CString::new("ignored").unwrap())); + assert!(matches!( + default_value_for_param(&def), + Some(NodeValue::Binary(ref b)) if b.is_empty() + )); + + // Unsupported kinds (and a bare parametric default) have no value. + assert!(default_value_for_param(&def_of(ofx::TYPE_PUSHBUTTON)).is_none()); + assert!(default_value_for_param(&def_of(ofx::TYPE_GROUP)).is_none()); + assert!(default_value_for_param(&def_of("OfxParamTypeBogus")).is_none()); + assert!(default_value_for_param(&def_of(ofx::TYPE_PARAMETRIC)).is_none()); + } + + #[test] + fn default_value_for_color_and_vector_types() { + let def = def_of(ofx::TYPE_RGB); + def.props.define( + ofx::P_DEFAULT, + vec![ + PropValue::Double(0.1), + PropValue::Double(0.2), + PropValue::Double(0.3), + ], + ); + assert!(matches!( + default_value_for_param(&def), + Some(NodeValue::Color(c)) + if c == [0.1, 0.2, 0.3, 1.0] + )); + + let def = def_of(ofx::TYPE_RGBA); + def.props.define( + ofx::P_DEFAULT, + vec![ + PropValue::Double(0.1), + PropValue::Double(0.2), + PropValue::Double(0.3), + PropValue::Double(0.4), + ], + ); + assert!(matches!( + default_value_for_param(&def), + Some(NodeValue::Color(c)) + if c == [0.1, 0.2, 0.3, 0.4] + )); + + let def = def_of(ofx::TYPE_DOUBLE2D); + def.props.define( + ofx::P_DEFAULT, + vec![PropValue::Double(1.5), PropValue::Double(2.5)], + ); + assert!(matches!( + default_value_for_param(&def), + Some(NodeValue::Vec2(v)) if v == [1.5, 2.5] + )); + + let def = def_of(ofx::TYPE_INTEGER2D); + def.props.define( + ofx::P_DEFAULT, + vec![PropValue::Int(1), PropValue::Int(2)], + ); + assert!(matches!( + default_value_for_param(&def), + Some(NodeValue::Vec2(v)) if v == [1.0, 2.0] + )); + + let def = def_of(ofx::TYPE_INTEGER3D); + def.props.define( + ofx::P_DEFAULT, + vec![PropValue::Int(1), PropValue::Int(2), PropValue::Int(3)], + ); + assert!(matches!( + default_value_for_param(&def), + Some(NodeValue::Vec3(v)) if v == [1.0, 2.0, 3.0] + )); + } + + #[test] + fn project_extent_scales_normalised_defaults() { + static EXTENT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = EXTENT_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + set_project_extent(3840.0, 2160.0); + assert_eq!(project_extent(), (3840.0, 2160.0)); + assert_eq!(to_canonical(0.5, 3840.0), 1920.0); + // A zero extent passes the normalised value through untouched. + assert_eq!(to_canonical(0.5, 0.0), 0.5); + + let def = def_of(ofx::TYPE_DOUBLE); + def.props.set_one(ofx::P_DEFAULT, PropValue::Double(0.5)); + def.props.set_one( + ofx::P_DEFAULT_COORD_SYS, + PropValue::String(CString::new(ofx::V_COORD_NORMALISED).unwrap()), + ); + assert!(matches!( + default_value_for_param(&def), + Some(NodeValue::Float(v)) if v == 1920.0 + )); + + let vec3 = def_of(ofx::TYPE_DOUBLE3D); + vec3.props.define( + ofx::P_DEFAULT, + vec![ + PropValue::Double(0.5), + PropValue::Double(0.5), + PropValue::Double(0.5), + ], + ); + vec3.props.set_one( + ofx::P_DEFAULT_COORD_SYS, + PropValue::String(CString::new(ofx::V_COORD_NORMALISED).unwrap()), + ); + assert!(matches!( + default_value_for_param(&vec3), + Some(NodeValue::Vec3(v)) if v == [1920.0, 1080.0, 1920.0] + )); + + set_project_extent(1920.0, 1080.0); + } + + #[test] + fn registered_instance_count_is_observable() { + let a = registered_instance_count(); + let b = registered_instance_count(); + assert_eq!(a, b); + } + + // ---- branch coverage batch: translation tables / executor ------------ + + use crate::clip::ClipInstance; + use crate::descriptor::ClipDescriptor; + use std::ffi::{c_char, c_void}; + + /// 宿主测试用最小插件入口:任何 action 都成功(不做任何事)。 + unsafe extern "C" fn ok_entry( + _: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> i32 { + crate::suites::status::OK + } + + fn text(s: &str) -> PropValue { + PropValue::String(CString::new(s).unwrap()) + } + + /// Text 变体提取(两臂都被调用方触达;避免测试里出现不可达 + /// panic 分支)。 + fn text_of(value: &NodeValue) -> Option { + match value { + NodeValue::Text(s) => Some(s.clone()), + _ => None, + } + } + + /// Parametric 变体提取(同上)。 + fn curves_of(value: ParamValue) -> Option> { + match value { + ParamValue::Parametric(c) => Some(c), + _ => None, + } + } + + fn param_set(defs: Vec) -> crate::param::ParamSetInstance { + crate::param::ParamSetInstance { + params: defs + .into_iter() + .map(|d| Box::new(crate::param::ParamInstance::from_def(d))) + .collect(), + } + } + + fn clip(name: &str) -> ClipInstance { + ClipInstance::from_descriptor(&ClipDescriptor::new(name)) + } + + fn fake_instance( + identifier: &str, + entry: crate::host::EntryPoint, + params: crate::param::ParamSetInstance, + clips: Vec, + ) -> crate::instance::Instance { + use crate::descriptor::EffectDescriptor; + use crate::host::Plugin; + use std::sync::atomic::{AtomicBool, AtomicUsize}; + + let plugin = Arc::new(Plugin { + identifier: identifier.into(), + version: (1, 0), + bundle_path: std::path::PathBuf::new(), + contexts: vec![], + descriptor: EffectDescriptor::new(), + lib: std::ptr::null_mut(), + entry, + ofx_plugin: std::ptr::null_mut(), + unloaded: AtomicBool::new(false), + }); + crate::instance::Instance { + props: PropertySet::new(), + plugin, + context: "OfxImageEffectContextFilter".into(), + params, + clips: clips.into_iter().map(Box::new).collect(), + node_identity: AtomicUsize::new(0), + destroyed: AtomicBool::new(false), + sequence_range: std::sync::Mutex::new(None), + progress_cb: std::sync::Mutex::new(None), + cancel: AtomicBool::new(false), + edit: std::sync::Mutex::new(crate::instance::EditTransaction::new()), + render_lock: std::sync::Mutex::new(()), + interact: std::sync::Mutex::new(None), + } + } + + fn register_fake(inst: crate::instance::Instance) -> u64 { + use crate::handle::RefBox; + use std::sync::atomic::AtomicU32; + register_instance(Arc::new(RefBox { + refs: AtomicU32::new(1), + value: inst, + })) + } + + fn action_is(action: *const c_char, name: &str) -> bool { + !action.is_null() + && unsafe { std::ffi::CStr::from_ptr(action) }.to_bytes() == name.as_bytes() + } + + fn prop_values(input: &oak_node::input::Input, key: &str) -> Vec { + input + .properties + .iter() + .filter(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + .collect() + } + + /// 规则 4(默认值全相等)与规则 5(parent/group 关键词)以及 + /// rule-3 的 dmin < -0.01 半边。 + #[test] + fn color_semantic_rules_4_and_5_and_display_min() { + let empty = HashMap::new(); + // 规则 4:默认值全相等 → scalar。 + let def = def_of(ofx::TYPE_RGB); + def.props.define(ofx::P_DEFAULT, vec![PropValue::Double(0.5); 3]); + assert_eq!(deduce_color_semantic(&def, &empty), "scalar"); + + // 规则 4 不等 + 无 parent → 兜底 color。 + let def = def_of(ofx::TYPE_RGB); + def.props.define( + ofx::P_DEFAULT, + vec![ + PropValue::Double(0.1), + PropValue::Double(0.2), + PropValue::Double(0.3), + ], + ); + assert_eq!(deduce_color_semantic(&def, &empty), "color"); + + // 规则 3 的 dmin 半边。 + let def = def_of(ofx::TYPE_RGB); + def.props + .set_one(ofx::P_DISPLAY_MIN, PropValue::Double(-0.5)); + assert_eq!(deduce_color_semantic(&def, &empty), "scalar"); + + // 规则 5:parent 名带标量关键词。 + let def = def_of(ofx::TYPE_RGB); + def.props.define( + ofx::P_DEFAULT, + vec![ + PropValue::Double(0.1), + PropValue::Double(0.2), + PropValue::Double(0.3), + ], + ); + def.props + .set_one(ofx::P_PARENT, text("gamma_group")); + assert_eq!(deduce_color_semantic(&def, &empty), "scalar"); + + // 规则 5:parent 经 group 表解析出的 label 带关键词。 + let mut groups = HashMap::new(); + groups.insert("grp".to_string(), "Gamma".to_string()); + let def = def_of(ofx::TYPE_RGB); + def.props.define( + ofx::P_DEFAULT, + vec![ + PropValue::Double(0.1), + PropValue::Double(0.2), + PropValue::Double(0.3), + ], + ); + def.props.set_one(ofx::P_PARENT, text("grp")); + assert_eq!(deduce_color_semantic(&def, &groups), "scalar"); + } + + /// build_core 的 group/page 第一遍:label 空回退 name、page 子项映射、 + /// skip row/column 跳过、空名/未知类型参数跳过。 + #[test] + fn build_core_translates_groups_pages_and_skips_unknowns() { + let group_labeled = ParamDef::new("grp_labeled", ofx::TYPE_GROUP); + group_labeled + .props + .set_one(ofx::PROP_LABEL, text("Grouped")); + let group_bare = ParamDef::new("grp_bare", ofx::TYPE_GROUP); + group_bare.props.set_one(ofx::PROP_LABEL, text("")); + let page = ParamDef::new("page_a", ofx::TYPE_PAGE); + page.props.set_one(ofx::PROP_LABEL, text("Page A")); + page.props.define( + ofx::P_PAGE_CHILD, + vec![ + text("p_double"), + text("p_orphan"), + text(ofx::PAGE_SKIP_ROW), + text(ofx::PAGE_SKIP_COLUMN), + ], + ); + let p_double = ParamDef::new("p_double", ofx::TYPE_DOUBLE); + p_double + .props + .set_one(ofx::P_PARENT, text("grp_labeled")); + let p_orphan = ParamDef::new("p_orphan", ofx::TYPE_DOUBLE); + p_orphan + .props + .set_one(ofx::P_PARENT, text("missing_group")); + let empty_name = ParamDef::new("", ofx::TYPE_DOUBLE); + let bogus = ParamDef::new("bogus", "OfxParamTypeBogus"); + + let inst = fake_instance( + "test.groups.pages", + ok_entry, + param_set(vec![ + group_labeled, + group_bare, + page, + p_double, + p_orphan, + empty_name, + bogus, + ]), + vec![], + ); + let core = build_core(&inst); + + let double = core.get_input("p_double").expect("p_double 输入"); + assert_eq!( + prop_values(double, "ui_group"), + vec![NodeValue::Text("Grouped".into())] + ); + assert_eq!( + prop_values(double, "ui_page"), + vec![NodeValue::Text("Page A".into())] + ); + + let orphan = core.get_input("p_orphan").expect("p_orphan 输入"); + // 未登记的 group → 回退 parent 名本身。 + assert_eq!( + prop_values(orphan, "ui_group"), + vec![NodeValue::Text("missing_group".into())] + ); + assert_eq!( + prop_values(orphan, "ui_page"), + vec![NodeValue::Text("Page A".into())] + ); + + // group/page/未知/空名都不产出输入。 + assert!(core.get_input("grp_labeled").is_none()); + assert!(core.get_input("page_a").is_none()); + assert!(core.get_input("bogus").is_none()); + assert!(core.get_input("").is_none()); + } + + /// build_core 的 clip→纹理输入与 effect_input 回退链。 + #[test] + fn build_core_selects_effect_input_from_clips() { + // Source 存在 → effect_input = Source;Output 不入输入表。 + let inst = fake_instance( + "test.clips.source", + ok_entry, + param_set(vec![]), + vec![clip("Source"), clip("Output")], + ); + let core = build_core(&inst); + assert_eq!(core.effect_input, SOURCE_CLIP); + assert!(core.get_input("Output").is_none()); + let source = core.get_input("Source").expect("Source 输入"); + assert_eq!(source.value_type, ValueType::Texture); + assert_eq!(source.display_name, "Source"); + + // 无 Source 但有 Texture → effect_input = Texture(不新建)。 + let inst = fake_instance( + "test.clips.texture", + ok_entry, + param_set(vec![]), + vec![clip("Texture"), clip("Output")], + ); + let core = build_core(&inst); + assert_eq!(core.effect_input, TEXTURE_INPUT); + assert_eq!(core.get_input("Texture").unwrap().display_name, "Texture"); + + // 只有非标准 clip → 合成 Texture 输入。 + let inst = fake_instance( + "test.clips.mask", + ok_entry, + param_set(vec![]), + vec![clip("Mask"), clip("Output")], + ); + let core = build_core(&inst); + assert_eq!(core.effect_input, TEXTURE_INPUT); + let tex = core.get_input(TEXTURE_INPUT).expect("合成 Texture 输入"); + assert_eq!(tex.value_type, ValueType::Texture); + assert_eq!(tex.display_name, "Texture"); + assert!(core.get_input("Mask").is_some()); + } + + /// 颜色输入属性(semantic/min/max/tooltip)与 combo 选项表 + /// (enum 回退、ChoiceOrder 排序、StrCombo 值表)。 + #[test] + fn build_core_color_and_combo_property_tables() { + let tint = ParamDef::new("tint", ofx::TYPE_RGB); + tint.props.set_one(ofx::PROP_LABEL, text("Tint Color")); + tint.props.set_one(ofx::P_HINT, text("Pick me")); + tint.props + .set_one(ofx::P_DISPLAY_MIN, PropValue::Double(0.0)); + tint.props + .set_one(ofx::P_DISPLAY_MAX, PropValue::Double(1.0)); + + let mode = ParamDef::new("mode", ofx::TYPE_CHOICE); + mode.props + .define(ofx::P_CHOICE_OPTION, vec![text("A"), text("B"), text("C")]); + mode.props + .define(ofx::P_CHOICE_ENUM, vec![text("a"), text("b"), text("c")]); + mode.props.define( + ofx::P_CHOICE_ORDER, + vec![ + PropValue::Int(2), + PropValue::Int(0), + PropValue::Int(1), + ], + ); + + let labels_only = ParamDef::new("labels_only", ofx::TYPE_CHOICE); + labels_only + .props + .define(ofx::P_CHOICE_OPTION, vec![text("OnlyA"), text("OnlyB")]); + + let sc = ParamDef::new("sc", ofx::TYPE_STRCHOICE); + sc.props + .define(ofx::P_CHOICE_ENUM, vec![text("v1"), text("v2")]); + + // 无 label + 默认值不等的 RGBA → 兜底 color,显示名回退参数 id。 + let plain = ParamDef::new("plain_rgb", ofx::TYPE_RGBA); + plain.props.set_one(ofx::PROP_LABEL, text("")); + plain.props.define( + ofx::P_DEFAULT, + vec![ + PropValue::Double(0.1), + PropValue::Double(0.2), + PropValue::Double(0.3), + PropValue::Double(1.0), + ], + ); + + let inst = fake_instance( + "test.color.combo", + ok_entry, + param_set(vec![tint, mode, labels_only, sc, plain]), + vec![], + ); + let core = build_core(&inst); + + let tint = core.get_input("tint").unwrap(); + assert_eq!( + prop_values(tint, "color_semantic"), + vec![NodeValue::Text("color".into())] + ); + assert_eq!( + prop_values(tint, "min"), + vec![NodeValue::Float(0.0)] + ); + assert_eq!( + prop_values(tint, "max"), + vec![NodeValue::Float(1.0)] + ); + assert_eq!( + prop_values(tint, "tooltip"), + vec![NodeValue::Text("Pick me".into())] + ); + + let mode = core.get_input("mode").unwrap(); + assert_eq!( + prop_values(mode, "combo_option"), + vec![ + NodeValue::Text("B".into()), + NodeValue::Text("C".into()), + NodeValue::Text("A".into()), + ] + ); + assert!(prop_values(mode, "combo_value").is_empty()); + + // labels 有、enum 空 → values 回退 labels。 + let labels_only = core.get_input("labels_only").unwrap(); + assert_eq!( + prop_values(labels_only, "combo_option"), + vec![ + NodeValue::Text("OnlyA".into()), + NodeValue::Text("OnlyB".into()), + ] + ); + assert!(prop_values(labels_only, "combo_value").is_empty()); + + // StrCombo:enum 回退 labels + combo_value 表。 + let sc = core.get_input("sc").unwrap(); + assert_eq!( + prop_values(sc, "combo_option"), + vec![ + NodeValue::Text("v1".into()), + NodeValue::Text("v2".into()), + ] + ); + assert_eq!( + prop_values(sc, "combo_value"), + vec![ + NodeValue::Text("v1".into()), + NodeValue::Text("v2".into()), + ] + ); + + let plain = core.get_input("plain_rgb").unwrap(); + assert_eq!(plain.display_name, "plain_rgb"); + assert_eq!( + prop_values(plain, "color_semantic"), + vec![NodeValue::Text("color".into())] + ); + } + + /// parametric UI 颜色表不足一整维时在循环内 break。 + #[test] + fn build_core_parametric_partial_ui_colour() { + let def = ParamDef::new("curve", ofx::TYPE_PARAMETRIC); + def.props + .set_one(ofx::P_PARAMETRIC_DIMENSION, PropValue::Int(2)); + def.props.define( + ofx::P_PARAMETRIC_RANGE, + vec![PropValue::Double(0.0), PropValue::Double(1.0)], + ); + // 只够第 1 维(3 个 double)→ 第 2 维 break。 + def.props.define( + ofx::P_PARAMETRIC_UI_COLOUR, + vec![ + PropValue::Double(1.0), + PropValue::Double(0.0), + PropValue::Double(0.0), + ], + ); + let inst = fake_instance( + "test.parametric.partial", + ok_entry, + param_set(vec![def]), + vec![], + ); + let core = build_core(&inst); + let input = core.get_input("curve").unwrap(); + assert_eq!( + prop_values(input, "parametric_ui_colour"), + vec![NodeValue::Color([1.0, 0.0, 0.0, 1.0])] + ); + assert_eq!( + prop_values(input, "parametric_dimension"), + vec![NodeValue::Int(2)] + ); + assert_eq!( + prop_values(input, "parametric_range"), + vec![NodeValue::Vec2([0.0, 1.0])] + ); + } + + /// 显示名/描述回退与子分类映射。 + #[test] + fn display_name_description_and_sub_category() { + let inst = fake_instance("test.display", ok_entry, param_set(vec![]), vec![]); + assert_eq!(plugin_display_name(&inst), "test.display"); + inst.plugin + .descriptor + .props + .set_one(ofx::PROP_LABEL, text("Pretty")); + assert_eq!(plugin_display_name(&inst), "Pretty"); + inst.plugin + .descriptor + .props + .set_one(PROP_PLUGIN_DESCRIPTION, text("Desc")); + assert_eq!(plugin_description(&inst), "Desc"); + // 未配置 → 空串。 + let bare = fake_instance("test.display.bare", ok_entry, param_set(vec![]), vec![]); + assert_eq!(plugin_description(&bare), ""); + + assert_eq!(sub_category_for("OfxImageEffectContextFilter"), "Filter"); + assert_eq!( + sub_category_for("OfxImageEffectContextGenerator"), + "Generator" + ); + assert_eq!( + sub_category_for("OfxImageEffectContextTransition"), + "Transition" + ); + assert_eq!(sub_category_for("OfxImageEffectContextGeneral"), "General"); + assert_eq!(sub_category_for("OfxImageEffectContextBogus"), "General"); + } + + /// set_text_param 的类型分发:String/StrChoice 写回、NUL 拒绝、 + /// 非文本类型与查无参数静默忽略。 + #[test] + fn set_text_param_type_dispatch_and_nul_rejection() { + let s = ParamDef::new("s", ofx::TYPE_STRING); + let sc = ParamDef::new("sc", ofx::TYPE_STRCHOICE); + let d = ParamDef::new("d", ofx::TYPE_DOUBLE); + let inst = fake_instance( + "test.text.dispatch", + ok_entry, + param_set(vec![s, sc, d]), + vec![], + ); + + // 查无参数 → no-op。 + set_text_param(&inst, "missing", "x"); + + // String:NUL → 拒绝(值保持),有效 → 写回。 + let before = inst.params.find("s").unwrap().get(); + set_text_param(&inst, "s", "with\0nul"); + assert_eq!(inst.params.find("s").unwrap().get(), before); + set_text_param(&inst, "s", "hello"); + assert!(matches!( + inst.params.find("s").unwrap().get(), + ParamValue::String(_) + )); + + // StrChoice:NUL → 拒绝,有效 → 写回。 + let before = inst.params.find("sc").unwrap().get(); + set_text_param(&inst, "sc", "with\0nul"); + assert_eq!(inst.params.find("sc").unwrap().get(), before); + set_text_param(&inst, "sc", "pick"); + assert!(matches!( + inst.params.find("sc").unwrap().get(), + ParamValue::StrChoice(_) + )); + + // 非文本类型 → 静默忽略。 + let before = inst.params.find("d").unwrap().get(); + set_text_param(&inst, "d", "5"); + assert_eq!(inst.params.find("d").unwrap().get(), before); + } + + /// recording entry 的边界分支(空 action、非 instanceChanged、 + /// 缺失/类型不符的 change reason)——直接调 entry 覆盖。 + #[test] + fn push_button_recording_entry_edge_branches() { + let _g = PUSH_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + // 空 action → 不记录。 + let rc = unsafe { + recording_entry( + std::ptr::null(), + std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + assert_eq!(rc, 0); + // 其它 action → 不记录。 + let other = CString::new("OfxActionSomethingElse").unwrap(); + let rc = unsafe { + recording_entry( + other.as_ptr(), + std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + assert_eq!(rc, 0); + + // instanceChanged 但 inArgs 缺 reason → 记录空串。 + *PUSH_ENTRY_CALLS.lock().unwrap_or_else(|e| e.into_inner()) = Vec::new(); + let changed = CString::new(crate::host::ACTION_INSTANCE_CHANGED).unwrap(); + let props = PropertySet::new(); + unsafe { + recording_entry( + changed.as_ptr(), + std::ptr::null(), + &props as *const PropertySet as *mut c_void, + std::ptr::null_mut(), + ) + }; + assert_eq!( + *PUSH_ENTRY_CALLS.lock().unwrap_or_else(|e| e.into_inner()), + vec!["OfxActionInstanceChanged".to_string(), String::new()] + ); + + // reason 类型不符 → 空串。 + *PUSH_ENTRY_CALLS.lock().unwrap_or_else(|e| e.into_inner()) = Vec::new(); + props.set_one(crate::host::PROP_CHANGE_REASON, PropValue::Int(3)); + unsafe { + recording_entry( + changed.as_ptr(), + std::ptr::null(), + &props as *const PropertySet as *mut c_void, + std::ptr::null_mut(), + ) + }; + assert_eq!( + *PUSH_ENTRY_CALLS.lock().unwrap_or_else(|e| e.into_inner()), + vec!["OfxActionInstanceChanged".to_string(), String::new()] + ); + } + + /// push_button_clicked 的 instanceChanged 失败只记日志、仍返回 true。 + #[test] + fn push_button_clicked_instance_changed_failure_returns_true() { + unsafe extern "C" fn fail_changed( + action: *const c_char, + _: *const c_void, + _: *mut c_void, + _: *mut c_void, + ) -> i32 { + if action_is(action, crate::host::ACTION_INSTANCE_CHANGED) { + crate::suites::status::ERR_FATAL + } else { + crate::suites::status::OK + } + } + let button = ParamDef::new("button", ofx::TYPE_PUSHBUTTON); + button.props.set_one(ofx::PROP_LABEL, text("Button")); + let inst = fake_instance( + "test.push.fail", + fail_changed, + param_set(vec![button]), + vec![], + ); + let id = register_fake(inst); + assert!(push_button_clicked(id, "button")); + unregister_instance(id); + } + + /// execute_plugin_job:非 Plugin spec、未登记实例、dummy/零尺寸 + /// 输入、参数值分发的 PushButton/None 分支。 + #[test] + fn execute_plugin_job_spec_and_source_branches() { + let inst = fake_instance("test.exec.branches", ok_entry, param_set(vec![]), vec![]); + let id = register_fake(inst); + + fn plugin_spec(instance: u64, values: Vec<(String, NodeValue)>) -> oak_render::eval::JobSpec { + oak_render::eval::JobSpec::Plugin { + instance, + type_id: "org.oak.test.exec".to_string(), + time: 0.0, + effect_input_id: Some(SOURCE_CLIP.to_string()), + inputs: Vec::new(), + values, + } + } + + // 非 Plugin spec → Invalid。 + let spec = oak_render::eval::JobSpec::Generate; + let req = oak_render::eval::PluginJobRequest { + spec: &spec, + src: oak_core::texture::Texture::dummy(), + }; + assert!(execute_plugin_job(&req).is_err()); + + // 未登记实例 → Failed。 + let spec = plugin_spec(u64::MAX, Vec::new()); + let req = oak_render::eval::PluginJobRequest { + spec: &spec, + src: oak_core::texture::Texture::dummy(), + }; + assert!(execute_plugin_job(&req).is_err()); + + // 登记实例 + dummy 源 → Failed。 + let spec = plugin_spec(id, Vec::new()); + let req = oak_render::eval::PluginJobRequest { + spec: &spec, + src: oak_core::texture::Texture::dummy(), + }; + assert!(execute_plugin_job(&req).is_err()); + + // 登记实例 + 零尺寸(非 dummy)源 → generate_frame 失败。 + let pod = oak_core::frame::VideoParamsPod { + width: 1, + height: 0, + ..Default::default() + }; + let mut zero = oak_core::texture::Frame::new(); + zero.set_video_params(pod); + let spec = plugin_spec(id, Vec::new()); + let req = oak_render::eval::PluginJobRequest { + spec: &spec, + src: oak_core::texture::Texture::wrap_frame(zero), + }; + assert!(execute_plugin_job(&req).is_err()); + + // 参数值分发:PushButton/None 跳过、数值走 POD、文本走 set_ofx; + // 后续 render_frame 的结果不做断言(假插件没有真实渲染行为), + // 这里只要求各分发分支被走到。 + let frame = + oak_render::eval::generate_frame(oak_core::Rational::new(0, 1), (2, 2), oak_core::PixelFormat::F32) + .expect("frame"); + let spec = plugin_spec( + id, + vec![ + ("button".to_string(), NodeValue::PushButton), + ("none".to_string(), NodeValue::None), + ("gain".to_string(), NodeValue::Float(1.0)), + ("title".to_string(), NodeValue::Text("hello".to_string())), + ], + ); + let req = oak_render::eval::PluginJobRequest { + spec: &spec, + src: oak_core::texture::Texture::wrap_frame(frame), + }; + let _ = execute_plugin_job(&req); + + unregister_instance(id); + } + + // ---- 真实测试插件(build.rs 产物)的宿主面覆盖 ---------------------- + + /// 宿主全局状态串行(真实插件扫描/实例化)。 + static HOST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn fixture_bundle_dir() -> Option { + let out = std::path::PathBuf::from(env!("OUT_DIR")); + let lib = out.join(if cfg!(target_os = "macos") { + "oak_test_plugin.dylib" + } else { + "oak_test_plugin.so" + }); + if !lib.is_file() { + return None; + } + let platform = if cfg!(target_os = "macos") { + "MacOS" + } else if cfg!(target_os = "windows") { + "Win64" + } else { + "Linux-x86-64" + }; + let dir = std::env::temp_dir().join(format!("oak-plugin-unit-fixture-{}", std::process::id())); + let bin_dir = dir + .join("oak-test-plugin.ofx.bundle/Contents") + .join(platform); + std::fs::create_dir_all(&bin_dir).ok()?; + let target = bin_dir.join(if cfg!(target_os = "windows") { + "plugin.dll" + } else { + "plugin" + }); + if !target.exists() { + std::fs::copy(&lib, &target).ok()?; + } + Some(dir) + } + + fn ensure_fixture_loaded() -> bool { + let Some(dir) = fixture_bundle_dir() else { + return false; + }; + if Host::global().cache.scan_path(&dir).is_err() { + return false; + } + Host::global() + .cache + .find("org.oak.test-plugin") + .is_some() + } + + fn with_trace(f: impl FnOnce() -> R) -> R { + static TRACE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _g = TRACE_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev = std::env::var_os("OAK_OFX_TRACE"); + std::env::set_var("OAK_OFX_TRACE", "1"); + let out = f(); + match prev { + Some(v) => std::env::set_var("OAK_OFX_TRACE", v), + None => std::env::remove_var("OAK_OFX_TRACE"), + } + out + } + + /// duplicate_instance:注册表换新实例(真实测试插件)+ 未知句柄 None。 + #[test] + fn duplicate_instance_recreates_and_unknown_handle_is_none() { + let _g = HOST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + if !ensure_fixture_loaded() { + eprintln!("SKIP: 测试插件未构建"); + return; + } + let inst = Host::global() + .create_instance("org.oak.test-plugin", Some("OfxImageEffectContextFilter")) + .expect("测试插件实例"); + let handle = PluginInstanceHandle(register_instance(inst)); + let dup = duplicate_instance(handle).expect("duplicate 应成功"); + assert_ne!(dup.0, handle.0); + let dup_inst = instance_from_id(dup.0).expect("duplicate 已登记"); + assert_eq!(dup_inst.value.plugin.identifier, "org.oak.test-plugin"); + assert_eq!(dup_inst.value.context, "OfxImageEffectContextFilter"); + unregister_instance(dup.0); + assert!(duplicate_instance(PluginInstanceHandle(u64::MAX)).is_none()); + } + + /// shared_plugin_instance:首访建实例并缓存,再访命中缓存;查无 + /// 标识 → None。 + #[test] + fn shared_plugin_instance_caches_and_misses() { + let _g = HOST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + if !ensure_fixture_loaded() { + eprintln!("SKIP: 测试插件未构建"); + return; + } + let first = shared_plugin_instance("org.oak.test-plugin").expect("首访建实例"); + let second = shared_plugin_instance("org.oak.test-plugin").expect("再访命中缓存"); + assert_eq!(first, second); + assert!(shared_plugin_instance("com.example.no-such-plugin").is_none()); + } + + /// 用 build.rs 产出的测试插件跑通 render_real_plugin_smoke 全路径 + /// (含 OAK_OFX_TRACE 探针分支),不再依赖机器上装有真实插件。 + #[test] + fn fixture_plugin_renders_through_the_executor() { + let _g = HOST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + if !ensure_fixture_loaded() { + eprintln!("SKIP: 测试插件未构建"); + return; + } + with_trace(|| render_real_plugin_smoke("org.oak.test-plugin")); + } } diff --git a/crates/oak-plugin/src/render_driver.rs b/crates/oak-plugin/src/render_driver.rs index 9d8177447..c761b41f2 100644 --- a/crates/oak-plugin/src/render_driver.rs +++ b/crates/oak-plugin/src/render_driver.rs @@ -620,11 +620,810 @@ pub(crate) fn write_output_frame(dst: &mut Texture, image: &Image) -> crate::err ctx.upload(*token, &frame) .map_err(|e| Error::Failed(format!("输出纹理上传失败:{e}")))?; } + // 未解析的平面纹理不会成为插件输出目标(解码路径会先解析)。 + Texture::Planar(_) => { + return Err(Error::Failed("平面纹理不能作为插件输出目标".into())); + } } Ok(()) } - ctx.upload(*token, &frame) - .map_err(|e| Error::Failed(format!("输出纹理上传失败:{e}")))?; - } -+ // 未解析的平面纹理不会成为插件输出目标(解码路径会先解析)。 -+ Texture::Planar(_) => { + +#[cfg(test)] +mod tests { + use super::*; + use crate::descriptor::{ClipDescriptor, EffectDescriptor}; + use crate::error::Error; + use crate::instance::Instance; + use crate::param::{ParamDef, ParamInstance, ParamSetInstance, ParamValue}; + use crate::property::{PropertySet, Value}; + + fn cs(s: &str) -> std::ffi::CString { + std::ffi::CString::new(s).unwrap() + } + + fn f32_frame(w: i32, h: i32, fill: f32) -> crate::render::Frame { + let params = render::VideoParams { + width: w, + height: h, + format: render::PIXEL_FORMAT_F32, + ..Default::default() + }; + let mut frame = crate::render::Frame::new(); + frame.set_video_params(params); + assert!(frame.allocate()); + for c in frame.data.as_chunks_mut::<4>().0 { + c.copy_from_slice(&fill.to_le_bytes()); + } + frame + } + + fn f32_texture(w: i32, h: i32, fill: f32) -> Texture { + Texture::wrap_frame(f32_frame(w, h, fill)) + } + + fn u8_texture(w: i32, h: i32) -> Texture { + let params = render::VideoParams { + width: w, + height: h, + format: render::PIXEL_FORMAT_U8, + ..Default::default() + }; + let pixels = vec![0u8; w as usize * h as usize * 4]; + render::texture_create(¶ms, &pixels, 0).expect("U8 纹理应可建") + } + + fn f32_image(w: i32, h: i32, fill: f32) -> Image { + let mut img = Image::allocate( + crate::image::BitDepth::Float, + crate::image::Components::Rgba, + OfxRectD { + x1: 0.0, + y1: 0.0, + x2: w as f64, + y2: h as f64, + }, + ); + for c in img.pixels_mut().as_chunks_mut::<4>().0 { + c.copy_from_slice(&fill.to_le_bytes()); + } + img + } + + /// 测试入口:按实例 props 里的 test.* 配置扮演插件行为。实例句柄 + /// (offset 0)即配置存储,避免测试间共享全局状态。 + unsafe extern "C" fn test_entry( + action: *const std::ffi::c_char, + handle: *const std::ffi::c_void, + in_args: *mut std::ffi::c_void, + out_args: *mut std::ffi::c_void, + ) -> std::ffi::c_int { + let action_name = unsafe { std::ffi::CStr::from_ptr(action) } + .to_string_lossy() + .into_owned(); + let inst_props = unsafe { + &*(crate::suites::tag::strip(handle as *mut std::ffi::c_void)) + }; + let in_args = unsafe { &*(in_args as *const PropertySet) }; + let out_args = unsafe { &*(out_args as *const PropertySet) }; + + if action_name == crate::host::ACTION_GET_CLIP_PREFERENCES { + if let Some(Value::String(comps)) = inst_props.get("test.components", 0) { + out_args.set_one( + &crate::host::clip_pref_prop(crate::host::CLIP_PREF_COMPONENTS, "Output"), + Value::String(comps.clone()), + ); + } + if let Some(Value::String(depth)) = inst_props.get("test.depth", 0) { + out_args.set_one( + &crate::host::clip_pref_prop(crate::host::CLIP_PREF_DEPTH, "Output"), + Value::String(depth.clone()), + ); + } + } else if action_name == crate::host::ACTION_IS_IDENTITY { + if let Some(Value::String(clip)) = inst_props.get("test.identity_clip", 0) { + if !clip.to_string_lossy().is_empty() { + out_args.set_one(crate::host::PROP_IS_IDENTITY, Value::String(clip.clone())); + if let Some(Value::Double(t)) = inst_props.get("test.identity_time", 0) { + out_args.set_one(crate::host::PROP_TIME, Value::Double(t)); + } + } + } + } else if action_name == crate::host::ACTION_RENDER { + // GL 失败注入:仅 GL 模式的 render(in args 带 OpenGLEnabled=1)。 + let gl_enabled = matches!( + in_args.get(crate::host::PROP_GL_ENABLED, 0), + Some(Value::Int(1)) + ); + if gl_enabled && matches!(inst_props.get("test.gl_fail", 0), Some(Value::Int(1))) { + return crate::suites::status::FAILED; + } + if let Some(Value::Int(st)) = inst_props.get("test.render_status", 0) { + return st; + } + } + crate::suites::status::OK + } + + #[allow(clippy::vec_box)] // ParamSetInstance 的字段类型即 Vec>。 + fn make_instance( + desc_props: Vec<(&'static str, Value)>, + inst_props: Vec<(&'static str, Value)>, + clips: &[&str], + params: Vec>, + ) -> Instance { + let desc = EffectDescriptor::new(); + for (k, v) in desc_props { + desc.props.set_one(k, v); + } + let props = PropertySet::new(); + for (k, v) in inst_props { + props.set_one(k, v); + } + Instance { + props, + plugin: std::sync::Arc::new(crate::host::Plugin { + identifier: "org.oak.test-render-driver".into(), + version: (1, 0), + bundle_path: std::path::PathBuf::new(), + contexts: vec![], + descriptor: desc, + lib: std::ptr::null_mut(), + entry: test_entry, + ofx_plugin: std::ptr::null_mut(), + unloaded: std::sync::atomic::AtomicBool::new(false), + }), + context: "OfxImageEffectContextFilter".into(), + params: ParamSetInstance { params }, + clips: clips + .iter() + .map(|n| Box::new(crate::clip::ClipInstance::from_descriptor(&ClipDescriptor::new(n)))) + .collect(), + node_identity: std::sync::atomic::AtomicUsize::new(0), + // 置位销毁门:测试实例 drop 时不再向假插件发 destroyInstance。 + destroyed: std::sync::atomic::AtomicBool::new(true), + sequence_range: std::sync::Mutex::new(None), + progress_cb: std::sync::Mutex::new(None), + cancel: std::sync::atomic::AtomicBool::new(false), + edit: std::sync::Mutex::new(crate::instance::EditTransaction::new()), + render_lock: std::sync::Mutex::new(()), + interact: std::sync::Mutex::new(None), + } + } + + fn job(dst: Texture, src: Option) -> RenderJob { + RenderJob { + time: 0.0, + dst, + src, + effect_input_id: Some("Source".into()), + inputs: Vec::new(), + values: Vec::new(), + renderer: None, + clear_destination: false, + interactive: false, + } + } + + fn window(w: f64, h: f64) -> OfxRectD { + OfxRectD { + x1: 0.0, + y1: 0.0, + x2: w, + y2: h, + } + } + + fn first_pixel(tex: &Texture) -> [f32; 4] { + let frame = render::texture_get_frame(tex).unwrap(); + let mut out = [0f32; 4]; + for (i, v) in out.iter_mut().enumerate() { + *v = f32::from_le_bytes(frame.data[i * 4..i * 4 + 4].try_into().unwrap()); + } + out + } + + /// RenderJob::default:全字段的默认值。 + #[test] + fn render_job_default() { + let j = RenderJob::default(); + assert_eq!(j.time, 0.0); + assert!(j.dst.is_dummy()); + assert!(j.src.is_none()); + assert!(j.effect_input_id.is_none()); + assert!(j.inputs.is_empty()); + assert!(j.values.is_empty()); + assert!(j.renderer.is_none()); + assert!(!j.clear_destination); + assert!(!j.interactive); + } + + /// read_dst:非 F32 拒绝;0 尺寸 Invalid;正常返回 w/h。 + #[test] + fn read_dst_validation() { + let u8tex = u8_texture(2, 2); + assert!(matches!(read_dst(&u8tex), Err(Error::Failed(_)))); + + let params = render::VideoParams { + format: render::PIXEL_FORMAT_F32, + ..Default::default() + }; + let mut frame = crate::render::Frame::new(); + frame.set_video_params(params); + let zero = Texture::wrap_frame(frame); + assert!(matches!(read_dst(&zero), Err(Error::Invalid))); + + let (p, w, h) = read_dst(&f32_texture(3, 2, 0.0)).unwrap(); + assert_eq!((p.width, p.height), (3, 2)); + assert_eq!((w, h), (3.0, 2.0)); + } + + /// pixel_aspect:den == 0 回退 1.0,否则比值。 + #[test] + fn pixel_aspect_zero_den_falls_back() { + let mut p = render::VideoParams { + pixel_aspect_num: 4, + pixel_aspect_den: 0, + ..Default::default() + }; + assert_eq!(pixel_aspect(&p), 1.0); + p.pixel_aspect_num = 3; + p.pixel_aspect_den = 2; + assert_eq!(pixel_aspect(&p), 1.5); + } + + /// plugin_supports_opengl:"true"/"needed" 为真;其余/缺失/非字符串 + /// 为假。 + #[test] + fn plugin_supports_opengl_matrix() { + let inst = make_instance(vec![], vec![], &[], vec![]); + assert!(!plugin_supports_opengl(&inst), "缺失属性 → false"); + + inst.plugin.descriptor.props.set_one( + crate::host::PROP_GL_RENDER_SUPPORTED, + Value::String(cs("true")), + ); + assert!(plugin_supports_opengl(&inst)); + inst.plugin.descriptor.props.set_one( + crate::host::PROP_GL_RENDER_SUPPORTED, + Value::String(cs("needed")), + ); + assert!(plugin_supports_opengl(&inst)); + inst.plugin.descriptor.props.set_one( + crate::host::PROP_GL_RENDER_SUPPORTED, + Value::String(cs("false")), + ); + assert!(!plugin_supports_opengl(&inst)); + inst.plugin + .descriptor + .props + .set_one(crate::host::PROP_GL_RENDER_SUPPORTED, Value::Int(1)); + assert!(!plugin_supports_opengl(&inst), "非字符串 → false"); + } + + /// pick_input:effect_input_id 命中 src;命中但 src 缺失继续; + /// inputs 表命中;Source 回退;未知 clip → None。 + #[test] + fn pick_input_selection_rules() { + let src = f32_texture(1, 1, 0.25); + let matte = f32_texture(1, 1, 0.5); + + let mut j = job(f32_texture(1, 1, 0.0), Some(src.clone())); + assert!(pick_input("Source", &j).is_some()); + + // effect_input_id 命中但 src 缺失、inputs 表有同名 → 用表。 + j.src = None; + j.inputs = vec![("Source".into(), matte.clone())]; + assert!(pick_input("Source", &j).is_some()); + + // inputs 表命中非 Source clip。 + j.inputs.push(("Matte".into(), matte.clone())); + assert!(pick_input("Matte", &j).is_some()); + + // effect_input_id 命中但 src 缺失、无 inputs、非 Source → None。 + let j2 = job(f32_texture(1, 1, 0.0), None); + let mut j2 = j2; + j2.effect_input_id = Some("Matte".into()); + assert!(pick_input("Matte", &j2).is_none()); + + // Source 回退(effect_input_id 不是 Source、无 inputs、src 有)。 + j2.src = Some(src); + assert!(pick_input("Source", &j2).is_some()); + + // 未知 clip → None。 + assert!(pick_input("Nope", &j2).is_none()); + } + + /// begin/endSequenceRender:CPU 路径走非 GL 变体;GL 路径在无 GL + /// 环境下明确失败(有 GL 环境走 GL 变体)。 + #[test] + fn begin_end_sequence_cpu_and_gl() { + let inst = make_instance(vec![], vec![], &[], vec![]); + let range = OfxRangeD { + min: 0.0, + max: 10.0, + }; + assert!(begin_sequence(&inst, range, None).is_ok()); + assert!(end_sequence(&inst, range, None).is_ok()); + + let renderer: Renderer = std::sync::Arc::new(crate::render::GlKindMarker); + let began = begin_sequence(&inst, range, Some(renderer.clone())); + if began.is_ok() { + assert!(end_sequence(&inst, range, Some(renderer)).is_ok()); + } else { + println!("SKIP: GL 上下文不可用——GL 序列括号分支在无 GL 环境不可达"); + assert!(end_sequence(&inst, range, Some(renderer)).is_err()); + } + } + + /// render_frame CPU 全链路:输入收集、协商、RoD/RoI、render 与 + /// 输出装配;返回的 RoI 列表与输入 clip 配对(不含 Output)。 + #[test] + fn render_frame_cpu_happy_path() { + let inst = make_instance(vec![], vec![], &["Source", "Output"], vec![]); + let j = job(f32_texture(2, 2, 0.25), Some(f32_texture(2, 2, 0.75))); + let (out, rois) = render_frame(&inst, &j).expect("CPU render_frame 应成功"); + assert_eq!(rois.len(), 1, "Output clip 不参与 RoI 列表"); + assert_eq!(rois[0].0, "Source"); + // 输入 clip 已连接(property suite 可读)。 + assert!(matches!( + inst.clips[0].props.get(crate::host::PROP_CLIP_CONNECTED, 0), + Some(Value::Int(1)) + )); + // 假插件 render 未写像素 → 输出为零填充(Image::allocate 零初始化)。 + assert_eq!(first_pixel(&out), [0.0, 0.0, 0.0, 0.0]); + } + + /// render_frame:取消标记在任何插件调用前短路。 + #[test] + fn render_frame_cancel_short_circuits() { + let inst = make_instance(vec![], vec![], &["Source", "Output"], vec![]); + inst.cancel + .store(true, std::sync::atomic::Ordering::Relaxed); + let j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5))); + assert!(matches!(render_frame(&inst, &j), Err(Error::Failed(_)))); + } + + /// render_frame:目标纹理校验失败时在插件调用前返回。 + #[test] + fn render_frame_rejects_bad_destination() { + let inst = make_instance(vec![], vec![], &["Source", "Output"], vec![]); + let j = job(u8_texture(2, 2), None); + assert!(matches!(render_frame(&inst, &j), Err(Error::Failed(_)))); + + let params = render::VideoParams { + format: render::PIXEL_FORMAT_F32, + ..Default::default() + }; + let mut frame = crate::render::Frame::new(); + frame.set_video_params(params); + let j = job(Texture::wrap_frame(frame), None); + assert!(matches!(render_frame(&inst, &j), Err(Error::Invalid))); + } + + /// render_frame:实例无 Output clip → 明确失败。 + #[test] + fn render_frame_requires_output_clip() { + let inst = make_instance(vec![], vec![], &["Source"], vec![]); + let j = job(f32_texture(2, 2, 0.0), None); + assert!(matches!(render_frame(&inst, &j), Err(Error::Failed(_)))); + } + + /// render_frame:协商分量 RGBA/RGB/Alpha 与未知值。 + #[test] + fn render_frame_component_negotiation() { + for comps in [ + "OfxImageComponentRGB", + "OfxImageComponentAlpha", + "OfxImageComponentRGBA", + ] { + let inst = make_instance( + vec![], + vec![("test.components", Value::String(cs(comps)))], + &["Source", "Output"], + vec![], + ); + let j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5))); + render_frame(&inst, &j).expect("已知分量应可渲染"); + let got = inst.clips[1] + .props + .get(crate::image::K_IMAGE_EFFECT_PROP_COMPONENTS, 0); + assert!( + matches!(&got, Some(Value::String(s)) if s.to_string_lossy() == comps), + "{comps} 应写回 Output clip" + ); + } + + // 未知分量 → 协商失败。 + let inst = make_instance( + vec![], + vec![("test.components", Value::String(cs("OfxImageComponentBogus")))], + &["Source", "Output"], + vec![], + ); + let j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5))); + assert!(matches!(render_frame(&inst, &j), Err(Error::Failed(_)))); + } + + /// render_frame:协商位深 Byte/Short/Half/Float 与未知值。低位深 + /// 输出经 convert_depth 转回 F32 后装配;未知位深在 render 前失败。 + #[test] + fn render_frame_depth_negotiation() { + for depth in [ + "OfxBitDepthByte", + "OfxBitDepthShort", + "OfxBitDepthHalf", + "OfxBitDepthFloat", + ] { + let inst = make_instance( + vec![], + vec![("test.depth", Value::String(cs(depth)))], + &["Source", "Output"], + vec![], + ); + let j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5))); + render_frame(&inst, &j).unwrap_or_else(|e| panic!("{depth} 应可渲染:{e}")); + // 协商结果写回 Output clip(Format 号经 set_video_params 再现)。 + let got = inst.clips[1] + .props + .get(crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH, 0); + assert!( + matches!(&got, Some(Value::String(s)) if s.to_string_lossy() == depth), + "{depth} 应写回 Output clip" + ); + // 返回纹理恒为 F32(管线工作格式)。 + assert_eq!( + render::texture_get_params(&j.dst).format, + render::PIXEL_FORMAT_F32 + ); + } + + let inst = make_instance( + vec![], + vec![("test.depth", Value::String(cs("OfxBitDepthBogus")))], + &["Source", "Output"], + vec![], + ); + let j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5))); + assert!(matches!(render_frame(&inst, &j), Err(Error::Failed(_)))); + } + + /// render_frame:render action 失败原样上抛。 + #[test] + fn render_frame_propagates_render_failure() { + let inst = make_instance( + vec![], + vec![("test.render_status", Value::Int(crate::suites::status::FAILED))], + &["Source", "Output"], + vec![], + ); + let j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5))); + assert!(matches!(render_frame(&inst, &j), Err(Error::Failed(_)))); + } + + /// render_frame:isIdentity 命中 → 透传所引输入 clip 的帧;引用 + /// 未知 clip → 明确失败。 + #[test] + fn render_frame_identity_passthrough() { + let inst = make_instance( + vec![], + vec![ + ("test.identity_clip", Value::String(cs("Source"))), + ("test.identity_time", Value::Double(7.0)), + ], + &["Source", "Output"], + vec![], + ); + let src_tex = f32_texture(2, 2, 0.5); + inst.clips[0].set_input_texture(Some(src_tex.clone()), 0.0); + let j = job(f32_texture(2, 2, 0.25), Some(src_tex)); + let (out, _) = render_frame(&inst, &j).expect("透传应成功"); + assert_eq!(first_pixel(&out), [0.5, 0.5, 0.5, 0.5]); + + // 引用未知 clip → passthrough 失败。 + let inst = make_instance( + vec![], + vec![("test.identity_clip", Value::String(cs("Nope")))], + &["Source", "Output"], + vec![], + ); + let j = job( + f32_texture(2, 2, 0.25), + Some(f32_texture(2, 2, 0.5)), + ); + assert!(matches!(render_frame(&inst, &j), Err(Error::Failed(_)))); + } + + /// render_frame:GL 渲染器 + 插件声明 GL 支持。无 GL 环境回退 CPU + /// (决策链的 gl 名门失败);有 GL 环境走真实 GL 路径。 + #[test] + fn render_frame_gl_decision_and_path() { + let inst = make_instance( + vec![(crate::host::PROP_GL_RENDER_SUPPORTED, Value::String(cs("true")))], + vec![], + &["Source", "Output"], + vec![], + ); + let mut j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5))); + j.renderer = Some(std::sync::Arc::new(crate::render::GlKindMarker)); + if crate::gl_bridge::gl_available() { + // 真实 GL 路径:回读内容未清屏(未定义像素值)——只断言 + // 装配成功与输出格式/尺寸。 + let (out, _) = render_frame(&inst, &j).expect("GL 路径应成功"); + let params = render::texture_get_params(&out); + assert_eq!( + (params.width, params.height, params.format), + (2, 2, render::PIXEL_FORMAT_F32) + ); + } else { + println!("SKIP: GL 不可用(Linux/Windows stub)——仅覆盖 use_opengl 回退决策"); + let (out, _) = render_frame(&inst, &j).expect("GL 不可用应回退 CPU"); + assert_eq!(first_pixel(&out), [0.0, 0.0, 0.0, 0.0]); + } + } + + /// render_frame:GL render action 失败 → 回退 CPU(GL 环境下)。 + #[test] + fn render_frame_gl_failure_falls_back_to_cpu() { + if !crate::gl_bridge::gl_available() { + println!("SKIP: GL 不可用——GL 失败回退 CPU 分支不可达"); + return; + } + let inst = make_instance( + vec![(crate::host::PROP_GL_RENDER_SUPPORTED, Value::String(cs("true")))], + vec![("test.gl_fail", Value::Int(1))], + &["Source", "Output"], + vec![], + ); + let mut j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5))); + j.renderer = Some(std::sync::Arc::new(crate::render::GlKindMarker)); + let (out, _) = render_frame(&inst, &j).expect("GL 失败应回退 CPU 成功"); + assert_eq!(first_pixel(&out), [0.0, 0.0, 0.0, 0.0]); + } + + /// render_gl_frame:无 GL 上下文时明确失败(Linux/Windows stub 或 + /// CGL 创建失败);本机 GL 可用时跳过(该用例只验证失败路径)。 + #[test] + fn render_gl_frame_reports_unavailable_context() { + if crate::gl_bridge::gl_available() { + println!("SKIP: 本机 GL 可用——该用例只验证无 GL 时的明确失败"); + return; + } + let inst = make_instance(vec![], vec![], &["Source", "Output"], vec![]); + let j = job(f32_texture(2, 2, 0.0), None); + let params = read_dst(&j.dst).unwrap().0; + let res = render_gl_frame( + &inst, + &j, + RenderScale { x: 1.0, y: 1.0 }, + window(2.0, 2.0), + 2.0, + 2.0, + ¶ms, + ); + assert!(res.is_err(), "无 GL 环境 render_gl_frame 应失败"); + } + + /// apply_param_overrides:未知参数名与类型不符跳过;Double 标量 + /// 的 NaN/Inf 清洗与 Min/Max 钳制。 + #[test] + fn apply_param_overrides_scrubs_and_clamps() { + let def = ParamDef::new("gain", crate::param::TYPE_DOUBLE); + def.props + .set_one(crate::param::P_DEFAULT, Value::Double(0.25)); + def.props.set_one(crate::param::P_MIN, Value::Double(-1.0)); + def.props.set_one(crate::param::P_MAX, Value::Double(1.0)); + let inst = make_instance( + vec![], + vec![], + &[], + vec![Box::new(ParamInstance::from_def(def))], + ); + let gain = || inst.params.find("gain").unwrap().get(); + + // 未知 key + 类型不符的 key → 都跳过,值不变。 + apply_param_overrides( + &inst, + &[ + ("nope".into(), crate::node::Value::float(2.0)), + ("gain".into(), crate::node::Value::int(3)), + ], + ); + assert_eq!(gain(), ParamValue::Double([0.0, 0.0, 0.0], 1)); + + // 超上限 → 钳到 max。 + apply_param_overrides(&inst, &[("gain".into(), crate::node::Value::float(5.0))]); + assert_eq!(gain(), ParamValue::Double([1.0, 0.0, 0.0], 1)); + + // 超下限 → 钳到 min。 + apply_param_overrides(&inst, &[("gain".into(), crate::node::Value::float(-5.0))]); + assert_eq!(gain(), ParamValue::Double([-1.0, 0.0, 0.0], 1)); + + // NaN → 回退默认(0.25),再走钳制。 + apply_param_overrides(&inst, &[("gain".into(), crate::node::Value::float(f64::NAN))]); + assert_eq!(gain(), ParamValue::Double([0.25, 0.0, 0.0], 1)); + + // +Inf → 同样回退默认。 + apply_param_overrides(&inst, &[("gain".into(), crate::node::Value::float(f64::INFINITY))]); + assert_eq!(gain(), ParamValue::Double([0.25, 0.0, 0.0], 1)); + + // 多维 Double 不经标量清洗路径:Double2D 直接写入。 + let def2 = ParamDef::new("pos", crate::param::TYPE_DOUBLE2D); + let inst = make_instance( + vec![], + vec![], + &[], + vec![Box::new(ParamInstance::from_def(def2))], + ); + apply_param_overrides( + &inst, + &[("pos".into(), crate::node::Value::vec(&[1.0, 2.0]))], + ); + assert_eq!( + inst.params.find("pos").unwrap().get(), + ParamValue::Double([1.0, 2.0, 0.0], 2) + ); + } + + /// prop_double:Double/Int 提升/缺失默认 0。 + #[test] + fn prop_double_reads_double_int_and_default() { + let set = PropertySet::new(); + set.set_one("d", Value::Double(2.5)); + set.set_one("i", Value::Int(3)); + set.set_one("s", Value::String(cs("x"))); + assert_eq!(prop_double(&set, "d", 0), 2.5); + assert_eq!(prop_double(&set, "i", 0), 3.0); + assert_eq!(prop_double(&set, "s", 0), 0.0); + assert_eq!(prop_double(&set, "missing", 0), 0.0); + } + + /// write_output_frame:非 F32/尺寸不符错误;CPU 就地写回;GPU 经 + /// upload 回写;Planar 在 readback 阶段即失败(Planar 分支防御性 + /// 不可达,见测试注释)。 + #[test] + fn write_output_frame_validation_and_writeback() { + let image = f32_image(2, 2, 0.5); + + let mut u8dst = u8_texture(2, 2); + assert!(matches!( + write_output_frame(&mut u8dst, &image), + Err(Error::Failed(_)) + )); + + let mut baddst = f32_texture(3, 1, 0.0); + assert!(matches!( + write_output_frame(&mut baddst, &image), + Err(Error::Failed(_)) + )); + + let mut cpudst = f32_texture(2, 2, 0.0); + write_output_frame(&mut cpudst, &image).unwrap(); + let frame = render::texture_get_frame(&cpudst).unwrap(); + assert_eq!(frame.data.as_slice(), image.pixels()); + + // GPU:download 帧命中尺寸校验后经 upload 回写。 + let ctx = std::sync::Arc::new(FakeUpload { + uploads: std::sync::Mutex::new(Vec::new()), + fail: false, + }); + let mut gpudst = Texture::gpu(ctx.clone(), 11, 2, 2, oak_core::PixelFormat::F32); + write_output_frame(&mut gpudst, &image).unwrap(); + { + let uploads = ctx.uploads.lock().unwrap(); + assert_eq!(uploads.len(), 1); + assert_eq!(uploads[0].0, 11); + assert_eq!(uploads[0].1.data.as_slice(), image.pixels()); + } + + // 上传失败 → Failed。 + let ctx = std::sync::Arc::new(FakeUpload { + uploads: std::sync::Mutex::new(Vec::new()), + fail: true, + }); + let mut gpudst = Texture::gpu(ctx, 12, 2, 2, oak_core::PixelFormat::F32); + assert!(matches!( + write_output_frame(&mut gpudst, &image), + Err(Error::Failed(_)) + )); + + // Planar:texture_get_frame 在到达 Planar match 分支前即失败 + // (to_frame 对未解析平面纹理返回 State)——该防御分支当前 + // 不可达,这里锁定"平面目标被拒绝"的行为。 + let planar = Texture::wrap_planar(oak_core::texture::PlanarTexture::new( + std::sync::Arc::new(FakeUpload { + uploads: std::sync::Mutex::new(Vec::new()), + fail: false, + }), + oak_core::texture::PlanarFormat::Nv12, + (2, 2), + (1, 2), + oak_core::backend::YuvTransform::bt601_limited(), + (1, 1), + )); + let mut planardst = planar; + assert!(matches!( + write_output_frame(&mut planardst, &image), + Err(Error::Failed(_)) + )); + } + + /// zip_rois:跳过 Output、按 clips 顺序配对、RoI 不足时默认零矩形。 + #[test] + fn zip_rois_pairs_inputs_only() { + let inst = make_instance(vec![], vec![], &["Source", "Matte", "Output"], vec![]); + let rect = OfxRectD { + x1: 1.0, + y1: 2.0, + x2: 3.0, + y2: 4.0, + }; + let zipped = zip_rois(&inst, &[rect]); + assert_eq!(zipped.len(), 2, "Output 不计入"); + assert_eq!(zipped[0].0, "Source"); + assert_eq!(zipped[0].1.x2, 3.0); + assert_eq!(zipped[1].0, "Matte"); + assert_eq!(zipped[1].1.x1, 0.0, "RoI 不足时默认矩形"); + + let empty = zip_rois(&inst, &[]); + assert_eq!(empty.len(), 2); + assert_eq!(empty[0].1.y2, 0.0); + } + + /// OAK_OFX_TRACE 开启时 use_opengl 决策与 CPU 输出 dump 的 trace + /// 分支(打印点覆盖)。串行化并还原环境变量。 + #[test] + fn trace_branches_in_render_frame() { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev = std::env::var_os("OAK_OFX_TRACE"); + std::env::set_var("OAK_OFX_TRACE", "1"); + let body = std::panic::catch_unwind(|| { + // 插件未声明 GL → use_opengl 决策为 false,但 trace 分支 + // 在 renderer 是 GL 时执行(打印 raw GL prop)。 + let inst = make_instance(vec![], vec![], &["Source", "Output"], vec![]); + let mut j = job(f32_texture(2, 2, 0.0), Some(f32_texture(2, 2, 0.5))); + j.renderer = Some(std::sync::Arc::new(crate::render::GlKindMarker)); + render_frame(&inst, &j).expect("CPU 回退应成功"); + }); + match prev { + Some(v) => std::env::set_var("OAK_OFX_TRACE", v), + None => std::env::remove_var("OAK_OFX_TRACE"), + } + if let Err(p) = body { + std::panic::resume_unwind(p); + } + } + + struct FakeUpload { + uploads: std::sync::Mutex>, + fail: bool, + } + + impl oak_core::backend::GpuContextLike for FakeUpload { + fn kind(&self) -> oak_core::backend::BackendKind { + oak_core::backend::BackendKind::Cpu + } + fn destroy_texture(&self, _token: u64) {} + fn upload(&self, token: u64, frame: &crate::render::Frame) -> oak_render::error::Result<()> { + if self.fail { + return Err(oak_render::error::Error::State); + } + self.uploads + .lock() + .unwrap_or_else(|e| e.into_inner()) + .push((token, frame.clone())); + Ok(()) + } + fn download(&self, _token: u64) -> oak_render::error::Result { + Ok(f32_frame(2, 2, 0.0)) + } + fn blit( + &self, + _src: u64, + _dst: u64, + _processor: Option<&oak_core::color::ColorProcessor>, + ) -> oak_render::error::Result<()> { + Ok(()) + } + } +} diff --git a/crates/oak-plugin/src/suites/gl_render.rs b/crates/oak-plugin/src/suites/gl_render.rs index 36cff2e9b..f10487a5d 100644 --- a/crates/oak-plugin/src/suites/gl_render.rs +++ b/crates/oak-plugin/src/suites/gl_render.rs @@ -506,6 +506,106 @@ mod tests { CString::new(s).unwrap() } + /// GL 渲染 suite 的存活表是进程全局状态:本模块所有触碰它的用例 + /// 经此锁串行(否则 purge_leftovers 会踩其他用例的登记条目)。 + fn suite_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn clip(name: &str) -> ClipInstance { + ClipInstance::from_descriptor(&crate::descriptor::ClipDescriptor::new(name)) + } + + fn clip_handle(c: &ClipInstance) -> *mut c_void { + tag::make(&c.props as *const PropertySet, tag::CLIP) + } + + fn f32_frame(w: i32, h: i32, fill: f32) -> crate::render::Frame { + let params = crate::render::VideoParams { + width: w, + height: h, + format: crate::render::PIXEL_FORMAT_F32, + ..Default::default() + }; + let mut frame = crate::render::Frame::new(); + frame.set_video_params(params); + assert!(frame.allocate()); + for c in frame.data.as_chunks_mut::<4>().0 { + c.copy_from_slice(&fill.to_le_bytes()); + } + frame + } + + fn f32_texture(w: i32, h: i32, fill: f32) -> crate::render::Texture { + crate::render::Texture::wrap_frame(f32_frame(w, h, fill)) + } + + struct FakeGpu; + + impl oak_core::backend::GpuContextLike for FakeGpu { + fn kind(&self) -> oak_core::backend::BackendKind { + oak_core::backend::BackendKind::Cpu + } + fn destroy_texture(&self, _token: u64) {} + fn upload( + &self, + _token: u64, + _frame: &oak_core::texture::Frame, + ) -> oak_render::error::Result<()> { + Ok(()) + } + fn download(&self, _token: u64) -> oak_render::error::Result { + Ok(oak_core::texture::Frame::new()) + } + fn blit( + &self, + _src: u64, + _dst: u64, + _processor: Option<&oak_core::color::ColorProcessor>, + ) -> oak_render::error::Result<()> { + Ok(()) + } + } + + fn gl_ctx(output: crate::render::Texture, index: Option) -> crate::suites::GlCtx { + crate::suites::GlCtx { + renderer: std::sync::Arc::new(FakeGpu), + output_texture: output, + gl_pixel_depth: "OfxBitDepthFloat", + output_gl_texture: index, + } + } + + /// 从 clipLoadTexture 写出的句柄读属性集。 + fn handle_props(handle: *mut c_void) -> PropertySet { + assert_eq!(tag::kind(handle), tag::PROPERTY_SET, "纹理句柄标签 0"); + unsafe { (*(tag::strip(handle))).clone() } + } + + // `Value` 未实现 PartialEq(Pointer 无法比较)——按种类解包断言。 + + fn prop_int(props: &PropertySet, name: &str, index: usize) -> i32 { + match props.get(name, index) { + Some(Value::Int(i)) => i, + other => panic!("{name}[{index}] 应为 Int,got {other:?}"), + } + } + + fn prop_double(props: &PropertySet, name: &str, index: usize) -> f64 { + match props.get(name, index) { + Some(Value::Double(d)) => d, + other => panic!("{name}[{index}] 应为 Double,got {other:?}"), + } + } + + fn prop_string(props: &PropertySet, name: &str, index: usize) -> String { + match props.get(name, index) { + Some(Value::String(s)) => s.to_string_lossy().into_owned(), + other => panic!("{name}[{index}] 应为 String,got {other:?}"), + } + } + /// GL 像素深度协商矩阵(ofxGPURender.h kOfxOpenGLPropPixelDepth): /// 未声明/含 Float → 管线 F32 可行;声明且不含 Float → None /// (GL 模式不可行,回退 CPU)。 @@ -540,5 +640,578 @@ mod tests { vec![Value::String(cs("OfxBitDepthFloat"))], ); assert_eq!(pick_gl_pixel_depth(&props4), Some("OfxBitDepthFloat")); + + // 列表存在但元素非字符串 → 跳过,不含 Float → None。 + let props5 = PropertySet::new(); + props5.define(crate::host::PROP_GL_PIXEL_DEPTH, vec![Value::Int(4)]); + assert_eq!(pick_gl_pixel_depth(&props5), None); + } + + /// 纹理请求格式 → 期望分量矩阵(NULL/未知 → 宿主自选)。 + #[test] + fn format_components_matrix() { + use crate::image::Components; + assert_eq!(format_components(Some(GL_FORMAT_RGBA)), Some(Components::Rgba)); + assert_eq!(format_components(None), Some(Components::Rgba)); + assert_eq!(format_components(Some(GL_FORMAT_RGB)), Some(Components::Rgb)); + assert_eq!(format_components(Some(GL_FORMAT_ALPHA)), Some(Components::Alpha)); + assert_eq!(format_components(Some(GL_FORMAT_LUMINANCE)), None); + assert_eq!(format_components(Some(GL_FORMAT_LUMINANCE_ALPHA)), None); + assert_eq!(format_components(Some("bogus")), None); + } + + /// make_texture_props:ofxGPURender.h 规定的 12 个属性齐全且值正确。 + #[test] + fn make_texture_props_fields() { + let props = make_texture_props( + 4.0, + 2.0, + crate::image::Components::Rgb, + "OfxBitDepthFloat", + PREMULT_NONE, + 1.5, + crate::instance::RenderScale { x: 2.0, y: 3.0 }, + 48, + 7, + ); + assert_eq!(prop_int(&props, GL_TEXTURE_INDEX, 0), 7); + assert_eq!(prop_int(&props, GL_TEXTURE_TARGET, 0), GL_TEXTURE_2D); + assert_eq!( + prop_string(&props, crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH, 0), + "OfxBitDepthFloat" + ); + assert_eq!( + prop_string(&props, crate::image::K_IMAGE_EFFECT_PROP_COMPONENTS, 0), + "OfxImageComponentRGB" + ); + assert_eq!( + prop_string(&props, "OfxImageEffectPropPreMultiplication", 0), + PREMULT_NONE + ); + assert_eq!(prop_double(&props, crate::host::PROP_RENDER_SCALE, 0), 2.0); + assert_eq!(prop_double(&props, crate::host::PROP_RENDER_SCALE, 1), 3.0); + assert_eq!( + prop_double(&props, "OfxImagePropPixelAspectRatio", 0), + 1.5 + ); + for (i, want) in [0, 0, 4, 2].iter().enumerate() { + assert_eq!( + prop_int(&props, crate::image::K_IMAGE_PROP_BOUNDS, i), + *want, + "Bounds[{i}]" + ); + assert_eq!( + prop_int(&props, crate::image::K_IMAGE_PROP_ROD, i), + *want, + "RoD[{i}]" + ); + } + assert_eq!(prop_int(&props, crate::image::K_IMAGE_PROP_ROW_BYTES, 0), 48); + assert_eq!( + prop_string(&props, "OfxImagePropField", 0), + "OfxFieldNone" + ); + assert!(matches!( + props.get(crate::image::K_IMAGE_PROP_UNIQUE_ID, 0), + Some(Value::String(_)) + )); + } + + /// rect_props:Double 矩形四舍五入为 Int×4(负半轴远离零)。 + #[test] + fn rect_props_rounds_to_int() { + let props = PropertySet::new(); + rect_props( + &props, + "r", + crate::instance::OfxRectD { + x1: 1.4, + y1: -0.6, + x2: 3.5, + y2: 2.49, + }, + ); + for (i, want) in [1, -1, 4, 2].iter().enumerate() { + assert_eq!(prop_int(&props, "r", i), *want, "r[{i}]"); + } + } + + /// clip_string / clip_par:字符串与非字符串回退、PAR 缺失默认 1.0。 + #[test] + fn clip_string_and_par_fallbacks() { + let c = clip("Source"); + assert_eq!(clip_string(&c, "missing", "def"), "def"); + assert_eq!(clip_par(&c), 1.0); + + c.props.set_one("s", Value::String(cs("xyz"))); + assert_eq!(clip_string(&c, "s", "def"), "xyz"); + c.props.set_one("s2", Value::Int(3)); + assert_eq!(clip_string(&c, "s2", "def"), "def"); + + c.props + .set_one("OfxImagePropPixelAspectRatio", Value::Double(2.0)); + assert_eq!(clip_par(&c), 2.0); + c.props + .set_one("OfxImagePropPixelAspectRatio", Value::Int(2)); + assert_eq!(clip_par(&c), 1.0, "非 Double 回退 1.0"); + } + + /// texture_size / image_width / image_height。 + #[test] + fn texture_size_and_image_dims() { + assert_eq!(texture_size(&crate::render::Texture::dummy()), (0.0, 0.0)); + assert_eq!(texture_size(&f32_texture(3, 2, 0.0)), (3.0, 2.0)); + + let img = Image::allocate( + crate::image::BitDepth::Float, + crate::image::Components::Rgba, + crate::instance::OfxRectD { + x1: 0.0, + y1: 0.0, + x2: 5.0, + y2: 4.0, + }, + ); + assert_eq!(image_width(&img), 5.0); + assert_eq!(image_height(&img), 4.0); + } + + /// resolve_clip:空句柄/非 CLIP 标签 → BadHandle;CLIP 标签有效。 + #[test] + fn resolve_clip_handle_rules() { + assert!(matches!( + resolve_clip(std::ptr::null_mut()), + Err(status::ERR_BAD_HANDLE) + )); + let props = PropertySet::new(); + let wrong = tag::make(&props as *const PropertySet, tag::PROPERTY_SET); + assert!(matches!(resolve_clip(wrong), Err(status::ERR_BAD_HANDLE))); + let c = clip("Source"); + assert!(resolve_clip(clip_handle(&c)).is_ok()); + } + + /// clipLoadTexture:入口防御(空 out / 空 clip / 非 clip 句柄 / + /// 无 GL 上下文)。 + #[test] + fn clip_load_texture_entry_guards() { + let _lock = suite_lock(); + crate::suites::set_gl_ctx(None); + let s = suite_v1(); + let c = clip("Source"); + let name = cs(GL_FORMAT_RGBA); + let mut sentinel = 0u8; + let mut out: *mut c_void = &mut sentinel as *mut u8 as *mut c_void; + unsafe { + // 空 out → BadHandle(且不触碰返回值)。 + assert_eq!( + (s.clip_load_texture)( + clip_handle(&c), + 0.0, + name.as_ptr(), + std::ptr::null(), + std::ptr::null_mut(), + ), + status::ERR_BAD_HANDLE + ); + // 空 clip 句柄 → BadHandle。 + assert_eq!( + (s.clip_load_texture)( + std::ptr::null_mut(), + 0.0, + name.as_ptr(), + std::ptr::null(), + &mut out, + ), + status::ERR_BAD_HANDLE + ); + assert!(out.is_null(), "入口先把 out 置空"); + // 非 clip 标签 → BadHandle。 + let wrong = tag::make(&c.props as *const PropertySet, tag::PROPERTY_SET); + assert_eq!( + (s.clip_load_texture)(wrong, 0.0, name.as_ptr(), std::ptr::null(), &mut out), + status::ERR_BAD_HANDLE + ); + // 无 GL 上下文 → MissingHostFeature(GL suite 只在 GL 渲染期可达)。 + assert_eq!( + (s.clip_load_texture)( + clip_handle(&c), + 0.0, + name.as_ptr(), + std::ptr::null(), + &mut out, + ), + status::ERR_MISSING_HOST_FEATURE + ); + } + } + + /// clipLoadTexture(Output):忽略 format,返回已附着的输出纹理句柄 + /// (真实 GL 名经 OpenGLTextureIndex 上报);free 只摘句柄。 + #[test] + fn clip_load_texture_output_clip() { + let _lock = suite_lock(); + let s = suite_v1(); + let out_clip = clip("Output"); + let tex = f32_texture(4, 2, 0.25); + crate::suites::set_gl_ctx(Some(gl_ctx(tex, Some(9)))); + let rgb = cs(GL_FORMAT_RGB); // Output 忽略请求格式。 + let mut handle: *mut c_void = std::ptr::null_mut(); + unsafe { + assert_eq!( + (s.clip_load_texture)( + clip_handle(&out_clip), + 0.0, + rgb.as_ptr(), + std::ptr::null(), + &mut handle, + ), + status::OK + ); + } + let props = handle_props(handle); + assert_eq!(prop_int(&props, GL_TEXTURE_INDEX, 0), 9); + assert_eq!(prop_int(&props, GL_TEXTURE_TARGET, 0), GL_TEXTURE_2D); + assert_eq!( + prop_string(&props, crate::image::K_IMAGE_EFFECT_PROP_COMPONENTS, 0), + "OfxImageComponentRGBA" + ); + assert_eq!(prop_int(&props, crate::image::K_IMAGE_PROP_BOUNDS, 2), 4); + // 行字节 = w × RGBA × F32;OpenGLTextureIndex=0 变体(CPU 回退)。 + assert_eq!( + prop_int(&props, crate::image::K_IMAGE_PROP_ROW_BYTES, 0), + 4 * 4 * 4 + ); + assert!(LIVE_TEXTURES.lock().unwrap().contains_key(&(tag::strip(handle) as usize))); + + // free:输出 clip 只释放句柄、不删纹理(宿主还要回读)。 + unsafe { + assert_eq!((s.clip_free_texture)(handle), status::OK); + // 二次释放 / 空句柄 → BadHandle。 + assert_eq!((s.clip_free_texture)(handle), status::ERR_BAD_HANDLE); + assert_eq!( + (s.clip_free_texture)(std::ptr::null_mut()), + status::ERR_BAD_HANDLE + ); + } + assert!(!LIVE_TEXTURES.lock().unwrap().contains_key(&(tag::strip(handle) as usize))); + + // output_gl_texture = None → 索引 0(CPU 回退语义)。 + crate::suites::set_gl_ctx(Some(gl_ctx(f32_texture(2, 2, 0.0), None))); + let mut h0: *mut c_void = std::ptr::null_mut(); + unsafe { + assert_eq!( + (s.clip_load_texture)( + clip_handle(&out_clip), + 0.0, + std::ptr::null(), + std::ptr::null(), + &mut h0, + ), + status::OK + ); + } + assert_eq!(prop_int(&handle_props(h0), GL_TEXTURE_INDEX, 0), 0); + unsafe { + assert_eq!((s.clip_free_texture)(h0), status::OK); + } + crate::suites::set_gl_ctx(None); + } + + /// clipLoadTexture(Output):附着纹理为 dummy/0 尺寸 → BadHandle。 + #[test] + fn clip_load_texture_output_rejects_empty_texture() { + let _lock = suite_lock(); + let s = suite_v1(); + let out_clip = clip("Output"); + crate::suites::set_gl_ctx(Some(gl_ctx(crate::render::Texture::dummy(), None))); + let mut handle: *mut c_void = std::ptr::null_mut(); + unsafe { + assert_eq!( + (s.clip_load_texture)( + clip_handle(&out_clip), + 0.0, + std::ptr::null(), + std::ptr::null(), + &mut handle, + ), + status::ERR_BAD_HANDLE + ); + } + crate::suites::set_gl_ctx(None); + } + + /// clipLoadTexture(输入 clip):整帧限制、格式匹配、协商属性与 + /// 释放;GL 不可用时 OpenGLTextureIndex=0(CPU 回退)。 + #[test] + fn clip_load_texture_input_clip() { + let _lock = suite_lock(); + let s = suite_v1(); + let c = clip("Source"); + c.set_input_texture(Some(f32_texture(2, 2, 0.5)), 0.0); + crate::suites::set_gl_ctx(Some(gl_ctx(crate::render::Texture::dummy(), None))); + + // 子区域请求(region 非空)→ Failed(Phase 2 仅整帧)。 + let region = crate::instance::OfxRectD::default(); + unsafe { + let mut h: *mut c_void = std::ptr::null_mut(); + assert_eq!( + (s.clip_load_texture)( + clip_handle(&c), + 0.0, + std::ptr::null(), + ®ion as *const crate::instance::OfxRectD as *const c_void, + &mut h, + ), + status::FAILED + ); + } + + // 未挂输入纹理 → Failed(fetch_image NotFound 映射)。 + let empty = clip("Source"); + unsafe { + let mut h: *mut c_void = std::ptr::null_mut(); + assert_eq!( + (s.clip_load_texture)( + clip_handle(&empty), + 0.0, + std::ptr::null(), + std::ptr::null(), + &mut h, + ), + status::FAILED + ); + } + + // 分量不符:请求 RGB 但图像 RGBA(协商默认)→ Failed。 + let rgb = cs(GL_FORMAT_RGB); + unsafe { + let mut h: *mut c_void = std::ptr::null_mut(); + assert_eq!( + (s.clip_load_texture)( + clip_handle(&c), + 0.0, + rgb.as_ptr(), + std::ptr::null(), + &mut h, + ), + status::FAILED + ); + } + + // 成功(format=NULL):纹理句柄属性齐全。 + let mut handle: *mut c_void = std::ptr::null_mut(); + unsafe { + assert_eq!( + (s.clip_load_texture)( + clip_handle(&c), + 0.0, + std::ptr::null(), + std::ptr::null(), + &mut handle, + ), + status::OK + ); + } + let props = handle_props(handle); + assert_eq!( + prop_string(&props, crate::image::K_IMAGE_EFFECT_PROP_COMPONENTS, 0), + "OfxImageComponentRGBA" + ); + assert_eq!( + prop_string(&props, crate::image::K_IMAGE_EFFECT_PROP_PIXEL_DEPTH, 0), + "OfxBitDepthFloat" + ); + assert_eq!( + prop_int(&props, crate::image::K_IMAGE_PROP_ROW_BYTES, 0), + 2 * 4 * 4 + ); + assert_eq!(prop_int(&props, GL_TEXTURE_TARGET, 0), GL_TEXTURE_2D); + // 非 GL(Linux/Windows stub)→ 索引 0;macOS 有 GL 时 > 0。 + let index = match props.get(GL_TEXTURE_INDEX, 0) { + Some(Value::Int(i)) => i, + other => panic!("OpenGLTextureIndex 应为 Int,got {other:?}"), + }; + if crate::gl_bridge::gl_available() { + assert!(index > 0, "GL 可用时输入纹理应有真实 GL 名"); + } else { + assert_eq!(index, 0, "无 GL 时索引为 0"); + } + + // 请求 RGBA(显式)与请求 Luminance 族(None = 宿主自选)都成功。 + let rgba = cs(GL_FORMAT_RGBA); + let lum = cs(GL_FORMAT_LUMINANCE); + let lum_a = cs(GL_FORMAT_LUMINANCE_ALPHA); + let mut h2: *mut c_void = std::ptr::null_mut(); + let mut h3: *mut c_void = std::ptr::null_mut(); + let mut h4: *mut c_void = std::ptr::null_mut(); + unsafe { + assert_eq!( + (s.clip_load_texture)(clip_handle(&c), 0.0, rgba.as_ptr(), std::ptr::null(), &mut h2), + status::OK + ); + assert_eq!( + (s.clip_load_texture)(clip_handle(&c), 0.0, lum.as_ptr(), std::ptr::null(), &mut h3), + status::OK + ); + assert_eq!( + (s.clip_load_texture)(clip_handle(&c), 0.0, lum_a.as_ptr(), std::ptr::null(), &mut h4), + status::OK + ); + // 非 UTF-8 format 串按 None 处理(宿主自选)→ OK。 + let bad = CString::new(vec![0xFFu8]).unwrap(); + let mut h5: *mut c_void = std::ptr::null_mut(); + assert_eq!( + (s.clip_load_texture)( + clip_handle(&c), + 0.0, + bad.as_ptr(), + std::ptr::null(), + &mut h5, + ), + status::OK + ); + // free 输入纹理:摘表(GL 模式同时删真实 GL 纹理)。 + assert_eq!((s.clip_free_texture)(h2), status::OK); + assert_eq!((s.clip_free_texture)(h3), status::OK); + assert_eq!((s.clip_free_texture)(h4), status::OK); + assert_eq!((s.clip_free_texture)(h5), status::OK); + assert_eq!((s.clip_free_texture)(handle), status::OK); + } + crate::suites::set_gl_ctx(None); + } + + /// clipLoadTexture:渲染上下文的 scale 进入纹理属性;0 尺寸图像 + /// → Failed。 + #[test] + fn clip_load_texture_uses_render_scale_and_rejects_zero_image() { + let _lock = suite_lock(); + let s = suite_v1(); + + // 有 render ctx:scale 写入纹理句柄的 RenderScale 属性。 + let c = clip("Source"); + c.set_input_texture(Some(f32_texture(2, 2, 0.0)), 0.0); + crate::suites::set_render_ctx(Some(crate::suites::RenderCtx { + time: 1.0, + scale: crate::instance::RenderScale { x: 2.0, y: 2.0 }, + range: crate::instance::OfxRangeD { min: 0.0, max: 1.0 }, + })); + crate::suites::set_gl_ctx(Some(gl_ctx(crate::render::Texture::dummy(), None))); + let mut handle: *mut c_void = std::ptr::null_mut(); + unsafe { + assert_eq!( + (s.clip_load_texture)( + clip_handle(&c), + 0.0, + std::ptr::null(), + std::ptr::null(), + &mut handle, + ), + status::OK + ); + } + let props = handle_props(handle); + assert_eq!(prop_double(&props, crate::host::PROP_RENDER_SCALE, 0), 2.0); + unsafe { + assert_eq!((s.clip_free_texture)(handle), status::OK); + } + crate::suites::set_render_ctx(None); + + // 0×0 帧(非 dummy:data 非空)→ image_width == 0 → Failed。 + let zero = clip("Source"); + let params = crate::render::VideoParams { + format: crate::render::PIXEL_FORMAT_F32, + ..Default::default() + }; + let mut frame = crate::render::Frame::new(); + frame.set_video_params(params); + frame.data = vec![0u8; 16]; + zero.set_input_texture(Some(crate::render::Texture::wrap_frame(frame)), 0.0); + let mut h: *mut c_void = std::ptr::null_mut(); + unsafe { + assert_eq!( + (s.clip_load_texture)( + clip_handle(&zero), + 0.0, + std::ptr::null(), + std::ptr::null(), + &mut h, + ), + status::FAILED + ); + } + crate::suites::set_gl_ctx(None); + } + + /// purge_leftovers:释放残留输入句柄、保留输出句柄(宿主还要回读)。 + #[test] + fn purge_leftovers_keeps_outputs() { + let _lock = suite_lock(); + let s = suite_v1(); + let input = clip("Source"); + input.set_input_texture(Some(f32_texture(2, 2, 0.0)), 0.0); + let out_clip = clip("Output"); + crate::suites::set_gl_ctx(Some(gl_ctx(f32_texture(2, 2, 0.0), Some(1)))); + let mut input_handle: *mut c_void = std::ptr::null_mut(); + let mut out_handle: *mut c_void = std::ptr::null_mut(); + unsafe { + assert_eq!( + (s.clip_load_texture)( + clip_handle(&input), + 0.0, + std::ptr::null(), + std::ptr::null(), + &mut input_handle, + ), + status::OK + ); + assert_eq!( + (s.clip_load_texture)( + clip_handle(&out_clip), + 0.0, + std::ptr::null(), + std::ptr::null(), + &mut out_handle, + ), + status::OK + ); + } + assert!(LIVE_TEXTURES.lock().unwrap().contains_key(&(tag::strip(input_handle) as usize))); + assert!(LIVE_TEXTURES.lock().unwrap().contains_key(&(tag::strip(out_handle) as usize))); + + purge_leftovers(); + let live = LIVE_TEXTURES.lock().unwrap(); + assert!( + !live.contains_key(&(tag::strip(input_handle) as usize)), + "输入句柄应被兜底释放" + ); + assert!( + live.contains_key(&(tag::strip(out_handle) as usize)), + "输出句柄应保留" + ); + drop(live); + unsafe { + assert_eq!((s.clip_free_texture)(out_handle), status::OK); + } + crate::suites::set_gl_ctx(None); + } + + /// delete_gl_texture_if_gl:无 GL 上下文 no-op;有上下文时经桥删除 + /// (GL 不可用环境下桥为 stub,仅验证不 panic)。 + #[test] + fn delete_gl_texture_if_gl_branches() { + let _lock = suite_lock(); + crate::suites::set_gl_ctx(None); + delete_gl_texture_if_gl(3); + crate::suites::set_gl_ctx(Some(gl_ctx(crate::render::Texture::dummy(), None))); + delete_gl_texture_if_gl(4); + crate::suites::set_gl_ctx(None); + } + + /// flushResources:宿主不缓存 GPU 资源 → ReplyDefault。 + #[test] + fn flush_resources_replies_default() { + unsafe { + assert_eq!((suite_v1().flush_resources)(), status::REPLY_DEFAULT); + } } } diff --git a/crates/oak-plugin/src/suites/property.rs b/crates/oak-plugin/src/suites/property.rs index b51fe85a2..5e11fdff2 100644 --- a/crates/oak-plugin/src/suites/property.rs +++ b/crates/oak-plugin/src/suites/property.rs @@ -769,4 +769,790 @@ pub fn suite_v1() -> &'static PropertySuiteV1 { get_dimension: prop_get_dimension, }) } - get_dimension: prop_get_dimension, + +#[cfg(test)] +mod tests { + use super::*; + + fn handle(set: &PropertySet) -> *mut c_void { + set as *const PropertySet as *mut c_void + } + + fn cs(s: &str) -> CString { + CString::new(s).unwrap() + } + + /// `Value` 未实现 PartialEq(Pointer 无法比较)——用 Debug 串比较。 + fn val_eq(a: &Value, b: &Value) -> bool { + format!("{a:?}") == format!("{b:?}") + } + + fn assert_value(set: &PropertySet, name: &str, index: usize, want: &Value) { + assert!( + val_eq(set.get(name, index).as_ref().expect("属性应存在"), want), + "{name}[{index}] 值不符" + ); + } + + /// 空句柄 → BadHandle;空/非 UTF-8 属性名 → ErrValue。 + #[test] + fn bad_handles_and_bad_names() { + let s = suite_v1(); + let set = PropertySet::new(); + let name = cs("a"); + let mut iv = 0; + let mut dv = 0.0; + let mut sv: *mut c_char = std::ptr::null_mut(); + let mut pv: *mut c_void = std::ptr::null_mut(); + unsafe { + assert_eq!( + (s.set_int)(std::ptr::null_mut(), name.as_ptr(), 0, 1), + status::ERR_BAD_HANDLE + ); + assert_eq!( + (s.get_int)(std::ptr::null_mut(), name.as_ptr(), 0, &mut iv), + status::ERR_BAD_HANDLE + ); + // 空属性名。 + assert_eq!((s.set_int)(handle(&set), std::ptr::null(), 0, 1), status::ERR_VALUE); + assert_eq!( + (s.get_int)(handle(&set), std::ptr::null(), 0, &mut iv), + status::ERR_VALUE + ); + assert_eq!( + (s.set_double)(handle(&set), std::ptr::null(), 0, 1.0), + status::ERR_VALUE + ); + assert_eq!( + (s.set_string)(handle(&set), std::ptr::null(), 0, name.as_ptr()), + status::ERR_VALUE + ); + assert_eq!( + (s.get_double)(handle(&set), std::ptr::null(), 0, &mut dv), + status::ERR_VALUE + ); + assert_eq!( + (s.get_string)(handle(&set), std::ptr::null(), 0, &mut sv), + status::ERR_VALUE + ); + assert_eq!( + (s.get_pointer)(handle(&set), std::ptr::null(), 0, &mut pv), + status::ERR_VALUE + ); + assert_eq!( + (s.get_dimension)(handle(&set), std::ptr::null(), &mut iv), + status::ERR_VALUE + ); + assert_eq!( + (s.reset)(handle(&set), std::ptr::null()), + status::ERR_VALUE + ); + // 非 UTF-8 属性名(0xFF 后接 NUL)。 + let bad: [c_char; 2] = [0xFFu8 as c_char, 0]; + assert_eq!( + (s.set_int)(handle(&set), bad.as_ptr(), 0, 1), + status::ERR_VALUE + ); + assert_eq!( + (s.get_int)(handle(&set), bad.as_ptr(), 0, &mut iv), + status::ERR_VALUE + ); + } + } + + /// 未定义属性 get → ErrUnknown;define 后空数组维度 0 且越界读 + /// → BadIndex;负索引 → BadIndex。 + #[test] + fn get_missing_empty_and_bounds() { + let s = suite_v1(); + let set = PropertySet::new(); + let n = cs("a"); + let mut iv = 0; + unsafe { + assert_eq!( + (s.get_int)(handle(&set), n.as_ptr(), 0, &mut iv), + status::ERR_UNKNOWN + ); + assert_eq!( + (s.get_int)(handle(&set), n.as_ptr(), -1, &mut iv), + status::ERR_UNKNOWN, + "未定义属性先报 Unknown(HS 先查类型)" + ); + } + set.define("a", vec![]); + unsafe { + assert_eq!( + (s.get_int)(handle(&set), n.as_ptr(), 0, &mut iv), + status::ERR_BAD_INDEX + ); + } + set.define("a", vec![Value::Int(3)]); + unsafe { + assert_eq!( + (s.get_int)(handle(&set), n.as_ptr(), -1, &mut iv), + status::ERR_BAD_INDEX + ); + assert_eq!( + (s.get_int)(handle(&set), n.as_ptr(), 1, &mut iv), + status::ERR_BAD_INDEX + ); + } + } + + /// propSet 隐式创建:index 2 建出维度 3 的属性且前导槽位同值; + /// 已定义数组 index == 维度追加、越界 → BadIndex。 + #[test] + fn set_creates_and_appends() { + let s = suite_v1(); + let set = PropertySet::new(); + let n = cs("a"); + unsafe { + assert_eq!((s.set_int)(handle(&set), n.as_ptr(), 2, 7), 0); + } + assert_eq!(set.dimension("a"), 3); + assert_value(&set, "a", 0, &Value::Int(7)); + assert_value(&set, "a", 2, &Value::Int(7)); + + // index == 维度 → 追加(协商属性逐位增长语义)。 + unsafe { + assert_eq!((s.set_int)(handle(&set), n.as_ptr(), 3, 9), 0); + } + assert_eq!(set.dimension("a"), 4); + assert_value(&set, "a", 3, &Value::Int(9)); + + // 越界(跳过追加位)→ BadIndex。 + unsafe { + assert_eq!( + (s.set_int)(handle(&set), n.as_ptr(), 9, 1), + status::ERR_BAD_INDEX + ); + } + assert_eq!(set.dimension("a"), 4); + } + + /// 空预定义数组:index 0 推入;index > 0 → BadIndex。 + #[test] + fn set_into_empty_predefined_array() { + let s = suite_v1(); + let set = PropertySet::new(); + set.define("empty", vec![]); + let n = cs("empty"); + unsafe { + assert_eq!( + (s.set_int)(handle(&set), n.as_ptr(), 1, 1), + status::ERR_BAD_INDEX + ); + assert_eq!((s.set_int)(handle(&set), n.as_ptr(), 0, 5), 0); + } + assert_value(&set, "empty", 0, &Value::Int(5)); + } + + /// propSet 类型不符 → Unknown;数值 Int/Double 不互转(set 严格)。 + #[test] + fn set_type_mismatch() { + let s = suite_v1(); + let set = PropertySet::new(); + set.define("i", vec![Value::Int(0)]); + set.define("d", vec![Value::Double(0.0)]); + let ni = cs("i"); + let nd = cs("d"); + unsafe { + assert_eq!( + (s.set_double)(handle(&set), ni.as_ptr(), 0, 1.0), + status::ERR_UNKNOWN + ); + assert_eq!( + (s.set_int)(handle(&set), nd.as_ptr(), 0, 1), + status::ERR_UNKNOWN + ); + } + } + + /// propGet:Int ↔ Double 数值协随;混合数组元素类型不符 → Failed。 + #[test] + fn get_numeric_coercion_and_mixed_arrays() { + let s = suite_v1(); + let set = PropertySet::new(); + set.define("i", vec![Value::Int(3)]); + set.define("d", vec![Value::Double(1.5)]); + let ni = cs("i"); + let nd = cs("d"); + let mut dv = 0.0; + let mut iv = 0; + unsafe { + assert_eq!((s.get_double)(handle(&set), ni.as_ptr(), 0, &mut dv), 0); + } + assert_eq!(dv, 3.0); + unsafe { + assert_eq!((s.get_int)(handle(&set), nd.as_ptr(), 0, &mut iv), 0); + } + assert_eq!(iv, 1); + + // 混合数组(首元素定类型,后续元素类型不符)→ 单元素读 Failed。 + let mix = PropertySet::new(); + mix.define( + "m", + vec![Value::Double(1.0), Value::String(cs("x"))], + ); + let nm = cs("m"); + unsafe { + assert_eq!( + (s.get_double)(handle(&mix), nm.as_ptr(), 1, &mut dv), + status::FAILED + ); + } + let mix2 = PropertySet::new(); + mix2.define("m", vec![Value::Int(1), Value::String(cs("x"))]); + unsafe { + assert_eq!( + (s.get_int)(handle(&mix2), nm.as_ptr(), 1, &mut iv), + status::FAILED + ); + } + } + + /// 分量不足/超出的类型读:get_string 读数值属性 → Unknown(类型 + /// 不符),get_double 读字符串 → Unknown。 + #[test] + fn get_kind_mismatch() { + let s = suite_v1(); + let set = PropertySet::new(); + set.define("i", vec![Value::Int(1)]); + set.define("str", vec![Value::String(cs("s"))]); + let ni = cs("i"); + let ns = cs("str"); + let mut sv: *mut c_char = std::ptr::null_mut(); + let mut dv = 0.0; + unsafe { + assert_eq!( + (s.get_string)(handle(&set), ni.as_ptr(), 0, &mut sv), + status::ERR_UNKNOWN + ); + assert_eq!( + (s.get_double)(handle(&set), ns.as_ptr(), 0, &mut dv), + status::ERR_UNKNOWN + ); + } + } + + /// propGet 空 out 指针 → ErrValue(四种标量读取)。 + #[test] + fn get_null_out_pointers() { + let s = suite_v1(); + let set = PropertySet::new(); + set.define("i", vec![Value::Int(1)]); + set.define("d", vec![Value::Double(1.0)]); + set.define("s", vec![Value::String(cs("x"))]); + set.define("p", vec![Value::Pointer(std::ptr::null_mut())]); + let n = cs("i"); + unsafe { + assert_eq!( + (s.get_int)(handle(&set), n.as_ptr(), 0, std::ptr::null_mut()), + status::ERR_VALUE + ); + assert_eq!( + (s.get_double)(handle(&set), n.as_ptr(), 0, std::ptr::null_mut()), + status::ERR_VALUE + ); + assert_eq!( + (s.get_string)(handle(&set), n.as_ptr(), 0, std::ptr::null_mut()), + status::ERR_VALUE + ); + assert_eq!( + (s.get_pointer)(handle(&set), n.as_ptr(), 0, std::ptr::null_mut()), + status::ERR_VALUE + ); + } + } + + /// propGetPointer:成功写出指针;混合数组类型不符 → Failed。 + #[test] + fn get_pointer_paths() { + let s = suite_v1(); + let raw = 0x1234usize as *mut c_void; + let set = PropertySet::new(); + set.define("p", vec![Value::Pointer(raw)]); + let np = cs("p"); + let mut out: *mut c_void = std::ptr::null_mut(); + unsafe { + assert_eq!((s.get_pointer)(handle(&set), np.as_ptr(), 0, &mut out), 0); + } + assert_eq!(out, raw); + + let mix = PropertySet::new(); + mix.define( + "p", + vec![Value::Pointer(raw), Value::Int(1)], + ); + unsafe { + assert_eq!( + (s.get_pointer)(handle(&mix), np.as_ptr(), 1, &mut out), + status::FAILED + ); + } + } + + /// propGetString:写出内驻指针且内容正确;混合数组 → Failed。 + #[test] + fn get_string_paths() { + let s = suite_v1(); + let set = PropertySet::new(); + set.define("s", vec![Value::String(cs("hello"))]); + let ns = cs("s"); + let mut out: *mut c_char = std::ptr::null_mut(); + unsafe { + assert_eq!((s.get_string)(handle(&set), ns.as_ptr(), 0, &mut out), 0); + assert_eq!(CStr::from_ptr(out).to_bytes(), b"hello"); + } + let mix = PropertySet::new(); + mix.define( + "s", + vec![Value::String(cs("x")), Value::Int(1)], + ); + unsafe { + assert_eq!( + (s.get_string)(handle(&mix), ns.as_ptr(), 1, &mut out), + status::FAILED + ); + } + } + + /// propSetString:NULL 值 → ErrValue;截断到首个 NUL。 + #[test] + fn set_string_null_and_truncation() { + let s = suite_v1(); + let set = PropertySet::new(); + let n = cs("s"); + unsafe { + assert_eq!( + (s.set_string)(handle(&set), n.as_ptr(), 0, std::ptr::null()), + status::ERR_VALUE + ); + } + } + + /// propSetN 家族:隐式创建、维度替换/逐位写、空数组与 NULL 参数。 + #[test] + fn set_n_families() { + let s = suite_v1(); + let set = PropertySet::new(); + + // set_int_n:隐式创建 2 维。 + let ni = cs("ints"); + let ivals = [1 as c_int, 2]; + unsafe { + assert_eq!( + (s.set_int_n)(handle(&set), ni.as_ptr(), 2, ivals.as_ptr()), + 0 + ); + } + assert_eq!(set.dimension("ints"), 2); + assert_value(&set, "ints", 1, &Value::Int(2)); + + // count 与现有维度相同 → 逐位覆盖。 + let ivals2 = [7 as c_int, 8]; + unsafe { + assert_eq!( + (s.set_int_n)(handle(&set), ni.as_ptr(), 2, ivals2.as_ptr()), + 0 + ); + } + assert_value(&set, "ints", 0, &Value::Int(7)); + + // 维度变化 → 整体替换。 + let ivals3 = [9 as c_int]; + unsafe { + assert_eq!( + (s.set_int_n)(handle(&set), ni.as_ptr(), 1, ivals3.as_ptr()), + 0 + ); + } + assert_eq!(set.dimension("ints"), 1); + + // count 0 + NULL:合法(空维度替换)。 + unsafe { + assert_eq!( + (s.set_int_n)(handle(&set), ni.as_ptr(), 0, std::ptr::null()), + 0 + ); + } + assert_eq!(set.dimension("ints"), 0); + + // count > 0 + NULL → ErrValue;负 count → BadIndex。 + unsafe { + assert_eq!( + (s.set_int_n)(handle(&set), ni.as_ptr(), 1, std::ptr::null()), + status::ERR_VALUE + ); + assert_eq!( + (s.set_int_n)(handle(&set), ni.as_ptr(), -1, ivals.as_ptr()), + status::ERR_BAD_INDEX + ); + } + + // set_double_n:隐式创建 + NULL 分支。 + let nd = cs("dbls"); + let dvals = [1.5 as c_double, 2.5]; + unsafe { + assert_eq!( + (s.set_double_n)(handle(&set), nd.as_ptr(), 2, dvals.as_ptr()), + 0 + ); + assert_eq!( + (s.set_double_n)(handle(&set), nd.as_ptr(), 1, std::ptr::null()), + status::ERR_VALUE + ); + } + assert_value(&set, "dbls", 1, &Value::Double(2.5)); + + // set_pointer_n:单元素与 count 0 + NULL。 + let np = cs("ptrs"); + let pvals = [0x55usize as *mut c_void]; + unsafe { + assert_eq!( + (s.set_pointer_n)(handle(&set), np.as_ptr(), 1, pvals.as_ptr()), + 0 + ); + assert_eq!( + (s.set_pointer_n)(handle(&set), np.as_ptr(), 1, std::ptr::null()), + status::ERR_VALUE + ); + assert_eq!( + (s.set_pointer_n)(handle(&set), np.as_ptr(), 0, std::ptr::null()), + 0 + ); + } + assert_eq!(set.dimension("ptrs"), 0); + + // set_string_n:成功、NULL 数组、数组内 NULL 元素。 + let nstr = cs("strs"); + let sa = cs("a"); + let sb = cs("b"); + let svals = [sa.as_ptr(), sb.as_ptr()]; + unsafe { + assert_eq!( + (s.set_string_n)(handle(&set), nstr.as_ptr(), 2, svals.as_ptr()), + 0 + ); + assert_eq!( + (s.set_string_n)(handle(&set), nstr.as_ptr(), 1, std::ptr::null()), + status::ERR_VALUE + ); + let with_null = [sa.as_ptr(), std::ptr::null()]; + assert_eq!( + (s.set_string_n)(handle(&set), nstr.as_ptr(), 2, with_null.as_ptr()), + status::ERR_VALUE + ); + } + assert_eq!(set.dimension("strs"), 2); + } + + /// set_n 类型不符 → Unknown;已有数组 count 0 替换为空。 + #[test] + fn set_n_type_mismatch_and_empty_replace() { + let s = suite_v1(); + let set = PropertySet::new(); + set.define("i", vec![Value::Int(1)]); + let ni = cs("i"); + let dvals = [2.0 as c_double]; + unsafe { + assert_eq!( + (s.set_double_n)(handle(&set), ni.as_ptr(), 1, dvals.as_ptr()), + status::ERR_UNKNOWN + ); + // values 为空(count 0)→ 无类型探针,整体替换为空。 + assert_eq!( + (s.set_double_n)(handle(&set), ni.as_ptr(), 0, dvals.as_ptr()), + 0 + ); + } + assert_eq!(set.dimension("i"), 0); + } + + /// propGetN:Int ↔ Double 协随、min(count, dim) 截断、混合数组写 + /// 跳过不符元素。 + #[test] + fn get_n_families() { + let s = suite_v1(); + let set = PropertySet::new(); + set.define("ints", vec![Value::Int(1), Value::Int(2)]); + set.define("dbls", vec![Value::Double(1.5), Value::Double(2.5)]); + + // get_double_n 读 Int 属性 → 协随为 f64。 + let ni = cs("ints"); + let mut dout = [0.0 as c_double; 2]; + unsafe { + assert_eq!( + (s.get_double_n)(handle(&set), ni.as_ptr(), 2, dout.as_mut_ptr()), + 0 + ); + } + assert_eq!(dout, [1.0, 2.0]); + + // get_int_n 读 Double 属性 → 截断为 i32。 + let nd = cs("dbls"); + let mut iout = [0 as c_int; 2]; + unsafe { + assert_eq!( + (s.get_int_n)(handle(&set), nd.as_ptr(), 2, iout.as_mut_ptr()), + 0 + ); + } + assert_eq!(iout, [1, 2]); + + // count > 维度:只拷贝 min(count, dim)。 + let mut one = [0 as c_int; 1]; + unsafe { + assert_eq!( + (s.get_int_n)(handle(&set), ni.as_ptr(), 5, one.as_mut_ptr()), + 0 + ); + } + assert_eq!(one, [1]); + + // count 0:不写。 + let mut zero = [7 as c_int; 1]; + unsafe { + assert_eq!( + (s.get_int_n)(handle(&set), ni.as_ptr(), 0, zero.as_mut_ptr()), + 0 + ); + } + assert_eq!(zero, [7]); + + // get_pointer_n / get_string_n 成功路径。 + let ptrs = PropertySet::new(); + let raw = 0x99usize as *mut c_void; + ptrs.define("p", vec![Value::Pointer(raw)]); + let np = cs("p"); + let mut pout = [std::ptr::null_mut(); 1]; + unsafe { + assert_eq!( + (s.get_pointer_n)(handle(&ptrs), np.as_ptr(), 1, pout.as_mut_ptr()), + 0 + ); + } + assert_eq!(pout[0], raw); + + let strs = PropertySet::new(); + strs.define("s", vec![Value::String(cs("hi"))]); + let ns = cs("s"); + let mut sout = [std::ptr::null_mut(); 1]; + unsafe { + assert_eq!( + (s.get_string_n)(handle(&strs), ns.as_ptr(), 1, sout.as_mut_ptr()), + 0 + ); + assert_eq!(CStr::from_ptr(sout[0]).to_bytes(), b"hi"); + } + } + + /// propGetN 错误路径:空 out、未定义、空数组、类型不符、负 count。 + #[test] + fn get_n_error_paths() { + let s = suite_v1(); + let set = PropertySet::new(); + set.define("ints", vec![Value::Int(1)]); + set.define("strs", vec![Value::String(cs("s"))]); + set.define("empty", vec![]); + let ni = cs("ints"); + let ns = cs("strs"); + let ne = cs("empty"); + let nmissing = cs("missing"); + let mut iout = [0 as c_int; 2]; + let mut dout = [0.0 as c_double; 2]; + let mut pout = [std::ptr::null_mut(); 2]; + let mut sout = [std::ptr::null_mut(); 2]; + unsafe { + // 空 out(count > 0)。 + assert_eq!( + (s.get_int_n)(handle(&set), ni.as_ptr(), 1, std::ptr::null_mut()), + status::ERR_VALUE + ); + assert_eq!( + (s.get_double_n)(handle(&set), ni.as_ptr(), 1, std::ptr::null_mut()), + status::ERR_VALUE + ); + assert_eq!( + (s.get_pointer_n)(handle(&set), ni.as_ptr(), 1, std::ptr::null_mut()), + status::ERR_VALUE + ); + assert_eq!( + (s.get_string_n)(handle(&set), ni.as_ptr(), 1, std::ptr::null_mut()), + status::ERR_VALUE + ); + // 未定义 → Unknown;空数组 → Unknown(无类型探针)。 + assert_eq!( + (s.get_int_n)(handle(&set), nmissing.as_ptr(), 1, iout.as_mut_ptr()), + status::ERR_UNKNOWN + ); + assert_eq!( + (s.get_int_n)(handle(&set), ne.as_ptr(), 1, iout.as_mut_ptr()), + status::ERR_UNKNOWN + ); + // 类型不符 → Unknown(数值族之间协随,其余严格)。 + assert_eq!( + (s.get_int_n)(handle(&set), ns.as_ptr(), 1, iout.as_mut_ptr()), + status::ERR_UNKNOWN + ); + assert_eq!( + (s.get_double_n)(handle(&set), ns.as_ptr(), 1, dout.as_mut_ptr()), + status::ERR_UNKNOWN + ); + assert_eq!( + (s.get_pointer_n)(handle(&set), ni.as_ptr(), 1, pout.as_mut_ptr()), + status::ERR_UNKNOWN + ); + assert_eq!( + (s.get_string_n)(handle(&set), ni.as_ptr(), 1, sout.as_mut_ptr()), + status::ERR_UNKNOWN + ); + // 负 count → BadIndex(在 min 之前 idx 转换失败)。 + assert_eq!( + (s.get_int_n)(handle(&set), ni.as_ptr(), -1, iout.as_mut_ptr()), + status::ERR_BAD_INDEX + ); + } + } + + /// propReset 明确不支持;propGetDimension 空 out/未定义/空数组。 + #[test] + fn reset_and_dimension() { + let s = suite_v1(); + let set = PropertySet::new(); + set.define("a", vec![Value::Int(1)]); + set.define("empty", vec![]); + let na = cs("a"); + let ne = cs("empty"); + let nmissing = cs("missing"); + let mut dim = -1; + unsafe { + assert_eq!( + (s.reset)(handle(&set), na.as_ptr()), + status::ERR_UNSUPPORTED + ); + assert_eq!( + (s.get_dimension)(handle(&set), na.as_ptr(), std::ptr::null_mut()), + status::ERR_VALUE + ); + assert_eq!( + (s.get_dimension)(handle(&set), nmissing.as_ptr(), &mut dim), + status::ERR_UNKNOWN + ); + assert_eq!((s.get_dimension)(handle(&set), na.as_ptr(), &mut dim), 0); + assert_eq!(dim, 1); + assert_eq!((s.get_dimension)(handle(&set), ne.as_ptr(), &mut dim), 0); + assert_eq!(dim, 0, "已定义空数组是合法维度 0"); + } + } + + /// OAK_OFX_TRACE 开启时的诊断分支:所有 trace 打印点都被走到 + /// (错误路径与成功路径)。测试串行化并还原环境变量。 + #[test] + fn trace_diagnostic_branches() { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev = std::env::var_os("OAK_OFX_TRACE"); + // set_var/remove_var 在本 crate 的 2021 edition 中是安全函数。 + std::env::set_var("OAK_OFX_TRACE", "1"); + let body = std::panic::catch_unwind(|| { + let s = suite_v1(); + let set = PropertySet::new(); + let ni = cs("i"); + let nstr = cs("str"); + let nmissing = cs("missing"); + set.define("i", vec![Value::Int(1)]); + set.define("str", vec![Value::String(cs("s"))]); + let mut iv = 0; + let mut dv = 0.0; + let mut sv: *mut c_char = std::ptr::null_mut(); + let mut pv: *mut c_void = std::ptr::null_mut(); + let mut dim = 0; + unsafe { + // caught 的 trace:非 OK 状态码。 + assert_eq!( + (s.get_int)(handle(&set), nmissing.as_ptr(), 0, &mut iv), + status::ERR_UNKNOWN + ); + // get_value trace:成功 + 失败。 + assert_eq!((s.get_int)(handle(&set), ni.as_ptr(), 0, &mut iv), 0); + assert_eq!( + (s.get_int)(handle(&set), nmissing.as_ptr(), 0, &mut iv), + status::ERR_UNKNOWN + ); + // 类型不符 trace。 + assert_eq!( + (s.get_string)(handle(&set), ni.as_ptr(), 0, &mut sv), + status::ERR_UNKNOWN + ); + // set_value:空数组越界 trace;越界 trace。 + let empty = PropertySet::new(); + empty.define("e", vec![]); + let ne = cs("e"); + assert_eq!( + (s.set_int)(handle(&empty), ne.as_ptr(), 1, 1), + status::ERR_BAD_INDEX + ); + assert_eq!( + (s.set_int)(handle(&set), ni.as_ptr(), 5, 2), + status::ERR_BAD_INDEX + ); + // propGetPointer trace(成功)。 + let ptrs = PropertySet::new(); + ptrs.define("p", vec![Value::Pointer(0x11usize as *mut c_void)]); + let np = cs("p"); + assert_eq!((s.get_pointer)(handle(&ptrs), np.as_ptr(), 0, &mut pv), 0); + // propGetString trace:成功 + 失败。 + assert_eq!((s.get_string)(handle(&set), nstr.as_ptr(), 0, &mut sv), 0); + assert_eq!( + (s.get_string)(handle(&set), nmissing.as_ptr(), 0, &mut sv), + status::ERR_UNKNOWN + ); + // propGetInt inspect_err trace。 + assert_eq!( + (s.get_int)(handle(&set), nmissing.as_ptr(), 0, &mut iv), + status::ERR_UNKNOWN + ); + // get_n trace:成功 dump + 未命中 inspect_err。 + let mut iout = [0 as c_int; 2]; + assert_eq!( + (s.get_int_n)(handle(&set), ni.as_ptr(), 2, iout.as_mut_ptr()), + 0 + ); + assert_eq!( + (s.get_int_n)(handle(&set), nmissing.as_ptr(), 1, iout.as_mut_ptr()), + status::ERR_UNKNOWN + ); + // dump 的 Double 与未知元素分支。 + let mixed = PropertySet::new(); + mixed.define( + "m", + vec![Value::Double(1.5), Value::String(cs("x"))], + ); + let nm = cs("m"); + let mut dout = [0.0 as c_double; 2]; + assert_eq!( + (s.get_double_n)(handle(&mixed), nm.as_ptr(), 2, dout.as_mut_ptr()), + 0 + ); + // 空 out 的 get_double trace 分支(Err 路径渲染)。 + assert_eq!( + (s.get_double)(handle(&set), ni.as_ptr(), 0, &mut dv), + 0 + ); + // propGetDimension 未定义 trace。 + assert_eq!( + (s.get_dimension)(handle(&set), nmissing.as_ptr(), &mut dim), + status::ERR_UNKNOWN + ); + } + }); + match prev { + Some(v) => std::env::set_var("OAK_OFX_TRACE", v), + None => std::env::remove_var("OAK_OFX_TRACE"), + } + if let Err(p) = body { + std::panic::resume_unwind(p); + } + } +} diff --git a/crates/oak-timeline/tests/undocommands_test.rs b/crates/oak-timeline/tests/undocommands_test.rs new file mode 100644 index 000000000..18404c061 --- /dev/null +++ b/crates/oak-timeline/tests/undocommands_test.rs @@ -0,0 +1,1627 @@ +// 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 . + +//! Contract tests for the undo commands left untested by `domain_test`: +//! the remaining ripple, pointer, track, split and general commands. +//! +//! Each test builds a project with a sequence/track-list/track, applies a +//! command through `redo`, checks the observable graph state and `undo`s +//! back to the original state. Commands whose `redo` self-prepares are +//! also exercised through the oakundo boxing path (`to_command` + +//! `redo_now`/`undo_now`); commands that need `prepare` are prepared +//! before boxing (the boxed `Command` trait has no prepare callback). + +use std::sync::{Arc, Mutex}; + +use oak_core::{Rational, TimeRange}; +use oak_node::block::{ClipBlockBehavior, GapBlockBehavior}; +use oak_node::project::Project; +use oak_node::sequence::SequenceBehavior; +use oak_node::track::{TrackBehavior, TrackListBehavior}; +use oak_timeline::common::MovementMode; +use oak_timeline::undocommon::Command; +use oak_timeline::undogeneral::{ + BlockEnableDisableCommand, BlockResizeWithMediaInCommand, BlockSetMediaInCommand, + TimelineAddDefaultTransitionCommand, TimelineAddTrackCommand, TrackListInsertGaps, +}; +use oak_timeline::undopointer::{BlockTrimCommand, TrackSlideCommand}; +use oak_timeline::undoripple::{ + RippleInfo, TimelineRippleDeleteGapsAtRegionsCommand, TimelineRippleRemoveAreaCommand, + TrackListRippleRemoveAreaCommand, TrackListRippleToolCommand, TrackRippleRemoveAreaCommand, +}; +use oak_timeline::undosplit::{ + BlockSplitCommand, BlockSplitPreservingLinksCommand, TrackSplitAtTimeCommand, +}; +use oak_timeline::undotrack::{ + TrackInsertBlockAfterCommand, TrackPrependBlockCommand, TrackReplaceBlockCommand, +}; +use oak_timeline::util::{ + block_enabled, block_kind, block_remove_from_graph, block_track, clip_media_in, + track_append_block, track_block_at, track_block_count, track_length, BlockKind, NodeRef, +}; + +/// A project with one sequence owning one (video) track list. +fn make_project() -> Arc> { + let project = Project::new(); + let (seq_id, list_id) = { + let mut p = project.lock().unwrap(); + let (core, behavior) = SequenceBehavior::create(); + let seq_id = p.graph.add_node(core, behavior); + let (core, behavior) = TrackListBehavior::create(); + let list_id = p.graph.add_node(core, behavior); + if let Some(e) = p.graph.get_mut(seq_id) { + if let Some(a) = e.behavior.as_any_mut() { + if let Some(s) = a.downcast_mut::() { + s.track_lists.push(list_id); + } + } + } + if let Some(e) = p.graph.get_mut(list_id) { + if let Some(a) = e.behavior.as_any_mut() { + if let Some(l) = a.downcast_mut::() { + l.sequence = Some(seq_id); + } + } + } + (seq_id, list_id) + }; + let _ = (seq_id, list_id); + project +} + +/// The sequence and its video track list of `project`. +fn sequence_and_list(project: &Arc>) -> (NodeRef, NodeRef) { + let p = project.lock().unwrap(); + let mut seq = None; + let mut list = None; + for id in p.graph.node_ids() { + if let Some(e) = p.graph.get(id) { + if e.behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .is_some() + { + seq = Some(NodeRef::new(project.clone(), id)); + } else if e + .behavior + .as_any() + .and_then(|a| a.downcast_ref::()) + .is_some() + { + list = Some(NodeRef::new(project.clone(), id)); + } + } + } + (seq.unwrap(), list.unwrap()) +} + +/// Create a clip block spanning `[in, out)` that is not on any track. +fn new_clip(project: &Arc>, in_: Rational, out: Rational) -> NodeRef { + let clip = oak_timeline::util::block_clip_create(project); + { + let mut p = project.lock().unwrap(); + if let Some(e) = p.graph.get_mut(clip.id) { + if let Some(a) = e.behavior.as_any_mut() { + if let Some(c) = a.downcast_mut::() { + c.core.range = TimeRange::new(in_, out); + } + } + } + } + clip +} + +/// Create a gap block spanning `[in, out)` that is not on any track. +fn new_gap(project: &Arc>, in_: Rational, out: Rational) -> NodeRef { + let gap = oak_timeline::util::block_gap_create(project); + { + let mut p = project.lock().unwrap(); + if let Some(e) = p.graph.get_mut(gap.id) { + if let Some(a) = e.behavior.as_any_mut() { + if let Some(g) = a.downcast_mut::() { + g.core.range = TimeRange::new(in_, out); + } + } + } + } + gap +} + +/// Append a clip block spanning `[in, out)` to `track`. +fn add_clip(track: &NodeRef, in_: Rational, out: Rational) -> NodeRef { + let clip = new_clip(&track.project, in_, out); + track_append_block(track, &clip); + clip +} + +/// Append a gap block spanning `[in, out)` to `track`. +fn add_gap(track: &NodeRef, in_: Rational, out: Rational) -> NodeRef { + let gap = new_gap(&track.project, in_, out); + track_append_block(track, &gap); + gap +} + +/// The block span of `block` (`None` for a stale node). +fn span_of(block: &NodeRef) -> Option<(Rational, Rational)> { + let p = block.project.lock().unwrap(); + p.graph.get(block.id).and_then(|e| e.behavior.as_any()).and_then(|a| { + [ + a.downcast_ref::().map(|b| &b.core), + a.downcast_ref::().map(|b| &b.core), + ] + .into_iter() + .flatten() + .next() + .map(|core| (core.in_(), core.out())) + }) +} + +/// Set a track's locked flag (the flag `prepare` consults). +fn set_track_locked(track: &NodeRef, locked: bool) { + let mut p = track.project.lock().unwrap(); + if let Some(a) = p.graph.get_mut(track.id).and_then(|e| e.behavior.as_any_mut()) { + if let Some(t) = a.downcast_mut::() { + t.locked = locked; + } + } +} + +/// Whether `node` is currently attached to the project graph. +fn node_in_graph(project: &Arc>, node: &NodeRef) -> bool { + let p = project.lock().unwrap(); + p.graph.get(node.id).is_some() +} + +/// Link two blocks (C++ `Node::link`). +fn link_blocks(project: &Arc>, a: &NodeRef, b: &NodeRef) { + let mut p = project.lock().unwrap(); + let _ = p.graph.link(a.id, b.id); +} + +/// Whether two blocks are linked (C++ `Node::are_linked`). +fn blocks_are_linked(project: &Arc>, a: &NodeRef, b: &NodeRef) -> bool { + let p = project.lock().unwrap(); + p.graph.are_linked(a.id, b.id) +} + +// --------------------------------------------------------------------------- +// undoripple.rs +// --------------------------------------------------------------------------- + +/// `TrackRippleRemoveAreaCommand` splices a block that the range cuts +/// through: redo splits it at the range in, trims the remainder to the +/// range out, and undo re-joins the halves. +#[test] +fn ripple_remove_area_splices_cutting_block() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + + let mut cmd = TrackRippleRemoveAreaCommand::new( + track.clone(), + TimeRange::new(Rational::new(25, 1), Rational::new(50, 1)), + ); + cmd.prepare(); + // The range cuts through the block, so the host is the insertion point + // and no second half exists yet. + assert_eq!(cmd.get_insertion_index().map(|b| b.id), Some(clip.id)); + assert!(cmd.get_spliced_block().is_none()); + + cmd.redo(); + let spliced = cmd.get_spliced_block().expect("range split the block"); + assert_ne!(spliced.id, clip.id); + assert_eq!(track_block_count(&track), 2); + assert_eq!(track_block_at(&track, 0).unwrap().id, clip.id); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(25, 1)))); + assert_eq!(span_of(&spliced), Some((Rational::new(25, 1), Rational::new(75, 1)))); + + cmd.undo(); + assert_eq!(track_block_count(&track), 1); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); + + // The next redo re-attaches the same second half identity. + cmd.redo(); + assert_eq!(cmd.get_spliced_block().map(|b| b.id), Some(spliced.id)); + assert_eq!(track_block_count(&track), 2); +} + +/// Gaps are not split by default (`set_allow_splitting_gaps`): the range +/// trims the gap as a whole. With the flag set, the splice path splits it. +#[test] +fn ripple_remove_area_gap_splitting_flag() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let gap = add_gap(&track, Rational::new(0, 1), Rational::new(100, 1)); + let range = TimeRange::new(Rational::new(25, 1), Rational::new(50, 1)); + + // Default: the whole range shortens the gap (100 - 25 = 75), no split. + let mut cmd = TrackRippleRemoveAreaCommand::new(track.clone(), range); + cmd.prepare(); + cmd.redo(); + assert_eq!(track_block_count(&track), 1); + assert!(cmd.get_spliced_block().is_none()); + assert_eq!(span_of(&gap), Some((Rational::new(0, 1), Rational::new(75, 1)))); + cmd.undo(); + assert_eq!(span_of(&gap), Some((Rational::new(0, 1), Rational::new(100, 1)))); + + // Allowed: the gap is split and the remainders are trimmed. + let mut cmd = TrackRippleRemoveAreaCommand::new(track.clone(), range); + cmd.set_allow_splitting_gaps(true); + cmd.redo(); + assert_eq!(track_block_count(&track), 2); + let spliced = cmd.get_spliced_block().expect("gap split allowed"); + assert_eq!(block_kind(&spliced), BlockKind::Gap); + assert_eq!(span_of(&gap), Some((Rational::new(0, 1), Rational::new(25, 1)))); + assert_eq!(span_of(&spliced), Some((Rational::new(25, 1), Rational::new(75, 1)))); + cmd.undo(); + assert_eq!(track_block_count(&track), 1); + assert_eq!(span_of(&gap), Some((Rational::new(0, 1), Rational::new(100, 1)))); +} + +/// `TrackRippleRemoveAreaCommand`: undo before any redo leaves the graph +/// untouched (the trim operations are only applied by redo). +#[test] +fn ripple_remove_area_undo_before_redo_is_noop() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let c1 = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let c2 = add_clip(&track, Rational::new(50, 1), Rational::new(100, 1)); + + let mut cmd = TrackRippleRemoveAreaCommand::new( + track.clone(), + TimeRange::new(Rational::new(25, 1), Rational::new(75, 1)), + ); + cmd.prepare(); + cmd.undo(); + assert_eq!(track_block_count(&track), 2); + assert_eq!(span_of(&c1), Some((Rational::new(0, 1), Rational::new(50, 1)))); + assert_eq!(span_of(&c2), Some((Rational::new(50, 1), Rational::new(100, 1)))); + + cmd.redo(); + assert_eq!(span_of(&c1), Some((Rational::new(0, 1), Rational::new(25, 1)))); + // c2's in-end trim is out-anchored: its out stays at 100. + assert_eq!(span_of(&c2), Some((Rational::new(75, 1), Rational::new(100, 1)))); + + cmd.undo(); + assert_eq!(span_of(&c1), Some((Rational::new(0, 1), Rational::new(50, 1)))); + assert_eq!(span_of(&c2), Some((Rational::new(50, 1), Rational::new(100, 1)))); +} + +/// `TrackRippleRemoveAreaCommand` with no block at/before the range (an +/// empty track or a range before the first block) is a no-op. +#[test] +fn ripple_remove_area_no_candidate_block_is_noop() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list.clone()); + + let mut cmd = TrackRippleRemoveAreaCommand::new( + track.clone(), + TimeRange::new(Rational::new(0, 1), Rational::new(50, 1)), + ); + cmd.redo(); + cmd.undo(); + assert_eq!(track_block_count(&track), 0); + + let clip = add_clip(&track, Rational::new(50, 1), Rational::new(100, 1)); + let mut cmd = TrackRippleRemoveAreaCommand::new( + track.clone(), + TimeRange::new(Rational::new(0, 1), Rational::new(25, 1)), + ); + cmd.redo(); + cmd.undo(); + assert_eq!(track_block_count(&track), 1); + assert_eq!(span_of(&clip), Some((Rational::new(50, 1), Rational::new(100, 1)))); +} + +/// `TrackRippleRemoveAreaCommand` against a stale track reference (its +/// node detached from the graph) is a no-op. +#[test] +fn ripple_remove_area_stale_track_is_noop() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + + block_remove_from_graph(&track); + assert!(!node_in_graph(&project, &track)); + + let mut cmd = TrackRippleRemoveAreaCommand::new( + track.clone(), + TimeRange::new(Rational::new(25, 1), Rational::new(50, 1)), + ); + cmd.redo(); + cmd.undo(); + assert!(!node_in_graph(&project, &track)); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); +} + +/// `TrackListRippleRemoveAreaCommand` applies the range to every unlocked +/// track in the list and skips locked ones. +#[test] +fn tracklist_ripple_remove_area_skips_locked_track() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let unlocked = TimelineAddTrackCommand::run_immediately(list.clone()); + let locked = TimelineAddTrackCommand::run_immediately(list.clone()); + let unlocked_clip = add_clip(&unlocked, Rational::new(0, 1), Rational::new(100, 1)); + let locked_clip = add_clip(&locked, Rational::new(0, 1), Rational::new(100, 1)); + set_track_locked(&locked, true); + + let mut cmd = TrackListRippleRemoveAreaCommand::new( + list.clone(), + Rational::new(25, 1), + Rational::new(75, 1), + ); + cmd.prepare(); + cmd.redo(); + // Unlocked track spliced into [0,25) + [25,50); locked track untouched. + assert_eq!(track_block_count(&unlocked), 2); + assert_eq!(span_of(&unlocked_clip), Some((Rational::new(0, 1), Rational::new(25, 1)))); + assert_eq!(track_block_count(&locked), 1); + assert_eq!(span_of(&locked_clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); + assert_eq!(track_length(&locked), Rational::new(100, 1)); + + cmd.undo(); + assert_eq!(track_block_count(&unlocked), 1); + assert_eq!(span_of(&unlocked_clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); +} + +/// `TimelineRippleRemoveAreaCommand` walks the sequence's track lists; the +/// boxing path (`to_command`) dispatches through the trait. +#[test] +fn timeline_ripple_remove_area_round_trip() { + let project = make_project(); + let (seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + + let mut cmd = TimelineRippleRemoveAreaCommand::new( + seq.clone(), + Rational::new(25, 1), + Rational::new(75, 1), + ) + .to_command(); + cmd.redo_now(); + assert_eq!(track_block_count(&track), 2); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(25, 1)))); + cmd.undo_now(); + assert_eq!(track_block_count(&track), 1); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); + + // A range covering the whole block removes it (graph node included) and + // undo re-attaches it. + let mut cmd = TimelineRippleRemoveAreaCommand::new( + seq, + Rational::new(0, 1), + Rational::new(100, 1), + ) + .to_command(); + cmd.redo_now(); + assert_eq!(track_block_count(&track), 0); + assert!(!node_in_graph(&project, &clip)); + cmd.undo_now(); + assert_eq!(track_block_count(&track), 1); + assert!(node_in_graph(&project, &clip)); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); +} + +/// `TimelineRippleDeleteGapsAtRegionsCommand` removes a gap covering the +/// requested region; undo restores it at its old slot. +#[test] +fn timeline_ripple_delete_gaps_at_regions_round_trip() { + let project = make_project(); + let (seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let first = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let gap = add_gap(&track, Rational::new(50, 1), Rational::new(100, 1)); + let second = add_clip(&track, Rational::new(100, 1), Rational::new(150, 1)); + + let mut cmd = TimelineRippleDeleteGapsAtRegionsCommand::new( + seq, + vec![( + track.clone(), + TimeRange::new(Rational::new(50, 1), Rational::new(100, 1)), + )], + ); + assert!(!cmd.has_commands()); + cmd.prepare(); + assert!(cmd.has_commands()); + + cmd.redo(); + assert_eq!(track_block_count(&track), 2); + assert_eq!(track_block_at(&track, 0).unwrap().id, first.id); + assert_eq!(track_block_at(&track, 1).unwrap().id, second.id); + assert!(block_track(&gap).is_none()); + + cmd.undo(); + assert_eq!(track_block_count(&track), 3); + assert_eq!(track_block_at(&track, 1).unwrap().id, gap.id); + assert_eq!(span_of(&gap), Some((Rational::new(50, 1), Rational::new(100, 1)))); +} + +/// A requested region that lies inside a gap longer than the region +/// resizes the gap by the region length rather than removing it. +#[test] +fn timeline_ripple_delete_gaps_resizes_longer_gap() { + let project = make_project(); + let (seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let gap = add_gap(&track, Rational::new(50, 1), Rational::new(150, 1)); + add_clip(&track, Rational::new(150, 1), Rational::new(200, 1)); + + let mut cmd = TimelineRippleDeleteGapsAtRegionsCommand::new( + seq, + vec![( + track.clone(), + TimeRange::new(Rational::new(60, 1), Rational::new(80, 1)), + )], + ); + cmd.prepare(); + assert!(cmd.has_commands()); + + cmd.redo(); + // The gap keeps its out point and loses the 20-frame region length. + assert_eq!(track_block_count(&track), 3); + assert_eq!(span_of(&gap), Some((Rational::new(70, 1), Rational::new(150, 1)))); + + cmd.undo(); + assert_eq!(span_of(&gap), Some((Rational::new(50, 1), Rational::new(150, 1)))); +} + +/// A region whose nearest block is not a gap, and an empty region list, +/// both produce no commands and leave the track untouched. +#[test] +fn timeline_ripple_delete_gaps_non_gap_region_is_noop() { + let project = make_project(); + let (seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + let gap = add_gap(&track, Rational::new(100, 1), Rational::new(150, 1)); + + let mut cmd = TimelineRippleDeleteGapsAtRegionsCommand::new( + seq.clone(), + vec![( + track.clone(), + TimeRange::new(Rational::new(0, 1), Rational::new(50, 1)), + )], + ); + cmd.prepare(); + assert!(!cmd.has_commands()); + cmd.redo(); + cmd.undo(); + assert_eq!(track_block_count(&track), 2); + + let mut empty = + TimelineRippleDeleteGapsAtRegionsCommand::new(seq, Vec::new()); + empty.prepare(); + assert!(!empty.has_commands()); + empty.redo(); + empty.undo(); + assert_eq!(track_block_count(&track), 2); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); + assert_eq!(span_of(&gap), Some((Rational::new(100, 1), Rational::new(150, 1)))); +} + +/// `TrackListRippleToolCommand` with no per-track info is a no-op in both +/// directions (the `ripple()` early return). Real-info behavior is covered +/// by `track_list_ripple_tool_*` below, which `RippleInfo::new` made +/// constructible (review §M5). +#[test] +fn track_list_ripple_tool_empty_info_is_noop() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list.clone()); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + + let mut cmd = TrackListRippleToolCommand::new( + list.clone(), + Vec::new(), + Rational::new(10, 1), + MovementMode::TrimOut, + ); + cmd.redo(); + cmd.undo(); + assert_eq!(track_block_count(&track), 1); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); + + let mut boxed = TrackListRippleToolCommand::new( + list, + Vec::new(), + Rational::new(0, 1) - Rational::new(10, 1), + MovementMode::TrimIn, + ) + .to_command(); + boxed.redo_now(); + boxed.undo_now(); + assert_eq!(track_block_count(&track), 1); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); +} + +/// `TrackListRippleToolCommand` with real per-track info resizes the block +/// and restores it on undo. +/// +/// KNOWN-SWAP: the expectations below pin the CURRENT (swapped) +/// `set_length_and_media_out` behavior documented in +/// `docs/zh/plans/test-coverage-90-80-review.md` §3.1/§9 — the trim ripples the +/// in-point and writes the timeline in into `media_in` instead of moving the +/// out-point. The semantic fix must rewrite these values deliberately +/// (C++ `BlockTrimCommand::redo` kTrimOut keeps the in-point and moves out). +#[test] +fn track_list_ripple_tool_resizes_with_real_info() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list.clone()); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + + let info = RippleInfo::new(clip.clone(), false); + assert!(!info.append_gap()); + assert_eq!(info.block().id, clip.id); + let mut cmd = TrackListRippleToolCommand::new( + list.clone(), + vec![(track.clone(), info)], + Rational::new(10, 1), + MovementMode::TrimOut, + ); + cmd.redo(); + assert_eq!( + span_of(&clip), + Some((Rational::new(-10, 1), Rational::new(100, 1))), + "current (swapped) behavior: the in-point shifts, the out-point stays" + ); + assert_eq!(clip_media_in(&clip), Rational::new(-10, 1)); + + cmd.undo(); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); + assert_eq!(clip_media_in(&clip), Rational::new(0, 1)); +} + +/// `TrackListRippleToolCommand` with `append_gap = true` inserts a gap of +/// the movement span ahead of the block and removes it on undo. +#[test] +fn track_list_ripple_tool_append_gap_creates_a_gap() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list.clone()); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + + let mut cmd = TrackListRippleToolCommand::new( + list.clone(), + vec![(track.clone(), RippleInfo::new(clip.clone(), true))], + Rational::new(10, 1), + MovementMode::TrimOut, + ); + cmd.redo(); + assert_eq!(track_block_count(&track), 2, "a gap was inserted"); + // KNOWN-SWAP: the inserted gap overlaps the clip (its in is the clip's + // in); §3.1/§9's fix will move it to the clip's out. + let gap = track_block_at(&track, 0).expect("gap at the front"); + assert_eq!(block_kind(&gap), BlockKind::Gap); + assert_eq!(span_of(&gap), Some((Rational::new(0, 1), Rational::new(10, 1)))); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); + + cmd.undo(); + assert_eq!(track_block_count(&track), 1, "the inserted gap is removed"); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); +} + +// --------------------------------------------------------------------------- +// undopointer.rs +// --------------------------------------------------------------------------- + +/// `BlockTrimCommand` shortens a clip and lengthens its adjacent gap so +/// the rest of the track keeps its position; undo restores both. +/// +/// KNOWN-SWAP (§3.1/§9 of `docs/zh/plans/test-coverage-90-80-review.md`): +/// the expectations below pin the current (swapped) +/// `set_length_and_media_out` behavior — the trim moves the clip's in-point +/// instead of its out-point. C++ `BlockTrimCommand` TrimOut is in-anchored +/// (`common.rs` `k_trim_out`: trim the out point); the semantic fix must +/// re-derive this geometry. +#[test] +fn block_trim_with_gap_adjacent_round_trip() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let gap = add_gap(&track, Rational::new(50, 1), Rational::new(100, 1)); + + let mut cmd = BlockTrimCommand::new( + track.clone(), + clip.clone(), + Rational::new(25, 1), + MovementMode::TrimOut, + ); + cmd.prepare(); + cmd.redo(); + // Current (swapped) behavior: the clip's in shifts to 25 and the gap + // keeps its in at 50, growing its out by the 25 trimmed away. The + // in-anchored C++ TrimOut would keep the in and move the out instead + // (see the KNOWN-SWAP note). + assert_eq!(span_of(&clip), Some((Rational::new(25, 1), Rational::new(50, 1)))); + assert_eq!(span_of(&gap), Some((Rational::new(50, 1), Rational::new(125, 1)))); + + cmd.undo(); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(50, 1)))); + assert_eq!(span_of(&gap), Some((Rational::new(50, 1), Rational::new(100, 1)))); +} + +/// `BlockTrimCommand` inserts a gap filling the space the trim freed when +/// the adjacent block is a clip and the trim is not a roll edit. +/// +/// KNOWN-SWAP (§3.1/§9 of `docs/zh/plans/test-coverage-90-80-review.md`): +/// the geometry below pins the swapped `set_length_and_media_out` behavior +/// — the swapped trim moved `first`'s in to 25 and inserted the +/// compensating gap at [50,75), sharing its in with `second` (the true +/// in-anchored TrimOut keeps `first` at [0,25); the semantic fix must +/// rewrite this geometry). +#[test] +fn block_trim_creates_gap_next_to_clip() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let first = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let second = add_clip(&track, Rational::new(50, 1), Rational::new(100, 1)); + + let mut cmd = BlockTrimCommand::new( + track.clone(), + first.clone(), + Rational::new(25, 1), + MovementMode::TrimOut, + ); + cmd.prepare(); + cmd.redo(); + assert_eq!(track_block_count(&track), 3); + assert_eq!(track_block_at(&track, 0).unwrap().id, first.id); + let created = track_block_at(&track, 1).unwrap(); + assert_eq!(block_kind(&created), BlockKind::Gap); + // KNOWN-SWAP: the swapped trim shrinks `first` from the in side, so the + // "compensating" gap lands at [50,75) and overlaps `second` instead of + // filling the freed [25,50) space (§3.1/§9). + assert_eq!(span_of(&first), Some((Rational::new(25, 1), Rational::new(50, 1)))); + assert_eq!(span_of(&created), Some((Rational::new(50, 1), Rational::new(75, 1)))); + assert_eq!(track_block_at(&track, 2).unwrap().id, second.id); + + cmd.undo(); + assert_eq!(track_block_count(&track), 2); + assert_eq!(span_of(&first), Some((Rational::new(0, 1), Rational::new(50, 1)))); + assert_eq!(span_of(&second), Some((Rational::new(50, 1), Rational::new(100, 1)))); +} + +/// `BlockTrimCommand::set_trim_is_a_roll_edit` trims into the adjacent +/// clip instead of creating a compensating gap. +/// +/// KNOWN-SWAP (§3.1/§9 of `docs/zh/plans/test-coverage-90-80-review.md`): the +/// expectations below characterize the swapped `set_length_and_media_out` +/// behavior — the seam does NOT move; the left clip's IN shifts and the +/// follower's OUT grows. A true roll edit is first=[0,25) / second=[25,100) +/// with the seam at 25; the semantic fix must rewrite these values. +#[test] +fn block_trim_roll_edit_resizes_adjacent_clip() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let first = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let second = add_clip(&track, Rational::new(50, 1), Rational::new(100, 1)); + + let mut cmd = BlockTrimCommand::new( + track.clone(), + first.clone(), + Rational::new(25, 1), + MovementMode::TrimOut, + ); + cmd.set_trim_is_a_roll_edit(true); + cmd.prepare(); + cmd.redo(); + assert_eq!(track_block_count(&track), 2); + assert_eq!(span_of(&first), Some((Rational::new(25, 1), Rational::new(50, 1)))); + assert_eq!(span_of(&second), Some((Rational::new(50, 1), Rational::new(125, 1)))); + // The seam stays at 50 under the current mapping (see the KNOWN-SWAP + // note); `media_in` pins the coordinate-space confusion: the left clip's + // timeline in (25) is written into `media_in`, and the follower's + // media-in is left untouched. + assert_eq!( + span_of(&first).map(|(_, out)| out), + span_of(&second).map(|(in_, _)| in_), + "the shared seam does not move under the current mapping" + ); + assert_eq!(clip_media_in(&first), Rational::new(25, 1)); + assert_eq!(clip_media_in(&second), Rational::new(0, 1)); + + cmd.undo(); + assert_eq!(span_of(&first), Some((Rational::new(0, 1), Rational::new(50, 1)))); + assert_eq!(span_of(&second), Some((Rational::new(50, 1), Rational::new(100, 1)))); +} + +/// `BlockTrimCommand` (default `set_remove_zero_length_from_graph`) removes +/// a zero-length adjacent from the whole graph; undo re-attaches it. +#[test] +fn block_trim_removes_zero_length_adjacent_from_graph() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let gap = add_gap(&track, Rational::new(50, 1), Rational::new(75, 1)); + + // Trimming longer by exactly the gap length shrinks the gap to zero. + let mut cmd = BlockTrimCommand::new( + track.clone(), + clip.clone(), + Rational::new(75, 1), + MovementMode::TrimOut, + ); + cmd.prepare(); + cmd.redo(); + assert_eq!(track_block_count(&track), 1); + assert!(block_track(&gap).is_none()); + assert!(!node_in_graph(&project, &gap)); + + cmd.undo(); + assert_eq!(track_block_count(&track), 2); + assert!(node_in_graph(&project, &gap)); + assert_eq!(track_block_at(&track, 1).unwrap().id, gap.id); + assert_eq!(span_of(&gap), Some((Rational::new(50, 1), Rational::new(75, 1)))); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(50, 1)))); +} + +/// `BlockTrimCommand::set_remove_zero_length_from_graph(false)` leaves a +/// zero-length adjacent in the graph (only off the track). +#[test] +fn block_trim_zero_length_adjacent_kept_in_graph() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let gap = add_gap(&track, Rational::new(50, 1), Rational::new(75, 1)); + + let mut cmd = BlockTrimCommand::new( + track.clone(), + clip.clone(), + Rational::new(75, 1), + MovementMode::TrimOut, + ); + cmd.set_remove_zero_length_from_graph(false); + cmd.prepare(); + cmd.redo(); + assert_eq!(track_block_count(&track), 1); + assert!(block_track(&gap).is_none()); + assert!(node_in_graph(&project, &gap)); + + cmd.undo(); + assert_eq!(track_block_count(&track), 2); + assert_eq!(track_block_at(&track, 1).unwrap().id, gap.id); + assert_eq!(span_of(&gap), Some((Rational::new(50, 1), Rational::new(75, 1)))); +} + +/// A trim that does not change the length is a no-op; the boxing path +/// (`to_command` + `redo_now`/`undo_now`) routes through the same body. +#[test] +fn block_trim_same_length_is_noop() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + + let mut cmd = BlockTrimCommand::new( + track.clone(), + clip.clone(), + Rational::new(50, 1), + MovementMode::TrimOut, + ); + cmd.prepare(); + let mut boxed = cmd.to_command(); + boxed.redo_now(); + boxed.undo_now(); + assert_eq!(track_block_count(&track), 1); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(50, 1)))); +} + +/// `TrackSlideCommand` resizes both adjacent blocks by the movement; +/// undo restores their original lengths. +/// +/// KNOWN-SWAP (§3.1/§9 of `docs/zh/plans/test-coverage-90-80-review.md`): +/// under the current setters the slid block itself does not move; the +/// previous block grows leftward into the slide's start (reaching a negative +/// timeline in-point) while the next block's out shrinks by the movement. +/// These expectations characterize that state; the semantic fix must rewrite +/// them to a true slide. +#[test] +fn track_slide_resizes_adjacent_blocks() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let previous = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let slide = add_clip(&track, Rational::new(50, 1), Rational::new(100, 1)); + let next = add_clip(&track, Rational::new(100, 1), Rational::new(150, 1)); + + let mut cmd = TrackSlideCommand::new( + track.clone(), + vec![slide.clone()], + Some(previous.clone()), + Some(next.clone()), + Rational::new(20, 1), + ); + cmd.prepare(); + cmd.redo(); + assert_eq!(span_of(&previous), Some((Rational::new(-20, 1), Rational::new(50, 1)))); + assert_eq!(span_of(&slide), Some((Rational::new(50, 1), Rational::new(100, 1)))); + assert_eq!(span_of(&next), Some((Rational::new(100, 1), Rational::new(130, 1)))); + + cmd.undo(); + assert_eq!(span_of(&previous), Some((Rational::new(0, 1), Rational::new(50, 1)))); + assert_eq!(span_of(&next), Some((Rational::new(100, 1), Rational::new(150, 1)))); +} + +/// `TrackSlideCommand` removes the out adjacent when the movement exactly +/// consumes it; undo re-attaches it after the moving block. +/// +/// KNOWN-SWAP (§3.1/§9 of `docs/zh/plans/test-coverage-90-80-review.md`): +/// the swapped setters leave the slid clip in place and grow `in_gap`'s in +/// leftward (a negative timeline in-point) instead of resizing it on the +/// slide's trailing side; the semantic fix must rewrite this geometry. +#[test] +fn track_slide_removes_out_adjacent_at_boundary() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let in_gap = add_gap(&track, Rational::new(0, 1), Rational::new(20, 1)); + let clip = add_clip(&track, Rational::new(20, 1), Rational::new(70, 1)); + let out_gap = add_gap(&track, Rational::new(70, 1), Rational::new(90, 1)); + + let mut cmd = TrackSlideCommand::new( + track.clone(), + vec![clip.clone()], + Some(in_gap.clone()), + Some(out_gap.clone()), + Rational::new(20, 1), + ); + cmd.prepare(); + cmd.redo(); + assert_eq!(track_block_count(&track), 2); + assert!(block_track(&out_gap).is_none()); + assert!(!node_in_graph(&project, &out_gap)); + // KNOWN-SWAP: the gap grew leftward past zero because the slide did not + // move the clip (§3.1/§9). + assert_eq!(span_of(&in_gap), Some((Rational::new(-20, 1), Rational::new(20, 1)))); + + cmd.undo(); + assert_eq!(track_block_count(&track), 3); + assert!(node_in_graph(&project, &out_gap)); + assert_eq!(track_block_at(&track, 2).unwrap().id, out_gap.id); + assert_eq!(span_of(&in_gap), Some((Rational::new(0, 1), Rational::new(20, 1)))); + assert_eq!(span_of(&out_gap), Some((Rational::new(70, 1), Rational::new(90, 1)))); +} + +/// `TrackSlideCommand` removes the in adjacent when a leftward movement +/// exactly consumes it; undo re-attaches it before the moving block. +/// +/// KNOWN-SWAP (§3.1/§9 of `docs/zh/plans/test-coverage-90-80-review.md`): +/// the swapped setters leave the slid clip in place and grow `out_gap`'s out +/// rightward instead of resizing it on the slide's leading side; the +/// semantic fix must rewrite this geometry. +#[test] +fn track_slide_removes_in_adjacent_at_boundary() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let in_gap = add_gap(&track, Rational::new(0, 1), Rational::new(20, 1)); + let clip = add_clip(&track, Rational::new(20, 1), Rational::new(70, 1)); + let out_gap = add_gap(&track, Rational::new(70, 1), Rational::new(90, 1)); + + let mut cmd = TrackSlideCommand::new( + track.clone(), + vec![clip.clone()], + Some(in_gap.clone()), + Some(out_gap.clone()), + Rational::new(-20, 1), + ); + cmd.prepare(); + cmd.redo(); + assert_eq!(track_block_count(&track), 2); + assert!(block_track(&in_gap).is_none()); + assert!(!node_in_graph(&project, &in_gap)); + // KNOWN-SWAP: the gap grew rightward because the slide did not move the + // clip (§3.1/§9). + assert_eq!(span_of(&out_gap), Some((Rational::new(70, 1), Rational::new(110, 1)))); + + cmd.undo(); + assert_eq!(track_block_count(&track), 3); + assert!(node_in_graph(&project, &in_gap)); + assert_eq!(track_block_at(&track, 0).unwrap().id, in_gap.id); + assert_eq!(span_of(&in_gap), Some((Rational::new(0, 1), Rational::new(20, 1)))); + assert_eq!(span_of(&out_gap), Some((Rational::new(70, 1), Rational::new(90, 1)))); +} + +/// `TrackSlideCommand::prepare` creates the missing in adjacent (a leftward +/// slide into empty space) and undo removes it again. +#[test] +fn track_slide_creates_in_adjacent_gap() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + + let mut cmd = TrackSlideCommand::new( + track.clone(), + vec![clip.clone()], + None, + None, + Rational::new(-10, 1), + ); + cmd.prepare(); + cmd.redo(); + assert_eq!(track_block_count(&track), 2); + let created = track_block_at(&track, 0).unwrap(); + assert_eq!(block_kind(&created), BlockKind::Gap); + assert_eq!(span_of(&created), Some((Rational::new(-10, 1), Rational::new(0, 1)))); + assert_eq!(track_block_at(&track, 1).unwrap().id, clip.id); + + cmd.undo(); + assert_eq!(track_block_count(&track), 1); + assert!(!node_in_graph(&project, &created)); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); +} + +/// `TrackSlideCommand::prepare` creates the missing out adjacent when the +/// moving block has a successor, and undo removes it again. +#[test] +fn track_slide_creates_out_adjacent_gap() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let in_gap = add_gap(&track, Rational::new(0, 1), Rational::new(10, 1)); + let clip = add_clip(&track, Rational::new(10, 1), Rational::new(110, 1)); + let tail = add_clip(&track, Rational::new(110, 1), Rational::new(160, 1)); + + let mut cmd = TrackSlideCommand::new( + track.clone(), + vec![clip.clone()], + Some(in_gap.clone()), + None, + Rational::new(10, 1), + ); + cmd.prepare(); + cmd.redo(); + assert_eq!(track_block_count(&track), 4); + let created = track_block_at(&track, 2).unwrap(); + assert_eq!(block_kind(&created), BlockKind::Gap); + assert_eq!(span_of(&created), Some((Rational::new(110, 1), Rational::new(120, 1)))); + assert_eq!(track_block_at(&track, 3).unwrap().id, tail.id); + + cmd.undo(); + assert_eq!(track_block_count(&track), 3); + assert!(!node_in_graph(&project, &created)); + assert_eq!(span_of(&in_gap), Some((Rational::new(0, 1), Rational::new(10, 1)))); + assert_eq!(span_of(&clip), Some((Rational::new(10, 1), Rational::new(110, 1)))); +} + +// --------------------------------------------------------------------------- +// undotrack.rs +// --------------------------------------------------------------------------- + +/// `TrackPrependBlockCommand` prepends the block; undo detaches it again. +/// The trait dispatch (the oakundo path) is exercised explicitly. +#[test] +fn track_prepend_block_round_trip() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let existing = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let fresh = new_clip(&project, Rational::new(50, 1), Rational::new(100, 1)); + + let mut cmd = TrackPrependBlockCommand::new(track.clone(), fresh.clone()); + Command::redo(&mut cmd); + assert_eq!(track_block_count(&track), 2); + assert_eq!(track_block_at(&track, 0).unwrap().id, fresh.id); + assert_eq!(block_track(&fresh).map(|t| t.id), Some(track.id)); + + Command::undo(&mut cmd); + assert_eq!(track_block_count(&track), 1); + assert_eq!(track_block_at(&track, 0).unwrap().id, existing.id); + assert!(block_track(&fresh).is_none()); + + cmd.redo(); + assert_eq!(track_block_count(&track), 2); + cmd.undo(); + assert_eq!(track_block_count(&track), 1); +} + +/// `TrackInsertBlockAfterCommand` inserts after a predecessor (`None` +/// prepends) and undo ripples the block back out; the boxing path is +/// covered too. +#[test] +fn track_insert_block_after_round_trip() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let first = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let second = add_clip(&track, Rational::new(50, 1), Rational::new(100, 1)); + let inserted = new_clip(&project, Rational::new(50, 1), Rational::new(75, 1)); + + let mut cmd = + TrackInsertBlockAfterCommand::new(track.clone(), inserted.clone(), Some(first.clone())); + cmd.redo(); + assert_eq!(track_block_count(&track), 3); + assert_eq!(track_block_at(&track, 0).unwrap().id, first.id); + assert_eq!(track_block_at(&track, 1).unwrap().id, inserted.id); + assert_eq!(track_block_at(&track, 2).unwrap().id, second.id); + + cmd.undo(); + assert_eq!(track_block_count(&track), 2); + assert_eq!(track_block_at(&track, 0).unwrap().id, first.id); + assert_eq!(track_block_at(&track, 1).unwrap().id, second.id); + + // `None` predecessor inserts at the front. + let front = new_clip(&project, Rational::new(200, 1), Rational::new(250, 1)); + let mut boxed = + TrackInsertBlockAfterCommand::new(track.clone(), front.clone(), None).to_command(); + boxed.redo_now(); + assert_eq!(track_block_count(&track), 3); + assert_eq!(track_block_at(&track, 0).unwrap().id, front.id); + boxed.undo_now(); + assert_eq!(track_block_count(&track), 2); + assert!(block_track(&front).is_none()); +} + +/// `TrackReplaceBlockCommand` swaps one block for another at the same +/// track slot and swaps back on undo. +#[test] +fn track_replace_block_round_trip() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let old = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let replacement = new_clip(&project, Rational::new(0, 1), Rational::new(50, 1)); + + let mut cmd = TrackReplaceBlockCommand::new(track.clone(), old.clone(), replacement.clone()); + cmd.redo(); + assert_eq!(track_block_count(&track), 1); + assert_eq!(track_block_at(&track, 0).unwrap().id, replacement.id); + assert_eq!(block_track(&replacement).map(|t| t.id), Some(track.id)); + assert!(block_track(&old).is_none()); + + cmd.undo(); + assert_eq!(track_block_count(&track), 1); + assert_eq!(track_block_at(&track, 0).unwrap().id, old.id); + assert_eq!(block_track(&old).map(|t| t.id), Some(track.id)); + assert!(block_track(&replacement).is_none()); +} + +// --------------------------------------------------------------------------- +// undosplit.rs +// --------------------------------------------------------------------------- + +/// `TrackSplitAtTimeCommand` splits the block containing the point; undo +/// re-joins the halves, a second redo re-attaches the same second half. +#[test] +fn track_split_at_time_round_trip() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + + let mut cmd = TrackSplitAtTimeCommand::new(track.clone(), Rational::new(40, 1)); + cmd.prepare(); + cmd.redo(); + assert_eq!(track_block_count(&track), 2); + assert_eq!(track_block_at(&track, 0).unwrap().id, clip.id); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(40, 1)))); + let second = track_block_at(&track, 1).unwrap(); + assert_eq!(block_kind(&second), BlockKind::Clip); + assert_eq!(span_of(&second), Some((Rational::new(40, 1), Rational::new(100, 1)))); + + cmd.undo(); + assert_eq!(track_block_count(&track), 1); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); + + cmd.redo(); + assert_eq!(track_block_count(&track), 2); + assert_eq!(track_block_at(&track, 1).unwrap().id, second.id); +} + +/// `TrackSplitAtTimeCommand` with a point outside the block (past the +/// end, on the in point or on the out point) is a no-op. +#[test] +fn track_split_at_time_outside_block_is_noop() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + + for point in [Rational::new(100, 1), Rational::new(0, 1), Rational::new(150, 1)] { + let mut cmd = TrackSplitAtTimeCommand::new(track.clone(), point); + cmd.redo(); + cmd.undo(); + assert_eq!(track_block_count(&track), 1); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); + } +} + +/// `BlockSplitPreservingLinksCommand` splits every block containing the +/// time and links the resulting halves of originally linked blocks; undo +/// restores all originals. +#[test] +fn block_split_preserving_links_round_trip() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track_a = TimelineAddTrackCommand::run_immediately(list.clone()); + let track_b = TimelineAddTrackCommand::run_immediately(list.clone()); + let a = add_clip(&track_a, Rational::new(0, 1), Rational::new(100, 1)); + let b = add_clip(&track_b, Rational::new(0, 1), Rational::new(100, 1)); + link_blocks(&project, &a, &b); + assert!(blocks_are_linked(&project, &a, &b)); + + let mut cmd = BlockSplitPreservingLinksCommand::new( + vec![a.clone(), b.clone()], + vec![Rational::new(40, 1)], + ); + // `redo` builds and applies the child splits on first use. + cmd.redo(); + assert_eq!(track_block_count(&track_a), 2); + assert_eq!(track_block_count(&track_b), 2); + assert_eq!(span_of(&a), Some((Rational::new(0, 1), Rational::new(40, 1)))); + assert_eq!(span_of(&b), Some((Rational::new(0, 1), Rational::new(40, 1)))); + + let split_a = cmd.get_split(&a, 0).expect("a was split"); + let split_b = cmd.get_split(&b, 0).expect("b was split"); + assert_eq!(split_a.id, track_block_at(&track_a, 1).unwrap().id); + assert_eq!(split_b.id, track_block_at(&track_b, 1).unwrap().id); + assert_eq!(span_of(&split_a), Some((Rational::new(40, 1), Rational::new(100, 1)))); + assert_eq!(span_of(&split_b), Some((Rational::new(40, 1), Rational::new(100, 1)))); + // The new halves of the linked pair stay linked. + assert!(blocks_are_linked(&project, &split_a, &split_b)); + assert!(cmd.get_split(&split_a, 0).is_none()); + assert!(cmd.get_split(&a, 1).is_none()); + + cmd.undo(); + assert_eq!(track_block_count(&track_a), 1); + assert_eq!(track_block_count(&track_b), 1); + assert_eq!(span_of(&a), Some((Rational::new(0, 1), Rational::new(100, 1)))); + assert_eq!(span_of(&b), Some((Rational::new(0, 1), Rational::new(100, 1)))); + assert!(blocks_are_linked(&project, &a, &b)); + + cmd.redo(); + assert_eq!(track_block_count(&track_a), 2); + assert_eq!(track_block_count(&track_b), 2); +} + +/// A time that lies outside every block, and an empty block/time list, +/// produce no child commands and leave the tracks untouched. +#[test] +fn block_split_preserving_links_noop_time() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list.clone()); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + + let mut cmd = + BlockSplitPreservingLinksCommand::new(vec![clip.clone()], vec![Rational::new(200, 1)]); + cmd.redo(); + cmd.undo(); + assert_eq!(track_block_count(&track), 1); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); + assert!(cmd.get_split(&clip, 0).is_none()); + + let mut empty = BlockSplitPreservingLinksCommand::new(Vec::new(), Vec::new()); + empty.redo(); + empty.undo(); + assert_eq!(track_block_count(&track), 1); +} + +// --------------------------------------------------------------------------- +// undogeneral.rs +// --------------------------------------------------------------------------- + +/// `BlockResizeWithMediaInCommand` resizes keeping the timeline in-point +/// fixed (C++ `ClipBlock::set_length_and_media_in` adjusts `media_in` so the +/// media out stays put); undo restores the original length. +/// +/// KNOWN-SWAP (§3.1/§9 of `docs/zh/plans/test-coverage-90-80-review.md`): +/// today neither setter adjusts `media_in`, so the media +/// out is silently shortened with the block (media_in stays 0 here). The +/// assertion below pins that current state so the semantic fix must change +/// it deliberately (the fixed behavior yields `media_in = 20` for this +/// resize, keeping `media_out = 50`). +#[test] +fn block_resize_with_media_in_round_trip() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + + let mut cmd = BlockResizeWithMediaInCommand::new(clip.clone(), Rational::new(30, 1)); + cmd.redo(); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(30, 1)))); + assert_eq!(clip_media_in(&clip), Rational::new(0, 1)); + cmd.undo(); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(50, 1)))); + assert_eq!(clip_media_in(&clip), Rational::new(0, 1)); +} + +/// `BlockSetMediaInCommand` writes the media-in point and restores it on +/// undo; the boxing path is covered too. +#[test] +fn block_set_media_in_round_trip() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + assert_eq!(clip_media_in(&clip), Rational::new(0, 1)); + + let mut cmd = BlockSetMediaInCommand::new(clip.clone(), Rational::new(12, 1)); + cmd.redo(); + assert_eq!(clip_media_in(&clip), Rational::new(12, 1)); + cmd.undo(); + assert_eq!(clip_media_in(&clip), Rational::new(0, 1)); + + let mut boxed = BlockSetMediaInCommand::new(clip.clone(), Rational::new(20, 1)).to_command(); + boxed.redo_now(); + assert_eq!(clip_media_in(&clip), Rational::new(20, 1)); + boxed.undo_now(); + assert_eq!(clip_media_in(&clip), Rational::new(0, 1)); +} + +/// `BlockEnableDisableCommand` flips the enabled flag (captured at +/// construction) and restores it on undo; the boxing path is covered too. +#[test] +fn block_enable_disable_round_trip() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + assert!(block_enabled(&clip)); + + let mut cmd = BlockEnableDisableCommand::new(clip.clone(), false); + cmd.redo(); + assert!(!block_enabled(&clip)); + cmd.undo(); + assert!(block_enabled(&clip)); + + let mut boxed = BlockEnableDisableCommand::new(clip.clone(), false).to_command(); + boxed.redo_now(); + assert!(!block_enabled(&clip)); + boxed.undo_now(); + assert!(block_enabled(&clip)); +} + +/// `TrackListInsertGaps` extends an existing gap crossed by the insertion +/// point instead of adding a second one; undo restores its length. +/// +/// KNOWN-SWAP (§3.1/§9 of `docs/zh/plans/test-coverage-90-80-review.md`): +/// the gap grows leftwards across the insertion point +/// and overlaps the preceding block; the fixed `_media_out`/`_media_in` +/// semantics must re-derive this geometry. +#[test] +fn track_list_insert_gaps_extends_existing_gap() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list.clone()); + add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let gap = add_gap(&track, Rational::new(50, 1), Rational::new(100, 1)); + add_clip(&track, Rational::new(100, 1), Rational::new(150, 1)); + + let mut cmd = + TrackListInsertGaps::new(list, Rational::new(75, 1), Rational::new(20, 1)); + cmd.prepare(); + cmd.redo(); + // The gap keeps its out point and grows leftward by the gap length. + assert_eq!(track_block_count(&track), 3); + assert_eq!(span_of(&gap), Some((Rational::new(30, 1), Rational::new(100, 1)))); + + cmd.undo(); + assert_eq!(track_block_count(&track), 3); + assert_eq!(span_of(&gap), Some((Rational::new(50, 1), Rational::new(100, 1)))); +} + +/// `TrackListInsertGaps` at the in point of the first block prepends the +/// gap; undo removes it again. +#[test] +fn track_list_insert_gaps_at_track_start_round_trip() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list.clone()); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + + let mut cmd = + TrackListInsertGaps::new(list, Rational::new(0, 1), Rational::new(20, 1)); + cmd.prepare(); + cmd.redo(); + assert_eq!(track_block_count(&track), 2); + let gap = track_block_at(&track, 0).unwrap(); + assert_eq!(block_kind(&gap), BlockKind::Gap); + assert_eq!(span_of(&gap), Some((Rational::new(0, 1), Rational::new(20, 1)))); + assert_eq!(track_block_at(&track, 1).unwrap().id, clip.id); + + cmd.undo(); + assert_eq!(track_block_count(&track), 1); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); +} + +/// `TrackListInsertGaps` skips locked tracks; a point past the track end +/// produces no work at all. +#[test] +fn track_list_insert_gaps_locked_and_past_end_are_noop() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list.clone()); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + set_track_locked(&track, true); + + let mut locked = + TrackListInsertGaps::new(list.clone(), Rational::new(25, 1), Rational::new(20, 1)); + locked.prepare(); + locked.redo(); + locked.undo(); + assert_eq!(track_block_count(&track), 1); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(50, 1)))); + + // The point is past the single 50-long clip: no split, no gap. + set_track_locked(&track, false); + let mut past_end = TrackListInsertGaps::new(list, Rational::new(100, 1), Rational::new(20, 1)); + past_end.prepare(); + past_end.redo(); + past_end.undo(); + assert_eq!(track_block_count(&track), 1); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(50, 1)))); +} + +/// `TimelineAddDefaultTransitionCommand` walks the selection and builds no +/// child commands (the default-transition config has no Rust model yet), +/// so redo/undo are no-ops; `prepare` and the boxing path still run. +#[test] +fn timeline_add_default_transition_is_noop() { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + add_gap(&track, Rational::new(0, 1), Rational::new(10, 1)); + let a = add_clip(&track, Rational::new(10, 1), Rational::new(110, 1)); + let b = add_clip(&track, Rational::new(110, 1), Rational::new(210, 1)); + add_gap(&track, Rational::new(210, 1), Rational::new(230, 1)); + + let mut cmd = TimelineAddDefaultTransitionCommand::new( + vec![a.clone(), b.clone()], + Rational::new(1, 30), + ); + cmd.prepare(); + cmd.redo(); + cmd.undo(); + assert_eq!(track_block_count(&track), 4); + assert_eq!(block_kind(&track_block_at(&track, 0).unwrap()), BlockKind::Gap); + assert_eq!(block_kind(&track_block_at(&track, 1).unwrap()), BlockKind::Clip); + assert_eq!(block_kind(&track_block_at(&track, 2).unwrap()), BlockKind::Clip); + assert_eq!(block_kind(&track_block_at(&track, 3).unwrap()), BlockKind::Gap); + assert_eq!(span_of(&a), Some((Rational::new(10, 1), Rational::new(110, 1)))); + + let mut boxed = TimelineAddDefaultTransitionCommand::new( + vec![a.clone()], + Rational::new(1, 30), + ) + .to_command(); + boxed.redo_now(); + boxed.undo_now(); + assert_eq!(track_block_count(&track), 4); +} + +// --------------------------------------------------------------------------- +// Boxing sweep: `to_command` for the remaining commands +// --------------------------------------------------------------------------- + +/// The oakundo boxing path dispatches through the `Command` trait for the +/// commands that build their derived state on the first `redo`. One fresh +/// project per command keeps the command state independent. +#[test] +fn boxed_self_preparing_commands_round_trip() { + // TrackRippleRemoveAreaCommand (splice path). + { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + let mut boxed = TrackRippleRemoveAreaCommand::new( + track.clone(), + TimeRange::new(Rational::new(25, 1), Rational::new(75, 1)), + ) + .to_command(); + boxed.redo_now(); + assert_eq!(track_block_count(&track), 2); + boxed.undo_now(); + assert_eq!(track_block_count(&track), 1); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(100, 1)))); + } + + // TrackListRippleRemoveAreaCommand. + { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list.clone()); + add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + let mut cmd = TrackListRippleRemoveAreaCommand::new( + list, + Rational::new(25, 1), + Rational::new(75, 1), + ); + // `redo` runs the children but does not build them: prepare first. + cmd.prepare(); + let mut boxed = cmd.to_command(); + boxed.redo_now(); + assert_eq!(track_block_count(&track), 2); + boxed.undo_now(); + assert_eq!(track_block_count(&track), 1); + } + + // TimelineRippleDeleteGapsAtRegionsCommand. + { + let project = make_project(); + let (seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let gap = add_gap(&track, Rational::new(50, 1), Rational::new(100, 1)); + let mut boxed = TimelineRippleDeleteGapsAtRegionsCommand::new( + seq, + vec![( + track.clone(), + TimeRange::new(Rational::new(50, 1), Rational::new(100, 1)), + )], + ) + .to_command(); + boxed.redo_now(); + assert!(block_track(&gap).is_none()); + boxed.undo_now(); + assert!(block_track(&gap).is_some()); + } + + // TrackSplitAtTimeCommand. + { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + let mut boxed = + TrackSplitAtTimeCommand::new(track.clone(), Rational::new(40, 1)).to_command(); + boxed.redo_now(); + assert_eq!(track_block_count(&track), 2); + boxed.undo_now(); + assert_eq!(track_block_count(&track), 1); + } + + // BlockSplitPreservingLinksCommand. + { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track_a = TimelineAddTrackCommand::run_immediately(list.clone()); + let track_b = TimelineAddTrackCommand::run_immediately(list); + let a = add_clip(&track_a, Rational::new(0, 1), Rational::new(100, 1)); + let b = add_clip(&track_b, Rational::new(0, 1), Rational::new(100, 1)); + let mut boxed = BlockSplitPreservingLinksCommand::new( + vec![a, b], + vec![Rational::new(40, 1)], + ) + .to_command(); + boxed.redo_now(); + assert_eq!(track_block_count(&track_a), 2); + assert_eq!(track_block_count(&track_b), 2); + boxed.undo_now(); + assert_eq!(track_block_count(&track_a), 1); + assert_eq!(track_block_count(&track_b), 1); + } + + // BlockSplitCommand (round-tripped in domain_test; boxing here). + { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + let host = track_block_at(&track, 0).unwrap(); + let mut boxed = BlockSplitCommand::new(host, Rational::new(40, 1)).to_command(); + boxed.redo_now(); + assert_eq!(track_block_count(&track), 2); + boxed.undo_now(); + assert_eq!(track_block_count(&track), 1); + } + + // TrackPrependBlockCommand. + { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let fresh = new_clip(&project, Rational::new(50, 1), Rational::new(100, 1)); + let mut boxed = TrackPrependBlockCommand::new(track.clone(), fresh.clone()).to_command(); + boxed.redo_now(); + assert_eq!(track_block_at(&track, 0).unwrap().id, fresh.id); + boxed.undo_now(); + assert_eq!(track_block_count(&track), 1); + } + + // TrackReplaceBlockCommand. + { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let old = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let replacement = new_clip(&project, Rational::new(0, 1), Rational::new(50, 1)); + let mut boxed = + TrackReplaceBlockCommand::new(track.clone(), old.clone(), replacement.clone()) + .to_command(); + boxed.redo_now(); + assert_eq!(track_block_at(&track, 0).unwrap().id, replacement.id); + boxed.undo_now(); + assert_eq!(track_block_at(&track, 0).unwrap().id, old.id); + } + + // BlockResizeWithMediaInCommand. + { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let clip = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let mut boxed = + BlockResizeWithMediaInCommand::new(clip.clone(), Rational::new(30, 1)).to_command(); + boxed.redo_now(); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(30, 1)))); + boxed.undo_now(); + assert_eq!(span_of(&clip), Some((Rational::new(0, 1), Rational::new(50, 1)))); + } +} + +/// The boxing path for the commands whose derived state is built by an +/// explicit `prepare` (the boxed `Command` trait has no prepare callback). +#[test] +fn boxed_prepared_commands_round_trip() { + // TrackSlideCommand. + { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list); + let previous = add_clip(&track, Rational::new(0, 1), Rational::new(50, 1)); + let slide = add_clip(&track, Rational::new(50, 1), Rational::new(100, 1)); + let next = add_clip(&track, Rational::new(100, 1), Rational::new(150, 1)); + let mut cmd = TrackSlideCommand::new( + track.clone(), + vec![slide], + Some(previous.clone()), + Some(next.clone()), + Rational::new(20, 1), + ); + cmd.prepare(); + let mut boxed = cmd.to_command(); + boxed.redo_now(); + assert_eq!(span_of(&previous), Some((Rational::new(-20, 1), Rational::new(50, 1)))); + assert_eq!(span_of(&next), Some((Rational::new(100, 1), Rational::new(130, 1)))); + boxed.undo_now(); + assert_eq!(span_of(&previous), Some((Rational::new(0, 1), Rational::new(50, 1)))); + assert_eq!(span_of(&next), Some((Rational::new(100, 1), Rational::new(150, 1)))); + } + + // TrackListInsertGaps. + { + let project = make_project(); + let (_seq, list) = sequence_and_list(&project); + let track = TimelineAddTrackCommand::run_immediately(list.clone()); + add_clip(&track, Rational::new(0, 1), Rational::new(100, 1)); + let mut cmd = TrackListInsertGaps::new(list, Rational::new(40, 1), Rational::new(20, 1)); + cmd.prepare(); + let mut boxed = cmd.to_command(); + boxed.redo_now(); + assert_eq!(track_block_count(&track), 3); + boxed.undo_now(); + assert_eq!(track_block_count(&track), 1); + } +}