refactor: drop internal bridge/ffi layers; exporter family lands
Single-lib cleanup: the per-crate src/bridge/ and src/ffi.rs layers are gone (oakundo/oakcommon/oaknode/oaktimeline/oakcodec/oakaudio/ oakrender/oaktask/oakplugin/oakstorage); cross-crate calls are plain Rust, CHandle marshalling shrinks to the oakengine boundary, and tests call the Rust APIs directly (pure C-ABI wrapper tests removed where the domain layer already covers the behavior). exporter.h family implemented: oakengine_export_render (CLI contract), oakengine_export_render_with_params (was a stub), last_error and progress callback; synchronous path reuses task_create_export + start_sync. Fixes on the way: oaktask video ticket self-deadlock, audio params dropped on the export path, codec encoder AAC slicing and H.264 time base. Real-mp4 tests cover both entry points, progress and the illegal-argument matrix. Also: oakstorage session maps null project handles to None (version- info path), configstore test double literal 3.14 -> 3.15 (clippy PI lint), oakaudio output callback scratch buffer + env-aware P1 test, cli media round-trip test uses a generated 16-frame clip (no more minute-long debug runs).
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -18,9 +18,6 @@
|
||||
//! or a real cancellation (`Err(Cancelled)`); the cancelled callback fires
|
||||
//! only for real cancellations.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -36,7 +33,7 @@ fn spawn_parked(
|
||||
let state = cc.state();
|
||||
let atom = cc.base.get_cancel_atom();
|
||||
let title = cc.base.title().to_string();
|
||||
let mut outer = Task::new(&title, atom);
|
||||
let mut outer = Task::new(&title, Some(atom));
|
||||
outer.set_behavior(Box::new(cc));
|
||||
let handle = std::thread::spawn(move || outer.start());
|
||||
// Let the worker park on the condvar.
|
||||
@@ -48,9 +45,6 @@ fn spawn_parked(
|
||||
/// task which then reports success.
|
||||
#[test]
|
||||
fn finish_completes_parked_task() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
|
||||
let cc = CustomCacheTask::new("my-sequence");
|
||||
assert_eq!(cc.base.title(), "Caching custom range for \"my-sequence\"");
|
||||
let (handle, state) = spawn_parked(cc);
|
||||
@@ -74,9 +68,6 @@ fn finish_completes_parked_task() {
|
||||
/// `finish()`.
|
||||
#[test]
|
||||
fn cancelled_callback_fires_only_on_real_cancel() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
|
||||
// finish(): the callback must NOT fire.
|
||||
let fired_on_finish = Arc::new(AtomicBool::new(false));
|
||||
let mut cc = CustomCacheTask::new("finish-seq");
|
||||
@@ -111,9 +102,6 @@ fn cancelled_callback_fires_only_on_real_cancel() {
|
||||
/// The cancel hook wakes the parked task even without finish().
|
||||
#[test]
|
||||
fn cancel_hook_wakes_parked_task() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
|
||||
let cc = CustomCacheTask::new("wake-seq");
|
||||
let (handle, state) = spawn_parked(cc);
|
||||
// Simulate the manager's cancel path (base.cancel -> atom + hook).
|
||||
|
||||
@@ -1,335 +0,0 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! C ABI contract tests (ffi.rs). One normal + one error path per
|
||||
//! export family; the exhaustive matrix is driven from the existing
|
||||
//! C++ gtest suite (`src/task/tests`, unchanged) running against this
|
||||
//! crate — these tests only pin Rust-side specifics.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use std::ffi::{c_char, c_int, c_void};
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
|
||||
use oaktask::error::{OAKTASK_E_INVALID, OAKTASK_E_STATE, OAKTASK_OK};
|
||||
use oaktask::ffi::manager::{
|
||||
oaktask_manager_at, oaktask_manager_count, oaktask_manager_delete_finished,
|
||||
oaktask_manager_init, oaktask_manager_shutdown,
|
||||
};
|
||||
use oaktask::ffi::project::oaktask_create_project_save;
|
||||
use oaktask::ffi::task::{
|
||||
oaktask_debug_alive_count, oaktask_task_cancel, oaktask_task_error, oaktask_task_free,
|
||||
oaktask_task_is_finished, oaktask_task_start, oaktask_task_start_sync, oaktask_task_subscribe,
|
||||
oaktask_task_succeeded, oaktask_task_title, oaktask_task_wait,
|
||||
};
|
||||
use oaktask::handle::CHandle;
|
||||
|
||||
fn free_task(t: &mut CHandle) {
|
||||
unsafe {
|
||||
oaktask_task_free(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every exported handle-returning function returns ctx==NULL on
|
||||
/// failure and a valid refcounted handle on success (abi_version
|
||||
/// stamped).
|
||||
#[test]
|
||||
fn handle_contract_all_exports() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
|
||||
// create_project_save with a null project -> empty.
|
||||
let empty = unsafe { oaktask_create_project_save(CHandle::null(), std::ptr::null(), 0) };
|
||||
assert!(empty.is_null());
|
||||
|
||||
// create_project_save with a valid project -> valid handle with the
|
||||
// stamped ABI version.
|
||||
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-contract-save.oakproj".to_string();
|
||||
let task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 1) };
|
||||
assert!(!task.ctx.is_null());
|
||||
assert_eq!(task.abi_version, 1);
|
||||
|
||||
// Title getter round-trip (two-stage).
|
||||
let mut buf = [0i8; 512];
|
||||
let needed = unsafe { oaktask_task_title(task, buf.as_mut_ptr(), buf.len() as c_int) };
|
||||
assert!(needed > 0);
|
||||
assert!(needed <= buf.len() as c_int);
|
||||
// Exact-size query with a null buffer returns the same needed size.
|
||||
let query = unsafe { oaktask_task_title(task, std::ptr::null_mut(), 0) };
|
||||
assert_eq!(query, needed);
|
||||
// The save title embeds the project filename.
|
||||
assert!(needed > 10);
|
||||
|
||||
let mut t = task;
|
||||
free_task(&mut t);
|
||||
assert!(t.is_null());
|
||||
}
|
||||
|
||||
/// free(NULL)/free(empty) are no-ops across every free export.
|
||||
#[test]
|
||||
fn free_null_noop_all_exports() {
|
||||
unsafe {
|
||||
oaktask_task_free(std::ptr::null_mut());
|
||||
}
|
||||
let mut empty = CHandle::null();
|
||||
unsafe {
|
||||
oaktask_task_free(&mut empty);
|
||||
}
|
||||
assert!(empty.ctx.is_null());
|
||||
}
|
||||
|
||||
/// Two-stage string functions: size query, short buffer truncation
|
||||
/// rule, and exact-fit write — for every string getter
|
||||
/// (task_title, task_error, import_invalid_at).
|
||||
#[test]
|
||||
fn two_stage_string_contract() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-two-stage.oakproj".to_string();
|
||||
let task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
|
||||
assert!(!task.ctx.is_null());
|
||||
|
||||
// Null buffer -> needed size only.
|
||||
let needed = unsafe { oaktask_task_title(task, std::ptr::null_mut(), 0) };
|
||||
assert!(needed > 0);
|
||||
|
||||
// Too-small buffer -> needed size, no write.
|
||||
let mut small = [0i8; 4];
|
||||
let r = unsafe { oaktask_task_title(task, small.as_mut_ptr(), small.len() as c_int) };
|
||||
assert_eq!(r, needed);
|
||||
// C++ only writes when the buffer fits; a short buffer stays untouched.
|
||||
assert_ne!(small[0], b'S' as i8);
|
||||
|
||||
// Exact-fit write.
|
||||
let mut buf = vec![0i8; needed as usize];
|
||||
let r = unsafe { oaktask_task_title(task, buf.as_mut_ptr(), needed) };
|
||||
assert_eq!(r, needed);
|
||||
assert_eq!(buf[0], b'S' as i8);
|
||||
assert_eq!(buf[needed as usize - 1], 0);
|
||||
|
||||
// Error getter on a fresh task reports the default.
|
||||
let err_needed = unsafe { oaktask_task_error(task, std::ptr::null_mut(), 0) };
|
||||
assert!(err_needed > 0);
|
||||
|
||||
// Error-path: empty handle -> E_INVALID.
|
||||
assert_eq!(
|
||||
unsafe { oaktask_task_title(CHandle::null(), std::ptr::null_mut(), 0) },
|
||||
OAKTASK_E_INVALID
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaktask_task_error(CHandle::null(), std::ptr::null_mut(), 0) },
|
||||
OAKTASK_E_INVALID
|
||||
);
|
||||
|
||||
let mut t = task;
|
||||
free_task(&mut t);
|
||||
}
|
||||
|
||||
/// Task manager singleton: init → create/start → wait → delete_finished
|
||||
/// round-trips; alive-count accounting returns to baseline.
|
||||
#[test]
|
||||
fn identity_registry_roundtrip() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
unsafe { oaktask_manager_shutdown() }; // ensure clean slate
|
||||
|
||||
assert_eq!(unsafe { oaktask_manager_init() }, OAKTASK_OK);
|
||||
assert_eq!(unsafe { oaktask_manager_count() }, 0);
|
||||
|
||||
// Async start of a save task; the worker writes the file.
|
||||
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-identity-roundtrip.oakproj".to_string();
|
||||
let _ = std::fs::remove_file("/tmp/oak-identity-roundtrip.oakproj");
|
||||
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
|
||||
assert_eq!(unsafe { oaktask_task_start(task) }, OAKTASK_OK);
|
||||
assert_eq!(unsafe { oaktask_task_start(task) }, OAKTASK_E_STATE); // already running
|
||||
|
||||
assert_eq!(unsafe { oaktask_task_wait(task) }, OAKTASK_OK);
|
||||
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
|
||||
assert_eq!(unsafe { oaktask_task_succeeded(task) }, 1);
|
||||
assert!(std::path::Path::new("/tmp/oak-identity-roundtrip.oakproj").exists());
|
||||
|
||||
unsafe { oaktask_manager_delete_finished() };
|
||||
assert_eq!(unsafe { oaktask_manager_count() }, 0);
|
||||
|
||||
free_task(&mut task);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
/// alive count: task create/destroy moves oaktask_debug_alive_count
|
||||
/// predictably and returns to baseline.
|
||||
#[test]
|
||||
fn alive_count_accounting() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
|
||||
let baseline = unsafe { oaktask_debug_alive_count() };
|
||||
assert_eq!(baseline, 0);
|
||||
|
||||
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-alive-count.oakproj".to_string();
|
||||
let mut t1 = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
|
||||
let mut t2 = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, baseline + 2);
|
||||
|
||||
free_task(&mut t1);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, baseline + 1);
|
||||
free_task(&mut t2);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, baseline);
|
||||
}
|
||||
|
||||
/// subscribe + async start: the callback observes STARTED (with a start
|
||||
/// time), then FINISHED (1.0 on success); the subscription is one-shot.
|
||||
#[test]
|
||||
fn subscribe_records_started_and_finished() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
unsafe { oaktask_manager_init() };
|
||||
|
||||
static STARTED: AtomicI32 = AtomicI32::new(0);
|
||||
static FINISHED: AtomicI32 = AtomicI32::new(0);
|
||||
static FINISHED_VALUE: AtomicI32 = AtomicI32::new(-1);
|
||||
|
||||
unsafe extern "C" fn recorder(event_id: c_int, value: f64, _ud: *mut c_void) {
|
||||
match event_id {
|
||||
0 => {
|
||||
STARTED.fetch_add(1, Ordering::SeqCst);
|
||||
assert!(value > 0.0); // start time in ms
|
||||
}
|
||||
2 => {
|
||||
FINISHED.fetch_add(1, Ordering::SeqCst);
|
||||
FINISHED_VALUE.store(value as i32, Ordering::SeqCst);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-subscribe.oakproj".to_string();
|
||||
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
|
||||
|
||||
// Null callback -> E_INVALID.
|
||||
assert_eq!(
|
||||
unsafe { oaktask_task_subscribe(task, None, std::ptr::null_mut()) },
|
||||
OAKTASK_E_INVALID as i64
|
||||
);
|
||||
// Empty handle -> E_INVALID.
|
||||
assert_eq!(
|
||||
unsafe { oaktask_task_subscribe(CHandle::null(), Some(recorder), std::ptr::null_mut()) },
|
||||
OAKTASK_E_INVALID as i64
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
unsafe { oaktask_task_subscribe(task, Some(recorder), std::ptr::null_mut()) },
|
||||
0
|
||||
);
|
||||
assert_eq!(unsafe { oaktask_task_start(task) }, OAKTASK_OK);
|
||||
assert_eq!(unsafe { oaktask_task_wait(task) }, OAKTASK_OK);
|
||||
|
||||
assert_eq!(STARTED.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(FINISHED.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(FINISHED_VALUE.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
|
||||
|
||||
unsafe { oaktask_manager_delete_finished() };
|
||||
free_task(&mut task);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
/// start_sync on a task with a failing serializer reports failure (0) with
|
||||
/// an error message, and the error getter returns it (two-stage).
|
||||
#[test]
|
||||
fn start_sync_failure_paths() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
*PROJECT_FILENAME.lock().unwrap() = "".to_string(); // no filename
|
||||
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
|
||||
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
|
||||
assert_eq!(unsafe { oaktask_task_succeeded(task) }, 0);
|
||||
|
||||
let needed = unsafe { oaktask_task_error(task, std::ptr::null_mut(), 0) };
|
||||
assert!(needed > 0);
|
||||
let mut buf = vec![0i8; needed as usize];
|
||||
unsafe { oaktask_task_error(task, buf.as_mut_ptr(), needed) };
|
||||
let msg = cstr_read(&buf);
|
||||
assert!(msg.contains("no filename"), "error was: {msg}");
|
||||
|
||||
// Null handle -> E_INVALID.
|
||||
assert_eq!(
|
||||
unsafe { oaktask_task_start_sync(CHandle::null()) },
|
||||
OAKTASK_E_INVALID
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaktask_task_cancel(CHandle::null()) },
|
||||
OAKTASK_E_INVALID
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaktask_task_wait(CHandle::null()) },
|
||||
OAKTASK_E_INVALID
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaktask_task_is_finished(CHandle::null()) },
|
||||
OAKTASK_E_INVALID
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaktask_task_succeeded(CHandle::null()) },
|
||||
OAKTASK_E_INVALID
|
||||
);
|
||||
|
||||
free_task(&mut task);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
}
|
||||
|
||||
/// cancel on a fresh task is a no-op success; is_finished stays 0.
|
||||
#[test]
|
||||
fn cancel_is_noop_before_start() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-cancel.oakproj".to_string();
|
||||
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
|
||||
assert_eq!(unsafe { oaktask_task_cancel(task) }, OAKTASK_OK);
|
||||
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 0);
|
||||
free_task(&mut task);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
}
|
||||
|
||||
/// start without an initialized manager -> E_STATE.
|
||||
#[test]
|
||||
fn start_without_manager_is_state_error() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
|
||||
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-no-manager.oakproj".to_string();
|
||||
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
|
||||
assert_eq!(unsafe { oaktask_task_start(task) }, OAKTASK_E_STATE);
|
||||
// Manager accessors also report E_STATE / empty without a manager.
|
||||
assert_eq!(unsafe { oaktask_manager_count() }, OAKTASK_E_STATE);
|
||||
assert!(unsafe { oaktask_manager_at(0) }.is_null());
|
||||
unsafe { oaktask_manager_delete_finished() }; // no-op
|
||||
|
||||
// start_sync still works without a manager.
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
|
||||
|
||||
free_task(&mut task);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
}
|
||||
|
||||
/// cstr_read helper: read a NUL-terminated c_char buffer.
|
||||
fn cstr_read(buf: &[i8]) -> String {
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
let bytes = unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) };
|
||||
String::from_utf8_lossy(bytes).into_owned()
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! TaskManager singleton lifecycle tests (manager.rs + ffi/manager.rs).
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use oaktask::error::{OAKTASK_E_STATE, OAKTASK_OK};
|
||||
use oaktask::ffi::manager::{
|
||||
oaktask_manager_at, oaktask_manager_count, oaktask_manager_delete_finished,
|
||||
oaktask_manager_init, oaktask_manager_shutdown, oaktask_register_codec_submitter,
|
||||
};
|
||||
use oaktask::ffi::project::oaktask_create_project_save;
|
||||
use oaktask::ffi::task::{
|
||||
oaktask_debug_alive_count, oaktask_task_free, oaktask_task_is_finished, oaktask_task_start,
|
||||
oaktask_task_wait,
|
||||
};
|
||||
use oaktask::handle::CHandle;
|
||||
|
||||
/// Given an uninitialized manager, `oaktask_manager_count` reports 0
|
||||
/// and `oaktask_manager_at` returns an empty handle.
|
||||
#[test]
|
||||
fn uninitialized_manager_is_empty() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
|
||||
assert_eq!(unsafe { oaktask_manager_count() }, OAKTASK_E_STATE);
|
||||
assert!(unsafe { oaktask_manager_at(0) }.is_null());
|
||||
assert!(unsafe { oaktask_manager_at(-1) }.is_null());
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
}
|
||||
|
||||
/// Given an initialized manager, starting a task adds it to the list
|
||||
/// and `oaktask_manager_at` returns a borrowed handle at that index.
|
||||
#[test]
|
||||
fn started_task_is_listed_at_index() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
unsafe { oaktask_manager_init() };
|
||||
|
||||
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-manager-at.oakproj".to_string();
|
||||
let mut task = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
|
||||
assert_eq!(unsafe { oaktask_task_start(task) }, OAKTASK_OK);
|
||||
assert_eq!(unsafe { oaktask_manager_count() }, 1);
|
||||
|
||||
// The borrowed handle reports the same task once it finishes.
|
||||
assert_eq!(unsafe { oaktask_task_wait(task) }, OAKTASK_OK);
|
||||
let borrowed = unsafe { oaktask_manager_at(0) };
|
||||
assert!(!borrowed.ctx.is_null());
|
||||
assert_eq!(unsafe { oaktask_task_is_finished(borrowed) }, 1);
|
||||
let mut b = borrowed;
|
||||
unsafe { oaktask_task_free(&mut b) };
|
||||
|
||||
// Out of range -> empty.
|
||||
assert!(unsafe { oaktask_manager_at(5) }.is_null());
|
||||
|
||||
unsafe { oaktask_manager_delete_finished() };
|
||||
assert_eq!(unsafe { oaktask_manager_count() }, 0);
|
||||
free(&mut task);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
/// Given finished tasks, `oaktask_manager_delete_finished` removes them
|
||||
/// and `oaktask_manager_count` shrinks accordingly.
|
||||
#[test]
|
||||
fn delete_finished_removes_finished_tasks() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
unsafe { oaktask_manager_init() };
|
||||
|
||||
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-delete-finished.oakproj".to_string();
|
||||
let mut t1 = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
|
||||
let mut t2 = unsafe { oaktask_create_project_save(fake_handle(), std::ptr::null(), 0) };
|
||||
assert_eq!(unsafe { oaktask_task_start(t1) }, OAKTASK_OK);
|
||||
assert_eq!(unsafe { oaktask_task_start(t2) }, OAKTASK_OK);
|
||||
assert_eq!(unsafe { oaktask_manager_count() }, 2);
|
||||
|
||||
// Wait for both to finish, then delete_finished removes both.
|
||||
unsafe { oaktask_task_wait(t1) };
|
||||
unsafe { oaktask_task_wait(t2) };
|
||||
unsafe { oaktask_manager_delete_finished() };
|
||||
assert_eq!(unsafe { oaktask_manager_count() }, 0);
|
||||
|
||||
free(&mut t1);
|
||||
free(&mut t2);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
/// Given a registered codec submitter, codec tasks are routed through
|
||||
/// the manager; `oaktask_register_codec_submitter` is idempotent.
|
||||
#[test]
|
||||
fn codec_submitter_registration_is_idempotent() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
|
||||
// The submitter can be registered without a manager (it is a codec-side
|
||||
// callback, not a manager task).
|
||||
assert_eq!(unsafe { oaktask_register_codec_submitter() }, OAKTASK_OK);
|
||||
assert_eq!(unsafe { oaktask_register_codec_submitter() }, OAKTASK_OK);
|
||||
unsafe { oaktask_manager_init() };
|
||||
// Manager init registers too; repeated registration stays OK.
|
||||
assert_eq!(unsafe { oaktask_register_codec_submitter() }, OAKTASK_OK);
|
||||
|
||||
// The stub captured the callback.
|
||||
assert!(SUBMIT_CB.lock().unwrap().is_some());
|
||||
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
/// Given a shutdown manager, subsequent start/at calls are safe and
|
||||
/// report no tasks without panicking.
|
||||
#[test]
|
||||
fn shutdown_is_safe_and_idempotent() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
|
||||
unsafe { oaktask_manager_init() };
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
// Idempotent.
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
|
||||
// After shutdown, start/at are safe (E_STATE / empty).
|
||||
assert_eq!(unsafe { oaktask_manager_count() }, OAKTASK_E_STATE);
|
||||
assert!(unsafe { oaktask_manager_at(0) }.is_null());
|
||||
unsafe { oaktask_manager_delete_finished() };
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
|
||||
// Re-init works after shutdown.
|
||||
assert_eq!(unsafe { oaktask_manager_init() }, OAKTASK_OK);
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
/// A conform request submitted through the codec callback runs the
|
||||
/// ConformTask synchronously (interim contract).
|
||||
#[test]
|
||||
fn codec_submit_runs_conform_synchronously() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
|
||||
assert_eq!(unsafe { oaktask_register_codec_submitter() }, OAKTASK_OK);
|
||||
|
||||
// Prepare real working files so the conform rename succeeds.
|
||||
let dir = std::env::temp_dir().join("oak-conform-submit");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let final0 = dir.join("out.0.pcm");
|
||||
let final1 = dir.join("out.1.pcm");
|
||||
let _ = std::fs::remove_file(&final0);
|
||||
let _ = std::fs::remove_file(&final1);
|
||||
let working0 = dir.join("out.0.pcm.working");
|
||||
let working1 = dir.join("out.1.pcm.working");
|
||||
std::fs::write(&working0, b"pcm").unwrap();
|
||||
std::fs::write(&working1, b"pcm").unwrap();
|
||||
|
||||
DECODER_OPEN_RESULT.store(0, Ordering::SeqCst);
|
||||
DECODER_CONFORM_RESULT.store(0, Ordering::SeqCst);
|
||||
|
||||
let request = oaktask::bridge::codec::OakCodecTaskRequest {
|
||||
kind: 0, // OAKCODEC_TASK_CONFORM
|
||||
input_filename: common::cstr_of("/tmp/oak-conform-src.mov"),
|
||||
output_filename: common::cstr_of(&final0.to_string_lossy()),
|
||||
stream_index: 1,
|
||||
sample_rate: 48000,
|
||||
channel_layout: 3, // stereo
|
||||
sample_format: 0,
|
||||
proxy_width: 0,
|
||||
proxy_height: 0,
|
||||
};
|
||||
let cb = SUBMIT_CB.lock().unwrap().unwrap();
|
||||
let ret = unsafe { cb(&request, std::ptr::null_mut()) };
|
||||
assert_eq!(ret, 0); // OAKCODEC_OK
|
||||
assert!(final0.exists(), "final pcm file was not moved into place");
|
||||
assert!(
|
||||
final1.exists(),
|
||||
"second channel pcm file was not moved into place"
|
||||
);
|
||||
let _ = std::fs::remove_file(&final0);
|
||||
let _ = std::fs::remove_file(&final1);
|
||||
let _ = std::fs::remove_file(&working0);
|
||||
let _ = std::fs::remove_file(&working1);
|
||||
|
||||
// Null request -> OAKCODEC_E_INVALID.
|
||||
let ret = unsafe { cb(std::ptr::null(), std::ptr::null_mut()) };
|
||||
assert_eq!(ret, oaktask::bridge::codec::OAKCODEC_E_INVALID);
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
/// A proxy request submitted through the codec callback runs ffmpeg (a real
|
||||
/// test script), parses progress lines, and renames the working file into
|
||||
/// place.
|
||||
#[test]
|
||||
fn codec_submit_runs_proxy_with_script() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
assert_eq!(unsafe { oaktask_register_codec_submitter() }, OAKTASK_OK);
|
||||
|
||||
let dir = std::env::temp_dir().join("oak-proxy-run");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let script = dir.join("fake-ffmpeg.sh");
|
||||
let output = dir.join("proxy.mp4");
|
||||
let _ = std::fs::remove_file(&output);
|
||||
|
||||
// The script reports progress on stdout and creates its last argument
|
||||
// (the working file), then exits 0.
|
||||
std::fs::write(
|
||||
&script,
|
||||
"#!/bin/bash\nprintf 'out_time_us=5000000\\n'\nprintf 'out_time_us=9000000\\n'\nOUT=\"${@: -1}\"\ntouch \"$OUT\"\nexit 0\n",
|
||||
)
|
||||
.unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
*PROXY_FFMPEG_PATH.lock().unwrap() = script.to_string_lossy().into_owned();
|
||||
|
||||
let request = oaktask::bridge::codec::OakCodecTaskRequest {
|
||||
kind: 1, // OAKCODEC_TASK_PROXY
|
||||
input_filename: common::cstr_of("/tmp/oak-proxy-src.mov"),
|
||||
output_filename: common::cstr_of(&output.to_string_lossy()),
|
||||
stream_index: 0,
|
||||
sample_rate: 0,
|
||||
channel_layout: 0,
|
||||
sample_format: 0,
|
||||
proxy_width: 0,
|
||||
proxy_height: 0,
|
||||
};
|
||||
let cb = SUBMIT_CB.lock().unwrap().unwrap();
|
||||
let ret = unsafe { cb(&request, std::ptr::null_mut()) };
|
||||
assert_eq!(ret, 0, "proxy submit should succeed");
|
||||
assert!(output.exists(), "proxy output file was not created");
|
||||
|
||||
let _ = std::fs::remove_file(&output);
|
||||
let _ = std::fs::remove_file(&script);
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
/// Proxy failure paths: ffmpeg missing, failing script, and a script that
|
||||
/// does not produce the output.
|
||||
#[test]
|
||||
fn proxy_failure_paths() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
assert_eq!(unsafe { oaktask_register_codec_submitter() }, OAKTASK_OK);
|
||||
|
||||
let dir = std::env::temp_dir().join("oak-proxy-fail");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let output = dir.join("proxy.mp4");
|
||||
|
||||
let cb = SUBMIT_CB.lock().unwrap().unwrap();
|
||||
let request = oaktask::bridge::codec::OakCodecTaskRequest {
|
||||
kind: 1,
|
||||
input_filename: common::cstr_of("/tmp/oak-proxy-src.mov"),
|
||||
output_filename: common::cstr_of(&output.to_string_lossy()),
|
||||
stream_index: 0,
|
||||
sample_rate: 0,
|
||||
channel_layout: 0,
|
||||
sample_format: 0,
|
||||
proxy_width: 0,
|
||||
proxy_height: 0,
|
||||
};
|
||||
|
||||
// 1. No ffmpeg found.
|
||||
PROXY_FFMPEG_PATH.lock().unwrap().clear();
|
||||
let ret = unsafe { cb(&request, std::ptr::null_mut()) };
|
||||
assert_eq!(ret, oaktask::bridge::codec::OAKCODEC_E_FAILED);
|
||||
|
||||
// 2. ffmpeg exits non-zero.
|
||||
let script = dir.join("fail-ffmpeg.sh");
|
||||
std::fs::write(&script, "#!/bin/bash\nexit 1\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
*PROXY_FFMPEG_PATH.lock().unwrap() = script.to_string_lossy().into_owned();
|
||||
let ret = unsafe { cb(&request, std::ptr::null_mut()) };
|
||||
assert_eq!(ret, oaktask::bridge::codec::OAKCODEC_E_FAILED);
|
||||
|
||||
// 3. ffmpeg exits 0 but creates nothing.
|
||||
let script2 = dir.join("noout-ffmpeg.sh");
|
||||
std::fs::write(&script2, "#!/bin/bash\nexit 0\n").unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&script2, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
*PROXY_FFMPEG_PATH.lock().unwrap() = script2.to_string_lossy().into_owned();
|
||||
let ret = unsafe { cb(&request, std::ptr::null_mut()) };
|
||||
assert_eq!(ret, oaktask::bridge::codec::OAKCODEC_E_FAILED);
|
||||
|
||||
let _ = std::fs::remove_file(&output);
|
||||
let _ = std::fs::remove_file(&script);
|
||||
let _ = std::fs::remove_file(&script2);
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
/// Conform failure paths: decoder open fails and conform returns a
|
||||
/// non-OK code.
|
||||
#[test]
|
||||
fn conform_failure_paths() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
assert_eq!(unsafe { oaktask_register_codec_submitter() }, OAKTASK_OK);
|
||||
|
||||
let dir = std::env::temp_dir().join("oak-conform-fail");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let final0 = dir.join("out.0.pcm");
|
||||
let working0 = dir.join("out.0.pcm.working");
|
||||
|
||||
let cb = SUBMIT_CB.lock().unwrap().unwrap();
|
||||
let request = oaktask::bridge::codec::OakCodecTaskRequest {
|
||||
kind: 0,
|
||||
input_filename: common::cstr_of("/tmp/oak-conform-src.mov"),
|
||||
output_filename: common::cstr_of(&final0.to_string_lossy()),
|
||||
stream_index: 1,
|
||||
sample_rate: 48000,
|
||||
channel_layout: 3,
|
||||
sample_format: 0,
|
||||
proxy_width: 0,
|
||||
proxy_height: 0,
|
||||
};
|
||||
|
||||
// Decoder open fails.
|
||||
DECODER_OPEN_RESULT.store(-1, Ordering::SeqCst);
|
||||
let ret = unsafe { cb(&request, std::ptr::null_mut()) };
|
||||
assert_eq!(ret, oaktask::bridge::codec::OAKCODEC_E_FAILED);
|
||||
|
||||
// Conform reports a failure.
|
||||
DECODER_OPEN_RESULT.store(0, Ordering::SeqCst);
|
||||
DECODER_CONFORM_RESULT.store(-50003, Ordering::SeqCst);
|
||||
std::fs::write(&working0, b"pcm").unwrap();
|
||||
let ret = unsafe { cb(&request, std::ptr::null_mut()) };
|
||||
assert_eq!(ret, oaktask::bridge::codec::OAKCODEC_E_FAILED);
|
||||
// The partial working file is cleaned up.
|
||||
assert!(!working0.exists(), "partial working file should be removed");
|
||||
|
||||
// Conform reports cancellation.
|
||||
DECODER_CONFORM_RESULT.store(-50006, Ordering::SeqCst);
|
||||
std::fs::write(&working0, b"pcm").unwrap();
|
||||
let ret = unsafe { cb(&request, std::ptr::null_mut()) };
|
||||
assert_eq!(ret, oaktask::bridge::codec::OAKCODEC_E_FAILED);
|
||||
|
||||
let _ = std::fs::remove_file(&working0);
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
fn free(t: &mut CHandle) {
|
||||
unsafe {
|
||||
oaktask_task_free(t);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,615 +0,0 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Project task factory tests (project.rs + ffi/project.rs): load, save,
|
||||
//! import, OTIO load/save and pre-cache/export factories.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use std::ffi::{c_char, c_int, c_void};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use oaktask::error::{OAKTASK_E_INVALID, OAKTASK_E_NOT_FOUND};
|
||||
use oaktask::ffi::manager::{oaktask_manager_init, oaktask_manager_shutdown};
|
||||
use oaktask::ffi::project::{
|
||||
oaktask_create_export, oaktask_create_precache, oaktask_create_project_import,
|
||||
oaktask_create_project_load, oaktask_create_project_load_otio, oaktask_create_project_save,
|
||||
oaktask_create_project_save_otio, oaktask_import_footage_at, oaktask_import_footage_count,
|
||||
oaktask_import_invalid_at, oaktask_import_invalid_count,
|
||||
oaktask_import_set_image_sequence_confirm_cb, oaktask_import_take_command,
|
||||
oaktask_load_otio_set_confirm_cb, oaktask_load_otio_take_project, oaktask_load_take_project,
|
||||
};
|
||||
use oaktask::ffi::task::{
|
||||
oaktask_debug_alive_count, oaktask_task_error, oaktask_task_free, oaktask_task_is_finished,
|
||||
oaktask_task_start_sync, oaktask_task_title,
|
||||
};
|
||||
use oaktask::handle::CHandle;
|
||||
|
||||
unsafe extern "C" fn accept_all(
|
||||
_seq: *const *const c_char,
|
||||
_count: c_int,
|
||||
_ud: *mut c_void,
|
||||
) -> c_int {
|
||||
1
|
||||
}
|
||||
|
||||
unsafe extern "C" fn reject_all(
|
||||
_seq: *const *const c_char,
|
||||
_count: c_int,
|
||||
_ud: *mut c_void,
|
||||
) -> c_int {
|
||||
0
|
||||
}
|
||||
|
||||
unsafe extern "C" fn image_seq_confirm(_filename: *const c_char, _ud: *mut c_void) -> c_int {
|
||||
1
|
||||
}
|
||||
|
||||
fn free(t: &mut CHandle) {
|
||||
unsafe {
|
||||
oaktask_task_free(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a valid project file, `create_project_load` returns a task whose
|
||||
/// `start_sync` succeeds and whose `load_take_project` transfers an
|
||||
/// initialized project with a root folder.
|
||||
#[test]
|
||||
fn project_load_transfers_initialized_project() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
let path = std::env::temp_dir().join("oak-load-ok.oakproj");
|
||||
std::fs::write(&path, b"oakproj-stub").unwrap();
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
|
||||
let mut task = unsafe { oaktask_create_project_load(common::cstr_of(&path_str)) };
|
||||
assert!(!task.ctx.is_null());
|
||||
|
||||
// Title embeds the filename.
|
||||
let needed = unsafe { oaktask_task_title(task, std::ptr::null_mut(), 0) };
|
||||
assert!(needed > 10);
|
||||
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
|
||||
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
|
||||
|
||||
// take_project transfers a non-empty project.
|
||||
let project = unsafe { oaktask_load_take_project(task) };
|
||||
assert!(!project.ctx.is_null(), "load_take_project returned empty");
|
||||
// Second take is empty (ownership transferred).
|
||||
let project2 = unsafe { oaktask_load_take_project(task) };
|
||||
assert!(project2.ctx.is_null());
|
||||
|
||||
free(&mut task);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
/// Loading a missing file fails: start_sync=0, an error is set, and
|
||||
/// take_project is empty.
|
||||
#[test]
|
||||
fn project_load_missing_file_fails() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
let missing = "/tmp/oak-does-not-exist-12345.oakproj";
|
||||
let _ = std::fs::remove_file(missing);
|
||||
|
||||
let mut task = unsafe { oaktask_create_project_load(common::cstr_of(missing)) };
|
||||
assert!(!task.ctx.is_null());
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
|
||||
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
|
||||
|
||||
let needed = unsafe { oaktask_task_error(task, std::ptr::null_mut(), 0) };
|
||||
assert!(needed > 0);
|
||||
let mut buf = vec![0i8; needed as usize];
|
||||
unsafe { oaktask_task_error(task, buf.as_mut_ptr(), needed) };
|
||||
assert!(cstr_read(&buf).contains("Failed to read file"));
|
||||
|
||||
let project = unsafe { oaktask_load_take_project(task) };
|
||||
assert!(project.ctx.is_null());
|
||||
|
||||
// Null filename -> empty handle (factory failure path).
|
||||
let empty = unsafe { oaktask_create_project_load(std::ptr::null()) };
|
||||
assert!(empty.ctx.is_null());
|
||||
|
||||
free(&mut task);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
}
|
||||
|
||||
/// Given a loaded project, `create_project_save` writes the project and the
|
||||
/// task succeeds with compression on and off.
|
||||
#[test]
|
||||
fn project_save_writes_with_and_without_compression() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
let path = std::env::temp_dir().join("oak-save-compressed.oakproj");
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
// Without compression.
|
||||
let mut task =
|
||||
unsafe { oaktask_create_project_save(fake_handle(), common::cstr_of(&path_str), 0) };
|
||||
assert!(!task.ctx.is_null());
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
|
||||
assert!(path.exists(), "saved file was not written");
|
||||
free(&mut task);
|
||||
|
||||
// With compression (overwrites).
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let mut task =
|
||||
unsafe { oaktask_create_project_save(fake_handle(), common::cstr_of(&path_str), 1) };
|
||||
assert!(!task.ctx.is_null());
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
|
||||
assert!(path.exists());
|
||||
free(&mut task);
|
||||
|
||||
// Null project -> empty handle.
|
||||
let empty =
|
||||
unsafe { oaktask_create_project_save(CHandle::null(), common::cstr_of(&path_str), 0) };
|
||||
assert!(empty.ctx.is_null());
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
}
|
||||
|
||||
/// Given an import folder and a media URL, `create_project_import` runs to
|
||||
/// completion, records one footage and exposes it via `import_footage_at`.
|
||||
#[test]
|
||||
fn project_import_records_and_exposes_footage() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
let file = std::env::temp_dir().join("oak-import-ok.mp4");
|
||||
std::fs::write(&file, b"media").unwrap();
|
||||
let file_str = file.to_string_lossy().into_owned();
|
||||
|
||||
let urls = [common::cstr_of(&file_str)];
|
||||
let mut task = unsafe {
|
||||
oaktask_create_project_import(
|
||||
fake_handle(),
|
||||
fake_handle(),
|
||||
urls.as_ptr(),
|
||||
urls.len() as c_int,
|
||||
)
|
||||
};
|
||||
assert!(!task.ctx.is_null());
|
||||
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
|
||||
assert_eq!(unsafe { oaktask_import_footage_count(task) }, 1);
|
||||
assert_eq!(unsafe { oaktask_import_invalid_count(task) }, 0);
|
||||
|
||||
// footage_at returns an addref'd handle for in-range indices.
|
||||
let footage = unsafe { oaktask_import_footage_at(task, 0) };
|
||||
assert!(!footage.ctx.is_null());
|
||||
// Out of range -> empty.
|
||||
assert!(unsafe { oaktask_import_footage_at(task, 5) }.ctx.is_null());
|
||||
assert!(unsafe { oaktask_import_footage_at(task, -1) }.ctx.is_null());
|
||||
|
||||
// The undo command is produced; redo adds the folder child, undo
|
||||
// removes it again.
|
||||
let mut command = unsafe { oaktask_import_take_command(task) };
|
||||
assert!(!command.ctx.is_null());
|
||||
// Second take is empty.
|
||||
assert!(unsafe { oaktask_import_take_command(task) }.ctx.is_null());
|
||||
|
||||
assert_eq!(
|
||||
unsafe { oaktask::bridge::undo::oakundo_command_redo_now(command) },
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaktask::bridge::node::oaknode_folder_child_count(fake_handle()) },
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaktask::bridge::undo::oakundo_command_undo_now(command) },
|
||||
0
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaktask::bridge::node::oaknode_folder_child_count(fake_handle()) },
|
||||
0
|
||||
);
|
||||
|
||||
unsafe {
|
||||
oaktask::bridge::undo::oakundo_command_free(&mut command);
|
||||
}
|
||||
free(&mut task);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
let _ = std::fs::remove_file(&file);
|
||||
}
|
||||
|
||||
/// Given an unreadable import URL, `create_project_import` records the
|
||||
/// invalid filename and `import_invalid_at` reports it (two-stage).
|
||||
#[test]
|
||||
fn project_import_reports_invalid_urls() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
FOOTAGE_VALID.store(0, Ordering::SeqCst);
|
||||
|
||||
let bad = "/tmp/oak-invalid-media-12345.mp4";
|
||||
let _ = std::fs::remove_file(bad);
|
||||
let urls = [common::cstr_of(bad)];
|
||||
let mut task = unsafe {
|
||||
oaktask_create_project_import(
|
||||
fake_handle(),
|
||||
fake_handle(),
|
||||
urls.as_ptr(),
|
||||
urls.len() as c_int,
|
||||
)
|
||||
};
|
||||
assert!(!task.ctx.is_null());
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
|
||||
assert_eq!(unsafe { oaktask_import_invalid_count(task) }, 1);
|
||||
assert_eq!(unsafe { oaktask_import_footage_count(task) }, 0);
|
||||
|
||||
// Two-stage invalid filename getter.
|
||||
let needed = unsafe { oaktask_import_invalid_at(task, 0, std::ptr::null_mut(), 0) };
|
||||
assert!(needed > 0);
|
||||
let mut buf = vec![0i8; needed as usize];
|
||||
let r = unsafe { oaktask_import_invalid_at(task, 0, buf.as_mut_ptr(), needed) };
|
||||
assert_eq!(r, needed);
|
||||
assert_eq!(cstr_read(&buf), bad);
|
||||
|
||||
// Out of range -> E_NOT_FOUND.
|
||||
assert_eq!(
|
||||
unsafe { oaktask_import_invalid_at(task, 3, std::ptr::null_mut(), 0) },
|
||||
OAKTASK_E_NOT_FOUND
|
||||
);
|
||||
|
||||
// Factory failure paths: null folder, null project, negative count.
|
||||
assert!(unsafe {
|
||||
oaktask_create_project_import(CHandle::null(), fake_handle(), std::ptr::null(), 0)
|
||||
}
|
||||
.ctx
|
||||
.is_null());
|
||||
assert!(unsafe {
|
||||
oaktask_create_project_import(fake_handle(), CHandle::null(), std::ptr::null(), 0)
|
||||
}
|
||||
.ctx
|
||||
.is_null());
|
||||
let urls_null = [std::ptr::null()];
|
||||
assert!(unsafe {
|
||||
oaktask_create_project_import(fake_handle(), fake_handle(), urls_null.as_ptr(), 1)
|
||||
}
|
||||
.ctx
|
||||
.is_null());
|
||||
|
||||
// Accessors on a non-import task -> E_INVALID / empty.
|
||||
let mut save_task =
|
||||
unsafe { oaktask_create_project_save(fake_handle(), common::cstr_of("/tmp/x.oakproj"), 0) };
|
||||
assert_eq!(
|
||||
unsafe { oaktask_import_footage_count(save_task) },
|
||||
OAKTASK_E_INVALID
|
||||
);
|
||||
assert!(unsafe { oaktask_import_take_command(save_task) }
|
||||
.ctx
|
||||
.is_null());
|
||||
free(&mut save_task);
|
||||
|
||||
free(&mut task);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
}
|
||||
|
||||
/// An image sequence with a confirmation callback imports once and prunes
|
||||
/// the remaining frames from the list.
|
||||
#[test]
|
||||
fn project_import_image_sequence_confirmed() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
let dir = std::env::temp_dir().join("oak-seq");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let files = ["seq001.png", "seq002.png", "seq003.png"];
|
||||
let mut paths = Vec::new();
|
||||
for f in &files {
|
||||
let p = dir.join(f);
|
||||
std::fs::write(&p, b"png").unwrap();
|
||||
paths.push(p.to_string_lossy().into_owned());
|
||||
}
|
||||
|
||||
IMAGE_SEQUENCE_DIGIT_COUNT.store(3, Ordering::SeqCst);
|
||||
CONFIG_DEFAULT_SEQ_FRAME_RATE.store(1, Ordering::SeqCst);
|
||||
unsafe {
|
||||
oaktask_import_set_image_sequence_confirm_cb(Some(image_seq_confirm), std::ptr::null_mut())
|
||||
};
|
||||
|
||||
let urls: Vec<*const c_char> = paths.iter().map(|p| common::cstr_of(p)).collect();
|
||||
let mut task = unsafe {
|
||||
oaktask_create_project_import(
|
||||
fake_handle(),
|
||||
fake_handle(),
|
||||
urls.as_ptr(),
|
||||
urls.len() as c_int,
|
||||
)
|
||||
};
|
||||
assert!(!task.ctx.is_null());
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
|
||||
|
||||
// The three frames collapse into a single footage import.
|
||||
assert_eq!(unsafe { oaktask_import_footage_count(task) }, 1);
|
||||
assert_eq!(unsafe { oaktask_import_invalid_count(task) }, 0);
|
||||
|
||||
unsafe { oaktask_import_set_image_sequence_confirm_cb(None, std::ptr::null_mut()) };
|
||||
IMAGE_SEQUENCE_DIGIT_COUNT.store(0, Ordering::SeqCst);
|
||||
free(&mut task);
|
||||
for p in &paths {
|
||||
let _ = std::fs::remove_file(p);
|
||||
}
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
}
|
||||
|
||||
/// Given a valid OTIO file, `create_project_load_otio` runs to completion and
|
||||
/// `load_otio_take_project` transfers an initialized project; a missing file
|
||||
/// fails cleanly. (OTIO parsing now happens inside the task through the
|
||||
/// `oakotio` binding — README decision #6.)
|
||||
#[test]
|
||||
fn otio_load_transfers_project_with_default_confirm() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
|
||||
// A minimal Timeline document (no tracks).
|
||||
let path = std::env::temp_dir().join("oak-load-ok.otio");
|
||||
let otio = r#"{
|
||||
"OTIO_SCHEMA": "Timeline.1",
|
||||
"metadata": {},
|
||||
"name": "T",
|
||||
"global_start_time": null,
|
||||
"tracks": {
|
||||
"OTIO_SCHEMA": "Stack.1",
|
||||
"metadata": {},
|
||||
"name": "tracks",
|
||||
"source_range": null,
|
||||
"effects": [],
|
||||
"markers": [],
|
||||
"enabled": true,
|
||||
"children": []
|
||||
}
|
||||
}"#;
|
||||
std::fs::write(&path, otio).unwrap();
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
|
||||
let mut task = unsafe { oaktask_create_project_load_otio(common::cstr_of(&path_str)) };
|
||||
assert!(!task.ctx.is_null());
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
|
||||
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
|
||||
assert!(!(unsafe { oaktask_load_otio_take_project(task) })
|
||||
.ctx
|
||||
.is_null());
|
||||
|
||||
free(&mut task);
|
||||
|
||||
// Missing file -> failure with a clear error.
|
||||
let missing = std::env::temp_dir().join("oak-missing-otio.otio");
|
||||
let missing_str = missing.to_string_lossy().into_owned();
|
||||
let _ = std::fs::remove_file(&missing);
|
||||
let mut task = unsafe { oaktask_create_project_load_otio(common::cstr_of(&missing_str)) };
|
||||
assert!(!task.ctx.is_null());
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
|
||||
let needed = unsafe { oaktask_task_error(task, std::ptr::null_mut(), 0) };
|
||||
assert!(needed > 0);
|
||||
let mut buf = vec![0i8; needed as usize];
|
||||
unsafe { oaktask_task_error(task, buf.as_mut_ptr(), needed) };
|
||||
assert!(
|
||||
cstr_read(&buf).contains("Failed to load OpenTimelineIO"),
|
||||
"error was: {}",
|
||||
cstr_read(&buf)
|
||||
);
|
||||
assert!(unsafe { oaktask_load_otio_take_project(task) }
|
||||
.ctx
|
||||
.is_null());
|
||||
|
||||
// The confirm callback setters are no-ops (null clears).
|
||||
unsafe {
|
||||
oaktask_load_otio_set_confirm_cb(Some(accept_all), std::ptr::null_mut());
|
||||
oaktask_load_otio_set_confirm_cb(Some(reject_all), std::ptr::null_mut());
|
||||
oaktask_load_otio_set_confirm_cb(None, std::ptr::null_mut());
|
||||
}
|
||||
|
||||
free(&mut task);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
}
|
||||
|
||||
/// Given a project with a sequence, `create_project_save_otio` writes the
|
||||
/// OTIO file and the task succeeds; a project without sequences fails.
|
||||
#[test]
|
||||
fn otio_save_writes_file() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
|
||||
// One sequence in the root folder, one video track with a single clip.
|
||||
FOLDER_CHILD_COUNT.store(1, Ordering::SeqCst);
|
||||
*NODE_ID.lock().unwrap() = "org.olivevideoeditor.Olive.sequence".to_string();
|
||||
*NODE_LABEL.lock().unwrap() = "My Sequence".to_string();
|
||||
VIDEO_FRAME_RATE_NUM.store(25, Ordering::SeqCst);
|
||||
VIDEO_FRAME_RATE_DEN.store(1, Ordering::SeqCst);
|
||||
TRACK_LIST_TRACK_COUNT.store(1, Ordering::SeqCst);
|
||||
TRACK_BLOCK_COUNT.store(1, Ordering::SeqCst);
|
||||
BLOCK_KIND.store(1, Ordering::SeqCst); // OAKNODE_BLOCK_CLIP
|
||||
BLOCK_IN_NUM.store(0, Ordering::SeqCst);
|
||||
BLOCK_IN_DEN.store(1, Ordering::SeqCst);
|
||||
BLOCK_LENGTH_NUM.store(25, Ordering::SeqCst);
|
||||
BLOCK_LENGTH_DEN.store(1, Ordering::SeqCst);
|
||||
*FOOTAGE_FILENAME.lock().unwrap() = "file:///tmp/video.mp4".to_string();
|
||||
|
||||
let out = std::env::temp_dir().join("oak-otio-out.otio");
|
||||
let out_str = out.to_string_lossy().into_owned();
|
||||
let _ = std::fs::remove_file(&out);
|
||||
|
||||
let mut task =
|
||||
unsafe { oaktask_create_project_save_otio(fake_handle(), common::cstr_of(&out_str)) };
|
||||
assert!(!task.ctx.is_null());
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
|
||||
assert!(out.exists(), "OTIO file was not written");
|
||||
|
||||
// Failure paths: null project or null filename.
|
||||
assert!(unsafe {
|
||||
oaktask_create_project_save_otio(CHandle::null(), common::cstr_of("/tmp/x.otio"))
|
||||
}
|
||||
.ctx
|
||||
.is_null());
|
||||
assert!(
|
||||
unsafe { oaktask_create_project_save_otio(fake_handle(), std::ptr::null()) }
|
||||
.ctx
|
||||
.is_null()
|
||||
);
|
||||
|
||||
free(&mut task);
|
||||
let _ = std::fs::remove_file(&out);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
}
|
||||
|
||||
/// Given a footage/sequence pair, `create_precache` runs to completion and
|
||||
/// succeeds.
|
||||
#[test]
|
||||
fn precache_runs_and_succeeds() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
FOOTAGE_FILENAME.lock().unwrap().clear();
|
||||
let mut task = unsafe { oaktask_create_precache(fake_handle(), 0, fake_handle()) };
|
||||
assert!(!task.ctx.is_null());
|
||||
let needed = unsafe { oaktask_task_title(task, std::ptr::null_mut(), 0) };
|
||||
assert!(needed > 0);
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
|
||||
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
|
||||
|
||||
// Failure path: null footage/sequence.
|
||||
assert!(
|
||||
unsafe { oaktask_create_precache(CHandle::null(), 0, fake_handle()) }
|
||||
.ctx
|
||||
.is_null()
|
||||
);
|
||||
assert!(
|
||||
unsafe { oaktask_create_precache(fake_handle(), 0, CHandle::null()) }
|
||||
.ctx
|
||||
.is_null()
|
||||
);
|
||||
|
||||
free(&mut task);
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
}
|
||||
|
||||
/// create_export runs the full render+encoder path against the stubs and
|
||||
/// succeeds; the encoder-failure path fails cleanly.
|
||||
#[test]
|
||||
fn export_runs_with_stubbed_encoder() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
*PROJECT_FILENAME.lock().unwrap() = "/tmp/oak-export-test.mp4".to_string();
|
||||
NODE_LABEL.lock().unwrap().clear();
|
||||
|
||||
let mut params = oaktask::bridge::codec::OakCodecEncodingParams {
|
||||
filename: [0; 1024],
|
||||
format: 0,
|
||||
video_enabled: 1,
|
||||
video_codec: 0,
|
||||
video_width: 1920,
|
||||
video_height: 1080,
|
||||
video_time_base_num: 1,
|
||||
video_time_base_den: 25,
|
||||
video_pixel_format: 0,
|
||||
video_interlacing: 0,
|
||||
video_pixel_aspect_num: 1,
|
||||
video_pixel_aspect_den: 1,
|
||||
video_bit_rate: 0,
|
||||
video_min_bit_rate: 0,
|
||||
video_max_bit_rate: 0,
|
||||
video_buffer_size: 0,
|
||||
video_threads: 0,
|
||||
video_pix_fmt: [0; 64],
|
||||
video_is_image_sequence: 0,
|
||||
video_scaling_method: 0,
|
||||
audio_enabled: 0,
|
||||
audio_codec: 0,
|
||||
audio_sample_rate: 0,
|
||||
audio_channel_layout: 0,
|
||||
audio_sample_format: 0,
|
||||
audio_bit_rate: 0,
|
||||
subtitles_enabled: 0,
|
||||
subtitles_codec: 0,
|
||||
subtitles_are_sidecar: 0,
|
||||
subtitles_sidecar_format: 0,
|
||||
color_transform_output: [0; 256],
|
||||
export_length_num: 0,
|
||||
export_length_den: 0,
|
||||
has_custom_range: 0,
|
||||
custom_range_in_num: 0,
|
||||
custom_range_in_den: 0,
|
||||
custom_range_out_num: 0,
|
||||
custom_range_out_den: 0,
|
||||
};
|
||||
let filename = std::env::temp_dir()
|
||||
.join("oak-export-test.mp4")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
write_cstr(&filename, params.filename.as_mut_ptr(), 1024);
|
||||
|
||||
ENCODER_INIT_NULL.store(0, Ordering::SeqCst);
|
||||
ENCODER_OPEN_RESULT.store(0, Ordering::SeqCst);
|
||||
ENCODER_WRITE_VIDEO_RESULT.store(0, Ordering::SeqCst);
|
||||
SEQUENCE_LENGTH_NUM.store(25, Ordering::SeqCst);
|
||||
SEQUENCE_LENGTH_DEN.store(1, Ordering::SeqCst);
|
||||
VIDEO_TIME_BASE_NUM.store(1, Ordering::SeqCst);
|
||||
VIDEO_TIME_BASE_DEN.store(1, Ordering::SeqCst); // 1s frames -> 25 frames
|
||||
|
||||
let mut task = unsafe { oaktask_create_export(fake_handle(), fake_handle(), ¶ms) };
|
||||
assert!(!task.ctx.is_null());
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
|
||||
assert_eq!(unsafe { oaktask_task_is_finished(task) }, 1);
|
||||
free(&mut task);
|
||||
|
||||
// Failure path: encoder init fails.
|
||||
ENCODER_INIT_NULL.store(1, Ordering::SeqCst);
|
||||
let mut task = unsafe { oaktask_create_export(fake_handle(), fake_handle(), ¶ms) };
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
|
||||
let needed = unsafe { oaktask_task_error(task, std::ptr::null_mut(), 0) };
|
||||
assert!(needed > 0);
|
||||
let mut buf = vec![0i8; needed as usize];
|
||||
unsafe { oaktask_task_error(task, buf.as_mut_ptr(), needed) };
|
||||
assert!(cstr_read(&buf).contains("Failed to create encoder"));
|
||||
free(&mut task);
|
||||
|
||||
// Failure path: null viewer / null params.
|
||||
assert!(
|
||||
unsafe { oaktask_create_export(CHandle::null(), fake_handle(), ¶ms) }
|
||||
.ctx
|
||||
.is_null()
|
||||
);
|
||||
assert!(
|
||||
unsafe { oaktask_create_export(fake_handle(), fake_handle(), std::ptr::null()) }
|
||||
.ctx
|
||||
.is_null()
|
||||
);
|
||||
|
||||
reset_stubs();
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
}
|
||||
|
||||
/// The manager is needed for nothing in these factories; a fresh
|
||||
/// init/shutdown cycle leaves the alive count at zero.
|
||||
#[test]
|
||||
fn manager_cycle_leaves_no_leaks() {
|
||||
let _guard = MANAGER_LOCK.lock().unwrap();
|
||||
reset_stubs();
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
assert_eq!(unsafe { oaktask_manager_init() }, 0);
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
assert_eq!(unsafe { oaktask_debug_alive_count() }, 0);
|
||||
}
|
||||
|
||||
/// cstr_read helper: read a NUL-terminated c_char buffer.
|
||||
fn cstr_read(buf: &[i8]) -> String {
|
||||
let len = buf.iter().position(|&c| c == 0).unwrap_or(buf.len());
|
||||
let bytes = unsafe { std::slice::from_raw_parts(buf.as_ptr() as *const u8, len) };
|
||||
String::from_utf8_lossy(bytes).into_owned()
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Concurrency contract of the [`RenderTask`] render loop (src/render.rs):
|
||||
//! up to `max_inflight` oakrender tickets run concurrently, results reach
|
||||
//! the behavior hooks in timestamp order through the reorder buffer (even
|
||||
//! when tickets complete out of order), cancellation drains in-flight
|
||||
//! tickets (their completions still fire exactly once), progress is
|
||||
//! monotonic, and a hook error stops dispatch immediately.
|
||||
//!
|
||||
//! The link-time stubs in `common` simulate the oakrender ticket arena;
|
||||
//! with `TICKET_DEFER = 1` tickets stay in flight until the test fires
|
||||
//! them with `stub_complete`, so completion order is fully scripted. Frame
|
||||
//! handles encode the submitting ticket id (0x10000 + id), so the tests
|
||||
//! can assert the exact delivery order of frames.
|
||||
|
||||
#[path = "common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use common::*;
|
||||
use oakcore_rs::{Rational, TimeRange};
|
||||
use oaktask::error::{Error, Result, OAKTASK_E_CANCELLED, OAKTASK_E_FAILED};
|
||||
use oaktask::handle::CHandle;
|
||||
use oaktask::render::{ForceParams, RenderTask, RenderTaskBehavior};
|
||||
use oaktask::task::{Task, TaskEvent};
|
||||
|
||||
/// Poison-tolerant serialization (the shared stub registry is process-wide).
|
||||
fn lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
MANAGER_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Frame-handle ctx base (see common: STUB_FRAME_CTX_BASE).
|
||||
const FRAME_CTX_BASE: usize = 0x10000;
|
||||
|
||||
/// A RenderTask rendering `frames` 1-second frames (timebase 1/1) from
|
||||
/// 0, optionally with audio enabled for the whole range.
|
||||
fn render_task(frames: i64, audio: bool) -> RenderTask {
|
||||
VIDEO_TIME_BASE_NUM.store(1, Ordering::SeqCst);
|
||||
VIDEO_TIME_BASE_DEN.store(1, Ordering::SeqCst);
|
||||
let range = TimeRange::new(Rational::new(0, 1), Rational::new(frames, 1));
|
||||
let base = Task::new("render-loop-test", fake_atom());
|
||||
let mut rt = RenderTask::new(
|
||||
base,
|
||||
CHandle::null(),
|
||||
CHandle::null(),
|
||||
fake_handle(),
|
||||
ForceParams::default(),
|
||||
None,
|
||||
);
|
||||
rt.set_render_inputs(fake_handle(), CHandle::null(), 0, audio, range);
|
||||
rt
|
||||
}
|
||||
|
||||
/// Records the hook calls in delivery order; `fail_on_frame` (0-based
|
||||
/// index) makes that frame's `frame_downloaded` return an error.
|
||||
#[derive(Default)]
|
||||
struct RecordingBehavior {
|
||||
/// ctx values of delivered frames, in delivery order (0x10000 + id).
|
||||
frames: Vec<usize>,
|
||||
/// number of `audio_downloaded` calls.
|
||||
audio_calls: usize,
|
||||
/// fail the frame at this delivery index, if set.
|
||||
fail_on_frame: Option<usize>,
|
||||
}
|
||||
|
||||
impl RenderTaskBehavior for RecordingBehavior {
|
||||
fn frame_downloaded(&mut self, _task: &mut Task, frame: CHandle) -> Result<()> {
|
||||
if let Some(i) = self.fail_on_frame {
|
||||
if self.frames.len() == i {
|
||||
return Err(Error::Failed("stub encoder write failure".to_string()));
|
||||
}
|
||||
}
|
||||
self.frames.push(frame.ctx as usize);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_downloaded(&mut self, _task: &mut Task, _buffer: CHandle) -> Result<()> {
|
||||
self.audio_calls += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_subtitle(&mut self, _task: &mut Task, _text: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive `render` on a spawned thread. The base task lives inside the
|
||||
/// render task; it is moved out first so the test can keep its cancel atom.
|
||||
fn run_render(
|
||||
mut rt: RenderTask,
|
||||
behavior: RecordingBehavior,
|
||||
) -> std::thread::JoinHandle<(Result<()>, RecordingBehavior)> {
|
||||
std::thread::spawn(move || {
|
||||
let mut base = std::mem::replace(&mut rt.base, Task::new("", CHandle::null()));
|
||||
let mut behavior = behavior;
|
||||
let result = rt.render(&mut base, &mut behavior);
|
||||
(result, behavior)
|
||||
})
|
||||
}
|
||||
|
||||
/// Frames complete in scrambled order; the hooks still see them in
|
||||
/// timestamp order, and every completion fires exactly once.
|
||||
#[test]
|
||||
fn render_delivers_frames_in_order_under_scrambled_completion() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
TICKET_DEFER.store(1, Ordering::SeqCst);
|
||||
|
||||
let mut rt = render_task(5, false);
|
||||
rt.set_max_inflight(8); // window >= frame count: all tickets submitted upfront
|
||||
let handle = run_render(rt, RecordingBehavior::default());
|
||||
|
||||
stub_wait_submitted(5);
|
||||
for id in [2usize, 0, 1, 4, 3] {
|
||||
stub_complete(id);
|
||||
}
|
||||
|
||||
let (result, behavior) = handle.join().unwrap();
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(
|
||||
behavior.frames,
|
||||
vec![
|
||||
FRAME_CTX_BASE,
|
||||
FRAME_CTX_BASE + 1,
|
||||
FRAME_CTX_BASE + 2,
|
||||
FRAME_CTX_BASE + 3,
|
||||
FRAME_CTX_BASE + 4
|
||||
],
|
||||
"frames must be delivered in timestamp order despite scrambled completion"
|
||||
);
|
||||
assert_eq!(
|
||||
stub_completed_count(),
|
||||
5,
|
||||
"each ticket completes exactly once"
|
||||
);
|
||||
}
|
||||
|
||||
/// The audio ticket completes last of all; it is still delivered before any
|
||||
/// frame (the observable audio-first contract).
|
||||
#[test]
|
||||
fn render_delivers_audio_before_frames_under_scrambled_completion() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
TICKET_DEFER.store(1, Ordering::SeqCst);
|
||||
|
||||
let mut rt = render_task(3, true);
|
||||
rt.set_max_inflight(4); // audio + 3 frames, all submitted upfront
|
||||
let handle = run_render(rt, RecordingBehavior::default());
|
||||
|
||||
stub_wait_submitted(4);
|
||||
// Frames 2,3 and 1 complete before the audio ticket (id 0).
|
||||
for id in [2usize, 3, 1, 0] {
|
||||
stub_complete(id);
|
||||
}
|
||||
|
||||
let (result, behavior) = handle.join().unwrap();
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(behavior.audio_calls, 1, "audio delivered exactly once");
|
||||
assert_eq!(
|
||||
behavior.frames,
|
||||
vec![FRAME_CTX_BASE + 1, FRAME_CTX_BASE + 2, FRAME_CTX_BASE + 3],
|
||||
"audio first, then frames in timestamp order"
|
||||
);
|
||||
assert_eq!(stub_completed_count(), 4);
|
||||
}
|
||||
|
||||
/// Cancellation between frames: the loop aborts, every in-flight ticket is
|
||||
/// cancelled+waited, and each of their completions still fires exactly once.
|
||||
#[test]
|
||||
fn render_cancel_drains_inflight_and_fires_their_completions() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
TICKET_DEFER.store(1, Ordering::SeqCst);
|
||||
|
||||
let mut rt = render_task(5, false);
|
||||
rt.set_max_inflight(2);
|
||||
let atom = rt.base.get_cancel_atom();
|
||||
let handle = run_render(rt, RecordingBehavior::default());
|
||||
|
||||
// Frame 0 completes normally; the loop delivers it and tops up ticket 2.
|
||||
stub_wait_submitted(2);
|
||||
stub_complete(0);
|
||||
stub_wait_submitted(3);
|
||||
|
||||
// Cancel through the atom. The stub atom-cancel finishes the pending
|
||||
// tickets (real cancellation propagates into in-flight renders), waking
|
||||
// the loop; the loop then sees the cancellation and drains.
|
||||
unsafe {
|
||||
oaktask::bridge::render::oakrender_cancelatom_cancel(atom);
|
||||
}
|
||||
|
||||
let (result, behavior) = handle.join().unwrap();
|
||||
assert_eq!(result.unwrap_err().code(), OAKTASK_E_CANCELLED);
|
||||
assert_eq!(
|
||||
behavior.frames,
|
||||
vec![FRAME_CTX_BASE],
|
||||
"only frame 0 delivered before cancel"
|
||||
);
|
||||
assert_eq!(
|
||||
stub_completed_count(),
|
||||
3,
|
||||
"every submitted ticket's completion fires (incl. on cancel)"
|
||||
);
|
||||
assert_eq!(
|
||||
stub_submitted_count(),
|
||||
3,
|
||||
"no new tickets are dispatched after cancellation"
|
||||
);
|
||||
}
|
||||
|
||||
/// Progress events are non-decreasing and end at 1.0 (5 frames, 1s each).
|
||||
#[test]
|
||||
fn render_progress_is_monotonic_and_reaches_one() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
|
||||
let mut rt = render_task(5, false);
|
||||
rt.set_max_inflight(8);
|
||||
let progress: Arc<Mutex<Vec<f64>>> = Default::default();
|
||||
{
|
||||
let progress = progress.clone();
|
||||
rt.base.set_event_listener(Box::new(move |ev: TaskEvent| {
|
||||
if let TaskEvent::Progress(v) = ev {
|
||||
progress.lock().unwrap().push(v);
|
||||
}
|
||||
}));
|
||||
}
|
||||
let handle = run_render(rt, RecordingBehavior::default());
|
||||
|
||||
let (result, behavior) = handle.join().unwrap();
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(behavior.frames.len(), 5);
|
||||
|
||||
let seen = progress.lock().unwrap().clone();
|
||||
assert_eq!(seen.len(), 5, "one progress event per delivered frame");
|
||||
assert!(
|
||||
seen.windows(2).all(|w| w[0] <= w[1]),
|
||||
"progress must be non-decreasing, got {seen:?}"
|
||||
);
|
||||
assert!(
|
||||
(seen[seen.len() - 1] - 1.0).abs() < 1e-9,
|
||||
"progress reaches 1.0"
|
||||
);
|
||||
}
|
||||
|
||||
/// A hook error mid-stream stops dispatching new tickets; the in-flight
|
||||
/// tickets are drained and their completions fire.
|
||||
#[test]
|
||||
fn render_error_stops_dispatching_new_tickets() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
TICKET_DEFER.store(1, Ordering::SeqCst);
|
||||
|
||||
let mut behavior = RecordingBehavior::default();
|
||||
behavior.fail_on_frame = Some(2); // the third delivered frame fails
|
||||
let mut rt = render_task(10, false);
|
||||
rt.set_max_inflight(2);
|
||||
let handle = run_render(rt, behavior);
|
||||
|
||||
stub_wait_submitted(2);
|
||||
stub_complete(0);
|
||||
stub_wait_submitted(3); // frame 0 delivered, ticket 2 submitted
|
||||
stub_complete(1);
|
||||
stub_wait_submitted(4); // frame 1 delivered, ticket 3 submitted
|
||||
stub_complete(2); // frame 2 fails in the hook
|
||||
|
||||
let (result, behavior) = handle.join().unwrap();
|
||||
assert_eq!(result.unwrap_err().code(), OAKTASK_E_FAILED);
|
||||
assert_eq!(
|
||||
behavior.frames,
|
||||
vec![FRAME_CTX_BASE, FRAME_CTX_BASE + 1],
|
||||
"two frames delivered before the error"
|
||||
);
|
||||
assert_eq!(
|
||||
stub_submitted_count(),
|
||||
4,
|
||||
"no ticket past the failing frame"
|
||||
);
|
||||
assert_eq!(
|
||||
stub_completed_count(),
|
||||
4,
|
||||
"in-flight tickets are drained, completions fire"
|
||||
);
|
||||
}
|
||||
|
||||
/// Default stub mode (completions fire synchronously on submit): the loop
|
||||
/// still windows the submissions and delivers in timestamp order.
|
||||
#[test]
|
||||
fn render_succeeds_with_immediate_completion_and_windowing() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
|
||||
let mut rt = render_task(7, false);
|
||||
rt.set_max_inflight(3);
|
||||
let handle = run_render(rt, RecordingBehavior::default());
|
||||
|
||||
let (result, behavior) = handle.join().unwrap();
|
||||
assert!(result.is_ok());
|
||||
assert_eq!(behavior.frames.len(), 7);
|
||||
assert_eq!(
|
||||
behavior.frames,
|
||||
(0..7).map(|i| FRAME_CTX_BASE + i).collect::<Vec<_>>(),
|
||||
"frames delivered in timestamp order"
|
||||
);
|
||||
assert_eq!(stub_completed_count(), 7);
|
||||
}
|
||||
|
||||
/// An empty export range renders nothing and succeeds.
|
||||
#[test]
|
||||
fn render_empty_range_succeeds_without_tickets() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
|
||||
let mut rt = render_task(0, false);
|
||||
rt.set_max_inflight(1);
|
||||
let handle = run_render(rt, RecordingBehavior::default());
|
||||
|
||||
let (result, behavior) = handle.join().unwrap();
|
||||
assert!(result.is_ok());
|
||||
assert!(behavior.frames.is_empty());
|
||||
assert_eq!(stub_submitted_count(), 0);
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Real-oakrender integration: drive oaktask's [`RenderTask`] render loop
|
||||
//! against the **actual** oakrender ticket arena instead of the link-time
|
||||
//! stubs of `tests/common/mod.rs`.
|
||||
//!
|
||||
//! The two crates' C ABIs are joined the way the app joins them: oaktask's
|
||||
//! `bridge::render` `extern "C"` declarations resolve against oakrender's
|
||||
//! `#[no_mangle]` exports (both rlibs linked into this one test binary),
|
||||
//! the `oakrender_ticket_finished_fn` callback (the frozen 2-argument
|
||||
//! `(ticket, userdata)` contract) fires on the arena's worker threads, and
|
||||
//! the loop's reorder buffer delivers the frames to the hooks in timestamp
|
||||
//! order. The render crate's CPU path is used (`eval::render_produced_frame`
|
||||
//! generates F32 frames; no GPU is initialized).
|
||||
//!
|
||||
//! Only the render symbols are real; the oaknode/oakcodec/oakcommon/undo
|
||||
//! sides still come from the `common` stubs (the render loop only touches
|
||||
//! them for graph queries and timebase reads).
|
||||
//!
|
||||
//! Build / run (both rlibs in one binary — duplicate `#[no_mangle]`
|
||||
//! symbols make this incompatible with the stub-based test binaries, so it
|
||||
//! must be built with the feature and the `--test` filter):
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test --features real-oakrender --test render_real_integration_test
|
||||
//! ```
|
||||
|
||||
#![cfg(feature = "real-oakrender")]
|
||||
#![allow(clippy::missing_safety_doc)]
|
||||
|
||||
#[path = "common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use oakcore_rs::{Rational, TimeRange};
|
||||
use oaktask::error::Result;
|
||||
use oaktask::handle::CHandle;
|
||||
use oaktask::render::{ForceParams, RenderTask, RenderTaskBehavior};
|
||||
use oaktask::task::Task;
|
||||
|
||||
/// Records what the render loop delivers: each frame's timestamp (the
|
||||
/// producer stamps `Frame::timestamp` with the ticket time) and size.
|
||||
#[derive(Default)]
|
||||
struct RecordingBehavior {
|
||||
timestamps: Vec<Rational>,
|
||||
sizes: Vec<(i32, i32)>,
|
||||
}
|
||||
|
||||
impl RenderTaskBehavior for RecordingBehavior {
|
||||
fn frame_downloaded(&mut self, _task: &mut Task, frame: CHandle) -> Result<()> {
|
||||
assert!(!frame.is_null(), "real arena must hand out an owned frame");
|
||||
// The frame handle boxes a `oakrender::texture::Frame` (created by
|
||||
// `oakrender_ticket_get_frame`); read it through oakrender's own
|
||||
// handle API to assert the real payload.
|
||||
let rh = oakrender::handle::CHandle {
|
||||
ctx: frame.ctx,
|
||||
addref: frame.addref,
|
||||
release: frame.release,
|
||||
abi_version: frame.abi_version,
|
||||
};
|
||||
let f = unsafe { oakrender::handle::get::<oakrender::texture::Frame>(&rh) }
|
||||
.expect("frame handle must box a real oakrender Frame");
|
||||
self.timestamps.push(f.timestamp);
|
||||
self.sizes.push((f.width, f.height));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_downloaded(&mut self, _task: &mut Task, _buffer: CHandle) -> Result<()> {
|
||||
panic!("audio disabled for this test")
|
||||
}
|
||||
|
||||
fn encode_subtitle(&mut self, _task: &mut Task, _text: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes against anything else touching the process-wide manager.
|
||||
fn manager_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// The real-oakrender end-to-end run: 3 one-second frames (timebase 1/1)
|
||||
/// through the task render loop into the arena, delivered in timestamp
|
||||
/// order with the forced 64x64 size.
|
||||
#[test]
|
||||
fn render_loop_submits_real_tickets_and_delivers_in_timestamp_order() {
|
||||
let _guard = manager_guard();
|
||||
common::reset_stubs();
|
||||
common::VIDEO_TIME_BASE_NUM.store(1, Ordering::SeqCst);
|
||||
common::VIDEO_TIME_BASE_DEN.store(1, Ordering::SeqCst);
|
||||
|
||||
// Fresh real manager (CPU path; the ticket producer is eval-based, no
|
||||
// GPU backend is created).
|
||||
oakrender::manager::RenderManager::shutdown();
|
||||
oakrender::manager::RenderManager::init().expect("real oakrender manager init");
|
||||
let baseline = oakrender::handle::alive_count();
|
||||
|
||||
{
|
||||
let range = TimeRange::new(Rational::new(0, 1), Rational::new(3, 1));
|
||||
let base = Task::new("real-render-integration", CHandle::null());
|
||||
let mut rt = RenderTask::new(
|
||||
base,
|
||||
common::fake_handle(), // borrowed video params (ctx checked only)
|
||||
CHandle::null(), // no audio
|
||||
common::fake_handle(), // viewer node (the node stub supplies the output)
|
||||
ForceParams {
|
||||
force_width: 64,
|
||||
force_height: 64,
|
||||
..Default::default()
|
||||
},
|
||||
None,
|
||||
);
|
||||
rt.set_render_inputs(CHandle::null(), CHandle::null(), 0, false, range);
|
||||
rt.set_max_inflight(3);
|
||||
|
||||
let mut behavior = RecordingBehavior::default();
|
||||
let mut task = std::mem::replace(&mut rt.base, Task::new("", CHandle::null()));
|
||||
let result = rt.render(&mut task, &mut behavior);
|
||||
drop(rt);
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"render through the real arena must succeed: {result:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
behavior.timestamps,
|
||||
vec![
|
||||
Rational::new(0, 1),
|
||||
Rational::new(1, 1),
|
||||
Rational::new(2, 1),
|
||||
],
|
||||
"frames must arrive in timestamp order (reorder buffer over the real arena)"
|
||||
);
|
||||
assert_eq!(
|
||||
behavior.sizes,
|
||||
vec![(64, 64), (64, 64), (64, 64)],
|
||||
"force params must reach the real producer"
|
||||
);
|
||||
// `task` owns the real cancel atom created by Task::new; dropping it
|
||||
// releases the atom so the handle accounting below returns to
|
||||
// baseline.
|
||||
drop(task);
|
||||
}
|
||||
|
||||
oakrender::manager::RenderManager::shutdown();
|
||||
assert_eq!(
|
||||
oakrender::handle::alive_count(),
|
||||
baseline,
|
||||
"every handle created by the run (tickets, frames, cancel atom) must be released"
|
||||
);
|
||||
}
|
||||
@@ -1,550 +0,0 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Run-path coverage: conform/proxy/export/render task bodies driven end to
|
||||
//! end against the link-time stubs in `common` (codec-submit routing,
|
||||
//! decoder/encoder knobs, a fake ffmpeg executable), plus the base
|
||||
//! [`Task`] API (reset/elapsed/wait/cancel) and the subscribe event stream.
|
||||
//!
|
||||
//! These tests exercise the same paths the C++ gtest suite covers with the
|
||||
//! real dylibs (`src/task/tests/task_test.cpp`); here the stubs make them
|
||||
//! deterministic under plain `cargo test`.
|
||||
|
||||
#[path = "common/mod.rs"]
|
||||
mod common;
|
||||
|
||||
use std::ffi::c_int;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use common::*;
|
||||
use oaktask::bridge::codec::{
|
||||
OakCodecTaskRequest, OAKCODEC_E_CANCELLED, OAKCODEC_E_FAILED, OAKCODEC_TASK_CONFORM,
|
||||
OAKCODEC_TASK_PROXY,
|
||||
};
|
||||
use oaktask::error::Result;
|
||||
use oaktask::ffi::manager::oaktask_manager_shutdown;
|
||||
use oaktask::ffi::project::oaktask_create_export;
|
||||
use oaktask::ffi::task::{oaktask_task_error, oaktask_task_free, oaktask_task_start_sync};
|
||||
use oaktask::task::{Task, TaskBehavior, TaskEvent};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Codec-submit routing (conform/proxy run bodies)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a codec task request and drive it through the registered submit
|
||||
/// callback (the codec module's view of the task module).
|
||||
/// Poison-tolerant serialization (a failing sibling test must not cascade).
|
||||
fn lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
MANAGER_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn submit(request: &OakCodecTaskRequest) -> c_int {
|
||||
let cb = SUBMIT_CB.lock().unwrap().clone();
|
||||
match cb {
|
||||
Some(f) => unsafe { f(request, std::ptr::null_mut()) },
|
||||
None => OAKCODEC_E_FAILED,
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a conform request with pre-existing working files, the conform task
|
||||
/// transcodes (stub) and renames the working files into place — the submit
|
||||
/// callback returns OAKCODEC_OK.
|
||||
#[test]
|
||||
fn conform_run_succeeds_via_codec_submit() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
unsafe { oaktask::ffi::manager::oaktask_register_codec_submitter() };
|
||||
DECODER_OPEN_RESULT.store(0, Ordering::SeqCst);
|
||||
DECODER_CONFORM_RESULT.store(0, Ordering::SeqCst);
|
||||
|
||||
let dir = std::env::temp_dir();
|
||||
let out = dir.join("oak-conform.0.pcm");
|
||||
let out_str = out.to_string_lossy().into_owned();
|
||||
let working = [
|
||||
dir.join("oak-conform.0.pcm.working"),
|
||||
dir.join("oak-conform.1.pcm.working"),
|
||||
];
|
||||
for w in &working {
|
||||
std::fs::write(w, b"pcm").unwrap();
|
||||
}
|
||||
|
||||
let request = OakCodecTaskRequest {
|
||||
kind: OAKCODEC_TASK_CONFORM,
|
||||
input_filename: cstr_of("/nonexistent/input.mp4"),
|
||||
output_filename: cstr_of(&out_str),
|
||||
stream_index: 0,
|
||||
sample_rate: 48000,
|
||||
channel_layout: 0x3, // stereo -> 2 working files
|
||||
sample_format: 4,
|
||||
proxy_width: 0,
|
||||
proxy_height: 0,
|
||||
};
|
||||
assert_eq!(submit(&request), 0, "conform submit must succeed");
|
||||
|
||||
// The working files were renamed into their final names.
|
||||
assert!(out.exists(), "final pcm file must exist after rename");
|
||||
assert!(!working[0].exists(), "working file must be renamed away");
|
||||
assert!(!working[1].exists());
|
||||
|
||||
for w in &working {
|
||||
let _ = std::fs::remove_file(w);
|
||||
}
|
||||
for f in [&out, &dir.join("oak-conform.1.pcm")] {
|
||||
let _ = std::fs::remove_file(f);
|
||||
}
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
/// Given a conform request whose decoder fails to open, the task reports the
|
||||
/// decoder error and the submit callback returns OAKCODEC_E_FAILED.
|
||||
#[test]
|
||||
fn conform_run_fails_when_decoder_open_fails() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
unsafe { oaktask::ffi::manager::oaktask_register_codec_submitter() };
|
||||
DECODER_OPEN_RESULT.store(-50003, Ordering::SeqCst);
|
||||
|
||||
let request = OakCodecTaskRequest {
|
||||
kind: OAKCODEC_TASK_CONFORM,
|
||||
input_filename: cstr_of("/nonexistent/input.mp4"),
|
||||
output_filename: cstr_of("/nonexistent/audio.0.pcm"),
|
||||
stream_index: 0,
|
||||
sample_rate: 48000,
|
||||
channel_layout: 0x3,
|
||||
sample_format: 4,
|
||||
proxy_width: 0,
|
||||
proxy_height: 0,
|
||||
};
|
||||
assert_eq!(submit(&request), OAKCODEC_E_FAILED);
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
/// Given a conform request whose decode is cancelled, the task cleans up the
|
||||
/// working files and fails (the cancelled error is surfaced through the
|
||||
/// task's error string).
|
||||
#[test]
|
||||
fn conform_run_reports_cancelled_conform() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
unsafe { oaktask::ffi::manager::oaktask_register_codec_submitter() };
|
||||
DECODER_OPEN_RESULT.store(0, Ordering::SeqCst);
|
||||
DECODER_CONFORM_RESULT.store(OAKCODEC_E_CANCELLED, Ordering::SeqCst);
|
||||
|
||||
let dir = std::env::temp_dir();
|
||||
let out = dir.join("oak-cancel.0.pcm");
|
||||
let working = dir.join("oak-cancel.0.pcm.working");
|
||||
std::fs::write(&working, b"pcm").unwrap();
|
||||
|
||||
let request = OakCodecTaskRequest {
|
||||
kind: OAKCODEC_TASK_CONFORM,
|
||||
input_filename: cstr_of("/nonexistent/input.mp4"),
|
||||
output_filename: cstr_of(&out.to_string_lossy()),
|
||||
stream_index: 0,
|
||||
sample_rate: 48000,
|
||||
channel_layout: 0x3,
|
||||
sample_format: 4,
|
||||
proxy_width: 0,
|
||||
proxy_height: 0,
|
||||
};
|
||||
assert_eq!(submit(&request), OAKCODEC_E_FAILED);
|
||||
// The cancelled conform removes its partial working files.
|
||||
assert!(
|
||||
!working.exists(),
|
||||
"working files must be cleaned up on cancel"
|
||||
);
|
||||
assert!(!out.exists());
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
/// Write an executable fake-ffmpeg script that creates its last argument
|
||||
/// (the working proxy file) and reports some progress.
|
||||
fn write_fake_ffmpeg(path: &std::path::Path, body: &str) {
|
||||
std::fs::write(path, body).unwrap();
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
/// Given a proxy request and a working fake ffmpeg, the proxy task spawns it,
|
||||
/// parses progress, renames the working file into place and succeeds.
|
||||
#[test]
|
||||
fn proxy_run_succeeds_with_fake_ffmpeg() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
unsafe { oaktask::ffi::manager::oaktask_register_codec_submitter() };
|
||||
|
||||
let dir = std::env::temp_dir();
|
||||
let script = dir.join("oak-fake-ffmpeg.sh");
|
||||
write_fake_ffmpeg(
|
||||
&script,
|
||||
"#!/bin/sh\nfor a in \"$@\"; do :; done\necho \"out_time_us=5000000\"\ntouch \"$a\"\nexit 0\n",
|
||||
);
|
||||
*PROXY_FFMPEG_PATH.lock().unwrap() = script.to_string_lossy().into_owned();
|
||||
|
||||
let out = dir.join("oak-proxy-out.mp4");
|
||||
let out_str = out.to_string_lossy().into_owned();
|
||||
let working = dir.join("oak-proxy-out.mp4.working.mp4");
|
||||
let _ = std::fs::remove_file(&out);
|
||||
let _ = std::fs::remove_file(&working);
|
||||
|
||||
let request = OakCodecTaskRequest {
|
||||
kind: OAKCODEC_TASK_PROXY,
|
||||
input_filename: cstr_of("/nonexistent/source.mp4"),
|
||||
output_filename: cstr_of(&out_str),
|
||||
stream_index: 0,
|
||||
sample_rate: 0,
|
||||
channel_layout: 0,
|
||||
sample_format: 0,
|
||||
proxy_width: 0,
|
||||
proxy_height: 0,
|
||||
};
|
||||
assert_eq!(submit(&request), 0, "proxy submit must succeed");
|
||||
assert!(out.exists(), "proxy output must exist after rename");
|
||||
assert!(!working.exists());
|
||||
|
||||
let _ = std::fs::remove_file(&out);
|
||||
let _ = std::fs::remove_file(&script);
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
/// Given a proxy request whose ffmpeg exits non-zero, the task cleans up the
|
||||
/// working file and fails.
|
||||
#[test]
|
||||
fn proxy_run_fails_when_ffmpeg_fails() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
unsafe { oaktask::ffi::manager::oaktask_register_codec_submitter() };
|
||||
|
||||
let dir = std::env::temp_dir();
|
||||
let script = dir.join("oak-fake-ffmpeg-fail.sh");
|
||||
write_fake_ffmpeg(&script, "#!/bin/sh\nexit 1\n");
|
||||
*PROXY_FFMPEG_PATH.lock().unwrap() = script.to_string_lossy().into_owned();
|
||||
|
||||
let out = dir.join("oak-proxy-fail.mp4");
|
||||
let request = OakCodecTaskRequest {
|
||||
kind: OAKCODEC_TASK_PROXY,
|
||||
input_filename: cstr_of("/nonexistent/source.mp4"),
|
||||
output_filename: cstr_of(&out.to_string_lossy()),
|
||||
stream_index: 0,
|
||||
sample_rate: 0,
|
||||
channel_layout: 0,
|
||||
sample_format: 0,
|
||||
proxy_width: 0,
|
||||
proxy_height: 0,
|
||||
};
|
||||
assert_eq!(submit(&request), OAKCODEC_E_FAILED);
|
||||
assert!(!out.exists());
|
||||
|
||||
let _ = std::fs::remove_file(&script);
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
/// Given no configured ffmpeg, the proxy task fails with the documented
|
||||
/// "not found" error.
|
||||
#[test]
|
||||
fn proxy_run_fails_without_ffmpeg() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
unsafe { oaktask::ffi::manager::oaktask_register_codec_submitter() };
|
||||
PROXY_FFMPEG_PATH.lock().unwrap().clear();
|
||||
|
||||
let request = OakCodecTaskRequest {
|
||||
kind: OAKCODEC_TASK_PROXY,
|
||||
input_filename: cstr_of("/nonexistent/source.mp4"),
|
||||
output_filename: cstr_of("/nonexistent/out.mp4"),
|
||||
stream_index: 0,
|
||||
sample_rate: 0,
|
||||
channel_layout: 0,
|
||||
sample_format: 0,
|
||||
proxy_width: 0,
|
||||
proxy_height: 0,
|
||||
};
|
||||
assert_eq!(submit(&request), OAKCODEC_E_FAILED);
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
/// Given an unknown codec task kind, the submit callback rejects it with
|
||||
/// OAKCODEC_E_INVALID.
|
||||
#[test]
|
||||
fn codec_submit_rejects_unknown_kind() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
unsafe { oaktask::ffi::manager::oaktask_register_codec_submitter() };
|
||||
|
||||
let request = OakCodecTaskRequest {
|
||||
kind: 99,
|
||||
input_filename: std::ptr::null(),
|
||||
output_filename: std::ptr::null(),
|
||||
stream_index: 0,
|
||||
sample_rate: 0,
|
||||
channel_layout: 0,
|
||||
sample_format: 0,
|
||||
proxy_width: 0,
|
||||
proxy_height: 0,
|
||||
};
|
||||
assert_eq!(submit(&request), oaktask::bridge::codec::OAKCODEC_E_INVALID);
|
||||
unsafe { oaktask_manager_shutdown() };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export run paths (render + encoder)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A base encoding-params POD with video enabled.
|
||||
fn export_params(filename: &str) -> oaktask::bridge::codec::OakCodecEncodingParams {
|
||||
let mut params = oaktask::bridge::codec::OakCodecEncodingParams {
|
||||
filename: [0; 1024],
|
||||
format: 0,
|
||||
video_enabled: 1,
|
||||
video_codec: 0,
|
||||
video_width: 1280,
|
||||
video_height: 720,
|
||||
video_time_base_num: 1,
|
||||
video_time_base_den: 25,
|
||||
video_pixel_format: 0,
|
||||
video_interlacing: 0,
|
||||
video_pixel_aspect_num: 1,
|
||||
video_pixel_aspect_den: 1,
|
||||
video_bit_rate: 0,
|
||||
video_min_bit_rate: 0,
|
||||
video_max_bit_rate: 0,
|
||||
video_buffer_size: 0,
|
||||
video_threads: 0,
|
||||
video_pix_fmt: [0; 64],
|
||||
video_is_image_sequence: 0,
|
||||
video_scaling_method: 0,
|
||||
audio_enabled: 0,
|
||||
audio_codec: 0,
|
||||
audio_sample_rate: 0,
|
||||
audio_channel_layout: 0,
|
||||
audio_sample_format: 0,
|
||||
audio_bit_rate: 0,
|
||||
subtitles_enabled: 0,
|
||||
subtitles_codec: 0,
|
||||
subtitles_are_sidecar: 0,
|
||||
subtitles_sidecar_format: 0,
|
||||
color_transform_output: [0; 256],
|
||||
export_length_num: 0,
|
||||
export_length_den: 0,
|
||||
has_custom_range: 0,
|
||||
custom_range_in_num: 0,
|
||||
custom_range_in_den: 0,
|
||||
custom_range_out_num: 0,
|
||||
custom_range_out_den: 0,
|
||||
};
|
||||
write_cstr(filename, params.filename.as_mut_ptr(), 1024);
|
||||
params
|
||||
}
|
||||
|
||||
/// The export task with audio enabled renders the audio ticket and succeeds;
|
||||
/// the encoder receives the frames.
|
||||
#[test]
|
||||
fn export_runs_audio_path() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
let mut params = export_params("/tmp/oak-export-audio.mp4");
|
||||
params.audio_enabled = 1;
|
||||
SEQUENCE_LENGTH_NUM.store(10, Ordering::SeqCst);
|
||||
SEQUENCE_LENGTH_DEN.store(1, Ordering::SeqCst);
|
||||
VIDEO_TIME_BASE_NUM.store(1, Ordering::SeqCst);
|
||||
VIDEO_TIME_BASE_DEN.store(1, Ordering::SeqCst); // 1s frames
|
||||
|
||||
let mut task = unsafe { oaktask_create_export(fake_handle(), fake_handle(), ¶ms) };
|
||||
assert!(!task.ctx.is_null());
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 1);
|
||||
unsafe { oaktask_task_free(&mut task) };
|
||||
}
|
||||
|
||||
/// When the encoder cannot open the file, the export fails with the
|
||||
/// documented error.
|
||||
#[test]
|
||||
fn export_fails_on_encoder_open() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
ENCODER_OPEN_RESULT.store(-50003, Ordering::SeqCst);
|
||||
let params = export_params("/tmp/oak-export-open-fail.mp4");
|
||||
|
||||
let mut task = unsafe { oaktask_create_export(fake_handle(), fake_handle(), ¶ms) };
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
|
||||
let needed = unsafe { oaktask_task_error(task, std::ptr::null_mut(), 0) };
|
||||
assert!(needed > 0);
|
||||
unsafe { oaktask_task_free(&mut task) };
|
||||
}
|
||||
|
||||
/// When the encoder rejects a video frame, the export aborts with the
|
||||
/// encoder's error.
|
||||
#[test]
|
||||
fn export_fails_on_video_write() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
ENCODER_WRITE_VIDEO_RESULT.store(-50003, Ordering::SeqCst);
|
||||
SEQUENCE_LENGTH_NUM.store(10, Ordering::SeqCst);
|
||||
SEQUENCE_LENGTH_DEN.store(1, Ordering::SeqCst);
|
||||
VIDEO_TIME_BASE_NUM.store(1, Ordering::SeqCst);
|
||||
VIDEO_TIME_BASE_DEN.store(1, Ordering::SeqCst);
|
||||
let params = export_params("/tmp/oak-export-write-fail.mp4");
|
||||
|
||||
let mut task = unsafe { oaktask_create_export(fake_handle(), fake_handle(), ¶ms) };
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
|
||||
unsafe { oaktask_task_free(&mut task) };
|
||||
}
|
||||
|
||||
/// When the encoder flush surfaces an error, the export fails after the
|
||||
/// render loop.
|
||||
#[test]
|
||||
fn export_fails_on_flush_error() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
ENCODER_FLUSH_ERROR.store(1, Ordering::SeqCst);
|
||||
*ENCODER_LAST_ERROR.lock().unwrap() = "stub flush failure".to_string();
|
||||
SEQUENCE_LENGTH_NUM.store(1, Ordering::SeqCst);
|
||||
SEQUENCE_LENGTH_DEN.store(1, Ordering::SeqCst);
|
||||
VIDEO_TIME_BASE_NUM.store(1, Ordering::SeqCst);
|
||||
VIDEO_TIME_BASE_DEN.store(1, Ordering::SeqCst);
|
||||
let params = export_params("/tmp/oak-export-flush-fail.mp4");
|
||||
|
||||
let mut task = unsafe { oaktask_create_export(fake_handle(), fake_handle(), ¶ms) };
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
|
||||
let mut err = [0i8; 256];
|
||||
let needed = unsafe { oaktask_task_error(task, err.as_mut_ptr(), err.len() as i32) };
|
||||
assert!(needed > 0);
|
||||
unsafe { oaktask_task_free(&mut task) };
|
||||
}
|
||||
|
||||
/// When the viewer has no connected output node, the first frame render fails
|
||||
/// and the export aborts.
|
||||
#[test]
|
||||
fn export_fails_without_connected_output() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
CONNECTED_NODE.store(0, Ordering::SeqCst);
|
||||
SEQUENCE_LENGTH_NUM.store(10, Ordering::SeqCst);
|
||||
SEQUENCE_LENGTH_DEN.store(1, Ordering::SeqCst);
|
||||
VIDEO_TIME_BASE_NUM.store(1, Ordering::SeqCst);
|
||||
VIDEO_TIME_BASE_DEN.store(1, Ordering::SeqCst);
|
||||
let params = export_params("/tmp/oak-export-no-output.mp4");
|
||||
|
||||
let mut task = unsafe { oaktask_create_export(fake_handle(), fake_handle(), ¶ms) };
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
|
||||
unsafe { oaktask_task_free(&mut task) };
|
||||
}
|
||||
|
||||
/// When the render ticket hands out null frames repeatedly, the export
|
||||
/// aborts after the null-frame streak limit (C++ `null_frame_streak_`).
|
||||
#[test]
|
||||
fn export_fails_on_null_frame_streak() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
TICKET_FRAME_VALID.store(0, Ordering::SeqCst);
|
||||
SEQUENCE_LENGTH_NUM.store(30, Ordering::SeqCst);
|
||||
SEQUENCE_LENGTH_DEN.store(1, Ordering::SeqCst);
|
||||
VIDEO_TIME_BASE_NUM.store(1, Ordering::SeqCst);
|
||||
VIDEO_TIME_BASE_DEN.store(1, Ordering::SeqCst);
|
||||
let params = export_params("/tmp/oak-export-null-frames.mp4");
|
||||
|
||||
let mut task = unsafe { oaktask_create_export(fake_handle(), fake_handle(), ¶ms) };
|
||||
assert_eq!(unsafe { oaktask_task_start_sync(task) }, 0);
|
||||
let mut err = [0i8; 256];
|
||||
let needed = unsafe { oaktask_task_error(task, err.as_mut_ptr(), err.len() as i32) };
|
||||
assert!(needed > 0);
|
||||
unsafe { oaktask_task_free(&mut task) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base Task API + subscribe event stream
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A no-op behavior for direct `Task` API tests.
|
||||
struct NoopBehavior;
|
||||
|
||||
impl TaskBehavior for NoopBehavior {
|
||||
fn run(&mut self, _task: &mut Task) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The base task API: title/error/elapsed/reset/wait/cancel and the
|
||||
/// lifecycle event stream (STARTED/PROGRESS/FINISHED).
|
||||
#[test]
|
||||
fn task_base_api_and_events() {
|
||||
let _g = lock();
|
||||
reset_stubs();
|
||||
|
||||
let mut task = Task::new("Base API", common::fake_atom());
|
||||
task.set_title("Renamed");
|
||||
assert_eq!(task.title(), "Renamed");
|
||||
assert!(task.error().is_none());
|
||||
assert!(!task.is_finished());
|
||||
assert!(!task.succeeded());
|
||||
assert!(task.elapsed().is_none());
|
||||
|
||||
// Progress is clamped to 0..1 and delivered to the listener.
|
||||
let events: std::sync::Arc<std::sync::Mutex<Vec<TaskEvent>>> = Default::default();
|
||||
let listener = {
|
||||
let events = events.clone();
|
||||
Box::new(move |ev: TaskEvent| events.lock().unwrap().push(ev))
|
||||
};
|
||||
task.set_event_listener(listener);
|
||||
task.emit_progress(2.0);
|
||||
task.set_behavior(Box::new(NoopBehavior));
|
||||
|
||||
let result = task.start();
|
||||
assert!(result.is_ok());
|
||||
assert!(task.is_finished());
|
||||
assert!(task.succeeded());
|
||||
assert!(task.elapsed().is_some());
|
||||
|
||||
// The listener saw PROGRESS (emitted before start), then STARTED and
|
||||
// FINISHED from `start()`.
|
||||
let seen = events.lock().unwrap().clone();
|
||||
assert_eq!(seen.len(), 3, "PROGRESS + STARTED + FINISHED");
|
||||
assert!(matches!(seen[0], TaskEvent::Progress(p) if p == 1.0));
|
||||
assert!(matches!(seen[1], TaskEvent::Started));
|
||||
assert!(matches!(seen[2], TaskEvent::Finished));
|
||||
|
||||
// Cancellation through the atom.
|
||||
let atom = task.get_cancel_atom();
|
||||
task.set_cancel_event(Box::new(|| {}));
|
||||
task.cancel();
|
||||
assert!(task.is_cancelled());
|
||||
unsafe {
|
||||
oaktask::bridge::render::oakrender_cancelatom_cancel(atom);
|
||||
}
|
||||
assert!(task.is_cancelled());
|
||||
|
||||
// reset clears the lifecycle state.
|
||||
task.reset();
|
||||
assert!(!task.is_finished());
|
||||
assert!(!task.succeeded());
|
||||
assert!(task.error().is_none());
|
||||
assert!(task.elapsed().is_none());
|
||||
|
||||
// error reporting.
|
||||
task.set_error("boom");
|
||||
assert_eq!(task.error(), Some("boom"));
|
||||
|
||||
// wait_finished on a finished task returns immediately.
|
||||
task.wait_finished();
|
||||
drop(atom);
|
||||
}
|
||||
|
||||
/// The error marker `Task::cancelled()` maps to OAKTASK_E_CANCELLED.
|
||||
#[test]
|
||||
fn cancelled_marker_maps_to_code() {
|
||||
assert_eq!(
|
||||
oaktask::task::cancelled().code(),
|
||||
oaktask::error::OAKTASK_E_CANCELLED
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user