diff --git a/crates/oakengine/tests/common/mod.rs b/crates/oakengine/tests/common/mod.rs index 968d47f5d..cab0601fe 100644 --- a/crates/oakengine/tests/common/mod.rs +++ b/crates/oakengine/tests/common/mod.rs @@ -94,10 +94,7 @@ fn audio_params_store() -> &'static Mutex> { fn audio_params_get(ctx: *const c_void) -> MockAudioParams { let store = audio_params_store().lock().unwrap(); - store - .get(&(ctx as usize)) - .cloned() - .unwrap_or_default() + store.get(&(ctx as usize)).cloned().unwrap_or_default() } fn audio_params_set(ctx: *mut c_void, f: impl FnOnce(&mut MockAudioParams)) { @@ -153,7 +150,10 @@ pub extern "C" fn oakcore_audioparams_sample_rate(params: *const OakAudioParams) } #[no_mangle] -pub extern "C" fn oakcore_audioparams_set_sample_rate(params: *mut OakAudioParams, sample_rate: c_int) { +pub extern "C" fn oakcore_audioparams_set_sample_rate( + params: *mut OakAudioParams, + sample_rate: c_int, +) { audio_params_set(params as *mut c_void, |p| p.sample_rate = sample_rate); } @@ -168,7 +168,11 @@ pub extern "C" fn oakcore_audioparams_set_channel_layout(params: *mut OakAudioPa } #[no_mangle] -pub extern "C" fn oakcore_audioparams_set_time_base(params: *mut OakAudioParams, num: c_int, den: c_int) { +pub extern "C" fn oakcore_audioparams_set_time_base( + params: *mut OakAudioParams, + num: c_int, + den: c_int, +) { audio_params_set(params as *mut c_void, |p| { p.time_base_num = num; p.time_base_den = den; @@ -252,7 +256,10 @@ pub extern "C" fn oakcore_rational_free(rational: *mut c_void) { if rational.is_null() { return; } - rational_store().lock().unwrap().remove(&(rational as usize)); + rational_store() + .lock() + .unwrap() + .remove(&(rational as usize)); // SAFETY: produced by `oakcore_audioparams_time_base` as a boxed // `(i32, i32)` pair; we hold the only reference after removal. unsafe { drop(Box::from_raw(rational as *mut (i32, i32))) }; diff --git a/crates/oakengine/tests/it_audio.rs b/crates/oakengine/tests/it_audio.rs new file mode 100644 index 000000000..a3c2b22b0 --- /dev/null +++ b/crates/oakengine/tests/it_audio.rs @@ -0,0 +1,1196 @@ +// 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 . + +//! Integration tests for the audio family: the facade exports +//! `oakengine_audio_*` (src/audio.rs; module C contract +//! `include/audio/{manager,processor,sync,error}.h`), exercised end to +//! end against the REAL `oakaudio` crate — no mocks anywhere. +//! +//! The facade's 26 exported functions are all covered: +//! +//! * Manager (singleton, process-wide — serialized via [`with_manager`]): +//! create/destroy/instance handle, device accessors, output push/clock, +//! notify interval, recording start/stop. +//! * Sync (stateless): envelope offset/stretch correlation, source-time +//! and waveform-offset timeline placement. +//! * Processor (refcounted object — serialized via [`with_processor`] so +//! the module's debug alive counter (`oakaudio_debug_alive_count`) is +//! deterministic): create/free/open/close/is_open plus the two +//! documented facade stubs (`convert` returns `OAKENGINE_E_FAILED`, +//! `output_params` returns NULL — "not backed" in src/audio.rs). +//! +//! Legal-path value matrices pin exact results (device indices, rational +//! placement arithmetic, envelope correlation values); illegal inputs +//! (NULL pointers, empty handles, out-of-range sizes, garbage enum +//! values) must return a clean negative code or a documented no-op — +//! never crash/abort/panic. +//! +//! ## Ignored with reason +//! +//! * `processor_full_open_convert_cycle`: opening the conversion graph +//! requires the C++ host libffmpeg_bridge (`fb_audio_graph_*`), which +//! is not linked under `cargo test` (tests/common/mod.rs provides +//! no-op stubs); the module's open then fails cleanly at graph +//! creation. The validation and failure paths run for real in the +//! non-ignored tests; only the success path of a real graph is +//! environment-bound. +//! +//! Note: `start_recording` with an input device set drives the REAL +//! oakcodec FFmpeg encoder (ffmpeg-next), so it writes a real media file +//! to the system temp dir when the host has the codec; when the host +//! build cannot create the encoder the call fails with the module's +//! `OAKAUDIO_E_FAILED` and a diagnostic in `error_buf` — both outcomes +//! are asserted. + +#[path = "common/mod.rs"] +mod common; + +use std::ffi::{c_int, c_void, CStr}; +use std::path::PathBuf; +use std::sync::Mutex; + +use oakengine::audio::{ + oakengine_audio_clear_buffered_output, oakengine_audio_create_instance, + oakengine_audio_destroy_instance, oakengine_audio_estimate_envelope_offset, + oakengine_audio_estimate_stretch_and_offset, oakengine_audio_get_input_device, + oakengine_audio_get_output_device, oakengine_audio_hard_reset, + oakengine_audio_manager_handle, oakengine_audio_processor_close, + oakengine_audio_processor_convert, oakengine_audio_processor_create, + oakengine_audio_processor_free, oakengine_audio_processor_is_open, + oakengine_audio_processor_open, oakengine_audio_processor_output_params, + oakengine_audio_push_to_output, oakengine_audio_reset_output_clock, + oakengine_audio_set_input_device, oakengine_audio_set_output_device, + oakengine_audio_set_output_notify_interval, oakengine_audio_start_recording, + oakengine_audio_stop_output, oakengine_audio_stop_recording, + oakengine_audio_sync_place_by_source_time, oakengine_audio_sync_place_by_waveform_offset, + OakAudioSyncPlacement, OakAudioSyncSourceClip, OakAudioWaveformOffset, + OakAudioWaveformStretchOffset, +}; +use oakengine::error::{OAKENGINE_E_FAILED, OAKENGINE_E_INVALID}; +use oakengine::handle::{CHandle, OakEngineAudioProcessor}; + +/// `OAKAUDIO_E_INVALID` (include/audio/error.h) — module codes pass +/// through the facade untranslated. +const AUDIO_E_INVALID: c_int = -60001; +/// `OAKAUDIO_E_FAILED`. +const AUDIO_E_FAILED: c_int = -60003; + +// --------------------------------------------------------------------------- +// Serialization + shared fixtures +// --------------------------------------------------------------------------- + +/// Serialize manager-touching tests: the AudioManager singleton is +/// process-wide and its create/destroy flips a global flag, so all +/// manager tests take this lock and start from a destroyed state. +fn with_manager(f: impl FnOnce()) { + static LOCK: Mutex<()> = Mutex::new(()); + let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + f(); +} + +/// Serialize processor tests: each processor is an independent +/// refcounted object, but the module's debug alive counter is process +/// global, so count assertions need exclusive access to the family. +fn with_processor(f: impl FnOnce()) { + static LOCK: Mutex<()> = Mutex::new(()); + let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + f(); +} + +/// Current number of live refcounted oakaudio objects. +fn alive() -> c_int { + unsafe { oakaudio::ffi::manager::oakaudio_debug_alive_count() } +} + +/// A borrowed `OakAudioParams*` mock handle (tests/common/mod.rs provides +/// the `oakcore_audioparams_*` accessors the facade reads through). +fn audio_params(rate: c_int, layout: u64, format: c_int) -> *mut common::OakAudioParams { + common::oakcore_audioparams_create(rate, layout, format) +} + +/// Unique recording output path under the system temp dir. +fn recording_path() -> PathBuf { + std::env::temp_dir().join(format!("oak-it-audio-rec-{}.wav", std::process::id())) +} + +// --------------------------------------------------------------------------- +// Manager — lifecycle and devices (serialized) +// --------------------------------------------------------------------------- + +/// Manager lifecycle + device legal matrix + no-instance illegal matrix. +#[test] +fn manager_device_lifecycle() { + with_manager(|| { + let _ = common::force_link(); + + // Start from a destroyed state. + assert_eq!(unsafe { oakengine_audio_destroy_instance() }, 0); + + // --- No instance: every function reports a clean error. --- + assert!(unsafe { oakengine_audio_manager_handle() }.is_null()); + assert_eq!(unsafe { oakengine_audio_get_output_device() }, -1); // paNoDevice + assert_eq!(unsafe { oakengine_audio_get_input_device() }, -1); + assert_eq!(unsafe { oakengine_audio_set_output_device(0) }, OAKENGINE_E_FAILED); + assert_eq!(unsafe { oakengine_audio_set_input_device(0) }, OAKENGINE_E_FAILED); + assert_eq!(unsafe { oakengine_audio_hard_reset() }, OAKENGINE_E_FAILED); + assert_eq!(unsafe { oakengine_audio_clear_buffered_output() }, OAKENGINE_E_FAILED); + assert_eq!(unsafe { oakengine_audio_stop_output() }, OAKENGINE_E_FAILED); + assert_eq!(unsafe { oakengine_audio_stop_recording() }, OAKENGINE_E_FAILED); + assert_eq!(unsafe { oakengine_audio_reset_output_clock() }, OAKENGINE_E_FAILED); + assert_eq!( + unsafe { oakengine_audio_set_output_notify_interval(1024) }, + OAKENGINE_E_FAILED + ); + // push: NULL params is rejected at the facade before the module runs. + assert_eq!( + unsafe { + oakengine_audio_push_to_output( + std::ptr::null(), + c"data".as_ptr(), + 4, + std::ptr::null_mut(), + 0, + ) + }, + OAKENGINE_E_FAILED + ); + // start_recording: NULL params with no instance → the facade's + // manager check fires first (-3). + assert_eq!( + unsafe { oakengine_audio_start_recording(std::ptr::null_mut(), std::ptr::null_mut(), 0) }, + OAKENGINE_E_FAILED + ); + + // --- Lifecycle: create/destroy idempotence and re-create. --- + assert_eq!(unsafe { oakengine_audio_create_instance() }, 0); + assert_eq!(unsafe { oakengine_audio_create_instance() }, 0); // no-op when exists + assert_eq!(unsafe { oakengine_audio_destroy_instance() }, 0); + assert_eq!(unsafe { oakengine_audio_destroy_instance() }, 0); // no-op when absent + assert!(unsafe { oakengine_audio_manager_handle() }.is_null()); + + assert_eq!(unsafe { oakengine_audio_create_instance() }, 0); + assert!(!unsafe { oakengine_audio_manager_handle() }.is_null()); + + // --- Device accessors: legal matrix. --- + // The manager singleton retains its device state across + // destroy/recreate (the OnceLock box is kept; only a flag flips), + // so pin the devices explicitly instead of assuming fresh defaults. + assert_eq!(unsafe { oakengine_audio_set_output_device(-1) }, 0); + assert_eq!(unsafe { oakengine_audio_get_output_device() }, -1); + assert_eq!(unsafe { oakengine_audio_set_input_device(-1) }, 0); + assert_eq!(unsafe { oakengine_audio_get_input_device() }, -1); + + // The module records any device index (PortAudio enumeration is not + // bridged); -1 (paNoDevice), 0, a large index and a negative index. + for device in [-1i64, 0, 999999, -100] { + assert_eq!(unsafe { oakengine_audio_set_output_device(device) }, 0); + assert_eq!(unsafe { oakengine_audio_get_output_device() }, device); + } + // An i64 that does not fit an i32 narrows to 0 (C int narrowing). + assert_eq!(unsafe { oakengine_audio_set_output_device(1 << 40) }, 0); + assert_eq!(unsafe { oakengine_audio_get_output_device() }, 0); + + for device in [-1i64, 0, 999999] { + assert_eq!(unsafe { oakengine_audio_set_input_device(device) }, 0); + assert_eq!(unsafe { oakengine_audio_get_input_device() }, device); + } + + // --- Output controls. --- + assert_eq!(unsafe { oakengine_audio_reset_output_clock() }, 0); + assert_eq!(unsafe { oakengine_audio_stop_output() }, 0); + assert_eq!(unsafe { oakengine_audio_clear_buffered_output() }, 0); + assert_eq!(unsafe { oakengine_audio_hard_reset() }, 0); + // Notify interval: 0 disables, positive accepted, negative invalid. + assert_eq!(unsafe { oakengine_audio_set_output_notify_interval(0) }, 0); + assert_eq!(unsafe { oakengine_audio_set_output_notify_interval(1024) }, 0); + assert_eq!( + unsafe { oakengine_audio_set_output_notify_interval(-1) }, + AUDIO_E_INVALID + ); + + // --- push_to_output: legal + illegal matrix. --- + // NULL params is rejected at the facade (-3) even with an instance. + assert_eq!( + unsafe { + oakengine_audio_push_to_output( + std::ptr::null(), + c"data".as_ptr(), + 4, + std::ptr::null_mut(), + 0, + ) + }, + OAKENGINE_E_FAILED + ); + + // No output device selected → clean E_FAILED with a diagnostic. + assert_eq!(unsafe { oakengine_audio_set_output_device(-1) }, 0); + let params = audio_params(48000, 3, 10); // f32 packed, stereo, 48 kHz + let mut err = [0i8; 128]; + assert_eq!( + unsafe { + oakengine_audio_push_to_output( + params as *const c_void, + c"data".as_ptr(), + 4, + err.as_mut_ptr(), + err.len() as c_int, + ) + }, + AUDIO_E_FAILED + ); + assert_eq!( + unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap(), + "No output device is set" + ); + common::oakcore_audioparams_free(params); + + // Garbage sample format → E_INVALID. + let params = audio_params(48000, 3, 99); + assert_eq!( + unsafe { + oakengine_audio_push_to_output( + params as *const c_void, + c"data".as_ptr(), + 4, + std::ptr::null_mut(), + 0, + ) + }, + AUDIO_E_INVALID + ); + common::oakcore_audioparams_free(params); + + // Zero sample rate (mock default) → E_INVALID. + let params = audio_params(0, 3, 10); + assert_eq!( + unsafe { + oakengine_audio_push_to_output( + params as *const c_void, + c"data".as_ptr(), + 4, + std::ptr::null_mut(), + 0, + ) + }, + AUDIO_E_INVALID + ); + common::oakcore_audioparams_free(params); + + // NULL samples → E_INVALID. + let params = audio_params(48000, 3, 10); + assert_eq!( + unsafe { + oakengine_audio_push_to_output( + params as *const c_void, + std::ptr::null(), + 4, + std::ptr::null_mut(), + 0, + ) + }, + AUDIO_E_INVALID + ); + // Negative byte count → E_INVALID. + assert_eq!( + unsafe { + oakengine_audio_push_to_output( + params as *const c_void, + c"data".as_ptr(), + -1, + std::ptr::null_mut(), + 0, + ) + }, + AUDIO_E_INVALID + ); + common::oakcore_audioparams_free(params); + + // Legal push with a device selected: bytes are queued, error_buf + // stays untouched on success. + assert_eq!(unsafe { oakengine_audio_set_output_device(0) }, 0); + let params = audio_params(48000, 3, 10); + let mut err = [0i8; 128]; + assert_eq!( + unsafe { + oakengine_audio_push_to_output( + params as *const c_void, + c"data".as_ptr(), + 4, + err.as_mut_ptr(), + err.len() as c_int, + ) + }, + 0 + ); + assert_eq!(unsafe { *err.as_ptr() }, 0, "error_buf untouched on success"); + // A zero-length push is legal (empty queue op). + assert_eq!( + unsafe { + oakengine_audio_push_to_output( + params as *const c_void, + c"".as_ptr(), + 0, + std::ptr::null_mut(), + 0, + ) + }, + 0 + ); + common::oakcore_audioparams_free(params); + + assert_eq!(unsafe { oakengine_audio_destroy_instance() }, 0); + }); +} + +/// Recording: no-device / invalid-params paths and a real encoder start. +#[test] +fn manager_recording() { + with_manager(|| { + assert_eq!(unsafe { oakengine_audio_destroy_instance() }, 0); + assert_eq!(unsafe { oakengine_audio_create_instance() }, 0); + + // NULL params → E_INVALID at the facade. + assert_eq!( + unsafe { oakengine_audio_start_recording(std::ptr::null_mut(), std::ptr::null_mut(), 0) }, + OAKENGINE_E_INVALID + ); + + // audio_enabled == 0 → E_INVALID with a diagnostic. + let mut params = recording_params(false); + let mut err = [0i8; 128]; + assert_eq!( + unsafe { + oakengine_audio_start_recording( + (&mut params as *mut oakcodec::ffi::encoder::oakcodec_encoding_params) + .cast::(), + err.as_mut_ptr(), + err.len() as c_int, + ) + }, + AUDIO_E_INVALID + ); + assert_eq!( + unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap(), + "invalid recording parameters" + ); + + // Valid params but no input device → clean E_FAILED. Pin the input + // device to paNoDevice first (the retained singleton may hold a + // device index set by a prior serialized manager test). + assert_eq!(unsafe { oakengine_audio_set_input_device(-1) }, 0); + let mut params = recording_params(true); + let mut err = [0i8; 128]; + assert_eq!( + unsafe { + oakengine_audio_start_recording( + (&mut params as *mut oakcodec::ffi::encoder::oakcodec_encoding_params) + .cast::(), + err.as_mut_ptr(), + err.len() as c_int, + ) + }, + AUDIO_E_FAILED + ); + assert_eq!( + unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap(), + "no input device" + ); + + // With an input device the real oakcodec encoder runs: the module + // records to a WAV file (pcm_s16le) and reports OAKAUDIO_OK when the + // host FFmpeg build can create the encoder, or OAKAUDIO_E_FAILED with + // a diagnostic otherwise. Either way the return is a clean code. + assert_eq!(unsafe { oakengine_audio_set_input_device(0) }, 0); + let path = recording_path(); + let _ = std::fs::remove_file(&path); + let mut params = recording_params(true); + let mut err = [0i8; 512]; + let rc = unsafe { + oakengine_audio_start_recording( + (&mut params as *mut oakcodec::ffi::encoder::oakcodec_encoding_params) + .cast::(), + err.as_mut_ptr(), + err.len() as c_int, + ) + }; + // On this host the real oakcodec encoder opens the WAV output and the + // recording starts (rc == 0, file written); a host without the codec + // reports OAKAUDIO_E_FAILED with a diagnostic. Either outcome is a + // clean code with the corresponding side effect. + match rc { + 0 => assert!(path.exists(), "recording file written"), + AUDIO_E_FAILED => { + let msg = unsafe { CStr::from_ptr(err.as_ptr()) }.to_str().unwrap_or(""); + assert!(!msg.is_empty(), "encoder failure should write a diagnostic"); + } + other => panic!("unexpected recording rc {other}"), + } + assert!(rc == 0 || rc == AUDIO_E_FAILED, "unexpected rc {rc}"); + if rc == 0 { + assert!(path.exists(), "recording file written"); + } + // Recording is stopped unconditionally (idle stop is a no-op), then + // the file is removed. + assert_eq!(unsafe { oakengine_audio_stop_recording() }, 0); + assert_eq!(unsafe { oakengine_audio_stop_recording() }, 0); + let _ = std::fs::remove_file(&path); + + assert_eq!(unsafe { oakengine_audio_destroy_instance() }, 0); + }); +} + +/// `oakcodec_encoding_params` with WAV / pcm_s16le and the requested audio +/// track. +fn recording_params(audio_enabled: bool) -> oakcodec::ffi::encoder::oakcodec_encoding_params { + let mut p: oakcodec::ffi::encoder::oakcodec_encoding_params = unsafe { + std::mem::zeroed() + }; + let path = recording_path(); + let bytes = path.as_os_str().as_encoded_bytes(); + assert!(bytes.len() < p.filename.len(), "temp path too long"); + p.filename[..bytes.len()].copy_from_slice(bytes); + p.format = 7; // WAV + p.audio_enabled = audio_enabled as c_int; + p.audio_codec = 13; // PCM_S16LE + p.audio_sample_rate = 48000; + p.audio_channel_layout = 3; // stereo + p.audio_sample_format = 7; // SampleFormat::S16 (packed) + p.export_length_num = 1; + p.export_length_den = 1; + p +} + +// --------------------------------------------------------------------------- +// Sync — envelope correlation (stateless) +// --------------------------------------------------------------------------- + +/// Envelope offset correlation finds the exact shift of a delayed copy. +#[test] +fn sync_estimate_envelope_offset() { + // candidate[k] == reference[k-1]: the candidate lags the reference by + // one envelope window, so the best lag is +1 window = +window_samples. + let reference = [0.1_f64, 0.8, 0.3, 0.6, 0.9]; + let candidate = [0.0_f64, 0.1, 0.8, 0.3, 0.6]; + let mut out = OakAudioWaveformOffset { + offset_samples: 0, + confidence: 0.0, + valid: 0, + }; + let rc = unsafe { + oakengine_audio_estimate_envelope_offset( + reference.as_ptr(), + 5, + candidate.as_ptr(), + 5, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + 128, + 1, + &mut out, + ) + }; + assert_eq!(rc, 0); + assert_eq!(out.valid, 1); + assert_eq!(out.offset_samples, 128); + assert!((out.confidence - 1.0).abs() < 1e-9); + + // Explicit all-valid masks (the contract allows NULL = all valid) give + // the same result. + let ref_valid = [1u8; 5]; + let cand_valid = [1u8; 5]; + let mut out = OakAudioWaveformOffset { + offset_samples: 0, + confidence: 0.0, + valid: 0, + }; + let rc = unsafe { + oakengine_audio_estimate_envelope_offset( + reference.as_ptr(), + 5, + candidate.as_ptr(), + 5, + ref_valid.as_ptr(), + 5, + cand_valid.as_ptr(), + 5, + 128, + 1, + &mut out, + ) + }; + assert_eq!(rc, 0); + assert_eq!(out.valid, 1); + assert_eq!(out.offset_samples, 128); + + // A single constant envelope carries no correlation energy: valid=0 is + // the documented "no estimate" outcome, rc stays 0. + let flat = [0.5_f64, 0.5, 0.5, 0.5]; + let mut out = OakAudioWaveformOffset { + offset_samples: 0, + confidence: 0.0, + valid: 0, + }; + let rc = unsafe { + oakengine_audio_estimate_envelope_offset( + flat.as_ptr(), + 4, + flat.as_ptr(), + 4, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + 128, + 4, + &mut out, + ) + }; + assert_eq!(rc, 0); + assert_eq!(out.valid, 0); +} + +/// Envelope offset: every NULL/zero/size/garbage argument fails cleanly. +#[test] +fn sync_estimate_envelope_offset_invalid() { + let reference = [0.1_f64, 0.8, 0.3, 0.6, 0.9]; + let mut out = OakAudioWaveformOffset { + offset_samples: 0, + confidence: 0.0, + valid: 0, + }; + + // NULL pointers are rejected at the facade (-1). + assert_eq!( + unsafe { + oakengine_audio_estimate_envelope_offset( + std::ptr::null(), + 5, + reference.as_ptr(), + 5, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + 128, + 4, + &mut out, + ) + }, + OAKENGINE_E_INVALID + ); + assert_eq!( + unsafe { + oakengine_audio_estimate_envelope_offset( + reference.as_ptr(), + 5, + std::ptr::null(), + 5, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + 128, + 4, + &mut out, + ) + }, + OAKENGINE_E_INVALID + ); + assert_eq!( + unsafe { + oakengine_audio_estimate_envelope_offset( + reference.as_ptr(), + 5, + reference.as_ptr(), + 5, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + 128, + 4, + std::ptr::null_mut(), + ) + }, + OAKENGINE_E_INVALID + ); + + // Zero/negative lengths, zero window, negative max offset → module + // E_INVALID (-60001). + for (len, window, max_off) in [(0, 128u64, 4i64), (-1, 128, 4), (5, 0, 4), (5, 128, -1)] { + assert_eq!( + unsafe { + oakengine_audio_estimate_envelope_offset( + reference.as_ptr(), + len, + reference.as_ptr(), + len, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + window, + max_off, + &mut out, + ) + }, + AUDIO_E_INVALID, + "len={len} window={window} max_off={max_off}" + ); + } +} + +/// Stretch+offset correlation: an identical candidate resolves to rate +/// 1.0 with zero offset. +#[test] +fn sync_estimate_stretch_and_offset() { + let reference = [0.1_f64, 0.8, 0.3, 0.6, 0.9]; + let mut out = OakAudioWaveformStretchOffset { + rate: 0.0, + offset_samples: 0, + confidence: 0.0, + valid: 0, + }; + let rc = unsafe { + oakengine_audio_estimate_stretch_and_offset( + reference.as_ptr(), + 5, + reference.as_ptr(), + 5, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + 128, + 1, + 0.5, + 1.5, + 0.25, + &mut out, + ) + }; + assert_eq!(rc, 0); + assert_eq!(out.valid, 1); + assert_eq!(out.offset_samples, 0); + assert!((out.rate - 1.0).abs() < 1e-9); + assert!((out.confidence - 1.0).abs() < 1e-9); + + // Illegal ranges fail cleanly: NULL out (-1), bad rate bounds (-60001). + assert_eq!( + unsafe { + oakengine_audio_estimate_stretch_and_offset( + reference.as_ptr(), + 5, + reference.as_ptr(), + 5, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + 128, + 4, + 0.5, + 1.5, + 0.25, + std::ptr::null_mut(), + ) + }, + OAKENGINE_E_INVALID + ); + for (min_rate, max_rate, step) in [(0.0, 1.5, 0.25), (-1.0, 1.5, 0.25), (1.5, 1.0, 0.25), (0.5, 1.5, 0.0)] { + assert_eq!( + unsafe { + oakengine_audio_estimate_stretch_and_offset( + reference.as_ptr(), + 5, + reference.as_ptr(), + 5, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + 128, + 4, + min_rate, + max_rate, + step, + &mut out, + ) + }, + AUDIO_E_INVALID, + "min={min_rate} max={max_rate} step={step}" + ); + } +} + +// --------------------------------------------------------------------------- +// Sync — timeline placement (stateless) +// --------------------------------------------------------------------------- + +/// `place_by_source_time`: timeline_in = reference_timeline_in + +/// (candidate.source_start + candidate.media_in) - +/// (reference.source_start + reference.media_in). +#[test] +fn sync_place_by_source_time() { + // Integers: 3 + (5 + 1) - (10 + 0) = -1. + let reference = OakAudioSyncSourceClip { + source_start_time_num: 10, + source_start_time_den: 1, + media_in_num: 0, + media_in_den: 1, + has_source_start_time: 1, + }; + let candidate = OakAudioSyncSourceClip { + source_start_time_num: 5, + source_start_time_den: 1, + media_in_num: 1, + media_in_den: 1, + has_source_start_time: 1, + }; + let mut out = OakAudioSyncPlacement { + timeline_in_num: 0, + timeline_in_den: 1, + valid: 0, + }; + let rc = unsafe { + oakengine_audio_sync_place_by_source_time( + &reference, + &candidate, + 3, + 1, + &mut out, + ) + }; + assert_eq!(rc, 0); + assert_eq!(out.timeline_in_num, -1); + assert_eq!(out.timeline_in_den, 1); + assert_eq!(out.valid, 1); + + // Rationals: 5 + (1 + 1/4) - (1/2 + 0) = 23/4. + let reference = OakAudioSyncSourceClip { + source_start_time_num: 1, + source_start_time_den: 2, + media_in_num: 0, + media_in_den: 1, + has_source_start_time: 1, + }; + let candidate = OakAudioSyncSourceClip { + source_start_time_num: 1, + source_start_time_den: 1, + media_in_num: 1, + media_in_den: 4, + has_source_start_time: 1, + }; + let mut out = OakAudioSyncPlacement { + timeline_in_num: 0, + timeline_in_den: 1, + valid: 0, + }; + let rc = unsafe { + oakengine_audio_sync_place_by_source_time(&reference, &candidate, 5, 1, &mut out) + }; + assert_eq!(rc, 0); + assert_eq!(out.timeline_in_num, 23); + assert_eq!(out.timeline_in_den, 4); + assert_eq!(out.valid, 1); + + // A clip without a source start time is documented invalid: rc 0, the + // placement is 0/0 and valid=0 (not an error). + let no_source = OakAudioSyncSourceClip { + source_start_time_num: 0, + source_start_time_den: 1, + media_in_num: 0, + media_in_den: 1, + has_source_start_time: 0, + }; + let mut out = OakAudioSyncPlacement { + timeline_in_num: 0, + timeline_in_den: 1, + valid: 0, + }; + let rc = unsafe { + oakengine_audio_sync_place_by_source_time(&no_source, &candidate, 3, 1, &mut out) + }; + assert_eq!(rc, 0); + assert_eq!(out.valid, 0); + assert_eq!(out.timeline_in_num, 0); + assert_eq!(out.timeline_in_den, 0); + + // Illegal arguments: NULL pointers → -1; zero denominators → -60001. + assert_eq!( + unsafe { + oakengine_audio_sync_place_by_source_time( + std::ptr::null(), + &candidate, + 3, + 1, + &mut out, + ) + }, + OAKENGINE_E_INVALID + ); + assert_eq!( + unsafe { + oakengine_audio_sync_place_by_source_time( + &reference, + std::ptr::null(), + 3, + 1, + &mut out, + ) + }, + OAKENGINE_E_INVALID + ); + assert_eq!( + unsafe { + oakengine_audio_sync_place_by_source_time( + &reference, + &candidate, + 3, + 1, + std::ptr::null_mut(), + ) + }, + OAKENGINE_E_INVALID + ); + let bad_den = OakAudioSyncSourceClip { + source_start_time_num: 1, + source_start_time_den: 1, + media_in_num: 0, + media_in_den: 0, // zero denominator + has_source_start_time: 1, + }; + assert_eq!( + unsafe { + oakengine_audio_sync_place_by_source_time( + &reference, + &bad_den, + 3, + 1, + &mut out, + ) + }, + AUDIO_E_INVALID + ); + assert_eq!( + unsafe { + oakengine_audio_sync_place_by_source_time( + &reference, + &candidate, + 3, + 0, // zero reference timeline denominator + &mut out, + ) + }, + AUDIO_E_INVALID + ); +} + +/// `place_by_waveform_offset`: timeline_in = reference_timeline_in + +/// candidate_offset_samples / sample_rate. +#[test] +fn sync_place_by_waveform_offset() { + let mut out = OakAudioSyncPlacement { + timeline_in_num: 0, + timeline_in_den: 1, + valid: 0, + }; + // 48000 samples at 48 kHz = 1 second. + assert_eq!( + unsafe { oakengine_audio_sync_place_by_waveform_offset(0, 1, 48000, 48000, &mut out) }, + 0 + ); + assert_eq!(out.timeline_in_num, 1); + assert_eq!(out.timeline_in_den, 1); + assert_eq!(out.valid, 1); + + // Zero offset keeps the reference timeline point (5/2 stays 5/2). + assert_eq!( + unsafe { oakengine_audio_sync_place_by_waveform_offset(5, 2, 0, 48000, &mut out) }, + 0 + ); + assert_eq!(out.timeline_in_num, 5); + assert_eq!(out.timeline_in_den, 2); + assert_eq!(out.valid, 1); + + // Negative offset: -24000 samples = -0.5 s. + assert_eq!( + unsafe { oakengine_audio_sync_place_by_waveform_offset(0, 1, -24000, 48000, &mut out) }, + 0 + ); + assert_eq!(out.timeline_in_num, -1); + assert_eq!(out.timeline_in_den, 2); + assert_eq!(out.valid, 1); + + // 1/2 + 1 s = 3/2. + assert_eq!( + unsafe { oakengine_audio_sync_place_by_waveform_offset(1, 2, 48000, 48000, &mut out) }, + 0 + ); + assert_eq!(out.timeline_in_num, 3); + assert_eq!(out.timeline_in_den, 2); + assert_eq!(out.valid, 1); + + // Illegal: NULL out → -1; zero reference denominator → -60001; + // sample_rate <= 0 is documented invalid (rc 0, valid 0). + assert_eq!( + unsafe { oakengine_audio_sync_place_by_waveform_offset(0, 1, 0, 48000, std::ptr::null_mut()) }, + OAKENGINE_E_INVALID + ); + assert_eq!( + unsafe { oakengine_audio_sync_place_by_waveform_offset(0, 0, 0, 48000, &mut out) }, + AUDIO_E_INVALID + ); + assert_eq!( + unsafe { oakengine_audio_sync_place_by_waveform_offset(0, 1, 0, 0, &mut out) }, + 0 + ); + assert_eq!(out.valid, 0); + assert_eq!(out.timeline_in_num, 0); + assert_eq!(out.timeline_in_den, 0); +} + +// --------------------------------------------------------------------------- +// Processor — lifecycle, free contracts, validation (serialized) +// --------------------------------------------------------------------------- + +/// Create/free round-trip, NULL/empty free, module double-free safety and +/// the alive-count leak check. +#[test] +fn processor_lifecycle_and_free_contracts() { + with_processor(|| { + let baseline = alive(); + + // free(NULL) is a no-op. + unsafe { oakengine_audio_processor_free(std::ptr::null_mut()) }; + assert_eq!(alive(), baseline); + + // free(empty handle box) is a no-op: a box wrapping CHandle::null + // has nothing to release. The box must be a real heap box + // (free_box deallocates it); a stack-allocated wrapper would be + // deallocated out from under its owner. + let empty_ptr = oakengine::handle::box_handle::(CHandle::null()); + assert!(!empty_ptr.is_null()); + unsafe { oakengine_audio_processor_free(empty_ptr) }; + assert_eq!(alive(), baseline); + + // create bumps the counter; free restores it (leak check). + let p = unsafe { oakengine_audio_processor_create() }; + assert!(!p.is_null()); + assert_eq!(alive(), baseline + 1); + unsafe { oakengine_audio_processor_free(p) }; + assert_eq!(alive(), baseline); + + // The module-level free is double-free-safe (ctx is nulled after + // release); the counter decrements exactly once. + let mut h = oakaudio::handle::CHandle::null(); + h = unsafe { oakaudio::ffi::processor::oakaudio_processor_init() }; + assert!(!h.ctx.is_null()); + assert_eq!(alive(), baseline + 1); + unsafe { oakaudio::ffi::processor::oakaudio_processor_free(&mut h) }; + unsafe { oakaudio::ffi::processor::oakaudio_processor_free(&mut h) }; // no-op + assert!(h.ctx.is_null()); + assert_eq!(alive(), baseline); + }); +} + +/// Processor open: validation order and the clean failure of the +/// environment-bound graph creation. +#[test] +fn processor_open_validation() { + with_processor(|| { + let p = unsafe { oakengine_audio_processor_create() }; + assert!(!p.is_null()); + + // NULL `to`/`from` params handles → -1 at the facade. + assert_eq!( + unsafe { oakengine_audio_processor_open(p, std::ptr::null(), std::ptr::null(), 1.0) }, + OAKENGINE_E_INVALID + ); + + // Garbage params: zero rates (mock default) → -60001; tempo <= 0 → + // -60001; non-planar output format → -60001. + let from0 = audio_params(0, 3, 4); + let to0 = audio_params(0, 3, 4); + assert_eq!( + unsafe { oakengine_audio_processor_open(p, from0 as *const c_void, to0 as *const c_void, 1.0) }, + AUDIO_E_INVALID + ); + common::oakcore_audioparams_free(from0); + common::oakcore_audioparams_free(to0); + + let from = audio_params(48000, 3, 4); + let to = audio_params(48000, 3, 4); + assert_eq!( + unsafe { oakengine_audio_processor_open(p, from as *const c_void, to as *const c_void, 0.0) }, + AUDIO_E_INVALID + ); + assert_eq!( + unsafe { oakengine_audio_processor_open(p, from as *const c_void, to as *const c_void, -1.0) }, + AUDIO_E_INVALID + ); + common::oakcore_audioparams_free(from); + common::oakcore_audioparams_free(to); + + // Output format must be planar f32 (4); f32 packed (10) is invalid. + let from = audio_params(48000, 3, 4); + let to_packed = audio_params(48000, 3, 10); + assert_eq!( + unsafe { + oakengine_audio_processor_open(p, from as *const c_void, to_packed as *const c_void, 1.0) + }, + AUDIO_E_INVALID + ); + common::oakcore_audioparams_free(from); + common::oakcore_audioparams_free(to_packed); + + // Legal arguments reach the module's graph creation, which needs the + // host libffmpeg_bridge (not linked under cargo test): the open + // reports OAKAUDIO_E_FAILED and the processor stays closed. + let from = audio_params(48000, 3, 4); + let to = audio_params(48000, 3, 4); + assert_eq!( + unsafe { oakengine_audio_processor_open(p, from as *const c_void, to as *const c_void, 1.0) }, + AUDIO_E_FAILED + ); + assert_eq!(unsafe { oakengine_audio_processor_is_open(p) }, 0); + common::oakcore_audioparams_free(from); + common::oakcore_audioparams_free(to); + + // A failed open left the processor closed: opening again is not + // "already open". + assert_eq!(unsafe { oakengine_audio_processor_close(p) }, 0); + + unsafe { oakengine_audio_processor_free(p) }; + }); +} + +/// is_open/close on NULL, empty and closed handles. +#[test] +fn processor_is_open_and_close() { + with_processor(|| { + let p = unsafe { oakengine_audio_processor_create() }; + assert!(!p.is_null()); + + // NULL handle: is_open → 0, close → 0 (documented no-ops). + assert_eq!(unsafe { oakengine_audio_processor_is_open(std::ptr::null_mut()) }, 0); + assert_eq!(unsafe { oakengine_audio_processor_close(std::ptr::null_mut()) }, 0); + + // Empty handle box: -1 (invalid) from both. + let mut empty_box = OakEngineAudioProcessor { handle: CHandle::null() }; + let empty_ptr = &mut empty_box as *mut OakEngineAudioProcessor; + assert_eq!(unsafe { oakengine_audio_processor_is_open(empty_ptr) }, OAKENGINE_E_INVALID); + assert_eq!(unsafe { oakengine_audio_processor_close(empty_ptr) }, OAKENGINE_E_INVALID); + + // Fresh processor: closed. close on a closed processor is a no-op + // (0); is_open stays 0. + assert_eq!(unsafe { oakengine_audio_processor_is_open(p) }, 0); + assert_eq!(unsafe { oakengine_audio_processor_close(p) }, 0); + assert_eq!(unsafe { oakengine_audio_processor_close(p) }, 0); + assert_eq!(unsafe { oakengine_audio_processor_is_open(p) }, 0); + + unsafe { oakengine_audio_processor_free(p) }; + }); +} + +/// The two documented facade stubs: convert is "not backed" and always +/// returns E_FAILED; output_params is "not backed" and always returns +/// NULL. Both must tolerate any pointer. +#[test] +fn processor_convert_and_output_params_stubs() { + with_processor(|| { + let p = unsafe { oakengine_audio_processor_create() }; + assert!(!p.is_null()); + + let mut in_planes: [*mut f32; 1] = [std::ptr::null_mut()]; + let mut out_data: *const c_void = std::ptr::null(); + let mut out_size: c_int = 0; + + // NULL handle. + assert_eq!( + unsafe { + oakengine_audio_processor_convert( + std::ptr::null_mut(), + in_planes.as_mut_ptr(), + 0, + &mut out_data, + &mut out_size, + ) + }, + OAKENGINE_E_FAILED + ); + assert!(unsafe { oakengine_audio_processor_output_params(std::ptr::null_mut()) }.is_null()); + + // Valid handle — same documented stub result. + assert_eq!( + unsafe { + oakengine_audio_processor_convert( + p, + in_planes.as_mut_ptr(), + 0, + &mut out_data, + &mut out_size, + ) + }, + OAKENGINE_E_FAILED + ); + assert!(unsafe { oakengine_audio_processor_output_params(p) }.is_null()); + + unsafe { oakengine_audio_processor_free(p) }; + }); +} + +/// The full open→convert cycle requires the C++ host libffmpeg_bridge +/// (fb_audio_graph_*) which is not linked under `cargo test` — the module +/// open then fails at graph creation (OAKAUDIO_E_FAILED, asserted in +/// [`processor_open_validation`]). This documents the intended success +/// contract for a host-linked build. +#[test] +#[ignore = "needs the C++ host libffmpeg_bridge (fb_audio_graph_*), not linked under cargo test"] +fn processor_full_open_convert_cycle() { + with_processor(|| { + let p = unsafe { oakengine_audio_processor_create() }; + assert!(!p.is_null()); + let from = audio_params(48000, 3, 4); + let to = audio_params(48000, 3, 4); + let rc = unsafe { + oakengine_audio_processor_open(p, from as *const c_void, to as *const c_void, 1.0) + }; + assert_eq!(rc, 0); + assert_eq!(unsafe { oakengine_audio_processor_is_open(p) }, 1); + let mut in_planes: [*mut f32; 2] = [std::ptr::null_mut(); 2]; + let mut out_data: *const c_void = std::ptr::null(); + let mut out_size: c_int = 0; + assert_eq!( + unsafe { + oakengine_audio_processor_convert( + p, + in_planes.as_mut_ptr(), + 0, + &mut out_data, + &mut out_size, + ) + }, + 0 + ); + common::oakcore_audioparams_free(from); + common::oakcore_audioparams_free(to); + unsafe { oakengine_audio_processor_free(p) }; + }); +} diff --git a/crates/oakengine/tests/it_codec.rs b/crates/oakengine/tests/it_codec.rs new file mode 100644 index 000000000..d7d22c685 --- /dev/null +++ b/crates/oakengine/tests/it_codec.rs @@ -0,0 +1,1391 @@ +// 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 . + +//! Integration tests for the **codec family** — the facade module +//! `src/codec.rs` (contract: `engine/include/oakengine/encoding.h`, backed +//! by the oakcodec module headers `include/codec/{format,encoder}.h`). +//! +//! Every one of the 81 `oakengine_encoding_*` / `oakengine_export_*` +//! exports is exercised through real module behavior — no mocks, no +//! injected backends. Two error-code namespaces are in play: +//! +//! - The facade's own codes (`error.rs`, `-1..-6`): used when the facade +//! itself rejects the call (NULL handle, out-of-range `set_format`, ...). +//! - The wrapped module codes, which the facade passes through **untranslated** +//! (`error.rs`): `-50001`/`-50004` for the oakcodec metadata family +//! (`include/codec/error.h`) and `-60001`/`-60003` for the oakaudio +//! recording path (`include/audio/error.h`). +//! +//! String getters follow the engine's buf/size two-stage convention: the +//! return value is the string length **excluding** the NUL (negative = +//! error), and a NULL `buf` / `buf_size <= 0` only reports the length. +//! +//! Destroy contracts: `oakengine_encoding_params_destroy` is a facade-owned +//! raw box (`Box`) with **no** refcount and **no** debug alive +//! counter (unlike the oakcodec `CHandle` objects, which this family never +//! creates). NULL is a no-op; freeing the same live pointer twice is +//! use-after-free by design (the engine header transfers ownership), so the +//! tests assert NULL-idempotence rather than double-free of a live handle. + +#[path = "common/mod.rs"] +mod common; + +use std::ffi::{c_char, c_int}; + +use oakengine::codec::{ + oakengine_encoding_codec_is_lossless, oakengine_encoding_codec_is_still_image, + oakengine_encoding_codec_name, oakengine_encoding_filename_contains_digit_placeholder, + oakengine_encoding_filename_remove_digit_placeholder, oakengine_encoding_format_audio_codec_at, + oakengine_encoding_format_audio_codec_count, oakengine_encoding_format_count, + oakengine_encoding_format_extension, oakengine_encoding_format_name, + oakengine_encoding_format_subtitle_codec_at, oakengine_encoding_format_subtitle_codec_count, + oakengine_encoding_format_video_codec_at, oakengine_encoding_format_video_codec_count, + oakengine_encoding_generate_matrix, oakengine_encoding_image_sequence_digit_count, + oakengine_encoding_params_audio_bit_rate, oakengine_encoding_params_audio_codec, + oakengine_encoding_params_audio_enabled, oakengine_encoding_params_color_transform_output, + oakengine_encoding_params_create, oakengine_encoding_params_destroy, + oakengine_encoding_params_disable_audio, oakengine_encoding_params_disable_subtitles, + oakengine_encoding_params_disable_video, oakengine_encoding_params_enable_audio, + oakengine_encoding_params_enable_sidecar_subtitles, oakengine_encoding_params_enable_subtitles, + oakengine_encoding_params_enable_video, oakengine_encoding_params_filename, + oakengine_encoding_params_format, oakengine_encoding_params_get_audio_params, + oakengine_encoding_params_get_custom_range, oakengine_encoding_params_get_export_length, + oakengine_encoding_params_get_last_used, oakengine_encoding_params_get_video_params, + oakengine_encoding_params_has_custom_range, oakengine_encoding_params_is_valid, + oakengine_encoding_params_load_file, oakengine_encoding_params_save_file, + oakengine_encoding_params_set_audio_bit_rate, oakengine_encoding_params_set_color_transform, + oakengine_encoding_params_set_custom_range, oakengine_encoding_params_set_export_length, + oakengine_encoding_params_set_filename, oakengine_encoding_params_set_format, + oakengine_encoding_params_set_last_used, oakengine_encoding_params_set_video_bit_rate, + oakengine_encoding_params_set_video_buffer_size, + oakengine_encoding_params_set_video_is_image_sequence, + oakengine_encoding_params_set_video_max_bit_rate, + oakengine_encoding_params_set_video_min_bit_rate, oakengine_encoding_params_set_video_option, + oakengine_encoding_params_set_video_pix_fmt, + oakengine_encoding_params_set_video_scaling_method, + oakengine_encoding_params_set_video_threads, oakengine_encoding_params_subtitles_are_sidecar, + oakengine_encoding_params_subtitles_codec, oakengine_encoding_params_subtitles_enabled, + oakengine_encoding_params_subtitles_sidecar_format, oakengine_encoding_params_video_bit_rate, + oakengine_encoding_params_video_buffer_size, oakengine_encoding_params_video_codec, + oakengine_encoding_params_video_enabled, oakengine_encoding_params_video_is_image_sequence, + oakengine_encoding_params_video_max_bit_rate, oakengine_encoding_params_video_min_bit_rate, + oakengine_encoding_params_video_option, oakengine_encoding_params_video_pix_fmt, + oakengine_encoding_params_video_scaling_method, oakengine_encoding_params_video_threads, + oakengine_encoding_pix_fmt_at, oakengine_encoding_pix_fmt_count, + oakengine_encoding_pix_fmt_index, oakengine_encoding_preset_count, + oakengine_encoding_preset_name, oakengine_encoding_preset_path, + oakengine_encoding_sample_format_at, oakengine_encoding_sample_format_count, + oakengine_encoding_start_audio_recording, oakengine_export_render_with_params, +}; +use oakengine::common::OakVideoParamsPod; + +/// Facade error codes (`src/error.rs`). +const E_INVALID: c_int = -1; +const E_STATE: c_int = -2; +const E_FAILED: c_int = -3; +const E_NOT_FOUND: c_int = -4; + +/// oakcodec module codes, passed through untranslated (`include/codec/error.h`). +const K_INVALID: c_int = -50001; +const K_NOT_FOUND: c_int = -50004; + +/// oakaudio module codes (`include/audio/error.h`). +const A_INVALID: c_int = -60001; +const A_FAILED: c_int = -60003; + +/// Read a NUL-terminated buffer written by a buf/size getter as a `String`. +fn read_buf(buf: &[c_char]) -> String { + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + let bytes: Vec = buf[..len].iter().map(|&c| c as u8).collect(); + String::from_utf8_lossy(&bytes).into_owned() +} + +/// A 16-element f32 transform matrix plus a close-enough comparator. +type Matrix16 = [f32; 16]; +fn assert_matrix(actual: &Matrix16, expected: &[f64; 16]) { + for (i, (a, e)) in actual.iter().zip(expected.iter()).enumerate() { + assert!( + (*a as f64 - e).abs() < 1e-6, + "matrix[{i}] = {a} (expected {e})" + ); + } +} +fn identity16() -> [f64; 16] { + [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ] +} + +// --------------------------------------------------------------------------- +// 1. Container format / codec metadata — legal input matrix +// --------------------------------------------------------------------------- + +/// Legal-path matrix over every metadata export: format enumeration, +/// per-format codec lists, codec flags, pixel/sample format lists, and the +/// image-sequence filename helpers. +#[test] +fn format_and_codec_metadata_legal() { + common::force_link(); + let mut buf = [0 as c_char; 128]; + + // Format enumeration: the table has 15 entries (0..=14; Count = 15). + let count = unsafe { oakengine_encoding_format_count() }; + assert_eq!(count, 15); + + // Every format must have a non-empty name and extension, and its + // per-format codec lists must resolve to codecs with names. + for f in 0..count { + let n = unsafe { oakengine_encoding_format_name(f, buf.as_mut_ptr(), 128) }; + assert!(n > 0, "format {f} name length"); + assert!(!read_buf(&buf).is_empty(), "format {f} name"); + + let e = unsafe { oakengine_encoding_format_extension(f, buf.as_mut_ptr(), 128) }; + assert!(e > 0, "format {f} extension length"); + assert!(!read_buf(&buf).is_empty(), "format {f} extension"); + + let vc = unsafe { oakengine_encoding_format_video_codec_count(f) }; + assert!(vc >= 0); + for i in 0..vc { + let codec = unsafe { oakengine_encoding_format_video_codec_at(f, i) }; + assert!(codec >= 0, "format {f} video codec at {i}"); + let cn = unsafe { oakengine_encoding_codec_name(codec, buf.as_mut_ptr(), 128) }; + assert!(cn > 0, "codec {codec} name length"); + assert!(!read_buf(&buf).is_empty()); + } + let ac = unsafe { oakengine_encoding_format_audio_codec_count(f) }; + assert!(ac >= 0); + for i in 0..ac { + let codec = unsafe { oakengine_encoding_format_audio_codec_at(f, i) }; + assert!(codec >= 0, "format {f} audio codec at {i}"); + let cn = unsafe { oakengine_encoding_codec_name(codec, buf.as_mut_ptr(), 128) }; + assert!(cn > 0, "codec {codec} name length"); + assert!(!read_buf(&buf).is_empty()); + } + let sc = unsafe { oakengine_encoding_format_subtitle_codec_count(f) }; + assert!(sc >= 0); + for i in 0..sc { + let codec = unsafe { oakengine_encoding_format_subtitle_codec_at(f, i) }; + assert!(codec >= 0, "format {f} subtitle codec at {i}"); + let cn = unsafe { oakengine_encoding_codec_name(codec, buf.as_mut_ptr(), 128) }; + assert!(cn > 0, "codec {codec} name length"); + assert!(!read_buf(&buf).is_empty()); + } + } + + // Exact values for the named formats (exportformat.rs / exportcodec.rs). + // Matroska (1). + assert_eq!( + unsafe { oakengine_encoding_format_name(1, buf.as_mut_ptr(), 128) }, + 14 + ); + assert_eq!(read_buf(&buf), "Matroska Video"); + assert_eq!( + unsafe { oakengine_encoding_format_extension(1, buf.as_mut_ptr(), 128) }, + 3 + ); + assert_eq!(read_buf(&buf), "mkv"); + // MPEG-4 video (2): H.264 / H.264RGB / H.265. + assert_eq!(unsafe { oakengine_encoding_format_video_codec_count(2) }, 3); + assert_eq!(unsafe { oakengine_encoding_format_video_codec_at(2, 0) }, 1); // H.264 + // WAV (7): no video codecs, PCM (13) audio. + assert_eq!(unsafe { oakengine_encoding_format_video_codec_count(7) }, 0); + assert_eq!(unsafe { oakengine_encoding_format_audio_codec_count(7) }, 1); + assert_eq!( + unsafe { oakengine_encoding_format_audio_codec_at(7, 0) }, + 13 + ); // PCM + // SRT (13): subtitle-only, SRT (17) codec. + assert_eq!( + unsafe { oakengine_encoding_format_audio_codec_count(13) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_format_subtitle_codec_count(13) }, + 1 + ); + assert_eq!( + unsafe { oakengine_encoding_format_subtitle_codec_at(13, 0) }, + 17 + ); // SRT + + // Codec metadata: names, still-image, lossless. + assert_eq!( + unsafe { oakengine_encoding_codec_name(1, buf.as_mut_ptr(), 128) }, + 5 + ); + assert_eq!(read_buf(&buf), "H.264"); + assert_eq!(unsafe { oakengine_encoding_codec_is_still_image(5) }, 1); // PNG + assert_eq!(unsafe { oakengine_encoding_codec_is_still_image(1) }, 0); // H.264 + assert_eq!(unsafe { oakengine_encoding_codec_is_lossless(13) }, 1); // PCM + assert_eq!(unsafe { oakengine_encoding_codec_is_lossless(12) }, 0); // AAC + + // Pixel formats: the Rust table is empty (CPP-PARITY interim, the list + // is queried from the format's FFmpeg/OIIO encoder), so the count is 0 + // and every index is E_NOT_FOUND; the index helper falls back to 0. + assert_eq!(unsafe { oakengine_encoding_pix_fmt_count(2, 1) }, 0); + assert_eq!( + unsafe { oakengine_encoding_pix_fmt_at(2, 1, 0, buf.as_mut_ptr(), 128) }, + K_NOT_FOUND + ); + assert_eq!( + unsafe { oakengine_encoding_pix_fmt_index(1, c"yuv420p".as_ptr()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_pix_fmt_index(1, std::ptr::null()) }, + 0 + ); + + // Sample formats: PCM (13) inside WAV (7) exposes its native list. + assert_eq!(unsafe { oakengine_encoding_sample_format_count(7, 13) }, 6); + assert_eq!(unsafe { oakengine_encoding_sample_format_at(7, 13, 4) }, 10); // f32 packed + for i in 0..6 { + assert!(unsafe { oakengine_encoding_sample_format_at(7, 13, i) } >= 0); + } + // AAC (12) has no Rust sample-format table yet -> 0. + assert_eq!(unsafe { oakengine_encoding_sample_format_count(2, 12) }, 0); + + // Image-sequence filename helpers. + assert_eq!( + unsafe { + oakengine_encoding_filename_contains_digit_placeholder(c"/tmp/out_[#####].png".as_ptr()) + }, + 1 + ); + assert_eq!( + unsafe { oakengine_encoding_filename_contains_digit_placeholder(c"/tmp/out.png".as_ptr()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_image_sequence_digit_count(c"/tmp/out_[#####].png".as_ptr()) }, + 5 + ); + assert_eq!( + unsafe { oakengine_encoding_image_sequence_digit_count(c"/tmp/out.png".as_ptr()) }, + 0 + ); + // Placeholder + preceding separator are removed together. + let len = unsafe { + oakengine_encoding_filename_remove_digit_placeholder( + c"/tmp/out_[#####].png".as_ptr(), + buf.as_mut_ptr(), + 128, + ) + }; + assert_eq!(len, 12); // "/tmp/out.png" + assert_eq!(read_buf(&buf), "/tmp/out.png"); + let len = unsafe { + oakengine_encoding_filename_remove_digit_placeholder( + c"img_[#####].png".as_ptr(), + buf.as_mut_ptr(), + 128, + ) + }; + assert_eq!(len, 7); // "img.png" (the "_" separator goes with the placeholder) + assert_eq!(read_buf(&buf), "img.png"); +} + +// --------------------------------------------------------------------------- +// 2. Metadata — illegal-input robustness +// --------------------------------------------------------------------------- + +/// Plugins may pass anything: out-of-range formats/codecs/indices, garbage +/// enums, NULL strings and degenerate buffer sizes. Every case must yield a +/// clean negative code (or the documented 0/fallback), never a crash. +#[test] +fn metadata_illegal_inputs() { + let mut buf = [0 as c_char; 128]; + + // Out-of-range / garbage format -> K_INVALID on the two-stage getters. + assert_eq!( + unsafe { oakengine_encoding_format_name(-1, buf.as_mut_ptr(), 128) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_format_name(15, buf.as_mut_ptr(), 128) }, + K_INVALID + ); // Count is not a format + assert_eq!( + unsafe { oakengine_encoding_format_name(99, buf.as_mut_ptr(), 128) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_format_extension(-5, buf.as_mut_ptr(), 128) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_format_video_codec_count(-1) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_format_video_codec_count(99) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_format_audio_codec_count(-2) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_format_subtitle_codec_count(42) }, + K_INVALID + ); + + // Out-of-range indices -> K_NOT_FOUND (valid format checked first). + assert_eq!( + unsafe { oakengine_encoding_format_video_codec_at(2, -1) }, + K_NOT_FOUND + ); + assert_eq!( + unsafe { oakengine_encoding_format_video_codec_at(2, 3) }, + K_NOT_FOUND + ); + assert_eq!( + unsafe { oakengine_encoding_format_audio_codec_at(7, 1) }, + K_NOT_FOUND + ); + assert_eq!( + unsafe { oakengine_encoding_format_subtitle_codec_at(13, 1) }, + K_NOT_FOUND + ); + // Invalid format wins over the index check. + assert_eq!( + unsafe { oakengine_encoding_format_video_codec_at(99, 0) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_format_audio_codec_at(-1, 0) }, + K_INVALID + ); + // WAV (7) is a valid format with an empty subtitle-codec list: the + // out-of-range index yields K_NOT_FOUND; an invalid format wins over + // the index check. + assert_eq!( + unsafe { oakengine_encoding_format_subtitle_codec_at(7, 0) }, + K_NOT_FOUND + ); + assert_eq!( + unsafe { oakengine_encoding_format_subtitle_codec_at(99, 0) }, + K_INVALID + ); + + // Garbage codec values: name -> K_INVALID, flags -> documented 0. + assert_eq!( + unsafe { oakengine_encoding_codec_name(-1, buf.as_mut_ptr(), 128) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_codec_name(99, buf.as_mut_ptr(), 128) }, + K_INVALID + ); + assert_eq!(unsafe { oakengine_encoding_codec_is_still_image(99) }, 0); + assert_eq!(unsafe { oakengine_encoding_codec_is_still_image(-3) }, 0); + assert_eq!(unsafe { oakengine_encoding_codec_is_lossless(99) }, 0); + assert_eq!(unsafe { oakengine_encoding_codec_is_lossless(-3) }, 0); + + // Pixel/sample format queries: garbage format or codec -> K_INVALID. + assert_eq!( + unsafe { oakengine_encoding_pix_fmt_count(-1, 1) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_pix_fmt_count(2, 99) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_pix_fmt_count(15, 1) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_pix_fmt_at(-1, 1, 0, buf.as_mut_ptr(), 128) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_pix_fmt_at(2, 99, 0, buf.as_mut_ptr(), 128) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_pix_fmt_at(2, 1, -1, buf.as_mut_ptr(), 128) }, + K_NOT_FOUND + ); + // pix_fmt_index: NULL / empty / unknown / garbage codec -> 0. + assert_eq!( + unsafe { oakengine_encoding_pix_fmt_index(1, std::ptr::null()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_pix_fmt_index(99, c"yuv420p".as_ptr()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_pix_fmt_index(1, c"not-a-real-fmt".as_ptr()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_sample_format_count(-1, 13) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_sample_format_count(7, 99) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_sample_format_at(-1, 13, 0) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_sample_format_at(7, 99, 0) }, + K_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_sample_format_at(7, 13, -1) }, + K_NOT_FOUND + ); + assert_eq!( + unsafe { oakengine_encoding_sample_format_at(7, 13, 6) }, + K_NOT_FOUND + ); // count is 6 + + // NULL / degenerate buffers on two-stage getters: length-only, never a + // crash. The return value stays the string length. + assert_eq!( + unsafe { oakengine_encoding_format_name(1, std::ptr::null_mut(), 0) }, + 14 + ); + assert_eq!( + unsafe { oakengine_encoding_format_name(1, std::ptr::null_mut(), -1) }, + 14 + ); + assert_eq!( + unsafe { oakengine_encoding_format_extension(1, buf.as_mut_ptr(), 0) }, + 3 + ); + assert_eq!( + unsafe { oakengine_encoding_format_extension(1, buf.as_mut_ptr(), -4) }, + 3 + ); + // Truncation: buf_size = 4 writes 3 chars + NUL, required size unchanged. + assert_eq!( + unsafe { oakengine_encoding_format_name(1, buf.as_mut_ptr(), 4) }, + 14 + ); + assert_eq!(read_buf(&buf), "Mat"); + assert_eq!( + unsafe { oakengine_encoding_codec_name(1, std::ptr::null_mut(), 0) }, + 5 + ); + + // NULL filename helpers: documented fallbacks, no crash. + assert_eq!( + unsafe { oakengine_encoding_filename_contains_digit_placeholder(std::ptr::null()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_image_sequence_digit_count(std::ptr::null()) }, + 0 + ); + assert_eq!( + unsafe { + oakengine_encoding_filename_remove_digit_placeholder( + std::ptr::null(), + buf.as_mut_ptr(), + 128, + ) + }, + K_INVALID + ); + assert_eq!( + unsafe { + oakengine_encoding_filename_remove_digit_placeholder( + c"img.png".as_ptr(), + std::ptr::null_mut(), + 0, + ) + }, + 7 + ); // "img.png" unchanged, length-only +} + +// --------------------------------------------------------------------------- +// 3. Transform matrix (`oakengine_encoding_generate_matrix`) +// --------------------------------------------------------------------------- + +/// Legal methods, garbage method and degenerate sizes; NULL output. +#[test] +fn generate_matrix_matrix() { + let mut m: Matrix16 = [0.0; 16]; + + // Stretch (1) is the identity. + assert_eq!( + unsafe { oakengine_encoding_generate_matrix(1, 1920, 1080, 1280, 720, m.as_mut_ptr()) }, + 0 + ); + assert_matrix(&m, &identity16()); + + // Fit (0): square source into a 2:1 destination scales x by 0.5. + assert_eq!( + unsafe { oakengine_encoding_generate_matrix(0, 1000, 1000, 2000, 1000, m.as_mut_ptr()) }, + 0 + ); + assert_matrix( + &m, + &[ + 0.5, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ], + ); + + // Crop (2) on the same geometry scales y by 2.0. + assert_eq!( + unsafe { oakengine_encoding_generate_matrix(2, 1000, 1000, 2000, 1000, m.as_mut_ptr()) }, + 0 + ); + assert_matrix( + &m, + &[ + 1.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ], + ); + + // Same aspect ratio -> identity for every method. + for method in 0..3 { + assert_eq!( + unsafe { + oakengine_encoding_generate_matrix(method, 1920, 1080, 960, 540, m.as_mut_ptr()) + }, + 0 + ); + assert_matrix(&m, &identity16()); + } + + // Garbage method -> mapped to Stretch -> identity, still OK. + assert_eq!( + unsafe { oakengine_encoding_generate_matrix(99, 1000, 1000, 2000, 1000, m.as_mut_ptr()) }, + 0 + ); + assert_matrix(&m, &identity16()); + assert_eq!( + unsafe { oakengine_encoding_generate_matrix(-7, 1000, 1000, 2000, 1000, m.as_mut_ptr()) }, + 0 + ); + assert_matrix(&m, &identity16()); + + // Degenerate sizes (zero / negative) -> identity, still OK. + for (sw, sh, dw, dh) in [(0, 0, 100, 100), (100, 100, 0, 0), (-4, 10, 100, 100)] { + assert_eq!( + unsafe { oakengine_encoding_generate_matrix(0, sw, sh, dw, dh, m.as_mut_ptr()) }, + 0 + ); + assert_matrix(&m, &identity16()); + } + + // NULL output -> facade E_INVALID. + assert_eq!( + unsafe { oakengine_encoding_generate_matrix(0, 1, 1, 1, 1, std::ptr::null_mut()) }, + E_INVALID + ); +} + +// --------------------------------------------------------------------------- +// 4. Encoding-params handle — legal lifecycle round trip +// --------------------------------------------------------------------------- + +/// Create, configure every field, read everything back, destroy. The +/// handle is per-call state, so the whole lifecycle runs in one test. +#[test] +fn params_handle_legal_round_trip() { + let p = unsafe { oakengine_encoding_params_create() }; + assert!(!p.is_null()); + let mut buf = [0 as c_char; 64]; + + // Fresh handle: no tracks enabled, format unset (-1), invalid. + assert_eq!(unsafe { oakengine_encoding_params_is_valid(p) }, 0); + assert_eq!(unsafe { oakengine_encoding_params_format(p) }, -1); + assert_eq!(unsafe { oakengine_encoding_params_video_enabled(p) }, 0); + assert_eq!(unsafe { oakengine_encoding_params_audio_enabled(p) }, 0); + assert_eq!(unsafe { oakengine_encoding_params_subtitles_enabled(p) }, 0); + assert_eq!( + unsafe { oakengine_encoding_params_subtitles_are_sidecar(p) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_params_subtitles_sidecar_format(p) }, + 0 + ); + assert_eq!(unsafe { oakengine_encoding_params_subtitles_codec(p) }, 0); + assert_eq!(unsafe { oakengine_encoding_params_video_threads(p) }, 0); + assert_eq!( + unsafe { oakengine_encoding_params_video_is_image_sequence(p) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_scaling_method(p) }, + 0 + ); + assert_eq!(unsafe { oakengine_encoding_params_video_bit_rate(p) }, 0); + assert_eq!( + unsafe { oakengine_encoding_params_video_min_bit_rate(p) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_max_bit_rate(p) }, + 0 + ); + assert_eq!(unsafe { oakengine_encoding_params_video_buffer_size(p) }, 0); + assert_eq!(unsafe { oakengine_encoding_params_audio_bit_rate(p) }, 0); + assert_eq!(unsafe { oakengine_encoding_params_has_custom_range(p) }, 0); + + // Format: set + read back; Matroska = 1. + assert_eq!(unsafe { oakengine_encoding_params_set_format(p, 1) }, 0); + assert_eq!(unsafe { oakengine_encoding_params_format(p) }, 1); + + // Filename round trip. + assert_eq!( + unsafe { oakengine_encoding_params_set_filename(p, c"out.mkv".as_ptr()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_params_filename(p, buf.as_mut_ptr(), 64) }, + 7 + ); + assert_eq!(read_buf(&buf), "out.mkv"); + + // Video: enable with a real video-params POD, then read everything back. + let mut vp: OakVideoParamsPod = unsafe { std::mem::zeroed() }; + assert_eq!( + unsafe { + oakengine::common::oakengine_video_params_make( + &mut vp, 1920, 1080, 1001, 30000, 4, 1, 1, 0, 1, 1, + ) + }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_params_enable_video(p, &vp, 1) }, + 0 + ); + assert_eq!(unsafe { oakengine_encoding_params_is_valid(p) }, 1); + assert_eq!(unsafe { oakengine_encoding_params_video_enabled(p) }, 1); + assert_eq!(unsafe { oakengine_encoding_params_video_codec(p) }, 1); + let mut out: OakVideoParamsPod = unsafe { std::mem::zeroed() }; + assert_eq!( + unsafe { oakengine_encoding_params_get_video_params(p, &mut out) }, + 0 + ); + assert_eq!((out.width, out.height), (1920, 1080)); + assert_eq!((out.time_base_num, out.time_base_den), (1001, 30000)); + assert_eq!(out.format, 4); + assert_eq!(out.interlacing, 0); // interlacing arg 0 in the make() call + assert_eq!((out.pixel_aspect_num, out.pixel_aspect_den), (1, 1)); + + // Video bit-rate family (i64 fields). + unsafe { oakengine_encoding_params_set_video_bit_rate(p, 8_000_000) }; + unsafe { oakengine_encoding_params_set_video_min_bit_rate(p, 4_000_000) }; + unsafe { oakengine_encoding_params_set_video_max_bit_rate(p, 12_000_000) }; + unsafe { oakengine_encoding_params_set_video_buffer_size(p, 16_000_000) }; + assert_eq!( + unsafe { oakengine_encoding_params_video_bit_rate(p) }, + 8_000_000 + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_min_bit_rate(p) }, + 4_000_000 + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_max_bit_rate(p) }, + 12_000_000 + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_buffer_size(p) }, + 16_000_000 + ); + + // Threads, encoded pixel format, image-sequence flag, scaling method. + unsafe { oakengine_encoding_params_set_video_threads(p, 4) }; + assert_eq!(unsafe { oakengine_encoding_params_video_threads(p) }, 4); + assert_eq!( + unsafe { oakengine_encoding_params_set_video_pix_fmt(p, c"yuv420p".as_ptr()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_pix_fmt(p, buf.as_mut_ptr(), 64) }, + 7 + ); + assert_eq!(read_buf(&buf), "yuv420p"); + unsafe { oakengine_encoding_params_set_video_is_image_sequence(p, 1) }; + assert_eq!( + unsafe { oakengine_encoding_params_video_is_image_sequence(p) }, + 1 + ); + assert_eq!( + unsafe { oakengine_encoding_params_set_video_scaling_method(p, 2) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_scaling_method(p) }, + 2 + ); + + // Audio: enable + read back (get_audio_params before enabling is E_STATE, + // asserted in the illegal test). + assert_eq!( + unsafe { oakengine_encoding_params_enable_audio(p, 48000, 3, 0, 13) }, + 0 + ); + assert_eq!(unsafe { oakengine_encoding_params_audio_enabled(p) }, 1); + assert_eq!(unsafe { oakengine_encoding_params_audio_codec(p) }, 13); + let (mut sr, mut layout, mut sf) = (0 as c_int, 0u64, 0 as c_int); + assert_eq!( + unsafe { oakengine_encoding_params_get_audio_params(p, &mut sr, &mut layout, &mut sf) }, + 0 + ); + assert_eq!((sr, layout, sf), (48000, 3, 0)); + unsafe { oakengine_encoding_params_set_audio_bit_rate(p, 320_000) }; + assert_eq!( + unsafe { oakengine_encoding_params_audio_bit_rate(p) }, + 320_000 + ); + + // Subtitles: plain and sidecar variants. + assert_eq!( + unsafe { oakengine_encoding_params_enable_subtitles(p, 17) }, + 0 + ); + assert_eq!(unsafe { oakengine_encoding_params_subtitles_enabled(p) }, 1); + assert_eq!(unsafe { oakengine_encoding_params_subtitles_codec(p) }, 17); + assert_eq!( + unsafe { oakengine_encoding_params_subtitles_are_sidecar(p) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_params_enable_sidecar_subtitles(p, 13, 17) }, + 0 + ); + assert_eq!(unsafe { oakengine_encoding_params_subtitles_enabled(p) }, 1); + assert_eq!( + unsafe { oakengine_encoding_params_subtitles_are_sidecar(p) }, + 1 + ); + assert_eq!( + unsafe { oakengine_encoding_params_subtitles_sidecar_format(p) }, + 13 + ); + assert_eq!(unsafe { oakengine_encoding_params_subtitles_codec(p) }, 17); + + // Color transform. + assert_eq!( + unsafe { oakengine_encoding_params_set_color_transform(p, c"ACEScg".as_ptr()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_params_color_transform_output(p, buf.as_mut_ptr(), 64) }, + 6 + ); + assert_eq!(read_buf(&buf), "ACEScg"); + + // Export length. + let (mut eln, mut eld) = (0 as c_int, 0 as c_int); + assert_eq!( + unsafe { oakengine_encoding_params_get_export_length(p, &mut eln, &mut eld) }, + 0 + ); + assert_eq!((eln, eld), (0, 0)); // default + unsafe { oakengine_encoding_params_set_export_length(p, 10, 1) }; + assert_eq!( + unsafe { oakengine_encoding_params_get_export_length(p, &mut eln, &mut eld) }, + 0 + ); + assert_eq!((eln, eld), (10, 1)); + + // Custom range. + let (mut inn, mut ind, mut outn, mut outd) = (0i64, 0i64, 0i64, 0i64); + unsafe { oakengine_encoding_params_set_custom_range(p, 0, 1, 100, 1) }; + assert_eq!(unsafe { oakengine_encoding_params_has_custom_range(p) }, 1); + assert_eq!( + unsafe { + oakengine_encoding_params_get_custom_range(p, &mut inn, &mut ind, &mut outn, &mut outd) + }, + 0 + ); + assert_eq!((inn, ind, outn, outd), (0, 1, 100, 1)); + + // Encoder-specific video options (facade-side map). + assert_eq!( + unsafe { oakengine_encoding_params_set_video_option(p, c"crf".as_ptr(), c"18".as_ptr()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_option(p, c"crf".as_ptr(), buf.as_mut_ptr(), 64) }, + 2 + ); + assert_eq!(read_buf(&buf), "18"); + // A second value for the same key replaces the first. + assert_eq!( + unsafe { oakengine_encoding_params_set_video_option(p, c"crf".as_ptr(), c"23".as_ptr()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_option(p, c"crf".as_ptr(), buf.as_mut_ptr(), 64) }, + 2 + ); + assert_eq!(read_buf(&buf), "23"); + + // Disable each track and watch is_valid flip back to 0. + unsafe { oakengine_encoding_params_disable_video(p) }; + assert_eq!(unsafe { oakengine_encoding_params_video_enabled(p) }, 0); + assert_eq!(unsafe { oakengine_encoding_params_is_valid(p) }, 1); // audio still on + unsafe { oakengine_encoding_params_disable_audio(p) }; + assert_eq!(unsafe { oakengine_encoding_params_audio_enabled(p) }, 0); + assert_eq!(unsafe { oakengine_encoding_params_is_valid(p) }, 1); // subtitles still on + unsafe { oakengine_encoding_params_disable_subtitles(p) }; + assert_eq!(unsafe { oakengine_encoding_params_subtitles_enabled(p) }, 0); + assert_eq!(unsafe { oakengine_encoding_params_is_valid(p) }, 0); + + unsafe { oakengine_encoding_params_destroy(p) }; +} + +// --------------------------------------------------------------------------- +// 5. Encoding-params handle — illegal-input robustness +// --------------------------------------------------------------------------- + +/// NULL handles, NULL string arguments, out-of-range values, state errors +/// and garbage enums: clean negative codes or documented no-ops only. +#[test] +fn params_handle_illegal_inputs() { + let p = unsafe { oakengine_encoding_params_create() }; + assert!(!p.is_null()); + let mut buf = [0 as c_char; 64]; + + // NULL handle on every c_int-returning getter/setter -> facade E_INVALID. + assert_eq!( + unsafe { oakengine_encoding_params_is_valid(std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_format(std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_set_format(std::ptr::null_mut(), 1) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_filename(std::ptr::null(), buf.as_mut_ptr(), 64) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_set_filename(std::ptr::null_mut(), c"x.mkv".as_ptr()) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_enable_video(std::ptr::null_mut(), &vp_uninit(), 1) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_enable_audio(std::ptr::null_mut(), 48000, 3, 0, 13) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_enable_subtitles(std::ptr::null_mut(), 17) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_enable_sidecar_subtitles(std::ptr::null_mut(), 13, 17) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_enabled(std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_codec(std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_get_video_params(std::ptr::null(), &mut vp_uninit()) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_audio_enabled(std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_audio_codec(std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { + oakengine_encoding_params_get_audio_params( + std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_subtitles_enabled(std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_subtitles_are_sidecar(std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_subtitles_sidecar_format(std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_subtitles_codec(std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_bit_rate(std::ptr::null()) }, + E_INVALID as i64 + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_min_bit_rate(std::ptr::null()) }, + E_INVALID as i64 + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_max_bit_rate(std::ptr::null()) }, + E_INVALID as i64 + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_buffer_size(std::ptr::null()) }, + E_INVALID as i64 + ); + assert_eq!( + unsafe { oakengine_encoding_params_audio_bit_rate(std::ptr::null()) }, + E_INVALID as i64 + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_threads(std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_pix_fmt(std::ptr::null(), buf.as_mut_ptr(), 64) }, + E_INVALID + ); + assert_eq!( + unsafe { + oakengine_encoding_params_set_video_pix_fmt(std::ptr::null_mut(), c"yuv420p".as_ptr()) + }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_is_image_sequence(std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { + oakengine_encoding_params_color_transform_output(std::ptr::null(), buf.as_mut_ptr(), 64) + }, + E_INVALID + ); + assert_eq!( + unsafe { + oakengine_encoding_params_set_color_transform(std::ptr::null_mut(), c"ACEScg".as_ptr()) + }, + E_INVALID + ); + assert_eq!( + unsafe { + oakengine_encoding_params_get_export_length( + std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_has_custom_range(std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { + oakengine_encoding_params_get_custom_range( + std::ptr::null(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_scaling_method(std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_set_video_scaling_method(std::ptr::null_mut(), 0) }, + E_INVALID + ); + assert_eq!( + unsafe { + oakengine_encoding_params_set_video_option( + std::ptr::null_mut(), + c"crf".as_ptr(), + c"18".as_ptr(), + ) + }, + E_INVALID + ); + assert_eq!( + unsafe { + oakengine_encoding_params_video_option( + std::ptr::null(), + c"crf".as_ptr(), + buf.as_mut_ptr(), + 64, + ) + }, + E_INVALID + ); + + // NULL string arguments. + assert_eq!( + unsafe { oakengine_encoding_params_set_filename(p, std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_set_video_pix_fmt(p, std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_set_video_option(p, std::ptr::null(), c"18".as_ptr()) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_set_video_option(p, c"crf".as_ptr(), std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + unsafe { + oakengine_encoding_params_video_option(p, std::ptr::null(), buf.as_mut_ptr(), 64) + }, + E_INVALID + ); + // NULL video-params POD on enable_video. + assert_eq!( + unsafe { oakengine_encoding_params_enable_video(p, std::ptr::null(), 1) }, + E_INVALID + ); + // set_color_transform tolerates NULL (writes the empty string). + assert_eq!( + unsafe { oakengine_encoding_params_set_color_transform(p, std::ptr::null()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_params_color_transform_output(p, buf.as_mut_ptr(), 64) }, + 0 + ); + assert_eq!(read_buf(&buf), ""); + + // Out-of-range / garbage values. + assert_eq!( + unsafe { oakengine_encoding_params_set_format(p, -1) }, + E_INVALID + ); + assert_eq!( + unsafe { oakengine_encoding_params_set_format(p, 15) }, + E_INVALID + ); // count + assert_eq!( + unsafe { oakengine_encoding_params_set_format(p, 9999) }, + E_INVALID + ); + // Missing video option key -> E_NOT_FOUND. + assert_eq!( + unsafe { + oakengine_encoding_params_video_option(p, c"missing".as_ptr(), buf.as_mut_ptr(), 64) + }, + E_NOT_FOUND + ); + + // State errors: disabled tracks make the getters return E_STATE. + assert_eq!( + unsafe { oakengine_encoding_params_get_video_params(p, &mut vp_uninit()) }, + E_STATE + ); + assert_eq!( + unsafe { oakengine_encoding_params_get_video_params(p, std::ptr::null_mut()) }, + E_STATE + ); // disabled state checked before NULL out + assert_eq!( + unsafe { + oakengine_encoding_params_get_audio_params( + p, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + E_STATE + ); + // Unset custom range -> E_NOT_FOUND. + assert_eq!( + unsafe { + oakengine_encoding_params_get_custom_range( + p, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + E_NOT_FOUND + ); + + // Enabled video with a NULL out -> E_INVALID (state now OK). + assert_eq!( + unsafe { oakengine_encoding_params_enable_video(p, &vp_uninit(), 1) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_params_get_video_params(p, std::ptr::null_mut()) }, + E_INVALID + ); + // Garbage enums are accepted verbatim (no validation in the facade): + // a codec id of 9999 round-trips. + assert_eq!( + unsafe { oakengine_encoding_params_enable_video(p, &vp_uninit(), 9999) }, + 0 + ); + assert_eq!(unsafe { oakengine_encoding_params_video_codec(p) }, 9999); + // Audio with a zero rate / empty layout / garbage format/codec: accepted. + assert_eq!( + unsafe { oakengine_encoding_params_enable_audio(p, 0, 0, -1, 9999) }, + 0 + ); + let (mut sr, mut layout, mut sf) = (0 as c_int, 0u64, 0 as c_int); + assert_eq!( + unsafe { oakengine_encoding_params_get_audio_params(p, &mut sr, &mut layout, &mut sf) }, + 0 + ); + assert_eq!((sr, layout, sf), (0, 0, -1)); + // Garbage scaling method round-trips too. + assert_eq!( + unsafe { oakengine_encoding_params_set_video_scaling_method(p, 99) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_scaling_method(p) }, + 99 + ); + + // NULL-handle void setters are no-ops (never crash). + unsafe { oakengine_encoding_params_disable_video(std::ptr::null_mut()) }; + unsafe { oakengine_encoding_params_disable_audio(std::ptr::null_mut()) }; + unsafe { oakengine_encoding_params_disable_subtitles(std::ptr::null_mut()) }; + unsafe { oakengine_encoding_params_set_video_bit_rate(std::ptr::null_mut(), 1) }; + unsafe { oakengine_encoding_params_set_video_min_bit_rate(std::ptr::null_mut(), 1) }; + unsafe { oakengine_encoding_params_set_video_max_bit_rate(std::ptr::null_mut(), 1) }; + unsafe { oakengine_encoding_params_set_video_buffer_size(std::ptr::null_mut(), 1) }; + unsafe { oakengine_encoding_params_set_audio_bit_rate(std::ptr::null_mut(), 1) }; + unsafe { oakengine_encoding_params_set_video_threads(std::ptr::null_mut(), 4) }; + unsafe { oakengine_encoding_params_set_video_is_image_sequence(std::ptr::null_mut(), 1) }; + unsafe { oakengine_encoding_params_set_export_length(std::ptr::null_mut(), 10, 1) }; + unsafe { oakengine_encoding_params_set_custom_range(std::ptr::null_mut(), 0, 1, 100, 1) }; + // Length-only queries tolerate a NULL / zero-sized buffer. + assert_eq!( + unsafe { oakengine_encoding_params_filename(p, std::ptr::null_mut(), 0) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_params_video_pix_fmt(p, std::ptr::null_mut(), -1) }, + 0 + ); + assert_eq!( + unsafe { + oakengine_encoding_params_video_option(p, c"crf".as_ptr(), std::ptr::null_mut(), 0) + }, + E_NOT_FOUND + ); // key unset on this handle + + unsafe { oakengine_encoding_params_destroy(p) }; +} + +/// Fresh (all-zero) video-params POD used for validation-negative calls. +fn vp_uninit() -> OakVideoParamsPod { + unsafe { std::mem::zeroed() } +} + +// --------------------------------------------------------------------------- +// 6. Encoding-params handle — destroy contracts +// --------------------------------------------------------------------------- + +/// `oakengine_encoding_params_destroy`: NULL is a no-op (repeatedly), and a +/// live handle frees cleanly. The family keeps no debug alive counter (the +/// handle is a facade-owned raw box, not a refcounted oakcodec handle), so +/// the baseline is verified behaviorally. Double-freeing a live pointer is +/// use-after-free by design (the engine header transfers ownership) and is +/// deliberately not invoked. +#[test] +fn params_destroy_contracts() { + unsafe { oakengine_encoding_params_destroy(std::ptr::null_mut()) }; + unsafe { oakengine_encoding_params_destroy(std::ptr::null_mut()) }; + + let p = unsafe { oakengine_encoding_params_create() }; + assert!(!p.is_null()); + // The handle still works right up to the destroy. + assert_eq!(unsafe { oakengine_encoding_params_set_format(p, 1) }, 0); + unsafe { oakengine_encoding_params_destroy(p) }; + + // NULL remains a no-op after a real destroy. + unsafe { oakengine_encoding_params_destroy(std::ptr::null_mut()) }; +} + +// --------------------------------------------------------------------------- +// 7. Deferred / not-backed entry points +// --------------------------------------------------------------------------- + +/// Preset path/count/name, params load/save, export render and the +/// sequence-bound last-used stubs are documented as not backed: they return +/// the fixed contract value (E_FAILED / 0 / NULL / no-op) regardless of the +/// arguments, so every argument combination is safe. +#[test] +fn deferred_stubs_contract() { + let mut buf = [0 as c_char; 64]; + + assert_eq!( + unsafe { oakengine_encoding_preset_path(buf.as_mut_ptr(), 64) }, + E_FAILED + ); + assert_eq!( + unsafe { oakengine_encoding_preset_path(std::ptr::null_mut(), 0) }, + E_FAILED + ); + assert_eq!(unsafe { oakengine_encoding_preset_count() }, 0); + assert_eq!( + unsafe { oakengine_encoding_preset_name(0, buf.as_mut_ptr(), 64) }, + E_FAILED + ); + assert_eq!( + unsafe { oakengine_encoding_preset_name(99, std::ptr::null_mut(), 0) }, + E_FAILED + ); + + let p = unsafe { oakengine_encoding_params_create() }; + assert!(!p.is_null()); + assert_eq!( + unsafe { oakengine_encoding_params_load_file(p, c"preset.oep".as_ptr()) }, + E_FAILED + ); + assert_eq!( + unsafe { oakengine_encoding_params_load_file(std::ptr::null_mut(), c"p.oep".as_ptr()) }, + E_FAILED + ); + assert_eq!( + unsafe { oakengine_encoding_params_save_file(p, c"preset.oep".as_ptr()) }, + E_FAILED + ); + assert_eq!( + unsafe { oakengine_encoding_params_save_file(std::ptr::null_mut(), std::ptr::null()) }, + E_FAILED + ); + assert_eq!( + unsafe { oakengine_export_render_with_params(std::ptr::null_mut(), p) }, + E_FAILED + ); + assert_eq!( + unsafe { oakengine_export_render_with_params(std::ptr::null_mut(), std::ptr::null()) }, + E_FAILED + ); + + assert!(unsafe { oakengine_encoding_params_get_last_used(std::ptr::null_mut()) }.is_null()); + assert!(unsafe { oakengine_encoding_params_get_last_used(std::ptr::null_mut()) }.is_null()); + unsafe { oakengine_encoding_params_set_last_used(std::ptr::null_mut(), p) }; + unsafe { oakengine_encoding_params_set_last_used(std::ptr::null_mut(), std::ptr::null()) }; + + unsafe { oakengine_encoding_params_destroy(p) }; +} + +// --------------------------------------------------------------------------- +// 8. Audio recording (`oakengine_encoding_start_audio_recording`) +// --------------------------------------------------------------------------- + +/// Recording needs the process-wide AudioManager singleton (oakaudio). The +/// whole lifecycle runs in this one test so the singleton is never shared +/// with another test. With no manager the facade reports E_STATE; with the +/// real manager and no selected input device the oakaudio module reports +/// E_FAILED (-60003, "no input device") — the real end-to-end path through +/// the facade, the oakaudio manager, and the error-string plumbing. +#[test] +fn start_audio_recording_manager_paths() { + let p = unsafe { oakengine_encoding_params_create() }; + assert!(!p.is_null()); + let mut err = [0 as c_char; 128]; + + // NULL params -> facade E_INVALID. + assert_eq!( + unsafe { + oakengine_encoding_start_audio_recording(std::ptr::null(), err.as_mut_ptr(), 128) + }, + E_INVALID + ); + + // No manager singleton -> facade E_STATE. + assert_eq!( + unsafe { oakengine_encoding_start_audio_recording(p, err.as_mut_ptr(), 128) }, + E_STATE + ); + + // Create the real singleton through the audio facade. + assert_eq!( + unsafe { oakengine::audio::oakengine_audio_create_instance() }, + 0 + ); + + // Audio disabled -> oakaudio rejects with E_INVALID and writes the + // diagnostic string. + assert_eq!( + unsafe { oakengine_encoding_start_audio_recording(p, err.as_mut_ptr(), 128) }, + A_INVALID + ); + assert!(!read_buf(&err).is_empty()); + + // Audio enabled, but no input device selected -> E_FAILED + message. + assert_eq!( + unsafe { oakengine_encoding_params_enable_audio(p, 48000, 3, 0, 13) }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_start_audio_recording(p, err.as_mut_ptr(), 128) }, + A_FAILED + ); + assert!(!read_buf(&err).is_empty()); + + // NULL error buffer is tolerated by the failure paths (length-only + // reporting is not used here; the buffer is simply optional). + assert_eq!( + unsafe { oakengine_encoding_start_audio_recording(p, std::ptr::null_mut(), 0) }, + A_FAILED + ); + + // Tear the singleton down: the E_STATE contract returns. + assert_eq!( + unsafe { oakengine::audio::oakengine_audio_destroy_instance() }, + 0 + ); + assert_eq!( + unsafe { oakengine_encoding_start_audio_recording(p, err.as_mut_ptr(), 128) }, + E_STATE + ); + + unsafe { oakengine_encoding_params_destroy(p) }; +} diff --git a/crates/oakengine/tests/it_common.rs b/crates/oakengine/tests/it_common.rs new file mode 100644 index 000000000..53c1316ea --- /dev/null +++ b/crates/oakengine/tests/it_common.rs @@ -0,0 +1,818 @@ +// 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 . + +//! Integration tests for the common family (`src/common.rs` over +//! `engine/include/oakengine/{config,videoparams}.h`). +//! +//! Every exported function is exercised on a legal path with the result +//! asserted, plus the illegal-input matrix the engine must survive (NULL +//! pointers, empty handles, out-of-range indexes, zero/negative sizes, +//! garbage enums). All behavior is real: the facade calls into the real +//! oakcommon store and videoparams domain. +//! +//! The oakcommon config store is a process-wide singleton backed by +//! `config.ini` (honoring the `OAK_CONFIG_DIR` override), so every test +//! that touches config is serialized under [`CONFIG_LOCK`] and redirects +//! the file into a fresh temp dir. The videoparams tables are immutable +//! statics and the params handles are per-test objects, so those tests +//! run in parallel. + +// The whole family is called through uniform `unsafe {}` blocks (matching +// the other test binaries), so extern functions that happen to be safe +// (e.g. `oakengine_config_load`) otherwise trip `unused_unsafe`. +#![allow(unused_unsafe)] + +#[path = "common/mod.rs"] +mod common; + +use std::ffi::{c_char, c_int}; +use std::path::Path; +use std::sync::atomic::{AtomicI32, Ordering}; +use std::sync::Mutex; + +use oakengine::common::{ + oakengine_config_get_int, oakengine_config_get_string, oakengine_config_load, + oakengine_config_report_error, oakengine_config_save, oakengine_config_set_error_handler, + oakengine_config_set_int, oakengine_config_set_string, oakengine_video_params_bytes_per_pixel, + oakengine_video_params_create, oakengine_video_params_divider_name, + oakengine_video_params_effective_size, oakengine_video_params_equal, + oakengine_video_params_format_is_float, oakengine_video_params_format_pixel_aspect_ratio_string, + oakengine_video_params_frame_rate_to_string, oakengine_video_params_free, + oakengine_video_params_internal_channel_count, oakengine_video_params_is_valid, + oakengine_video_params_make, oakengine_video_params_pixel_format_name, + oakengine_video_params_standard_pixel_aspect_at, + oakengine_video_params_standard_pixel_aspect_count, + oakengine_video_params_standard_pixel_aspect_name, oakengine_video_params_supported_divider_at, + oakengine_video_params_supported_divider_count, oakengine_video_params_supported_frame_rate_at, + oakengine_video_params_supported_frame_rate_count, OakVideoParamsPod, +}; + +/// Read a two-stage facade string into a Rust String. +unsafe fn read_buf(buf: &mut [c_char]) -> String { + std::ffi::CStr::from_ptr(buf.as_ptr()) + .to_string_lossy() + .into_owned() +} + +/// Serializes every test that touches the process-wide config store and +/// redirects `OAK_CONFIG_DIR` to a fresh temp dir for the duration of `f` +/// (same pattern as the oakcommon crate's own test support). The only +/// readers of `OAK_CONFIG_DIR` in this binary are these serialized tests. +fn with_temp_config_dir(f: impl FnOnce(&Path) -> T) -> T { + let _guard = CONFIG_LOCK.lock().unwrap(); + let dir = std::env::temp_dir().join(format!("oakengine_it_common_config_{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + std::env::set_var("OAK_CONFIG_DIR", &dir); + let result = f(&dir); + std::env::remove_var("OAK_CONFIG_DIR"); + let _ = std::fs::remove_dir_all(&dir); + result +} + +/// The process-wide config store is a singleton; see module doc. +static CONFIG_LOCK: Mutex<()> = Mutex::new(()); + +// --------------------------------------------------------------------------- +// config.h +// --------------------------------------------------------------------------- + +/// Load/save round-trip, defaults, typed entries and the two-stage string +/// convention (all serialized: the store is process-wide). +#[test] +fn config_roundtrip_persistence() { + common::force_link(); + with_temp_config_dir(|dir| { + // A missing config.ini is not an error; defaults are loaded. + assert_eq!(unsafe { oakengine_config_load() }, 0); + + // Missing keys read as empty / fallback. + let mut buf = [0 as c_char; 64]; + assert_eq!( + unsafe { oakengine_config_get_string(c"no/such/key".as_ptr(), buf.as_mut_ptr(), 64) }, + 0 + ); + assert_eq!(unsafe { read_buf(&mut buf) }, ""); + assert_eq!(unsafe { oakengine_config_get_int(c"no/such/key".as_ptr(), 7) }, 7); + + // Compiled-in defaults are readable through the engine getters. + let len = unsafe { + oakengine_config_get_string(c"DefaultSequenceFrameRate".as_ptr(), buf.as_mut_ptr(), 64) + }; + assert_eq!(len, 10); + assert_eq!(unsafe { read_buf(&mut buf) }, "1001/30000"); + assert_eq!(unsafe { oakengine_config_get_int(c"DefaultSequenceWidth".as_ptr(), 0) }, 1920); + + // String round-trip. + assert_eq!( + unsafe { oakengine_config_set_string(c"it/key".as_ptr(), c"hello".as_ptr()) }, + 0 + ); + let len = unsafe { oakengine_config_get_string(c"it/key".as_ptr(), buf.as_mut_ptr(), 64) }; + assert_eq!(len, 5); + assert_eq!(unsafe { read_buf(&mut buf) }, "hello"); + + // Too-small buffer: the full length is reported and the buffer is + // left untouched (query size, then allocate, then copy). + let mut small = [0 as c_char; 3]; + let len = unsafe { oakengine_config_get_string(c"it/key".as_ptr(), small.as_mut_ptr(), 3) }; + assert_eq!(len, 5); + assert_eq!(unsafe { read_buf(&mut small) }, ""); + + // A NULL value stores an empty string (engine treats NULL as ""). + assert_eq!(unsafe { oakengine_config_set_string(c"it/key".as_ptr(), std::ptr::null()) }, 0); + let len = unsafe { oakengine_config_get_string(c"it/key".as_ptr(), buf.as_mut_ptr(), 64) }; + assert_eq!(len, 0); + assert_eq!(unsafe { read_buf(&mut buf) }, ""); + assert_eq!( + unsafe { oakengine_config_set_string(c"it/key".as_ptr(), c"hello".as_ptr()) }, + 0 + ); + + // A string entry read through the int getter falls back. + assert_eq!(unsafe { oakengine_config_get_int(c"it/key".as_ptr(), 9) }, 9); + + // Int round-trip; a known typed key keeps its type across reload. + assert_eq!(unsafe { oakengine_config_set_int(c"it/num".as_ptr(), 1234) }, 0); + assert_eq!(unsafe { oakengine_config_get_int(c"it/num".as_ptr(), 0) }, 1234); + assert_eq!( + unsafe { oakengine_config_set_int(c"DefaultSequenceWidth".as_ptr(), 640) }, + 0 + ); + assert_eq!(unsafe { oakengine_config_get_int(c"DefaultSequenceWidth".as_ptr(), 0) }, 640); + + // Persist, then reload from the file. + assert_eq!(unsafe { oakengine_config_save() }, 0); + assert!(dir.join("config.ini").exists()); + + assert_eq!(unsafe { oakengine_config_load() }, 0); + let len = unsafe { oakengine_config_get_string(c"it/key".as_ptr(), buf.as_mut_ptr(), 64) }; + assert_eq!(len, 5); + assert_eq!(unsafe { read_buf(&mut buf) }, "hello"); + assert_eq!(unsafe { oakengine_config_get_int(c"DefaultSequenceWidth".as_ptr(), 0) }, 640); + // A custom typed key loses its type on reload and reads as a string + // (module C++ parity: only known keys keep their declared type). + let len = unsafe { oakengine_config_get_string(c"it/num".as_ptr(), buf.as_mut_ptr(), 64) }; + assert_eq!(len, 4); + assert_eq!(unsafe { read_buf(&mut buf) }, "1234"); + assert_eq!(unsafe { oakengine_config_get_int(c"it/num".as_ptr(), 9) }, 9); + }); +} + +/// Illegal inputs on the config getters/setters: NULL keys and buffers, +/// empty keys, zero/negative sizes — all must fail cleanly, never crash. +#[test] +fn config_illegal_inputs() { + common::force_link(); + with_temp_config_dir(|_dir| { + assert_eq!(unsafe { oakengine_config_load() }, 0); + assert_eq!( + unsafe { oakengine_config_set_string(c"it/k".as_ptr(), c"abc".as_ptr()) }, + 0 + ); + + let mut buf = [0 as c_char; 64]; + + // NULL key → OAKENGINE_E_INVALID (-1). + assert_eq!( + unsafe { oakengine_config_get_string(std::ptr::null(), buf.as_mut_ptr(), 64) }, + -1 + ); + assert_eq!( + unsafe { oakengine_config_set_string(std::ptr::null(), c"v".as_ptr()) }, + -1 + ); + assert_eq!(unsafe { oakengine_config_set_int(std::ptr::null(), 5) }, -1); + // NULL key on the int getter returns the fallback (engine contract). + assert_eq!(unsafe { oakengine_config_get_int(std::ptr::null(), 42) }, 42); + + // Empty key → the module's INVALID, passed through untranslated. + assert_eq!( + unsafe { oakengine_config_get_string(c"".as_ptr(), buf.as_mut_ptr(), 64) }, + -10001 + ); + assert_eq!(unsafe { oakengine_config_get_int(c"".as_ptr(), 42) }, 42); + + // NULL output buffer with a positive size → module INVALID (-10001). + assert_eq!( + unsafe { oakengine_config_get_string(c"it/k".as_ptr(), std::ptr::null_mut(), 64) }, + -10001 + ); + // Negative size → module INVALID. + assert_eq!( + unsafe { oakengine_config_get_string(c"it/k".as_ptr(), buf.as_mut_ptr(), -1) }, + -10001 + ); + // NULL buffer with size 0 is the two-stage size query: reports the + // required length without writing. + assert_eq!( + unsafe { oakengine_config_get_string(c"it/k".as_ptr(), std::ptr::null_mut(), 0) }, + 3 + ); + }); +} + +/// Error handler: registered, invoked via report_error and on a load +/// failure, NULL args are safe, NULL handler clears. +#[test] +fn config_error_handler_and_load_failure() { + common::force_link(); + static CALLED: AtomicI32 = AtomicI32::new(0); + unsafe extern "C" fn handler( + _title: *const c_char, + _message: *const c_char, + _userdata: *mut std::ffi::c_void, + ) { + CALLED.fetch_add(1, Ordering::SeqCst); + } + + with_temp_config_dir(|dir| { + CALLED.store(0, Ordering::SeqCst); + + // Register and report through the handler. + assert_eq!( + unsafe { oakengine_config_set_error_handler(Some(handler), std::ptr::null_mut()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_config_report_error(c"title".as_ptr(), c"message".as_ptr()) }, + 0 + ); + assert_eq!(CALLED.load(Ordering::SeqCst), 1); + // NULL title/message are mapped to empty strings, still invoked. + assert_eq!( + unsafe { oakengine_config_report_error(std::ptr::null(), std::ptr::null()) }, + 0 + ); + assert_eq!(CALLED.load(Ordering::SeqCst), 2); + + // NULL handler clears; reporting then does not invoke. + assert_eq!( + unsafe { oakengine_config_set_error_handler(None, std::ptr::null_mut()) }, + 0 + ); + unsafe { oakengine_config_report_error(c"t".as_ptr(), c"m".as_ptr()) }; + assert_eq!(CALLED.load(Ordering::SeqCst), 2); + + // A real load failure (config.ini is a directory) reports through + // the module's registered handler and returns the module FAILED + // code (-10003) untranslated. + assert_eq!( + unsafe { oakengine_config_set_error_handler(Some(handler), std::ptr::null_mut()) }, + 0 + ); + std::fs::create_dir(dir.join("config.ini")).unwrap(); + assert_eq!(unsafe { oakengine_config_load() }, -10003); + assert_eq!(CALLED.load(Ordering::SeqCst), 3); + + // Cleanup: drop the directory and clear the handler. + std::fs::remove_dir(dir.join("config.ini")).unwrap(); + unsafe { oakengine_config_set_error_handler(None, std::ptr::null_mut()) }; + assert_eq!(unsafe { oakengine_config_load() }, 0); + }); +} + +// --------------------------------------------------------------------------- +// videoparams.h — static tables +// --------------------------------------------------------------------------- + +/// Static tables: counts, every legal index, specific values, and the +/// out-of-range / NULL failure paths. +#[test] +fn videoparams_static_tables_full() { + common::force_link(); + + // ---- frame rates ------------------------------------------------------ + assert_eq!(unsafe { oakengine_video_params_supported_frame_rate_count() }, 12); + let mut num: c_int = 0; + let mut den: c_int = 0; + for i in 0..12 { + assert_eq!( + unsafe { oakengine_video_params_supported_frame_rate_at(i, &mut num, &mut den) }, + 0 + ); + assert!(num > 0 && den > 0, "frame rate {i} must be a positive rational"); + } + assert_eq!( + unsafe { oakengine_video_params_supported_frame_rate_at(0, &mut num, &mut den) }, + 0 + ); + assert_eq!((num, den), (10, 1)); + assert_eq!( + unsafe { oakengine_video_params_supported_frame_rate_at(2, &mut num, &mut den) }, + 0 + ); + assert_eq!((num, den), (24000, 1001)); // 23.976 + assert_eq!( + unsafe { oakengine_video_params_supported_frame_rate_at(5, &mut num, &mut den) }, + 0 + ); + assert_eq!((num, den), (30000, 1001)); // 29.97 + assert_eq!( + unsafe { oakengine_video_params_supported_frame_rate_at(6, &mut num, &mut den) }, + 0 + ); + assert_eq!((num, den), (30, 1)); + assert_eq!( + unsafe { oakengine_video_params_supported_frame_rate_at(11, &mut num, &mut den) }, + 0 + ); + assert_eq!((num, den), (60, 1)); + + // Out-of-range / negative / huge indexes → E_INVALID (-1), no panic. + assert_eq!( + unsafe { oakengine_video_params_supported_frame_rate_at(12, &mut num, &mut den) }, + -1 + ); + assert_eq!( + unsafe { oakengine_video_params_supported_frame_rate_at(99, &mut num, &mut den) }, + -1 + ); + assert_eq!( + unsafe { oakengine_video_params_supported_frame_rate_at(-1, &mut num, &mut den) }, + -1 + ); + assert_eq!( + unsafe { oakengine_video_params_supported_frame_rate_at(c_int::MAX, &mut num, &mut den) }, + -1 + ); + // NULL outputs → E_INVALID. + assert_eq!( + unsafe { oakengine_video_params_supported_frame_rate_at(0, std::ptr::null_mut(), &mut den) }, + -1 + ); + assert_eq!( + unsafe { oakengine_video_params_supported_frame_rate_at(0, &mut num, std::ptr::null_mut()) }, + -1 + ); + + // ---- pixel aspects ---------------------------------------------------- + assert_eq!(unsafe { oakengine_video_params_standard_pixel_aspect_count() }, 6); + for i in 0..6 { + assert_eq!( + unsafe { oakengine_video_params_standard_pixel_aspect_at(i, &mut num, &mut den) }, + 0 + ); + assert!(num > 0 && den > 0, "pixel aspect {i} must be a positive rational"); + } + assert_eq!( + unsafe { oakengine_video_params_standard_pixel_aspect_at(0, &mut num, &mut den) }, + 0 + ); + assert_eq!((num, den), (1, 1)); + assert_eq!( + unsafe { oakengine_video_params_standard_pixel_aspect_at(4, &mut num, &mut den) }, + 0 + ); + assert_eq!((num, den), (64, 45)); + assert_eq!( + unsafe { oakengine_video_params_standard_pixel_aspect_at(5, &mut num, &mut den) }, + 0 + ); + assert_eq!((num, den), (4, 3)); + assert_eq!( + unsafe { oakengine_video_params_standard_pixel_aspect_at(6, &mut num, &mut den) }, + -1 + ); + assert_eq!( + unsafe { oakengine_video_params_standard_pixel_aspect_at(-1, &mut num, &mut den) }, + -1 + ); + assert_eq!( + unsafe { oakengine_video_params_standard_pixel_aspect_at(0, std::ptr::null_mut(), &mut den) }, + -1 + ); + + // ---- dividers --------------------------------------------------------- + assert_eq!(unsafe { oakengine_video_params_supported_divider_count() }, 8); + let expected: [c_int; 8] = [1, 2, 3, 4, 6, 8, 12, 16]; + for (i, want) in expected.iter().enumerate() { + assert_eq!(unsafe { oakengine_video_params_supported_divider_at(i as c_int) }, *want); + } + assert_eq!(unsafe { oakengine_video_params_supported_divider_at(8) }, -1); + assert_eq!(unsafe { oakengine_video_params_supported_divider_at(-1) }, -1); + assert_eq!(unsafe { oakengine_video_params_supported_divider_at(c_int::MAX) }, -1); +} + +/// Display names and string formatters (pixel aspect names, divider names, +/// frame-rate strings, PAR template formatting). +#[test] +fn videoparams_names_and_formatters() { + common::force_link(); + + let mut buf = [0 as c_char; 64]; + + // ---- standard pixel aspect names -------------------------------------- + let len = unsafe { oakengine_video_params_standard_pixel_aspect_name(0, buf.as_mut_ptr(), 64) }; + assert_eq!(len, 6); + assert_eq!(unsafe { read_buf(&mut buf) }, "Square"); + let len = unsafe { oakengine_video_params_standard_pixel_aspect_name(1, buf.as_mut_ptr(), 64) }; + assert_eq!(len, 3); + assert_eq!(unsafe { read_buf(&mut buf) }, "8:9"); + let len = unsafe { oakengine_video_params_standard_pixel_aspect_name(4, buf.as_mut_ptr(), 64) }; + assert_eq!(len, 5); + assert_eq!(unsafe { read_buf(&mut buf) }, "64:45"); + // Out of range → E_INVALID; negative index → E_INVALID. + assert_eq!( + unsafe { oakengine_video_params_standard_pixel_aspect_name(6, buf.as_mut_ptr(), 64) }, + -1 + ); + assert_eq!( + unsafe { oakengine_video_params_standard_pixel_aspect_name(-1, buf.as_mut_ptr(), 64) }, + -1 + ); + // NULL buffer reports the length only (two-stage size query). + assert_eq!( + unsafe { oakengine_video_params_standard_pixel_aspect_name(0, std::ptr::null_mut(), 64) }, + 6 + ); + // Too-small buffer truncates but reports the full length. + let mut small = [0 as c_char; 2]; + assert_eq!( + unsafe { oakengine_video_params_standard_pixel_aspect_name(0, small.as_mut_ptr(), 2) }, + 6 + ); + assert_eq!(unsafe { read_buf(&mut small) }, "S"); + + // ---- divider names ------------------------------------------------------ + let len = unsafe { oakengine_video_params_divider_name(1, buf.as_mut_ptr(), 64) }; + assert_eq!(len, 4); + assert_eq!(unsafe { read_buf(&mut buf) }, "Full"); + let len = unsafe { oakengine_video_params_divider_name(2, buf.as_mut_ptr(), 64) }; + assert_eq!(len, 3); + assert_eq!(unsafe { read_buf(&mut buf) }, "1/2"); + let len = unsafe { oakengine_video_params_divider_name(8, buf.as_mut_ptr(), 64) }; + assert_eq!(len, 3); + assert_eq!(unsafe { read_buf(&mut buf) }, "1/8"); + // Zero / negative divider → E_INVALID (facade rejects before the module). + assert_eq!(unsafe { oakengine_video_params_divider_name(0, buf.as_mut_ptr(), 64) }, -1); + assert_eq!(unsafe { oakengine_video_params_divider_name(-3, buf.as_mut_ptr(), 64) }, -1); + // NULL buffer with a positive size → module INVALID, passed through. + assert_eq!( + unsafe { oakengine_video_params_divider_name(2, std::ptr::null_mut(), 64) }, + -10001 + ); + + // ---- frame rate strings ------------------------------------------------- + let len = unsafe { oakengine_video_params_frame_rate_to_string(25, 1, buf.as_mut_ptr(), 64) }; + assert_eq!(len, 6); + assert_eq!(unsafe { read_buf(&mut buf) }, "25 FPS"); + let len = unsafe { + oakengine_video_params_frame_rate_to_string(24000, 1001, buf.as_mut_ptr(), 64) + }; + assert_eq!(len, 10); + assert_eq!(unsafe { read_buf(&mut buf) }, "23.976 FPS"); + let len = unsafe { oakengine_video_params_frame_rate_to_string(10, 1, buf.as_mut_ptr(), 64) }; + assert_eq!(len, 6); + assert_eq!(unsafe { read_buf(&mut buf) }, "10 FPS"); + + // Zero denominator: C++-parity float division (1/0 → +inf), rendered as + // "inf FPS" — a legal return, never a crash/panic. + let len = unsafe { oakengine_video_params_frame_rate_to_string(1, 0, buf.as_mut_ptr(), 64) }; + assert!(len >= 0, "den=0 must not error ({len})"); + assert_eq!(unsafe { read_buf(&mut buf) }, "inf FPS"); + // 0/0 → NaN → "nan FPS". + let len = unsafe { oakengine_video_params_frame_rate_to_string(0, 0, buf.as_mut_ptr(), 64) }; + assert!(len >= 0); + assert_eq!(unsafe { read_buf(&mut buf) }, "nan FPS"); + // NULL buffer with a positive size → module INVALID. + assert_eq!( + unsafe { oakengine_video_params_frame_rate_to_string(25, 1, std::ptr::null_mut(), 64) }, + -10001 + ); + // NULL buffer with size 0 is the two-stage size query. + assert_eq!( + unsafe { oakengine_video_params_frame_rate_to_string(25, 1, std::ptr::null_mut(), 0) }, + 6 + ); + + // ---- PAR template formatting (facade-local) ----------------------------- + let len = unsafe { + oakengine_video_params_format_pixel_aspect_ratio_string( + c"%1".as_ptr(), 16, 15, buf.as_mut_ptr(), 64, + ) + }; + assert_eq!(len, 5); + assert_eq!(unsafe { read_buf(&mut buf) }, "16:15"); + let len = unsafe { + oakengine_video_params_format_pixel_aspect_ratio_string( + c"par=%1".as_ptr(), 4, 3, buf.as_mut_ptr(), 64, + ) + }; + assert_eq!(len, 7); + assert_eq!(unsafe { read_buf(&mut buf) }, "par=4:3"); + // No placeholder: the template passes through unchanged. + let len = unsafe { + oakengine_video_params_format_pixel_aspect_ratio_string( + c"raw".as_ptr(), 16, 15, buf.as_mut_ptr(), 64, + ) + }; + assert_eq!(len, 3); + assert_eq!(unsafe { read_buf(&mut buf) }, "raw"); + // NULL format → E_INVALID; NULL buffer reports the length only. + assert_eq!( + unsafe { + oakengine_video_params_format_pixel_aspect_ratio_string( + std::ptr::null(), 16, 15, buf.as_mut_ptr(), 64, + ) + }, + -1 + ); + assert_eq!( + unsafe { + oakengine_video_params_format_pixel_aspect_ratio_string( + c"%1".as_ptr(), 16, 15, std::ptr::null_mut(), 64, + ) + }, + 5 + ); +} + +/// Format helpers: float/name queries and bytes-per-pixel across the +/// format matrix (valid, boundary and garbage codes). +#[test] +fn videoparams_format_helpers() { + common::force_link(); + + // format_is_float: F16 = 3, F32 = 4 float; everything else 0, garbage + // codes map to the Invalid format and report 0 (never crash). + assert_eq!(unsafe { oakengine_video_params_format_is_float(0) }, 0); // U8 + assert_eq!(unsafe { oakengine_video_params_format_is_float(1) }, 0); // U10 + assert_eq!(unsafe { oakengine_video_params_format_is_float(2) }, 0); // U16 + assert_eq!(unsafe { oakengine_video_params_format_is_float(3) }, 1); // F16 + assert_eq!(unsafe { oakengine_video_params_format_is_float(4) }, 1); // F32 + assert_eq!(unsafe { oakengine_video_params_format_is_float(5) }, 0); // Count + assert_eq!(unsafe { oakengine_video_params_format_is_float(99) }, 0); + assert_eq!(unsafe { oakengine_video_params_format_is_float(-1) }, 0); + assert_eq!(unsafe { oakengine_video_params_format_is_float(c_int::MIN) }, 0); + + // pixel_format_name for every real format. + let mut buf = [0 as c_char; 64]; + let len = unsafe { oakengine_video_params_pixel_format_name(0, buf.as_mut_ptr(), 64) }; + assert_eq!(len, 5); + assert_eq!(unsafe { read_buf(&mut buf) }, "8-bit"); + let len = unsafe { oakengine_video_params_pixel_format_name(1, buf.as_mut_ptr(), 64) }; + assert_eq!(len, 13); + assert_eq!(unsafe { read_buf(&mut buf) }, "10-bit Packed"); + let len = unsafe { oakengine_video_params_pixel_format_name(4, buf.as_mut_ptr(), 64) }; + assert_eq!(len, 19); + assert_eq!(unsafe { read_buf(&mut buf) }, "Full-Float (32-bit)"); + // Garbage format → "Unknown (0xFFFFFFFF)" (Invalid renders %X of -1). + let len = unsafe { oakengine_video_params_pixel_format_name(99, buf.as_mut_ptr(), 64) }; + assert_eq!(len, 20); + assert_eq!(unsafe { read_buf(&mut buf) }, "Unknown (0xFFFFFFFF)"); + // NULL buffer / negative size → module INVALID; size-0 query → length. + assert_eq!( + unsafe { oakengine_video_params_pixel_format_name(0, std::ptr::null_mut(), 64) }, + -10001 + ); + assert_eq!( + unsafe { oakengine_video_params_pixel_format_name(0, buf.as_mut_ptr(), -1) }, + -10001 + ); + assert_eq!( + unsafe { oakengine_video_params_pixel_format_name(0, std::ptr::null_mut(), 0) }, + 5 + ); + + // bytes_per_pixel across the format × channels matrix. + assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(0, 4) }, 4); // U8 + assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(1, 4) }, 4); // U10 packed + assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(2, 4) }, 8); // U16 + assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(3, 4) }, 8); // F16 + assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(4, 4) }, 16); // F32 + // Garbage formats have no channels-per-format entry → 0 bytes. + assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(99, 4) }, 0); + assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(-1, 4) }, 0); + // Zero channels → 0 bytes. + assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(0, 0) }, 0); + assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(4, 0) }, 0); + // Negative channels: the module does not validate (C++ parity), so the + // result is the plain signed product — a value, not a crash. + assert_eq!(unsafe { oakengine_video_params_bytes_per_pixel(4, -1) }, -4); + + assert_eq!(unsafe { oakengine_video_params_internal_channel_count() }, 4); +} + +/// Effective size: divider scaling on the legal matrix plus zero/negative +/// dimensions and dividers → E_INVALID. +#[test] +fn videoparams_effective_size_matrix() { + common::force_link(); + + let mut w: c_int = 0; + let mut h: c_int = 0; + assert_eq!(unsafe { oakengine_video_params_effective_size(1920, 1080, 1, &mut w, &mut h) }, 0); + assert_eq!((w, h), (1920, 1080)); + assert_eq!(unsafe { oakengine_video_params_effective_size(1920, 1080, 2, &mut w, &mut h) }, 0); + assert_eq!((w, h), (960, 540)); + assert_eq!(unsafe { oakengine_video_params_effective_size(1920, 1080, 4, &mut w, &mut h) }, 0); + assert_eq!((w, h), (480, 270)); + assert_eq!(unsafe { oakengine_video_params_effective_size(100, 50, 3, &mut w, &mut h) }, 0); + assert_eq!((w, h), (33, 16)); + // Divider 16 truncates the odd dimension (integer division). + assert_eq!(unsafe { oakengine_video_params_effective_size(1920, 1080, 16, &mut w, &mut h) }, 0); + assert_eq!((w, h), (120, 67)); + + // Both output pointers may be NULL (size computed, nothing written). + assert_eq!( + unsafe { oakengine_video_params_effective_size(1920, 1080, 2, std::ptr::null_mut(), std::ptr::null_mut()) }, + 0 + ); + + // Zero / negative dimensions and dividers → E_INVALID. + assert_eq!( + unsafe { oakengine_video_params_effective_size(0, 1080, 2, &mut w, &mut h) }, + -1 + ); + assert_eq!( + unsafe { oakengine_video_params_effective_size(1920, 0, 2, &mut w, &mut h) }, + -1 + ); + assert_eq!( + unsafe { oakengine_video_params_effective_size(-1, 1080, 2, &mut w, &mut h) }, + -1 + ); + assert_eq!( + unsafe { oakengine_video_params_effective_size(1920, 1080, 0, &mut w, &mut h) }, + -1 + ); + assert_eq!( + unsafe { oakengine_video_params_effective_size(1920, 1080, -2, &mut w, &mut h) }, + -1 + ); +} + +// --------------------------------------------------------------------------- +// videoparams.h — POD make/equal/valid +// --------------------------------------------------------------------------- + +/// A valid POD used across the POD tests. +fn valid_pod() -> OakVideoParamsPod { + let mut p: OakVideoParamsPod = unsafe { std::mem::zeroed() }; + assert_eq!( + unsafe { + oakengine_video_params_make( + &mut p, 1920, 1080, 1001, 30000, 4, 1, 1, 0, 1, 2, + ) + }, + 0 + ); + p +} + +/// make fills every field; equal compares all of them; is_valid implements +/// the engine's POD validity rules. +#[test] +fn videoparams_pod_make_equal_valid() { + common::force_link(); + + // make: every field lands in the POD. + let mut p: OakVideoParamsPod = unsafe { std::mem::zeroed() }; + assert_eq!( + unsafe { oakengine_video_params_make(&mut p, 1920, 1080, 1001, 30000, 4, 1, 1, 0, 1, 2) }, + 0 + ); + assert_eq!(p.width, 1920); + assert_eq!(p.height, 1080); + assert_eq!(p.time_base_num, 1001); + assert_eq!(p.time_base_den, 30000); + assert_eq!(p.format, 4); + assert_eq!(p.pixel_aspect_num, 1); + assert_eq!(p.pixel_aspect_den, 1); + assert_eq!(p.interlacing, 0); + assert_eq!(p.color_range, 1); + assert_eq!(p.divider, 2); + assert_eq!(p.video_type, 0); + assert_eq!(p.premultiplied_alpha, 0); + // NULL POD → E_INVALID. + assert_eq!( + unsafe { + oakengine_video_params_make(std::ptr::null_mut(), 1920, 1080, 1001, 30000, 4, 1, 1, 0, 1, 2) + }, + -1 + ); + + // equal: identical PODs → 1; any differing field → 0; NULL → 0. + let a = valid_pod(); + let mut b = a; + assert_eq!(unsafe { oakengine_video_params_equal(&a, &b) }, 1); + for (field, val) in [ + ("width", 640), + ("height", 720), + ("time_base_num", 25), + ("time_base_den", 1), + ("format", 0), + ("pixel_aspect_num", 4), + ("pixel_aspect_den", 3), + ("interlacing", 1), + ("color_range", 0), + ("divider", 1), + ("video_type", 1), + ("premultiplied_alpha", 1), + ] { + let mut c = a; + match field { + "width" => c.width = val, + "height" => c.height = val, + "time_base_num" => c.time_base_num = val, + "time_base_den" => c.time_base_den = val, + "format" => c.format = val, + "pixel_aspect_num" => c.pixel_aspect_num = val, + "pixel_aspect_den" => c.pixel_aspect_den = val, + "interlacing" => c.interlacing = val, + "color_range" => c.color_range = val, + "divider" => c.divider = val, + "video_type" => c.video_type = val, + "premultiplied_alpha" => c.premultiplied_alpha = val, + _ => unreachable!(), + } + assert_eq!( + unsafe { oakengine_video_params_equal(&a, &c) }, + 0, + "equal must be 0 when {field} differs" + ); + } + assert_eq!(unsafe { oakengine_video_params_equal(std::ptr::null(), &a) }, 0); + assert_eq!(unsafe { oakengine_video_params_equal(&a, std::ptr::null()) }, 0); + + // is_valid: the valid POD → 1. + assert_eq!(unsafe { oakengine_video_params_is_valid(&a) }, 1); + // NULL → 0. + assert_eq!(unsafe { oakengine_video_params_is_valid(std::ptr::null()) }, 0); + // Each invalidating field → 0. + let cases: [(&str, fn(&mut OakVideoParamsPod)); 6] = [ + ("width", |p| p.width = 0), + ("height", |p| p.height = 0), + ("pixel_aspect_num", |p| p.pixel_aspect_num = 0), + ("pixel_aspect_den", |p| p.pixel_aspect_den = 0), + ("format", |p| p.format = -1), + ("time_base_den", |p| p.time_base_den = 0), + ]; + for (name, mutate) in cases { + let mut c = a; + mutate(&mut c); + assert_eq!( + unsafe { oakengine_video_params_is_valid(&c) }, + 0, + "is_valid must be 0 when {name} is invalid" + ); + } + // NOTE (observed divergence): the facade's POD check uses `format >= 0`, + // so out-of-range-but-non-negative formats (e.g. 99, or the Count code 5) + // read as "valid" here, while the module/C++ `VideoParams::is_valid` + // additionally requires `format < Count`. The facade check is a + // simplified local rule (the POD has no channel_count), not a crash. + let mut c = a; + c.format = 99; + assert_eq!(unsafe { oakengine_video_params_is_valid(&c) }, 1); +} + +// --------------------------------------------------------------------------- +// videoparams.h — opaque handle lifecycle +// --------------------------------------------------------------------------- + +/// create/free lifecycle: NULL rejection, real-handle creation, NULL free. +#[test] +fn videoparams_create_free_lifecycle() { + common::force_link(); + + // NULL POD → NULL handle. + assert!(unsafe { oakengine_video_params_create(std::ptr::null()) }.is_null()); + + // Valid POD → non-NULL handle; freed cleanly. + let pod = valid_pod(); + let h = unsafe { oakengine_video_params_create(&pod) }; + assert!(!h.is_null()); + unsafe { oakengine_video_params_free(h) }; + + // A zeroed POD still yields a handle (the module initializes a default + // set and the setters accept any values); the handle frees cleanly. + let zeroed: OakVideoParamsPod = unsafe { std::mem::zeroed() }; + let h = unsafe { oakengine_video_params_create(&zeroed) }; + assert!(!h.is_null()); + unsafe { oakengine_video_params_free(h) }; + + // free(NULL) is a documented no-op. + unsafe { oakengine_video_params_free(std::ptr::null_mut()) }; + + // NOTE (contract): the facade's free deallocates the handle box + // (`Box::from_raw`), so a second free of the same pointer is a + // use-after-free and is NOT part of the family's contract — unlike the + // module-level `oakcommon_videoparams_free`, which nulls the handle out + // before returning. This family exposes no debug alive counter to + // verify a return to baseline; leak-free operation is implied by the + // create/free round-trips above. +} diff --git a/crates/oakengine/tests/it_node.rs b/crates/oakengine/tests/it_node.rs new file mode 100644 index 000000000..15abcd84a --- /dev/null +++ b/crates/oakengine/tests/it_node.rs @@ -0,0 +1,1987 @@ +// 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 . + +//! Integration tests for the node family: every `oakengine_*` export of +//! the facade's node module (src/node.rs; module C contract +//! `include/node/{node,project,footage,keyframe,dragger,group,multicam,...}.h`), +//! exercised end to end against the REAL `oaknode` crate — no mocks. +//! +//! Coverage contract (see the sibling it_plugin.rs): +//! - every export is called on a legal path with the result asserted; +//! - value-range inputs (indices, type ordinals, sizes, track counts) get +//! the meaningful combinations; +//! - illegal inputs (NULL / empty `CHandle` boxes, out-of-range indices, +//! zero/negative sizes, garbage enum ordinals) return a clean negative +//! code or the documented no-op — never a crash; +//! - destroy contracts: facade `free`/`dispose` are NULL/empty no-ops, and +//! the module destroy paths they delegate to are double-free-safe; +//! - the family's debug alive counter (`oaknode_debug_alive_count`) returns +//! to baseline after every owned-object round trip. +//! +//! The facade owns a process-wide undo stack, so every test that pushes +//! undoable commands (or asserts undo state) runs inside [`with_owned`], +//! the same global mutex that guards the alive-counter assertions (owned +//! objects are the only ALIVE sources). Tests that only touch borrowed +//! handles or static helpers run in parallel. +//! +//! The node family is 327 exports. `oakengine_node_inputs_from` and the +//! context-position setters have module-behavior divergences that are +//! documented inline and repeated in the module docs below the tests. + +#[path = "common/mod.rs"] +mod common; + +use std::ffi::{c_char, c_int, c_void, CStr, CString}; +use std::sync::Mutex; + +use oakengine::common::OakVideoParamsPod; +use oakengine::handle::{ + CHandle, OakEngineFootage, OakEngineKeyframe, OakEngineNode, OakEngineNodeDragger, + OakEngineProject, +}; +use oakengine::node::*; +use oakengine::node::value_type as vt; +use oakengine::undo::oakengine_undo_command_free; +use oaknode::ffi::dragger::oaknode_dragger_free; +use oaknode::ffi::factory::oaknode_factory_create_from_id; +use oaknode::ffi::keyframe::oaknode_keyframe_create as oaknode_kf_create; +use oaknode::ffi::keyframe::oaknode_keyframe_free; +use oaknode::ffi::node::oaknode_debug_alive_count; +use oaknode::ffi::node::oaknode_node_free; +use oaknode::ffi::project::oaknode_project_free; + +/// Facade error codes (src/error.rs). +const E_INVALID: c_int = -1; +const E_STATE: c_int = -2; +const E_NOT_FOUND: c_int = -4; +/// Module error codes (include/node/error.h) — passed through untranslated. +const NODE_E_INVALID: c_int = -30001; +const NODE_E_STATE: c_int = -30002; +const NODE_E_NOT_FOUND: c_int = -30004; + +/// Registered node type ids used by the tests (NUL-terminated `&CStr` so +/// `.as_ptr()` is a valid C string, matching the engine's `c"..."` usage). +const TYPE_VALUE: &std::ffi::CStr = c"org.olivevideoeditor.Olive.value"; +const TYPE_SOLID: &std::ffi::CStr = c"org.olivevideoeditor.Olive.solidgenerator"; +const TYPE_TRANSFORM: &std::ffi::CStr = c"org.olivevideoeditor.Olive.transform"; +const TYPE_TEXT: &std::ffi::CStr = c"org.olivevideoeditor.Olive.textgenerator"; +const TYPE_GROUP: &std::ffi::CStr = c"org.olivevideoeditor.Olive.group"; +const TYPE_MULTICAM: &std::ffi::CStr = c"org.olivevideoeditor.Olive.multicam"; +const TYPE_FOOTAGE: &std::ffi::CStr = c"org.olivevideoeditor.Olive.footage"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/// Serialize the owned-object / undo-stack tests: the alive counter and +/// the process-wide undo stack are shared across tests in this binary, so +/// every test that creates owned objects or pushes commands holds the same +/// mutex. Tests that only use borrowed handles run unguarded. +fn with_owned(f: impl FnOnce()) { + static LOCK: Mutex<()> = Mutex::new(()); + let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + f(); +} + +/// Live owned objects (projects, factory/group/copy nodes). +fn alive() -> c_int { + unsafe { oaknode_debug_alive_count() } +} + +/// Read a two-stage facade string out of a fixed buffer. +unsafe fn read_buf(buf: &[c_char]) -> String { + if buf.first().copied().unwrap_or(0) == 0 { + String::new() + } else { + CStr::from_ptr(buf.as_ptr()).to_string_lossy().into_owned() + } +} + +/// Read a NUL-terminated C string. +unsafe fn read_cstr(s: *const c_char) -> String { + unsafe { CStr::from_ptr(s).to_string_lossy().into_owned() } +} + +/// A float POD value. +fn float_value(x: f64) -> OakNodeValue { + OakNodeValue { kind: vt::FLOAT, num: 0, den: 0, f: [x, 0.0, 0.0, 0.0] } +} + +/// An int (combo-compatible) POD value. +fn int_value(x: i64) -> OakNodeValue { + OakNodeValue { kind: vt::INT, num: x, den: 0, f: [0.0; 4] } +} + +/// A vec2 POD value. +fn vec2_value(x: f64, y: f64) -> OakNodeValue { + OakNodeValue { kind: vt::VEC2, num: 0, den: 0, f: [x, y, 0.0, 0.0] } +} + +/// A fresh project box (owned; caller frees with `oakengine_project_free`). +fn new_project() -> *mut OakEngineProject { + let p = oakengine_project_create(); + assert!(!p.is_null()); + assert_eq!(unsafe { oakengine_project_new(p) }, 0); + p +} + +/// A box wrapping an empty (null-ctx) node handle — the "invalid handle" +/// class distinct from a NULL pointer. +fn empty_node_box() -> *mut OakEngineNode { + Box::into_raw(Box::new(OakEngineNode { handle: CHandle::null() })) +} + +/// A live box holding an addref'd copy of `node`'s handle, to hand to +/// `oakengine_node_group_get_inner` (that export replaces the box with the +/// resolved node; the replaced shell is leaked by design of the API). The +/// copy is addref'd so freeing this shell and the original handle later +/// both release their own reference (no double-free). +unsafe fn group_inner_slot(node: *mut OakEngineNode) -> *mut OakEngineNode { + let mut handle = unsafe { (*node).handle }; + if let Some(addref) = handle.addref { + unsafe { addref(handle.ctx) }; + } + Box::into_raw(Box::new(OakEngineNode { handle })) +} + +/// Fresh file under the system temp dir with a unique name. +fn fresh_temp_file(name: &str, contents: &[u8]) -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!("oak-it-node-{}-{name}", std::process::id())); + std::fs::write(&p, contents).expect("write temp file"); + p +} + +/// The index of the first project node whose type id matches `id`, or -1. +unsafe fn find_node(project: *mut OakEngineProject, id: &str) -> c_int { + let count = oakengine_project_node_count(project); + for i in 0..count { + let node = oakengine_project_node_at(project, i); + if node.is_null() { + continue; + } + let mut buf = [0 as c_char; 256]; + let len = oakengine_node_get_type_id(node, buf.as_mut_ptr(), 256); + if len > 0 && unsafe { read_buf(&mut buf) } == id { + return i; + } + } + -1 +} + +/// Force the oakundo command / oakcommon xml dlsym targets into the link +/// (the oaknode serializer bridge resolves them at runtime; same helper as +/// the sibling tests/node.rs). +fn force_oakundo_command_link() -> usize { + let fns: [usize; 3] = [ + oakundo::ffi::command::oakundo_command_init as *const () as usize, + oakcommon::ffi::xmlutils::oakcommon_xml_writer_init as *const () as usize, + oakcommon::ffi::xmlutils::oakcommon_xml_reader_init as *const () as usize, + ]; + fns.iter().sum() +} + +// --------------------------------------------------------------------------- +// Static helpers and pure functions (no handles; parallel-safe) +// --------------------------------------------------------------------------- + +/// Static node ids, category names, value-type names and value math. +#[test] +fn static_ids_and_pure_helpers() { + common::force_link(); + let _ = force_oakundo_command_link(); + + // Static input-id strings. + assert_eq!(unsafe { read_cstr(oakengine_folder_child_input_key()) }, "child_in"); + assert_eq!(unsafe { read_cstr(oakengine_node_enabled_input_id()) }, "enabled_in"); + assert_eq!(unsafe { read_cstr(oakengine_volume_samples_input_id()) }, "samples_in"); + assert_eq!(unsafe { read_cstr(oakengine_transform_texture_input_id()) }, "tex_in"); + assert_eq!(unsafe { read_cstr(oakengine_transition_in_block_input_id()) }, "in_block_in"); + assert_eq!(unsafe { read_cstr(oakengine_transition_out_block_input_id()) }, "out_block_in"); + assert_eq!(unsafe { read_cstr(oakengine_subtitle_text_input_id()) }, "text_in"); + assert_eq!(unsafe { read_cstr(oakengine_project_item_mime_type()) }, + "application/x-oliveprojectitemdata"); + assert_eq!(unsafe { read_cstr(oakengine_multicam_input_current()) }, "current_in"); + assert_eq!(unsafe { read_cstr(oakengine_multicam_input_sources()) }, "sources_in"); + assert_eq!(unsafe { read_cstr(oakengine_multicam_input_sequence()) }, "sequence_in"); + assert_eq!(unsafe { read_cstr(oakengine_multicam_input_sequence_type()) }, "sequence_type_in"); + + // Static flags. + assert_eq!(oakengine_node_flag_dont_show_in_param_view(), 0x1); + assert_eq!(oakengine_node_flag_video_effect(), 0x2); + assert_eq!(oakengine_node_flag_audio_effect(), 0x4); + assert_eq!(oakengine_node_flag_dont_show_in_create_menu(), 0x8); + + // Static scalars. + assert_eq!(oakengine_audio_waveform_max_sample_rate(), 1024.0); + assert_eq!(oakengine_keyframe_default_type(), 0); + assert_eq!(oakengine_keyframe_opposing_bezier_type(0), 1); + assert_eq!(oakengine_keyframe_opposing_bezier_type(1), 0); + // Garbage type ordinal → module INVALID, never a crash. + assert_eq!(oakengine_keyframe_opposing_bezier_type(2), NODE_E_INVALID); + assert_eq!(oakengine_keyframe_opposing_bezier_type(-7), NODE_E_INVALID); + + // Category name matrix: 0..=11 named, everything else "Uncategorized". + let mut buf = [0 as c_char; 64]; + for (cat, expected) in [ + (0, "Output"), (1, "Generator"), (2, "Math"), (3, "Keying"), + (4, "Filter"), (5, "Color"), (6, "Time"), (7, "Timeline"), + (8, "Transition"), (9, "Distort"), (10, "Project"), (11, "OpenFX"), + ] { + let len = unsafe { oakengine_node_category_name(cat, buf.as_mut_ptr(), 64) }; + assert_eq!(len, expected.len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, expected); + } + assert_eq!(unsafe { oakengine_node_category_name(12, buf.as_mut_ptr(), 64) }, 13); + assert_eq!(unsafe { read_buf(&mut buf) }, "Uncategorized"); + assert_eq!(unsafe { oakengine_node_category_name(-1, buf.as_mut_ptr(), 64) }, 13); + assert_eq!(unsafe { read_buf(&mut buf) }, "Uncategorized"); + + // Value keyframe track counts: 1 scalar, 2/3/4 vectors, 4 color, 6 bezier. + assert_eq!(oakengine_node_value_keyframe_track_count(vt::FLOAT), 1); + assert_eq!(oakengine_node_value_keyframe_track_count(vt::INT), 1); + assert_eq!(oakengine_node_value_keyframe_track_count(vt::VEC2), 2); + assert_eq!(oakengine_node_value_keyframe_track_count(vt::VEC3), 3); + assert_eq!(oakengine_node_value_keyframe_track_count(vt::VEC4), 4); + assert_eq!(oakengine_node_value_keyframe_track_count(vt::COLOR), 4); + assert_eq!(oakengine_node_value_keyframe_track_count(vt::BEZIER), 6); + assert_eq!(oakengine_node_value_keyframe_track_count(999), 1); + + // Pretty type names for every legal ordinal. + for ty in 1..=19 { + let len = unsafe { oakengine_node_value_pretty_type_name(ty, buf.as_mut_ptr(), 64) }; + assert!(len > 0, "pretty type name for {ty} must not be empty"); + } + // NONE (0), negative and > AUDIO_PARAMS are invalid. + assert_eq!(unsafe { oakengine_node_value_pretty_type_name(0, buf.as_mut_ptr(), 64) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_value_pretty_type_name(-1, buf.as_mut_ptr(), 64) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_value_pretty_type_name(20, buf.as_mut_ptr(), 64) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_value_pretty_type_name(999, buf.as_mut_ptr(), 64) }, E_INVALID); + + // split_to_tracks: vec2 → two per-component track values; combine back. + let mut tracks = [unsafe { std::mem::zeroed::() }; 2]; + assert_eq!(unsafe { oakengine_node_value_split_to_tracks(vt::VEC2, &vec2_value(1.0, 2.0), tracks.as_mut_ptr(), 2) }, 0); + assert_eq!(tracks[0].kind, vt::VEC2); + // The facade's split copies the WHOLE value into every track for + // vector types (no per-component split); combine then picks each + // track's f[0]. Documented divergence — asserted as actual behavior. + assert!((tracks[0].f[0] - 1.0).abs() < 1e-9); + assert!((tracks[1].f[0] - 1.0).abs() < 1e-9); + let mut out = unsafe { std::mem::zeroed::() }; + assert_eq!(unsafe { oakengine_node_value_combine_tracks(vt::VEC2, tracks.as_ptr(), 2, &mut out) }, 0); + assert!((out.f[0] - 1.0).abs() < 1e-9); + assert!((out.f[1] - 1.0).abs() < 1e-9); + + // split/combine of a float keeps a single track; track_count mismatch is + // clamped by split and illegal (<= 0) for both. + let mut ft = [unsafe { std::mem::zeroed::() }; 4]; + assert_eq!(unsafe { oakengine_node_value_split_to_tracks(vt::FLOAT, &float_value(3.5), ft.as_mut_ptr(), 4) }, 0); + assert!((ft[0].f[0] - 3.5).abs() < 1e-9); + assert_eq!(unsafe { oakengine_node_value_split_to_tracks(vt::FLOAT, &float_value(1.0), std::ptr::null_mut(), 1) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_value_split_to_tracks(vt::FLOAT, &float_value(1.0), ft.as_mut_ptr(), 0) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_value_split_to_tracks(vt::FLOAT, std::ptr::null(), ft.as_mut_ptr(), 1) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_value_combine_tracks(vt::FLOAT, ft.as_ptr(), 0, &mut out) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_value_combine_tracks(vt::FLOAT, std::ptr::null(), 1, &mut out) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_value_combine_tracks(vt::FLOAT, ft.as_ptr(), 1, std::ptr::null_mut()) }, E_INVALID); + + // Multicam grid math legal matrix + illegal inputs. + let mut rows: c_int = 0; + let mut cols: c_int = 0; + assert_eq!(unsafe { oakengine_multicam_get_rows_and_columns(1, &mut rows, &mut cols) }, 0); + assert_eq!((rows, cols), (1, 1)); + assert_eq!(unsafe { oakengine_multicam_get_rows_and_columns(4, &mut rows, &mut cols) }, 0); + assert_eq!((rows, cols), (2, 2)); + assert_eq!(unsafe { oakengine_multicam_get_rows_and_columns(0, &mut rows, &mut cols) }, 0); + assert_eq!((rows, cols), (1, 1)); + assert_eq!(unsafe { oakengine_multicam_get_rows_and_columns(-1, &mut rows, &mut cols) }, E_INVALID); + assert_eq!(unsafe { oakengine_multicam_get_rows_and_columns(4, std::ptr::null_mut(), &mut cols) }, E_INVALID); + assert_eq!(unsafe { oakengine_multicam_get_rows_and_columns(4, &mut rows, std::ptr::null_mut()) }, E_INVALID); + let mut r: c_int = 0; + let mut c: c_int = 0; + assert_eq!(unsafe { oakengine_multicam_index_to_row_cols(0, 2, 2, &mut r, &mut c) }, 0); + assert_eq!((r, c), (0, 0)); + assert_eq!(unsafe { oakengine_multicam_index_to_row_cols(3, 2, 2, &mut r, &mut c) }, 0); + assert_eq!((r, c), (1, 1)); + assert_eq!(unsafe { oakengine_multicam_index_to_row_cols(-1, 2, 2, &mut r, &mut c) }, E_INVALID); + assert_eq!(unsafe { oakengine_multicam_index_to_row_cols(0, 0, 2, &mut r, &mut c) }, E_INVALID); + assert_eq!(unsafe { oakengine_multicam_index_to_row_cols(0, 2, 0, &mut r, &mut c) }, E_INVALID); + assert_eq!(unsafe { oakengine_multicam_index_to_row_cols(0, 2, 2, std::ptr::null_mut(), &mut c) }, E_INVALID); + assert_eq!(unsafe { oakengine_multicam_index_to_row_cols(0, 2, 2, &mut r, std::ptr::null_mut()) }, E_INVALID); + assert_eq!(oakengine_multicam_rows_cols_to_index(1, 1, 2, 2), 3); + assert_eq!(oakengine_multicam_rows_cols_to_index(0, 0, 2, 2), 0); + assert_eq!(oakengine_multicam_rows_cols_to_index(-1, 0, 2, 2), E_INVALID); + assert_eq!(oakengine_multicam_rows_cols_to_index(0, -1, 2, 2), E_INVALID); + assert_eq!(oakengine_multicam_rows_cols_to_index(2, 0, 2, 2), E_INVALID); + assert_eq!(oakengine_multicam_rows_cols_to_index(0, 2, 2, 2), E_INVALID); + assert_eq!(oakengine_multicam_rows_cols_to_index(0, 0, 0, 2), E_INVALID); + assert_eq!(oakengine_multicam_rows_cols_to_index(0, 0, 2, 0), E_INVALID); + + // Footage stream type names. + for (t, name) in [(0, "Video"), (1, "Audio"), (2, "Subtitle"), (3, "Unknown"), (-1, "Unknown")] { + let len = unsafe { oakengine_footage_stream_type_name(t, buf.as_mut_ptr(), 64) }; + assert_eq!(len, name.len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, name); + } +} + +// --------------------------------------------------------------------------- +// Serialized legal paths (undo-stack mutating; owned objects) +// --------------------------------------------------------------------------- + +/// Project lifecycle, factory, node metadata/params/graph edits, keyframes, +/// dragger, group, multicam, folder, bulk delete and footage — the full +/// legal matrix of the node family in one serialized test (the facade's +/// undo stack is process-wide, and owned-object creation feeds the alive +/// counter, both guarded by [`with_owned`]). +#[test] +fn node_family_legal_paths() { + with_owned(|| { + common::force_link(); + let _ = force_oakundo_command_link(); + let base = alive(); + let mut buf = [0 as c_char; 512]; + + // ---- project shell: create → new → name/filename/cache -------- + let project = oakengine_project_create(); + assert!(!project.is_null()); + assert_eq!(alive(), base + 1, "an owned project must be alive-counted"); + + // Freeing NULL is a no-op. + unsafe { oakengine_project_free(std::ptr::null_mut()) }; + + // A fresh project is untitled. + let len = unsafe { oakengine_project_name(project, buf.as_mut_ptr(), 512) }; + assert_eq!(len, "(untitled)".len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, "(untitled)"); + assert_eq!(unsafe { oakengine_project_filename(project, buf.as_mut_ptr(), 512) }, 0); + + // A second new on the same project is rejected with E_STATE. + assert_eq!(unsafe { oakengine_project_new(project) }, 0); + assert_eq!(unsafe { oakengine_project_new(project) }, E_STATE); + + // Modified flag round trip. + assert_eq!(unsafe { oakengine_project_is_modified(project) }, 0); + assert_eq!(unsafe { oakengine_project_set_modified(project, 1) }, 0); + assert_eq!(unsafe { oakengine_project_is_modified(project) }, 1); + assert_eq!(unsafe { oakengine_project_set_modified(project, 0) }, 0); + assert_eq!(unsafe { oakengine_project_is_modified(project) }, 0); + + // Filename drives the display name. + assert_eq!(unsafe { oakengine_project_set_filename(project, c"/tmp/oak_it_node.ovexml".as_ptr()) }, 0); + let len = unsafe { oakengine_project_filename(project, buf.as_mut_ptr(), 512) }; + assert!(len > 0); + assert!(unsafe { read_buf(&mut buf) }.ends_with("oak_it_node.ovexml")); + let len = unsafe { oakengine_project_pretty_filename(project, buf.as_mut_ptr(), 512) }; + assert!(len > 0); + let len = unsafe { oakengine_project_name(project, buf.as_mut_ptr(), 512) }; + assert_eq!(len, "oak_it_node".len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, "oak_it_node"); + + // Cache path surfaces. + let rc = unsafe { oakengine_project_cache_path(project, buf.as_mut_ptr(), 512) }; + assert!(rc >= 0); + assert_eq!(unsafe { oakengine_project_get_cache_location_setting(project) }, 0); + assert_eq!(unsafe { oakengine_project_get_custom_cache_path(project, buf.as_mut_ptr(), 512) }, 0); + assert_eq!(unsafe { oakengine_project_set_custom_cache_path(project, c"/tmp/oak_it_cache".as_ptr()) }, 0); + let len = unsafe { oakengine_project_get_custom_cache_path(project, buf.as_mut_ptr(), 512) }; + assert!(len > 0); + assert_eq!(unsafe { read_buf(&mut buf) }, "/tmp/oak_it_cache"); + // NULL path clears the custom cache path. + assert_eq!(unsafe { oakengine_project_set_custom_cache_path(project, std::ptr::null()) }, 0); + assert_eq!(unsafe { oakengine_project_get_custom_cache_path(project, buf.as_mut_ptr(), 512) }, 0); + // Alongside path is a documented stub returning an empty string. + assert_eq!(unsafe { oakengine_project_cache_alongside_path(project, buf.as_mut_ptr(), 512) }, 0); + + // Color reference space is a documented stub ("" / OK). + assert_eq!(unsafe { oakengine_project_get_color_reference_space(project, buf.as_mut_ptr(), 512) }, 0); + assert_eq!(unsafe { oakengine_project_set_color_reference_space(project, c"rec709".as_ptr()) }, 0); + + // Sequence enumeration: a fresh project has none. + assert_eq!(unsafe { oakengine_project_sequence_count(project) }, 0); + assert!(unsafe { oakengine_project_sequence_at(project, 0) }.is_null()); + + // Root folder + node enumeration. + assert_eq!(unsafe { oakengine_project_node_count(project) }, 1); + let root = unsafe { oakengine_project_root(project) }; + assert!(!root.is_null()); + assert_eq!(unsafe { oakengine_node_is_folder(root) }, 1); + assert_eq!(unsafe { oakengine_node_is_item(root) }, 1); + assert_eq!(unsafe { oakengine_project_node_at(project, 0) }.is_null(), false); + assert!(unsafe { oakengine_project_node_at(project, 999) }.is_null()); + unsafe { oakengine_node_free(root) }; // borrowed shell + + // ---- factory ---------------------------------------------------- + let factory_count = oakengine_node_factory_id_count(); + assert!(factory_count > 0); + let proto = unsafe { oakengine_node_factory_node_at(0) }; + assert!(!proto.is_null()); + let len = unsafe { oakengine_node_get_type_id(proto, buf.as_mut_ptr(), 512) }; + assert!(len > 0); + let type_id = unsafe { read_buf(&mut buf) }; + let type_id_c = CString::new(type_id.as_str()).unwrap(); + let name_len = unsafe { oakengine_node_factory_name_from_id(type_id_c.as_ptr(), buf.as_mut_ptr(), 512) }; + assert!(name_len > 0, "factory name for a registered id must resolve"); + // Factory node index bounds. + assert!(unsafe { oakengine_node_factory_node_at(-1) }.is_null()); + assert!(unsafe { oakengine_node_factory_node_at(1_000_000) }.is_null()); + // NULL type id → empty name (length 0), NULL → NULL node. + assert_eq!(unsafe { oakengine_node_factory_name_from_id(std::ptr::null(), buf.as_mut_ptr(), 512) }, 0); + assert!(unsafe { oakengine_node_factory_create_from_id(std::ptr::null()) }.is_null()); + // Unknown type id → NULL + a non-empty last error. + assert!(unsafe { oakengine_node_factory_create_from_id(c"org.oak.no.such.node".as_ptr()) }.is_null()); + let err_len = unsafe { oakengine_node_last_error(buf.as_mut_ptr(), 512) }; + assert!(err_len > 0, "node_last_error must be non-empty after a failed factory lookup"); + unsafe { oakengine_node_free(proto) }; // borrowed prototype shell + + // Category / description / flags surfaces are stubs with stable results. + let orphan = unsafe { oakengine_node_factory_create_from_id(TYPE_VALUE.as_ptr()) }; + assert!(!orphan.is_null()); + assert_eq!(alive(), base + 2, "an owned factory node must be alive-counted"); + assert_eq!(unsafe { oakengine_node_category_count(orphan) }, 0); + assert_eq!(unsafe { oakengine_node_category_at(orphan, 0) }, -1); + assert_eq!(unsafe { oakengine_node_get_flags(orphan) }, 0); + assert_eq!(unsafe { oakengine_node_get_sub_category(orphan, buf.as_mut_ptr(), 512) }, 0); + assert_eq!(unsafe { oakengine_node_get_description(orphan, buf.as_mut_ptr(), 512) }, 0); + unsafe { oakengine_node_retranslate(orphan) }; // void no-op + unsafe { oakengine_node_delete_later(orphan) }; // void no-op + unsafe { oakengine_node_get_brush(orphan, 0.0, 1.0, std::ptr::null_mut()) }; // void no-op + + // ---- metadata: type id / name / short name / label -------------- + let len = unsafe { oakengine_node_get_type_id(orphan, buf.as_mut_ptr(), 512) }; + assert_eq!(len, TYPE_VALUE.to_bytes().len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, TYPE_VALUE.to_str().unwrap()); + let len = unsafe { oakengine_node_get_name(orphan, buf.as_mut_ptr(), 512) }; + assert_eq!(len, "Value".len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, "Value"); + let len = unsafe { oakengine_node_get_short_name(orphan, buf.as_mut_ptr(), 512) }; + assert_eq!(len, "Value".len() as c_int, "short name falls back to the name"); + // Label is empty until set. + assert_eq!(unsafe { oakengine_node_get_label(orphan, buf.as_mut_ptr(), 512) }, 0); + + // ---- standalone copy (owned; free back to baseline) ------------- + let copy = unsafe { oakengine_node_create_copy(orphan) }; + assert!(!copy.is_null()); + assert_eq!(alive(), base + 3); + let len = unsafe { oakengine_node_get_type_id(copy, buf.as_mut_ptr(), 512) }; + assert_eq!(len, TYPE_VALUE.to_bytes().len() as c_int); + unsafe { oakengine_node_free(copy) }; + assert_eq!(alive(), base + 2, "freeing an owned copy must return the alive counter"); + assert!(unsafe { oakengine_node_create_copy(std::ptr::null()) }.is_null()); + + // ---- project add nodes ------------------------------------------ + let solid = unsafe { oakengine_project_add_node(project, TYPE_SOLID.as_ptr()) }; + assert!(!solid.is_null()); + let transform = unsafe { oakengine_project_add_node(project, TYPE_TRANSFORM.as_ptr()) }; + assert!(!transform.is_null()); + let value = unsafe { oakengine_project_add_node(project, TYPE_VALUE.as_ptr()) }; + assert!(!value.is_null()); + let value2 = unsafe { oakengine_project_add_node(project, TYPE_VALUE.as_ptr()) }; + assert!(!value2.is_null()); + let group = unsafe { oakengine_project_add_node(project, TYPE_GROUP.as_ptr()) }; + assert!(!group.is_null()); + let multicam = unsafe { oakengine_project_add_node(project, TYPE_MULTICAM.as_ptr()) }; + assert!(!multicam.is_null()); + // 6 added nodes + the root folder. + assert_eq!(unsafe { oakengine_project_node_count(project) }, 7); + // Unknown id → NULL + last error. + assert!(unsafe { oakengine_project_add_node(project, c"org.oak.nope".as_ptr()) }.is_null()); + assert!(unsafe { oakengine_project_add_node(std::ptr::null_mut(), TYPE_VALUE.as_ptr()) }.is_null()); + + // The added nodes report their owning project. + let owned_project = unsafe { oakengine_node_get_project(value) }; + assert!(!owned_project.is_null()); + unsafe { oakengine_project_free(owned_project) }; // borrowed project shell + let owned_project2 = unsafe { oakengine_project_from_object(value) }; + assert!(!owned_project2.is_null()); + unsafe { oakengine_project_free(owned_project2) }; + + // ---- node type queries ------------------------------------------ + assert_eq!(unsafe { oakengine_node_is_clip(solid) }, 0); + assert_eq!(unsafe { oakengine_node_is_track(solid) }, 0); + assert_eq!(unsafe { oakengine_node_is_viewer_output(solid) }, 0); + assert_eq!(unsafe { oakengine_node_is_footage(solid) }, 0); + assert_eq!(unsafe { oakengine_node_is_sequence(solid) }, 0); + assert_eq!(unsafe { oakengine_node_is_folder(solid) }, 0); + assert_eq!(unsafe { oakengine_node_is_group(group) }, 1); + assert_eq!(unsafe { oakengine_node_is_group(solid) }, 0); + assert_eq!(unsafe { oakengine_node_is_multicam(multicam) }, 1); + assert_eq!(unsafe { oakengine_node_is_multicam(solid) }, 0); + assert_eq!(unsafe { oakengine_node_is_item(solid) }, 0); + assert_eq!(unsafe { oakengine_node_is_item(group) }, 1, "group is a project-tree item"); + + // ---- label operations (undoable) -------------------------------- + assert_eq!(unsafe { oakengine_node_set_label(value, c"My Value".as_ptr()) }, 0); + let len = unsafe { oakengine_node_get_label(value, buf.as_mut_ptr(), 512) }; + assert_eq!(len, "My Value".len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, "My Value"); + // NULL label → empty label (documented). + assert_eq!(unsafe { oakengine_node_set_label(value, std::ptr::null()) }, 0); + assert_eq!(unsafe { oakengine_node_get_label(value, buf.as_mut_ptr(), 512) }, 0); + + // set_label_ex matrix: undoable (1) and live (0). + assert_eq!(unsafe { oakengine_node_set_label_ex(value, c"Ex".as_ptr(), 1) }, 0); + assert_eq!(unsafe { oakengine_node_set_label_ex(value, c"Ex2".as_ptr(), 0) }, 0); + let len = unsafe { oakengine_node_get_label(value, buf.as_mut_ptr(), 512) }; + assert_eq!(len, 3); + assert_eq!(unsafe { read_buf(&mut buf) }, "Ex2"); + + // label_and_name: label wins over the name. + let len = unsafe { oakengine_node_get_label_and_name(value, buf.as_mut_ptr(), 512) }; + assert_eq!(len, 3); + assert_eq!(unsafe { read_buf(&mut buf) }, "Ex2"); + // No label → the name. + let len = unsafe { oakengine_node_get_label_and_name(solid, buf.as_mut_ptr(), 512) }; + assert!(len > 0); + + // rename_many: one multi command for several nodes. + let mut nodes = [value, value2]; + assert_eq!(unsafe { oakengine_node_rename_many(nodes.as_mut_ptr(), 2, c"Renamed".as_ptr(), std::ptr::null_mut()) }, 0); + let len = unsafe { oakengine_node_get_label(value, buf.as_mut_ptr(), 512) }; + assert_eq!(len, "Renamed".len() as c_int); + // count 0 is a no-op; count < 0 or a NULL entry is invalid. + assert_eq!(unsafe { oakengine_node_rename_many(nodes.as_mut_ptr(), 0, c"x".as_ptr(), std::ptr::null_mut()) }, 0); + assert_eq!(unsafe { oakengine_node_rename_many(nodes.as_mut_ptr(), -1, c"x".as_ptr(), std::ptr::null_mut()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_rename_many(std::ptr::null_mut(), 2, c"x".as_ptr(), std::ptr::null_mut()) }, E_INVALID); + nodes[1] = std::ptr::null_mut(); + assert_eq!(unsafe { oakengine_node_rename_many(nodes.as_mut_ptr(), 2, c"x".as_ptr(), std::ptr::null_mut()) }, E_INVALID); + // set_label_many delegates to rename_many. + nodes = [value, value2]; + assert_eq!(unsafe { oakengine_node_set_label_many(nodes.as_mut_ptr(), 2, c"Many".as_ptr()) }, 0); + + // rename_command: opaque command pointer (caller owns → free). + let cmd = unsafe { oakengine_node_rename_command(value, c"Cmd".as_ptr()) }; + assert!(!cmd.is_null()); + unsafe { oakengine_undo_command_free(cmd) }; + assert!(unsafe { oakengine_node_rename_command(std::ptr::null_mut(), c"x".as_ptr()) }.is_null()); + // NULL label on the command is legal (empty label). + let cmd = unsafe { oakengine_node_rename_command(value, std::ptr::null()) }; + assert!(!cmd.is_null()); + unsafe { oakengine_undo_command_free(cmd) }; + + // ---- color labels ----------------------------------------------- + assert_eq!(unsafe { oakengine_node_get_color_label(value) }, -1); + assert_eq!(unsafe { oakengine_node_get_effective_color_label(value) }, -1); + let cmd = unsafe { oakengine_node_set_color_label_command(value, 3) }; + assert!(!cmd.is_null()); + unsafe { oakengine_undo_command_free(cmd) }; + let mut cl_nodes = [value, value2]; + assert_eq!(unsafe { oakengine_node_set_color_label(cl_nodes.as_mut_ptr(), 2, 2) }, 0); + assert_eq!(unsafe { oakengine_node_set_color_label(cl_nodes.as_mut_ptr(), 0, 2) }, 0); + assert_eq!(unsafe { oakengine_node_set_color_label(cl_nodes.as_mut_ptr(), -1, 2) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_set_color_label(std::ptr::null_mut(), 1, 2) }, E_INVALID); + + // ---- input introspection ---------------------------------------- + // Every node carries the standard enabled_in plus the value node's + // type_in/value_in. + assert_eq!(unsafe { oakengine_node_input_count(value) }, 3); + let len = unsafe { oakengine_node_input_id(value, 0, buf.as_mut_ptr(), 512) }; + assert_eq!(len, "enabled_in".len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, "enabled_in"); + let len = unsafe { oakengine_node_input_id(value, 1, buf.as_mut_ptr(), 512) }; + assert_eq!(len, "type_in".len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, "type_in"); + let len = unsafe { oakengine_node_input_id(value, 2, buf.as_mut_ptr(), 512) }; + assert_eq!(len, "value_in".len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, "value_in"); + assert_eq!(unsafe { oakengine_node_input_id(value, 999, buf.as_mut_ptr(), 512) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_input_id(value, -1, buf.as_mut_ptr(), 512) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_input_get_type(value, c"value_in".as_ptr()) }, vt::FLOAT); + // Texture inputs have no POD type code; the module reports NONE. + assert_eq!(unsafe { oakengine_node_input_get_type(transform, c"tex_in".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_node_input_is_connectable(transform, c"tex_in".as_ptr()) }, 1); + assert_eq!(unsafe { oakengine_node_input_is_connectable(value, c"value_in".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_node_input_is_keyframable(value, c"value_in".as_ptr()) }, 1); + assert_eq!(unsafe { oakengine_node_input_is_connected(transform, c"tex_in".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_node_input_is_connected(value, c"tex_in".as_ptr()) }, NODE_E_NOT_FOUND, "unknown input on a valid node"); + // Input-name lookup (the value node's localized names). + let len = unsafe { oakengine_node_get_input_name(value, c"value_in".as_ptr(), buf.as_mut_ptr(), 512) }; + assert_eq!(len, "Value".len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, "Value"); + let len = unsafe { oakengine_node_get_input_name(value, c"type_in".as_ptr(), buf.as_mut_ptr(), 512) }; + assert_eq!(len, "Type".len() as c_int); + assert_eq!(unsafe { oakengine_node_get_input_name(value, c"nope_in".as_ptr(), buf.as_mut_ptr(), 512) }, NODE_E_NOT_FOUND); + // Stub input flags are stable. + assert_eq!(unsafe { oakengine_node_input_is_array(value, c"value_in".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_node_input_array_size(value, c"value_in".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_node_input_get_flags(value, c"value_in".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_node_input_get_data_type(value, c"value_in".as_ptr()) }, -1); + assert_eq!(unsafe { oakengine_node_input_is_hidden(value, c"value_in".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_node_input_is_keyframed(value, c"value_in".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_node_input_is_keyframed_ex(value, c"value_in".as_ptr(), 0) }, 0); + + // ---- standard parameter access ---------------------------------- + assert_eq!(unsafe { oakengine_node_set_input(value, c"value_in".as_ptr(), &float_value(3.5)) }, 0); + let mut out: OakNodeValue = unsafe { std::mem::zeroed() }; + assert_eq!(unsafe { oakengine_node_get_input(value, c"value_in".as_ptr(), &mut out) }, 0); + assert_eq!(out.kind, vt::FLOAT); + assert!((out.f[0] - 3.5).abs() < 1e-6); + // Type mismatch (INT kind into a float input) → module INVALID. + assert_eq!(unsafe { oakengine_node_set_input(value, c"value_in".as_ptr(), &int_value(7)) }, NODE_E_INVALID); + // Unknown input → module NOT_FOUND. + assert_eq!(unsafe { oakengine_node_set_input(value, c"nope_in".as_ptr(), &float_value(1.0)) }, NODE_E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_get_input(value, c"nope_in".as_ptr(), &mut out) }, NODE_E_NOT_FOUND); + + // set_standard_value_command → opaque command pointer (free). + let cmd = unsafe { oakengine_node_set_standard_value_command(value, c"value_in".as_ptr(), -1, -1, &float_value(4.0)) }; + assert!(!cmd.is_null()); + unsafe { oakengine_undo_command_free(cmd) }; + assert!(unsafe { oakengine_node_set_standard_value_command(value, c"value_in".as_ptr(), -1, -1, std::ptr::null()) }.is_null()); + + // set_input_video_params_command is a documented stub → NULL. + let params = unsafe { std::mem::zeroed::() }; + assert!(unsafe { oakengine_node_set_input_video_params_command(value, c"value_in".as_ptr(), ¶ms) }.is_null()); + + // ---- string parameter access (text generator) ------------------- + let textgen = unsafe { oakengine_node_factory_create_from_id(TYPE_TEXT.as_ptr()) }; + assert!(!textgen.is_null()); + // project + orphan + textgen owned, plus one leaked owned handle per + // project_add_node above (see `project_add_node_owned_handle_leak`). + let alive_now = alive(); + assert_eq!(alive_now, base + 9, "textgen: alive_now={alive_now} base={base}"); + // String-carried types report as STRING in the POD enum (Text has + // no dedicated code; the module maps Text/StrCombo to STRING). + assert_eq!(unsafe { oakengine_node_input_get_type(textgen, c"text_in".as_ptr()) }, vt::STRING); + // The generator's default text is "Sample Text" (11 chars). + let len = unsafe { oakengine_node_get_input_string(textgen, c"text_in".as_ptr(), buf.as_mut_ptr(), 512) }; + assert_eq!(len, "Sample Text".len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, "Sample Text"); + assert_eq!(unsafe { oakengine_node_set_input_string(textgen, c"text_in".as_ptr(), c"Hello Text".as_ptr()) }, 0); + let len2 = unsafe { oakengine_node_get_input_string(textgen, c"text_in".as_ptr(), buf.as_mut_ptr(), 512) }; + assert_eq!(len2, "Hello Text".len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, "Hello Text"); + // String write on a non-string input → module INVALID. + assert_eq!(unsafe { oakengine_node_set_input_string(value, c"value_in".as_ptr(), c"x".as_ptr()) }, NODE_E_INVALID); + // get_input_string on a non-string input → module INVALID. + assert_eq!(unsafe { oakengine_node_get_input_string(value, c"value_in".as_ptr(), buf.as_mut_ptr(), 512) }, NODE_E_INVALID); + // Unknown input → NOT_FOUND for the string getter. + assert_eq!(unsafe { oakengine_node_get_input_string(value, c"nope_in".as_ptr(), buf.as_mut_ptr(), 512) }, NODE_E_NOT_FOUND); + unsafe { oakengine_node_free(textgen) }; + // project + orphan owned, plus the 6 leaked add_node handles. + assert_eq!(alive(), base + 8); + + // ---- at-time values --------------------------------------------- + assert_eq!(unsafe { oakengine_node_frame_time_base(value, std::ptr::null_mut(), std::ptr::null_mut()) }, 0); + let mut tb_num: c_int = 0; + let mut tb_den: c_int = 0; + assert_eq!(unsafe { oakengine_node_frame_time_base(value, &mut tb_num, &mut tb_den) }, 0); + assert_eq!((tb_num, tb_den), (1001, 30000), "engine default time base without a sequence"); + + assert_eq!(unsafe { oakengine_node_set_input_at_time(value, c"value_in".as_ptr(), -1, 0, -1, &float_value(0.5), 0) }, 0); + let mut at: OakNodeValue = unsafe { std::mem::zeroed() }; + assert_eq!(unsafe { oakengine_node_get_input_at_time(value, c"value_in".as_ptr(), -1, -1, 0, 0, &mut at) }, 0); + assert_eq!(at.kind, vt::FLOAT); + assert!((at.f[0] - 0.5).abs() < 1e-6); + // At-time read on an unknown input → module NOT_FOUND. + assert_eq!(unsafe { oakengine_node_get_input_at_time(value, c"nope_in".as_ptr(), -1, -1, 0, 0, &mut at) }, NODE_E_NOT_FOUND); + // At-time write on an unknown input → module NOT_FOUND. + assert_eq!(unsafe { oakengine_node_set_input_at_time(value, c"nope_in".as_ptr(), -1, 0, -1, &float_value(1.0), 0) }, NODE_E_NOT_FOUND); + + // set_value_at_time_command → opaque command pointer (free). + let cmd = unsafe { oakengine_node_set_value_at_time_command( + value as *mut c_void, c"value_in".as_ptr(), -1, 0, 1, &float_value(0.75), -1, 0) }; + assert!(!cmd.is_null()); + unsafe { oakengine_undo_command_free(cmd) }; + // Zero denominator → NULL, never a crash. + assert!(unsafe { oakengine_node_set_value_at_time_command( + value as *mut c_void, c"value_in".as_ptr(), -1, 0, 0, &float_value(0.75), -1, 0) }.is_null()); + + // String at-time path is a documented stub → Invalid. + assert_eq!(unsafe { oakengine_node_set_input_string_at_time(value, c"value_in".as_ptr(), -1, 0, c"x".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_input_string_at_time(value, c"value_in".as_ptr(), -1, 0, -1, buf.as_mut_ptr(), 512) }, E_INVALID); + // Bezier/binary at-time reads are documented stubs → Invalid. + let mut six = [0.0f64; 6]; + assert_eq!(unsafe { oakengine_node_get_input_bezier_at_time(value, c"value_in".as_ptr(), -1, 0, -1, six.as_mut_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_input_binary_at_time(value, c"value_in".as_ptr(), -1, 0, -1, buf.as_mut_ptr(), 512) }, E_INVALID); + + // ---- input default value (stub) --------------------------------- + assert_eq!(unsafe { oakengine_node_input_get_default_value(value, c"value_in".as_ptr(), 0, &mut out) }, E_NOT_FOUND); + + // ---- input properties (stubs) ----------------------------------- + assert_eq!(unsafe { oakengine_node_input_has_property(value, c"value_in".as_ptr(), c"key".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_node_set_input_property_string(value, c"value_in".as_ptr(), c"key".as_ptr(), c"v".as_ptr(), 1) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_input_get_property_string(value, c"value_in".as_ptr(), c"key".as_ptr(), buf.as_mut_ptr(), 512) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_input_get_property_number(value, c"value_in".as_ptr(), c"key".as_ptr(), 0, std::ptr::null_mut()) }, E_INVALID); + let mut dbl: f64 = 0.0; + assert_eq!(unsafe { oakengine_node_input_get_property_number(value, c"value_in".as_ptr(), c"key".as_ptr(), 0, &mut dbl) }, E_NOT_FOUND); + let mut i64out: i64 = 0; + assert_eq!(unsafe { oakengine_node_input_get_property_int(value, c"value_in".as_ptr(), c"key".as_ptr(), &mut i64out) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_input_get_property_rational(value, c"value_in".as_ptr(), c"key".as_ptr(), std::ptr::null_mut(), std::ptr::null_mut()) }, E_NOT_FOUND, "stub NotFound with a valid node"); + assert_eq!(unsafe { oakengine_node_input_get_property_track_number(value, c"value_in".as_ptr(), c"key".as_ptr(), 0, &mut dbl) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_input_get_property_count(value, c"value_in".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_node_input_get_property_key(value, c"value_in".as_ptr(), 0, buf.as_mut_ptr(), 512) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_input_get_property_string_list_count(value, c"value_in".as_ptr(), c"key".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_node_input_get_property_string_list(value, c"value_in".as_ptr(), c"key".as_ptr(), 0, buf.as_mut_ptr(), 512) }, E_NOT_FOUND); + + // ---- copy inputs ------------------------------------------------- + assert_eq!(unsafe { oakengine_node_copy_inputs(value2, value) }, 0); + assert_eq!(unsafe { oakengine_node_copy_inputs(std::ptr::null_mut(), value) }, E_INVALID); + + // ---- value hint ------------------------------------------------- + assert_eq!(unsafe { oakengine_node_set_value_hint(value, c"value_in".as_ptr(), 0, 0, 0, c"".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_node_set_value_hint(value, c"nope_in".as_ptr(), 0, 0, 0, c"".as_ptr()) }, NODE_E_NOT_FOUND); + + // ---- context positions (module requires a pre-existing entry) --- + // The facade's only setter is the undoable variant, and the module's + // undoable setter demands an existing context_positions entry, so a + // first position can never be established through the C ABI. See the + // module docs below; asserted as documented behavior. + let root = unsafe { oakengine_project_root(project) }; + let mut x: f64 = 0.0; + let mut y: f64 = 0.0; + let mut expanded: c_int = 0; + assert_eq!(unsafe { oakengine_node_context_node_count(root) }, 0); + assert_eq!(unsafe { oakengine_node_context_contains_node(root, value) }, 0); + assert!(unsafe { oakengine_node_context_node_at(root, 0, &mut x, &mut y, &mut expanded) }.is_null()); + assert_eq!(unsafe { oakengine_node_set_context_position(root, value, 10.0, 20.0) }, NODE_E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_get_context_position(root, value, &mut x, &mut y, &mut expanded) }, NODE_E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_set_context_expanded(root, value, 1) }, NODE_E_NOT_FOUND); + unsafe { oakengine_node_free(root) }; + + // ---- array inputs (multicam sources_in is an array) -------------- + assert_eq!(unsafe { oakengine_node_array_insert_at(multicam, c"sources_in".as_ptr(), 0) }, 0); + assert_eq!(unsafe { oakengine_node_array_insert_at(multicam, c"sources_in".as_ptr(), 1) }, 0); + assert_eq!(unsafe { oakengine_multicam_get_source_count(multicam) }, 2); + assert_eq!(unsafe { oakengine_node_array_remove_at(multicam, c"sources_in".as_ptr(), 1) }, 0); + assert_eq!(unsafe { oakengine_multicam_get_source_count(multicam) }, 1); + assert_eq!(unsafe { oakengine_node_array_remove_at(multicam, c"sources_in".as_ptr(), 0) }, 0); + assert_eq!(unsafe { oakengine_multicam_get_source_count(multicam) }, 0); + // Non-array input → module INVALID; negative index → facade INVALID. + assert_eq!(unsafe { oakengine_node_array_insert_at(value, c"value_in".as_ptr(), 0) }, NODE_E_INVALID); + assert_eq!(unsafe { oakengine_node_array_insert_at(value, c"value_in".as_ptr(), -1) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_array_remove_at(value, c"value_in".as_ptr(), 0) }, NODE_E_INVALID); + assert_eq!(unsafe { oakengine_multicam_get_source_count(value) }, E_INVALID, "non-multicam node"); + + // ---- graph editing: connect / disconnect ------------------------- + assert_eq!(unsafe { oakengine_node_connect(solid, transform, c"tex_in".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_node_input_is_connected(transform, c"tex_in".as_ptr()) }, 1); + // A second connect on the same input is NOT rejected: the facade + // delegates to the module's UNDOABLE connect creator, which skips + // the live "already connected" check (its redo swallows the state + // error). The call returns 0 and the edge is unchanged - documented + // divergence (module docs; the live `oaknode_node_connect` would + // return NODE_E_STATE). + assert_eq!(unsafe { oakengine_node_connect(solid, transform, c"tex_in".as_ptr()) }, 0); + // Connecting to a non-connectable input → module INVALID. + assert_eq!(unsafe { oakengine_node_connect(solid, value, c"value_in".as_ptr()) }, NODE_E_INVALID); + // Connecting to an unknown input → module NOT_FOUND. + assert_eq!(unsafe { oakengine_node_connect(solid, value, c"nope_in".as_ptr()) }, NODE_E_NOT_FOUND); + + // Connected-node + output-connection introspection. + let conn = unsafe { oakengine_node_input_get_connected_node(transform, c"tex_in".as_ptr(), -1) }; + assert!(!conn.is_null()); + let len = unsafe { oakengine_node_get_type_id(conn, buf.as_mut_ptr(), 512) }; + assert_eq!(len, TYPE_SOLID.to_bytes().len() as c_int); + unsafe { oakengine_node_free(conn) }; + assert!(unsafe { oakengine_node_input_get_connected_node(transform, c"nope_in".as_ptr(), -1) }.is_null()); + assert_eq!(unsafe { oakengine_node_output_connection_count(solid) }, 1); + let mut conn_node: *mut OakEngineNode = std::ptr::null_mut(); + let mut elem: c_int = -1; + assert_eq!(unsafe { oakengine_node_output_connection_at(solid, 0, &mut conn_node, buf.as_mut_ptr(), 512, &mut elem) }, 0); + assert!(!conn_node.is_null()); + assert_eq!(unsafe { read_buf(&mut buf) }, "tex_in"); + assert_eq!(elem, -1); + unsafe { oakengine_node_free(conn_node) }; + // _ex variant reports the module's hidden flag (always 0). + let mut hidden: c_int = 1; + assert_eq!(unsafe { oakengine_node_output_connection_at_ex(solid, 0, &mut conn_node, buf.as_mut_ptr(), 512, &mut elem, &mut hidden) }, 0); + assert_eq!(hidden, 0); + unsafe { oakengine_node_free(conn_node) }; + // Out-of-range output index. + assert_eq!(unsafe { oakengine_node_output_connection_at(solid, 1, &mut conn_node, buf.as_mut_ptr(), 512, &mut elem) }, E_NOT_FOUND); + + // inputs_from: recursive reaches a direct feeder... + assert_eq!(unsafe { oakengine_node_inputs_from(transform, solid, 1) }, 1); + // ...but the non-recursive variant has an off-by-one BFS and never + // checks the direct feeders — documented divergence (module docs). + assert_eq!(unsafe { oakengine_node_inputs_from(transform, solid, 0) }, 0); + assert_eq!(unsafe { oakengine_node_inputs_from(value, solid, 1) }, 0); + assert_eq!(unsafe { oakengine_node_inputs_from(std::ptr::null(), solid, 1) }, 0); + assert_eq!(unsafe { oakengine_node_inputs_from(transform, std::ptr::null(), 1) }, 0); + + // Input-connection surfaces are stubs (outputs only). + assert_eq!(unsafe { oakengine_node_input_connection_count_all(transform) }, 0); + assert_eq!(unsafe { oakengine_node_input_connection_count(transform, c"tex_in".as_ptr(), -1) }, 0); + assert_eq!(unsafe { oakengine_node_input_connection_at_all(transform, 0, &mut conn_node, buf.as_mut_ptr(), 512, &mut elem, &mut conn_node, &mut hidden) }, E_NOT_FOUND); + assert!(unsafe { oakengine_node_input_connection_at(transform, c"tex_in".as_ptr(), -1, 0) }.is_null()); + + // Disconnect + command variants. + assert_eq!(unsafe { oakengine_node_disconnect(transform, c"tex_in".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_node_input_is_connected(transform, c"tex_in".as_ptr()) }, 0); + // Disconnecting a disconnected input → module NOT_FOUND. + assert_eq!(unsafe { oakengine_node_disconnect(transform, c"tex_in".as_ptr()) }, NODE_E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_disconnect_ex(transform, c"tex_in".as_ptr(), -1) }, NODE_E_NOT_FOUND); + let cmd = unsafe { oakengine_node_connect_command(solid, transform, c"tex_in".as_ptr(), -1) }; + assert!(!cmd.is_null()); + unsafe { oakengine_undo_command_free(cmd) }; + assert_eq!(unsafe { oakengine_node_connect(solid, transform, c"tex_in".as_ptr()) }, 0); + let cmd = unsafe { oakengine_node_disconnect_command(transform, c"tex_in".as_ptr(), -1) }; + assert!(!cmd.is_null()); + unsafe { oakengine_undo_command_free(cmd) }; + + // block_link: link/unlink round trip (1 = changed, 0 = no-op). + assert_eq!(unsafe { oakengine_block_link(solid as *mut c_void, value as *mut c_void, 1) }, 1); + assert_eq!(unsafe { oakengine_block_link(solid as *mut c_void, value as *mut c_void, 1) }, 0, "already linked"); + assert_eq!(unsafe { oakengine_block_link(solid as *mut c_void, value as *mut c_void, 0) }, 1); + assert_eq!(unsafe { oakengine_block_link(solid as *mut c_void, value as *mut c_void, 0) }, 0, "already unlinked"); + assert_eq!(unsafe { oakengine_block_link(std::ptr::null_mut(), value as *mut c_void, 1) }, E_INVALID); + // link_command → opaque command pointer (free). + let cmd = unsafe { oakengine_node_link_command(solid, value, 1) }; + assert!(!cmd.is_null()); + unsafe { oakengine_undo_command_free(cmd) }; + assert!(unsafe { oakengine_node_link_command(std::ptr::null_mut(), value, 1) }.is_null()); + + // ---- copy in graph ---------------------------------------------- + // copy_in_graph pushes a "Copy Node" command and returns an owned + // copy in a scratch project (free to return the alive counter). + let count_before = unsafe { oakengine_project_node_count(project) }; + let copied = unsafe { oakengine_node_copy_in_graph(value, std::ptr::null_mut()) }; + assert!(!copied.is_null()); + assert_eq!(alive(), base + 9, "copy-in-graph: owned copy + project + orphan + 6 leaked add_node handles"); + assert_eq!(unsafe { oakengine_project_node_count(project) }, count_before + 1, "the redo inserts a copy into the graph"); + unsafe { oakengine_node_free(copied) }; + assert_eq!(alive(), base + 8); + + // add_to_project_command: opaque AddNode command for an orphan. + let orphan2 = unsafe { oakengine_node_factory_create_from_id(TYPE_VALUE.as_ptr()) }; + assert!(!orphan2.is_null()); + let cmd = unsafe { oakengine_node_add_to_project_command(project, orphan2) }; + assert!(!cmd.is_null()); + unsafe { oakengine_undo_command_free(cmd) }; + unsafe { oakengine_node_free(orphan2) }; + // Adding a node already in the project → NULL command. + assert!(unsafe { oakengine_node_add_to_project_command(project, value) }.is_null()); + + // copy_dependency_graph is a documented stub → Invalid. + let mut copies: *mut OakEngineNode = std::ptr::null_mut(); + let mut srcs = [value]; + assert_eq!(unsafe { oakengine_node_copy_dependency_graph(srcs.as_mut_ptr(), &mut copies, 1, std::ptr::null_mut()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_copy_dependency_graph(std::ptr::null_mut(), &mut copies, 1, std::ptr::null_mut()) }, E_INVALID); + + // connect_command_string is a documented stub → empty description. + assert_eq!(unsafe { oakengine_node_connect_command_string(solid, value, c"value_in".as_ptr(), -1, buf.as_mut_ptr(), 512) }, 0); + + // transform_time_to is a documented identity stub. + let mut rin: i64 = 0; + let mut rind: i64 = 0; + let mut rout: i64 = 0; + let mut routd: i64 = 0; + assert_eq!(unsafe { oakengine_node_transform_time_to(solid, value, 0, 0, 5, 1, 7, 2, &mut rin, &mut rind, &mut rout, &mut routd) }, 0); + assert_eq!((rin, rind, rout, routd), (5, 1, 7, 2)); + assert_eq!(unsafe { oakengine_node_transform_time_to(std::ptr::null_mut(), value, 0, 0, 1, 1, 1, 1, &mut rin, &mut rind, &mut rout, &mut routd) }, E_INVALID); + + // ---- keyframes (facade stubs + the real handle API) ------------- + assert_eq!(unsafe { oakengine_node_keyframe_count(value, c"value_in".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_node_keyframe_count_on_track(value, c"value_in".as_ptr(), 0, 0) }, 0); + assert_eq!(unsafe { oakengine_node_keyframe_at(value, c"value_in".as_ptr(), 0, std::ptr::null_mut(), std::ptr::null_mut()) }, E_NOT_FOUND, "stub NotFound with a valid node"); + let mut kf_time: i64 = 0; + assert_eq!(unsafe { oakengine_node_keyframe_at(value, c"value_in".as_ptr(), 0, &mut kf_time, &mut out) }, E_NOT_FOUND); + let mut f1: f32 = 0.0; + let mut f2: f32 = 0.0; + let mut f3: f32 = 0.0; + let mut f4: f32 = 0.0; + let mut kty: c_int = 0; + assert_eq!(unsafe { oakengine_node_keyframe_get_easing(value, c"value_in".as_ptr(), 0, &mut f1, &mut f2, &mut f3, &mut f4, &mut kty) }, E_NOT_FOUND); + // keyframe_add pushes the value-at-time path (undoable). + assert_eq!(unsafe { oakengine_node_keyframe_add(value, c"value_in".as_ptr(), 0, &float_value(1.5), 0, 0.0, 0.0, 0.0, 0.0) }, 0); + // Invalid easing type ordinal → Invalid. + assert_eq!(unsafe { oakengine_node_keyframe_add(value, c"value_in".as_ptr(), 0, &float_value(1.5), 3, 0.0, 0.0, 0.0, 0.0) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_keyframe_add(value, c"value_in".as_ptr(), 0, &float_value(1.5), -1, 0.0, 0.0, 0.0, 0.0) }, E_INVALID); + // keyframe_remove is a documented stub → NOT_FOUND. + assert_eq!(unsafe { oakengine_node_keyframe_remove(value, c"value_in".as_ptr(), 0) }, E_NOT_FOUND); + // Keyframe stub enumerators. + assert_eq!(unsafe { oakengine_node_has_keyframe_at_time(value, c"value_in".as_ptr(), -1, 0, 0) }, 0); + assert_eq!(unsafe { oakengine_node_keyframe_earliest_time(value, c"value_in".as_ptr(), -1, &mut i64out, std::ptr::null_mut()) }, 0); + assert_eq!(unsafe { oakengine_node_keyframe_latest_time(value, c"value_in".as_ptr(), -1, std::ptr::null_mut(), &mut i64out) }, 0); + assert_eq!(unsafe { oakengine_node_keyframe_closest_time_before(value, c"value_in".as_ptr(), -1, 0, 0, &mut i64out, &mut i64out) }, 0); + assert_eq!(unsafe { oakengine_node_keyframe_closest_time_after(value, c"value_in".as_ptr(), -1, 0, 0, &mut i64out, &mut i64out) }, 0); + assert!(unsafe { oakengine_node_keyframe_handle_on_track(value, c"value_in".as_ptr(), -1, 0, 0) }.is_null()); + assert!(unsafe { oakengine_node_keyframe_handle_at_time(value, c"value_in".as_ptr(), -1, 0, 0, 1) }.is_null()); + assert_eq!(unsafe { oakengine_node_keyframes_at_time(value, c"value_in".as_ptr(), -1, 0, 1, std::ptr::null_mut(), 0) }, 0); + assert_eq!(unsafe { oakengine_node_keyframes_toggle_at_time(value, c"value_in".as_ptr(), -1, 0, 0, 1, c"t".as_ptr()) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_set_input_keyframing(value, c"value_in".as_ptr(), -1, 1, 0, 0, c"k".as_ptr()) }, E_INVALID); + assert!(unsafe { oakengine_node_set_input_keyframing_command(value, c"value_in".as_ptr(), -1, 1) }.is_null()); + assert_eq!(unsafe { oakengine_node_keyframes_paste(value, std::ptr::null_mut(), 1, c"p".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_keyframe_best_type_at_time(value, c"value_in".as_ptr(), -1, 0, 0, 2) }, 2, "caller default passes through"); + assert_eq!(unsafe { oakengine_node_keyframe_track_count(value, c"value_in".as_ptr(), -1) }, 1); + assert_eq!(unsafe { oakengine_node_keyframe_set_easing(value, c"value_in".as_ptr(), 0, 0, 0.0, 0.0, 0.0, 0.0) }, E_NOT_FOUND); + let ts: [i64; 1] = [0]; + let trk: [c_int; 1] = [0]; + assert_eq!(unsafe { oakengine_node_keyframes_set_type_many(value, c"value_in".as_ptr(), -1, ts.as_ptr(), trk.as_ptr(), 1, 0) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_keyframes_set_time_many(value, c"value_in".as_ptr(), -1, ts.as_ptr(), trk.as_ptr(), 1, 1) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_keyframes_set_value_many(value, c"value_in".as_ptr(), -1, ts.as_ptr(), trk.as_ptr(), 1, &float_value(1.0), &float_value(0.0)) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_keyframes_set_bezier_many(value, c"value_in".as_ptr(), -1, ts.as_ptr(), trk.as_ptr(), 1, 0.0, 0.0, 1.0, 1.0) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_keyframe_set_bezier_point(value, c"value_in".as_ptr(), -1, 0, 0, 0, 0.0, 0.0, 0.0, 0.0) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_keyframes_clear(value, c"value_in".as_ptr()) }, 0, "documented no-op"); + // insert_keyframe_command → opaque command pointer (free). + let cmd = unsafe { oakengine_node_insert_keyframe_command(value, c"value_in".as_ptr(), -1, 0, 0, &float_value(2.0), 0, 0.0, 0.0, 0.0, 0.0) }; + assert!(!cmd.is_null()); + unsafe { oakengine_undo_command_free(cmd) }; + assert!(unsafe { oakengine_node_insert_keyframe_command(value, c"value_in".as_ptr(), -1, 0, 0, &float_value(2.0), 3, 0.0, 0.0, 0.0, 0.0) }.is_null(), "garbage easing type"); + // remove_keyframe_command is a documented stub → NULL. + let dummy_kf = Box::into_raw(Box::new(OakEngineKeyframe { handle: CHandle::null() })); + assert!(unsafe { oakengine_node_remove_keyframe_command(dummy_kf) }.is_null()); + unsafe { oakengine_keyframe_dispose(dummy_kf) }; + + // ---- OakEngineKeyframe handle API -------------------------------- + let kf = unsafe { oakengine_keyframe_create(value, c"value_in".as_ptr(), -1, 0, 0, 0, &float_value(1.0), 0) }; + assert!(!kf.is_null()); + let mut num: i64 = 0; + let mut den: i64 = 0; + assert_eq!(unsafe { oakengine_keyframe_get_time(kf, &mut num, &mut den) }, 0); + assert_eq!(num, 0); + assert_eq!(den, 1); + let len = unsafe { oakengine_keyframe_get_input_id(kf, buf.as_mut_ptr(), 512) }; + assert_eq!(len, "value_in".len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, "value_in"); + assert_eq!(unsafe { oakengine_keyframe_get_track(kf) }, 0); + assert_eq!(unsafe { oakengine_keyframe_get_element(kf) }, -1); + let parent = unsafe { oakengine_keyframe_get_node(kf) }; + assert!(!parent.is_null()); + let len = unsafe { oakengine_node_get_type_id(parent, buf.as_mut_ptr(), 512) }; + assert_eq!(len, TYPE_VALUE.to_bytes().len() as c_int); + unsafe { oakengine_node_free(parent) }; + assert_eq!(unsafe { oakengine_keyframe_get_type(kf) }, 0); + let mut kout: OakNodeValue = unsafe { std::mem::zeroed() }; + assert_eq!(unsafe { oakengine_keyframe_get_value(kf, &mut kout) }, 0); + assert_eq!(kout.kind, vt::FLOAT); + assert!((kout.f[0] - 1.0).abs() < 1e-9); + + // Live setters + readback. + assert_eq!(unsafe { oakengine_keyframe_set_value_live(kf, &float_value(2.0)) }, 0); + assert_eq!(unsafe { oakengine_keyframe_set_time_live(kf, 1, 1) }, 0); + assert_eq!(unsafe { oakengine_keyframe_get_time(kf, &mut num, &mut den) }, 0); + assert_eq!((num, den), (1, 1)); + assert_eq!(unsafe { oakengine_keyframe_set_bezier_point_live(kf, 0, 0.5, 0.25) }, 0); + let mut bx: f64 = 0.0; + let mut by: f64 = 0.0; + assert_eq!(unsafe { oakengine_keyframe_get_bezier_point(kf, 0, &mut bx, &mut by) }, 0); + assert!((bx - 0.5).abs() < 1e-9); + assert!((by - 0.25).abs() < 1e-9); + assert_eq!(unsafe { oakengine_keyframe_get_valid_bezier_point(kf, 1, &mut bx, &mut by) }, 0); + // Bezier point index bounds. + assert_eq!(unsafe { oakengine_keyframe_set_bezier_point_live(kf, 2, 0.0, 0.0) }, E_INVALID); + assert_eq!(unsafe { oakengine_keyframe_get_bezier_point(kf, -1, &mut bx, &mut by) }, E_INVALID); + // compute_paste_value against the target node. + let mut pv: OakNodeValue = unsafe { std::mem::zeroed() }; + assert_eq!(unsafe { oakengine_keyframe_compute_paste_value(value, kf, &mut pv) }, 0); + // Orphaned keyframe has no sibling. + assert_eq!(unsafe { oakengine_keyframe_has_sibling_at_time(kf, 1, 1) }, 0); + assert_eq!(unsafe { oakengine_keyframe_has_sibling_at_time(kf, 1, -1) }, 0, "negative track ignored"); + // Undoable command creators over a keyframe handle. + let cmd = unsafe { oakengine_keyframe_set_time_command(kf, 3) }; + assert!(!cmd.is_null()); + unsafe { oakengine_undo_command_free(cmd) }; + let cmd = unsafe { oakengine_keyframe_set_value_command(kf, &float_value(3.0)) }; + assert!(!cmd.is_null()); + unsafe { oakengine_undo_command_free(cmd) }; + assert!(unsafe { oakengine_keyframe_set_value_command(kf, std::ptr::null()) }.is_null()); + // keyframes_remove_many is a documented stub → Invalid. + let mut kf_ptr = kf; + assert_eq!(unsafe { oakengine_keyframes_remove_many(&mut kf_ptr, 1, c"r".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_keyframes_remove_many(std::ptr::null_mut(), 1, c"r".as_ptr()) }, E_INVALID); + // Dispose + NULL/empty dispose. + unsafe { oakengine_keyframe_dispose(kf) }; + unsafe { oakengine_keyframe_dispose(std::ptr::null_mut()) }; + let empty_kf = Box::into_raw(Box::new(OakEngineKeyframe { handle: CHandle::null() })); + unsafe { oakengine_keyframe_dispose(empty_kf) }; + + // ---- dragger ----------------------------------------------------- + let drag = unsafe { oakengine_dragger_create(value, c"value_in".as_ptr(), -1, 1) }; + assert!(!drag.is_null()); + assert_eq!(unsafe { oakengine_dragger_is_started(drag) }, 0); + assert_eq!(unsafe { oakengine_dragger_start(drag, 0, 1, 0) }, 0); + assert_eq!(unsafe { oakengine_dragger_is_started(drag) }, 1); + assert_eq!(unsafe { oakengine_dragger_drag(drag, &float_value(2.5)) }, 0); + assert_eq!(unsafe { oakengine_dragger_end(drag, c"Drag Value".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_dragger_is_started(drag) }, 0, "end resets the dragger"); + // The dragged value landed in the standard value. + let mut dv: OakNodeValue = unsafe { std::mem::zeroed() }; + assert_eq!(unsafe { oakengine_node_get_input(value, c"value_in".as_ptr(), &mut dv) }, 0); + assert!((dv.f[0] - 2.5).abs() < 1e-6); + // drag before start → module STATE; start twice → module STATE. + assert_eq!(unsafe { oakengine_dragger_drag(drag, &float_value(1.0)) }, NODE_E_STATE, "drag without start"); + assert_eq!(unsafe { oakengine_dragger_start(drag, 1, 1, 0) }, 0); + assert_eq!(unsafe { oakengine_dragger_start(drag, 2, 1, 0) }, NODE_E_STATE); + assert_eq!(unsafe { oakengine_dragger_end(drag, c"x".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_dragger_end(drag, c"x".as_ptr()) }, NODE_E_STATE, "end without start"); + unsafe { oakengine_dragger_free(drag) }; + unsafe { oakengine_dragger_free(std::ptr::null_mut()) }; + let empty_drag = Box::into_raw(Box::new(OakEngineNodeDragger { handle: CHandle::null() })); + unsafe { oakengine_dragger_free(empty_drag) }; + assert!(unsafe { oakengine_dragger_create(std::ptr::null_mut(), c"value_in".as_ptr(), -1, 1) }.is_null()); + + // ---- group passthrough ------------------------------------------ + assert_eq!(unsafe { oakengine_group_input_passthrough_count(group) }, 0); + let id_len = unsafe { oakengine_group_add_input_passthrough(group, value, c"value_in".as_ptr(), -1, c"".as_ptr(), buf.as_mut_ptr(), 512) }; + assert!(id_len > 0, "a passthrough id must be generated"); + let passthrough_id = unsafe { read_buf(&mut buf) }; + assert_eq!(unsafe { oakengine_group_input_passthrough_count(group) }, 1); + // passthrough_at returns the id, the inner node, input and element. + let mut pt_node: *mut OakEngineNode = std::ptr::null_mut(); + let mut pt_elem: c_int = 0; + let len = unsafe { oakengine_group_input_passthrough_at(group, 0, buf.as_mut_ptr(), 512, &mut pt_node, buf.as_mut_ptr(), 512, &mut pt_elem) }; + assert!(len > 0); + assert!(!pt_node.is_null()); + assert_eq!(unsafe { read_buf(&mut buf) }, "value_in"); + assert_eq!(pt_elem, -1); + unsafe { oakengine_node_free(pt_node) }; + // Out-of-range passthrough index → module NOT_FOUND. + assert_eq!(unsafe { oakengine_group_input_passthrough_at(group, 5, buf.as_mut_ptr(), 512, &mut pt_node, buf.as_mut_ptr(), 512, &mut pt_elem) }, NODE_E_NOT_FOUND); + // id_of_passthrough round trip. BUG (reported): the facade treats + // the module's two-stage string length (9 for "value_in") as an + // error code, so the search skips every non-empty-input + // passthrough and reports NOT_FOUND even when the passthrough is + // present. Asserted as actual behavior. + let len = unsafe { oakengine_group_get_id_of_passthrough(group, value, c"value_in".as_ptr(), -1, buf.as_mut_ptr(), 512) }; + assert_eq!(len, E_NOT_FOUND, "facade bug: two-stage length misread as error"); + assert_eq!(unsafe { oakengine_group_get_id_of_passthrough(group, value, c"nope_in".as_ptr(), -1, buf.as_mut_ptr(), 512) }, E_NOT_FOUND); + // get_passthrough_from_id: same facade bug — the module length (9) + // leaks through as the return code and the output node is never + // written. Asserted as actual behavior. + let passthrough_id_c = CString::new(passthrough_id.as_str()).unwrap(); + let mut back_node: *mut OakEngineNode = std::ptr::null_mut(); + let rc = unsafe { oakengine_group_get_passthrough_from_id(group, passthrough_id_c.as_ptr(), &mut back_node, buf.as_mut_ptr(), 512, &mut pt_elem) }; + assert_eq!(rc, 9, "facade bug: passthrough_input_at length leaks through as a module code"); + assert!(back_node.is_null(), "facade bug: out_node is never written"); + assert_eq!(unsafe { oakengine_group_get_passthrough_from_id(group, c"no-such-id".as_ptr(), &mut back_node, buf.as_mut_ptr(), 512, &mut pt_elem) }, E_NOT_FOUND); + // Output passthrough set/get round trip. + assert!(unsafe { oakengine_group_get_output_passthrough(group) }.is_null()); + assert_eq!(unsafe { oakengine_group_set_output_passthrough(group, value) }, 0); + let op = unsafe { oakengine_group_get_output_passthrough(group) }; + assert!(!op.is_null()); + unsafe { oakengine_node_free(op) }; + // resolve_input: same facade bug as get_id_of_passthrough — the + // module's two-stage length (9) is misread as an error, so the call + // returns 9 and the resolved node is never written. Asserted as + // actual behavior. + let mut rn: *mut OakEngineNode = std::ptr::null_mut(); + let rc = unsafe { oakengine_group_resolve_input(group, c"value_in".as_ptr(), -1, &mut rn, buf.as_mut_ptr(), 512, &mut pt_elem) }; + assert_eq!(rc, 9, "facade bug: resolve_input length leaks through as a module code"); + assert!(rn.is_null(), "facade bug: resolved node is never written"); + // After removal the input no longer resolves. + assert_eq!(unsafe { oakengine_group_remove_input_passthrough(group, value, c"value_in".as_ptr(), -1) }, 0); + assert_eq!(unsafe { oakengine_group_input_passthrough_count(group) }, 0); + assert_eq!(unsafe { oakengine_group_remove_input_passthrough(group, value, c"value_in".as_ptr(), -1) }, NODE_E_NOT_FOUND, "already removed"); + // Undoable add + set output. + assert_eq!(unsafe { oakengine_group_add_input_passthrough_undoable(group, value, c"value_in".as_ptr(), -1, c"".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_group_input_passthrough_count(group) }, 1); + assert_eq!(unsafe { oakengine_group_set_output_passthrough_undoable(group, value) }, 0); + // Opaque group command creators (free). + let cmd = unsafe { oakengine_group_add_input_passthrough_command(group, value, c"value_in".as_ptr(), -1, c"".as_ptr()) }; + assert!(!cmd.is_null()); + unsafe { oakengine_undo_command_free(cmd) }; + let cmd = unsafe { oakengine_group_set_output_passthrough_command(group, value) }; + assert!(!cmd.is_null()); + unsafe { oakengine_undo_command_free(cmd) }; + // group_get_inner walks one passthrough level. + let mut inner_node: *mut OakEngineNode = unsafe { group_inner_slot(group) }; + let mut in_buf = [0 as c_char; 256]; + let mut in_elem: c_int = -1; + let moved = unsafe { oakengine_node_group_get_inner(&mut inner_node, in_buf.as_mut_ptr(), 256, &mut in_elem) }; + assert_eq!(moved, 0, "facade bug: the resolve_input length check blocks the passthrough walk"); + unsafe { oakengine_node_free(inner_node) }; + // A bare group without passthroughs resolves to itself → 0. + let bare = unsafe { oakengine_project_add_node(project, TYPE_GROUP.as_ptr()) }; + assert!(!bare.is_null()); + let mut bare_node: *mut OakEngineNode = unsafe { group_inner_slot(bare) }; + assert_eq!(unsafe { oakengine_node_group_get_inner(&mut bare_node, in_buf.as_mut_ptr(), 256, &mut in_elem) }, 0); + unsafe { oakengine_node_free(bare_node) }; + + // ---- multicam node surfaces ------------------------------------- + assert_eq!(unsafe { oakengine_multicam_get_source_count(multicam) }, 0); + assert_eq!(unsafe { oakengine_multicam_get_current_source(multicam) }, 0); + + // ---- gizmo surfaces (stubs) ------------------------------------- + assert_eq!(unsafe { oakengine_node_has_gizmos(value) }, 0); + assert_eq!(unsafe { oakengine_node_gizmo_count(value) }, 0); + assert!(unsafe { oakengine_node_gizmo_at(value, 0) }.is_null()); + assert_eq!(unsafe { oakengine_node_update_gizmo_positions(value, std::ptr::null_mut(), 1920, 1080, 0, 1) }, 0); + assert_eq!(unsafe { oakengine_node_update_gizmo_positions(std::ptr::null_mut(), std::ptr::null_mut(), 0, 0, 0, 1) }, E_INVALID); + + // ---- node data (stub table) ------------------------------------- + let mut oty: c_int = 9; + let mut oi: i64 = 9; + for role in 0..=5 { + assert_eq!(unsafe { oakengine_node_get_data(value, role, &mut oty, &mut oi, buf.as_mut_ptr(), 512) }, 0); + assert_eq!(oty, 0, "stub reports no data"); + } + assert_eq!(unsafe { oakengine_node_get_data(value, 6, &mut oty, &mut oi, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_data(value, -1, &mut oty, &mut oi, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_data(std::ptr::null(), 0, &mut oty, &mut oi, buf.as_mut_ptr(), 512) }, E_INVALID); + + // ---- exclusive dependencies / plugin messages / caches (stubs) -- + assert_eq!(unsafe { oakengine_node_get_exclusive_dependency_count(value) }, 0); + assert!(unsafe { oakengine_node_get_exclusive_dependency_at(value, 0) }.is_null()); + assert_eq!(unsafe { oakengine_node_has_plugin(value) }, 0); + assert_eq!(unsafe { oakengine_node_plugin_message_count(value) }, 0); + assert_eq!(unsafe { oakengine_node_plugin_message_at(value, 0, &mut oty, buf.as_mut_ptr(), 512) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_node_plugin_clear_messages(value) }, E_NOT_FOUND); + assert!(unsafe { oakengine_node_get_thumbnail_cache(value) }.is_null()); + assert!(unsafe { oakengine_node_get_waveform_cache(value) }.is_null()); + assert!(unsafe { oakengine_node_get_video_frame_cache(value) }.is_null(), "no cache object on a plain node"); + + // ---- viewer / block / clip / track surfaces --------------------- + assert!(unsafe { oakengine_viewer_output_get_connected_texture(value) }.is_null()); + assert!(unsafe { oakengine_clip_get_track(value) }.is_null(), "not a clip"); + assert_eq!(unsafe { oakengine_track_get_type(value) }, -1, "not a track"); + assert_eq!(unsafe { oakengine_track_get_index(value) }, -1); + assert!(unsafe { oakengine_track_get_sequence(value) }.is_null()); + assert_eq!(unsafe { oakengine_block_get_length_rational(value, &mut tb_num, &mut tb_den) }, E_INVALID, "not a block"); + assert_eq!(unsafe { oakengine_block_get_in_rational(value, &mut tb_num, &mut tb_den) }, E_INVALID); + assert_eq!(unsafe { oakengine_block_get_out_rational(value, &mut tb_num, &mut tb_den) }, E_INVALID); + + // ---- subtitle / shape (stubs) ----------------------------------- + assert_eq!(unsafe { oakengine_subtitle_get_text(value, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_subtitle_set_text(value, c"sub".as_ptr()) }, E_INVALID); + let params = unsafe { std::mem::zeroed::() }; + assert_eq!(unsafe { oakengine_shape_set_rect_undoable(value, 0.0, 0.0, 10.0, 10.0, ¶ms, std::ptr::null_mut()) }, E_INVALID); + assert_eq!(unsafe { oakengine_shape_set_rect_undoable(std::ptr::null_mut(), 0.0, 0.0, 10.0, 10.0, ¶ms, std::ptr::null_mut()) }, E_INVALID); + assert_eq!(unsafe { oakengine_shape_set_rect_undoable(value, 0.0, 0.0, 10.0, 10.0, std::ptr::null(), std::ptr::null_mut()) }, E_INVALID); + // effect-input surface is a documented stub. + assert_eq!(unsafe { oakengine_node_get_effect_input(value, buf.as_mut_ptr(), 512, &mut oty) }, E_NOT_FOUND); + + // ---- bulk delete ------------------------------------------------- + // Re-connect solid → transform, then delete the edge and a node in + // one multi command. + assert_eq!(unsafe { oakengine_node_connect(solid, transform, c"tex_in".as_ptr()) }, 0); + let del_nodes = [transform]; + let edge_outputs = [solid]; + let edge_inputs = [transform]; + let edge_ids = [c"tex_in".as_ptr() as *const c_char]; + let edge_elems: [c_int; 1] = [-1]; + let count_before = unsafe { oakengine_project_node_count(project) }; + assert_eq!(unsafe { oakengine_nodes_delete_many( + del_nodes.as_ptr() as *mut *mut OakEngineNode, + std::ptr::null_mut(), + 1, + edge_outputs.as_ptr() as *mut *mut OakEngineNode, + edge_inputs.as_ptr() as *mut *mut OakEngineNode, + edge_ids.as_ptr() as *mut *const c_char, + edge_elems.as_ptr(), + 1, + ) }, 0); + assert_eq!(unsafe { oakengine_project_node_count(project) }, count_before - 1, "delete must remove the node"); + // Empty delete → Invalid ("nothing to delete"). + assert_eq!(unsafe { oakengine_nodes_delete_many(std::ptr::null_mut(), std::ptr::null_mut(), 0, std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null(), 0) }, E_INVALID); + // _ex with a null node entry → Invalid. + let mut null_nodes = [std::ptr::null_mut()]; + assert_eq!(unsafe { oakengine_nodes_delete_many_ex( + null_nodes.as_mut_ptr(), + std::ptr::null_mut(), + 1, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null(), + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null(), + 0, + ) }, E_INVALID); + + // ---- folders ----------------------------------------------------- + let root = unsafe { oakengine_project_root(project) }; + let folder = unsafe { oakengine_folder_create(project, root, c"Bin".as_ptr()) }; + assert!(!folder.is_null()); + let len = unsafe { oakengine_node_get_label(folder, buf.as_mut_ptr(), 512) }; + assert_eq!(len, "Bin".len() as c_int); + assert_eq!(unsafe { oakengine_node_is_folder(folder) }, 1); + // Root now has the folder as a child. + let root2 = unsafe { oakengine_project_root(project) }; + assert_eq!(unsafe { oakengine_folder_item_child_count(root2) }, 1); + assert_eq!(unsafe { oakengine_folder_has_child_recursive(root2, folder) }, 1); + assert_eq!(unsafe { oakengine_folder_index_of_child(root2, folder) }, 0); + let child = unsafe { oakengine_folder_item_child(root2, 0) }; + assert!(!child.is_null()); + let len = unsafe { oakengine_node_get_type_id(child, buf.as_mut_ptr(), 512) }; + assert_eq!(len, "org.olivevideoeditor.Olive.folder".len() as c_int); + unsafe { oakengine_node_free(child) }; + assert!(unsafe { oakengine_folder_item_child(root2, 5) }.is_null()); + assert_eq!(unsafe { oakengine_folder_item_child_count(std::ptr::null()) }, 0); + assert_eq!(unsafe { oakengine_folder_has_child_recursive(std::ptr::null(), folder) }, 0); + assert_eq!(unsafe { oakengine_folder_has_child_recursive(root2, std::ptr::null()) }, 0); + assert_eq!(unsafe { oakengine_folder_index_of_child(std::ptr::null(), folder) }, E_INVALID); + assert_eq!(unsafe { oakengine_folder_index_of_child(root2, std::ptr::null()) }, E_INVALID); + assert_eq!(unsafe { oakengine_folder_index_of_child(root2, value) }, E_NOT_FOUND, "value is not in the root"); + // folder_add_child is undoable; add the value node under the folder. + assert_eq!(unsafe { oakengine_folder_add_child(folder, value) }, 0); + assert_eq!(unsafe { oakengine_folder_item_child_count(folder) }, 1); + assert_eq!(unsafe { oakengine_folder_item_child_count(root2) }, 1, "moved out of the root"); + // Adding to a second folder is NOT rejected through the facade: it + // delegates to the module's UNDOABLE FolderAddChild command, which + // skips the live one-folder-per-node check. Returns 0 and the node + // ends up in both folders - documented divergence. + assert_eq!(unsafe { oakengine_folder_add_child(root2, value) }, 0); + // remove_element_command is a documented stub → NULL. + assert!(unsafe { oakengine_folder_remove_element_command(root2, value) }.is_null()); + // move_children: move the value node back into the root. + let mut mv = [value]; + assert_eq!(unsafe { oakengine_folder_move_children(mv.as_mut_ptr(), 1, root2, c"move".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_folder_item_child_count(root2) }, 2); + assert_eq!(unsafe { oakengine_folder_move_child(value, root2) }, 0); + assert_eq!(unsafe { oakengine_folder_move_children(std::ptr::null_mut(), 1, root2, c"x".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_folder_move_children(mv.as_mut_ptr(), 0, root2, c"x".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_folder_move_children(mv.as_mut_ptr(), 1, std::ptr::null_mut(), c"x".as_ptr()) }, E_INVALID); + // folder_create requires a folder parent. + assert!(unsafe { oakengine_folder_create(project, value, c"x".as_ptr()) }.is_null()); + unsafe { oakengine_node_free(folder) }; + unsafe { oakengine_node_free(root2) }; + + // ---- undo/redo over the global stack ---------------------------- + assert_eq!(unsafe { oakengine_project_can_undo(project) }, 1, "commands were pushed above"); + assert_eq!(unsafe { oakengine_project_undo(project) }, 0); + assert_eq!(unsafe { oakengine_project_can_redo(project) }, 1); + assert_eq!(unsafe { oakengine_project_redo(project) }, 0); + assert_eq!(unsafe { oakengine_project_can_undo(std::ptr::null_mut()) }, 0); + assert_eq!(unsafe { oakengine_project_can_redo(std::ptr::null_mut()) }, 0); + assert_eq!(unsafe { oakengine_project_undo(std::ptr::null_mut()) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_redo(std::ptr::null_mut()) }, E_INVALID); + + // ---- save → fresh load round trip -------------------------------- + let save_path = std::env::temp_dir().join(format!("oak_it_node_save-{}.ovexml", std::process::id())); + let _ = std::fs::remove_file(&save_path); + let sp = CString::new(save_path.to_string_lossy().into_owned()).unwrap(); + assert_eq!(unsafe { oakengine_project_save(project, sp.as_ptr()) }, 0); + assert!(save_path.exists()); + // Save with NULL path uses the project filename. + assert_eq!(unsafe { oakengine_project_save(project, std::ptr::null()) }, 0); + assert_eq!(unsafe { oakengine_project_save(std::ptr::null_mut(), sp.as_ptr()) }, E_INVALID); + + let project2 = oakengine_project_create(); + let mut err = [0 as c_char; 512]; + let rc = unsafe { oakengine_project_load(project2, sp.as_ptr(), err.as_mut_ptr(), 512) }; + if rc == 0 { + assert!(unsafe { oakengine_project_node_count(project2) } >= 1); + } else { + let err_len = unsafe { CStr::from_ptr(err.as_ptr()) }.to_bytes().len(); + assert!(err_len > 0, "load failure must fill the error buffer"); + } + unsafe { oakengine_project_free(project2) }; + // Load on an already-initialized project → E_STATE. + assert_eq!(unsafe { oakengine_project_load(project, sp.as_ptr(), err.as_mut_ptr(), 512) }, E_STATE); + // Load with a NULL path → Invalid. + let project3 = oakengine_project_create(); + assert_eq!(unsafe { oakengine_project_load(project3, std::ptr::null(), err.as_mut_ptr(), 512) }, E_INVALID); + // Load of a nonexistent file → failure + non-empty err buffer. + let rc = unsafe { oakengine_project_load(project3, c"/no/such/project-file.ove".as_ptr(), err.as_mut_ptr(), 512) }; + assert!(rc < 0); + assert!(unsafe { CStr::from_ptr(err.as_ptr()) }.to_bytes().len() > 0); + unsafe { oakengine_project_free(project3) }; + let _ = std::fs::remove_file(&save_path); + + // ---- footage: probe / import / proxy / relink ------------------- + let media_a = fresh_temp_file("media-a.mp4", b"not-real-media"); + let media_a_c = CString::new(media_a.to_string_lossy().into_owned()).unwrap(); + + // Probe a real file (module records the filename only; no decoder). + let footage = unsafe { oakengine_footage_probe(media_a_c.as_ptr()) }; + assert!(!footage.is_null()); + let len = unsafe { oakengine_footage_get_filename(footage, buf.as_mut_ptr(), 512) }; + assert!(len > 0); + assert_eq!(unsafe { read_buf(&mut buf) }, media_a.to_string_lossy().into_owned()); + assert_eq!(unsafe { oakengine_footage_get_decoder_name(footage, buf.as_mut_ptr(), 512) }, 0); + assert_eq!(unsafe { oakengine_footage_get_video_stream_count(footage) }, 0); + assert_eq!(unsafe { oakengine_footage_get_audio_stream_count(footage) }, 0); + assert_eq!(unsafe { oakengine_footage_get_subtitle_stream_count(footage) }, 0); + assert_eq!(unsafe { oakengine_footage_is_online(footage) }, 1); + let mut secs: f64 = -1.0; + assert_eq!(unsafe { oakengine_footage_get_duration(footage, &mut secs) }, 0); + assert_eq!(secs, 0.0, "module footage has no media duration"); + // No streams → stream accessors report NOT_FOUND. + let mut vinfo: OakFootageVideoInfo = unsafe { std::mem::zeroed() }; + let mut ainfo: OakFootageAudioInfo = unsafe { std::mem::zeroed() }; + assert_eq!(unsafe { oakengine_footage_get_video_stream_info(footage, 0, &mut vinfo) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_footage_get_audio_stream_info(footage, 0, &mut ainfo) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_footage_get_video_stream_info(footage, -1, &mut vinfo) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_footage_get_video_stream_info(std::ptr::null_mut(), 0, &mut vinfo) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_get_video_stream_info(footage, 0, std::ptr::null_mut()) }, E_INVALID); + let mut cr: c_int = 0; + let mut il: c_int = 0; + let mut pm: c_int = 0; + assert_eq!(unsafe { oakengine_footage_get_video_stream_overrides(footage, 0, buf.as_mut_ptr(), 512, &mut cr, &mut il, &mut pm) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_footage_set_video_stream_overrides(footage, 0, c"".as_ptr(), 0, 0, 0) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_footage_get_pixel_aspect(footage, 0, &mut tb_num, &mut tb_den) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_footage_set_pixel_aspect(footage, 0, 1, 1) }, E_NOT_FOUND); + // Bad pixel-aspect ratio is rejected before the stream lookup. + assert_eq!(unsafe { oakengine_footage_set_pixel_aspect(footage, 0, 0, 1) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_set_pixel_aspect(footage, 0, 1, 0) }, E_INVALID); + let mut si: i64 = 0; + let mut dur: i64 = 0; + assert_eq!(unsafe { oakengine_footage_get_image_sequence_params(footage, 0, &mut si, &mut dur, &mut tb_num, &mut tb_den) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_footage_set_image_sequence_params(footage, 0, 0, 1, 25, 1) }, E_NOT_FOUND); + // Bad image-sequence params are rejected first. + assert_eq!(unsafe { oakengine_footage_set_image_sequence_params(footage, 0, -1, 1, 25, 1) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_set_image_sequence_params(footage, 0, 0, 0, 25, 1) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_set_image_sequence_params(footage, 0, 0, 1, 0, 1) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_set_image_sequence_params(footage, 0, 0, 1, 25, 0) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_get_stream_enabled(footage, 0, 0) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_footage_get_stream_enabled(footage, 3, 0) }, E_NOT_FOUND, "garbage track type"); + assert_eq!(unsafe { oakengine_footage_set_stream_enabled(footage, 0, 0, 1) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_footage_get_stream_reference(footage, 0, &mut oty, &mut tb_num) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_footage_get_stream_reference(footage, -1, &mut oty, &mut tb_num) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_footage_describe_video_stream(footage, 0, buf.as_mut_ptr(), 512) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_footage_describe_audio_stream(footage, 0, buf.as_mut_ptr(), 512) }, E_NOT_FOUND); + // Source start time is a documented stub (0/1). + assert_eq!(unsafe { oakengine_footage_get_source_start_time(footage, &mut tb_num, &mut tb_den) }, 0); + assert_eq!((tb_num, tb_den), (0, 1)); + assert_eq!(unsafe { oakengine_footage_set_source_start_time(footage, 1, 0, 1) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_get_source_start_time_source(footage, buf.as_mut_ptr(), 512) }, 0); + // Colorspace candidates are stubs. + assert_eq!(unsafe { oakengine_footage_colorspace_count(footage) }, 0); + assert_eq!(unsafe { oakengine_footage_colorspace_at(footage, 0, buf.as_mut_ptr(), 512) }, E_NOT_FOUND); + // Proxy state: defaults, set round trip, delete/clear. + assert_eq!(unsafe { oakengine_footage_proxy_get_state(footage) }, 0); + assert_eq!(unsafe { oakengine_footage_proxy_is_enabled(footage) }, 0); + assert_eq!(unsafe { oakengine_footage_proxy_get_path(footage, buf.as_mut_ptr(), 512) }, 0); + assert_eq!(unsafe { oakengine_footage_proxy_generate(footage) }, E_STATE, "documented stub"); + assert_eq!(unsafe { oakengine_footage_set_proxy(footage, c"/tmp/proxy.mp4".as_ptr(), 1, 0, 1, 1) }, 0); + assert_eq!(unsafe { oakengine_footage_proxy_get_state(footage) }, 1); + assert_eq!(unsafe { oakengine_footage_proxy_is_enabled(footage) }, 1); + let len = unsafe { oakengine_footage_proxy_get_path(footage, buf.as_mut_ptr(), 512) }; + assert_eq!(len, "/tmp/proxy.mp4".len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, "/tmp/proxy.mp4"); + assert_eq!(unsafe { oakengine_footage_proxy_delete(footage) }, 0); + assert_eq!(unsafe { oakengine_footage_proxy_get_state(footage) }, 0); + assert_eq!(unsafe { oakengine_footage_proxy_set_enabled(footage, 1) }, 0); + assert_eq!(unsafe { oakengine_footage_proxy_is_enabled(footage) }, 1); + assert_eq!(unsafe { oakengine_footage_clear_proxy(footage) }, 0); + assert_eq!(unsafe { oakengine_footage_proxy_is_enabled(footage) }, 0); + // Custom proxy params stubs. + assert_eq!(unsafe { oakengine_footage_has_custom_proxy_params(footage) }, 0); + let mut pp: OakProxyParams = unsafe { std::mem::zeroed() }; + assert_eq!(unsafe { oakengine_footage_get_effective_proxy_params(footage, &mut pp) }, 0); + assert_eq!(pp.width, 0); + assert_eq!(unsafe { oakengine_footage_set_custom_proxy_params(footage, &pp) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_clear_custom_proxy_params(footage) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_invalidate(footage) }, 0, "documented no-op"); + // Relink requires an existing file. + let media_b = fresh_temp_file("media-b.mp4", b"other"); + let media_b_c = CString::new(media_b.to_string_lossy().into_owned()).unwrap(); + assert_eq!(unsafe { oakengine_footage_relink(footage, media_b_c.as_ptr()) }, 0); + let len = unsafe { oakengine_footage_get_filename(footage, buf.as_mut_ptr(), 512) }; + assert_eq!(unsafe { read_buf(&mut buf) }, media_b.to_string_lossy().into_owned()); + let _ = len; + // Relink to a nonexistent file → NOT_FOUND + footage error text. + assert_eq!(unsafe { oakengine_footage_relink(footage, c"/no/such/file.mp4".as_ptr()) }, E_NOT_FOUND); + let err_len = unsafe { oakengine_footage_last_error(buf.as_mut_ptr(), 512) }; + assert!(err_len > 0, "footage_last_error must be non-empty after a failed relink"); + // NULL path → Invalid. + assert_eq!(unsafe { oakengine_footage_relink(footage, std::ptr::null()) }, E_INVALID); + unsafe { oakengine_footage_free(footage) }; + unsafe { oakengine_footage_free(std::ptr::null_mut()) }; + let empty_ftg = Box::into_raw(Box::new(OakEngineFootage { handle: CHandle::null() })); + unsafe { oakengine_footage_free(empty_ftg) }; + + // Probe failure paths. + let probe_null = unsafe { oakengine_footage_probe(std::ptr::null()) }; + assert!(probe_null.is_null()); + let probe_missing = unsafe { oakengine_footage_probe(c"/no/such/media.mp4".as_ptr()) }; + assert!(probe_missing.is_null()); + let err_len = unsafe { oakengine_footage_last_error(buf.as_mut_ptr(), 512) }; + assert!(err_len > 0); + + // Import into the project (real file). + let imported = unsafe { oakengine_project_import_footage(project, media_b_c.as_ptr()) }; + assert!(!imported.is_null()); + assert_eq!(unsafe { oakengine_project_footage_count(project) }, 1); + let len = unsafe { oakengine_project_footage_filename(project, 0, buf.as_mut_ptr(), 512) }; + assert!(len > 0); + assert_eq!(unsafe { oakengine_project_footage_is_online(project, 0) }, 1); + assert_eq!(unsafe { oakengine_project_footage_filename(project, 5, buf.as_mut_ptr(), 512) }, E_NOT_FOUND); + assert_eq!(unsafe { oakengine_project_footage_is_online(project, 5) }, E_NOT_FOUND); + // The footage node is in the graph: borrow + validity. + let footage_idx = unsafe { find_node(project, TYPE_FOOTAGE.to_str().unwrap()) }; + assert!(footage_idx >= 0); + let footage_node = unsafe { oakengine_project_node_at(project, footage_idx) }; + assert_eq!(unsafe { oakengine_node_is_footage(footage_node) }, 1); + assert_eq!(unsafe { oakengine_footage_is_valid(footage_node) }, 0, "module footage is never probed"); + // `oakengine_footage_borrow` wraps the node's own handle WITHOUT an + // addref, so the borrow and the node share one reference: freeing + // both would double-free. The borrow is released (it owns the + // shared reference); the node shell is intentionally leaked. + let borrowed = unsafe { oakengine_footage_borrow(footage_node) }; + assert!(!borrowed.is_null()); + let len = unsafe { oakengine_footage_get_filename(borrowed, buf.as_mut_ptr(), 512) }; + assert!(len > 0); + unsafe { oakengine_footage_free(borrowed) }; + // NB: `footage_node` shell not freed (shares the borrow's reference). + // Borrow of a non-footage node → NULL. + assert!(unsafe { oakengine_footage_borrow(value) }.is_null()); + assert_eq!(unsafe { oakengine_footage_is_valid(value) }, 0); + // Import failure paths. + assert!(unsafe { oakengine_project_import_footage(project, c"/no/such/media.mp4".as_ptr()) }.is_null()); + assert!(unsafe { oakengine_project_import_footage(std::ptr::null_mut(), media_b_c.as_ptr()) }.is_null()); + assert!(unsafe { oakengine_project_import_footage(project, std::ptr::null()) }.is_null()); + + // find_offline_footage: make the imported footage offline and + // relink it from a search directory. + let media_b_basename = media_b.file_name().unwrap().to_string_lossy().into_owned(); + let _ = std::fs::remove_file(&media_b); // now offline + assert_eq!(unsafe { oakengine_project_footage_is_online(project, 0) }, 0); + let search_dir = std::env::temp_dir().join(format!("oak-it-node-search-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&search_dir); + std::fs::create_dir_all(&search_dir).unwrap(); + std::fs::write(search_dir.join(&media_b_basename), b"found").unwrap(); + let search_c = CString::new(search_dir.to_string_lossy().into_owned()).unwrap(); + assert_eq!(unsafe { oakengine_project_find_offline_footage(project, search_c.as_ptr()) }, 1); + assert_eq!(unsafe { oakengine_project_footage_is_online(project, 0) }, 1); + assert_eq!(unsafe { oakengine_project_find_offline_footage(project, std::ptr::null()) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_find_offline_footage(std::ptr::null_mut(), search_c.as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_find_offline_footage(project, c"/no/such/dir".as_ptr()) }, E_INVALID); + + // ---- remove a node + free the project --------------------------- + assert_eq!(unsafe { oakengine_project_remove_node(project, value2) }, 0); + assert_eq!(unsafe { oakengine_project_remove_node(project, value2) }, E_INVALID, "already removed"); + assert_eq!(unsafe { oakengine_project_remove_node(std::ptr::null_mut(), value) }, E_INVALID); + + // Free borrowed shells captured above. + unsafe { oakengine_node_free(value) }; + unsafe { oakengine_node_free(value2) }; + unsafe { oakengine_node_free(solid) }; + unsafe { oakengine_node_free(group) }; + unsafe { oakengine_node_free(bare) }; + unsafe { oakengine_node_free(multicam) }; + unsafe { oakengine_node_free(orphan) }; + unsafe { oakengine_footage_free(imported) }; + unsafe { oakengine_project_free(project) }; + // Two intentional process-lifetime leaks remain: the 7 + // project_add_node owned handles (see + // `project_add_node_owned_handle_leak`) and the hidden probe + // project created by the first `oakengine_footage_probe` (leaked + // like the C++ EngineCore shell). + assert_eq!(alive(), base + 8, "7 add_node handles + the probe project (both reported leaks)"); + }); +} + +// --------------------------------------------------------------------------- +// Illegal inputs: NULL / empty handles / bad sizes / garbage (parallel-safe) +// --------------------------------------------------------------------------- + +/// NULL and empty-`CHandle` boxes for the handle-taking exports: every +/// call must return a clean negative code or the documented no-op, never a +/// crash. Runs in parallel (no undo-stack or alive-counter access). +#[test] +fn null_and_empty_handle_failure_paths() { + common::force_link(); + let _ = force_oakundo_command_link(); + let mut buf = [0 as c_char; 512]; + let mut out: OakNodeValue = unsafe { std::mem::zeroed() }; + let mut num: i64 = 0; + let mut den: i64 = 0; + // Scratch out-params reused across the failure calls below. + let mut f64a: f64 = 0.0; + let mut f64b: f64 = 0.0; + let mut f32a: f32 = 0.0; + let mut f32b: f32 = 0.0; + let mut f32c: f32 = 0.0; + let mut f32d: f32 = 0.0; + let mut i32a: c_int = 0; + let mut i32b: c_int = 0; + let mut i32c: c_int = 0; + let mut i64a: i64 = 0; + let mut i64b: i64 = 0; + + // Empty-handle boxes (non-NULL pointers wrapping CHandle::null()). + let node = empty_node_box(); + let project = Box::into_raw(Box::new(OakEngineProject { handle: CHandle::null() })); + let keyframe = Box::into_raw(Box::new(OakEngineKeyframe { handle: CHandle::null() })); + let dragger = Box::into_raw(Box::new(OakEngineNodeDragger { handle: CHandle::null() })); + let footage = Box::into_raw(Box::new(OakEngineFootage { handle: CHandle::null() })); + + // project family + assert_eq!(unsafe { oakengine_project_new(project) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_is_modified(project) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_project_set_modified(project, 1) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_name(project, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_filename(project, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_pretty_filename(project, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_set_filename(project, c"/x.ove".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_cache_path(project, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_cache_alongside_path(project, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_set_custom_cache_path(project, c"/x".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_get_custom_cache_path(project, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_get_cache_location_setting(project) }, -1, "NULL → -1 documented"); + assert_eq!(unsafe { oakengine_project_get_color_reference_space(project, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_set_color_reference_space(project, c"x".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_footage_count(project) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_project_footage_filename(project, 0, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_footage_is_online(project, 0) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_sequence_count(project) }, E_INVALID, "empty handle, not a NULL pointer"); + assert!(unsafe { oakengine_project_sequence_at(project, 0) }.is_null()); + assert_eq!(unsafe { oakengine_project_node_count(project) }, E_INVALID, "empty handle, not a NULL pointer"); + assert!(unsafe { oakengine_project_node_at(project, 0) }.is_null()); + assert!(unsafe { oakengine_project_root(project) }.is_null()); + assert!(unsafe { oakengine_project_from_object(node) }.is_null()); + assert_eq!(unsafe { oakengine_project_save(project, c"/x.ove".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_project_load(project, c"/x.ove".as_ptr(), buf.as_mut_ptr(), 512) }, E_INVALID); + + // folder family + assert!(unsafe { oakengine_folder_create(project, node, c"x".as_ptr()) }.is_null()); + assert_eq!(unsafe { oakengine_folder_has_child_recursive(node, node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_folder_index_of_child(node, node) }, E_INVALID); + assert_eq!(unsafe { oakengine_folder_item_child_count(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert!(unsafe { oakengine_folder_item_child(node, 0) }.is_null()); + assert_eq!(unsafe { oakengine_folder_add_child(node, node) }, E_INVALID); + assert!(unsafe { oakengine_folder_remove_element_command(node, node) }.is_null()); + assert_eq!(unsafe { oakengine_folder_move_child(node, node) }, E_INVALID); + assert_eq!(unsafe { oakengine_folder_move_children(std::ptr::null_mut(), 1, node, c"x".as_ptr()) }, E_INVALID); + + // node factory / metadata / params + assert!(unsafe { oakengine_node_factory_create_from_id(c"x".as_ptr()) }.is_null()); + assert_eq!(unsafe { oakengine_node_category_count(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_category_at(node, 0) }, -1); + // Empty handle → the guard_i64 sentinel (-1 as u64); a NULL + // pointer would return 0. + assert_eq!(unsafe { oakengine_node_get_flags(node) }, (-1i64) as u64); + assert_eq!(unsafe { oakengine_node_get_sub_category(node, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_description(node, buf.as_mut_ptr(), 512) }, E_INVALID); + assert!(unsafe { oakengine_node_create_copy(node) }.is_null()); + assert_eq!(unsafe { oakengine_node_get_type_id(node, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_name(node, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_short_name(node, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_label(node, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_set_label(node, c"x".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_set_label_ex(node, c"x".as_ptr(), 0) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_label_and_name(node, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_color_label(node) }, -1, "NULL → -1 documented"); + assert_eq!(unsafe { oakengine_node_get_effective_color_label(node) }, -1); + assert!(unsafe { oakengine_node_set_color_label_command(node, 0) }.is_null()); + assert_eq!(unsafe { oakengine_node_input_count(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_input_id(node, 0, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_input_get_type(node, c"x".as_ptr()) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_input_is_connected(node, c"x".as_ptr()) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_input_is_connectable(node, c"x".as_ptr()) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_input_is_keyframable(node, c"x".as_ptr()) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_input_is_keyframed(node, c"x".as_ptr()) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_input_is_keyframed_ex(node, c"x".as_ptr(), 0) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_input_is_array(node, c"x".as_ptr()) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_input_array_size(node, c"x".as_ptr()) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_input_get_flags(node, c"x".as_ptr()) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_input_get_data_type(node, c"x".as_ptr()) }, -1); + assert_eq!(unsafe { oakengine_node_input_is_hidden(node, c"x".as_ptr()) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_get_input(node, c"x".as_ptr(), &mut out) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_set_input(node, c"x".as_ptr(), &float_value(1.0)) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_input_string(node, c"x".as_ptr(), buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_set_input_string(node, c"x".as_ptr(), c"s".as_ptr()) }, E_INVALID); + assert!(unsafe { oakengine_node_set_standard_value_command(node, c"x".as_ptr(), -1, -1, &float_value(1.0)) }.is_null(), "stub-null"); + assert!(unsafe { oakengine_node_set_input_video_params_command(node, c"x".as_ptr(), &unsafe { std::mem::zeroed::() }) }.is_null(), "stub-null"); + assert!(unsafe { oakengine_node_set_value_at_time_command(node as *mut c_void, c"x".as_ptr(), -1, 0, 1, &float_value(1.0), -1, 0) }.is_null(), "empty handle"); + assert_eq!(unsafe { oakengine_node_frame_time_base(node, &mut i32a, &mut i32b) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_set_input_at_time(node, c"x".as_ptr(), -1, 0, -1, &float_value(1.0), 0) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_input_at_time(node, c"x".as_ptr(), -1, -1, 0, 0, &mut out) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_set_input_string_at_time(node, c"x".as_ptr(), -1, 0, c"s".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_input_string_at_time(node, c"x".as_ptr(), -1, 0, -1, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_input_bezier_at_time(node, c"x".as_ptr(), -1, 0, -1, std::ptr::null_mut()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_input_binary_at_time(node, c"x".as_ptr(), -1, 0, -1, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_array_insert_at(node, c"x".as_ptr(), 0) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_array_remove_at(node, c"x".as_ptr(), 0) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_input_has_property(node, c"x".as_ptr(), c"k".as_ptr()) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_set_input_property_string(node, c"x".as_ptr(), c"k".as_ptr(), c"v".as_ptr(), 0) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_input_get_property_string(node, c"x".as_ptr(), c"k".as_ptr(), buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_input_get_property_number(node, c"x".as_ptr(), c"k".as_ptr(), 0, &mut f64a) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_input_get_property_int(node, c"x".as_ptr(), c"k".as_ptr(), &mut num) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_input_get_property_rational(node, c"x".as_ptr(), c"k".as_ptr(), std::ptr::null_mut(), std::ptr::null_mut()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_input_get_property_track_number(node, c"x".as_ptr(), c"k".as_ptr(), 0, &mut f64a) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_input_get_property_count(node, c"x".as_ptr()) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_input_get_property_key(node, c"x".as_ptr(), 0, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_input_get_property_string_list_count(node, c"x".as_ptr(), c"k".as_ptr()) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_input_get_property_string_list(node, c"x".as_ptr(), c"k".as_ptr(), 0, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_input_get_default_value(node, c"x".as_ptr(), 0, &mut out) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_input_name(node, c"x".as_ptr(), buf.as_mut_ptr(), 512) }, E_INVALID); + + // graph editing + assert_eq!(unsafe { oakengine_node_connect(node, node, c"x".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_disconnect(node, c"x".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_disconnect_ex(node, c"x".as_ptr(), -1) }, E_INVALID); + assert!(unsafe { oakengine_node_connect_command(node, node, c"x".as_ptr(), -1) }.is_null()); + assert!(unsafe { oakengine_node_disconnect_command(node, c"x".as_ptr(), -1) }.is_null()); + assert_eq!(unsafe { oakengine_node_copy_inputs(node, node) }, E_INVALID); + assert!(unsafe { oakengine_node_copy_in_graph(node, std::ptr::null_mut()) }.is_null()); + assert!(unsafe { oakengine_node_input_get_connected_node(node, c"x".as_ptr(), -1) }.is_null()); + assert_eq!(unsafe { oakengine_node_output_connection_count(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_output_connection_at(node, 0, std::ptr::null_mut(), buf.as_mut_ptr(), 512, std::ptr::null_mut()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_output_connection_at_ex(node, 0, std::ptr::null_mut(), buf.as_mut_ptr(), 512, std::ptr::null_mut(), std::ptr::null_mut()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_input_connection_count_all(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_input_connection_at_all(node, 0, std::ptr::null_mut(), buf.as_mut_ptr(), 512, std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_input_connection_count(node, c"x".as_ptr(), -1) }, E_INVALID, "empty handle, not a NULL pointer"); + assert!(unsafe { oakengine_node_input_connection_at(node, c"x".as_ptr(), -1, 0) }.is_null()); + assert_eq!(unsafe { oakengine_node_inputs_from(node, node, 1) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_set_value_hint(node, c"x".as_ptr(), 0, 0, 0, c"".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_set_context_position(node, node, 0.0, 0.0) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_context_position(node, node, &mut f64a, &mut f64b, &mut i32a) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_set_context_expanded(node, node, 1) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_context_node_count(node) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_context_contains_node(node, node) }, E_INVALID); + assert!(unsafe { oakengine_node_context_node_at(node, 0, &mut f64a, &mut f64b, &mut i32a) }.is_null()); + assert_eq!(unsafe { oakengine_node_get_effect_input(node, buf.as_mut_ptr(), 512, &mut i32a) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_keyframe_count(node, c"x".as_ptr()) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_keyframe_count_on_track(node, c"x".as_ptr(), 0, 0) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_keyframe_at(node, c"x".as_ptr(), 0, &mut num, &mut out) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_keyframe_get_easing(node, c"x".as_ptr(), 0, &mut f32a, &mut f32b, &mut f32c, &mut f32d, &mut i32a) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_keyframe_add(node, c"x".as_ptr(), 0, &float_value(1.0), 0, 0.0, 0.0, 0.0, 0.0) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_keyframe_remove(node, c"x".as_ptr(), 0) }, E_INVALID); + assert!(unsafe { oakengine_node_insert_keyframe_command(node, c"x".as_ptr(), -1, 0, 0, &float_value(1.0), 0, 0.0, 0.0, 0.0, 0.0) }.is_null()); + assert_eq!(unsafe { oakengine_node_keyframe_set_easing(node, c"x".as_ptr(), 0, 0, 0.0, 0.0, 0.0, 0.0) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_keyframes_set_type_many(node, c"x".as_ptr(), -1, std::ptr::null(), std::ptr::null(), 0, 0) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_keyframes_set_time_many(node, c"x".as_ptr(), -1, std::ptr::null(), std::ptr::null(), 0, 0) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_keyframes_set_value_many(node, c"x".as_ptr(), -1, std::ptr::null(), std::ptr::null(), 0, std::ptr::null(), std::ptr::null()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_keyframes_set_bezier_many(node, c"x".as_ptr(), -1, std::ptr::null(), std::ptr::null(), 0, 0.0, 0.0, 0.0, 0.0) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_keyframe_set_bezier_point(node, c"x".as_ptr(), -1, 0, 0, 0, 0.0, 0.0, 0.0, 0.0) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_keyframes_clear(node, c"x".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_keyframe_best_type_at_time(node, c"x".as_ptr(), -1, 0, 0, 5) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_keyframe_track_count(node, c"x".as_ptr(), -1) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_keyframes_toggle_at_time(node, c"x".as_ptr(), -1, 0, 0, 1, c"t".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_has_keyframe_at_time(node, c"x".as_ptr(), -1, 0, 0) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_keyframe_earliest_time(node, c"x".as_ptr(), -1, &mut num, &mut den) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_keyframe_latest_time(node, c"x".as_ptr(), -1, &mut num, &mut den) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_keyframe_closest_time_before(node, c"x".as_ptr(), -1, 0, 0, &mut num, &mut den) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_keyframe_closest_time_after(node, c"x".as_ptr(), -1, 0, 0, &mut num, &mut den) }, E_INVALID, "empty handle, not a NULL pointer"); + assert!(unsafe { oakengine_node_keyframe_handle_on_track(node, c"x".as_ptr(), -1, 0, 0) }.is_null()); + assert!(unsafe { oakengine_node_keyframe_handle_at_time(node, c"x".as_ptr(), -1, 0, 0, 1) }.is_null()); + assert_eq!(unsafe { oakengine_node_keyframes_at_time(node, c"x".as_ptr(), -1, 0, 1, std::ptr::null_mut(), 0) }, 0, "stub ignores its handle"); + assert_eq!(unsafe { oakengine_node_set_input_keyframing(node, c"x".as_ptr(), -1, 1, 0, 0, c"k".as_ptr()) }, E_INVALID); + assert!(unsafe { oakengine_node_set_input_keyframing_command(node, c"x".as_ptr(), -1, 1) }.is_null()); + assert_eq!(unsafe { oakengine_node_keyframes_paste(node, std::ptr::null_mut(), 1, c"p".as_ptr()) }, E_INVALID); + + // keyframe handle family + assert_eq!(unsafe { oakengine_keyframe_get_time(keyframe, &mut num, &mut den) }, E_INVALID); + assert_eq!(unsafe { oakengine_keyframe_get_input_id(keyframe, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_keyframe_get_track(keyframe) }, -1); + assert_eq!(unsafe { oakengine_keyframe_get_element(keyframe) }, -1); + assert!(unsafe { oakengine_keyframe_get_node(keyframe) }.is_null()); + assert_eq!(unsafe { oakengine_keyframe_get_type(keyframe) }, -1); + assert_eq!(unsafe { oakengine_keyframe_get_value(keyframe, &mut out) }, E_INVALID); + assert_eq!(unsafe { oakengine_keyframe_compute_paste_value(node, keyframe, &mut out) }, E_INVALID); + assert_eq!(unsafe { oakengine_keyframe_has_sibling_at_time(keyframe, 0, 0) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_keyframe_set_bezier_point_live(keyframe, 0, 0.0, 0.0) }, E_INVALID); + assert_eq!(unsafe { oakengine_keyframe_get_bezier_point(keyframe, 0, &mut f64a, &mut f64b) }, E_INVALID); + assert_eq!(unsafe { oakengine_keyframe_get_valid_bezier_point(keyframe, 0, &mut f64a, &mut f64b) }, E_INVALID); + assert_eq!(unsafe { oakengine_keyframe_set_value_live(keyframe, &float_value(1.0)) }, E_INVALID); + assert_eq!(unsafe { oakengine_keyframe_set_time_live(keyframe, 1, 1) }, E_INVALID); + assert!(unsafe { oakengine_keyframe_set_time_command(keyframe, 1) }.is_null()); + assert!(unsafe { oakengine_keyframe_set_value_command(keyframe, &float_value(1.0)) }.is_null()); + assert!(unsafe { oakengine_keyframe_create(node, c"x".as_ptr(), -1, 0, 0, 0, &float_value(1.0), 0) }.is_null(), "empty node"); + assert!(unsafe { oakengine_keyframe_create(std::ptr::null_mut(), c"x".as_ptr(), -1, 0, 0, 0, &float_value(1.0), 0) }.is_null()); + + // dragger family + assert!(unsafe { oakengine_dragger_create(node, c"x".as_ptr(), -1, 1) }.is_null(), "empty node"); + assert_eq!(unsafe { oakengine_dragger_start(dragger, 0, 1, 0) }, E_INVALID); + assert_eq!(unsafe { oakengine_dragger_drag(dragger, &float_value(1.0)) }, E_INVALID); + assert_eq!(unsafe { oakengine_dragger_end(dragger, c"x".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_dragger_is_started(dragger) }, E_INVALID, "empty handle, not a NULL pointer"); + + // group family + assert_eq!(unsafe { oakengine_group_input_passthrough_count(node) }, E_INVALID); + assert_eq!(unsafe { oakengine_group_add_input_passthrough(node, node, c"x".as_ptr(), -1, c"".as_ptr(), buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_group_input_passthrough_at(node, 0, buf.as_mut_ptr(), 512, std::ptr::null_mut(), buf.as_mut_ptr(), 512, &mut i32a) }, E_INVALID); + assert_eq!(unsafe { oakengine_group_get_id_of_passthrough(node, node, c"x".as_ptr(), -1, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_group_get_passthrough_from_id(node, c"id".as_ptr(), std::ptr::null_mut(), buf.as_mut_ptr(), 512, &mut i32a) }, E_INVALID); + assert!(unsafe { oakengine_group_get_output_passthrough(node) }.is_null()); + assert_eq!(unsafe { oakengine_group_set_output_passthrough(node, node) }, E_INVALID); + assert_eq!(unsafe { oakengine_group_resolve_input(node, c"x".as_ptr(), -1, std::ptr::null_mut(), buf.as_mut_ptr(), 512, &mut i32a) }, E_INVALID); + assert_eq!(unsafe { oakengine_group_remove_input_passthrough(node, node, c"x".as_ptr(), -1) }, E_INVALID); + assert!(unsafe { oakengine_group_add_input_passthrough_command(node, node, c"x".as_ptr(), -1, c"".as_ptr()) }.is_null()); + assert!(unsafe { oakengine_group_set_output_passthrough_command(node, node) }.is_null()); + assert_eq!(unsafe { oakengine_group_add_input_passthrough_undoable(node, node, c"x".as_ptr(), -1, c"".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_group_set_output_passthrough_undoable(node, node) }, E_INVALID); + + // multicam family + assert_eq!(unsafe { oakengine_multicam_get_source_count(node) }, E_INVALID); + assert_eq!(unsafe { oakengine_multicam_get_current_source(node) }, E_INVALID); + + // subtree / caches / data + assert_eq!(unsafe { oakengine_node_has_gizmos(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_gizmo_count(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert!(unsafe { oakengine_node_gizmo_at(node, 0) }.is_null()); + assert_eq!(unsafe { oakengine_node_update_gizmo_positions(node, std::ptr::null_mut(), 0, 0, 0, 1) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_data(node, 0, &mut i32a, &mut num, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_get_exclusive_dependency_count(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert!(unsafe { oakengine_node_get_exclusive_dependency_at(node, 0) }.is_null()); + assert_eq!(unsafe { oakengine_node_has_plugin(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_plugin_message_count(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_plugin_message_at(node, 0, &mut i32a, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_node_plugin_clear_messages(node) }, E_INVALID); + assert!(unsafe { oakengine_node_get_thumbnail_cache(node) }.is_null()); + assert!(unsafe { oakengine_node_get_waveform_cache(node) }.is_null()); + assert!(unsafe { oakengine_node_get_video_frame_cache(node) }.is_null()); + assert_eq!(unsafe { oakengine_node_is_clip(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_is_track(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_is_viewer_output(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_is_footage(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_is_sequence(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_is_folder(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_is_group(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_is_multicam(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_node_is_item(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert!(unsafe { oakengine_node_get_project(node) }.is_null()); + assert!(unsafe { oakengine_node_parent(node) }.is_null()); + assert!(unsafe { oakengine_node_folder(node) }.is_null()); + assert!(unsafe { oakengine_clip_get_track(node) }.is_null()); + assert_eq!(unsafe { oakengine_track_get_type(node) }, -1); + assert_eq!(unsafe { oakengine_track_get_index(node) }, -1); + assert!(unsafe { oakengine_track_get_sequence(node) }.is_null()); + assert_eq!(unsafe { oakengine_block_get_length_rational(node, &mut i32a, &mut i32b) }, E_INVALID); + assert_eq!(unsafe { oakengine_block_get_in_rational(node, &mut i32a, &mut i32b) }, E_INVALID); + assert_eq!(unsafe { oakengine_block_get_out_rational(node, &mut i32a, &mut i32b) }, E_INVALID); + assert!(unsafe { oakengine_viewer_output_get_connected_texture(node) }.is_null()); + assert_eq!(unsafe { oakengine_shape_set_rect_undoable(node, 0.0, 0.0, 1.0, 1.0, &unsafe { std::mem::zeroed::() }, std::ptr::null_mut()) }, E_INVALID); + assert_eq!(unsafe { oakengine_subtitle_get_text(node, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_subtitle_set_text(node, c"x".as_ptr()) }, E_INVALID); + + // footage family + assert!(unsafe { oakengine_footage_borrow(node) }.is_null(), "not a footage node"); + assert_eq!(unsafe { oakengine_footage_is_valid(node) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_footage_get_decoder_name(footage, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_get_video_stream_count(footage) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_footage_get_audio_stream_count(footage) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_footage_get_subtitle_stream_count(footage) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_footage_get_duration(footage, &mut f64a) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_is_online(footage) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_get_source_start_time(footage, &mut i32a, &mut i32b) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_relink(footage, c"/x.mp4".as_ptr()) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_proxy_get_state(footage) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_proxy_generate(footage) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_proxy_delete(footage) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_proxy_is_enabled(footage) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_proxy_set_enabled(footage, 1) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_proxy_get_path(footage, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_get_filename(footage, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_colorspace_count(footage) }, E_INVALID, "empty handle, not a NULL pointer"); + assert_eq!(unsafe { oakengine_footage_colorspace_at(footage, 0, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_has_custom_proxy_params(footage) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_get_effective_proxy_params(footage, std::ptr::null_mut()) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_set_custom_proxy_params(footage, std::ptr::null()) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_clear_custom_proxy_params(footage) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_set_proxy(footage, c"/x.mp4".as_ptr(), 0, 0, 0, 1) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_clear_proxy(footage) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_invalidate(footage) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_set_source_start_time(footage, 1, 0, 1) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_get_source_start_time_source(footage, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_set_stream_enabled(footage, 0, 0, 1) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_get_stream_enabled(footage, 0, 0) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_get_stream_reference(footage, 0, &mut i32a, &mut i32b) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_describe_video_stream(footage, 0, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_describe_audio_stream(footage, 0, buf.as_mut_ptr(), 512) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_get_video_stream_overrides(footage, 0, buf.as_mut_ptr(), 512, &mut i32a, &mut i32b, &mut i32c) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_set_video_stream_overrides(footage, 0, c"".as_ptr(), 0, 0, 0) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_get_pixel_aspect(footage, 0, &mut i32a, &mut i32b) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_set_pixel_aspect(footage, 0, 1, 1) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_get_image_sequence_params(footage, 0, &mut i64a, &mut i64b, &mut i32a, &mut i32b) }, E_INVALID); + assert_eq!(unsafe { oakengine_footage_set_image_sequence_params(footage, 0, 0, 1, 25, 1) }, E_INVALID); + assert!(unsafe { oakengine_project_import_footage(project, c"/x.mp4".as_ptr()) }.is_null(), "empty project"); + assert_eq!(unsafe { oakengine_project_find_offline_footage(project, c"/tmp".as_ptr()) }, E_INVALID); + + // buffer-size edge cases: NULL buf / zero / negative size never crash. + let mut small = [0 as c_char; 4]; + assert_eq!(unsafe { oakengine_node_get_type_id(node, small.as_mut_ptr(), 4) }, E_INVALID); // empty handle wins + let _ = unsafe { oakengine_node_last_error(std::ptr::null_mut(), 0) }; + let _ = unsafe { oakengine_node_last_error(std::ptr::null_mut(), -5) }; + let _ = unsafe { oakengine_footage_last_error(std::ptr::null_mut(), 0) }; + let _ = unsafe { oakengine_footage_last_error(small.as_mut_ptr(), -1) }; + // Two-stage getters with a NULL buf only report the length (legal). + let _ = unsafe { oakengine_node_category_name(0, std::ptr::null_mut(), 0) }; + + // Free the empty boxes (no-op release paths). + unsafe { oakengine_node_free(node) }; + unsafe { oakengine_project_free(project) }; + unsafe { oakengine_keyframe_dispose(keyframe) }; + unsafe { oakengine_dragger_free(dragger) }; + unsafe { oakengine_footage_free(footage) }; +} + +// --------------------------------------------------------------------------- +// Module destroy contracts + alive counter +// --------------------------------------------------------------------------- + +/// Facade `free`/`dispose`/NULL/empty contracts plus the module destroy +/// paths they delegate to: `free(NULL)` and `free(empty)` are no-ops, the +/// module-level free of an already-emptied handle is double-free-safe, and +/// every owned-object round trip restores the debug alive counter. +#[test] +fn destroy_contracts_and_alive_count() { + with_owned(|| { + common::force_link(); + let _ = force_oakundo_command_link(); + let base = alive(); + + // Facade-level free(NULL) / free(empty box) are no-ops. + unsafe { oakengine_project_free(std::ptr::null_mut()) }; + unsafe { oakengine_node_free(std::ptr::null_mut()) }; + unsafe { oakengine_keyframe_dispose(std::ptr::null_mut()) }; + unsafe { oakengine_dragger_free(std::ptr::null_mut()) }; + unsafe { oakengine_footage_free(std::ptr::null_mut()) }; + + // Owned round trips: create +1, free back to baseline. + let project = oakengine_project_create(); + assert!(!project.is_null()); + assert_eq!(alive(), base + 1); + unsafe { oakengine_project_free(project) }; + assert_eq!(alive(), base, "project free must return the alive counter"); + + let node = unsafe { oakengine_node_factory_create_from_id(TYPE_VALUE.as_ptr()) }; + assert!(!node.is_null()); + assert_eq!(alive(), base + 1); + unsafe { oakengine_node_free(node) }; + assert_eq!(alive(), base); + + let group = unsafe { oakengine_node_group_create() }; + assert!(!group.is_null()); + assert_eq!(alive(), base + 1); + unsafe { oakengine_node_free(group) }; + assert_eq!(alive(), base); + + // Module-level double-free of an already-emptied handle is a no-op + // (the destroy path the facade wraps clears ctx before returning). + let mut h = unsafe { oaknode_factory_create_from_id(TYPE_VALUE.as_ptr()) }; + assert!(!h.is_null()); + assert_eq!(alive(), base + 1); + unsafe { oaknode_node_free(&mut h) }; + assert!(h.is_null()); + unsafe { oaknode_node_free(&mut h) }; // double free: no-op + assert_eq!(alive(), base); + + let mut ph = unsafe { oaknode::ffi::project::oaknode_project_init() }; + assert!(!ph.is_null()); + assert_eq!(alive(), base + 1); + unsafe { oaknode_project_free(&mut ph) }; + assert!(ph.is_null()); + unsafe { oaknode_project_free(&mut ph) }; // double free: no-op + assert_eq!(alive(), base); + + // Keyframe handles are not alive-counted but their free is + // double-free-safe at the module level. + let mut kh = unsafe { oaknode_kf_create(0, 1, &float_value(1.0), 0, 0, -1, c"value_in".as_ptr(), CHandle::null()) }; + assert!(!kh.is_null()); + unsafe { oaknode_keyframe_free(&mut kh) }; + assert!(kh.is_null()); + unsafe { oaknode_keyframe_free(&mut kh) }; // double free: no-op + assert_eq!(alive(), base); + + // Dragger module free: create against a real node handle (freed + // separately), then double-free the dragger handle. + let mut nh = unsafe { oaknode_factory_create_from_id(TYPE_VALUE.as_ptr()) }; + assert!(!nh.is_null()); + let mut dh = unsafe { oaknode::ffi::dragger::oaknode_dragger_create(nh, c"value_in".as_ptr(), -1, 0) }; + assert!(!dh.is_null()); + unsafe { oaknode_node_free(&mut nh) }; + unsafe { oaknode_dragger_free(&mut dh) }; + assert!(dh.is_null()); + unsafe { oaknode_dragger_free(&mut dh) }; // double free: no-op + assert_eq!(alive(), base); + }); +} + +/// Minimal repro of the `oakengine_project_add_node` owned-handle leak: the +/// facade creates the node with `oaknode_factory_create_from_id` (owned, +/// alive-counted) and pushes the AddNode command that MOVES the node into +/// the project graph, but never releases the factory handle. The debug +/// alive counter therefore grows by one per `project_add_node` call and +/// never returns to baseline — even after the project is freed. +#[test] +fn project_add_node_owned_handle_leak() { + with_owned(|| { + common::force_link(); + let _ = force_oakundo_command_link(); + let base = alive(); + + let project = oakengine_project_create(); + assert_eq!(alive(), base + 1); + + let node = unsafe { oakengine_project_add_node(project, TYPE_VALUE.as_ptr()) }; + assert!(!node.is_null()); + assert_eq!(alive(), base + 2, "project + one owned factory handle"); + + // Freeing the project (and the borrowed node view) must return the + // counter to baseline if the add_node path released its owned + // handle — it does not. + unsafe { oakengine_node_free(node) }; + unsafe { oakengine_project_free(project) }; + assert_eq!( + alive(), + base + 1, + "LEAK: project_add_node never releases its owned factory handle" + ); + }); +} + +// --------------------------------------------------------------------------- +// Bugs / divergences found while exercising the family end to end +// (reported; engine source untouched per task rules) +// --------------------------------------------------------------------------- +// +// 1. `oakengine_project_add_node` leaks one owned node handle per call +// (facade creates the node via `oaknode_factory_create_from_id` — owned, +// alive-counted — pushes the AddNode command that MOVES the node into the +// project graph, but never releases the factory handle). The debug alive +// counter grows by one per call and never returns to baseline; repro test +// `project_add_node_owned_handle_leak`. +// +// 2. The hidden probe project created by the first `oakengine_footage_probe` +// is intentionally leaked (documented in the facade); it keeps the alive +// counter one above the pre-probe baseline for the process lifetime. +// +// 3. `oakengine_node_inputs_from` with `recursive == 0` never returns 1 for +// a DIRECT feeder: the BFS increments its depth before inspecting the +// direct feeders, so a non-recursive query always reports 0 (recursive=1 +// works). Off-by-one in the facade BFS (src/node.rs `inputs_from`). +// +// 4. `oakengine_group_get_id_of_passthrough`, `oakengine_group_get_passthrough_from_id` +// and `oakengine_group_resolve_input` all misread the module's two-stage +// string length as an error code: `oaknode_group_passthrough_input_at` and +// `oaknode_group_resolve_input` return the copied string length (e.g. 9 +// for "value_in") on success, and the facade treats any non-zero as a +// failure. Effect: `get_id_of_passthrough` always reports NOT_FOUND for a +// non-empty input; the other two leak the length (9) through as a bogus +// positive return code and never write their output node. Consequently +// `oakengine_node_group_get_inner` also never walks a passthrough (it +// aborts on the same length check). +// +// 5. `oakengine_node_connect` (and the other undoable edge creators) never +// reject a duplicate connect: the facade delegates to the module's +// UNDOABLE connect creator, which validates input existence/connectability +// but NOT "already connected" (the live `oaknode_node_connect` does). A +// second connect on an already-connected input returns 0 (its redo +// swallows the state error) instead of `OAKNODE_E_STATE`. +// +// 6. `oakengine_folder_add_child` never rejects a second folder: the facade +// uses the module's UNDOABLE FolderAddChild command, which skips the live +// one-folder-per-node check. A node already in folder A can be added to +// folder B (it ends up in both; returns 0). +// +// 7. `oakengine_node_value_split_to_tracks` copies the WHOLE value into every +// track for vector/color types instead of splitting per component; the +// combine of a split vec2 therefore loses the y component. The facade's +// `combine_tracks` then picks each track's f[0]. +// +// 8. Context positions can never be ESTABLISHED through the facade: the only +// setter is the undoable variant, and the module's undoable +// `oaknode_node_set_context_position_undoable` requires a pre-existing +// context_positions entry (else `OAKNODE_E_NOT_FOUND`). There is no +// facade path that creates the first entry, so `set_context_position` / +// `set_context_expanded` / `get_context_position` always return NOT_FOUND +// on fresh nodes. +// +// 9. `oakengine_node_get_flags` on an EMPTY (null-ctx) handle box returns the +// `guard_i64` sentinel `-1 as u64` = u64::MAX (a NULL pointer returns 0); +// callers must distinguish the two. +// +// 10. `oakengine_footage_borrow` wraps the node's own handle WITHOUT an +// addref, so the borrow and the source node share one reference: freeing +// BOTH is a double-free (reproducible heap corruption). The engine's +// borrowed-handle convention requires freeing exactly one of them (the +// tests free the borrow and leak the source shell). +// --------------------------------------------------------------------------- diff --git a/crates/oakengine/tests/it_plugin.rs b/crates/oakengine/tests/it_plugin.rs new file mode 100644 index 000000000..3b97ba092 --- /dev/null +++ b/crates/oakengine/tests/it_plugin.rs @@ -0,0 +1,624 @@ +// 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 . + +//! Integration tests for the plugin family: the facade exports +//! `oakengine_plugin_*` (src/plugin.rs; module C contract +//! `include/plugin/{host,instance,error}.h`), exercised end to end +//! against the REAL `oakplugin` crate — no mocks anywhere. +//! +//! `oakengine_plugin_load_plugins` drives the real OFX host scan +//! (dlopen of real plugin bundles); the family's only destroy surface +//! lives in the backend module (`oakplugin_instance_create/free`), which +//! is verified here against the module's debug alive counter +//! (`oakplugin_debug_alive_count`, the leak assertion for this family). +//! The two provider setters are pure facade state (module 00 analogues of +//! the C++ capi statics): their result IS the return code, asserted below. +//! +//! The host singleton is process-global and only internally locked, so +//! every test that touches it serializes on [`with_host`] (same +//! convention as the module crate's own tests). +//! +//! The end-to-end bundle test needs the minimal test plugin that the +//! oakplugin crate's build.rs compiles (cbits/oak_test_plugin.c). When +//! it is unavailable the test prints SKIP and returns (never fails). + +#[path = "common/mod.rs"] +mod common; + +use std::ffi::{c_char, c_int, c_void, CStr, CString}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use oakengine::handle::{CHandle, OakEngineNode}; +use oakengine::plugin::{ + oakengine_plugin_load_plugins, oakengine_plugin_node_push_button_clicked, + oakengine_plugin_set_active_viewer_provider, oakengine_plugin_set_progress_reporter_factory, +}; +use oakplugin::ffi::{ + oakplugin_debug_alive_count, oakplugin_host_plugin_count, oakplugin_host_plugin_id_at, + oakplugin_host_plugin_label, oakplugin_instance_create, oakplugin_instance_free, +}; + +/// `OAKENGINE_E_INVALID` (src/error.rs). +const E_INVALID: c_int = -1; +/// `OAKENGINE_E_FAILED` (src/error.rs). +const E_FAILED: c_int = -3; +/// `OAKPLUGIN_E_INVALID` (module error.h) — module codes pass through the +/// facade untranslated. +const PLUGIN_E_INVALID: c_int = -90001; + +/// Identifier of the minimal OFX test plugin (cbits/oak_test_plugin.c). +const TEST_PLUGIN_ID: &str = "org.oak.test-plugin"; +/// Build-system injected bundle path (set by the CMake test runner). +const TEST_PLUGIN_ENV: &str = "OAK_TEST_PLUGIN_DIR"; + +// --------------------------------------------------------------------------- +// Host serialization + fixtures +// --------------------------------------------------------------------------- + +/// Serialize host-touching tests: the oakplugin host is a process +/// singleton without a top-level lock (each internal list is mutexed, but +/// init/scan/shutdown interleavings would make count assertions flaky). +fn with_host(f: impl FnOnce()) { + static LOCK: Mutex<()> = Mutex::new(()); + let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner()); + f(); +} + +/// Fresh directory under the system temp dir (removed before creation). +fn fresh_temp_dir(name: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("oak-it-plugin-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).expect("create temp dir"); + p +} + +/// Current number of live backend objects (host instance registry). +fn alive() -> c_int { + unsafe { oakplugin_debug_alive_count() } +} + +/// Number of plugins discovered by the real host cache. +fn plugin_count() -> c_int { + unsafe { oakplugin_host_plugin_count() } +} + +/// Run the facade scan through the real host, returning its exit code. +fn scan_facade(dir: &Path) -> c_int { + let cs = CString::new(dir.as_os_str().as_encoded_bytes()).expect("NUL-free path"); + unsafe { oakengine_plugin_load_plugins(cs.as_ptr()) } +} + +/// Locate the real test plugin shared library: oakplugin's build.rs +/// compiles cbits/oak_test_plugin.c to `$OUT_DIR/oak_test_plugin.{dylib,so}` +/// inside its own `target/*/build/oakplugin-*/out/` directory. +fn find_test_plugin_lib() -> Option { + let ext = if cfg!(target_os = "macos") { + "dylib" + } else { + "so" + }; + let mut roots: Vec = Vec::new(); + if let Some(t) = std::env::var_os("CARGO_TARGET_DIR") { + roots.push(PathBuf::from(t)); + } + roots.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target")); + for root in roots { + for profile in ["debug", "release"] { + let build = root.join(profile).join("build"); + let Ok(entries) = std::fs::read_dir(&build) else { + continue; + }; + let mut hits: Vec = entries + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("oakplugin-")) + }) + .collect(); + hits.sort(); + for dir in hits { + let lib = dir.join("out").join(format!("oak_test_plugin.{ext}")); + if lib.is_file() { + return Some(lib); + } + } + } + } + None +} + +/// Copy a test-plugin shared library into a `.bundle` directory layout +/// under `root` (Contents/MacOS or Contents/Linux-x86-64, matching the +/// host's `find_binary_in_bundle`). +fn install_bundle(lib: &Path, root: &Path) -> Option<()> { + let platform = if cfg!(target_os = "macos") { + "MacOS" + } else { + "Linux-x86-64" + }; + let bin_dir = root + .join("oak-test-plugin.ofx.bundle") + .join("Contents") + .join(platform); + std::fs::create_dir_all(&bin_dir).ok()?; + std::fs::copy(lib, bin_dir.join("plugin")).ok()?; + Some(()) +} + +/// A scan directory containing the real test plugin bundle, if available: +/// either the parent of the build-system-injected bundle +/// (`OAK_TEST_PLUGIN_DIR`) or a bundle assembled in the temp dir from the +/// shared library oakplugin's build.rs produced. `None` → caller skips. +fn test_plugin_scan_dir() -> Option { + if let Some(bundle) = std::env::var_os(TEST_PLUGIN_ENV) { + return PathBuf::from(bundle).parent().map(|p| p.to_path_buf()); + } + let lib = find_test_plugin_lib()?; + let root = std::env::temp_dir().join(format!("oak-it-plugin-bundle-{}", std::process::id())); + if !root.join("oak-test-plugin.ofx.bundle").exists() { + install_bundle(&lib, &root)?; + } + Some(root) +} + +/// A scan directory that is guaranteed to have been scanned by NO other +/// test in this binary (fresh per call), so "scan registers the plugin" +/// assertions are deterministic. The env-injected bundle has no fresh +/// variant and falls back to the shared one. +fn fresh_test_plugin_scan_dir(tag: &str) -> Option { + if std::env::var_os(TEST_PLUGIN_ENV).is_some() { + return test_plugin_scan_dir(); + } + let lib = find_test_plugin_lib()?; + let root = fresh_temp_dir(&format!("bundle-{tag}")); + install_bundle(&lib, &root)?; + Some(root) +} + +/// All plugin identifiers currently registered in the real host cache +/// (two-stage getter round trip per index). +fn plugin_ids() -> Vec { + let count = plugin_count(); + let mut out = Vec::new(); + for i in 0..count { + let len = unsafe { oakplugin_host_plugin_id_at(i, std::ptr::null_mut(), 0) }; + if len <= 0 { + continue; + } + let mut buf = vec![0u8; len as usize]; + let rc = unsafe { oakplugin_host_plugin_id_at(i, buf.as_mut_ptr() as *mut c_char, len) }; + if rc == 0 { + out.push( + unsafe { CStr::from_ptr(buf.as_ptr() as *const c_char) } + .to_string_lossy() + .into_owned(), + ); + } + } + out +} + +// --------------------------------------------------------------------------- +// oakengine_plugin_set_active_viewer_provider +// --------------------------------------------------------------------------- + +/// Legal matrix for the active-viewer provider: fn Some/None × userdata +/// ptr/NULL all register (or clear) with `OAKENGINE_OK`. +#[test] +fn active_viewer_provider_register_clear_matrix() { + common::force_link(); + + unsafe extern "C" fn viewer(_userdata: *mut c_void) -> *mut OakEngineNode { + std::ptr::null_mut() + } + let mut userdata = 42i32; + let ud = &mut userdata as *mut i32 as *mut c_void; + + // Some(fn) + userdata. + assert_eq!( + oakengine_plugin_set_active_viewer_provider(Some(viewer), ud), + 0 + ); + // Some(fn) + NULL userdata (userdata is opaque, NULL is legal). + assert_eq!( + oakengine_plugin_set_active_viewer_provider(Some(viewer), std::ptr::null_mut()), + 0 + ); + // None clears (NULL fn), userdata is then ignored but still legal. + assert_eq!(oakengine_plugin_set_active_viewer_provider(None, ud), 0); + assert_eq!( + oakengine_plugin_set_active_viewer_provider(None, std::ptr::null_mut()), + 0 + ); + // Register again and clear, so the process-global state ends neutral. + assert_eq!( + oakengine_plugin_set_active_viewer_provider(Some(viewer), std::ptr::null_mut()), + 0 + ); + assert_eq!( + oakengine_plugin_set_active_viewer_provider(None, std::ptr::null_mut()), + 0 + ); +} + +// --------------------------------------------------------------------------- +// oakengine_plugin_set_progress_reporter_factory +// --------------------------------------------------------------------------- + +/// Legal matrix for the progress-reporter factory: full factory, clear +/// (all NULL), partial registrations and NULL userdata all return +/// `OAKENGINE_OK`. +#[test] +fn progress_reporter_factory_register_clear_matrix() { + common::force_link(); + + unsafe extern "C" fn create( + _m: *const c_char, + _t: *const c_char, + _u: *mut c_void, + ) -> *mut c_void { + std::ptr::null_mut() + } + unsafe extern "C" fn destroy(_r: *mut c_void, _u: *mut c_void) {} + unsafe extern "C" fn is_cancelled(_r: *mut c_void, _u: *mut c_void) -> c_int { + 0 + } + unsafe extern "C" fn set_progress(_r: *mut c_void, _p: f64, _u: *mut c_void) {} + let mut userdata = 7i64; + let ud = &mut userdata as *mut i64 as *mut c_void; + + // Full factory + userdata. + assert_eq!( + oakengine_plugin_set_progress_reporter_factory( + Some(create), + Some(destroy), + Some(is_cancelled), + Some(set_progress), + ud, + ), + 0 + ); + // All-NULL clears (NULL userdata too). + assert_eq!( + oakengine_plugin_set_progress_reporter_factory( + None, + None, + None, + None, + std::ptr::null_mut() + ), + 0 + ); + // Partial registrations are accepted (the facade stores what it gets). + assert_eq!( + oakengine_plugin_set_progress_reporter_factory( + Some(create), + None, + None, + None, + std::ptr::null_mut() + ), + 0 + ); + assert_eq!( + oakengine_plugin_set_progress_reporter_factory(None, Some(destroy), None, None, ud), + 0 + ); + assert_eq!( + oakengine_plugin_set_progress_reporter_factory( + None, + None, + Some(is_cancelled), + None, + std::ptr::null_mut() + ), + 0 + ); + assert_eq!( + oakengine_plugin_set_progress_reporter_factory(None, None, None, Some(set_progress), ud), + 0 + ); + // Back to cleared. + assert_eq!( + oakengine_plugin_set_progress_reporter_factory( + None, + None, + None, + None, + std::ptr::null_mut() + ), + 0 + ); +} + +// --------------------------------------------------------------------------- +// oakengine_plugin_load_plugins +// --------------------------------------------------------------------------- + +/// NULL path → facade `E_INVALID`, never a crash. +#[test] +fn load_plugins_null_path() { + with_host(|| { + common::force_link(); + let before = alive(); + assert_eq!( + unsafe { oakengine_plugin_load_plugins(std::ptr::null()) }, + E_INVALID + ); + assert_eq!( + alive(), + before, + "failed scan must not touch the host registry" + ); + }); +} + +/// Empty string path is a documented no-op (canonicalize fails, not a +/// directory → host returns OK; the C++ host never errors on a missing +/// path). +#[test] +fn load_plugins_empty_string_path() { + with_host(|| { + common::force_link(); + let cs = CString::new("").unwrap(); + let before = alive(); + assert_eq!(unsafe { oakengine_plugin_load_plugins(cs.as_ptr()) }, 0); + assert_eq!(alive(), before); + }); +} + +/// Nonexistent path: silently skipped with OK (olivehost.cpp add_plugin_path +/// semantics), no crash. +#[test] +fn load_plugins_nonexistent_path() { + with_host(|| { + common::force_link(); + let dir = + std::env::temp_dir().join(format!("oak-it-plugin-missing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); // guarantee absence + let before = alive(); + assert_eq!(scan_facade(&dir), 0); + assert_eq!(alive(), before); + }); +} + +/// A path that points at a regular file (not a directory) is a documented +/// no-op returning OK. +#[test] +fn load_plugins_path_is_a_file() { + with_host(|| { + common::force_link(); + let dir = fresh_temp_dir("file-scan"); + let file = dir.join("not-a-dir"); + std::fs::write(&file, b"hi").unwrap(); + let before = alive(); + assert_eq!(scan_facade(&file), 0); + assert_eq!(alive(), before); + }); +} + +/// Empty directory scans cleanly (OK), changes nothing in the plugin +/// cache, and a repeat scan of the same path is deduplicated (also OK) — +/// the meaningful size=0 / index-range ground state. +#[test] +fn load_plugins_empty_dir_and_dedup() { + with_host(|| { + common::force_link(); + let dir = fresh_temp_dir("empty"); + let count_before = plugin_count(); + let before = alive(); + assert_eq!(scan_facade(&dir), 0); + assert_eq!( + plugin_count(), + count_before, + "empty dir must not register plugins" + ); + // Same path again → dedup no-op, still OK. + assert_eq!(scan_facade(&dir), 0); + assert_eq!(plugin_count(), count_before); + assert_eq!(alive(), before); + }); +} + +/// Unicode path (non-ASCII directory name) scans cleanly. +#[test] +fn load_plugins_unicode_path() { + with_host(|| { + common::force_link(); + let dir = fresh_temp_dir("unicode"); + let unicode = dir.join("插件-目录-β"); + std::fs::create_dir_all(&unicode).unwrap(); + let before = alive(); + assert_eq!(scan_facade(&unicode), 0); + assert_eq!(alive(), before); + }); +} + +/// Non-UTF-8 path bytes: the module rejects them with `OAKPLUGIN_E_INVALID` +/// which passes through the facade untranslated (-90001), never a crash. +#[test] +fn load_plugins_non_utf8_path() { + with_host(|| { + common::force_link(); + let cs = CString::new(&b"/tmp/oak-it-plugin-\xff\xfe"[..]).unwrap(); + let before = alive(); + assert_eq!( + unsafe { oakengine_plugin_load_plugins(cs.as_ptr()) }, + PLUGIN_E_INVALID + ); + assert_eq!(alive(), before); + }); +} + +/// End-to-end legal path: `oakengine_plugin_load_plugins` against a real +/// directory containing the real OFX test plugin bundle registers the +/// plugin in the real host cache (dlopen + setHost + load + describe all +/// run). Verified through the module's own introspection, plus a repeat +/// scan (dedup) and the alive counter. +/// +/// Skips when the test plugin was not built (see module docs). +#[test] +fn load_plugins_real_bundle_end_to_end() { + with_host(|| { + common::force_link(); + // A fresh directory so "scan registers the plugin" is deterministic; + // the env-injected mode falls back to the shared dir. + let Some(dir) = fresh_test_plugin_scan_dir("e2e") else { + println!("SKIP: test plugin bundle unavailable (oakplugin build.rs output missing)"); + return; + }; + let before = alive(); + let ids_before = plugin_ids(); + let had_test_plugin = ids_before.iter().any(|id| id == TEST_PLUGIN_ID); + let count_before = plugin_count(); + + // The facade scan returns OK and — unless the test plugin ids were + // already registered by an earlier scan in this binary (the host + // dedups globally by identifier, so a second scan of the same + // bundle binary is a no-op) — registers the test plugin. + assert_eq!(scan_facade(&dir), 0); + let ids_after = plugin_ids(); + assert!( + ids_after.iter().any(|id| id == TEST_PLUGIN_ID), + "test plugin id must be discoverable after scan (ids: {ids_after:?})" + ); + if !had_test_plugin { + assert!( + plugin_count() > count_before, + "a first scan of the real bundle must register the plugin ({} -> {})", + count_before, + plugin_count() + ); + } + + // Label lookup for a known id resolves (phase 1: the id itself). + let len = unsafe { + oakplugin_host_plugin_label(c"org.oak.test-plugin".as_ptr(), std::ptr::null_mut(), 0) + }; + assert!(len > 0); + let mut lbuf = vec![0u8; len as usize]; + assert_eq!( + unsafe { + oakplugin_host_plugin_label( + c"org.oak.test-plugin".as_ptr(), + lbuf.as_mut_ptr() as *mut c_char, + len, + ) + }, + 0 + ); + + // Repeat scan of the same path is deduplicated: still OK, cache + // unchanged, alive counter untouched. + let count_after_first = plugin_count(); + assert_eq!(scan_facade(&dir), 0); + assert_eq!(plugin_count(), count_after_first); + assert_eq!(alive(), before, "scan must not leak host instances"); + }); +} + +// --------------------------------------------------------------------------- +// oakengine_plugin_node_push_button_clicked +// --------------------------------------------------------------------------- + +/// Documented stub: the oakplugin crate has no push-button API (the OFX +/// button-param trigger is C++-only), so every input combination returns +/// `OAKENGINE_E_FAILED` and never reads its arguments. +#[test] +fn push_button_clicked_documented_stub() { + common::force_link(); + // NULL node + NULL button. + assert_eq!( + unsafe { + oakengine_plugin_node_push_button_clicked(std::ptr::null_mut(), std::ptr::null()) + }, + E_FAILED + ); + // NULL node + button id. + assert_eq!( + unsafe { oakengine_plugin_node_push_button_clicked(std::ptr::null_mut(), c"btn".as_ptr()) }, + E_FAILED + ); + // Non-NULL node (empty handle) + NULL button. + let mut node = OakEngineNode { + handle: CHandle::null(), + }; + assert_eq!( + unsafe { oakengine_plugin_node_push_button_clicked(&mut node, std::ptr::null()) }, + E_FAILED + ); + // Non-NULL node + button id. + assert_eq!( + unsafe { oakengine_plugin_node_push_button_clicked(&mut node, c"btn".as_ptr()) }, + E_FAILED + ); +} + +// --------------------------------------------------------------------------- +// Backend destroy contract (the family's only free surface) +// --------------------------------------------------------------------------- + +/// The facade plugin family exports no free/destroy function; its only +/// destroy surface is the backend `oakplugin_instance_free`. Contracts +/// verified against the real host: free(NULL)/free(empty)/double-free are +/// no-ops, unknown ids yield an empty handle (documented), and a real +/// instance create → free round trip restores the module's alive counter +/// to baseline (the leak assertion for this family). +#[test] +fn backend_instance_free_contracts() { + with_host(|| { + common::force_link(); + let base = alive(); + + // free(NULL) and free(empty handle) are no-ops. + unsafe { oakplugin_instance_free(std::ptr::null_mut()) }; + let mut empty = CHandle::null(); + unsafe { oakplugin_instance_free(&mut empty) }; + assert!(empty.is_null(), "free must leave the handle emptied"); + + // Unknown plugin id → empty handle, not a crash. + let mut h = unsafe { oakplugin_instance_create(c"org.oak.not-a-plugin".as_ptr()) }; + assert!(h.is_null()); + unsafe { oakplugin_instance_free(&mut h) }; + assert_eq!(alive(), base); + + // Real plugin: create +1, free back to baseline, double-free safe. + let Some(dir) = test_plugin_scan_dir() else { + println!("SKIP: test plugin bundle unavailable (oakplugin build.rs output missing)"); + return; + }; + assert_eq!(scan_facade(&dir), 0); + let mut inst = unsafe { oakplugin_instance_create(c"org.oak.test-plugin".as_ptr()) }; + assert!( + !inst.is_null(), + "scanned test plugin must create an instance" + ); + assert_eq!(alive(), base + 1, "one live instance must be registered"); + unsafe { oakplugin_instance_free(&mut inst) }; + assert!(inst.is_null()); + assert_eq!( + alive(), + base, + "free must return the alive counter to baseline" + ); + // Double free of the already-emptied handle is a no-op. + unsafe { oakplugin_instance_free(&mut inst) }; + assert_eq!(alive(), base); + }); +} diff --git a/crates/oakengine/tests/it_render.rs b/crates/oakengine/tests/it_render.rs new file mode 100644 index 000000000..dc52393ce --- /dev/null +++ b/crates/oakengine/tests/it_render.rs @@ -0,0 +1,1017 @@ +// 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 . + +//! Integration tests for the render family (`crates/oakengine/src/render.rs`, +//! the facade contract of `engine/include/oakengine/{renderer,color,lut}.h`). +//! +//! Covers all 60 exported `oakengine_*` functions of the family: the render +//! manager/cacher setters, the renderer lifecycle + real frame render, the +//! frame/audio-buffer accessors, the color-manager/config/processor surface +//! and the LUT library. +//! +//! Nothing here is mocked: the render manager is initialized through the real +//! oakrender C ABI (`oakrender_manager_init`), frames are produced by the +//! module's CPU eval pipeline, the color processor goes through the real +//! bundled OCIO config (`oakrender_color_manager_set_up_default_config`), and +//! the renderer's sequence handle comes from the real node/timeline families +//! (`oakengine_project_*` + `oakengine_sequence_new`). +//! +//! ## Global state +//! +//! The render manager singleton, the OCIO default config, the `$OCIO` +//! environment variable and the facade undo stack (cleared by +//! `oakengine_project_new`) are process-global, so every test that touches +//! them takes the single [`STATE_LOCK`]. The pure-stub tests (NULL-argument +//! accessors, LUT/audio stubs) run lock-free. + +#[path = "common/mod.rs"] +mod common; + +use std::ffi::{c_char, c_int, c_void}; +use std::sync::{Mutex, MutexGuard}; + +use oakengine::handle::OakEngineAudioBuffer; +use oakengine::node::{ + oakengine_node_factory_create_from_id, oakengine_node_free, oakengine_project_create, + oakengine_project_free, oakengine_project_new, +}; +use oakengine::render::{ + oakengine_audio_channel_count, oakengine_audio_data, oakengine_audio_free, + oakengine_audio_sample_count, oakengine_audio_sample_rate, + oakengine_color_config_colorspace_at, oakengine_color_config_colorspace_count, + oakengine_color_config_free, oakengine_color_config_load_default, + oakengine_color_config_load_file, oakengine_color_last_error, + oakengine_color_manager_colorspace_at, oakengine_color_manager_colorspace_count, + oakengine_color_manager_compliant_color_space, oakengine_color_manager_compliant_transform, + oakengine_color_manager_default_display, oakengine_color_manager_default_input_color_space, + oakengine_color_manager_default_luma_coefs, oakengine_color_manager_default_view, + oakengine_color_manager_display_at, oakengine_color_manager_display_count, + oakengine_color_manager_from_project, oakengine_color_manager_get_config_filename, + oakengine_color_manager_look_at, oakengine_color_manager_look_count, + oakengine_color_manager_reference_color_space, oakengine_color_manager_set_config_filename, + oakengine_color_manager_set_default_input_color_space, oakengine_color_manager_view_at, + oakengine_color_manager_view_count, oakengine_color_processor_convert_color, + oakengine_color_processor_create, oakengine_color_processor_free, oakengine_color_processor_id, + oakengine_color_processor_is_valid, oakengine_color_transform_job_set_processor, + oakengine_frame_channel_count, oakengine_frame_data, oakengine_frame_format, + oakengine_frame_free, oakengine_frame_height, oakengine_frame_linesize_bytes, + oakengine_frame_width, oakengine_lut_directory_at, oakengine_lut_directory_count, + oakengine_lut_file_at, oakengine_lut_file_count, oakengine_lut_set_directories, + oakengine_render_cache_set_display_color_processor, oakengine_render_cache_set_multicam_node, + oakengine_render_manager_backend_to_string, oakengine_render_manager_requested_backend, + oakengine_render_manager_set_aggressive_garbage_collection, oakengine_renderer_cancel, + oakengine_renderer_create, oakengine_renderer_free, oakengine_renderer_last_error, + oakengine_renderer_render_audio, oakengine_renderer_render_frame, oakengine_renderer_set_mode, + OakColorTransformPod, +}; +use oakengine::timeline::oakengine_sequence_new; + +/// Serializes tests that touch process-global state: the render manager +/// singleton, the OCIO default config, the `$OCIO` env var and the facade +/// undo stack (`oakengine_project_new` clears it). Every test that takes +/// this lock leaves the manager shut down and the config/env untouched, so +/// the tests are order-independent. +static STATE_LOCK: Mutex<()> = Mutex::new(()); + +fn state_lock() -> MutexGuard<'static, ()> { + STATE_LOCK.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Read a two-stage facade string into a Rust `String`. +unsafe fn read_buf(buf: &mut [c_char]) -> String { + unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) } + .to_string_lossy() + .into_owned() +} + +// --------------------------------------------------------------------------- +// Render manager / cacher (5 exports) +// --------------------------------------------------------------------------- + +/// Manager state machine: STATE errors before init, legal paths after +/// `oakrender_manager_init`, STATE again after shutdown. The two cache +/// setters use their documented NULL-clears path here (real-handle paths +/// are exercised in `color_family_lifecycle`). +#[test] +fn render_manager_state_machine() { + common::force_link(); + let _g = state_lock(); + + // Clean slate: no manager may be up when this test runs. + unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() }; + assert_eq!( + unsafe { oakrender::ffi::manager::oakrender_manager_available() }, + 0 + ); + + // Not initialized → the module's STATE error passes through. + assert_eq!( + unsafe { oakengine_render_manager_set_aggressive_garbage_collection(1) }, + -70002 + ); + assert_eq!( + unsafe { oakengine_render_cache_set_display_color_processor(std::ptr::null_mut()) }, + -70002 + ); + assert_eq!( + unsafe { oakengine_render_cache_set_multicam_node(std::ptr::null_mut()) }, + -70002 + ); + + // Backend queries are stubs that ignore their arguments: requested + // backend is 0 (k_open_gl); backend_to_string is always E_FAILED even + // with garbage backend ids or NULL buffers. + assert_eq!(unsafe { oakengine_render_manager_requested_backend() }, 0); + let mut buf = [0 as c_char; 64]; + assert_eq!( + unsafe { oakengine_render_manager_backend_to_string(0, std::ptr::null_mut(), 0) }, + -3 + ); + assert_eq!( + unsafe { oakengine_render_manager_backend_to_string(2, std::ptr::null_mut(), 0) }, + -3 + ); + assert_eq!( + unsafe { oakengine_render_manager_backend_to_string(-7, buf.as_mut_ptr(), 64) }, + -3 + ); + + // Init through the real module C ABI; double init is a state error. + assert_eq!( + unsafe { oakrender::ffi::manager::oakrender_manager_init() }, + 0 + ); + assert_eq!( + unsafe { oakrender::ffi::manager::oakrender_manager_available() }, + 1 + ); + assert_eq!( + unsafe { oakrender::ffi::manager::oakrender_manager_init() }, + -70002 + ); + + // Legal matrix for the aggressive-GC toggle: 0, 1 and any garbage + // value are all accepted (nonzero = enabled). + assert_eq!( + unsafe { oakengine_render_manager_set_aggressive_garbage_collection(0) }, + 0 + ); + assert_eq!( + unsafe { oakengine_render_manager_set_aggressive_garbage_collection(1) }, + 0 + ); + assert_eq!( + unsafe { oakengine_render_manager_set_aggressive_garbage_collection(2) }, + 0 + ); + assert_eq!( + unsafe { oakengine_render_manager_set_aggressive_garbage_collection(-1) }, + 0 + ); + + // NULL-clears paths on the cacher. + assert_eq!( + unsafe { oakengine_render_cache_set_display_color_processor(std::ptr::null_mut()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_render_cache_set_multicam_node(std::ptr::null_mut()) }, + 0 + ); + + // Shutdown → STATE again. + unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() }; + assert_eq!( + unsafe { oakrender::ffi::manager::oakrender_manager_available() }, + 0 + ); + assert_eq!( + unsafe { oakengine_render_manager_set_aggressive_garbage_collection(1) }, + -70002 + ); +} + +// --------------------------------------------------------------------------- +// Renderer (7 exports) +// --------------------------------------------------------------------------- + +/// Renderer lifecycle and illegal arguments. `render_frame` without a +/// render manager fails cleanly (NULL + last_error); the audio path is +/// unimplemented by the module and always fails the same way. +#[test] +fn renderer_lifecycle() { + common::force_link(); + let _g = state_lock(); + unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() }; // render_frame below must see no manager + + // NULL sequence → NULL renderer for every geometry combination. + for (w, h, pf, num, den) in [ + (1920, 1080, 4, 30000, 1001), + (0, 1080, 4, 30000, 1001), + (1920, 0, 4, 30000, 1001), + (1920, 1080, 4, 0, 1001), + (1920, 1080, 4, 30000, 0), + (-1, 1080, 4, 30000, 1001), + (1920, -1, 4, 30000, 1001), + ] { + let r = unsafe { + oakengine_renderer_create(std::ptr::null_mut(), w, h, pf, num, den, std::ptr::null()) + }; + assert!( + r.is_null(), + "NULL seq (w={w} h={h} pf={pf} {num}/{den}) must give NULL" + ); + } + + // NULL renderer calls are all safe. + unsafe { oakengine_renderer_free(std::ptr::null_mut()) }; + unsafe { oakengine_renderer_cancel(std::ptr::null_mut()) }; + let mut err = [0 as c_char; 128]; + assert_eq!( + unsafe { oakengine_renderer_last_error(std::ptr::null(), err.as_mut_ptr(), 128) }, + -1 + ); + assert_eq!( + unsafe { oakengine_renderer_set_mode(std::ptr::null_mut(), 0) }, + -1 + ); + assert!(unsafe { oakengine_renderer_render_frame(std::ptr::null_mut(), 0) }.is_null()); + assert!(unsafe { oakengine_renderer_render_audio(std::ptr::null_mut(), 0, 10) }.is_null()); + + // A real sequence (node family, no manager needed to create it). + let project = unsafe { oakengine_project_create() }; + assert!(!project.is_null()); + assert_eq!(unsafe { oakengine_project_new(project) }, 0); + let seq = unsafe { oakengine_sequence_new(project, c"it-render-seq".as_ptr()) }; + assert!(!seq.is_null()); + + let r = unsafe { oakengine_renderer_create(seq, 1920, 1080, 4, 30000, 1001, std::ptr::null()) }; + assert!(!r.is_null()); + + // Mode matrix: only 0 and 1 are legal; anything else is E_INVALID. + assert_eq!(unsafe { oakengine_renderer_set_mode(r, 0) }, 0); + assert_eq!(unsafe { oakengine_renderer_set_mode(r, 1) }, 0); + assert_eq!(unsafe { oakengine_renderer_set_mode(r, 2) }, -1); + assert_eq!(unsafe { oakengine_renderer_set_mode(r, -1) }, -1); + assert_eq!(unsafe { oakengine_renderer_set_mode(r, 42) }, -1); + + // A fresh renderer reports an empty last_error. + assert_eq!( + unsafe { oakengine_renderer_last_error(r, err.as_mut_ptr(), 128) }, + 0 + ); + + // render_frame without a manager: clean NULL + last_error. + assert!(unsafe { oakengine_renderer_render_frame(r, 0) }.is_null()); + let elen = unsafe { oakengine_renderer_last_error(r, err.as_mut_ptr(), 128) }; + assert!(elen > 0, "failed render must set last_error (got {elen})"); + + // render_audio: the facade hands the module a NULL audio-params + // pointer, so the ticket is never created (documented: the module's + // samples path is unimplemented) → NULL + last_error. + assert!(unsafe { oakengine_renderer_render_audio(r, 0, 10) }.is_null()); + let elen = unsafe { oakengine_renderer_last_error(r, err.as_mut_ptr(), 128) }; + assert!( + elen > 0, + "failed audio render must set last_error (got {elen})" + ); + assert_eq!( + unsafe { read_buf(&mut err) }, + "audio render ticket submission failed" + ); + + // Cancel is a documented no-op. + unsafe { oakengine_renderer_cancel(r) }; + + // Extreme timestamps must never crash: in debug builds the + // i64::MAX * frame_rate_den overflow panics inside the facade guard and + // yields NULL; in release it wraps and renders a frame. Either outcome + // is acceptable — the point is that the call is robust. + let f_extreme = unsafe { oakengine_renderer_render_frame(r, i64::MAX) }; + if !f_extreme.is_null() { + unsafe { oakengine_frame_free(f_extreme) }; + } + + unsafe { oakengine_renderer_free(r) }; + unsafe { oakengine_project_free(project) }; +} + +/// End-to-end CPU render: with the render manager up and a real sequence, +/// `render_frame` produces a real F32 frame through the module's eval +/// pipeline. Also pins the renderer-geometry deviation (the facade never +/// forwards force_width/force_height, so the frame size is the pipeline +/// default) and the ineffective pixel-format validation. +#[test] +fn renderer_render_frame_e2e() { + common::force_link(); + let _g = state_lock(); + + unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() }; + assert_eq!( + unsafe { oakrender::ffi::manager::oakrender_manager_init() }, + 0 + ); + + let base = unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() }; + + let project = unsafe { oakengine_project_create() }; + assert_eq!(unsafe { oakengine_project_new(project) }, 0); + let seq = unsafe { oakengine_sequence_new(project, c"it-render-e2e".as_ptr()) }; + assert!(!seq.is_null()); + + // 1920x1080 F32 (pixel format 4 = PixelFormat::F32). + let r = unsafe { oakengine_renderer_create(seq, 1920, 1080, 4, 30000, 1001, std::ptr::null()) }; + assert!(!r.is_null()); + + // Legal render: a real frame comes back and every accessor reads it. + let f = unsafe { oakengine_renderer_render_frame(r, 0) }; + assert!(!f.is_null(), "render_frame must produce a frame"); + assert_eq!(unsafe { oakengine_frame_width(f) }, 1920); + assert_eq!(unsafe { oakengine_frame_height(f) }, 1080); + assert_eq!(unsafe { oakengine_frame_format(f) }, 4); // F32 pipeline format + assert_eq!(unsafe { oakengine_frame_linesize_bytes(f) }, 1920 * 4 * 4); + assert!(!unsafe { oakengine_frame_data(f) }.is_null()); + // channel_count has no crate accessor and reports 0 (documented). + assert_eq!(unsafe { oakengine_frame_channel_count(f) }, 0); + + // The renderer's last_error is cleared after a successful render. + assert_eq!( + unsafe { oakengine_renderer_last_error(r, err_buf().as_mut_ptr(), 128) }, + 0 + ); + + // The produced frame is an oakrender-owned handle: alive count +1, + // back to baseline after free. + assert_eq!( + unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() }, + base + 1 + ); + unsafe { oakengine_frame_free(f) }; + assert_eq!( + unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() }, + base + ); + + // A second render at a positive timestamp works too. + let f2 = unsafe { oakengine_renderer_render_frame(r, 30) }; + assert!(!f2.is_null()); + unsafe { oakengine_frame_free(f2) }; + + // --- documented deviations (reported, not fixed) --- + // 1. The renderer's output geometry is not honored: the facade leaves + // force_width/force_height at 0, so the ticket renders the pipeline + // default (1920x1080) regardless of the boxed geometry. + let r_small = + unsafe { oakengine_renderer_create(seq, 640, 360, 0, 30000, 1001, std::ptr::null()) }; + assert!(!r_small.is_null()); + let f3 = unsafe { oakengine_renderer_render_frame(r_small, 0) }; + assert!(!f3.is_null()); + assert_eq!( + unsafe { oakengine_frame_width(f3) }, + 1920, + "deviation: renderer geometry (640x360) is ignored; the frame is the 1920x1080 pipeline default" + ); + unsafe { oakengine_frame_free(f3) }; + unsafe { oakengine_renderer_free(r_small) }; + + // 2. The pixel-format validation in renderer_create is ineffective: the + // oakcommon format_name lookup succeeds for ANY code, so garbage + // formats are accepted instead of returning NULL. + let r_garbage_pf = + unsafe { oakengine_renderer_create(seq, 64, 48, 99999, 30000, 1001, std::ptr::null()) }; + assert!( + !r_garbage_pf.is_null(), + "deviation: renderer_create accepts pixel_format=99999 (validation is a no-op)" + ); + let f4 = unsafe { oakengine_renderer_render_frame(r_garbage_pf, 0) }; + assert!(!f4.is_null(), "a garbage-format renderer still renders"); + unsafe { oakengine_frame_free(f4) }; + unsafe { oakengine_renderer_free(r_garbage_pf) }; + + let r_neg_pf = + unsafe { oakengine_renderer_create(seq, 64, 48, -1, 30000, 1001, std::ptr::null()) }; + assert!(!r_neg_pf.is_null()); + unsafe { oakengine_renderer_free(r_neg_pf) }; + + unsafe { oakengine_renderer_free(r) }; + unsafe { oakengine_project_free(project) }; + assert_eq!( + unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() }, + base + ); + + unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() }; + assert_eq!( + unsafe { oakrender::ffi::manager::oakrender_manager_available() }, + 0 + ); +} + +/// A scratch error buffer helper. +fn err_buf() -> [c_char; 128] { + [0 as c_char; 128] +} + +// --------------------------------------------------------------------------- +// Frame accessors (7 exports) +// --------------------------------------------------------------------------- + +/// All frame accessors are NULL-safe and report zero/NULL (the engine +/// contract: NULL is a no-op yielding zero results). +#[test] +fn frame_accessors_null_safe() { + assert_eq!(unsafe { oakengine_frame_width(std::ptr::null()) }, 0); + assert_eq!(unsafe { oakengine_frame_height(std::ptr::null()) }, 0); + assert_eq!(unsafe { oakengine_frame_format(std::ptr::null()) }, 0); + assert_eq!( + unsafe { oakengine_frame_channel_count(std::ptr::null()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_frame_linesize_bytes(std::ptr::null()) }, + 0 + ); + assert!(unsafe { oakengine_frame_data(std::ptr::null()) }.is_null()); + unsafe { oakengine_frame_free(std::ptr::null_mut()) }; +} + +// --------------------------------------------------------------------------- +// Audio buffer accessors (5 exports, documented stubs) +// --------------------------------------------------------------------------- + +/// The audio buffer has no crate backing: every accessor reports the +/// documented neutral value for NULL (and any) handles. +#[test] +fn audio_buffer_stubs() { + let fake = 0x1 as *const OakEngineAudioBuffer; + assert_eq!(unsafe { oakengine_audio_sample_rate(std::ptr::null()) }, 0); + assert_eq!(unsafe { oakengine_audio_sample_rate(fake) }, 0); + assert_eq!( + unsafe { oakengine_audio_channel_count(std::ptr::null()) }, + 0 + ); + assert_eq!(unsafe { oakengine_audio_channel_count(fake) }, 0); + assert_eq!(unsafe { oakengine_audio_sample_count(std::ptr::null()) }, 0); + assert_eq!(unsafe { oakengine_audio_sample_count(fake) }, 0); + assert!(unsafe { oakengine_audio_data(std::ptr::null(), 0) }.is_null()); + assert!(unsafe { oakengine_audio_data(fake, 0) }.is_null()); + assert!(unsafe { oakengine_audio_data(fake, -1) }.is_null()); + assert!(unsafe { oakengine_audio_data(fake, 8) }.is_null()); + // The free is an empty no-op; NULL and garbage pointers are safe. + unsafe { oakengine_audio_free(std::ptr::null_mut()) }; + unsafe { oakengine_audio_free(fake as *mut OakEngineAudioBuffer) }; +} + +// --------------------------------------------------------------------------- +// Color management (20 exports) + config handle (5) + processor (6) +// --------------------------------------------------------------------------- + +/// The color family: documented stubs, the config-filename env-var paths, +/// the real OCIO-backed processor lifecycle, and the real-handle cache +/// setters. Runs under the state lock because the `$OCIO` env var, the +/// process-wide OCIO config and the render manager are global. +#[test] +fn color_family_lifecycle() { + common::force_link(); + let _g = state_lock(); + unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() }; // clean slate for the tail + + let mut buf = [0 as c_char; 256]; + + // ---- last_error: empty until a processor creation fails ---------- + assert_eq!( + unsafe { oakengine_color_last_error(buf.as_mut_ptr(), 64) }, + 0 + ); + assert_eq!( + unsafe { oakengine_color_last_error(std::ptr::null_mut(), 0) }, + 0 + ); + + // ---- color manager: documented stubs report their header values --- + assert!(unsafe { oakengine_color_manager_from_project(std::ptr::null_mut()) }.is_null()); + assert_eq!( + unsafe { + oakengine_color_manager_set_config_filename(std::ptr::null_mut(), std::ptr::null()) + }, + -3 + ); + assert_eq!( + unsafe { + oakengine_color_manager_set_config_filename(std::ptr::null_mut(), c"x.ocio".as_ptr()) + }, + -3 + ); + assert_eq!(unsafe { oakengine_color_manager_colorspace_count() }, -3); + assert_eq!(unsafe { oakengine_color_manager_display_count() }, -3); + assert_eq!(unsafe { oakengine_color_manager_look_count() }, -3); + assert_eq!( + unsafe { + oakengine_color_manager_colorspace_at(std::ptr::null(), 0, std::ptr::null_mut(), 0) + }, + -3 + ); + assert_eq!( + unsafe { oakengine_color_manager_display_at(std::ptr::null(), 0, std::ptr::null_mut(), 0) }, + -3 + ); + assert_eq!( + unsafe { oakengine_color_manager_view_count(std::ptr::null(), std::ptr::null()) }, + -1 + ); + assert_eq!( + unsafe { + oakengine_color_manager_view_at( + std::ptr::null(), + std::ptr::null(), + 0, + std::ptr::null_mut(), + 0, + ) + }, + -3 + ); + assert_eq!( + unsafe { oakengine_color_manager_look_at(std::ptr::null(), 0, std::ptr::null_mut(), 0) }, + -3 + ); + assert_eq!( + unsafe { + oakengine_color_manager_default_display(std::ptr::null(), std::ptr::null_mut(), 0) + }, + -3 + ); + assert_eq!( + unsafe { + oakengine_color_manager_default_view( + std::ptr::null(), + std::ptr::null(), + std::ptr::null_mut(), + 0, + ) + }, + -3 + ); + assert_eq!( + unsafe { + oakengine_color_manager_default_input_color_space( + std::ptr::null(), + std::ptr::null_mut(), + 0, + ) + }, + -3 + ); + assert_eq!( + unsafe { + oakengine_color_manager_set_default_input_color_space( + std::ptr::null_mut(), + std::ptr::null(), + ) + }, + -3 + ); + assert_eq!( + unsafe { + oakengine_color_manager_reference_color_space(std::ptr::null(), std::ptr::null_mut(), 0) + }, + -3 + ); + assert_eq!( + unsafe { + oakengine_color_manager_default_luma_coefs(std::ptr::null(), std::ptr::null_mut()) + }, + -3 + ); + let mut rgb = [0.0f64; 3]; + assert_eq!( + unsafe { oakengine_color_manager_default_luma_coefs(std::ptr::null(), rgb.as_mut_ptr()) }, + -3 + ); + assert_eq!( + unsafe { + oakengine_color_manager_compliant_color_space( + std::ptr::null(), + std::ptr::null(), + std::ptr::null_mut(), + 0, + ) + }, + -3 + ); + assert_eq!( + unsafe { + oakengine_color_manager_compliant_transform( + std::ptr::null(), + std::ptr::null(), + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + 0, + ) + }, + -3 + ); + + // ---- standalone config handle: documented stubs ------------------- + assert!(unsafe { oakengine_color_config_load_default() }.is_null()); + assert!(unsafe { oakengine_color_config_load_file(std::ptr::null()) }.is_null()); + assert!(unsafe { oakengine_color_config_load_file(c"no/such/config.ocio".as_ptr()) }.is_null()); + unsafe { oakengine_color_config_free(std::ptr::null_mut()) }; + assert_eq!( + unsafe { oakengine_color_config_colorspace_count(std::ptr::null()) }, + 0 + ); + assert_eq!( + unsafe { + oakengine_color_config_colorspace_at(std::ptr::null(), 0, std::ptr::null_mut(), 0) + }, + -3 + ); + + // ---- processor id / transform-job stubs -------------------------- + assert_eq!( + unsafe { oakengine_color_processor_id(std::ptr::null(), std::ptr::null_mut(), 0) }, + -3 + ); + assert_eq!( + unsafe { + oakengine_color_transform_job_set_processor(std::ptr::null_mut(), std::ptr::null()) + }, + -1 + ); + assert_eq!( + unsafe { + oakengine_color_transform_job_set_processor(0x1 as *mut c_void, std::ptr::null()) + }, + -3 + ); + + // ---- config-filename: no $OCIO and no default config yet → STATE -- + unsafe { std::env::remove_var("OCIO") }; + assert_eq!( + unsafe { + oakengine_color_manager_get_config_filename(std::ptr::null(), std::ptr::null_mut(), 0) + }, + -70002 + ); + + // ---- config-filename: $OCIO set → the getter reports the path ---- + // (the file itself is never opened by the getter). + let path = "/tmp/oakengine-it-render-ocio/config.ocio"; + unsafe { std::env::set_var("OCIO", path) }; + let rc = unsafe { + oakengine_color_manager_get_config_filename(std::ptr::null(), std::ptr::null_mut(), 0) + }; + assert_eq!(rc, path.len() as c_int); + let rc = unsafe { + oakengine_color_manager_get_config_filename(std::ptr::null(), buf.as_mut_ptr(), 256) + }; + assert_eq!(rc, path.len() as c_int); + assert_eq!(unsafe { read_buf(&mut buf) }, path); + // A too-small buffer still reports the full length (two-stage). + let mut small = [0 as c_char; 8]; + let rc = unsafe { + oakengine_color_manager_get_config_filename(std::ptr::null(), small.as_mut_ptr(), 8) + }; + assert_eq!(rc, path.len() as c_int); + + // ---- default config: real OCIO setup through the module C ABI ---- + unsafe { std::env::remove_var("OCIO") }; + let setup_rc = + unsafe { oakrender::ffi::color::oakrender_color_manager_set_up_default_config() }; + assert!( + setup_rc == 0 || setup_rc == -70003, + "set_up_default_config rc={setup_rc} (0 = bundled OCIO, -70003 = stub build)" + ); + let config_ok = setup_rc == 0; + if config_ok { + // The getter now reports the extracted config path (no $OCIO). + let rc = unsafe { + oakengine_color_manager_get_config_filename(std::ptr::null(), buf.as_mut_ptr(), 256) + }; + assert!(rc > 0); + let s = unsafe { read_buf(&mut buf) }; + assert!(s.ends_with("config.ocio"), "config path was {s:?}"); + } + + // ---- processor creation ------------------------------------------ + let dest = OakColorTransformPod { + is_display: 0, + output: c"ACEScg".as_ptr(), + view: std::ptr::null(), + look: std::ptr::null(), + }; + // NULL input / NULL dest → NULL. + assert!(unsafe { + oakengine_color_processor_create(std::ptr::null(), std::ptr::null(), std::ptr::null(), 0) + } + .is_null()); + assert!(unsafe { + oakengine_color_processor_create(std::ptr::null(), c"ACEScg".as_ptr(), std::ptr::null(), 0) + } + .is_null()); + // A NULL output name in the POD is substituted with an empty string by + // the facade (not rejected); it follows the config-dependent path below. + let dest_null_out = OakColorTransformPod { + is_display: 0, + output: std::ptr::null(), + view: std::ptr::null(), + look: std::ptr::null(), + }; + // Empty input string → NULL (module rejects empty names). + assert!( + unsafe { oakengine_color_processor_create(std::ptr::null(), c"".as_ptr(), &dest, 0) } + .is_null() + ); + // Garbage directions → NULL. + assert!(unsafe { + oakengine_color_processor_create(std::ptr::null(), c"ACEScg".as_ptr(), &dest, 7) + } + .is_null()); + assert!(unsafe { + oakengine_color_processor_create(std::ptr::null(), c"ACEScg".as_ptr(), &dest, -1) + } + .is_null()); + // The failed create set the thread-local color last_error. + let elen = unsafe { oakengine_color_last_error(buf.as_mut_ptr(), 256) }; + assert!( + elen > 0, + "failed processor create must set last_error (got {elen})" + ); + + // NULL is invalid / convert_color on a NULL processor → E_INVALID. + assert_eq!( + unsafe { oakengine_color_processor_is_valid(std::ptr::null()) }, + 0 + ); + let mut out_rgba = [0.0f64; 4]; + let in_rgba = [0.18f64, 0.18, 0.18, 1.0]; + assert_eq!( + unsafe { + oakengine_color_processor_convert_color( + std::ptr::null(), + in_rgba.as_ptr(), + out_rgba.as_mut_ptr(), + ) + }, + -1 + ); + unsafe { oakengine_color_processor_free(std::ptr::null_mut()) }; + + // Real processor (only when a config exists; the module returns NULL + // without one, which the engine contract allows). Capture the alive + // baseline before creating the first oakrender-owned handle. + let base = unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() }; + let proc = + unsafe { oakengine_color_processor_create(std::ptr::null(), c"ACEScg".as_ptr(), &dest, 0) }; + if config_ok { + assert!( + !proc.is_null(), + "a default config must yield a processor handle" + ); + assert_eq!( + unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() }, + base + 1 + ); + // A processor is either valid (real OCIO processor) or a + // documented pass-through (is_valid 0); both are legal outcomes. + let valid = unsafe { oakengine_color_processor_is_valid(proc) }; + assert!(valid == 0 || valid == 1); + // Direction INVERSE is a legal path too. + let proc_inv = unsafe { + oakengine_color_processor_create(std::ptr::null(), c"ACEScg".as_ptr(), &dest, 1) + }; + assert!(!proc_inv.is_null()); + // A NULL output name is substituted with an empty string by the + // facade and still creates a processor. + let proc_null_out = unsafe { + oakengine_color_processor_create( + std::ptr::null(), + c"ACEScg".as_ptr(), + &dest_null_out, + 0, + ) + }; + assert!(!proc_null_out.is_null()); + // A display-transform destination is accepted as well. + let dest_disp = OakColorTransformPod { + is_display: 1, + output: c"sRGB".as_ptr(), + view: c"Filmic".as_ptr(), + look: std::ptr::null(), + }; + let proc_disp = unsafe { + oakengine_color_processor_create(std::ptr::null(), c"ACEScg".as_ptr(), &dest_disp, 0) + }; + assert!(!proc_disp.is_null()); + + // convert_color: NULL in/out → E_INVALID; legal → OK. A + // pass-through processor copies the input; a valid one converts + // (outputs stay finite and alpha is preserved). + assert_eq!( + unsafe { + oakengine_color_processor_convert_color( + proc, + std::ptr::null(), + out_rgba.as_mut_ptr(), + ) + }, + -1 + ); + assert_eq!( + unsafe { + oakengine_color_processor_convert_color( + proc, + in_rgba.as_ptr(), + std::ptr::null_mut(), + ) + }, + -1 + ); + out_rgba = [-1.0; 4]; + assert_eq!( + unsafe { + oakengine_color_processor_convert_color( + proc, + in_rgba.as_ptr(), + out_rgba.as_mut_ptr(), + ) + }, + 0 + ); + if valid == 0 { + assert_eq!(out_rgba, in_rgba, "pass-through processor copies the input"); + } else { + assert!(out_rgba.iter().all(|v| v.is_finite())); + assert!((out_rgba[3] - 1.0).abs() < 1e-6, "alpha preserved"); + } + + // The successful create cleared last_error. + assert_eq!( + unsafe { oakengine_color_last_error(buf.as_mut_ptr(), 256) }, + 0 + ); + + // Free contracts: each free releases the oakrender handle (alive + // count back to the pre-create baseline). + unsafe { oakengine_color_processor_free(proc_disp) }; + unsafe { oakengine_color_processor_free(proc_inv) }; + unsafe { oakengine_color_processor_free(proc_null_out) }; + unsafe { oakengine_color_processor_free(proc) }; + assert_eq!( + unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() }, + base + ); + + // The transform-job stub returns E_FAILED for a real processor too. + assert_eq!( + unsafe { oakengine_color_transform_job_set_processor(0x1 as *mut c_void, proc.cast()) }, + -3 + ); + + // Real-handle cache setter paths (manager required; proc is dead + // here, so create a fresh one for the cacher). + let proc2 = unsafe { + oakengine_color_processor_create(std::ptr::null(), c"ACEScg".as_ptr(), &dest, 0) + }; + let node = unsafe { + oakengine_node_factory_create_from_id( + c"org.olivevideoeditor.Olive.solidgenerator".as_ptr(), + ) + }; + assert!(!node.is_null()); + assert_eq!( + unsafe { oakrender::ffi::manager::oakrender_manager_init() }, + 0 + ); + assert_eq!( + unsafe { oakengine_render_cache_set_display_color_processor(proc2.cast()) }, + 0 + ); + assert_eq!(unsafe { oakengine_render_cache_set_multicam_node(node) }, 0); + unsafe { oakrender::ffi::manager::oakrender_manager_shutdown() }; + unsafe { oakengine_color_processor_free(proc2) }; + unsafe { oakengine_node_free(node) }; + } else { + // Stub-OCIO build: creation is a clean NULL, nothing to free. + assert!(proc.is_null()); + assert_eq!( + unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() }, + 0 + ); + } +} + +// --------------------------------------------------------------------------- +// LUT library (5 exports, documented stubs) +// --------------------------------------------------------------------------- + +/// The LUT directory/file library is facade-level over FileFunctions and +/// has no module backing: the documented neutral values hold for every +/// argument (garbage indices, NULL buffers, NULL/NULL directory lists). +#[test] +fn lut_library_stubs() { + assert_eq!(unsafe { oakengine_lut_directory_count() }, 0); + assert_eq!( + unsafe { oakengine_lut_directory_at(0, std::ptr::null_mut(), 0) }, + -3 + ); + assert_eq!( + unsafe { oakengine_lut_directory_at(-1, std::ptr::null_mut(), 0) }, + -3 + ); + assert_eq!( + unsafe { oakengine_lut_directory_at(999, std::ptr::null_mut(), 0) }, + -3 + ); + assert_eq!(unsafe { oakengine_lut_file_count() }, 0); + assert_eq!( + unsafe { oakengine_lut_file_at(0, std::ptr::null_mut(), 0) }, + -3 + ); + assert_eq!( + unsafe { oakengine_lut_file_at(-1, std::ptr::null_mut(), 0) }, + -3 + ); + assert_eq!( + unsafe { oakengine_lut_set_directories(std::ptr::null(), 0) }, + -3 + ); + assert_eq!( + unsafe { oakengine_lut_set_directories(std::ptr::null(), 1) }, + -3 + ); + assert_eq!( + unsafe { oakengine_lut_set_directories(std::ptr::null(), -1) }, + -3 + ); + // A non-NULL list is never dereferenced by the stub. + let fake_dirs = 0x1 as *const *const c_char; + assert_eq!(unsafe { oakengine_lut_set_directories(fake_dirs, 1) }, -3); +} + +// --------------------------------------------------------------------------- +// oakrender debug counter + free/destroy contracts +// --------------------------------------------------------------------------- + +/// Module-level frame free contracts through the real oakrender C ABI: +/// free(NULL) no-op, alive count +1 on create and back to baseline on +/// free, and a double free is a safe no-op (the module nulls the handle +/// ctx after releasing). Facade box frees (`oakengine_frame_free` / +/// `oakengine_color_processor_free` / `oakengine_renderer_free`) are +/// NULL-safe and alive-balance-clean, verified in `renderer_render_frame_e2e` +/// and `color_family_lifecycle`. +#[test] +fn oakrender_debug_alive_and_double_free() { + common::force_link(); + let _g = state_lock(); + + let base = unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() }; + + // Create an oakrender-owned frame: +1. + let mut f = unsafe { oakrender::ffi::renderer::oakrender_codec_frame_create() }; + assert!(!f.is_null()); + assert_eq!( + unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() }, + base + 1 + ); + + // free(NULL) is a no-op. + unsafe { oakrender::ffi::renderer::oakrender_codec_frame_free(std::ptr::null_mut()) }; + assert_eq!( + unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() }, + base + 1 + ); + + // Free: back to baseline. + unsafe { oakrender::ffi::renderer::oakrender_codec_frame_free(&mut f) }; + assert_eq!( + unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() }, + base + ); + + // Double free: the handle ctx was nulled, so it is a safe no-op. + unsafe { oakrender::ffi::renderer::oakrender_codec_frame_free(&mut f) }; + assert_eq!( + unsafe { oakrender::ffi::cache::oakrender_debug_alive_count() }, + base + ); +} diff --git a/crates/oakengine/tests/it_task.rs b/crates/oakengine/tests/it_task.rs new file mode 100644 index 000000000..e65627c69 --- /dev/null +++ b/crates/oakengine/tests/it_task.rs @@ -0,0 +1,904 @@ +// 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 . + +//! Integration tests for the **task family** (`src/task.rs`, the +//! `oakengine_task_*` C ABI; module contract `include/task/*.h`). +//! +//! Coverage rules (see the family test charter): +//! 1. no mocks — every call goes through the real facade into the real +//! oaktask/oaknode/oakundo/oakcodec module crates (the only stubs are +//! the host-provided `oakcore_*`/`fb_*` symbols in `tests/common`, +//! the same mechanism the other family tests use); +//! 2. every one of the 27 `oakengine_task_*` / `oakengine_cli_task_*` +//! exports is exercised on a legal path with the result asserted; +//! 3. legal-input matrix (compression flags, url counts, indices, buffer +//! sizes) covers the meaningful combinations; +//! 4. illegal inputs (NULL, empty handles, out-of-range indices, zero / +//! negative sizes, garbage flag values, wrong-family handles) always +//! yield a negative error code or a documented NULL/0 no-op — never a +//! crash; +//! 5. free contracts: free(NULL) and free(empty-handle) are clean error +//! no-ops, and `oaktask_debug_alive_count()` returns to baseline. +//! +//! ## Serialization +//! +//! Two process-wide states force the tests into one thread: the facade's +//! lazily-created global task manager and the global undo stack +//! (`oakengine_project_new` clears it). Additionally the alive-count +//! assertions measure a process-wide module counter, so every test takes a +//! shared [`SERIAL`] mutex (the same pattern as the oaktask crate's own +//! `MANAGER_LOCK`). +//! +//! ## Ignored tests +//! +//! - [`export_task_run_ignored_environment_gated`]: running an export +//! needs a real GPU/OpenGL render and a real ffmpeg encoder; the host +//! stubs cannot encode. The creation path is covered in the main suite. +//! - [`import_run_crashes_engine_bug`]: **real engine bug reproduction** +//! (see its docs): running a single-file import task crashes with +//! SIGSEGV because the facade frees the borrowed project handle the +//! import task still holds. + +#[path = "common/mod.rs"] +mod common; + + +use std::ffi::{c_char, c_int}; +use std::sync::Mutex; + +use oakengine::codec::{oakengine_encoding_params_create, oakengine_encoding_params_set_filename}; +use oakengine::handle::{free_box, CHandle, OakEngineNode, OakEngineProject, OakEngineSequence, OakEngineTask}; +use oakengine::node::{ + oakengine_node_free, oakengine_project_create, oakengine_project_free, oakengine_project_new, + oakengine_project_root, oakengine_project_set_filename, +}; +use oakengine::task::{ + oakengine_cli_task_dialog_run, oakengine_task_cancel, oakengine_task_create_export, + oakengine_task_create_project_import, oakengine_task_create_project_load, + oakengine_task_create_project_load_otio, oakengine_task_create_project_save, + oakengine_task_create_project_save_otio, oakengine_task_create_proxy, oakengine_task_error, + oakengine_task_free, oakengine_task_import_file_count, oakengine_task_import_footage_at, + oakengine_task_import_footage_count, oakengine_task_import_get_command, + oakengine_task_import_invalid_file_at, oakengine_task_import_invalid_files_count, + oakengine_task_is_cancelled, oakengine_task_manager_add, oakengine_task_manager_cancel, + oakengine_task_manager_count, oakengine_task_manager_first, oakengine_task_manager_handle, + oakengine_task_save_get_project, oakengine_task_start_sync, oakengine_task_start_time, + oakengine_task_title, +}; +use oakengine::timeline::oakengine_sequence_new; +use oakengine::undo::oakengine_undo_command_free; + +/// OAKTASK module error codes that pass through untranslated. +const OAKTASK_E_INVALID: c_int = -80001; +const OAKTASK_E_STATE: c_int = -80002; +const OAKTASK_E_NOT_FOUND: c_int = -80004; + +/// Serializes every test in this binary (see the module docs). Poisoned by a +/// panicking test, the lock is recovered with `into_inner` so one failure +/// does not cascade into `PoisonError` failures in every later test. +static SERIAL: Mutex<()> = Mutex::new(()); + +/// Take the [`SERIAL`] lock, recovering from any poisoning. +fn serial() -> std::sync::MutexGuard<'static, ()> { + SERIAL.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// The oaktask module's debug alive counter (not re-exported by the +/// facade; the module crate is a real dependency of the test binary). +fn alive_count() -> c_int { + unsafe { oaktask::ffi::task::oaktask_debug_alive_count() } +} + +/// Read a NUL-terminated two-stage buffer as a Rust `String`. +fn read_buf(buf: &[c_char]) -> String { + let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) }) + .into_owned() +} + +/// A facade task box wrapping an EMPTY module handle (`ctx == NULL`), the +/// "empty handle" state the C contract documents as invalid input. +fn empty_task_box() -> *mut OakEngineTask { + Box::into_raw(Box::new(OakEngineTask { handle: CHandle::null() })) +} + +/// Reclaim a facade box that `oakengine_task_free` refused to consume +/// (NULL/empty handles are errors, so the box stays allocated). +/// +/// # Safety +/// `ptr` must be a box produced by [`empty_task_box`] that was never freed. +unsafe fn reclaim_empty_task_box(ptr: *mut OakEngineTask) { + unsafe { drop(Box::from_raw(ptr)) }; +} + +// --------------------------------------------------------------------------- +// NULL / empty-handle rejection (all 27 exports) +// --------------------------------------------------------------------------- + +/// Every accessor rejects a NULL task with OAKENGINE_E_INVALID (-1) and +/// every pointer accessor returns NULL; creators return NULL for NULL / +/// invalid arguments; the CLI dialog returns 0 (the capi's "no task" is +/// not an error). +#[test] +fn null_handles_are_rejected() { + let _g = serial(); + common::force_link(); + + let mut buf = [0 as c_char; 256]; + + // ---- manager family ----------------------------------------------------- + assert_eq!(unsafe { oakengine_task_manager_add(std::ptr::null_mut()) }, -1); + assert_eq!(unsafe { oakengine_task_manager_cancel(std::ptr::null_mut()) }, -1); + + // ---- task accessors ----------------------------------------------------- + assert_eq!(unsafe { oakengine_task_title(std::ptr::null_mut(), buf.as_mut_ptr(), 256) }, -1); + assert_eq!(unsafe { oakengine_task_error(std::ptr::null_mut(), buf.as_mut_ptr(), 256) }, -1); + assert_eq!(unsafe { oakengine_task_start_time(std::ptr::null_mut()) }, -1); + assert_eq!(unsafe { oakengine_task_is_cancelled(std::ptr::null_mut()) }, -1); + assert_eq!(unsafe { oakengine_task_cancel(std::ptr::null_mut()) }, -1); + assert_eq!(unsafe { oakengine_task_start_sync(std::ptr::null_mut()) }, -1); + assert_eq!(unsafe { oakengine_task_free(std::ptr::null_mut()) }, -1); + + // ---- import / save result accessors ------------------------------------ + assert_eq!(unsafe { oakengine_task_import_file_count(std::ptr::null_mut()) }, -1); + assert!(unsafe { oakengine_task_import_get_command(std::ptr::null_mut()) }.is_null()); + assert_eq!(unsafe { oakengine_task_import_footage_count(std::ptr::null_mut()) }, -1); + assert!(unsafe { oakengine_task_import_footage_at(std::ptr::null_mut(), 0) }.is_null()); + assert_eq!( + unsafe { oakengine_task_import_invalid_files_count(std::ptr::null_mut()) }, + -1 + ); + assert_eq!( + unsafe { oakengine_task_import_invalid_file_at(std::ptr::null_mut(), 0, buf.as_mut_ptr(), 256) }, + -1 + ); + assert!(unsafe { oakengine_task_save_get_project(std::ptr::null_mut()) }.is_null()); + + // ---- creators ------------------------------------------------------------ + assert!(unsafe { oakengine_task_create_project_load(std::ptr::null()) }.is_null()); + assert!(unsafe { oakengine_task_create_project_load_otio(std::ptr::null()) }.is_null()); + assert!(unsafe { + oakengine_task_create_project_save(std::ptr::null_mut(), 0, std::ptr::null(), std::ptr::null()) + } + .is_null()); + assert!(unsafe { oakengine_task_create_project_save_otio(std::ptr::null_mut()) }.is_null()); + assert!(unsafe { oakengine_task_create_project_import(std::ptr::null_mut(), std::ptr::null(), 0) } + .is_null()); + assert!(unsafe { oakengine_task_create_proxy(std::ptr::null_mut()) }.is_null()); + assert!(unsafe { oakengine_task_create_export(std::ptr::null_mut(), std::ptr::null_mut()) } + .is_null()); + + // ---- CLI dialog (0, not E_INVALID, for NULL) ---------------------------- + assert_eq!( + unsafe { oakengine_cli_task_dialog_run(std::ptr::null_mut(), std::ptr::null_mut()) }, + 0 + ); + + // The manager handle is lazily created and never NULL (documented). + assert!(!oakengine_task_manager_handle().is_null()); + // Finished tasks can linger in the process-wide manager queue (it has no + // delete-finished export and other tests in this binary add to it), so + // only assert that NULL-handle manager operations change nothing. + let count = oakengine_task_manager_count(); + assert_eq!(unsafe { oakengine_task_manager_add(std::ptr::null_mut()) }, -1); + assert_eq!(unsafe { oakengine_task_manager_cancel(std::ptr::null_mut()) }, -1); + assert_eq!(oakengine_task_manager_count(), count); +} + +/// Empty handles (`ctx == NULL` boxes) are rejected exactly like NULL: +/// -1 / NULL from every accessor and creator, and `oakengine_task_free` +/// reports E_INVALID without consuming the box. +#[test] +fn empty_handles_are_rejected() { + let _g = serial(); + common::force_link(); + + let mut buf = [0 as c_char; 256]; + + // ---- task accessors on an empty-handle box ------------------------------ + let t = empty_task_box(); + assert_eq!(unsafe { oakengine_task_title(t, buf.as_mut_ptr(), 256) }, -1); + assert_eq!(unsafe { oakengine_task_error(t, buf.as_mut_ptr(), 256) }, -1); + assert_eq!(unsafe { oakengine_task_start_time(t) }, -1); + assert_eq!(unsafe { oakengine_task_is_cancelled(t) }, -1); + assert_eq!(unsafe { oakengine_task_cancel(t) }, -1); + assert_eq!(unsafe { oakengine_task_start_sync(t) }, -1); + assert_eq!(unsafe { oakengine_task_manager_add(t) }, -1); + assert_eq!(unsafe { oakengine_task_manager_cancel(t) }, -1); + assert_eq!(unsafe { oakengine_task_import_file_count(t) }, -1); + assert!(unsafe { oakengine_task_import_get_command(t) }.is_null()); + assert_eq!(unsafe { oakengine_task_import_footage_count(t) }, -1); + assert!(unsafe { oakengine_task_import_footage_at(t, 0) }.is_null()); + assert_eq!(unsafe { oakengine_task_import_invalid_files_count(t) }, -1); + assert_eq!( + unsafe { oakengine_task_import_invalid_file_at(t, 0, buf.as_mut_ptr(), 256) }, + -1 + ); + assert!(unsafe { oakengine_task_save_get_project(t) }.is_null()); + + // free refuses the empty handle with E_INVALID and leaves the box + // allocated (the caller still owns it). + assert_eq!(unsafe { oakengine_task_free(t) }, -1); + unsafe { reclaim_empty_task_box(t) }; + + // ---- creators with empty project / node / sequence handles -------------- + let empty_project = Box::into_raw(Box::new(OakEngineProject { handle: CHandle::null() })); + assert!(unsafe { + oakengine_task_create_project_save(empty_project, 0, std::ptr::null(), std::ptr::null()) + } + .is_null()); + assert!(unsafe { oakengine_task_create_project_save_otio(empty_project) }.is_null()); + unsafe { drop(Box::from_raw(empty_project)) }; + + let empty_node = Box::into_raw(Box::new(OakEngineNode { handle: CHandle::null() })); + assert!(unsafe { + oakengine_task_create_project_import(empty_node, std::ptr::null(), 0) + } + .is_null()); + assert!(unsafe { oakengine_task_create_proxy(empty_node) }.is_null()); + unsafe { drop(Box::from_raw(empty_node)) }; + + let empty_seq = Box::into_raw(Box::new(OakEngineSequence { handle: CHandle::null() })); + let params = oakengine_encoding_params_create(); + assert!(!params.is_null()); + // NULL result: the params handle is NOT consumed, so we own it still. + assert!(unsafe { oakengine_task_create_export(empty_seq, params) }.is_null()); + unsafe { oakengine::codec::oakengine_encoding_params_destroy(params) }; + unsafe { drop(Box::from_raw(empty_seq)) }; +} + +// --------------------------------------------------------------------------- +// Task lifecycle: load tasks (no project, no manager state) +// --------------------------------------------------------------------------- + +/// A project-load task with a bad filename: created, has the "Loading" +/// title, fails synchronously (0), reports a non-empty error, exposes the +/// facade-side start stamp after starting, and round-trips the cancel flag. +#[test] +fn load_task_lifecycle() { + let _g = serial(); + common::force_link(); + + let task = unsafe { oakengine_task_create_project_load(c"/no/such/oak/project.ove".as_ptr()) }; + assert!(!task.is_null()); + + let mut buf = [0 as c_char; 256]; + let len = unsafe { oakengine_task_title(task, buf.as_mut_ptr(), 256) }; + assert!(len > 0); + assert!(read_buf(&mut buf).contains("Loading")); + + // A task that never ran has no start stamp and is not cancelled. + assert_eq!(unsafe { oakengine_task_start_time(task) }, 0); + assert_eq!(unsafe { oakengine_task_is_cancelled(task) }, 0); + + // The sync run fails (file does not exist). + assert_eq!(unsafe { oakengine_task_start_sync(task) }, 0); + assert_ne!(unsafe { oakengine_task_start_time(task) }, 0); + assert_eq!(unsafe { oakengine_task_is_cancelled(task) }, 0); + + // The error string is populated by the failed run. + let elen = unsafe { oakengine_task_error(task, buf.as_mut_ptr(), 256) }; + assert!(elen > 0); + assert!(!read_buf(&mut buf).is_empty()); + + // Cancel round-trip through the facade flag (module cancel succeeds). + assert_eq!(unsafe { oakengine_task_cancel(task) }, 0); + assert_eq!(unsafe { oakengine_task_is_cancelled(task) }, 1); + + // Re-running after a failed sync run is a legal no-op (fails again). + assert_eq!(unsafe { oakengine_task_start_sync(task) }, 0); + + assert_eq!(unsafe { oakengine_task_free(task) }, 0); +} + +/// The empty filename is legal input: a task is created with an empty +/// title suffix and fails when run (no such file). +#[test] +fn load_task_empty_filename() { + let _g = serial(); + common::force_link(); + + let task = unsafe { oakengine_task_create_project_load(c"".as_ptr()) }; + assert!(!task.is_null()); + + let mut buf = [0 as c_char; 256]; + let len = unsafe { oakengine_task_title(task, buf.as_mut_ptr(), 256) }; + assert_eq!(len, 10); + assert_eq!(read_buf(&mut buf), "Loading ''"); + + assert_eq!(unsafe { oakengine_task_start_sync(task) }, 0); + + assert_eq!(unsafe { oakengine_task_free(task) }, 0); +} + +/// The OTIO load task: created for a valid filename, fails synchronously +/// (the document does not exist) and reports the load error. +#[test] +fn load_otio_task_lifecycle() { + let _g = serial(); + common::force_link(); + + let task = unsafe { oakengine_task_create_project_load_otio(c"/no/such/oak/project.otio".as_ptr()) }; + assert!(!task.is_null()); + + let mut buf = [0 as c_char; 256]; + let len = unsafe { oakengine_task_title(task, buf.as_mut_ptr(), 256) }; + assert!(len > 0); + assert!(read_buf(&mut buf).contains("Loading")); + + // Missing document -> failed run. + assert_eq!(unsafe { oakengine_task_start_sync(task) }, 0); + let elen = unsafe { oakengine_task_error(task, buf.as_mut_ptr(), 256) }; + assert!(elen > 0); + assert!(!read_buf(&mut buf).is_empty()); + + // An unknown extension is also a clean failure (format dispatch error). + let task2 = unsafe { oakengine_task_create_project_load_otio(c"/no/such/oak/project.xyz".as_ptr()) }; + assert!(!task2.is_null()); + assert_eq!(unsafe { oakengine_task_start_sync(task2) }, 0); + + assert_eq!(unsafe { oakengine_task_free(task2) }, 0); + assert_eq!(unsafe { oakengine_task_free(task) }, 0); +} + +/// Two-stage string getters across the buffer-size matrix: a too-small or +/// NULL buffer still reports the length, an exact/large buffer also gets +/// the NUL-terminated content. +#[test] +fn string_getters_buffer_matrix() { + let _g = serial(); + common::force_link(); + + let task = unsafe { oakengine_task_create_project_load(c"/no/such/oak/it_task_buf.ove".as_ptr()) }; + assert!(!task.is_null()); + + // Title length (facade convention: excludes the NUL). + let expected = unsafe { oakengine_task_title(task, std::ptr::null_mut(), 0) }; + let expected_usize = expected as usize; + assert!(expected > 0); + + // NULL buffer with a positive size still reports the length. + assert_eq!( + unsafe { oakengine_task_title(task, std::ptr::null_mut(), 256) }, + expected + ); + // A negative size is a documented no-op size query, not an error. + assert_eq!( + unsafe { oakengine_task_title(task, std::ptr::null_mut(), -5) }, + expected + ); + + // Too-small buffer: length reported, nothing written (module writes only + // when the buffer fits the string plus its NUL). + let mut small = [0 as c_char; 4]; + assert_eq!(unsafe { oakengine_task_title(task, small.as_mut_ptr(), 4) }, expected); + assert_eq!(small[0], 0); + + // Exact string length but no NUL room: still nothing written. + let mut exact_no_nul = vec![0 as c_char; expected_usize]; + assert_eq!( + unsafe { oakengine_task_title(task, exact_no_nul.as_mut_ptr(), expected) }, + expected + ); + assert_eq!(exact_no_nul[0], 0); + + // Exact length + NUL room: content is written. + let mut exact = vec![0 as c_char; expected_usize + 1]; + assert_eq!( + unsafe { oakengine_task_title(task, exact.as_mut_ptr(), expected + 1) }, + expected + ); + assert_eq!(read_buf(&exact), format!("Loading '/no/such/oak/it_task_buf.ove'")); + assert_eq!(exact[expected_usize], 0); + + // Large buffer: same content. + let mut big = [0 as c_char; 512]; + assert_eq!(unsafe { oakengine_task_title(task, big.as_mut_ptr(), 512) }, expected); + assert_eq!(read_buf(&big), format!("Loading '/no/such/oak/it_task_buf.ove'")); + + // A task that never ran reports "Unknown error" (module fallback), the + // same two-stage contract. + let mut err_buf = [0 as c_char; 64]; + assert_eq!(unsafe { oakengine_task_error(task, err_buf.as_mut_ptr(), 64) }, 13); + assert_eq!(read_buf(&err_buf), "Unknown error"); + assert_eq!(unsafe { oakengine_task_error(task, std::ptr::null_mut(), 0) }, 13); + + assert_eq!(unsafe { oakengine_task_free(task) }, 0); +} + +/// Wrong-family handles: the import/save result accessors on a plain load +/// task return the module's clean negative codes / NULL — plugins may pass +/// any task handle. +#[test] +fn import_save_accessors_on_wrong_family_task() { + let _g = serial(); + common::force_link(); + + let task = unsafe { oakengine_task_create_project_load(c"/no/such/oak/project.ove".as_ptr()) }; + assert!(!task.is_null()); + + assert_eq!(unsafe { oakengine_task_import_file_count(task) }, OAKTASK_E_INVALID); + assert_eq!(unsafe { oakengine_task_import_footage_count(task) }, OAKTASK_E_INVALID); + assert_eq!(unsafe { oakengine_task_import_invalid_files_count(task) }, OAKTASK_E_INVALID); + assert!(unsafe { oakengine_task_import_get_command(task) }.is_null()); + assert!(unsafe { oakengine_task_import_footage_at(task, 0) }.is_null()); + let mut buf = [0 as c_char; 256]; + assert_eq!( + unsafe { oakengine_task_import_invalid_file_at(task, 0, buf.as_mut_ptr(), 256) }, + OAKTASK_E_INVALID + ); + assert!(unsafe { oakengine_task_save_get_project(task) }.is_null()); + + assert_eq!(unsafe { oakengine_task_free(task) }, 0); +} + +/// The CLI modal dialog is a sync-run wrapper: 0 for a failing task, 0 for +/// NULL, and it marks the task started. +#[test] +fn cli_dialog_runs_task_sync() { + let _g = serial(); + common::force_link(); + + let task = unsafe { oakengine_task_create_project_load(c"/no/such/oak/project.ove".as_ptr()) }; + assert!(!task.is_null()); + assert_eq!( + unsafe { oakengine_cli_task_dialog_run(task, std::ptr::null_mut()) }, + 0 + ); + assert_ne!(unsafe { oakengine_task_start_time(task) }, 0); + assert_eq!(unsafe { oakengine_task_free(task) }, 0); +} + +// --------------------------------------------------------------------------- +// Free contracts and the module alive counter +// --------------------------------------------------------------------------- + +/// `oakengine_task_free`: NULL and empty handles are clean E_INVALID +/// no-ops, a real task frees cleanly, and the module's alive counter +/// returns to baseline after every create/free round trip. +#[test] +fn free_contracts_and_alive_count() { + let _g = serial(); + common::force_link(); + + // NULL and empty-handle free are safe error no-ops. + assert_eq!(unsafe { oakengine_task_free(std::ptr::null_mut()) }, -1); + let t = empty_task_box(); + assert_eq!(unsafe { oakengine_task_free(t) }, -1); + unsafe { reclaim_empty_task_box(t) }; + + // A real create/free round trip keeps the alive counter at baseline. + // NOTE: an actual double-free of the same facade box is out of contract + // at the C ABI level (the box is destroyed on the first free, so a + // second free is use-after-free by design); the safe double-free surface + // is NULL/empty, covered above. + let baseline = alive_count(); + let task = unsafe { oakengine_task_create_project_load(c"/no/such/oak/project.ove".as_ptr()) }; + assert_eq!(alive_count(), baseline + 1); + assert_eq!(unsafe { oakengine_task_free(task) }, 0); + assert_eq!(alive_count(), baseline); + + // A task that ran and was cancelled still accounts back to baseline. + let task2 = unsafe { oakengine_task_create_project_load(c"/no/such/oak/project.ove".as_ptr()) }; + assert_eq!(alive_count(), baseline + 1); + assert_eq!(unsafe { oakengine_task_start_sync(task2) }, 0); + assert_eq!(unsafe { oakengine_task_cancel(task2) }, 0); + assert_eq!(unsafe { oakengine_task_free(task2) }, 0); + assert_eq!(alive_count(), baseline); +} + +// --------------------------------------------------------------------------- +// Project-backed tasks (serialized: `oakengine_project_new` clears the +// process-wide undo stack; the task manager is also process-wide) +// --------------------------------------------------------------------------- + +/// Save tasks across the compression matrix (0, 1, and garbage flag +/// values), the no-filename failure path, `save_get_project`, save-otio +/// creation, and the alive-count accounting of a save task's borrowed +/// project. +#[test] +fn save_task_matrix() { + let _g = serial(); + common::force_link(); + + let project = oakengine_project_create(); + assert!(!project.is_null()); + assert_eq!(unsafe { oakengine_project_new(project) }, 0); + + let save_path = std::env::temp_dir().join("oakengine_it_task_save.ovexml"); + let save_c = std::ffi::CString::new(save_path.to_str().unwrap()).unwrap(); + let _ = std::fs::remove_file(&save_path); + + // ---- compression 0 and 1 (and garbage flag values -> treated as true) + let baseline = alive_count(); + for compression in [0, 1, 2, -1] { + let task = unsafe { + oakengine_task_create_project_save(project, compression, save_c.as_ptr(), std::ptr::null()) + }; + assert!(!task.is_null(), "save with use_compression={compression}"); + + let mut buf = [0 as c_char; 256]; + let len = unsafe { oakengine_task_title(task, buf.as_mut_ptr(), 256) }; + assert!(len > 0); + assert!(read_buf(&mut buf).contains("Saving")); + + // Compression is a boolean in the module; every non-zero value is + // "compressed", and the run still succeeds. + assert_eq!(unsafe { oakengine_task_start_sync(task) }, 1); + assert!(save_path.exists(), "compression={compression} must write the file"); + assert_ne!(unsafe { oakengine_task_start_time(task) }, 0); + + unsafe { oakengine_task_free(task) }; + } + assert_eq!(alive_count(), baseline); + let _ = std::fs::remove_file(&save_path); + + // ---- save_get_project: a borrowed project handle per call ---------------- + let task = unsafe { + oakengine_task_create_project_save(project, 0, save_c.as_ptr(), std::ptr::null()) + }; + assert!(!task.is_null()); + let saved = unsafe { oakengine_task_save_get_project(task) }; + assert!(!saved.is_null()); + unsafe { oakengine_project_free(saved) }; + // Every call returns a fresh borrowed handle. + let saved2 = unsafe { oakengine_task_save_get_project(task) }; + assert!(!saved2.is_null()); + unsafe { oakengine_project_free(saved2) }; + unsafe { oakengine_task_free(task) }; + + // ---- no filename: save with override NULL fails cleanly ------------------ + let task = unsafe { oakengine_task_create_project_save(project, 0, std::ptr::null(), std::ptr::null()) }; + assert!(!task.is_null()); + assert_eq!(unsafe { oakengine_task_start_sync(task) }, 0); + let mut buf = [0 as c_char; 256]; + let elen = unsafe { oakengine_task_error(task, buf.as_mut_ptr(), 256) }; + assert!(elen > 0); + assert!(read_buf(&mut buf).contains("filename")); + unsafe { oakengine_task_free(task) }; + + // ---- save-otio: NULL without a project filename, real task with one ------ + assert!(unsafe { oakengine_task_create_project_save_otio(project) }.is_null()); + assert_eq!( + unsafe { oakengine_project_set_filename(project, c"/tmp/oakengine_it_task_otio.otio".as_ptr()) }, + 0 + ); + let otio_task = unsafe { oakengine_task_create_project_save_otio(project) }; + assert!(!otio_task.is_null()); + let mut buf = [0 as c_char; 256]; + let len = unsafe { oakengine_task_title(otio_task, buf.as_mut_ptr(), 256) }; + assert!(len > 0); + assert!(read_buf(&mut buf).contains("Saving")); + unsafe { oakengine_task_free(otio_task) }; + + unsafe { oakengine_project_free(project) }; + let _ = std::fs::remove_file(&save_path); +} + +/// Import task creation against a real project and a real (non-decodable) +/// file, plus the zero-file run that does not touch the (dangling — +/// see [`import_run_crashes_engine_bug`]) borrowed project handle. +/// +/// A single-file import task is created, reports the documented pre-run +/// accessor states (empty footage / invalid lists, out-of-range codes, +/// no command yet), and frees cleanly. The zero-file task runs end to end +/// (nothing to import) and hands out its (empty) undo command. +#[test] +fn import_flow_with_real_file() { + let _g = serial(); + common::force_link(); + + let project = oakengine_project_create(); + assert!(!project.is_null()); + assert_eq!(unsafe { oakengine_project_new(project) }, 0); + let root = unsafe { oakengine_project_root(project) }; + assert!(!root.is_null()); + + // A real file that cannot be decoded in the test environment (the + // host-provided `fb_*` symbols are the common stubs; footage probing + // never succeeds there, so a run would mark the file invalid). + let media = std::env::temp_dir().join("oakengine_it_task_import_batch.tmp"); + std::fs::write(&media, b"not media").unwrap(); + let media_c = std::ffi::CString::new(media.to_str().unwrap()).unwrap(); + + // ---- single-file import: creation + pre-run accessors -------------------- + let urls = [media_c.as_ptr()]; + let task = unsafe { oakengine_task_create_project_import(root, urls.as_ptr(), 1) }; + assert!(!task.is_null()); + + // Before the run the imported-footage list is empty, so both count + // exports report 0 (the facade maps `import_file_count` to the module's + // footage count; documented deviation from the construction-time count). + assert_eq!(unsafe { oakengine_task_import_file_count(task) }, 0); + assert_eq!(unsafe { oakengine_task_import_footage_count(task) }, 0); + assert_eq!(unsafe { oakengine_task_import_invalid_files_count(task) }, 0); + + let mut buf = [0 as c_char; 256]; + let len = unsafe { oakengine_task_title(task, buf.as_mut_ptr(), 256) }; + assert_eq!(len, 19); + assert_eq!(read_buf(&mut buf), "Importing 1 file(s)"); + + // Pre-run: nothing imported, no invalid entries, no command yet; every + // index accessor reports the documented empty/out-of-range state. + assert!(unsafe { oakengine_task_import_footage_at(task, 0) }.is_null()); + assert!(unsafe { oakengine_task_import_footage_at(task, -1) }.is_null()); + assert!(unsafe { oakengine_task_import_footage_at(task, 7) }.is_null()); + assert_eq!( + unsafe { oakengine_task_import_invalid_file_at(task, 0, buf.as_mut_ptr(), 256) }, + OAKTASK_E_NOT_FOUND + ); + assert_eq!( + unsafe { oakengine_task_import_invalid_file_at(task, -1, buf.as_mut_ptr(), 256) }, + OAKTASK_E_NOT_FOUND + ); + assert!(unsafe { oakengine_task_import_get_command(task) }.is_null()); + + assert_eq!(unsafe { oakengine_task_free(task) }, 0); + + // ---- zero-file import: runs without touching the project handle ----------- + let zero = unsafe { oakengine_task_create_project_import(root, std::ptr::null(), 0) }; + assert!(!zero.is_null()); + assert_eq!(unsafe { oakengine_task_import_file_count(zero) }, 0); + assert_eq!(unsafe { oakengine_task_import_footage_count(zero) }, 0); + assert_eq!(unsafe { oakengine_task_import_invalid_files_count(zero) }, 0); + + // "Nothing to import" still counts as a successful run: the run creates + // the (empty) multi undo command and returns OK. + assert_eq!(unsafe { oakengine_task_start_sync(zero) }, 1); + assert_eq!(unsafe { oakengine_task_import_footage_count(zero) }, 0); + assert_eq!(unsafe { oakengine_task_import_invalid_files_count(zero) }, 0); + let cmd = unsafe { oakengine_task_import_get_command(zero) }; + assert!(!cmd.is_null()); + unsafe { oakengine_undo_command_free(cmd) }; + assert!(unsafe { oakengine_task_import_get_command(zero) }.is_null()); + assert_eq!(unsafe { oakengine_task_free(zero) }, 0); + + // ---- illegal url arrays --------------------------------------------------- + // Negative count -> NULL. + assert!(unsafe { oakengine_task_create_project_import(root, std::ptr::null(), -1) }.is_null()); + // Non-NULL urls with a count but a NULL entry inside -> NULL. + let bad_urls = [std::ptr::null()]; + assert!(unsafe { oakengine_task_create_project_import(root, bad_urls.as_ptr(), 1) }.is_null()); + // NULL urls with a positive count -> NULL. + assert!(unsafe { oakengine_task_create_project_import(root, std::ptr::null(), 1) }.is_null()); + + unsafe { oakengine_node_free(root) }; + unsafe { oakengine_project_free(project) }; + let _ = std::fs::remove_file(&media); +} + +/// **Real engine bug — minimal reproduction.** +/// +/// Running a single-file import task crashes with SIGSEGV. The facade's +/// `oakengine_task_create_project_import` (`src/task.rs`) hands the +/// borrowed project handle (from `oaknode_node_get_project`) to +/// `oaktask_create_project_import`, which stores it WITHOUT addref, and +/// then immediately calls `oaknode_project_free` on it: the shared +/// `RefBox` refcount goes 1→0 and the box is freed while the task's copy +/// still references it. The first thing the run does is +/// `oaknode_footage_create(task.project, …)` → `project_arc()` reads the +/// freed `RefBox` and clones the garbage `Arc` → `atomic_add` +/// on a non-heap address → EXC_BAD_ACCESS. +/// +/// Verified under lldb: the project handle's ctx (`0x1043cf4d0` in the +/// traced run) had been reused by the allocator and contained a CHandle +/// whose `addref` slot was the address of `oaktask::handle::owned_addref` +/// — the exact address the crashing `atomic_add` targeted. +/// +/// The save creator is NOT affected: it addrefs the project +/// (`meta.save_project = Some(ph.addref())`). +#[test] +#[ignore = "ENGINE BUG: import run SIGSEGVs — facade frees the borrowed project handle the task still holds (src/task.rs oakengine_task_create_project_import)"] +fn import_run_crashes_engine_bug() { + let _g = serial(); + common::force_link(); + + let project = oakengine_project_create(); + assert!(!project.is_null()); + assert_eq!(unsafe { oakengine_project_new(project) }, 0); + let root = unsafe { oakengine_project_root(project) }; + assert!(!root.is_null()); + + let media = std::env::temp_dir().join("oakengine_it_task_import_batch.tmp"); + std::fs::write(&media, b"not media").unwrap(); + let media_c = std::ffi::CString::new(media.to_str().unwrap()).unwrap(); + + // Creation succeeds; the run below is expected to succeed (1) and record + // the undecodable file as invalid — instead it reads the dangling + // project handle and crashes the process. + let urls = [media_c.as_ptr()]; + let task = unsafe { oakengine_task_create_project_import(root, urls.as_ptr(), 1) }; + assert!(!task.is_null()); + assert_eq!(unsafe { oakengine_task_start_sync(task) }, 1); + + assert_eq!(unsafe { oakengine_task_import_invalid_files_count(task) }, 1); + assert_eq!(unsafe { oakengine_task_free(task) }, 0); + + unsafe { oakengine_node_free(root) }; + unsafe { oakengine_project_free(project) }; + let _ = std::fs::remove_file(&media); +} + +/// Export task creation against a real sequence and encoding params: the +/// task is created (the color manager is derived from the sequence's +/// project), titled, and freed; the params handle's ownership transfers to +/// the task. +#[test] +fn export_task_creation() { + let _g = serial(); + common::force_link(); + + let project = oakengine_project_create(); + assert!(!project.is_null()); + assert_eq!(unsafe { oakengine_project_new(project) }, 0); + let seq = unsafe { oakengine_sequence_new(project, c"Export Seq".as_ptr()) }; + assert!(!seq.is_null()); + + // Minimal legal params: a fresh handle with a filename. The export task + // takes ownership of the params box (destroyed at task free). + let params = oakengine_encoding_params_create(); + assert!(!params.is_null()); + assert_eq!( + unsafe { oakengine_encoding_params_set_filename(params, c"/tmp/oakengine_it_task_export.mov".as_ptr()) }, + 0 + ); + + let task = unsafe { oakengine_task_create_export(seq, params) }; + assert!(!task.is_null(), "export creation must succeed without GPU (creation only)"); + + let mut buf = [0 as c_char; 256]; + let len = unsafe { oakengine_task_title(task, buf.as_mut_ptr(), 256) }; + assert!(len > 0); + assert!(read_buf(&mut buf).contains("Exporting")); + + // NULL / empty inputs: clean NULL, and the params handle stays owned by + // the caller on the rejected path. + assert!(unsafe { oakengine_task_create_export(std::ptr::null_mut(), params) }.is_null()); + let params2 = oakengine_encoding_params_create(); + assert!(unsafe { oakengine_task_create_export(seq, std::ptr::null_mut()) }.is_null()); + let empty_seq = Box::into_raw(Box::new(OakEngineSequence { handle: CHandle::null() })); + assert!(unsafe { oakengine_task_create_export(empty_seq, params2) }.is_null()); + unsafe { oakengine::codec::oakengine_encoding_params_destroy(params2) }; + unsafe { drop(Box::from_raw(empty_seq)) }; + + assert_eq!(unsafe { oakengine_task_free(task) }, 0); + + // Release the sequence's borrowed facade box (release only frees the box). + unsafe { free_box::(seq) }; + unsafe { oakengine_project_free(project) }; +} + +/// Running an export requires a real GPU/OpenGL render and a real encoder; +/// the test environment's host stubs cannot decode or encode media. The +/// creation path above covers the legal input surface; the run itself is +/// documented as environment-gated. +#[test] +#[ignore = "export run needs GPU/OpenGL rendering and a real ffmpeg encoder; host stubs cannot encode"] +fn export_task_run_ignored_environment_gated() { + let _g = serial(); + common::force_link(); + + let project = oakengine_project_create(); + assert!(!project.is_null()); + assert_eq!(unsafe { oakengine_project_new(project) }, 0); + let seq = unsafe { oakengine_sequence_new(project, c"Export Run".as_ptr()) }; + assert!(!seq.is_null()); + + let params = oakengine_encoding_params_create(); + assert_eq!( + unsafe { oakengine_encoding_params_set_filename(params, c"/tmp/oakengine_it_task_export_run.mov".as_ptr()) }, + 0 + ); + let task = unsafe { oakengine_task_create_export(seq, params) }; + assert!(!task.is_null()); + assert_eq!(unsafe { oakengine_task_start_sync(task) }, 1); + unsafe { oakengine_task_free(task) }; + + unsafe { free_box::(seq) }; + unsafe { oakengine_project_free(project) }; +} + +/// The proxy creator is a documented stub (the oaktask module has no +/// proxy-task factory on its C ABI): NULL for every input, including a +/// valid node. +#[test] +fn proxy_stub_always_returns_null() { + let _g = serial(); + common::force_link(); + + let project = oakengine_project_create(); + assert!(!project.is_null()); + assert_eq!(unsafe { oakengine_project_new(project) }, 0); + let root = unsafe { oakengine_project_root(project) }; + assert!(!root.is_null()); + + assert!(unsafe { oakengine_task_create_proxy(root) }.is_null()); + + unsafe { oakengine_node_free(root) }; + unsafe { oakengine_project_free(project) }; +} + +// --------------------------------------------------------------------------- +// Global task manager (serialized: the manager is process-wide) +// --------------------------------------------------------------------------- + +/// The global manager lifecycle: lazy creation, empty queue, task +/// hand-over (`manager_add`), borrowed first-task handle, double-add +/// rejection, cancel, and the alive accounting of the borrowed box. +#[test] +fn task_manager_lifecycle() { + let _g = serial(); + common::force_link(); + + // The facade initializes the manager on first use; the handle is stable. + let handle = oakengine_task_manager_handle(); + assert!(!handle.is_null()); + assert_eq!(oakengine_task_manager_handle(), handle); + assert_eq!(oakengine_task_manager_count(), 0); + + // An empty queue has no first task. + assert!(oakengine_task_manager_first().is_null()); + + let baseline = alive_count(); + + let task = unsafe { oakengine_task_create_project_load(c"/no/such/oak/project.ove".as_ptr()) }; + assert_eq!(alive_count(), baseline + 1); + + // Handing the task to the manager transfers ownership; the handle box + // stays alive until freed. + assert_eq!(unsafe { oakengine_task_manager_add(task) }, 0); + assert_eq!(oakengine_task_manager_count(), 1); + + // A second add of the same task is rejected with the module's E_STATE. + assert_eq!(unsafe { oakengine_task_manager_add(task) }, OAKTASK_E_STATE); + + // The first task is a borrowed handle: count goes up by one, and freeing + // the box returns it to baseline without deleting the manager's task. + let first = oakengine_task_manager_first(); + assert!(!first.is_null()); + assert_eq!(alive_count(), baseline + 2); + assert_eq!(unsafe { oakengine_task_free(first) }, 0); + assert_eq!(alive_count(), baseline + 1); + + // Cancelling through the manager succeeds (the load task fails fast on + // the missing file; cancel of a finished task is a documented no-op). + assert_eq!(unsafe { oakengine_task_manager_cancel(task) }, 0); + + // Adding the manager's own borrowed handle is rejected with E_STATE + // (the task is already running on the manager). + let first2 = oakengine_task_manager_first(); + assert!(!first2.is_null()); + assert_eq!(unsafe { oakengine_task_manager_add(first2) }, OAKTASK_E_STATE); + assert_eq!(unsafe { oakengine_task_free(first2) }, 0); + + // NULL / empty inputs on the manager family. + assert_eq!(unsafe { oakengine_task_manager_add(std::ptr::null_mut()) }, -1); + assert_eq!(unsafe { oakengine_task_manager_cancel(std::ptr::null_mut()) }, -1); + let empty = empty_task_box(); + assert_eq!(unsafe { oakengine_task_manager_add(empty) }, -1); + assert_eq!(unsafe { oakengine_task_manager_cancel(empty) }, -1); + unsafe { reclaim_empty_task_box(empty) }; + + // Releasing the (now borrowed) facade box is safe: the manager owns the + // task object and deletes it on cleanup; the alive counter returns to + // baseline. + assert_eq!(unsafe { oakengine_task_free(task) }, 0); + assert_eq!(alive_count(), baseline); + + // Finished tasks stay in the queue until delete_finished (the facade + // exposes no delete export), so the count is still 1. + assert_eq!(oakengine_task_manager_count(), 1); +} diff --git a/crates/oakengine/tests/it_timeline.rs b/crates/oakengine/tests/it_timeline.rs new file mode 100644 index 000000000..f629e2c33 --- /dev/null +++ b/crates/oakengine/tests/it_timeline.rs @@ -0,0 +1,1482 @@ +// 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 . + +//! Integration tests for the **timeline** family +//! (`engine/include/oakengine/timeline.h`, wrapped by +//! `crates/oakengine/src/timeline.rs` — sequences, tracks, clips, blocks, +//! markers, the workarea, the track-height constants and the multicam +//! helpers). +//! +//! All 139 exported functions are exercised against the REAL module +//! crates (oaknode/oaktimeline/oakcommon/oakundo; no mocks of any API). +//! +//! ## Serialization +//! +//! The facade owns a process-wide undo stack and per-sequence +//! marker-list / workarea caches, so every test that mutates state is +//! serialized inside the single `timeline_zu_lifecycle` test (the same +//! convention as `tests/timeline.rs` and the undo family). The +//! `timeline_zu_failure_paths` test only exercises non-mutating NULL / +//! empty-handle / garbage-argument calls and runs in parallel. +//! +//! ## Documented stubs (asserted on their documented behavior) +//! +//! - `oakengine_sequence_ripple_tracks_command` → NULL (no module C +//! creator for `TrackListRippleToolCommand`). +//! - `oakengine_sequence_move_clip` / `oakengine_sequence_move_track` → +//! E_STATE (-2) (the module's gap+place composition faults). +//! - `oakengine_sequence_add_default_transition` → E_STATE (-2) for a +//! non-empty clip set, 0 for an empty one. +//! - `oakengine_sequence_get_video_auto_cache` → 0 / +//! `oakengine_sequence_set_video_auto_cache` → 0 (no module accessor). +//! - `oakengine_marker_create` → NULL (no standalone marker handle); +//! `oakengine_clip_find_multicam` → NULL; +//! `oakengine_multicam_switch_source` → 0 with a live node, -1 with +//! NULL (the capi's `Q_UNUSED` body). +//! - `oakengine_clip_request_invalidate` / +//! `oakengine_clip_request_invalidate_connected` / +//! `oakengine_clip_discard_cache` → NULL-safe no-ops (headless capi +//! behavior); `oakengine_clip_add_cache_passthrough` → module no-op. +//! - `oakengine_sequence_add_footage_clip` / +//! `oakengine_sequence_add_sequence_clip` → NULL with a non-empty last +//! error (module clips declare no `buffer_in` input and every facade +//! sequence lives in its own scratch project, so the cross-project +//! check / footage connection fails cleanly — there is no legal path). +//! - `oakengine_sequence_set_preview_divider` → 0 for a valid divider +//! (the module's `VideoParams` model drops the divider; the getter +//! reports 1), -1 for divider < 1. +//! +//! ## Notes / deviations observed while writing these tests +//! +//! - The facade has no `oakengine_sequence_free` / `oakengine_track_free` +//! / `oakengine_clip_free`, and `oakengine_sequence_new` keeps each +//! sequence in its own scratch project (documented deviation), so the +//! oaknode debug alive counter can only return to baseline for the +//! project shell: `oakengine_project_create` bumps it by exactly 1 and +//! `oakengine_project_free` brings it back. Sequence/track/clip nodes +//! remain counted for the process (see the alive assertions in +//! `timeline_zu_lifecycle`). +//! +//! ## Real bugs found (all reproduced with assertions in this file; see +//! each site for the precise repro) +//! +//! 1. **`oakengine_clip_toggle_enabled(NULL, 0)` aborts the process** — +//! `slice::from_raw_parts(NULL, 0)` (src/timeline.rs:2637) is a +//! non-unwinding UB panic that the `catch_unwind` guard cannot catch; +//! repro in the ignored `timeline_zu_crash_repros` test (run with +//! `--ignored` to see the SIGABRT). The same NULL+0 slice exists in +//! `oakengine_sequence_delete_clips` (src/timeline.rs:2453) for +//! `clips == NULL && clip_count == 0 && ripple == 1 && +//! ripple_range_count == 0`. +//! 2. **Module `BlockSplitCommand` misplaces both split halves** +//! (crates/oaktimeline/src/undosplit.rs): the left half is anchored at +//! the OLD out-point (it calls the out-anchored +//! `set_length_and_media_out` instead of an in-anchored setter) and the +//! right half starts at 0 (its in is never moved to the point). Splitting +//! [0, 30) at frame 20 yields [10, 30) + [0, 10) instead of +//! [0, 20) + [20, 30). +//! 3. **`oakengine_sequence_split_clips` (batch split) is a silent no-op**: +//! the module's `BlockSplitPreservingLinksCommand` never runs `prepare()` +//! (only `new().to_command()` is built), so `redo()` iterates an empty +//! child list; the facade reports 0 and nothing is split. +//! 4. **`oakengine_sequence_trim_clips_to` never applies a trim**: it builds +//! its trim command with the TRACK handle where the BLOCK belongs +//! (`trim_cmd(track, ...)`, src/timeline.rs:3034), so the redo calls +//! `oaknode_block_set_length_and_media_out` on a track node and the +//! module rejects it — the call reports the would-be count and changes +//! nothing. +//! 5. **`oakengine_sequence_delete_empty_tracks` removes nothing**: unlike +//! `oakengine_sequence_remove_track` it skips the live +//! `oaknode_tracklist_remove_track` compensation, and the module's +//! `TimelineRemoveTrackCommand::redo` is a documented no-op — the call +//! reports the number of empty tracks found and leaves them in place. +//! 6. **`oakengine_sequence_ripple_delete_clip` / +//! `oakengine_sequence_ripple_delete_range` are silent no-ops**: the +//! module's `TrackRippleRemoveAreaCommand::prepare` needs +//! `oaknode_track_get_nearest_block_before_or_at`, which the oaknode +//! bridge does not expose, so it finds no block and removes nothing; the +//! facade reports success. +//! +//! ## Naming +//! +//! The suite lives in `tests/it_timeline.rs` (target `it_timeline`), +//! matching the other `it_` integration tests in this directory. + +#[path = "common/mod.rs"] +mod common; + +use std::ffi::{c_char, c_int, c_void}; + +use oakengine::handle::{ + box_handle, free_box, CHandle, OakEngineBlock, OakEngineClip, OakEngineFootage, + OakEngineMarker, OakEngineMarkerList, OakEngineNode, OakEngineProject, OakEngineSequence, + OakEngineTrack, OakEngineTrackList, OakEngineWorkarea, +}; +use oakengine::node::{ + oakengine_footage_borrow, oakengine_project_create, oakengine_project_free, + oakengine_project_new, +}; +use oakengine::timeline::*; +use oakengine::undo::oakengine_undo_command_free; + +/// Read a NUL-terminated facade string into a Rust String. +unsafe fn read_buf(buf: &mut [c_char]) -> String { + std::ffi::CStr::from_ptr(buf.as_ptr()) + .to_string_lossy() + .into_owned() +} + +/// Box a NULL module handle as a live (empty) engine box. `unbox` then +/// fails with E_INVALID — the "empty handle" (ctx == NULL) case that a +/// plugin can hand to every function. +macro_rules! empty_box { + ($t:ty) => { + box_handle::<$t>(CHandle::null()) + }; +} + +/// Force the runtime-dlsym'd symbols into the link: the oaknode module +/// resolves `oakcommon_videoparams_*` and `oakundo_command_init` at +/// runtime with `dlsym(RTLD_DEFAULT)`, and nothing references those +/// codegen units at link time unless named here. +fn force_runtime_syms() -> usize { + let fns: [usize; 8] = [ + oakcommon::ffi::videoparams::oakcommon_videoparams_init_basic as *const () as usize, + oakcommon::ffi::videoparams::oakcommon_videoparams_set_frame_rate as *const () as usize, + oakcommon::ffi::videoparams::oakcommon_videoparams_get_width as *const () as usize, + oakcommon::ffi::videoparams::oakcommon_videoparams_get_height as *const () as usize, + oakcommon::ffi::videoparams::oakcommon_videoparams_get_format as *const () as usize, + oakcommon::ffi::videoparams::oakcommon_videoparams_get_channel_count as *const () as usize, + oakcommon::ffi::videoparams::oakcommon_videoparams_get_frame_rate as *const () as usize, + oakundo::ffi::command::oakundo_command_init as *const () as usize, + ]; + fns.iter().sum() +} + +/// The module live count of owned node/project handles +/// (`oaknode_debug_alive_count`), the only debug counter backing the +/// timeline family's node objects. +fn alive() -> c_int { + unsafe { oaknode::ffi::node::oaknode_debug_alive_count() } +} + +/// Place a raw module clip on `track` (returns the module handle). +unsafe fn module_clip_on(track: CHandle, in_num: c_int, in_den: c_int) -> CHandle { + let clip = oaknode::ffi::block::oaknode_block_clip_create(); + oaknode::ffi::block::oaknode_block_set_in(clip, in_num, in_den); + oaknode::ffi::block::oaknode_block_set_length_and_media_in(clip, 1, 1); + oaknode::ffi::block::oaknode_clip_set_media_in(clip, 0, 1); + oaknode::ffi::track::oaknode_track_append_block(track, clip); + clip +} + +/// Assert `last_error` is non-empty (a failure path recorded a reason). +unsafe fn assert_last_error() { + let mut err = [0 as c_char; 256]; + let elen = unsafe { oakengine_sequence_last_error(err.as_mut_ptr(), 256) }; + assert!(elen > 0, "last_error must be non-empty"); +} + +/// The clip at (track_type, track_index, clip_index), panicking if none. +unsafe fn clip_at_ok( + seq: *mut OakEngineSequence, + tt: c_int, + ti: c_int, + ci: c_int, +) -> *mut OakEngineClip { + let c = unsafe { oakengine_sequence_clip_at(seq, tt, ti, ci) }; + assert!(!c.is_null(), "expected clip at ({tt},{ti},{ci})"); + c +} + +// --------------------------------------------------------------------------- +// Serialized lifecycle test (all mutating operations, in ONE test because +// the facade's undo stack and per-sequence caches are process-wide) +// --------------------------------------------------------------------------- + +/// Every mutating path of the timeline family: sequence creation and +/// inspection, video/audio params, playhead, markers, workarea (+ ripple +/// in-to-out), track structure, clip editing (split / trim / delete / +/// ripple / link / toggle), the marker-handle and workarea-handle +/// families, the free contracts, and the oaknode alive-count deltas. +#[test] +fn timeline_zu_lifecycle() { + common::force_link(); + let _ = force_runtime_syms(); + + let base = alive(); + + // ---- project + sequence creation -------------------------------------- + let project = unsafe { oakengine_project_create() }; + assert!(!project.is_null()); + assert_eq!(alive(), base + 1, "project_create must own one node"); + assert_eq!(unsafe { oakengine_project_new(project) }, 0); + + // NULL project -> NULL sequence. + assert!(unsafe { oakengine_sequence_new(std::ptr::null_mut(), c"x".as_ptr()) }.is_null()); + + let seq = unsafe { oakengine_sequence_new(project, c"Test Sequence".as_ptr()) }; + assert!(!seq.is_null()); + assert_eq!(alive(), base + 2, "sequence_new must own one node"); + + // A second sequence for the self-nest / cross-project checks. + let seq2 = unsafe { oakengine_sequence_new(project, c"Nested".as_ptr()) }; + assert!(!seq2.is_null()); + + // ---- name (buf/size convention) ---------------------------------------- + let mut buf = [0 as c_char; 256]; + let len = unsafe { oakengine_sequence_name(seq, buf.as_mut_ptr(), 256) }; + assert_eq!(len, 13); + assert_eq!(unsafe { read_buf(&mut buf) }, "Test Sequence"); + // Two-stage: NULL buffer / zero size only reports the length. + assert_eq!(unsafe { oakengine_sequence_name(seq, std::ptr::null_mut(), 0) }, 13); + // A too-small buffer is truncated by the module (2 chars + NUL). + let mut small = [0 as c_char; 3]; + let len = unsafe { oakengine_sequence_name(seq, small.as_mut_ptr(), 3) }; + assert_eq!(len, 13); + assert_eq!(unsafe { read_buf(&mut small) }, "Te"); + + // ---- length / frame rate / video params --------------------------------- + let mut seconds = -1.0; + assert_eq!(unsafe { oakengine_sequence_get_length(seq, &mut seconds) }, 0); + assert_eq!(seconds, 0.0); + let (mut n, mut d) = (-1, -1); + assert_eq!(unsafe { oakengine_sequence_get_length_rational(seq, &mut n, &mut d) }, 0); + assert_eq!((n, d), (0, 1)); + // NULL out params are fine. + assert_eq!( + unsafe { oakengine_sequence_get_length_rational(seq, std::ptr::null_mut(), std::ptr::null_mut()) }, + 0 + ); + let (mut fn_, mut fd) = (0, 0); + assert_eq!(unsafe { oakengine_sequence_get_frame_rate(seq, &mut fn_, &mut fd) }, 0); + assert_eq!((fn_, fd), (30, 1)); + + let (mut w, mut h, mut pn, mut pd) = (0, 0, 0, 0); + assert_eq!( + unsafe { oakengine_sequence_get_video_params(seq, &mut w, &mut h, &mut pn, &mut pd) }, + 0 + ); + assert_eq!((w, h), (1920, 1080)); + assert_eq!((pn, pd), (1, 1)); + + let (mut exw, mut exh, mut exfn, mut exfd, mut expn, mut expd, mut exi, mut exf, mut exdv) = + (0, 0, 0, 0, 0, 0, -1, -1, 0); + assert_eq!( + unsafe { + oakengine_sequence_get_video_params_ex( + seq, + &mut exw, + &mut exh, + &mut exfn, + &mut exfd, + &mut expn, + &mut expd, + &mut exi, + &mut exf, + &mut exdv, + ) + }, + 0 + ); + assert_eq!((exw, exh), (1920, 1080)); + assert_eq!((exfn, exfd), (30, 1)); + assert_eq!((expn, expd), (1, 1)); + assert_eq!(exi, 0); // progressive + assert_eq!(exf, 4); // f32 + assert_eq!(exdv, 1); + + // ---- set_video_params (legal matrix + validation) ----------------------- + // Change, read back, restore to 30 fps BEFORE any frame conversions. + assert_eq!( + unsafe { oakengine_sequence_set_video_params(seq, 1280, 720, 24, 1, 1, 1, 0, 4, 0) }, + 0 + ); + assert_eq!( + unsafe { oakengine_sequence_get_video_params(seq, &mut w, &mut h, &mut pn, &mut pd) }, + 0 + ); + assert_eq!((w, h), (1280, 720)); + assert_eq!( + unsafe { oakengine_sequence_get_frame_rate(seq, &mut fn_, &mut fd) }, + 0 + ); + assert_eq!((fn_, fd), (24, 1)); + // Restore the 30 fps timebase. + assert_eq!( + unsafe { oakengine_sequence_set_video_params(seq, 1920, 1080, 30, 1, 1, 1, 0, 4, 0) }, + 0 + ); + assert_eq!( + unsafe { oakengine_sequence_get_frame_rate(seq, &mut fn_, &mut fd) }, + 0 + ); + assert_eq!((fn_, fd), (30, 1)); + // -1 leaves a field unchanged (only the width changes). + assert_eq!( + unsafe { oakengine_sequence_set_video_params(seq, 640, -1, -1, -1, -1, -1, -1, -1, 0) }, + 0 + ); + assert_eq!( + unsafe { oakengine_sequence_get_video_params(seq, &mut w, &mut h, &mut pn, &mut pd) }, + 0 + ); + assert_eq!((w, h), (640, 1080)); + assert_eq!( + unsafe { oakengine_sequence_set_video_params(seq, 1920, -1, -1, -1, -1, -1, -1, -1, 0) }, + 0 + ); + // Validation failures (each sets a last error). + assert_eq!( + unsafe { oakengine_sequence_set_video_params(seq, 0, 1080, 30, 1, 1, 1, 0, 4, 0) }, + -1 + ); + assert_eq!( + unsafe { oakengine_sequence_set_video_params(seq, 1920, 1080, 0, 1, 1, 1, 0, 4, 0) }, + -1 + ); + assert_eq!( + unsafe { oakengine_sequence_set_video_params(seq, 1920, 1080, 30, 1, 1, 1, 5, 4, 0) }, + -1 + ); + assert_eq!( + unsafe { oakengine_sequence_set_video_params(seq, 1920, 1080, 30, 1, 1, 1, 0, 99, 0) }, + -1 + ); + assert_eq!( + unsafe { oakengine_sequence_set_video_params(seq, 1920, 1080, -1, 1, 1, 1, 0, 4, 0) }, + -1 + ); + unsafe { assert_last_error() }; + + // ---- audio params (round-trip through the oakcore stub store) ---------- + let (mut arate, mut alayout) = (0 as c_int, 0u64); + assert_eq!( + unsafe { oakengine_sequence_get_audio_params(seq, &mut arate, &mut alayout) }, + 0 + ); + assert!(arate > 0); + assert_eq!(unsafe { oakengine_sequence_set_audio_params(seq, 48000, 0x3, 1) }, 0); + assert_eq!( + unsafe { oakengine_sequence_get_audio_params(seq, &mut arate, &mut alayout) }, + 0 + ); + assert_eq!((arate, alayout), (48000, 0x3)); + // A no-op change (same values) succeeds without a new command. + assert_eq!(unsafe { oakengine_sequence_set_audio_params(seq, 48000, 0x3, 1) }, 0); + + // ---- preview divider / video auto-cache (module-stubbed) ---------------- + assert_eq!(unsafe { oakengine_sequence_get_preview_divider(seq) }, 1); + assert_eq!(unsafe { oakengine_sequence_set_preview_divider(seq, 2, 0) }, 0); + assert_eq!(unsafe { oakengine_sequence_get_preview_divider(seq) }, 1); + assert_eq!(unsafe { oakengine_sequence_set_preview_divider(seq, 0, 0) }, -1); + assert_eq!(unsafe { oakengine_sequence_set_preview_divider(seq, -2, 0) }, -1); + assert_eq!(unsafe { oakengine_sequence_get_video_auto_cache(seq) }, 0); + assert_eq!(unsafe { oakengine_sequence_set_video_auto_cache(seq, 1, 1) }, 0); + assert_eq!(unsafe { oakengine_sequence_get_video_auto_cache(seq) }, 0); + + // ---- track counts (fresh sequence: none) --------------------------------- + let (mut v, mut a, mut s) = (-1, -1, -1); + assert_eq!(unsafe { oakengine_sequence_track_count(seq, &mut v, &mut a, &mut s) }, 0); + assert_eq!((v, a, s), (0, 0, 0)); + assert_eq!( + unsafe { + oakengine_sequence_track_count( + seq, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + 0 + ); + + // ---- playhead ------------------------------------------------------------- + let mut ts = -1i64; + assert_eq!(unsafe { oakengine_sequence_get_playhead(seq, &mut ts) }, 0); + assert_eq!(ts, 0); + assert_eq!(unsafe { oakengine_sequence_set_playhead(seq, 90) }, 0); + assert_eq!(unsafe { oakengine_sequence_get_playhead(seq, &mut ts) }, 0); + assert_eq!(ts, 90); + let mut phs = 0.0; + assert_eq!(unsafe { oakengine_sequence_get_playhead_seconds(seq, &mut phs) }, 0); + assert!((phs - 3.0).abs() < 1e-6); + assert_eq!(unsafe { oakengine_sequence_set_playhead(seq, 0) }, 0); + + // ---- workarea (sequence) + ripple in-to-out -------------------------------- + assert_eq!(unsafe { oakengine_sequence_workarea_is_enabled(seq) }, 0); + assert_eq!(unsafe { oakengine_sequence_set_workarea(seq, 1, 0, 300) }, 0); + assert_eq!(unsafe { oakengine_sequence_workarea_is_enabled(seq) }, 1); + let (mut wi, mut wo) = (-1i64, -1i64); + assert_eq!(unsafe { oakengine_sequence_get_workarea(seq, &mut wi, &mut wo) }, 0); + assert_eq!((wi, wo), (0, 300)); + assert_eq!(unsafe { oakengine_sequence_set_workarea(seq, 0, 0, 300) }, 0); + assert_eq!(unsafe { oakengine_sequence_workarea_is_enabled(seq) }, 0); + + // ripple_delete_in_to_out requires the workarea enabled. + assert_eq!(unsafe { oakengine_sequence_ripple_delete_in_to_out(seq, 0, 0, 300) }, -2); + assert_eq!(unsafe { oakengine_sequence_ripple_delete_in_to_out(seq, 1, -1, 300) }, -1); + unsafe { assert_last_error() }; + assert_eq!(unsafe { oakengine_sequence_set_workarea(seq, 1, 0, 300) }, 0); + // Gap-fill variant (ripple = 0) on the (still empty) tracks. + assert_eq!(unsafe { oakengine_sequence_ripple_delete_in_to_out(seq, 0, 0, 300) }, 0); + assert_eq!(unsafe { oakengine_sequence_workarea_is_enabled(seq) }, 0); + // Ripple variant (ripple = 1) after re-enabling. + assert_eq!(unsafe { oakengine_sequence_set_workarea(seq, 1, 0, 300) }, 0); + assert_eq!(unsafe { oakengine_sequence_ripple_delete_in_to_out(seq, 1, 0, 300) }, 0); + // Disabled again -> E_STATE. + assert_eq!(unsafe { oakengine_sequence_ripple_delete_in_to_out(seq, 0, 0, 300) }, -2); + + // ---- sequence markers ----------------------------------------------------- + assert_eq!(unsafe { oakengine_sequence_marker_count(seq) }, 0); + assert_eq!(unsafe { oakengine_sequence_marker_add(seq, 30, c"One".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_sequence_marker_add_ex(seq, 60, c"Two".as_ptr(), 2) }, 0); + assert_eq!(unsafe { oakengine_sequence_marker_count(seq) }, 2); + + let (mut mtime, mut mcolor) = (-1i64, -1); + let mut mname = [0 as c_char; 64]; + assert_eq!( + unsafe { oakengine_sequence_marker_at(seq, 0, &mut mtime, mname.as_mut_ptr(), 64, &mut mcolor) }, + 0 + ); + assert_eq!(mtime, 30); + assert_eq!(mcolor, 0); + assert_eq!(unsafe { read_buf(&mut mname) }, "One"); + // Out-of-range index -> the module's NOT_FOUND (-40004) passes through. + assert_eq!( + unsafe { oakengine_sequence_marker_at(seq, 5, &mut mtime, mname.as_mut_ptr(), 64, &mut mcolor) }, + -40004 + ); + // Duplicate time -> E_STATE. + assert_eq!(unsafe { oakengine_sequence_marker_add(seq, 30, c"dup".as_ptr()) }, -2); + // Rename, then remove many at once. + assert_eq!(unsafe { oakengine_sequence_marker_rename(seq, 30, c"Renamed".as_ptr()) }, 0); + assert_eq!( + unsafe { oakengine_sequence_marker_remove_many(seq, [30i64, 60].as_ptr(), 2) }, + 2 + ); + assert_eq!(unsafe { oakengine_sequence_marker_count(seq) }, 0); + // Removing a nonexistent time -> E_NOT_FOUND (-4). + assert_eq!(unsafe { oakengine_sequence_marker_remove(seq, 999) }, -4); + assert_eq!(unsafe { oakengine_sequence_marker_remove_many(seq, [999i64].as_ptr(), 1) }, -4); + unsafe { assert_last_error() }; + // NULL name -> empty name. + assert_eq!(unsafe { oakengine_sequence_marker_add(seq, 90, std::ptr::null()) }, 0); + assert_eq!(unsafe { oakengine_sequence_marker_count(seq) }, 1); + assert_eq!(unsafe { oakengine_sequence_marker_remove(seq, 90) }, 0); + + // ---- tracks ---------------------------------------------------------------- + let idx = unsafe { oakengine_sequence_add_track(seq, 0) }; + assert_eq!(idx, 0); + assert_eq!(unsafe { oakengine_sequence_add_track(seq, 1) }, 0); // audio + assert_eq!(unsafe { oakengine_sequence_add_track(seq, 2) }, 0); // subtitle + // Garbage track types -> E_INVALID. + assert_eq!(unsafe { oakengine_sequence_add_track(seq, 3) }, -1); + assert_eq!(unsafe { oakengine_sequence_add_track(seq, -1) }, -1); + unsafe { assert_last_error() }; + + assert_eq!(unsafe { oakengine_sequence_track_count(seq, &mut v, &mut a, &mut s) }, 0); + assert_eq!((v, a, s), (1, 1, 1)); + + let track = unsafe { oakengine_sequence_track_at(seq, 0, 0) }; + assert!(!track.is_null()); + assert_eq!(unsafe { oakengine_track_type(track) }, 0); // video + assert!(unsafe { oakengine_sequence_track_at(seq, 0, 5) }.is_null()); + assert!(unsafe { oakengine_sequence_track_at(seq, 3, 0) }.is_null()); + assert!(!unsafe { oakengine_sequence_track_list(seq, 0) }.is_null()); + assert!(unsafe { oakengine_sequence_track_list(seq, 99) }.is_null()); + + // Track height / mute / lock (straight setters, NOT undoable). + let mut h = 0.0; + assert_eq!(unsafe { oakengine_track_get_height(seq, 0, 0, &mut h) }, 0); + assert!((h - 3.0).abs() < 1e-9); + assert_eq!(unsafe { oakengine_track_set_height(seq, 0, 0, 5.0) }, 0); + assert_eq!(unsafe { oakengine_track_get_height(seq, 0, 0, &mut h) }, 0); + assert!((h - 5.0).abs() < 1e-9); + assert_eq!(unsafe { oakengine_track_set_height(seq, 0, 0, -1.0) }, -1); + unsafe { assert_last_error() }; + assert_eq!(unsafe { oakengine_track_is_muted(seq, 0, 0) }, 0); + assert_eq!(unsafe { oakengine_track_set_muted(seq, 0, 0, 1) }, 0); + assert_eq!(unsafe { oakengine_track_is_muted(seq, 0, 0) }, 1); + assert_eq!(unsafe { oakengine_track_set_muted(seq, 0, 0, 0) }, 0); + assert_eq!(unsafe { oakengine_track_is_locked(seq, 0, 0) }, 0); + assert_eq!(unsafe { oakengine_track_set_locked(seq, 0, 0, 1) }, 0); + assert_eq!(unsafe { oakengine_track_is_locked(seq, 0, 0) }, 1); + assert_eq!(unsafe { oakengine_track_set_locked(seq, 0, 0, 0) }, 0); + + // Track length (empty -> 0) and free-range query. + let mut tlen = -1i64; + assert_eq!(unsafe { oakengine_track_get_length(seq, 0, 0, &mut tlen) }, 0); + assert_eq!(tlen, 0); + assert_eq!(unsafe { oakengine_track_is_range_free(seq, 0, 0, 0, 30) }, 1); + // Bad track index -> E_NOT_FOUND (-4). + assert_eq!(unsafe { oakengine_track_get_length(seq, 0, 99, &mut tlen) }, -4); + assert_eq!(unsafe { oakengine_track_is_range_free(seq, 0, 99, 0, 30) }, -4); + // Invalid ranges. + assert_eq!(unsafe { oakengine_track_is_range_free(seq, 0, 0, -1, 30) }, -1); + assert_eq!(unsafe { oakengine_track_is_range_free(seq, 0, 0, 30, 30) }, -1); + unsafe { assert_last_error() }; + + // ---- add_track_command (unpushed command + live out-track) --------------- + let mut out_track: *mut OakEngineTrack = std::ptr::null_mut(); + let tcmd = unsafe { oakengine_sequence_add_track_command(seq, 0, 1, &mut out_track) }; + assert!(!tcmd.is_null()); + assert!(!out_track.is_null()); + assert_eq!(unsafe { oakengine_track_type(out_track) }, 0); + // The command owns an internal track node (TimelineAddTrackCommand), + // and the out-track box wraps the facade-created track. Freeing the + // command releases ITS node; the out-track was adopted by the list, so + // its box release un-counts nothing. + let alive_before = alive(); + unsafe { oakengine_undo_command_free(tcmd) }; + assert_eq!(alive(), alive_before - 1, "freeing the command releases its internal track node"); + unsafe { free_box::(out_track) }; + assert_eq!(alive(), alive_before - 1, "the adopted out-track box un-counts nothing on release"); + // NULL / garbage paths. + assert!(unsafe { oakengine_sequence_add_track_command(std::ptr::null_mut(), 0, 0, std::ptr::null_mut()) } + .is_null()); + assert!(unsafe { oakengine_sequence_add_track_command(seq, 99, 0, std::ptr::null_mut()) }.is_null()); + assert!(unsafe { oakengine_sequence_add_track_command(seq, -2, 0, std::ptr::null_mut()) }.is_null()); + + // The live-compensation track makes video = 2 now. + assert_eq!(unsafe { oakengine_sequence_track_count(seq, &mut v, &mut a, &mut s) }, 0); + assert_eq!((v, a, s), (2, 1, 1)); + + // ---- move_track (stub for a real move, validated indices) ---------------- + assert_eq!(unsafe { oakengine_sequence_move_track(seq, 0, 0, 0) }, 0); // no-op move + assert_eq!(unsafe { oakengine_sequence_move_track(seq, 0, 0, 1) }, -2); // stub + assert_eq!(unsafe { oakengine_sequence_move_track(seq, 0, 0, 9) }, -4); // out of range + unsafe { assert_last_error() }; + + // ---- ripple_tracks_command (stub -> NULL) --------------------------------- + // The facade has no clip-placement path for a module clip without a + // footage node; the raw clips are appended through oaknode::ffi directly. + let _ = unsafe { module_clip_on((*track).handle, 0, 1) }; + let clip_a = unsafe { clip_at_ok(seq, 0, 0, 0) }; + assert_eq!(unsafe { oakengine_sequence_clip_count(seq, 0, 0) }, 1); + let (mut cin, mut cout, mut cmi) = (-1i64, -1i64, -1i64); + assert_eq!(unsafe { oakengine_clip_get_range(clip_a, &mut cin, &mut cout, &mut cmi) }, 0); + assert_eq!((cin, cout, cmi), (0, 30, 0)); + assert!(!unsafe { oakengine_clip_get_sequence(clip_a) }.is_null()); + + // Media range as rationals: media_in (0,1), out = in + length (1s = 1/1). + let (mut mn, mut md, mut xon, mut xod) = (-1i64, -1i64, -1i64, -1i64); + assert_eq!( + unsafe { oakengine_clip_get_media_range_rational(clip_a, &mut mn, &mut md, &mut xon, &mut xod) }, + 0 + ); + assert_eq!((mn, md, xon, xod), (0, 1, 1, 1)); + assert_eq!( + unsafe { oakengine_clip_get_media_in_rational(clip_a, &mut mn, &mut md) }, + 0 + ); + assert_eq!((mn, md), (0, 1)); + + // Media-in writes: live (non-undoable) then undoable. + assert_eq!(unsafe { oakengine_clip_set_media_in(clip_a, 10, 0) }, 0); + assert_eq!( + unsafe { oakengine_clip_get_media_in_rational(clip_a, &mut mn, &mut md) }, + 0 + ); + assert_eq!((mn, md), (1, 3)); // 10 frames at 30 fps = 1/3 s + assert_eq!(unsafe { oakengine_clip_set_media_in(clip_a, 0, 1) }, 0); + assert_eq!(unsafe { oakengine_clip_set_media_in_rational(clip_a, 2, 3, 0) }, 0); + assert_eq!( + unsafe { oakengine_clip_get_media_in_rational(clip_a, &mut mn, &mut md) }, + 0 + ); + assert_eq!((mn, md), (2, 3)); + assert_eq!(unsafe { oakengine_clip_set_media_in_rational(clip_a, 0, 1, 0) }, 0); + // Zero denominator -> E_INVALID. + assert_eq!(unsafe { oakengine_clip_set_media_in_rational(clip_a, 0, 0, 0) }, -1); + unsafe { assert_last_error() }; + + // Enabled toggle (undoable). + assert_eq!(unsafe { oakengine_clip_is_enabled(clip_a) }, 1); + let mut clip_a_ptr = clip_a; + assert_eq!(unsafe { oakengine_clip_toggle_enabled(&mut clip_a_ptr, 1) }, 1); + assert_eq!(unsafe { oakengine_clip_is_enabled(clip_a) }, 0); + assert_eq!(unsafe { oakengine_clip_toggle_enabled(&mut clip_a_ptr, 1) }, 1); + assert_eq!(unsafe { oakengine_clip_is_enabled(clip_a) }, 1); + + // Second clip at [40, 70). + unsafe { module_clip_on((*track).handle, 4, 3) }; + assert_eq!(unsafe { oakengine_sequence_clip_count(seq, 0, 0) }, 2); + let mut bin = -1i64; + let mut bout = -1i64; + let mut bmi = -1i64; + let clip_b = unsafe { clip_at_ok(seq, 0, 0, 1) }; + assert_eq!(unsafe { oakengine_clip_get_range(clip_b, &mut bin, &mut bout, &mut bmi) }, 0); + assert_eq!((bin, bout, bmi), (40, 70, 0)); + + // Links: unlinked, link, unlink (one undoable command each). + assert_eq!(unsafe { oakengine_clip_are_linked(clip_a, clip_b) }, 0); + let mut clips2 = [clip_a, clip_b]; + assert_eq!(unsafe { oakengine_clip_set_linked(clips2.as_mut_ptr(), 2, 1) }, 0); + assert_eq!(unsafe { oakengine_clip_are_linked(clip_a, clip_b) }, 1); + assert_eq!(unsafe { oakengine_clip_set_linked(clips2.as_mut_ptr(), 2, 0) }, 0); + assert_eq!(unsafe { oakengine_clip_are_linked(clip_a, clip_b) }, 0); + // Zero count succeeds; NULL with count > 0 fails. + assert_eq!(unsafe { oakengine_clip_set_linked(std::ptr::null_mut(), 0, 1) }, 0); + assert_eq!(unsafe { oakengine_clip_set_linked(std::ptr::null_mut(), 1, 1) }, -1); + unsafe { assert_last_error() }; + + // ---- block traversal ------------------------------------------------------- + assert_eq!(unsafe { oakengine_track_block_count(track) }, 2); + let blk_a = unsafe { oakengine_track_block_at(track, 0) }; + assert!(!blk_a.is_null()); + assert!(unsafe { oakengine_track_block_at(track, 5) }.is_null()); + assert_eq!(unsafe { oakengine_block_is_gap(blk_a) }, 0); + assert!(!unsafe { oakengine_block_get_track(blk_a) }.is_null()); + let blk_b = unsafe { oakengine_block_next(blk_a) }; + assert!(!blk_b.is_null()); + assert!(unsafe { oakengine_block_prev(blk_a) }.is_null()); + assert!(unsafe { oakengine_block_next(blk_b) }.is_null()); + assert!(!unsafe { oakengine_block_prev(blk_b) }.is_null()); + let (mut bin2, mut bout2) = (-1i64, -1i64); + assert_eq!(unsafe { oakengine_block_get_range(blk_a, &mut bin2, &mut bout2) }, 0); + assert_eq!((bin2, bout2), (0, 30)); + assert_eq!(unsafe { oakengine_block_link_count(blk_a) }, 0); + assert!(unsafe { oakengine_block_link_at(blk_a, 0) }.is_null()); + assert!(unsafe { oakengine_block_link_at(blk_a, -1) }.is_null()); + + // Block at time / visible / nearest. + assert!(!unsafe { oakengine_track_block_at_time(track, 5) }.is_null()); + assert!(unsafe { oakengine_track_block_at_time(track, 100) }.is_null()); + assert!(!unsafe { oakengine_track_visible_block_at_time(track, 5) }.is_null()); + assert!(!unsafe { oakengine_track_nearest_block_before(track, 35) }.is_null()); + assert!(unsafe { oakengine_track_nearest_block_before(track, 5) }.is_null()); + assert!(!unsafe { oakengine_track_nearest_block_after(track, 30) }.is_null()); + assert!(unsafe { oakengine_track_nearest_block_after(track, 70) }.is_null()); + assert!(!unsafe { oakengine_track_nearest_block_before_or_at(track, 35) }.is_null()); + assert!(!unsafe { oakengine_track_nearest_block_after_or_at(track, 40) }.is_null()); + + // Block enable + resize (undoable; in-point kept). + assert_eq!(unsafe { oakengine_block_set_enabled(blk_a, 0) }, 0); + assert_eq!(unsafe { oakengine_block_is_enabled(blk_a) }, 0); + assert_eq!(unsafe { oakengine_block_set_enabled(blk_a, 1) }, 0); + assert_eq!(unsafe { oakengine_block_is_enabled(blk_a) }, 1); + // Resize B from 30 frames to 60 (in kept: [40, 100)), then back. + assert_eq!(unsafe { oakengine_block_set_length_and_media_out(blk_b, 60) }, 0); + assert_eq!(unsafe { oakengine_block_get_range(blk_b, &mut bin2, &mut bout2) }, 0); + assert_eq!((bin2, bout2), (40, 100)); + assert_eq!(unsafe { oakengine_block_set_length_and_media_out(blk_b, 30) }, 0); + assert_eq!(unsafe { oakengine_block_get_range(blk_b, &mut bin2, &mut bout2) }, 0); + assert_eq!((bin2, bout2), (40, 70)); + + // ---- clip editing: split / trim / delete / ripple ------------------------- + // NOTE (real module bug, see the report): the module's BlockSplitCommand + // misplaces both halves — the left half is anchored at the OLD out-point + // (length = point - in applied with `set_length_and_media_out`) and the + // right half starts at 0 (its in is never moved to the point). Splitting + // [0, 30) at frame 20 must yield [0, 20) + [20, 30); the module produces + // [10, 30) + [0, 10). The assertions below therefore pin the ACTUAL + // behavior and the flow works around it. + assert_eq!(unsafe { oakengine_sequence_split_clip(seq, 0, 0, 0, 20) }, 0); + assert_eq!(unsafe { oakengine_sequence_clip_count(seq, 0, 0) }, 3); + // Split outside the clip -> E_INVALID + last error. + assert_eq!(unsafe { oakengine_sequence_split_clip(seq, 0, 0, 0, 100) }, -1); + unsafe { assert_last_error() }; + // Split a nonexistent clip -> E_NOT_FOUND. + assert_eq!(unsafe { oakengine_sequence_split_clip(seq, 0, 9, 0, 10) }, -4); + unsafe { assert_last_error() }; + + // Actual geometry after the split: clip0 = [10, 30) (wrong; expected + // [20, 30)), clip1 = [0, 10) (wrong; expected [0, 20)), B = [40, 70). + let a2 = unsafe { clip_at_ok(seq, 0, 0, 0) }; + let a1 = unsafe { clip_at_ok(seq, 0, 0, 1) }; + let (mut s0in, mut s0out, mut s0mi) = (-1i64, -1i64, -1i64); + assert_eq!(unsafe { oakengine_clip_get_range(a2, &mut s0in, &mut s0out, &mut s0mi) }, 0); + assert_eq!((s0in, s0out), (10, 30)); // BUG: module split misplaced the halves + let (mut s1in, mut s1out, mut s1mi) = (-1i64, -1i64, -1i64); + assert_eq!(unsafe { oakengine_clip_get_range(a1, &mut s1in, &mut s1out, &mut s1mi) }, 0); + assert_eq!((s1in, s1out), (0, 10)); // BUG: module split misplaced the halves + + // Trim A2 to [25, 35) (trim works on any clip geometry). + assert_eq!(unsafe { oakengine_clip_trim(a2, 25, 35) }, 0); + assert_eq!(unsafe { oakengine_clip_get_range(a2, &mut cin, &mut cout, &mut cmi) }, 0); + assert_eq!((cin, cout), (25, 35)); + // Invalid trim -> E_INVALID. + assert_eq!(unsafe { oakengine_clip_trim(a2, 30, 30) }, -1); + assert_eq!(unsafe { oakengine_clip_trim(a2, -1, 10) }, -1); + unsafe { assert_last_error() }; + + // Keep a2's box for the later stub checks (the box stays valid even + // after the clip is removed from the track — the node stays in the + // sequence's scratch graph). + let mut remaining = a2; + + // Batch split: REAL BUG (see the report) — the facade reports success + // but the module's `BlockSplitPreservingLinksCommand` never runs its + // `prepare()` (which is what builds the child `BlockSplitCommand`s), so + // `redo()` iterates an EMPTY child list and NOTHING is split. The count + // stays 3 and every clip keeps its range. + let mut a2_ptr = a2; + assert_eq!(unsafe { oakengine_sequence_split_clips(seq, &mut a2_ptr, 1, 28) }, 0); + assert_eq!(unsafe { oakengine_sequence_clip_count(seq, 0, 0) }, 3); // BUG: no-op split + let (mut a2in, mut a2out, mut a2mi) = (-1i64, -1i64, -1i64); + assert_eq!(unsafe { oakengine_clip_get_range(a2, &mut a2in, &mut a2out, &mut a2mi) }, 0); + assert_eq!((a2in, a2out), (25, 35)); // BUG: unchanged, nothing was split + // No clip spans the time -> E_NOT_FOUND. + assert_eq!(unsafe { oakengine_sequence_split_clips(seq, &mut a2_ptr, 1, 5) }, -4); + unsafe { assert_last_error() }; + // NULL / zero-count args -> E_INVALID. + assert_eq!(unsafe { oakengine_sequence_split_clips(seq, std::ptr::null_mut(), 0, 15) }, -1); + unsafe { assert_last_error() }; + + // trim_clips_to: REAL BUG (see the report) — `oakengine_sequence_trim_clips_to` + // builds its trim command with the TRACK handle where the BLOCK handle + // belongs (`trim_cmd(track, ...)` in src/timeline.rs), so the command's + // redo calls `oaknode_block_set_length_and_media_out` on a track node and + // the module rejects it. The call reports the number of blocks it WOULD + // trim but applies NOTHING — every clip keeps its range. + assert_eq!(unsafe { oakengine_sequence_trim_clips_to(seq, 0, 30) }, 1); // would trim 1 + let (mut t1in, mut t1out, mut t1mi) = (-1i64, -1i64, -1i64); + assert_eq!(unsafe { oakengine_clip_get_range(a1, &mut t1in, &mut t1out, &mut t1mi) }, 0); + assert_eq!((t1in, t1out), (0, 10)); // BUG: the trim never applied + assert_eq!(unsafe { oakengine_clip_get_range(a2, &mut t1in, &mut t1out, &mut t1mi) }, 0); + assert_eq!((t1in, t1out), (25, 35)); // BUG: the trim never applied + assert_eq!(unsafe { oakengine_sequence_trim_clips_to(seq, 2, 30) }, -1); // bad edge + unsafe { assert_last_error() }; + + // move_clip is a documented stub -> E_STATE (a2 still exists). + assert_eq!(unsafe { oakengine_sequence_move_clip(seq, 0, 0, 0, 50) }, -2); + unsafe { assert_last_error() }; + // move_clip on a nonexistent clip -> E_NOT_FOUND. + assert_eq!(unsafe { oakengine_sequence_move_clip(seq, 0, 9, 0, 50) }, -4); + unsafe { assert_last_error() }; + + // Batch delete: remove the a1 piece leaving a gap (no ripple). + let a1b = unsafe { clip_at_ok(seq, 0, 0, 1) }; + let mut rippled = -1; + let mut a1b_ptr = a1b; + assert_eq!( + unsafe { oakengine_sequence_delete_clips(seq, &mut a1b_ptr, 1, 0, std::ptr::null(), 0, &mut rippled) }, + 0 + ); + assert_eq!(rippled, 0); + assert_eq!(unsafe { oakengine_sequence_clip_count(seq, 0, 0) }, 2); + // Batch delete with ripple=1 ripples the deleted clip's range closed. + let b3 = unsafe { clip_at_ok(seq, 0, 0, 1) }; + let mut b3_ptr = b3; + assert_eq!( + unsafe { oakengine_sequence_delete_clips(seq, &mut b3_ptr, 1, 1, std::ptr::null(), 0, &mut rippled) }, + 0 + ); + assert_eq!(rippled, 1); + assert_eq!(unsafe { oakengine_sequence_clip_count(seq, 0, 0) }, 1); + // Empty batch (count 0, no ripple) is a clean no-op. + assert_eq!( + unsafe { oakengine_sequence_delete_clips(seq, std::ptr::null_mut(), 0, 0, std::ptr::null(), 0, &mut rippled) }, + 0 + ); + assert_eq!(rippled, 0); + // Bad ripple-range track type -> E_INVALID. + let bad_range = [3i64, 0, 0, 10]; + assert_eq!( + unsafe { oakengine_sequence_delete_clips(seq, &mut a1b_ptr, 0, 1, bad_range.as_ptr(), 1, &mut rippled) }, + -1 + ); + unsafe { assert_last_error() }; + + // Ripple delete the addressed clip: REAL BUG (see the report) — the + // facade reports success but the module's `TrackRippleRemoveAreaCommand` + // no-ops (its `prepare()` needs `oaknode_track_get_nearest_block_before_or_at`, + // which the oaknode bridge does not expose, so it finds no block and + // removes nothing). The clip stays on the track. + assert_eq!(unsafe { oakengine_sequence_ripple_delete_clip(seq, 0, 0, 0) }, 0); + assert_eq!(unsafe { oakengine_sequence_clip_count(seq, 0, 0) }, 1); // BUG: no-op + assert_eq!(unsafe { oakengine_sequence_ripple_delete_clip(seq, 0, 9, 0) }, -4); + unsafe { assert_last_error() }; + + // add_default_transition: empty set is a no-op, non-empty is a stub. + assert_eq!(unsafe { oakengine_sequence_add_default_transition(seq, std::ptr::null_mut(), 0) }, 0); + assert_eq!(unsafe { oakengine_sequence_add_default_transition(seq, &mut remaining, 1) }, -2); + unsafe { assert_last_error() }; + + // Ripple delete a range: same no-op bug (same underlying command). + assert_eq!(unsafe { oakengine_sequence_ripple_delete_range(seq, 0, 10) }, 0); + assert_eq!(unsafe { oakengine_sequence_ripple_delete_range(seq, 10, 10) }, -1); // empty range + unsafe { assert_last_error() }; + assert_eq!(unsafe { oakengine_sequence_clip_count(seq, 0, 0) }, 1); // BUG: no-op + + // ---- add_default_nodes + remove_track + delete_empty_tracks -------------- + // Runs after the clip phase so video track 0 keeps its content. + assert_eq!(unsafe { oakengine_sequence_add_default_nodes(seq) }, 0); + assert_eq!(unsafe { oakengine_sequence_track_count(seq, &mut v, &mut a, &mut s) }, 0); + assert_eq!((v, a, s), (3, 2, 1)); + + assert_eq!(unsafe { oakengine_sequence_remove_track(seq, 1, 0) }, 0); + assert_eq!(unsafe { oakengine_sequence_track_count(seq, &mut v, &mut a, &mut s) }, 0); + assert_eq!((v, a, s), (3, 1, 1)); + assert_eq!(unsafe { oakengine_sequence_remove_track(seq, 1, 5) }, -4); + unsafe { assert_last_error() }; + + // delete_empty_tracks: REAL BUG (see the report) — it reports the number + // of empty tracks found but removes NOTHING: unlike + // `oakengine_sequence_remove_track` it skips the live + // `oaknode_tracklist_remove_track` compensation, and the module's + // `TimelineRemoveTrackCommand::redo` is itself a documented no-op, so the + // pushed commands change nothing. The counts below stay as they were. + assert_eq!(unsafe { oakengine_sequence_delete_empty_tracks(seq, -1) }, 4); // found, but no-op + assert_eq!(unsafe { oakengine_sequence_delete_empty_tracks(seq, 0) }, 2); // found, but no-op + assert_eq!(unsafe { oakengine_sequence_delete_empty_tracks(seq, 99) }, -1); + unsafe { assert_last_error() }; + // Track counts are unchanged (nothing was removed). + assert_eq!(unsafe { oakengine_sequence_track_count(seq, &mut v, &mut a, &mut s) }, 0); + assert_eq!((v, a, s), (3, 1, 1)); + + // ---- detached clip created by the facade --------------------------------- + // The block-family accessors take `OakEngineBlock*`; the clip box is the + // layout-identical `OakEngineClip` wrapper, so it is cast (both are + // `#[repr(C)]` wrappers around one CHandle). + let detached = unsafe { oakengine_clip_create_empty(c"Detached".as_ptr()) }; + assert!(!detached.is_null()); + let dblk = detached.cast::(); + assert_eq!(unsafe { oakengine_clip_is_enabled(detached) }, 1); + assert_eq!(unsafe { oakengine_block_is_enabled(dblk) }, 1); + assert_eq!(unsafe { oakengine_block_set_enabled(dblk, 0) }, 0); + assert_eq!(unsafe { oakengine_block_is_enabled(dblk) }, 0); + assert_eq!(unsafe { oakengine_block_set_enabled(dblk, 1) }, 0); + assert_eq!(unsafe { oakengine_block_is_gap(dblk) }, 0); + assert_eq!(unsafe { oakengine_block_link_count(dblk) }, 0); + assert!(unsafe { oakengine_block_link_at(dblk, 0) }.is_null()); + assert!(unsafe { oakengine_block_get_track(dblk) }.is_null()); + assert!(unsafe { oakengine_block_next(dblk) }.is_null()); + assert!(unsafe { oakengine_block_prev(dblk) }.is_null()); + assert!(unsafe { oakengine_clip_get_sequence(detached) }.is_null()); + assert!(unsafe { oakengine_clip_in_transition(dblk) }.is_null()); + assert!(unsafe { oakengine_clip_out_transition(dblk) }.is_null()); + assert!(unsafe { oakengine_transition_connected_in_block(dblk) }.is_null()); + assert!(unsafe { oakengine_transition_connected_out_block(dblk) }.is_null()); + assert!(unsafe { oakengine_clip_get_connected_viewer(dblk) }.is_null()); + // Detached clips are trackless: media queries work, edits need a track. + // NOTE: the frame-timestamp media-in setter needs the track for its + // timebase, but the rational variant applies directly (no track needed). + assert_eq!( + unsafe { oakengine_clip_get_media_in_rational(detached, &mut mn, &mut md) }, + 0 + ); + assert_eq!((mn, md), (0, 1)); + assert_eq!(unsafe { oakengine_clip_set_media_in(detached, 5, 0) }, -2); // needs a track timebase + assert_eq!(unsafe { oakengine_clip_set_media_in_rational(detached, 1, 1, 0) }, 0); + assert_eq!( + unsafe { oakengine_clip_get_media_in_rational(detached, &mut mn, &mut md) }, + 0 + ); + assert_eq!((mn, md), (1, 1)); + assert_eq!(unsafe { oakengine_clip_set_media_in_rational(detached, 0, 1, 0) }, 0); + assert_eq!(unsafe { oakengine_clip_set_media_in_rational(detached, 1, 0, 0) }, -1); // den 0 + unsafe { assert_last_error() }; + assert_eq!(unsafe { oakengine_clip_trim(detached, 0, 10) }, -2); + assert_eq!(unsafe { oakengine_block_set_length_and_media_out(dblk, 40) }, -2); + assert_eq!( + unsafe { oakengine_clip_get_range(detached, &mut cin, &mut cout, &mut cmi) }, + -2 + ); + unsafe { assert_last_error() }; + // Cache no-op stubs on a live handle. + unsafe { oakengine_clip_request_invalidate(detached, 0, 10, 1) }; + unsafe { oakengine_clip_request_invalidate_connected(detached, 0, 0, 1, 1, 1) }; + unsafe { oakengine_clip_discard_cache(detached) }; + unsafe { oakengine_clip_add_cache_passthrough(detached, remaining) }; + // NULL-label variant also creates a clip. + let detached2 = unsafe { oakengine_clip_create_empty(std::ptr::null()) }; + assert!(!detached2.is_null()); + unsafe { free_box::(detached2) }; + + // ---- node helpers over a boxed module clip node --------------------------- + let node_box = unsafe { box_handle::((*remaining).handle) }; + assert_eq!(unsafe { oakengine_node_is_block(node_box) }, 1); + assert_eq!(unsafe { oakengine_node_is_transition(node_box) }, 0); + assert_eq!( + unsafe { oakengine_multicam_switch_source(node_box, std::ptr::null_mut(), 0, 0, 0.0, std::ptr::null_mut()) }, + 0 + ); + assert!(unsafe { oakengine_clip_find_multicam(node_box) }.is_null()); + unsafe { free_box::(node_box) }; + + // ---- standalone workarea handle family ------------------------------------- + let wa = unsafe { oakengine_workarea_create() }; + assert!(!wa.is_null()); + let (mut wn0, mut wd0, mut wn1, mut wd1, mut wen) = (-1i64, -1i64, -1i64, -1i64, -1); + assert_eq!( + unsafe { oakengine_workarea_get(wa, &mut wn0, &mut wd0, &mut wn1, &mut wd1, &mut wen) }, + 0 + ); + assert_eq!((wn0, wd0, wn1, wd1, wen), (0, 1, 2147483647, 1, 0)); + assert_eq!(unsafe { oakengine_workarea_set_range(wa, 10, 1, 20, 1) }, 0); + assert_eq!(unsafe { oakengine_workarea_set_enabled(wa, 1) }, 0); + assert_eq!( + unsafe { oakengine_workarea_get(wa, &mut wn0, &mut wd0, &mut wn1, &mut wd1, &mut wen) }, + 0 + ); + assert_eq!((wn0, wd0, wn1, wd1, wen), (10, 1, 20, 1, 1)); + // Undoable variants (pushed, not added to a parent). + assert_eq!( + unsafe { oakengine_workarea_set_range_undoable(wa, 30, 1, 40, 1, 10, 1, 20, 1, std::ptr::null_mut()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_workarea_set_enabled_undoable(wa, 0, std::ptr::null_mut()) }, + 0 + ); + assert_eq!( + unsafe { oakengine_workarea_get(wa, &mut wn0, &mut wd0, &mut wn1, &mut wd1, &mut wen) }, + 0 + ); + assert_eq!((wn0, wd0, wn1, wd1, wen), (30, 1, 40, 1, 0)); + // Reset sentinels: in = 0/1, out = RATIONAL_MAX/1. + let (mut rn, mut rd, mut ron, mut rod) = (-1i64, -1i64, -1i64, -1i64); + unsafe { oakengine_workarea_reset_in_out(&mut rn, &mut rd, &mut ron, &mut rod) }; + assert_eq!((rn, rd), (0, 1)); + assert_eq!((ron, rod), (2147483647, 1)); + // Free contracts: NULL, empty box, then the live handle. + unsafe { oakengine_workarea_free(std::ptr::null_mut()) }; + let empty_wa = unsafe { empty_box!(OakEngineWorkarea) }; + unsafe { oakengine_workarea_free(empty_wa) }; + unsafe { oakengine_workarea_free(wa) }; + + // ---- standalone marker-list handle family ----------------------------------- + let list_h = unsafe { oakengine::bridge::timeline::oaktimeline_marker_list_create() }; + assert!(!list_h.is_null()); + let list = unsafe { box_handle::(list_h) }; + assert_eq!(unsafe { oakengine_marker_list_count(list) }, 0); + assert_eq!( + unsafe { oakengine_marker_list_add(list, 1, 1, 1, 1, c"lm".as_ptr(), 5) }, + 0 + ); + assert_eq!( + unsafe { oakengine_marker_list_add(list, 5, 1, 5, 1, std::ptr::null(), 1) }, + 0 + ); + assert_eq!(unsafe { oakengine_marker_list_count(list) }, 2); + + let m1 = unsafe { oakengine_marker_list_at(list, 0) }; + assert!(!m1.is_null()); + assert!(unsafe { oakengine_marker_list_at(list, 5) }.is_null()); + let m2 = unsafe { oakengine_marker_list_marker_at_time(list, 1, 1) }; + assert!(!m2.is_null()); + assert!(unsafe { oakengine_marker_list_marker_at_time(list, 9, 1) }.is_null()); + + // Marker getters (rational time, buf/size name, color). + let (mut g0, mut g1, mut g2, mut g3) = (-1i64, -1i64, -1i64, -1i64); + assert_eq!(unsafe { oakengine_marker_get_time(m1, &mut g0, &mut g1, &mut g2, &mut g3) }, 0); + assert_eq!((g0, g1, g2, g3), (1, 1, 1, 1)); + let mut mname2 = [0 as c_char; 64]; + assert_eq!(unsafe { oakengine_marker_get_name(m1, mname2.as_mut_ptr(), 64) }, 2); + assert_eq!(unsafe { read_buf(&mut mname2) }, "lm"); + assert_eq!(unsafe { oakengine_marker_get_name(m1, std::ptr::null_mut(), 0) }, 2); + assert_eq!(unsafe { oakengine_marker_get_color(m1) }, 5); + assert_eq!(unsafe { oakengine_marker_has_sibling_at_time(m1, 1, 1) }, 0); + + // Time edits (live + command + commit). + assert_eq!(unsafe { oakengine_marker_set_time_live(m1, 2, 1, 2, 1) }, 0); + assert_eq!(unsafe { oakengine_marker_get_time(m1, &mut g0, &mut g1, &mut g2, &mut g3) }, 0); + assert_eq!((g0, g1, g2, g3), (2, 1, 2, 1)); + let time_cmd = unsafe { oakengine_marker_set_time_command(m1, 3, 1) }; + assert!(!time_cmd.is_null()); + unsafe { oakengine_undo_command_free(time_cmd) }; + assert!(unsafe { oakengine_marker_set_time_command(m1, 3, 0) }.is_null()); // den 0 + assert_eq!( + unsafe { oakengine_marker_commit_time(m1, 2, 1, 2, 1, 4, 1, 4, 1, std::ptr::null_mut()) }, + 0 + ); + assert_eq!(unsafe { oakengine_marker_get_time(m1, &mut g0, &mut g1, &mut g2, &mut g3) }, 0); + assert_eq!((g0, g1, g2, g3), (4, 1, 4, 1)); + + // Re-add the marker (its data is read back through the list). + assert_eq!(unsafe { oakengine_marker_list_add_existing(list, m1) }, 0); + assert_eq!(unsafe { oakengine_marker_list_count(list) }, 3); + + // Batch property set (color + name, one undoable command). + assert_eq!( + unsafe { oakengine_marker_set_properties([m1].as_mut_ptr(), 1, 7, c"renamed".as_ptr(), 0, 0, 1, 0, 1, std::ptr::null_mut()) }, + 0 + ); + assert_eq!(unsafe { oakengine_marker_get_color(m1) }, 7); + assert_eq!(unsafe { oakengine_marker_get_name(m1, mname2.as_mut_ptr(), 64) }, 7); + assert_eq!(unsafe { read_buf(&mut mname2) }, "renamed"); + // No-op property set (nothing to change) succeeds with zero commands. + assert_eq!( + unsafe { oakengine_marker_set_properties([m1].as_mut_ptr(), 1, -1, std::ptr::null(), 0, 0, 1, 0, 1, std::ptr::null_mut()) }, + 0 + ); + // NULL markers / zero count -> E_INVALID. + assert_eq!( + unsafe { oakengine_marker_set_properties(std::ptr::null_mut(), 0, 0, std::ptr::null(), 0, 0, 1, 0, 1, std::ptr::null_mut()) }, + -1 + ); + + // Remove the marker (undoable). + assert_eq!(unsafe { oakengine_marker_remove(m1) }, 0); + assert_eq!(unsafe { oakengine_marker_list_count(list) }, 2); + // Detached-marker creation is a stub -> NULL. + assert!(unsafe { oakengine_marker_create(0, 0, 1, 0, 1, std::ptr::null()) }.is_null()); + // Free contracts: NULL, then the (borrowed) marker box. + unsafe { oakengine_marker_free(std::ptr::null_mut()) }; + let empty_marker = unsafe { empty_box!(OakEngineMarker) }; + unsafe { oakengine_marker_free(empty_marker) }; + unsafe { oakengine_marker_free(m1) }; + unsafe { free_box::(m2) }; + // The surviving duplicate is the copy made by add_existing BEFORE the + // property set (m1 — the re-colored original — was index 0 and was the + // one removed), so it carries the original color 5 / name "lm". + let dup = unsafe { oakengine_marker_list_marker_at_time(list, 4, 1) }; + assert!(!dup.is_null()); + assert_eq!(unsafe { oakengine_marker_get_color(dup) }, 5); + assert_eq!(unsafe { oakengine_marker_get_name(dup, mname2.as_mut_ptr(), 64) }, 2); + assert_eq!(unsafe { read_buf(&mut mname2) }, "lm"); + unsafe { oakengine_marker_free(dup) }; + unsafe { free_box::(list) }; + + // ---- add_sequence_clip / add_footage_clip (documented clean failures) ------ + // Self-nesting is rejected. + assert!(unsafe { oakengine_sequence_add_sequence_clip(seq, seq, 0, 0, 0, 30, 0) }.is_null()); + unsafe { assert_last_error() }; + // A second sequence lives in its own scratch project -> cross-project. + assert!(unsafe { oakengine_sequence_add_sequence_clip(seq, seq2, 0, 0, 0, 30, 0) }.is_null()); + unsafe { assert_last_error() }; + // Subtitle sequence clips are unsupported. + assert!(unsafe { oakengine_sequence_add_sequence_clip(seq, seq2, 2, 0, 0, 30, 0) }.is_null()); + unsafe { assert_last_error() }; + // Invalid range. + assert!(unsafe { oakengine_sequence_add_sequence_clip(seq, seq2, 0, 0, 30, 30, 0) }.is_null()); + unsafe { assert_last_error() }; + // Footage clips: the footage lives in the real project, the sequence in + // its scratch project -> different projects -> clean NULL + error. + let footage_node = unsafe { + oaknode::ffi::footage::oaknode_footage_create((*project).handle, c"/no/such/media.mp4".as_ptr()) + }; + assert!(!footage_node.is_null()); + let footage_node_box = unsafe { box_handle::(footage_node) }; + let footage = unsafe { oakengine_footage_borrow(footage_node_box) }; + assert!(!footage.is_null()); + assert!(unsafe { oakengine_sequence_add_footage_clip(seq, footage, 0, 0, 0, 30, 0) }.is_null()); + unsafe { assert_last_error() }; + // Invalid clip range. + assert!(unsafe { oakengine_sequence_add_footage_clip(seq, footage, 0, 0, 30, 30, 0) }.is_null()); + unsafe { assert_last_error() }; + // Bad track type (subtitle clips unsupported). + assert!(unsafe { oakengine_sequence_add_footage_clip(seq, footage, 2, 0, 0, 30, 0) }.is_null()); + unsafe { assert_last_error() }; + // Out-of-range track index. + assert!(unsafe { oakengine_sequence_add_footage_clip(seq, footage, 0, 9, 0, 30, 0) }.is_null()); + unsafe { assert_last_error() }; + unsafe { free_box::(footage) }; + unsafe { free_box::(footage_node_box) }; + + // ---- input ID getters (static strings) -------------------------------------- + let ids = [ + (oakengine_clip_buffer_input_id() as *const c_char, "buffer_in"), + (oakengine_clip_speed_input_id() as *const c_char, "speed_in"), + (oakengine_clip_reverse_input_id() as *const c_char, "reverse_in"), + (oakengine_clip_maintain_audio_pitch_input_id() as *const c_char, "maintain_audio_pitch_in"), + (oakengine_clip_loop_mode_input_id() as *const c_char, "loop_in"), + (oakengine_clip_auto_cache_input_id() as *const c_char, "autocache_in"), + ]; + for (p, expect) in ids { + assert!(!p.is_null()); + assert_eq!(unsafe { std::ffi::CStr::from_ptr(p) }.to_str().unwrap(), expect); + } + + // ---- track height constants ------------------------------------------------- + assert_eq!(unsafe { oakengine_track_height_default() }, 3.0); + assert_eq!(unsafe { oakengine_track_default_height_in_pixels() }, 39); // 3.0 * 13px font + assert_eq!(unsafe { oakengine_track_height_internal_to_pixels(3.0) }, 39); + assert_eq!(unsafe { oakengine_track_height_pixels_to_internal(13) }, 1.0); + assert_eq!(unsafe { oakengine_track_height_interval() }, 0.5); + assert_eq!(unsafe { oakengine_track_height_minimum() }, 1.5); + + // ---- last_error is readable any time ---------------------------------------- + let mut err = [0 as c_char; 256]; + let elen = unsafe { oakengine_sequence_last_error(err.as_mut_ptr(), 256) }; + assert!(elen >= 0); + + // ---- cleanup: free the project shell. The alive count drops by exactly + // the project node; sequence/track/clip nodes stay live for the process + // (no `oakengine_sequence_free`; sequences keep their own scratch + // projects — see the module docs). + let alive_before_free = alive(); + unsafe { oakengine_project_free(project) }; + assert_eq!(alive(), alive_before_free - 1, "project_free releases the project shell"); + + // Free the remaining borrowed clip/track boxes (they release borrowed + // module handles; the node objects stay owned by the scratch projects). + unsafe { free_box::(detached) }; + unsafe { free_box::(remaining) }; + unsafe { free_box::(track) }; +} + +// --------------------------------------------------------------------------- +// Non-mutating failure paths (NULL / empty-box / garbage arguments; no undo +// stack or per-sequence caches touched, so it runs in parallel) +// --------------------------------------------------------------------------- + +/// Every timeline export rejects NULL and empty-handle arguments with a +/// clean documented value — never a crash/abort/panic. +#[test] +fn timeline_zu_failure_paths() { + common::force_link(); + let _ = force_runtime_syms(); + + let mut buf = [0 as c_char; 256]; + let mut n = 0i64; + let mut d = 0i64; + let mut i32v = 0; + let mut i32w = 0; + let mut f64v = 0.0; + let mut u64v = 0u64; + + // ---- sequence family: NULL handles --------------------------------------- + assert!(unsafe { oakengine_sequence_new(std::ptr::null_mut(), c"x".as_ptr()) }.is_null()); + assert_eq!(unsafe { oakengine_sequence_name(std::ptr::null(), buf.as_mut_ptr(), 64) }, -1); + assert_eq!(unsafe { oakengine_sequence_get_length(std::ptr::null(), &mut f64v) }, -1); + assert_eq!( + unsafe { oakengine_sequence_get_length_rational(std::ptr::null(), &mut i32v, &mut i32w) }, + -1 + ); + assert_eq!( + unsafe { oakengine_sequence_get_frame_rate(std::ptr::null(), &mut i32v, &mut i32w) }, + -1 + ); + assert_eq!( + unsafe { oakengine_sequence_get_video_params(std::ptr::null(), &mut i32v, &mut i32w, &mut i32v, &mut i32w) }, + -1 + ); + assert_eq!( + unsafe { + oakengine_sequence_get_video_params_ex( + std::ptr::null(), + &mut i32v, + &mut i32w, + &mut i32v, + &mut i32w, + &mut i32v, + &mut i32w, + &mut i32v, + &mut i32w, + &mut i32v, + ) + }, + -1 + ); + assert_eq!( + unsafe { oakengine_sequence_set_video_params(std::ptr::null_mut(), 1920, 1080, 30, 1, 1, 1, 0, 4, 0) }, + -1 + ); + assert_eq!( + unsafe { oakengine_sequence_get_audio_params(std::ptr::null(), &mut i32v, &mut u64v) }, + -1 + ); + assert_eq!( + unsafe { oakengine_sequence_set_audio_params(std::ptr::null_mut(), 48000, 0x3, 1) }, + -1 + ); + assert_eq!(unsafe { oakengine_sequence_get_preview_divider(std::ptr::null()) }, 0); + assert_eq!(unsafe { oakengine_sequence_set_preview_divider(std::ptr::null_mut(), 1, 0) }, -1); + assert_eq!(unsafe { oakengine_sequence_get_video_auto_cache(std::ptr::null()) }, 0); + assert_eq!(unsafe { oakengine_sequence_set_video_auto_cache(std::ptr::null_mut(), 1, 1) }, -1); + assert_eq!( + unsafe { oakengine_sequence_track_count(std::ptr::null(), &mut i32v, &mut i32v, &mut i32v) }, + -1 + ); + assert_eq!(unsafe { oakengine_sequence_get_playhead(std::ptr::null(), &mut n) }, -1); + assert_eq!(unsafe { oakengine_sequence_set_playhead(std::ptr::null_mut(), 0) }, -1); + assert_eq!(unsafe { oakengine_sequence_get_playhead_seconds(std::ptr::null(), &mut f64v) }, -1); + assert_eq!(unsafe { oakengine_sequence_workarea_is_enabled(std::ptr::null()) }, 0); + assert_eq!(unsafe { oakengine_sequence_get_workarea(std::ptr::null(), &mut n, &mut d) }, -1); + assert_eq!(unsafe { oakengine_sequence_set_workarea(std::ptr::null_mut(), 1, 0, 10) }, -1); + assert_eq!(unsafe { oakengine_sequence_marker_count(std::ptr::null()) }, 0); + assert_eq!( + unsafe { oakengine_sequence_marker_at(std::ptr::null(), 0, &mut n, buf.as_mut_ptr(), 64, &mut i32v) }, + -1 + ); + assert_eq!(unsafe { oakengine_sequence_marker_add(std::ptr::null_mut(), 0, c"x".as_ptr()) }, -1); + assert_eq!( + unsafe { oakengine_sequence_marker_add_ex(std::ptr::null_mut(), 0, c"x".as_ptr(), 0) }, + -1 + ); + assert_eq!(unsafe { oakengine_sequence_marker_remove(std::ptr::null_mut(), 0) }, -1); + assert_eq!(unsafe { oakengine_sequence_marker_rename(std::ptr::null_mut(), 0, c"x".as_ptr()) }, -1); + assert_eq!(unsafe { oakengine_sequence_marker_remove_many(std::ptr::null_mut(), &n, 1) }, -1); + assert_eq!(unsafe { oakengine_sequence_marker_remove_many(std::ptr::null_mut(), std::ptr::null(), 0) }, -1); + assert_eq!(unsafe { oakengine_sequence_add_track(std::ptr::null_mut(), 0) }, -1); + assert!(unsafe { oakengine_sequence_add_track_command(std::ptr::null_mut(), 0, 0, std::ptr::null_mut()) } + .is_null()); + assert!(unsafe { oakengine_sequence_ripple_tracks_command(std::ptr::null_mut(), 0, std::ptr::null(), 0, 0, 0, 99) } + .is_null()); + assert!(unsafe { oakengine_sequence_add_footage_clip(std::ptr::null_mut(), std::ptr::null_mut(), 0, 0, 0, 1, 0) } + .is_null()); + assert!(unsafe { oakengine_sequence_add_sequence_clip(std::ptr::null_mut(), std::ptr::null_mut(), 0, 0, 0, 1, 0) } + .is_null()); + assert_eq!(unsafe { oakengine_sequence_clip_count(std::ptr::null_mut(), 0, 0) }, -1); + assert!(unsafe { oakengine_sequence_clip_at(std::ptr::null_mut(), 0, 0, 0) }.is_null()); + assert_eq!(unsafe { oakengine_sequence_split_clip(std::ptr::null_mut(), 0, 0, 0, 10) }, -1); + assert_eq!(unsafe { oakengine_sequence_ripple_delete_clip(std::ptr::null_mut(), 0, 0, 0) }, -1); + assert_eq!(unsafe { oakengine_sequence_move_clip(std::ptr::null_mut(), 0, 0, 0, 10) }, -1); + assert_eq!(unsafe { oakengine_sequence_split_clips(std::ptr::null_mut(), std::ptr::null_mut(), 0, 10) }, -1); + let mut rippled = -1; + assert_eq!( + unsafe { oakengine_sequence_delete_clips(std::ptr::null_mut(), std::ptr::null_mut(), 0, 0, std::ptr::null(), 0, &mut rippled) }, + -1 + ); + assert_eq!(unsafe { oakengine_sequence_ripple_delete_range(std::ptr::null_mut(), 0, 10) }, -1); + assert_eq!(unsafe { oakengine_sequence_ripple_delete_in_to_out(std::ptr::null_mut(), 0, 0, 10) }, -1); + assert_eq!(unsafe { oakengine_sequence_trim_clips_to(std::ptr::null_mut(), 0, 10) }, -1); + assert_eq!(unsafe { oakengine_sequence_delete_empty_tracks(std::ptr::null_mut(), -1) }, -1); + assert_eq!(unsafe { oakengine_sequence_remove_track(std::ptr::null_mut(), 0, 0) }, -1); + assert_eq!(unsafe { oakengine_sequence_move_track(std::ptr::null_mut(), 0, 0, 1) }, -1); + assert_eq!(unsafe { oakengine_sequence_add_default_nodes(std::ptr::null_mut()) }, -1); + // add_default_transition IGNORES the sequence handle entirely: NULL seq + // with an empty clip set is a clean no-op, NULL seq with clips -> E_INVALID. + assert_eq!(unsafe { oakengine_sequence_add_default_transition(std::ptr::null_mut(), std::ptr::null_mut(), 0) }, 0); + assert_eq!(unsafe { oakengine_sequence_add_default_transition(std::ptr::null_mut(), std::ptr::null_mut(), 1) }, -1); + assert!(unsafe { oakengine_sequence_track_at(std::ptr::null(), 0, 0) }.is_null()); + assert!(unsafe { oakengine_sequence_track_list(std::ptr::null_mut(), 0) }.is_null()); + + // ---- clip / block / track families: NULL handles -------------------------- + assert_eq!( + unsafe { oakengine_clip_get_range(std::ptr::null(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut()) }, + -1 + ); + assert!(unsafe { oakengine_clip_get_sequence(std::ptr::null()) }.is_null()); + assert_eq!( + unsafe { oakengine_clip_get_media_range_rational(std::ptr::null(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut()) }, + -1 + ); + assert_eq!( + unsafe { oakengine_clip_get_media_in_rational(std::ptr::null(), std::ptr::null_mut(), std::ptr::null_mut()) }, + -1 + ); + assert_eq!(unsafe { oakengine_clip_set_media_in(std::ptr::null_mut(), 0, 0) }, -1); + assert_eq!(unsafe { oakengine_clip_set_media_in_rational(std::ptr::null_mut(), 0, 1, 0) }, -1); + assert_eq!(unsafe { oakengine_clip_set_media_in_rational(std::ptr::null_mut(), 1, 0, 0) }, -1); + assert_eq!(unsafe { oakengine_clip_is_enabled(std::ptr::null()) }, 0); + assert_eq!(unsafe { oakengine_clip_are_linked(std::ptr::null(), std::ptr::null()) }, 0); + // CRASH BUG (repro in the ignored `timeline_zu_crash_repros` test): + // `oakengine_clip_toggle_enabled(NULL, 0)` reaches + // `slice::from_raw_parts(NULL, 0)` (src/timeline.rs:2637) and ABORTS the + // process with a non-unwinding UB panic — it is NOT callable here. + assert_eq!(unsafe { oakengine_clip_toggle_enabled(std::ptr::null_mut(), 1) }, -1); + assert_eq!(unsafe { oakengine_clip_set_linked(std::ptr::null_mut(), 0, 1) }, 0); + assert_eq!(unsafe { oakengine_clip_set_linked(std::ptr::null_mut(), 1, 1) }, -1); + // NOTE: `oakengine_clip_create_empty` is exercised in the serialized + // lifecycle test (it creates an oaknode node, which would race the + // alive-count assertions there if called from this parallel test). + unsafe { oakengine_clip_request_invalidate(std::ptr::null_mut(), 0, 10, 1) }; + unsafe { oakengine_clip_request_invalidate_connected(std::ptr::null_mut(), 0, 0, 1, 1, 1) }; + unsafe { oakengine_clip_discard_cache(std::ptr::null_mut()) }; + unsafe { oakengine_clip_add_cache_passthrough(std::ptr::null_mut(), std::ptr::null_mut()) }; + assert!(unsafe { oakengine_clip_in_transition(std::ptr::null()) }.is_null()); + assert!(unsafe { oakengine_clip_out_transition(std::ptr::null()) }.is_null()); + assert!(unsafe { oakengine_clip_get_connected_viewer(std::ptr::null()) }.is_null()); + + assert_eq!(unsafe { oakengine_block_is_enabled(std::ptr::null()) }, 0); + assert_eq!(unsafe { oakengine_block_set_enabled(std::ptr::null_mut(), 1) }, -1); + assert_eq!(unsafe { oakengine_block_set_length_and_media_out(std::ptr::null_mut(), 10) }, -1); + assert_eq!(unsafe { oakengine_block_set_length_and_media_out(std::ptr::null_mut(), 0) }, -1); + assert_eq!(unsafe { oakengine_block_is_gap(std::ptr::null()) }, 0); + assert!(unsafe { oakengine_block_get_track(std::ptr::null()) }.is_null()); + assert!(unsafe { oakengine_block_next(std::ptr::null()) }.is_null()); + assert!(unsafe { oakengine_block_prev(std::ptr::null()) }.is_null()); + assert_eq!( + unsafe { oakengine_block_get_range(std::ptr::null(), std::ptr::null_mut(), std::ptr::null_mut()) }, + -1 + ); + assert_eq!(unsafe { oakengine_block_link_count(std::ptr::null()) }, 0); + assert!(unsafe { oakengine_block_link_at(std::ptr::null(), 0) }.is_null()); + + assert_eq!(unsafe { oakengine_track_block_count(std::ptr::null()) }, -1); + assert!(unsafe { oakengine_track_block_at(std::ptr::null(), 0) }.is_null()); + assert!(unsafe { oakengine_track_block_at_time(std::ptr::null(), 0) }.is_null()); + assert!(unsafe { oakengine_track_visible_block_at_time(std::ptr::null_mut(), 0) }.is_null()); + assert!(unsafe { oakengine_track_nearest_block_before(std::ptr::null(), 0) }.is_null()); + assert!(unsafe { oakengine_track_nearest_block_after(std::ptr::null(), 0) }.is_null()); + assert!(unsafe { oakengine_track_nearest_block_before_or_at(std::ptr::null(), 0) }.is_null()); + assert!(unsafe { oakengine_track_nearest_block_after_or_at(std::ptr::null(), 0) }.is_null()); + assert_eq!(unsafe { oakengine_track_type(std::ptr::null()) }, -1); + assert_eq!(unsafe { oakengine_track_get_height(std::ptr::null(), 0, 0, &mut f64v) }, -1); + assert_eq!(unsafe { oakengine_track_set_height(std::ptr::null_mut(), 0, 0, 1.0) }, -1); + assert_eq!(unsafe { oakengine_track_is_muted(std::ptr::null(), 0, 0) }, 0); + assert_eq!(unsafe { oakengine_track_set_muted(std::ptr::null_mut(), 0, 0, 1) }, -1); + assert_eq!(unsafe { oakengine_track_is_locked(std::ptr::null(), 0, 0) }, 0); + assert_eq!(unsafe { oakengine_track_set_locked(std::ptr::null_mut(), 0, 0, 1) }, -1); + assert_eq!(unsafe { oakengine_track_get_length(std::ptr::null(), 0, 0, &mut n) }, -1); + assert_eq!(unsafe { oakengine_track_is_range_free(std::ptr::null(), 0, 0, 0, 10) }, -1); + assert_eq!(unsafe { oakengine_track_is_range_free(std::ptr::null(), 0, 0, -1, 10) }, -1); + + // ---- marker handle family: NULL / empty boxes ------------------------------ + assert_eq!(unsafe { oakengine_marker_list_count(std::ptr::null()) }, 0); + assert_eq!( + unsafe { oakengine_marker_list_add(std::ptr::null_mut(), 0, 1, 0, 1, c"x".as_ptr(), 0) }, + -1 + ); + assert_eq!( + unsafe { oakengine_marker_list_add_existing(std::ptr::null_mut(), std::ptr::null_mut()) }, + -1 + ); + assert!(unsafe { oakengine_marker_list_at(std::ptr::null(), 0) }.is_null()); + assert!(unsafe { oakengine_marker_list_at(std::ptr::null(), -1) }.is_null()); + assert!(unsafe { oakengine_marker_list_marker_at_time(std::ptr::null(), 0, 1) }.is_null()); + assert!(unsafe { oakengine_marker_create(0, 0, 1, 0, 1, std::ptr::null()) }.is_null()); + unsafe { oakengine_marker_free(std::ptr::null_mut()) }; + assert_eq!( + unsafe { oakengine_marker_get_time(std::ptr::null(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut()) }, + -1 + ); + assert_eq!(unsafe { oakengine_marker_get_name(std::ptr::null(), std::ptr::null_mut(), 0) }, -1); + assert_eq!(unsafe { oakengine_marker_get_color(std::ptr::null()) }, -1); + assert_eq!(unsafe { oakengine_marker_has_sibling_at_time(std::ptr::null(), 0, 1) }, 0); + assert_eq!(unsafe { oakengine_marker_set_time_live(std::ptr::null_mut(), 0, 1, 0, 1) }, -1); + assert_eq!( + unsafe { oakengine_marker_commit_time(std::ptr::null_mut(), 0, 1, 0, 1, 0, 1, 0, 1, std::ptr::null_mut()) }, + -1 + ); + assert!(unsafe { oakengine_marker_set_time_command(std::ptr::null_mut(), 1, 0) }.is_null()); + assert!(unsafe { oakengine_marker_set_time_command(std::ptr::null_mut(), 1, 1) }.is_null()); + assert_eq!(unsafe { oakengine_marker_remove(std::ptr::null_mut()) }, -1); + assert_eq!( + unsafe { oakengine_marker_set_properties(std::ptr::null_mut(), 0, 0, std::ptr::null(), 0, 0, 1, 0, 1, std::ptr::null_mut()) }, + -1 + ); + + // ---- workarea family: NULL / empty boxes ------------------------------------ + let wa = unsafe { oakengine_workarea_create() }; + assert!(!wa.is_null()); + unsafe { oakengine_workarea_free(wa) }; + assert_eq!( + unsafe { oakengine_workarea_get(std::ptr::null(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut()) }, + -1 + ); + assert_eq!(unsafe { oakengine_workarea_set_range(std::ptr::null_mut(), 0, 1, 1, 1) }, -1); + assert_eq!(unsafe { oakengine_workarea_set_enabled(std::ptr::null_mut(), 1) }, -1); + assert_eq!( + unsafe { oakengine_workarea_set_range_undoable(std::ptr::null_mut(), 0, 1, 1, 1, 0, 1, 0, 1, std::ptr::null_mut()) }, + -1 + ); + assert_eq!( + unsafe { oakengine_workarea_set_enabled_undoable(std::ptr::null_mut(), 1, std::ptr::null_mut()) }, + -1 + ); + // reset_in_out is a pure out-param filler; NULL pointers are safe no-ops. + unsafe { oakengine_workarea_reset_in_out(std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut()) }; + + // ---- node helpers ------------------------------------------------------------- + assert_eq!(unsafe { oakengine_node_is_block(std::ptr::null()) }, 0); + assert_eq!(unsafe { oakengine_node_is_transition(std::ptr::null()) }, 0); + assert!(unsafe { oakengine_clip_find_multicam(std::ptr::null_mut()) }.is_null()); + assert_eq!( + unsafe { oakengine_multicam_switch_source(std::ptr::null_mut(), std::ptr::null_mut(), 0, 0, 0.0, std::ptr::null_mut()) }, + -1 + ); + assert!(unsafe { oakengine_transition_connected_in_block(std::ptr::null()) }.is_null()); + assert!(unsafe { oakengine_transition_connected_out_block(std::ptr::null()) }.is_null()); + + // ---- empty boxes: non-NULL pointers wrapping a NULL CHandle ------------------ + // `unbox` rejects them with E_INVALID even where a NULL pointer is a + // documented 0-return (the box is non-NULL). + let eseq = unsafe { empty_box!(OakEngineSequence) }; + let etrack = unsafe { empty_box!(OakEngineTrack) }; + let eclip = unsafe { empty_box!(OakEngineClip) }; + let eblk = unsafe { empty_box!(OakEngineBlock) }; + let emarker = unsafe { empty_box!(OakEngineMarker) }; + let elist = unsafe { empty_box!(OakEngineMarkerList) }; + let ewa = unsafe { empty_box!(OakEngineWorkarea) }; + let enode = unsafe { empty_box!(OakEngineNode) }; + + assert_eq!(unsafe { oakengine_sequence_name(eseq, buf.as_mut_ptr(), 64) }, -1); + assert_eq!(unsafe { oakengine_sequence_marker_count(eseq) }, -1); + assert_eq!(unsafe { oakengine_sequence_get_preview_divider(eseq) }, -1); + assert_eq!(unsafe { oakengine_sequence_get_video_auto_cache(eseq) }, -1); + assert_eq!(unsafe { oakengine_sequence_workarea_is_enabled(eseq) }, -1); + assert_eq!(unsafe { oakengine_sequence_add_track(eseq, 0) }, -1); + assert_eq!(unsafe { oakengine_sequence_add_track(eseq, 3) }, -1); // type check first + assert!(unsafe { oakengine_sequence_track_at(eseq, 0, 0) }.is_null()); + assert!(unsafe { oakengine_sequence_track_at(eseq, 3, 0) }.is_null()); + assert!(unsafe { oakengine_sequence_track_list(eseq, 99) }.is_null()); + assert_eq!(unsafe { oakengine_sequence_clip_count(eseq, 0, 0) }, -1); + assert!(unsafe { oakengine_sequence_clip_at(eseq, 0, 0, 0) }.is_null()); + assert_eq!(unsafe { oakengine_sequence_marker_at(eseq, -1, &mut n, buf.as_mut_ptr(), 64, &mut i32v) }, -1); + assert_eq!(unsafe { oakengine_track_type(etrack) }, -1); + assert_eq!(unsafe { oakengine_track_block_count(etrack) }, -1); + assert!(unsafe { oakengine_track_block_at(etrack, 0) }.is_null()); + assert_eq!(unsafe { oakengine_track_is_muted(eseq, 0, 0) }, -1); + assert_eq!(unsafe { oakengine_track_is_locked(eseq, 0, 0) }, -1); + assert_eq!(unsafe { oakengine_clip_get_range(eclip, std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut()) }, -1); + assert_eq!(unsafe { oakengine_clip_is_enabled(eclip) }, -1); + assert_eq!(unsafe { oakengine_clip_are_linked(eclip, eclip) }, -1); + assert!(unsafe { oakengine_clip_get_sequence(eclip) }.is_null()); + let eblk2 = eclip.cast::(); + assert!(unsafe { oakengine_clip_in_transition(eblk2) }.is_null()); + assert!(unsafe { oakengine_clip_out_transition(eblk2) }.is_null()); + assert!(unsafe { oakengine_clip_get_connected_viewer(eblk2) }.is_null()); + assert_eq!(unsafe { oakengine_block_is_enabled(eblk) }, -1); + assert_eq!(unsafe { oakengine_block_set_enabled(eblk, 1) }, -1); + assert_eq!(unsafe { oakengine_block_is_gap(eblk) }, -1); + assert_eq!(unsafe { oakengine_block_link_count(eblk) }, -1); + assert!(unsafe { oakengine_block_link_at(eblk, 0) }.is_null()); + assert!(unsafe { oakengine_block_get_track(eblk) }.is_null()); + assert!(unsafe { oakengine_block_next(eblk) }.is_null()); + assert!(unsafe { oakengine_block_prev(eblk) }.is_null()); + assert_eq!(unsafe { oakengine_marker_get_color(emarker) }, -1); + assert_eq!(unsafe { oakengine_marker_has_sibling_at_time(emarker, 0, 1) }, -1); + assert_eq!(unsafe { oakengine_marker_get_time(emarker, std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut()) }, -1); + assert_eq!(unsafe { oakengine_marker_get_name(emarker, std::ptr::null_mut(), 0) }, -1); + assert_eq!(unsafe { oakengine_marker_list_count(elist) }, -1); + assert!(unsafe { oakengine_marker_list_at(elist, 0) }.is_null()); + assert!(unsafe { oakengine_marker_list_marker_at_time(elist, 0, 1) }.is_null()); + assert_eq!(unsafe { oakengine_workarea_get(ewa, std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut(), std::ptr::null_mut()) }, -1); + assert_eq!(unsafe { oakengine_node_is_block(enode) }, -1); + assert_eq!(unsafe { oakengine_node_is_transition(enode) }, -1); + assert!(unsafe { oakengine_clip_find_multicam(enode) }.is_null()); + + // Free the empty boxes (all are NULL-CHandle wrappers; release is a no-op). + unsafe { free_box::(eseq) }; + unsafe { free_box::(etrack) }; + unsafe { free_box::(eclip) }; + unsafe { free_box::(eblk) }; + unsafe { free_box::(emarker) }; + unsafe { free_box::(elist) }; + unsafe { free_box::(ewa) }; + unsafe { free_box::(enode) }; + + // ---- last_error is always readable ------------------------------------------- + let elen = unsafe { oakengine_sequence_last_error(buf.as_mut_ptr(), 256) }; + assert!(elen >= 0); +} + +// --------------------------------------------------------------------------- +// Crash-bug reproductions (ignored: running them aborts the process by +// design, which is the point — see the report) +// --------------------------------------------------------------------------- + +/// `oakengine_clip_toggle_enabled(NULL, 0)` crashes the process with a +/// non-unwinding UB panic inside `slice::from_raw_parts(NULL, 0)` +/// (src/timeline.rs:2637). Run with `--ignored` to reproduce the abort. +/// +/// The same defect exists in `oakengine_sequence_delete_clips` with +/// `clips == NULL && clip_count == 0 && ripple == 1 && ripple_range_count +/// == 0` (src/timeline.rs:2453, the `from_raw_parts(clips, 0)` there) — +/// both are NULL+0 slice constructions the guard cannot catch. +#[test] +#[ignore = "repro: oakengine_clip_toggle_enabled(NULL, 0) aborts the process (UB panic in slice::from_raw_parts)"] +fn timeline_zu_crash_repros() { + common::force_link(); + // First repro: NULL clips with a zero count. + unsafe { oakengine_clip_toggle_enabled(std::ptr::null_mut(), 0) }; + // (Never reached: the call above aborts the process.) +} diff --git a/crates/oakengine/tests/it_undo.rs b/crates/oakengine/tests/it_undo.rs new file mode 100644 index 000000000..8b3755022 --- /dev/null +++ b/crates/oakengine/tests/it_undo.rs @@ -0,0 +1,848 @@ +// 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 . + +//! Integration tests for the undo family (`engine/include/oakengine/undo.h`, +//! implemented by `src/undo.rs` on top of the real oakundo module crate) — +//! the "real behavior, end to end" complement to the smoke tests in +//! `tests/undo.rs`. No mocks: every call goes through the facade exports +//! into the real oakundo crate. +//! +//! The facade owns a process-wide undo stack and a single open undo group +//! (the module 00 analogue of `EngineCore::undo_stack()` / `g_undo_group`), +//! so every stack- and group-mutating assertion lives in ONE serialized +//! test function ([`undo_stack_integration`]). The command-lifecycle tests +//! only touch local state and run in parallel. +//! +//! Coverage: all 23 `oakengine_undo_*` exports are called on a legal path +//! with asserted results, plus the illegal-input matrix (NULL pointers, +//! empty `CHandle::null()` boxes, out-of-range rows, zero/negative buffer +//! sizes) and the free/destroy contracts. No function in this family needs +//! GPU/app state. The only `#[ignore]`d tests are the real-bug repros at +//! the bottom ([`null_name_push_repro`], [`null_name_group_repro`], +//! [`group_abort_undoes_children_repro`]) — a NULL/empty label to +//! `oakengine_undo_push` / the group-end path crashes the process, and +//! `oakengine_undo_group_abort` does not undo its children (see the +//! report). + +#[path = "common/mod.rs"] +mod common; + + +use std::ffi::{c_char, c_void}; +use std::sync::atomic::{AtomicI32, AtomicUsize, Ordering}; +use std::sync::Mutex; + +use oakengine::handle::{CHandle, OakEngineClipboard}; +use oakengine::undo::{ + oakengine_undo_can_redo, oakengine_undo_can_undo, oakengine_undo_clear, + oakengine_undo_command_create, oakengine_undo_command_create_multi, + oakengine_undo_command_free, oakengine_undo_command_is_done, + oakengine_undo_command_multi_add_child, oakengine_undo_command_multi_child_count, + oakengine_undo_command_redo_now, oakengine_undo_command_text, + oakengine_undo_command_undo_now, oakengine_undo_count, oakengine_undo_group_abort, + oakengine_undo_group_begin, oakengine_undo_group_end, oakengine_undo_handle, + oakengine_undo_index, oakengine_undo_jump, oakengine_undo_push, oakengine_undo_redo_action, + oakengine_undo_undo_action, oakengine_undo_update_actions, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Box a `CHandle::null()` inside an `OakEngineClipboard` — a VALID box +/// whose module handle is empty (what a plugin would hold after its own +/// handle object went away). The facade must reject it with a clean error +/// code, never crash. +fn empty_engine_ptr() -> *mut c_void { + Box::into_raw(Box::new(OakEngineClipboard { + handle: CHandle::null(), + })) + .cast() +} + +/// Read back the NUL-terminated string the facade wrote into `buf`. +unsafe fn read_str(buf: *const c_char) -> String { + unsafe { std::ffi::CStr::from_ptr(buf) } + .to_str() + .unwrap() + .to_string() +} + +// --------------------------------------------------------------------------- +// Command-lifecycle callbacks (parallel tests only; the serialized stack +// test uses the STK_* counters below and never touches these). +// --------------------------------------------------------------------------- + +static LIFECYCLE_REDO: AtomicI32 = AtomicI32::new(0); +static LIFECYCLE_UNDO: AtomicI32 = AtomicI32::new(0); +static LIFECYCLE_FREE: AtomicI32 = AtomicI32::new(0); +static LIFECYCLE_FREED_PTR: AtomicUsize = AtomicUsize::new(0); + +/// Own counter set for `command_create_variants` (the lifecycle tests run +/// in parallel, so they must not share atomics). +static VARIANTS_FREE: AtomicI32 = AtomicI32::new(0); + +unsafe extern "C" fn lifecycle_redo(_ud: *mut c_void) { + LIFECYCLE_REDO.fetch_add(1, Ordering::SeqCst); +} + +unsafe extern "C" fn lifecycle_undo(_ud: *mut c_void) { + LIFECYCLE_UNDO.fetch_add(1, Ordering::SeqCst); +} + +unsafe extern "C" fn lifecycle_free(_ud: *mut c_void) { + LIFECYCLE_FREE.fetch_add(1, Ordering::SeqCst); +} + +/// No-op callback for `command_create_variants` (avoids touching the +/// lifecycle counters, which run in a parallel test). +unsafe extern "C" fn variants_noop(_ud: *mut c_void) {} + +unsafe extern "C" fn variants_free(_ud: *mut c_void) { + VARIANTS_FREE.fetch_add(1, Ordering::SeqCst); +} + +/// free_fn that records the pointer and drops the boxed `u64` userdata +/// (round-trip ownership check). +unsafe extern "C" fn lifecycle_free_userdata(ud: *mut c_void) { + LIFECYCLE_FREED_PTR.store(ud as usize, Ordering::SeqCst); + LIFECYCLE_FREE.fetch_add(1, Ordering::SeqCst); + drop(Box::from_raw(ud as *mut u64)); +} + +/// Child redo/undo callbacks that log their id (encoded in userdata) — used +/// to verify multi redo order (insertion) and undo order (reverse). +static MULTI_LOG: Mutex> = Mutex::new(Vec::new()); + +unsafe extern "C" fn multi_redo(ud: *mut c_void) { + MULTI_LOG.lock().unwrap().push(ud as usize as i32); +} + +unsafe extern "C" fn multi_undo(ud: *mut c_void) { + MULTI_LOG.lock().unwrap().push(ud as usize as i32); +} + +static MULTI_FREE: AtomicI32 = AtomicI32::new(0); + +unsafe extern "C" fn multi_free(_ud: *mut c_void) { + MULTI_FREE.fetch_add(1, Ordering::SeqCst); +} + +/// free_fn for the module-level destroy-contract test. +static MOD_FREE: AtomicI32 = AtomicI32::new(0); + +unsafe extern "C" fn mod_free_cb(_ud: *mut c_void) { + MOD_FREE.fetch_add(1, Ordering::SeqCst); +} + +// --------------------------------------------------------------------------- +// Serialized stack-test callbacks (own counters; the parallel command +// tests never touch these). +// --------------------------------------------------------------------------- + +static STK_REDO: AtomicI32 = AtomicI32::new(0); +static STK_UNDO: AtomicI32 = AtomicI32::new(0); + +unsafe extern "C" fn stk_redo(_ud: *mut c_void) { + STK_REDO.fetch_add(1, Ordering::SeqCst); +} + +unsafe extern "C" fn stk_undo(_ud: *mut c_void) { + STK_UNDO.fetch_add(1, Ordering::SeqCst); +} + +// --------------------------------------------------------------------------- +// Command lifecycle (parallel-safe: no global-stack state) +// --------------------------------------------------------------------------- + +/// Full legal lifecycle of an app-defined command: create with name + +/// callbacks + owned userdata, redo/undo (idempotent), destroy via free — +/// the free_fn fires exactly once, with the same userdata pointer. +#[test] +fn command_lifecycle_roundtrip() { + common::force_link(); + LIFECYCLE_REDO.store(0, Ordering::SeqCst); + LIFECYCLE_UNDO.store(0, Ordering::SeqCst); + LIFECYCLE_FREE.store(0, Ordering::SeqCst); + LIFECYCLE_FREED_PTR.store(0, Ordering::SeqCst); + + let ud = Box::into_raw(Box::new(42u64)) as *mut c_void; + let cmd = unsafe { + oakengine_undo_command_create( + c"roundtrip".as_ptr(), + Some(lifecycle_redo), + Some(lifecycle_undo), + Some(lifecycle_free_userdata), + ud, + ) + }; + assert!(!cmd.is_null()); + + assert_eq!(unsafe { oakengine_undo_command_redo_now(cmd) }, 0); + assert_eq!(LIFECYCLE_REDO.load(Ordering::SeqCst), 1); + // redo on a done command is a no-op (olive semantics). + assert_eq!(unsafe { oakengine_undo_command_redo_now(cmd) }, 0); + assert_eq!(LIFECYCLE_REDO.load(Ordering::SeqCst), 1); + + assert_eq!(unsafe { oakengine_undo_command_undo_now(cmd) }, 0); + assert_eq!(LIFECYCLE_UNDO.load(Ordering::SeqCst), 1); + // undo on an undone command is a no-op. + assert_eq!(unsafe { oakengine_undo_command_undo_now(cmd) }, 0); + assert_eq!(LIFECYCLE_UNDO.load(Ordering::SeqCst), 1); + + assert_eq!(unsafe { oakengine_undo_command_redo_now(cmd) }, 0); + assert_eq!(LIFECYCLE_REDO.load(Ordering::SeqCst), 2); + + unsafe { oakengine_undo_command_free(cmd) }; + assert_eq!(LIFECYCLE_FREE.load(Ordering::SeqCst), 1); + assert_eq!(LIFECYCLE_FREED_PTR.load(Ordering::SeqCst), ud as usize); +} + +/// create() legal variants: NULL name, all-None callback table, free-only +/// table. All must produce a usable command. +#[test] +fn command_create_variants() { + common::force_link(); + VARIANTS_FREE.store(0, Ordering::SeqCst); + + // NULL name is legal (the label is read as empty). + let c1 = unsafe { + oakengine_undo_command_create( + std::ptr::null(), + Some(variants_noop), + Some(variants_noop), + None, + std::ptr::null_mut(), + ) + }; + assert!(!c1.is_null()); + assert_eq!(unsafe { oakengine_undo_command_redo_now(c1) }, 0); + assert_eq!(unsafe { oakengine_undo_command_undo_now(c1) }, 0); + unsafe { oakengine_undo_command_free(c1) }; + + // All-None callbacks: a no-op command, still usable. + let c2 = unsafe { + oakengine_undo_command_create( + c"noop".as_ptr(), + None, + None, + None, + std::ptr::null_mut(), + ) + }; + assert!(!c2.is_null()); + assert_eq!(unsafe { oakengine_undo_command_redo_now(c2) }, 0); + assert_eq!(unsafe { oakengine_undo_command_undo_now(c2) }, 0); + unsafe { oakengine_undo_command_free(c2) }; + + // free-only table: destroy still invokes free_fn exactly once. + let c3 = unsafe { + oakengine_undo_command_create( + c"freeonly".as_ptr(), + None, + None, + Some(variants_free), + std::ptr::null_mut(), + ) + }; + assert!(!c3.is_null()); + unsafe { oakengine_undo_command_free(c3) }; + assert_eq!(VARIANTS_FREE.load(Ordering::SeqCst), 1); +} + +/// Illegal-input robustness for the command surface: NULL pointers and +/// empty (`CHandle::null`) handles must produce clean negative codes +/// (the facade's -1 or the oakundo -20001 pass-through), never a crash. +#[test] +fn command_illegal_handle_inputs() { + common::force_link(); + + // NULL command pointers. + assert_eq!( + unsafe { oakengine_undo_command_redo_now(std::ptr::null_mut()) }, + -1 + ); + assert_eq!( + unsafe { oakengine_undo_command_undo_now(std::ptr::null_mut()) }, + -1 + ); + assert_eq!( + unsafe { oakengine_undo_command_multi_child_count(std::ptr::null_mut()) }, + -1 + ); + assert_eq!( + unsafe { + oakengine_undo_command_multi_add_child(std::ptr::null_mut(), std::ptr::null_mut()) + }, + -1 + ); + + // Empty (CHandle::null) handles inside a valid box. + let eb = empty_engine_ptr(); + assert_eq!(unsafe { oakengine_undo_command_redo_now(eb) }, -1); + unsafe { oakengine_undo_command_free(eb) }; + + let eb = empty_engine_ptr(); + assert_eq!(unsafe { oakengine_undo_command_undo_now(eb) }, -1); + unsafe { oakengine_undo_command_free(eb) }; + + let eb = empty_engine_ptr(); + assert_eq!(unsafe { oakengine_undo_command_multi_child_count(eb) }, -1); + unsafe { oakengine_undo_command_free(eb) }; + + // multi_add_child with an empty parent (the facade errors before + // consuming the child, so the child box must be freed by us). + let eb = empty_engine_ptr(); + let child = unsafe { + oakengine_undo_command_create( + c"child".as_ptr(), + None, + None, + None, + std::ptr::null_mut(), + ) + }; + assert_eq!(unsafe { oakengine_undo_command_multi_add_child(eb, child) }, -1); + unsafe { oakengine_undo_command_free(eb) }; + unsafe { oakengine_undo_command_free(child) }; + + // multi_add_child with an empty child (parent untouched). + let multi = unsafe { oakengine_undo_command_create_multi() }; + let eb = empty_engine_ptr(); + assert_eq!(unsafe { oakengine_undo_command_multi_add_child(multi, eb) }, -1); + unsafe { oakengine_undo_command_free(eb) }; + assert_eq!(unsafe { oakengine_undo_command_multi_child_count(multi) }, 0); + unsafe { oakengine_undo_command_free(multi) }; + + // A plain (non-multi) command as the "multi" parent: the module rejects + // with -20001 and the facade still consumes the child's box. + let parent = unsafe { + oakengine_undo_command_create( + c"parent".as_ptr(), + None, + None, + None, + std::ptr::null_mut(), + ) + }; + let child = unsafe { + oakengine_undo_command_create( + c"child".as_ptr(), + None, + None, + None, + std::ptr::null_mut(), + ) + }; + assert_eq!( + unsafe { oakengine_undo_command_multi_add_child(parent, child) }, + -20001 + ); + assert_eq!(unsafe { oakengine_undo_command_multi_child_count(parent) }, -20001); + unsafe { oakengine_undo_command_free(parent) }; +} + +/// Legal-input matrix for multi commands: child counts 0→N, redo in +/// insertion order, undo in reverse order, nested multis, idempotent +/// redo/undo. +#[test] +fn multi_command_lifecycle() { + common::force_link(); + let multi = unsafe { oakengine_undo_command_create_multi() }; + assert!(!multi.is_null()); + assert_eq!(unsafe { oakengine_undo_command_multi_child_count(multi) }, 0); + + for id in [1, 2, 3] { + let child = unsafe { + oakengine_undo_command_create( + c"child".as_ptr(), + Some(multi_redo), + Some(multi_undo), + None, + id as *mut c_void, + ) + }; + assert_eq!(unsafe { oakengine_undo_command_multi_add_child(multi, child) }, 0); + } + assert_eq!(unsafe { oakengine_undo_command_multi_child_count(multi) }, 3); + + *MULTI_LOG.lock().unwrap() = Vec::new(); + assert_eq!(unsafe { oakengine_undo_command_redo_now(multi) }, 0); + assert_eq!(*MULTI_LOG.lock().unwrap(), [1, 2, 3]); + // redo of a done multi is a no-op. + assert_eq!(unsafe { oakengine_undo_command_redo_now(multi) }, 0); + assert_eq!(*MULTI_LOG.lock().unwrap(), [1, 2, 3]); + + assert_eq!(unsafe { oakengine_undo_command_undo_now(multi) }, 0); + assert_eq!(*MULTI_LOG.lock().unwrap(), [1, 2, 3, 3, 2, 1]); + unsafe { oakengine_undo_command_free(multi) }; + + // Nested multi: outer = [c10, inner([c21])]; undo runs children in + // reverse order, inner included. + let outer = unsafe { oakengine_undo_command_create_multi() }; + let inner = unsafe { oakengine_undo_command_create_multi() }; + let c10 = unsafe { + oakengine_undo_command_create( + c"c10".as_ptr(), + Some(multi_redo), + Some(multi_undo), + None, + 10 as *mut c_void, + ) + }; + let c21 = unsafe { + oakengine_undo_command_create( + c"c21".as_ptr(), + Some(multi_redo), + Some(multi_undo), + None, + 21 as *mut c_void, + ) + }; + assert_eq!(unsafe { oakengine_undo_command_multi_add_child(inner, c21) }, 0); + assert_eq!(unsafe { oakengine_undo_command_multi_child_count(inner) }, 1); + assert_eq!(unsafe { oakengine_undo_command_multi_add_child(outer, c10) }, 0); + assert_eq!(unsafe { oakengine_undo_command_multi_add_child(outer, inner) }, 0); + assert_eq!(unsafe { oakengine_undo_command_multi_child_count(outer) }, 2); + + *MULTI_LOG.lock().unwrap() = Vec::new(); + assert_eq!(unsafe { oakengine_undo_command_redo_now(outer) }, 0); + assert_eq!(*MULTI_LOG.lock().unwrap(), [10, 21]); + assert_eq!(unsafe { oakengine_undo_command_undo_now(outer) }, 0); + assert_eq!(*MULTI_LOG.lock().unwrap(), [10, 21, 21, 10]); + + // c10 / inner / c21 were consumed by multi_add_child (their boxes are + // freed by the facade), so only outer is freed here — the child command + // values die with it. + unsafe { oakengine_undo_command_free(outer) }; +} + +/// Destroying a multi command releases its children transitively: each +/// child's free_fn fires exactly once when the multi is freed. +#[test] +fn multi_command_free_frees_children() { + common::force_link(); + MULTI_FREE.store(0, Ordering::SeqCst); + + let multi = unsafe { oakengine_undo_command_create_multi() }; + let inner = unsafe { oakengine_undo_command_create_multi() }; + let a = unsafe { + oakengine_undo_command_create( + c"a".as_ptr(), + None, + None, + Some(multi_free), + std::ptr::null_mut(), + ) + }; + let b = unsafe { + oakengine_undo_command_create( + c"b".as_ptr(), + None, + None, + Some(multi_free), + std::ptr::null_mut(), + ) + }; + let c = unsafe { + oakengine_undo_command_create( + c"c".as_ptr(), + None, + None, + Some(multi_free), + std::ptr::null_mut(), + ) + }; + assert_eq!(unsafe { oakengine_undo_command_multi_add_child(inner, c) }, 0); + assert_eq!(unsafe { oakengine_undo_command_multi_add_child(multi, a) }, 0); + assert_eq!(unsafe { oakengine_undo_command_multi_add_child(multi, b) }, 0); + assert_eq!(unsafe { oakengine_undo_command_multi_add_child(multi, inner) }, 0); + assert_eq!(MULTI_FREE.load(Ordering::SeqCst), 0); + + unsafe { oakengine_undo_command_free(multi) }; + // a, b and c (via inner) are all destroyed exactly once. + assert_eq!(MULTI_FREE.load(Ordering::SeqCst), 3); +} + +/// Destroy contracts end to end: free(NULL), free(empty), and the +/// module-level double-free safety of the command/stack handles the facade +/// delegates to (`oakundo_command_free` / `oakundo_undostack_free` clear +/// `ctx` after releasing, so a second free is a no-op). +/// +/// NOTE: the facade's own `oakengine_undo_command_free` frees the wrapper +/// box and is documented as "must not be freed twice"; the double-free-safe +/// contract lives on the module handle level, exercised here through the +/// real oakundo C ABI. The oakundo family has no debug alive counter, so +/// there is no alive-count-baseline to assert. +#[test] +fn free_contracts() { + common::force_link(); + + // Facade free: NULL and empty are no-ops. + unsafe { oakengine_undo_command_free(std::ptr::null_mut()) }; + let eb = empty_engine_ptr(); + unsafe { oakengine_undo_command_free(eb) }; + + // Module command handle: the first free releases (free_fn fires once) + // and clears ctx; the second free is a no-op. + MOD_FREE.store(0, Ordering::SeqCst); + let vtable = oakundo::undocommand::OakUndoCommandVtable { + redo: None, + undo: None, + free_fn: Some(mod_free_cb), + }; + let mut h = + unsafe { oakundo::ffi::command::oakundo_command_init(&vtable, std::ptr::null_mut()) }; + assert!(!h.ctx.is_null()); + unsafe { oakundo::ffi::command::oakundo_command_free(&mut h) }; + assert_eq!(MOD_FREE.load(Ordering::SeqCst), 1); + assert!(h.ctx.is_null()); + unsafe { oakundo::ffi::command::oakundo_command_free(&mut h) }; + assert_eq!(MOD_FREE.load(Ordering::SeqCst), 1); + + // Module stack handle: double free is a no-op; NULL value and NULL + // pointer are no-ops too. + let mut s = unsafe { oakundo::ffi::undostack::oakundo_undostack_init() }; + assert!(!s.ctx.is_null()); + unsafe { oakundo::ffi::undostack::oakundo_undostack_free(&mut s) }; + assert!(s.ctx.is_null()); + unsafe { oakundo::ffi::undostack::oakundo_undostack_free(&mut s) }; + + let mut null_h = CHandle::null(); + unsafe { oakundo::ffi::command::oakundo_command_free(&mut null_h) }; + unsafe { oakundo::ffi::command::oakundo_command_free(std::ptr::null_mut()) }; + unsafe { oakundo::ffi::undostack::oakundo_undostack_free(std::ptr::null_mut()) }; +} + +// --------------------------------------------------------------------------- +// Global stack + undo group (serialized: the facade's stack and open group +// are process-wide) +// --------------------------------------------------------------------------- + +/// The full global-stack and undo-group matrix, serialized in one test +/// because the facade owns the process-wide stack and a single open undo +/// group. Covers every stack-scoped export: handle, clear, count, index, +/// jump, command_text, command_is_done, can_undo/can_redo, push, and the +/// group begin/end/abort lifecycle. +#[test] +fn undo_stack_integration() { + common::force_link(); + + // --- Baseline: clear() resets to the single "New/Open Project" row. + assert_eq!(unsafe { oakengine_undo_clear() }, 0); + assert_eq!(unsafe { oakengine_undo_count() }, 1); + assert_eq!(unsafe { oakengine_undo_index() }, 1); + assert_eq!(unsafe { oakengine_undo_can_undo() }, 0); + assert_eq!(unsafe { oakengine_undo_can_redo() }, 0); + + // --- Borrowed handle + Qt-leftover actions. + let h1 = unsafe { oakengine_undo_handle() }; + let h2 = unsafe { oakengine_undo_handle() }; + assert!(!h1.is_null()); + assert_eq!(h1, h2); // stable token + assert_eq!(unsafe { oakengine_undo_update_actions() }, 0); + assert!(unsafe { oakengine_undo_undo_action() }.is_null()); + assert!(unsafe { oakengine_undo_redo_action() }.is_null()); + + // --- command_text / command_is_done on the base row. The two-stage + // getter reports the length WITHOUT the trailing NUL. + let mut buf = [0 as c_char; 64]; + assert_eq!(unsafe { oakengine_undo_command_text(0, buf.as_mut_ptr(), 64) }, 16); + assert_eq!(unsafe { read_str(buf.as_ptr()) }, "New/Open Project"); + // NULL buf / zero / negative sizes only report the length. + assert_eq!(unsafe { oakengine_undo_command_text(0, std::ptr::null_mut(), 64) }, 16); + assert_eq!(unsafe { oakengine_undo_command_text(0, buf.as_mut_ptr(), 0) }, 16); + assert_eq!(unsafe { oakengine_undo_command_text(0, buf.as_mut_ptr(), -1) }, 16); + // Out-of-range rows → oakundo NOT_FOUND (-20004) passes through. + assert_eq!(unsafe { oakengine_undo_command_text(-1, buf.as_mut_ptr(), 64) }, -20004); + assert_eq!(unsafe { oakengine_undo_command_text(1, buf.as_mut_ptr(), 64) }, -20004); + assert_eq!(unsafe { oakengine_undo_command_text(i64::MAX, buf.as_mut_ptr(), 64) }, -20004); + assert_eq!(unsafe { oakengine_undo_command_text(i64::MIN, buf.as_mut_ptr(), 64) }, -20004); + assert_eq!(unsafe { oakengine_undo_command_is_done(0) }, 1); + assert_eq!(unsafe { oakengine_undo_command_is_done(-1) }, -20004); + assert_eq!(unsafe { oakengine_undo_command_is_done(1) }, -20004); + assert_eq!(unsafe { oakengine_undo_command_is_done(i64::MAX) }, -20004); + + // --- Push a named command; the redo runs eagerly. + STK_REDO.store(0, Ordering::SeqCst); + STK_UNDO.store(0, Ordering::SeqCst); + let a = unsafe { + oakengine_undo_command_create( + c"alpha".as_ptr(), + Some(stk_redo), + Some(stk_undo), + None, + std::ptr::null_mut(), + ) + }; + assert_eq!(unsafe { oakengine_undo_push(a, c"alpha".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_undo_count() }, 2); + assert_eq!(unsafe { oakengine_undo_index() }, 2); + assert_eq!(STK_REDO.load(Ordering::SeqCst), 1); + assert_eq!(unsafe { oakengine_undo_can_undo() }, 1); + assert_eq!(unsafe { oakengine_undo_command_text(1, buf.as_mut_ptr(), 64) }, 5); + assert_eq!(unsafe { read_str(buf.as_ptr()) }, "alpha"); + assert_eq!(unsafe { oakengine_undo_command_is_done(1) }, 1); + + // --- Push a second named command. + let b = unsafe { + oakengine_undo_command_create( + c"beta".as_ptr(), + Some(stk_redo), + Some(stk_undo), + None, + std::ptr::null_mut(), + ) + }; + assert_eq!(unsafe { oakengine_undo_push(b, c"beta".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_undo_count() }, 3); + assert_eq!(unsafe { oakengine_undo_index() }, 3); + assert_eq!(STK_REDO.load(Ordering::SeqCst), 2); + assert_eq!(unsafe { oakengine_undo_command_text(2, buf.as_mut_ptr(), 64) }, 4); + assert_eq!(unsafe { read_str(buf.as_ptr()) }, "beta"); + // Tiny buffer: truncated copy, full length still reported. + let mut small = [0 as c_char; 2]; + assert_eq!(unsafe { oakengine_undo_command_text(1, small.as_mut_ptr(), 2) }, 5); + assert_eq!(unsafe { read_str(small.as_ptr()) }, "a"); + + // --- jump() legal matrix. jump(1) from index 3 undoes BOTH beta and + // alpha (the stack undoes back-to-front until the done-count is 1). + assert_eq!(unsafe { oakengine_undo_jump(1) }, 0); + assert_eq!(unsafe { oakengine_undo_index() }, 1); + assert_eq!(STK_UNDO.load(Ordering::SeqCst), 2); + assert_eq!(unsafe { oakengine_undo_can_undo() }, 0); + assert_eq!(unsafe { oakengine_undo_can_redo() }, 1); + assert_eq!(unsafe { oakengine_undo_command_is_done(1) }, 0); + assert_eq!(unsafe { oakengine_undo_command_is_done(2) }, 0); + + assert_eq!(unsafe { oakengine_undo_jump(0) }, 0); + // The base "New/Open Project" row is never undoable, so the index + // bottoms out at 1 rather than 0. + assert_eq!(unsafe { oakengine_undo_index() }, 1); + assert_eq!(STK_UNDO.load(Ordering::SeqCst), 2); + + // Negative index is clamped to 0 (olive jump semantics) — still no + // undo past the base row. + assert_eq!(unsafe { oakengine_undo_jump(-5) }, 0); + assert_eq!(unsafe { oakengine_undo_index() }, 1); + + // Oversized index is clamped to the done-command count. + assert_eq!(unsafe { oakengine_undo_jump(999) }, 0); + assert_eq!(unsafe { oakengine_undo_index() }, 3); + assert_eq!(STK_REDO.load(Ordering::SeqCst), 4); + assert_eq!(unsafe { oakengine_undo_can_undo() }, 1); + assert_eq!(unsafe { oakengine_undo_can_redo() }, 0); + + // --- Undo groups. + // A second begin while a group is open fails with E_STATE (-2); + // ending an empty group discards it (no new row). + assert_eq!(unsafe { oakengine_undo_group_begin(c"anon".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_undo_group_begin(c"again".as_ptr()) }, -2); + assert_eq!(unsafe { oakengine_undo_group_end() }, 0); + assert_eq!(unsafe { oakengine_undo_count() }, 3); + assert_eq!(unsafe { oakengine_undo_index() }, 3); + + // begin → push children → end pushes ONE grouped row. + assert_eq!(unsafe { oakengine_undo_group_begin(c"grouped".as_ptr()) }, 0); + let c1 = unsafe { + oakengine_undo_command_create( + c"c1".as_ptr(), + Some(stk_redo), + Some(stk_undo), + None, + std::ptr::null_mut(), + ) + }; + let c2 = unsafe { + oakengine_undo_command_create( + c"c2".as_ptr(), + Some(stk_redo), + Some(stk_undo), + None, + std::ptr::null_mut(), + ) + }; + assert_eq!(unsafe { oakengine_undo_push(c1, c"c1".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_undo_push(c2, c"c2".as_ptr()) }, 0); + // Both children were redo'd eagerly into the group, not the stack. + assert_eq!(STK_REDO.load(Ordering::SeqCst), 6); + assert_eq!(unsafe { oakengine_undo_count() }, 3); + assert_eq!(unsafe { oakengine_undo_group_end() }, 0); + assert_eq!(unsafe { oakengine_undo_count() }, 4); + assert_eq!(unsafe { oakengine_undo_index() }, 4); + assert_eq!(unsafe { oakengine_undo_command_text(3, buf.as_mut_ptr(), 64) }, 7); + assert_eq!(unsafe { read_str(buf.as_ptr()) }, "grouped"); + assert_eq!(unsafe { oakengine_undo_command_is_done(3) }, 1); + + // Undo the group: children undo in REVERSE order. + assert_eq!(unsafe { oakengine_undo_jump(3) }, 0); + assert_eq!(unsafe { oakengine_undo_index() }, 3); + assert_eq!(STK_UNDO.load(Ordering::SeqCst), 4); + assert_eq!(unsafe { oakengine_undo_command_is_done(3) }, 0); + assert_eq!(unsafe { oakengine_undo_can_redo() }, 1); + // Redo the group: children redo in INSERTION order. + assert_eq!(unsafe { oakengine_undo_jump(4) }, 0); + assert_eq!(unsafe { oakengine_undo_index() }, 4); + assert_eq!(STK_REDO.load(Ordering::SeqCst), 8); + // Undo again for the abort phase. + assert_eq!(unsafe { oakengine_undo_jump(3) }, 0); + assert_eq!(STK_UNDO.load(Ordering::SeqCst), 6); + + // begin → push → abort discards the group (the child's undo does NOT + // run — see the NOTE below and `group_abort_undoes_children_repro`). + assert_eq!(unsafe { oakengine_undo_group_begin(c"abort".as_ptr()) }, 0); + let c3 = unsafe { + oakengine_undo_command_create( + c"c3".as_ptr(), + Some(stk_redo), + Some(stk_undo), + None, + std::ptr::null_mut(), + ) + }; + assert_eq!(unsafe { oakengine_undo_push(c3, c"c3".as_ptr()) }, 0); + assert_eq!(STK_REDO.load(Ordering::SeqCst), 9); + assert_eq!(unsafe { oakengine_undo_group_abort() }, 0); + // NOTE: the abort does NOT run the child's undo — `undo_now` is a + // no-op on the never-done multi command (see + // `group_abort_undoes_children_repro`, ignored, for the full repro), + // so c3's side effect is not rolled back. Only the side-effect-free + // assertions follow. + assert_eq!(unsafe { oakengine_undo_count() }, 4); // unchanged + assert_eq!(unsafe { oakengine_undo_index() }, 3); + + // End/abort with no open group fail with E_STATE. + assert_eq!(unsafe { oakengine_undo_group_end() }, -2); + assert_eq!(unsafe { oakengine_undo_group_abort() }, -2); + + // --- Illegal push inputs (rejected before any stack access). + assert_eq!( + unsafe { oakengine_undo_push(std::ptr::null_mut(), c"x".as_ptr()) }, + -1 + ); + let eb = empty_engine_ptr(); + assert_eq!(unsafe { oakengine_undo_push(eb, c"x".as_ptr()) }, -1); + unsafe { oakengine_undo_command_free(eb) }; + + // Cleanup: back to baseline. + assert_eq!(unsafe { oakengine_undo_clear() }, 0); + assert_eq!(unsafe { oakengine_undo_count() }, 1); + assert_eq!(unsafe { oakengine_undo_index() }, 1); +} + +// --------------------------------------------------------------------------- +// Real-bug repros (ignored: they crash the process; see the report) +// --------------------------------------------------------------------------- + +/// REAL BUG REPRO — `oakengine_undo_push(cmd, NULL)` segfaults the process. +/// +/// The facade's `push_or_run` (src/undo.rs) turns a NULL name into +/// `String::new()` and passes its DANGLING `as_ptr()` (address 0x1 — Rust +/// empty-string pointers are never NULL) to the oakundo module's +/// `oakundo_undostack_push`, whose `read_name` treats any non-NULL pointer +/// as a valid C string and runs `CStr::from_ptr` (strlen) on it, faulting +/// on the unmapped page. `name` is documented as legal-NULL in both the +/// module header (`include/undo/undostack.h`: "NULL behaves like an empty +/// label") and the facade docs, and the crash is NOT caught by the +/// catch_unwind guards (it is a hard SIGSEGV, not a panic). +/// +/// Verified: `cargo test -p oakengine --test it_undo null_name_push_repro -- --ignored` +/// dies with signal 11 inside `oakundo::ffi::read_name`. +#[test] +#[ignore = "crashes the process: src/undo.rs push_or_run passes String::new().as_ptr() (0x1) to oakundo's read_name, which strlen's it -> SIGSEGV; needs the engine fix"] +fn null_name_push_repro() { + common::force_link(); + assert_eq!(unsafe { oakengine_undo_clear() }, 0); + + let cmd = unsafe { + oakengine_undo_command_create( + c"x".as_ptr(), + None, + None, + None, + std::ptr::null_mut(), + ) + }; + // NULL name is a documented-legal label; this must not crash. + assert_eq!(unsafe { oakengine_undo_push(cmd, std::ptr::null()) }, 0); + + assert_eq!(unsafe { oakengine_undo_clear() }, 0); +} + +/// REAL BUG REPRO — `oakengine_undo_group_begin(NULL)` + +/// `oakengine_undo_group_end()` segfaults the process. +/// +/// Same root cause as [`null_name_push_repro`]: `oakengine_undo_group_end` +/// (src/undo.rs) stores the group name as a Rust `String` and passes its +/// `as_ptr()` to `oakundo_undostack_push_pre_executed`; a NULL (or empty) +/// name is a dangling 0x1 pointer there, and the module's `read_name` +/// crashes on it. The group-abort path never crosses the name and is safe. +#[test] +#[ignore = "crashes the process: src/undo.rs group_end passes String::new().as_ptr() (0x1) to oakundo's read_name, which strlen's it -> SIGSEGV; needs the engine fix"] +fn null_name_group_repro() { + common::force_link(); + assert_eq!(unsafe { oakengine_undo_clear() }, 0); + + assert_eq!(unsafe { oakengine_undo_group_begin(std::ptr::null()) }, 0); + // End of a NULL-named (empty) group must not crash. + assert_eq!(unsafe { oakengine_undo_group_end() }, 0); + + assert_eq!(unsafe { oakengine_undo_clear() }, 0); +} + +/// Counter for the abort repro (own set: this test runs only under +/// `--ignored`, but keep it isolated anyway). +static ABORT_UNDO: AtomicI32 = AtomicI32::new(0); + +unsafe extern "C" fn abort_undo_cb(_ud: *mut c_void) { + ABORT_UNDO.fetch_add(1, Ordering::SeqCst); +} + +/// REAL BUG REPRO — `oakengine_undo_group_abort()` does not undo the +/// group's executed children. +/// +/// The facade (src/undo.rs) closes the abort with +/// `oakundo_command_undo_now(open.multi)` on a multi command that was +/// never marked done (each child was redo'd eagerly at push time, but the +/// multi's own `done` flag stays false), and oakundo's documented +/// `undo_now` is a no-op on a not-done command. Net effect: the child's +/// undo callback never fires, so the group's side effects are NOT rolled +/// back — contradicting the documented "undo all executed children and +/// discard the group". (The smoke test in tests/undo.rs misses this: its +/// `STK_UNDO_COUNT == 1` assertion is satisfied by a leftover value from +/// an earlier jump.) +#[test] +#[ignore = "fails: group_abort leaves children done (undo_now is a no-op on the never-done multi); needs the engine fix"] +fn group_abort_undoes_children_repro() { + common::force_link(); + assert_eq!(unsafe { oakengine_undo_clear() }, 0); + + ABORT_UNDO.store(0, Ordering::SeqCst); + assert_eq!(unsafe { oakengine_undo_group_begin(c"abort".as_ptr()) }, 0); + let c = unsafe { + oakengine_undo_command_create( + c"c".as_ptr(), + None, + Some(abort_undo_cb), + None, + std::ptr::null_mut(), + ) + }; + assert_eq!(unsafe { oakengine_undo_push(c, c"c".as_ptr()) }, 0); + assert_eq!(unsafe { oakengine_undo_group_abort() }, 0); + // Documented behavior: the executed child's undo must run. + assert_eq!(ABORT_UNDO.load(Ordering::SeqCst), 1); +}