test(oak-task, oak-storage): task managers, codec bridge, storage

Task/manager lifecycles, precache and render boundaries, OTIO/FCPXML
round trips, and the write-through/library contract tests.
This commit is contained in:
2026-09-22 20:54:04 +08:00
parent 666ac9b4d4
commit e04ce03058
11 changed files with 5370 additions and 2 deletions
@@ -0,0 +1,497 @@
// 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/>.
//! Boundary tests for the `.otio` / `.fcpxml` storage backend
//! (`oak_storage::backends::otio`): metadata, URI/extension validation,
//! a rich multi-sequence round trip (collection root, gap, transition,
//! missing media reference), the empty-project shape, and load errors.
use std::sync::Arc;
use std::sync::Mutex;
use oak_core::{Rational, TimeRange};
use oak_node::block::{ClipBlockBehavior, TransitionBlockBehavior};
use oak_node::footage::FootageBehavior;
use oak_node::id::NodeId;
use oak_node::node::NodeCore;
use oak_node::project::Project;
use oak_node::sequence::SequenceBehavior;
use oak_node::track::{TrackBehavior, TrackListBehavior, TrackType};
use oak_storage::backend::{LoadResult, StorageBackend};
use oak_storage::backends::otio::OtioBackend;
use oak_storage::nodeutil::{make_project_owned, project_arc};
use oak_storage::uri::StorageUri;
fn work_path(tag: &str, ext: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!(
"oakstorage_otio_{tag}_{}.{ext}",
std::process::id()
));
let _ = std::fs::remove_file(&path);
path
}
fn file_uri(path: &std::path::Path) -> StorageUri {
StorageUri::parse(&format!("file://{}", path.display())).expect("file uri")
}
/// A synthetic media path: the OTIO backend only carries the filename, it
/// never opens/probes the media, so no real file is needed.
fn media_path(tag: &str) -> std::path::PathBuf {
work_path(tag, "mp4")
}
/// One clip covering `[in, out)` (media seconds), optionally linked to
/// `footage` both as the domain field and as a graph edge.
fn add_clip(
p: &mut Project,
track: NodeId,
footage: Option<NodeId>,
in_: Rational,
out: Rational,
) -> NodeId {
let (core, mut behavior) = oak_node::block::clip_create();
let block = {
let clip = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
.expect("clip behavior");
clip.core.range = TimeRange::new(in_, out);
clip.core.media_in = Rational::new(0, 1);
clip.core.track = Some(track);
clip.footage = footage;
let id = p.graph.add_node(core, behavior);
if let Some(footage) = footage {
p.graph
.connect(
footage,
id,
oak_node::block::clip_input::TEXTURE_INPUT,
-1,
)
.expect("connect footage");
}
id
};
if let Some(entry) = p.graph.get_mut(track) {
if let Some(t) = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackBehavior>())
{
t.blocks.push(block);
}
}
block
}
fn add_gap(p: &mut Project, track: NodeId, in_: Rational, out: Rational) -> NodeId {
let (core, mut behavior) = oak_node::block::gap_create();
let id = {
let gap = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<oak_node::block::GapBlockBehavior>())
.expect("gap behavior");
gap.core.range = TimeRange::new(in_, out);
gap.core.track = Some(track);
p.graph.add_node(core, behavior)
};
if let Some(entry) = p.graph.get_mut(track) {
if let Some(t) = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackBehavior>())
{
t.blocks.push(id);
}
}
id
}
fn add_transition(
p: &mut Project,
track: NodeId,
in_offset: Rational,
out_offset: Rational,
) -> NodeId {
let (core, mut behavior) = oak_node::block::transition_create();
let id = {
let t = behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TransitionBlockBehavior>())
.expect("transition behavior");
t.core.range = TimeRange::new(Rational::new(3, 1), Rational::new(4, 1));
t.core.track = Some(track);
t.in_offset = in_offset;
t.out_offset = out_offset;
p.graph.add_node(core, behavior)
};
if let Some(entry) = p.graph.get_mut(track) {
if let Some(t) = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackBehavior>())
{
t.blocks.push(id);
}
}
id
}
fn add_track(p: &mut Project, kind: TrackType) -> NodeId {
let (core, behavior) = TrackBehavior::create();
let track = p.graph.add_node(core, behavior);
if let Some(entry) = p.graph.get_mut(track) {
if let Some(t) = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackBehavior>())
{
t.kind = kind;
}
}
track
}
fn attach_list(p: &mut Project, sequence: NodeId, kind: TrackType, tracks: Vec<NodeId>) -> NodeId {
let (core, behavior) = TrackListBehavior::create();
let list = p.graph.add_node(core, behavior);
if let Some(entry) = p.graph.get_mut(list) {
if let Some(l) = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<TrackListBehavior>())
{
l.kind = kind;
l.tracks = tracks;
}
}
if let Some(entry) = p.graph.get_mut(sequence) {
if let Some(s) = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
{
s.track_lists.push(list);
}
}
list
}
/// A project with `count` sequences named "Seq0".."SeqN"; the first has a
/// video track (clip, explicit gap, transition, footage-less clip) and an
/// audio track.
fn build_project(media: Option<&str>, count: usize) -> Arc<Mutex<Project>> {
let project = Project::new();
{
let mut p = project.lock().unwrap();
p.initialize().expect("root folder");
let footage =
media.map(|media| p.graph.add_node(NodeCore::new(), Box::new(FootageBehavior::new(media))));
for index in 0..count {
let (core, behavior) = SequenceBehavior::create();
let seq = p.graph.add_node(core, behavior);
if let Some(entry) = p.graph.get_mut(seq) {
entry.core.label = format!("Seq{index}");
}
if index == 0 {
let video = add_track(&mut p, TrackType::Video);
add_clip(&mut p, video, footage, Rational::new(0, 1), Rational::new(1, 1));
// A discontinuity before this block exercises the implicit-gap
// export path (block in 2 > position 1).
add_gap(&mut p, video, Rational::new(2, 1), Rational::new(3, 1));
add_transition(
&mut p,
video,
Rational::new(1, 2),
Rational::new(1, 4),
);
// No footage and no edge: exports a MissingReference.
add_clip(
&mut p,
video,
None,
Rational::new(4, 1),
Rational::new(5, 1),
);
attach_list(&mut p, seq, TrackType::Video, vec![video]);
let audio = add_track(&mut p, TrackType::Audio);
add_clip(&mut p, audio, footage, Rational::new(0, 1), Rational::new(1, 1));
attach_list(&mut p, seq, TrackType::Audio, vec![audio]);
}
}
}
project
}
struct Loaded {
project: Arc<Mutex<Project>>,
result: LoadResult,
}
impl Drop for Loaded {
fn drop(&mut self) {
if let Some(release) = self.result.project.release {
// SAFETY: the load result is an owned handle (refcount 1).
unsafe { release(self.result.project.ctx) };
}
}
}
fn load(uri: &StorageUri) -> oak_storage::error::Result<Loaded> {
let backend = OtioBackend::new();
let result = backend.load(uri)?;
let arc = unsafe { project_arc(&result.project)? };
Ok(Loaded { project: arc, result })
}
/// Sequence labels present in `project`.
fn sequence_labels(project: &Arc<Mutex<Project>>) -> Vec<String> {
let guard = project.lock().unwrap_or_else(|e| e.into_inner());
let mut labels = Vec::new();
for id in guard.graph.node_ids() {
if let Some(entry) = guard.graph.get(id) {
if entry
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
.is_some()
{
labels.push(entry.core.label.clone());
}
}
}
labels.sort();
labels
}
#[test]
fn backend_metadata_and_can_handle() {
let backend = OtioBackend::new();
assert_eq!(backend.name(), "otio");
assert_eq!(backend.uri_scheme(), "file");
assert!(backend.can_handle(&file_uri(std::path::Path::new("/tmp/a.otio"))));
assert!(backend.can_handle(&file_uri(std::path::Path::new("/tmp/a.fcpxml"))));
assert!(!backend.can_handle(&file_uri(std::path::Path::new("/tmp/a.ove"))));
assert!(!backend.can_handle(&file_uri(std::path::Path::new("/tmp/a"))));
}
#[test]
fn save_rejects_bad_uri_and_extension() {
let project = build_project(None, 0);
let backend = OtioBackend::new();
// Unsupported extension.
let uri = file_uri(&work_path("bad", "ove"));
assert!(backend.save_project(&project, &uri, 0).is_err());
// No extension at all.
let uri = StorageUri::parse("file:///tmp/noext").expect("uri");
assert!(backend.save_project(&project, &uri, 0).is_err());
// Non-local URI (the otio backend only writes files).
let uri = StorageUri::parse("oakdb+sqlite:///tmp/x.otio").expect("uri");
assert!(backend.save_project(&project, &uri, 0).is_err());
// The facade `save` (handle in, path validated the same way).
let handle = make_project_owned(project);
let uri = file_uri(&work_path("bad", "ove"));
assert!(StorageBackend::save(&backend, handle, &uri, 0).is_err());
if let Some(release) = handle.release {
unsafe { release(handle.ctx) };
}
}
#[test]
fn otio_collection_round_trip_preserves_sequences_tracks_and_blocks() {
let media = media_path("rich");
let project = build_project(Some(&media.to_string_lossy()), 2);
let out = work_path("rich", "otio");
let backend = OtioBackend::new();
let handle = make_project_owned(project.clone());
backend
.save(handle, &file_uri(&out), 0)
.expect("save through the facade");
if let Some(release) = handle.release {
unsafe { release(handle.ctx) };
}
let loaded = load(&file_uri(&out)).expect("load");
assert_eq!(loaded.result.version_info, oak_storage::error::OAKSTORAGE_OK);
assert_eq!(
sequence_labels(&loaded.project),
vec!["Seq0".to_string(), "Seq1".to_string()]
);
// The first sequence carries both track lists, and its video track has
// the four exported composables (clip, gap, transition, clip).
let guard = loaded.project.lock().unwrap();
let mut tracks = 0;
let mut clips = 0;
let mut transitions = 0;
let mut footage_links = 0;
let mut missing_links = 0;
for id in guard.graph.node_ids() {
let Some(entry) = guard.graph.get(id) else {
continue;
};
let Some(any) = entry.behavior.as_any() else {
continue;
};
if let Some(t) = any.downcast_ref::<TrackBehavior>() {
// clip, an implicit gap for the discontinuity, the explicit gap,
// the transition and the footage-less clip.
if t.blocks.len() == 5 {
tracks += 1;
}
}
if let Some(c) = any.downcast_ref::<ClipBlockBehavior>() {
clips += 1;
if c.footage.is_some() {
footage_links += 1;
} else {
missing_links += 1;
}
}
if let Some(t) = any.downcast_ref::<TransitionBlockBehavior>() {
transitions += 1;
assert_eq!(t.in_offset, Rational::new(1, 2));
assert_eq!(t.out_offset, Rational::new(1, 4));
}
}
assert_eq!(tracks, 1, "the rich video track");
assert_eq!(clips, 3, "two linked clips + one missing-reference clip");
assert_eq!(footage_links, 2);
assert_eq!(missing_links, 1);
assert_eq!(transitions, 1);
drop(guard);
let _ = std::fs::remove_file(&out);
}
#[test]
fn fcpxml_round_trip_preserves_sequences_tracks_and_blocks() {
let media = media_path("fcpxml");
let project = build_project(Some(&media.to_string_lossy()), 1);
let out = work_path("fcpxml", "fcpxml");
let backend = OtioBackend::new();
backend
.save_project(&project, &file_uri(&out), 0)
.expect("save fcpxml");
let loaded = load(&file_uri(&out)).expect("load fcpxml");
assert_eq!(sequence_labels(&loaded.project), vec!["Seq0".to_string()]);
// The sequence carries both track lists, and its video track has the
// exported composables (clip, implicit gap, explicit gap, transition,
// footage-less clip) — the FCPXML writer places blocks sequentially.
let guard = loaded.project.lock().unwrap();
let mut video_tracks = 0;
let mut audio_tracks = 0;
let mut clips = 0;
let mut transitions = 0;
let mut footage_links = 0;
let mut missing_links = 0;
for id in guard.graph.node_ids() {
let Some(entry) = guard.graph.get(id) else {
continue;
};
let Some(any) = entry.behavior.as_any() else {
continue;
};
if let Some(t) = any.downcast_ref::<TrackBehavior>() {
if t.kind == TrackType::Video && t.blocks.len() == 5 {
video_tracks += 1;
}
if t.kind == TrackType::Audio && t.blocks.len() == 1 {
audio_tracks += 1;
}
}
if let Some(c) = any.downcast_ref::<ClipBlockBehavior>() {
clips += 1;
if c.footage.is_some() {
footage_links += 1;
} else {
missing_links += 1;
}
}
if let Some(t) = any.downcast_ref::<TransitionBlockBehavior>() {
transitions += 1;
// FCP X centers transitions on import: each offset is half the
// exported duration (1/2 + 1/4 seconds).
assert_eq!(t.in_offset, Rational::new(3, 8));
assert_eq!(t.out_offset, Rational::new(3, 8));
}
}
assert_eq!(video_tracks, 1, "the rich video track");
assert_eq!(audio_tracks, 1, "the audio track");
assert_eq!(clips, 3, "two linked clips + one missing-reference clip");
assert_eq!(footage_links, 2);
assert_eq!(missing_links, 1);
assert_eq!(transitions, 1);
drop(guard);
let _ = std::fs::remove_file(&out);
}
#[test]
fn empty_project_exports_one_empty_timeline() {
let project = build_project(None, 0);
let out = work_path("empty", "otio");
let backend = OtioBackend::new();
backend
.save_project(&project, &file_uri(&out), 0)
.expect("save empty project");
let loaded = load(&file_uri(&out)).expect("load empty project");
// The export names the placeholder "Timeline"; the import carries it.
assert_eq!(sequence_labels(&loaded.project), vec!["Timeline".to_string()]);
let _ = std::fs::remove_file(&out);
}
#[test]
fn load_reports_missing_corrupt_and_unrooted_documents() {
let backend = OtioBackend::new();
// Missing file.
let missing = work_path("missing", "otio");
assert!(backend.load(&file_uri(&missing)).is_err());
// Malformed JSON.
let corrupt = work_path("corrupt", "otio");
std::fs::write(&corrupt, b"{ not json").expect("write");
assert!(backend.load(&file_uri(&corrupt)).is_err());
let _ = std::fs::remove_file(&corrupt);
// Valid JSON whose root is neither a timeline nor a collection.
let raw = work_path("raw", "otio");
std::fs::write(&raw, br#"{"OTIO_SCHEMA":"RationalTime.1","value":1,"rate":24}"#)
.expect("write");
let err = backend.load(&file_uri(&raw)).expect_err("raw root must fail");
assert!(
format!("{err}").contains("no timeline or collection root"),
"{err}"
);
let _ = std::fs::remove_file(&raw);
// Missing FCPXML and no-extension URIs.
let missing_xml = work_path("missing", "fcpxml");
assert!(backend.load(&file_uri(&missing_xml)).is_err());
let no_ext = StorageUri::parse("file:///tmp/noext").expect("uri");
assert!(backend.load(&no_ext).is_err());
}
@@ -0,0 +1,289 @@
// 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/>.
//! Boundary tests for the oakstorage write-through façade
//! (`oak_storage::writethrough`): configuration gating, binding lifecycle,
//! write-through / flush / unbind, error recording, and the null-handle
//! contract.
//!
//! The config store is process-global, so all tests serialize on a local
//! mutex and write `OAK_CONFIG_DIR` to an isolated temp directory before
//! the first settings access (the tests never call `ConfigStore::save`, so
//! nothing persists).
use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
use oak_node::project::Project;
use oak_storage::handle::CHandle;
use oak_storage::nodeutil::make_project_owned;
use oak_storage::uri::StorageUri;
use oak_storage::writethrough;
/// Serializes the tests (global config + global bindings + the snapshot
/// thread).
static CONFIG_LOCK: Mutex<()> = Mutex::new(());
/// Take the lock and isolate the config store under a per-process temp
/// directory (once) before any settings access.
fn isolated_config() -> MutexGuard<'static, ()> {
static INIT: OnceLock<()> = OnceLock::new();
let guard = CONFIG_LOCK.lock().unwrap_or_else(|e| e.into_inner());
INIT.get_or_init(|| {
let dir = std::env::temp_dir().join(format!(
"oakstorage_writethrough_config_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::create_dir_all(&dir);
std::env::set_var("OAK_CONFIG_DIR", &dir);
});
guard
}
fn set_storage_config(backend: &str, sqlite_path: Option<&std::path::Path>, pg_url: Option<&str>) {
let store = oak_core::configstore::ConfigStore::instance();
store.set(Some("Storage"), "Backend", backend);
store.set(
Some("Storage"),
"SqlitePath",
sqlite_path
.map(|p| p.to_string_lossy().into_owned())
.as_deref()
.unwrap_or(""),
);
store.set(Some("Storage"), "PgUrl", pg_url.unwrap_or(""));
}
fn work_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"oakstorage_writethrough_{tag}_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("work dir");
dir
}
fn project_handle() -> (CHandle, Arc<Mutex<Project>>) {
let project = Project::new();
let handle = make_project_owned(project.clone());
(handle, project)
}
fn release(handle: CHandle) {
if let Some(release) = handle.release {
// SAFETY: the handle is an owned box (refcount 1) that this test
// created via `make_project_owned`.
unsafe { release(handle.ctx) };
}
}
// ---- null handles and status ------------------------------------------
#[test]
fn null_handles_are_safe_no_ops() {
let _guard = isolated_config();
assert!(!writethrough::is_bound(CHandle::null()));
assert_eq!(writethrough::last_error(CHandle::null()), None);
writethrough::bind_project(CHandle::null());
writethrough::unbind_project(CHandle::null());
// Nothing to flush is a no-op (and idempotent).
writethrough::flush_all();
writethrough::flush_all();
}
// ---- configuration gating ---------------------------------------------
#[test]
fn backend_disabled_by_config_leaves_projects_unbound() {
let _guard = isolated_config();
set_storage_config("off", None, None);
assert!(!writethrough::storage_enabled());
let (handle, _project) = project_handle();
writethrough::bind_project(handle);
assert!(
!writethrough::is_bound(handle),
"disabled backend must not bind"
);
writethrough::unbind_project(handle);
release(handle);
}
#[test]
fn backend_enabled_by_config_binds_and_round_trips() {
let _guard = isolated_config();
let dir = work_dir("sq");
let db = dir.join("library.db");
set_storage_config("sqlite", Some(&db), None);
assert!(writethrough::storage_enabled());
let (handle, project) = project_handle();
assert!(!writethrough::is_bound(handle));
writethrough::bind_project(handle);
assert!(writethrough::is_bound(handle));
assert_eq!(writethrough::last_error(handle), None);
// Re-binding is idempotent (the existing binding is kept).
writethrough::bind_project(handle);
assert!(writethrough::is_bound(handle));
// Add real state so the round trip carries more than an empty row.
let original_uuid = {
let mut guard = project.lock().unwrap_or_else(|e| e.into_inner());
let (core, behavior) = oak_node::folder::create("Written Through");
guard.graph.add_node(core, behavior);
guard.uuid.clone()
};
// A command-success notification writes the project through.
writethrough::note_command();
assert_eq!(
writethrough::last_error(handle),
None,
"a write-through to a writable temp path succeeds"
);
// The write-through is real: the sqlite library file exists...
assert!(
db.is_file(),
"the sqlite library file was created at {}",
db.display()
);
assert!(
std::fs::metadata(&db).map(|m| m.len()).unwrap_or(0) > 0,
"the sqlite library file is non-empty"
);
// Flush drains and snapshots; it is idempotent and safe afterwards.
writethrough::flush_all();
writethrough::flush_all();
// ...and a fresh backend load reads the bound project's state back.
let uri = StorageUri::parse(&writethrough::library_uri().expect("library uri"))
.expect("parse library uri");
let loaded = writethrough::backend()
.load_project(&uri)
.expect("load the written-through project");
{
let guard = loaded.lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(
guard.uuid, original_uuid,
"the loaded library row is the bound project"
);
let labels: Vec<String> = guard
.graph
.node_ids()
.into_iter()
.filter_map(|id| guard.graph.get(id))
.map(|entry| entry.core.label.clone())
.collect();
assert!(
labels.iter().any(|label| label == "Written Through"),
"the loaded project carries the state added before note_command: {labels:?}"
);
}
writethrough::unbind_project(handle);
assert!(!writethrough::is_bound(handle), "unbind drops the binding");
assert_eq!(writethrough::last_error(handle), None);
release(handle);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn write_through_failure_is_recorded_in_last_error() {
let _guard = isolated_config();
// A directory cannot be opened as a SQLite database: the write-through
// fails and the binding records the error instead of panicking.
let dir = work_dir("bad");
set_storage_config("sqlite", Some(&dir), None);
let (handle, _project) = project_handle();
writethrough::bind_project(handle);
assert!(writethrough::is_bound(handle));
writethrough::note_command();
assert!(
writethrough::last_error(handle).is_some(),
"a failing write-through must record last_error"
);
writethrough::flush_all();
writethrough::unbind_project(handle);
release(handle);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn library_uri_resolves_sqlite_and_pg_configurations() {
let _guard = isolated_config();
let dir = work_dir("uri");
let db = dir.join("lib.db");
set_storage_config("sqlite", Some(&db), None);
let uri = writethrough::library_uri().expect("sqlite uri");
assert!(uri.starts_with("oakdb+sqlite://"), "{uri}");
assert!(uri.ends_with("lib.db"), "{uri}");
// PostgreSQL: the postgres:// scheme is stripped into the oakdb body.
set_storage_config(
"pg",
None,
Some("postgres://user:pass@host:5432/db"),
);
assert_eq!(
writethrough::library_uri().as_deref(),
Some("oakdb+pg://user:pass@host:5432/db")
);
// `postgresql://` is accepted too.
set_storage_config("pg", None, Some("postgresql://user@host/db"));
assert_eq!(
writethrough::library_uri().as_deref(),
Some("oakdb+pg://user@host/db")
);
// pg selected without a URL: no library configured.
set_storage_config("pg", None, None);
assert_eq!(writethrough::library_uri(), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn default_library_path_honors_the_config_directory() {
let _guard = isolated_config();
let path = writethrough::default_library_path();
assert!(path.ends_with("library.db"), "{path}");
let config_dir = std::env::var("OAK_CONFIG_DIR").expect("test isolation set it");
assert!(
path.starts_with(&config_dir),
"{path} must live under {config_dir}"
);
}
#[test]
fn note_command_without_bindings_is_a_no_op() {
let _guard = isolated_config();
set_storage_config(
"sqlite",
Some(&work_dir("none").join("lib.db")),
None,
);
// No project is bound in this process (the other tests' bindings are
// dropped before they return): the notification must not panic.
writethrough::note_command();
writethrough::flush_all();
}
+570 -1
View File
@@ -1181,4 +1181,573 @@ pub fn add_track_command(project: ProjectRef, list: NodeId) -> UndoCommand {
kind: TrackType::Video,
})
}
kind: TrackType::Video,
#[cfg(test)]
mod tests {
use super::*;
use oak_node::track::TrackType;
use oak_node::value::AudioParams;
// ---- fixtures -------------------------------------------------------
fn project() -> ProjectRef {
Project::new()
}
fn sequence_with_list(project: &ProjectRef, kind: TrackType) -> (NodeId, NodeId) {
let seq = sequence_create(project).expect("sequence");
let list = sequence_track_list(project, seq, kind).expect("track list");
(seq, list)
}
/// Append a track through the public command (the only path that wires
/// the `index`/`track_list` fields), returning its id.
fn append_track(project: &ProjectRef, list: NodeId) -> NodeId {
let mut cmd = add_track_command(project.clone(), list);
cmd.redo_now();
let guard = lock_project(project);
guard
.graph
.get(list)
.and_then(|e| e.behavior.as_any())
.and_then(|a| a.downcast_ref::<TrackListBehavior>())
.and_then(|l| l.tracks.last().copied())
.expect("track appended")
}
fn add_block(project: &ProjectRef, kind: BlockKind) -> NodeId {
block_create(project, kind).expect("block created")
}
fn add_adjustment(project: &ProjectRef) -> NodeId {
let (core, behavior) = oak_node::block::adjustment_create();
lock_project(project).graph.add_node(core, behavior)
}
/// Give a block an exact timeline range (the public setters keep the
/// out-point anchored and shift the in-point, which is awkward for
/// length assertions).
fn set_block_range(project: &ProjectRef, block: NodeId, in_: Rational, out: Rational) {
let mut guard = lock_project(project);
let ok = set_block_core(&mut guard.graph, block, |core| {
core.range = oak_core::TimeRange::new(in_, out);
});
assert!(ok, "block {block:?} range set");
}
fn with_graph<R>(project: &ProjectRef, f: impl FnOnce(&Graph) -> R) -> R {
let guard = lock_project(project);
f(&guard.graph)
}
fn folder_children(project: &ProjectRef, folder: NodeId) -> Vec<NodeId> {
with_graph(project, |g| {
g.get(folder)
.and_then(|e| e.behavior.as_any())
.and_then(|a| a.downcast_ref::<FolderBehavior>())
.map(|f| f.children.clone())
.unwrap_or_default()
})
}
fn bin_folder_of(project: &ProjectRef, node: NodeId) -> Option<NodeId> {
with_graph(project, |g| g.get(node).and_then(|e| e.core.bin_folder))
}
fn write_clip(tag: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!(
"oaktask_nodeops_{tag}_{}.mp4",
std::process::id()
));
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip");
path
}
// ---- shared block core ---------------------------------------------
#[test]
fn block_core_of_covers_every_block_kind_and_rejects_non_blocks() {
let p = project();
let clip = add_block(&p, BlockKind::Clip);
let gap = add_block(&p, BlockKind::Gap);
let transition = add_block(&p, BlockKind::Transition);
let adjustment = add_adjustment(&p);
let folder = folder_create(&p).expect("folder");
let sequence = sequence_create(&p).expect("sequence");
with_graph(&p, |g| {
for id in [clip, gap, transition, adjustment] {
assert!(block_core_of(g, id).is_some(), "block {id:?} must have a core");
}
assert!(block_core_of(g, folder).is_none(), "folder is not a block");
assert!(block_core_of(g, sequence).is_none(), "sequence is not a block");
assert!(block_core_of(g, NodeId::INVALID).is_none(), "stale id");
});
}
#[test]
fn block_create_rejects_the_unknown_kind() {
let p = project();
assert!(block_create(&p, BlockKind::Other).is_none());
assert!(block_create(&p, BlockKind::Clip).is_some());
assert!(block_create(&p, BlockKind::Gap).is_some());
assert!(block_create(&p, BlockKind::Transition).is_some());
}
// ---- node metadata --------------------------------------------------
#[test]
fn labels_round_trip_and_missing_nodes_stay_empty() {
let p = project();
let seq = sequence_create(&p).expect("sequence");
assert_eq!(node_label(&p, seq), "");
set_node_label(&p, seq, "Timeline 1");
assert_eq!(node_label(&p, seq), "Timeline 1");
// Missing / invalid nodes are safe no-ops.
set_node_label(&p, NodeId::INVALID, "nope");
assert_eq!(node_label(&p, NodeId::INVALID), "");
}
#[test]
fn node_type_ids_and_predicates_match_the_node_kind() {
let p = project();
let footage = footage_create(&p, None).expect("footage");
let sequence = sequence_create(&p).expect("sequence");
let folder = folder_create(&p).expect("folder");
assert_eq!(node_type_id(&p, sequence), SEQUENCE_TYPE_ID);
assert_eq!(node_type_id(&p, NodeId::INVALID), "");
with_graph(&p, |g| {
assert!(node_is_footage(g, footage));
assert!(!node_is_footage(g, sequence));
assert!(!node_is_footage(g, folder));
assert!(node_is_sequence(g, sequence));
assert!(!node_is_sequence(g, footage));
assert!(!node_is_sequence(g, NodeId::INVALID));
});
}
#[test]
fn context_position_is_a_safe_no_op_for_missing_nodes() {
let p = project();
let seq = sequence_create(&p).expect("sequence");
node_set_context_position(&p, seq, NodeId::INVALID, 1.0, 2.0, true);
node_set_context_position(&p, NodeId::INVALID, seq, 1.0, 2.0, false);
}
// ---- sequence / track list -----------------------------------------
#[test]
fn sequence_track_list_creates_once_per_kind_and_rejects_non_sequences() {
let p = project();
let seq = sequence_create(&p).expect("sequence");
let video = sequence_track_list(&p, seq, TrackType::Video).expect("video list");
let audio = sequence_track_list(&p, seq, TrackType::Audio).expect("audio list");
let subtitle = sequence_track_list(&p, seq, TrackType::Subtitle).expect("subtitle list");
assert_ne!(video, audio);
assert_ne!(video, subtitle);
// Created once: the sequence caches each list.
assert_eq!(sequence_track_list(&p, seq, TrackType::Video), Some(video));
assert_eq!(sequence_track_list(&p, seq, TrackType::Audio), Some(audio));
assert_eq!(sequence_track_list(&p, seq, TrackType::Subtitle), Some(subtitle));
// Non-sequences and stale ids have no track lists.
let folder = folder_create(&p).expect("folder");
assert_eq!(sequence_track_list(&p, folder, TrackType::Video), None);
assert_eq!(sequence_track_list(&p, NodeId::INVALID, TrackType::Video), None);
}
#[test]
fn sequence_params_default_and_read_back() {
let p = project();
let seq = sequence_create(&p).expect("sequence");
// The domain constructor applies the default parameters.
let default = sequence_video_params(&p, seq, 0).expect("default video params");
assert_eq!((default.width(), default.height()), (1920, 1080));
assert_eq!(default.channel_count(), 4);
assert_eq!(sequence_audio_params(&p, seq), (48000, 0x3));
assert!(sequence_video_params(&p, seq, 1).is_none(), "out of range");
assert_eq!(sequence_audio_params(&p, NodeId::INVALID), (48000, 0x3));
assert!(sequence_video_params(&p, NodeId::INVALID, 0).is_none());
assert!(sequence_video_params(&p, folder_create(&p).expect("folder"), 0).is_none());
// Replace the defaults and read them back through the oak_core type.
{
let mut guard = lock_project(&p);
let entry = guard.graph.get_mut(seq).expect("sequence");
let s = entry
.behavior
.as_any_mut()
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
.expect("sequence behavior");
s.video_params = vec![oak_node::value::VideoParams {
width: 1280,
height: 720,
frame_rate: Rational::new(24, 1),
pixel_format: oak_core::ocioutils::PixelFormat::F32.code(),
channels: 4,
interlaced: true,
}];
s.audio_params = vec![AudioParams {
sample_rate: 44100,
channel_layout: 0x3,
format: 0,
}];
}
let v = sequence_video_params(&p, seq, 0).expect("video params");
assert_eq!((v.width(), v.height()), (1280, 720));
assert_eq!(v.frame_rate(), (24, 1));
assert_eq!(v.channel_count(), 4);
assert_eq!(sequence_audio_params(&p, seq), (44100, 0x3));
}
// ---- footage --------------------------------------------------------
#[test]
fn footage_accessors_reject_wrong_node_types() {
let p = project();
let folder = folder_create(&p).expect("folder");
assert!(!footage_set_filename(&p, folder, "x.mp4"));
assert!(!footage_is_valid(&p, folder));
assert_eq!(footage_filename(&p, folder), "");
assert_eq!(footage_total_stream_count(&p, folder), 0);
assert!(footage_video_params(&p, folder, 0).is_none());
assert_eq!(footage_audio_sample_rate(&p, folder), None);
assert_eq!(footage_filename(&p, NodeId::INVALID), "");
assert!(!footage_is_valid(&p, NodeId::INVALID));
// Cancellation on a wrong node is a safe no-op.
footage_set_cancelled(&p, folder, true);
}
#[test]
fn footage_probe_round_trip_with_real_media() {
let path = write_clip("probe");
let p = project();
let footage = footage_create(&p, None).expect("footage");
assert!(!footage_is_valid(&p, footage));
assert_eq!(footage_total_stream_count(&p, footage), 0);
// A missing file fails the probe without panicking.
assert!(!footage_set_filename(&p, footage, "/definitely/not/here.mp4"));
assert!(!footage_is_valid(&p, footage));
// A real clip probes: valid, with stream parameters.
assert!(footage_set_filename(&p, footage, &path.to_string_lossy()));
assert!(footage_is_valid(&p, footage));
assert_eq!(footage_filename(&p, footage), path.to_string_lossy());
assert!(footage_total_stream_count(&p, footage) >= 1);
let video = footage_video_params(&p, footage, 0).expect("video params");
assert_eq!((video.width(), video.height()), (64, 64));
assert!(footage_audio_sample_rate(&p, footage).is_some());
assert!(node_length(&p, footage) > Rational::new(0, 1));
// Out-of-range stream index.
assert!(footage_video_params(&p, footage, 99).is_none());
// Overwriting a stream parameter round-trips through the oak_core type.
let mut params = CommonVideoParams::new_basic(
32,
32,
oak_core::ocioutils::PixelFormat::F32,
4,
1,
1,
0,
1,
);
params.set_frame_rate(30, 1);
footage_set_video_params(&p, footage, 0, &params);
let back = footage_video_params(&p, footage, 0).expect("overwritten params");
assert_eq!((back.width(), back.height()), (32, 32));
assert_eq!(back.frame_rate(), (30, 1));
// A wrong node type is a no-op.
footage_set_video_params(&p, NodeId::INVALID, 0, &params);
let _ = std::fs::remove_file(&path);
}
#[test]
fn node_length_covers_footage_sequence_and_other_types() {
let path = write_clip("length");
let p = project();
let footage = footage_create(&p, None).expect("footage");
assert!(footage_set_filename(&p, footage, &path.to_string_lossy()));
assert!(node_length(&p, footage) > Rational::new(0, 1));
let (seq, list) = sequence_with_list(&p, TrackType::Video);
assert_eq!(node_length(&p, seq), Rational::new(0, 1), "empty sequence");
let track = append_track(&p, list);
let a = add_block(&p, BlockKind::Clip);
let b = add_block(&p, BlockKind::Clip);
assert!(track_append_block(&p, track, a));
assert!(track_append_block(&p, track, b));
set_block_range(&p, a, Rational::new(0, 1), Rational::new(2, 1));
set_block_range(&p, b, Rational::new(2, 1), Rational::new(5, 1));
assert_eq!(node_length(&p, seq), Rational::new(5, 1), "longest track out");
let folder = folder_create(&p).expect("folder");
assert_eq!(node_length(&p, folder), Rational::new(0, 1));
assert_eq!(node_length(&p, NodeId::INVALID), Rational::new(0, 1));
let _ = std::fs::remove_file(&path);
}
// ---- tracks and blocks ---------------------------------------------
#[test]
fn tracklist_accessors_and_out_of_range() {
let p = project();
let (_, list) = sequence_with_list(&p, TrackType::Audio);
assert_eq!(tracklist_track_count(&p, list), 0);
assert!(tracklist_track_at(&p, list, 0).is_none());
let track = append_track(&p, list);
assert_eq!(tracklist_track_count(&p, list), 1);
assert_eq!(tracklist_track_at(&p, list, 0), Some(track));
assert!(tracklist_track_at(&p, list, 1).is_none());
assert_eq!(track_type(&p, track), Some(TrackType::Audio));
assert_eq!(track_type(&p, list), None);
assert_eq!(track_type(&p, NodeId::INVALID), None);
// A non-list node has no tracks.
assert_eq!(tracklist_track_count(&p, track), 0);
}
#[test]
fn track_blocks_append_guard_and_length() {
let p = project();
let (_, list) = sequence_with_list(&p, TrackType::Video);
let track = append_track(&p, list);
let clip = add_block(&p, BlockKind::Clip);
assert!(!track_append_block(&p, NodeId::INVALID, clip), "stale track");
assert!(track_append_block(&p, track, clip));
assert_eq!(track_block_count(&p, track), 1);
assert_eq!(track_block_at(&p, track, 0), Some(clip));
assert!(track_block_at(&p, track, 1).is_none());
assert_eq!(track_block_count(&p, NodeId::INVALID), 0);
set_block_range(&p, clip, Rational::new(0, 1), Rational::new(0, 1));
assert_eq!(track_length(&p, track), Rational::new(0, 1), "zero-length block");
set_block_range(&p, clip, Rational::new(1, 1), Rational::new(4, 1));
assert_eq!(track_length(&p, track), Rational::new(4, 1), "max out");
assert_eq!(track_length(&p, NodeId::INVALID), Rational::new(0, 1));
}
#[test]
fn block_getters_default_for_missing_nodes_and_setters_round_trip() {
let p = project();
let clip = add_block(&p, BlockKind::Clip);
assert_eq!(block_in(&p, NodeId::INVALID), Rational::new(0, 1));
assert_eq!(block_length(&p, NodeId::INVALID), Rational::new(0, 1));
assert_eq!(block_media_in(&p, NodeId::INVALID), Rational::new(0, 1));
clip_set_media_in(&p, clip, 2, 1);
assert_eq!(block_media_in(&p, clip), Rational::new(2, 1));
block_set_length_and_media_out(&p, clip, 5, 1);
assert_eq!(block_length(&p, clip), Rational::new(5, 1));
// Setters on a non-block/missing node are no-ops.
block_set_length_and_media_out(&p, NodeId::INVALID, 1, 1);
clip_set_media_in(&p, NodeId::INVALID, 9, 1);
}
#[test]
fn clip_footage_round_trip_and_non_clip_nodes() {
let p = project();
let clip = add_block(&p, BlockKind::Clip);
let gap = add_block(&p, BlockKind::Gap);
let footage = footage_create(&p, None).expect("footage");
assert_eq!(clip_footage(&p, clip), None);
clip_set_footage(&p, clip, footage);
assert_eq!(clip_footage(&p, clip), Some(footage));
assert_eq!(clip_footage(&p, gap), None);
assert_eq!(clip_footage(&p, NodeId::INVALID), None);
clip_set_footage(&p, gap, footage); // no-op on a gap
assert_eq!(clip_footage(&p, gap), None);
}
#[test]
fn transition_offsets_default_and_set_length() {
let p = project();
let transition = add_block(&p, BlockKind::Transition);
assert_eq!(transition_in_offset(&p, transition), Rational::new(0, 1));
assert_eq!(transition_out_offset(&p, transition), Rational::new(0, 1));
assert_eq!(transition_in_offset(&p, NodeId::INVALID), Rational::new(0, 1));
transition_set_offsets_and_length(&p, transition, 1, 2, 3, 4);
assert_eq!(transition_in_offset(&p, transition), Rational::new(1, 2));
assert_eq!(transition_out_offset(&p, transition), Rational::new(3, 4));
assert_eq!(
block_length(&p, transition),
Rational::new(5, 4),
"length = in + out"
);
// Wrong node types are no-ops.
transition_set_offsets_and_length(&p, NodeId::INVALID, 1, 1, 1, 1);
}
#[test]
fn block_kind_and_adjustment_predicates() {
let p = project();
let adjustment = add_adjustment(&p);
assert!(block_is_adjustment(&p, adjustment));
assert!(!block_is_adjustment(&p, NodeId::INVALID));
for (kind, expected) in [
(BlockKind::Clip, BlockKind::Clip),
(BlockKind::Gap, BlockKind::Gap),
(BlockKind::Transition, BlockKind::Transition),
] {
let block = add_block(&p, kind);
assert_eq!(block_kind(&p, block), expected);
}
assert_eq!(block_kind(&p, adjustment), BlockKind::Other);
assert_eq!(block_kind(&p, NodeId::INVALID), BlockKind::Other);
// An adjustment block is not one of the C-ABI block kinds.
let folder = folder_create(&p).expect("folder");
assert_eq!(block_kind(&p, folder), BlockKind::Other);
}
// ---- graph adapters and connections --------------------------------
#[test]
fn graph_range_adapters_default_for_missing_nodes() {
let p = project();
let (_, list) = sequence_with_list(&p, TrackType::Video);
let track = append_track(&p, list);
let clip = add_block(&p, BlockKind::Clip);
assert!(track_append_block(&p, track, clip));
set_block_range(&p, clip, Rational::new(2, 1), Rational::new(6, 1));
with_graph(&p, |g| {
let blocks = GraphBlockRange { graph: g };
assert_eq!(blocks.in_(NodeId::INVALID), Rational::new(0, 1));
assert_eq!(blocks.out(NodeId::INVALID), Rational::new(0, 1));
assert_eq!(blocks.in_(clip), Rational::new(2, 1));
assert_eq!(blocks.out(clip), Rational::new(6, 1));
let tracks = GraphTrackRange { graph: g };
assert_eq!(tracks.length(NodeId::INVALID), Rational::new(0, 1));
assert_eq!(tracks.length(clip), Rational::new(0, 1), "not a track");
assert_eq!(tracks.length(track), Rational::new(6, 1));
});
}
#[test]
fn node_connect_validates_endpoints_and_inputs() {
let p = project();
let footage = footage_create(&p, None).expect("footage");
let clip = add_block(&p, BlockKind::Clip);
assert!(node_connect(&p, footage, clip, CLIP_TEXTURE_INPUT));
assert!(!node_connect(&p, clip, footage, CLIP_TEXTURE_INPUT), "unknown input");
assert!(!node_connect(&p, NodeId::INVALID, clip, CLIP_TEXTURE_INPUT));
assert!(!node_connect(&p, footage, NodeId::INVALID, CLIP_TEXTURE_INPUT));
}
#[test]
fn find_input_footage_direct_and_through_the_graph() {
let p = project();
let footage = footage_create(&p, None).expect("footage");
let clip = add_block(&p, BlockKind::Clip);
let folder = folder_create(&p).expect("folder");
// A footage node answers with itself.
with_graph(&p, |g| assert_eq!(find_input_footage(g, footage), Some(footage)));
// A clip answers with its recorded footage...
clip_set_footage(&p, clip, footage);
with_graph(&p, |g| assert_eq!(find_input_footage(g, clip), Some(footage)));
// ...but a stale recorded footage is ignored (falls through to the
// graph walk, which finds nothing here).
clip_set_footage(&p, clip, NodeId::INVALID);
with_graph(&p, |g| assert_eq!(find_input_footage(g, clip), None));
assert_eq!(node_find_input_footage(&p, clip), None);
// A graph edge to a footage node is found by the walk.
assert!(node_connect(&p, footage, clip, CLIP_TEXTURE_INPUT));
assert_eq!(node_find_input_footage(&p, clip), Some(footage));
// Non-footage, unconnected nodes have no footage.
assert_eq!(node_find_input_footage(&p, folder), None);
assert_eq!(node_find_input_footage(&p, NodeId::INVALID), None);
}
#[test]
fn pixel_format_codes_map_known_and_unknown() {
use oak_core::PixelFormat as P;
assert_eq!(pixel_format_from_code(-1), P::Invalid);
assert_eq!(pixel_format_from_code(0), P::U8);
assert_eq!(pixel_format_from_code(1), P::U10);
assert_eq!(pixel_format_from_code(2), P::U16);
assert_eq!(pixel_format_from_code(3), P::F16);
assert_eq!(pixel_format_from_code(4), P::F32);
assert_eq!(pixel_format_from_code(99), P::Invalid);
}
// ---- undo commands --------------------------------------------------
#[test]
fn folder_add_child_command_redo_undo() {
let p = project();
let folder = folder_create(&p).expect("folder");
let child = footage_create(&p, None).expect("footage");
let mut cmd =
folder_add_child_command((p.clone(), folder), (p.clone(), child));
assert!(folder_children(&p, folder).is_empty());
cmd.redo_now();
assert_eq!(folder_children(&p, folder), vec![child]);
assert_eq!(bin_folder_of(&p, child), Some(folder));
cmd.undo_now();
assert!(folder_children(&p, folder).is_empty());
assert_eq!(bin_folder_of(&p, child), None);
}
#[test]
fn remove_node_command_restores_the_entry_and_folder_slot() {
let p = project();
let folder = folder_create(&p).expect("folder");
let first = footage_create(&p, None).expect("first");
let second = footage_create(&p, None).expect("second");
folder_add_child_command((p.clone(), folder), (p.clone(), first)).redo_now();
folder_add_child_command((p.clone(), folder), (p.clone(), second)).redo_now();
assert_eq!(folder_children(&p, folder), vec![first, second]);
let mut cmd = remove_node_command(p.clone(), first);
cmd.redo_now();
assert!(with_graph(&p, |g| g.get(first).is_none()), "removed from graph");
assert_eq!(folder_children(&p, folder), vec![second]);
cmd.undo_now();
assert!(with_graph(&p, |g| g.get(first).is_some()), "restored");
assert_eq!(folder_children(&p, folder), vec![first, second], "slot restored");
assert_eq!(bin_folder_of(&p, first), Some(folder));
}
#[test]
fn remove_node_command_handles_nodes_without_a_folder() {
let p = project();
let node = folder_create(&p).expect("folder");
let mut cmd = remove_node_command(p.clone(), node);
cmd.redo_now();
assert!(with_graph(&p, |g| g.get(node).is_none()));
cmd.undo_now();
assert!(with_graph(&p, |g| g.get(node).is_some()));
assert_eq!(bin_folder_of(&p, node), None);
}
#[test]
fn add_track_command_appends_and_removes() {
let p = project();
let (_, list) = sequence_with_list(&p, TrackType::Subtitle);
assert_eq!(tracklist_track_count(&p, list), 0);
let mut cmd = add_track_command(p.clone(), list);
cmd.redo_now();
assert_eq!(tracklist_track_count(&p, list), 1);
let track = tracklist_track_at(&p, list, 0).expect("track");
assert_eq!(track_type(&p, track), Some(TrackType::Subtitle));
cmd.undo_now();
assert_eq!(tracklist_track_count(&p, list), 0);
assert!(with_graph(&p, |g| g.get(track).is_none()), "track removed");
// A stale list makes redo a no-op instead of a panic.
let mut missing = add_track_command(p.clone(), NodeId::INVALID);
missing.redo_now();
missing.undo_now();
}
}
+706 -1
View File
@@ -1241,4 +1241,709 @@ fn shm_frame_to_texture(frame: &ShmFrameRef) -> oak_core::texture::Texture {
.collect();
oak_core::texture::Texture::wrap_frame(f)
}
.collect();
#[cfg(test)]
mod tests {
use super::*;
use oak_core::ocioutils::PixelFormat as OakPixelFormat;
use oak_core::PixelFormat;
use oak_node::block::ClipBlockBehavior;
use oak_node::folder;
use oak_node::id::NodeId;
use oak_node::node::NodeCore;
use oak_node::sequence::SequenceBehavior;
use oak_node::track::{TrackBehavior, TrackListBehavior, TrackType};
use oak_node::value::NodeValue;
use oak_render::ticket::AudioSamples;
/// A fresh project (the same `Arc<Mutex<Project>>` shape the integration
/// fixtures build, but without any media I/O — these tests only touch
/// the pure graph-to-params marshalling).
fn project() -> ProjectRef {
oak_node::project::Project::new()
}
fn add_footage(project: &ProjectRef, filename: &str) -> NodeId {
let (core, behavior) = oak_node::footage::FootageBehavior::create();
let id = project.lock().unwrap().graph.add_node(core, behavior);
{
let mut guard = project.lock().unwrap();
if let Some(f) = guard
.graph
.get_mut(id)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<oak_node::footage::FootageBehavior>())
{
f.filename = filename.to_string();
}
}
id
}
fn add_folder(project: &ProjectRef) -> NodeId {
let (core, behavior) = folder::create("");
project.lock().unwrap().graph.add_node(core, behavior)
}
fn add_sequence(project: &ProjectRef) -> NodeId {
let (core, behavior) = SequenceBehavior::create();
project.lock().unwrap().graph.add_node(core, behavior)
}
fn add_clip(project: &ProjectRef, footage: Option<NodeId>, range: TimeRange) -> NodeId {
let (core, behavior) = oak_node::block::clip_create();
let clip = project.lock().unwrap().graph.add_node(core, behavior);
let mut guard = project.lock().unwrap();
if let Some(c) = guard
.graph
.get_mut(clip)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<ClipBlockBehavior>())
{
c.core.range = range;
c.footage = footage;
}
clip
}
// ---- pure accessors -------------------------------------------------
#[test]
fn force_size_and_format_cover_off_and_on_paths() {
let project = project();
let make = |force: ForceParams| {
RenderTask::new(
Task::new("t", None),
None,
(project.clone(), NodeId::INVALID),
force,
None,
)
};
// All-off defaults: no forced size, no forced format.
let task = make(ForceParams::default());
assert_eq!(task.force_size(), None);
assert_eq!(task.force_format(), None);
// A single positive dimension is not enough for a forced size.
let task = make(ForceParams {
force_width: 32,
..ForceParams::default()
});
assert_eq!(task.force_size(), None);
let task = make(ForceParams {
force_width: 0,
force_height: 32,
..ForceParams::default()
});
assert_eq!(task.force_size(), None);
// Both positive: forced.
let task = make(ForceParams {
force_width: 32,
force_height: 16,
..ForceParams::default()
});
assert_eq!(task.force_size(), Some((32, 16)));
// Format codes map through `pixel_format_from_code`: -1 is off,
// known codes map, unknown codes become Invalid.
let task = make(ForceParams {
force_format: PixelFormat::F32 as i32,
..ForceParams::default()
});
assert_eq!(task.force_format(), Some(PixelFormat::F32));
let task = make(ForceParams {
force_format: 0,
..ForceParams::default()
});
assert_eq!(task.force_format(), Some(PixelFormat::U8));
let task = make(ForceParams {
force_format: 99,
..ForceParams::default()
});
assert_eq!(task.force_format(), Some(PixelFormat::Invalid));
}
#[test]
fn timebase_falls_back_for_missing_or_null_params() {
let project = project();
let make = |params: Option<VideoParams>| {
RenderTask::new(
Task::new("t", None),
params,
(project.clone(), NodeId::INVALID),
ForceParams::default(),
None,
)
};
// No params at all.
assert_eq!(make(None).timebase(), Rational::new(1, 1));
// Null frame rate (the default `VideoParams`).
assert_eq!(make(Some(VideoParams::new())).timebase(), Rational::new(1, 1));
// A valid frame rate flips into the frame duration.
let mut params = VideoParams::new_basic(
64,
64,
OakPixelFormat::from_code(0),
4,
1,
1,
0,
1,
);
params.set_frame_rate(25, 1);
assert_eq!(make(Some(params)).timebase(), Rational::new(1, 25));
}
/// The placeholder render state is a valid, empty task.
#[test]
fn placeholder_is_closed_and_empty() {
let task = RenderTask::placeholder();
assert_eq!(task.total_frames(), 0);
assert_eq!(task.viewer.1, NodeId::INVALID);
assert!(task.viewer.0.lock().is_ok());
}
#[test]
fn classify_ticket_maps_submitter_keys_only() {
let task = RenderTask::placeholder();
let mut keys = HashMap::new();
keys.insert(TicketId(7), (TICKET_VIDEO, 0, 1));
keys.insert(TicketId(9), (TICKET_AUDIO, 0, 1));
let mut slots = HashMap::new();
slots.insert((TICKET_VIDEO, 0, 1), 3);
assert_eq!(task.classify_ticket(&keys, TicketId(7), &slots), Some(3));
// Audio key without a slot.
assert_eq!(task.classify_ticket(&keys, TicketId(9), &slots), None);
// Unknown ticket id.
assert_eq!(task.classify_ticket(&keys, TicketId(99), &slots), None);
// Same key with a different time.
assert_eq!(
task.classify_ticket(&keys, TicketId(7), &HashMap::new()),
None
);
}
// ---- graph -> ticket params -----------------------------------------
/// Build a track node holding `blocks` (mutated directly: the graph is
/// only inspected by the montage builders, never rendered here).
fn track_with(project: &ProjectRef, kind: TrackType, blocks: Vec<NodeId>) -> NodeId {
let mut guard = project.lock().unwrap();
let track = guard
.graph
.add_node(NodeCore::new(), Box::new(TrackBehavior::new(kind)));
if let Some(t) = guard
.graph
.get_mut(track)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<TrackBehavior>())
{
t.blocks = blocks;
}
track
}
fn track_list_with(project: &ProjectRef, kind: TrackType, tracks: Vec<NodeId>) -> NodeId {
let mut guard = project.lock().unwrap();
let list = guard.graph.add_node(
NodeCore::new(),
Box::new(TrackListBehavior::new(kind)),
);
if let Some(l) = guard
.graph
.get_mut(list)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<TrackListBehavior>())
{
l.tracks = tracks;
}
list
}
fn push_track_list(project: &ProjectRef, sequence: NodeId, list: NodeId) {
let mut guard = project.lock().unwrap();
if let Some(seq) = guard
.graph
.get_mut(sequence)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
{
seq.track_lists.push(list);
}
}
#[test]
fn build_video_ticket_rejects_missing_and_non_viewer_nodes() {
let project = project();
for viewer in [NodeId::INVALID, add_folder(&project)] {
let task = RenderTask::new(
Task::new("t", None),
None,
(project.clone(), viewer),
ForceParams::default(),
None,
);
let error = task.build_video_ticket(Rational::new(0, 1)).unwrap_err();
assert!(
error.to_string().contains("viewer output"),
"viewer {viewer:?}: {error}"
);
}
}
#[test]
fn build_video_ticket_marshals_footage_force_params() {
let project = project();
let footage = add_footage(&project, "/tmp/oaktask-inline.mp4");
let mut task = RenderTask::new(
Task::new("t", None),
Some(VideoParams::new()),
(project.clone(), footage),
ForceParams {
force_width: 32,
force_height: 16,
force_format: PixelFormat::F32 as i32,
..ForceParams::default()
},
None,
);
task.set_render_inputs(0, false, TimeRange::default());
let params = task.build_video_ticket(Rational::new(1, 2)).unwrap();
assert_eq!(params.viewer, footage.identity());
assert_eq!(
params.footage,
Some(("/tmp/oaktask-inline.mp4".to_string(), 0))
);
assert!(params.montage.is_empty());
assert_eq!(params.force_size, Some((32, 16)));
assert_eq!(params.force_format, Some(PixelFormat::F32));
assert_eq!(params.cache, Some(footage.identity()));
assert_eq!(params.time, Rational::new(1, 2));
assert_eq!(params.project, task.viewer.0.lock().unwrap().uuid);
}
#[test]
fn video_montage_skips_degenerate_entries_and_keeps_valid_clips() {
let project = project();
let sequence = add_sequence(&project);
let footage = add_footage(&project, "valid.mp4");
let folder = add_folder(&project);
let ok = add_clip(
&project,
Some(footage),
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
);
let out_of_range = add_clip(
&project,
Some(footage),
TimeRange::new(Rational::new(10, 1), Rational::new(11, 1)),
);
let no_footage = add_clip(
&project,
None,
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
);
let wrong_footage = add_clip(
&project,
None,
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
);
crate::nodeops::clip_set_footage(&project, wrong_footage, folder);
// Track blocks: a non-block node, the valid clip, and every rejected
// clip kind.
let track = track_with(
&project,
TrackType::Video,
vec![folder, ok, out_of_range, no_footage, wrong_footage],
);
// A track list with bad track ids mixed in.
let list = track_list_with(
&project,
TrackType::Video,
vec![NodeId::INVALID, folder, track],
);
let audio_list = track_list_with(&project, TrackType::Audio, vec![track]);
// A valid id that is not a track list.
let not_a_list = folder;
// A valid id whose node was removed from the graph (the
// `graph.get` miss branch).
let removed = add_folder(&project);
{
let mut guard = project.lock().unwrap();
let _ = guard.graph.remove_node(removed);
assert!(removed.valid() && guard.graph.get(removed).is_none());
}
{
let mut guard = project.lock().unwrap();
if let Some(seq) = guard
.graph
.get_mut(sequence)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
{
seq.track_lists = vec![NodeId::INVALID, removed, not_a_list, audio_list, list];
}
}
let montage = RenderTask::video_montage(&project, sequence, Rational::new(0, 1));
assert_eq!(montage.len(), 1, "only the valid clip is kept");
assert_eq!(montage[0].filename, "valid.mp4");
assert_eq!(montage[0].in_time, Rational::new(0, 1));
assert_eq!(montage[0].out_time, Rational::new(1, 1));
assert_eq!(montage[0].media_in, Rational::new(0, 1));
assert_eq!(montage[0].gain, 1.0);
assert_eq!(montage[0].stream_index, 0);
// A time outside the clip's range yields an empty montage.
assert!(RenderTask::video_montage(&project, sequence, Rational::new(5, 1)).is_empty());
// Missing sequence and non-sequence viewers are empty, not panics.
assert!(RenderTask::video_montage(&project, NodeId::INVALID, Rational::new(0, 1)).is_empty());
assert!(RenderTask::video_montage(&project, folder, Rational::new(0, 1)).is_empty());
}
#[test]
fn audio_montage_filters_kinds_and_drops_effects() {
let project = project();
let sequence = add_sequence(&project);
let footage = add_footage(&project, "audio.mp4");
let folder = add_folder(&project);
let ok = add_clip(
&project,
Some(footage),
TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)),
);
let out_of_range = add_clip(
&project,
Some(footage),
TimeRange::new(Rational::new(9, 1), Rational::new(10, 1)),
);
let no_footage = add_clip(
&project,
None,
TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)),
);
let wrong_footage = add_clip(
&project,
None,
TimeRange::new(Rational::new(0, 1), Rational::new(2, 1)),
);
crate::nodeops::clip_set_footage(&project, wrong_footage, folder);
let track = track_with(
&project,
TrackType::Audio,
vec![NodeId::INVALID, folder, ok, out_of_range, no_footage, wrong_footage],
);
let audio_list = track_list_with(
&project,
TrackType::Audio,
vec![NodeId::INVALID, folder, track],
);
let video_list = track_list_with(&project, TrackType::Video, vec![track]);
let removed = add_folder(&project);
{
let mut guard = project.lock().unwrap();
let _ = guard.graph.remove_node(removed);
assert!(removed.valid() && guard.graph.get(removed).is_none());
}
{
let mut guard = project.lock().unwrap();
if let Some(seq) = guard
.graph
.get_mut(sequence)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
{
seq.track_lists = vec![video_list, NodeId::INVALID, removed, audio_list];
}
}
let montage = RenderTask::audio_montage(&project, sequence, Rational::new(1, 1));
assert_eq!(montage.len(), 1);
assert_eq!(montage[0].filename, "audio.mp4");
assert!(montage[0].effects.is_empty(), "audio carries no effects");
assert!(RenderTask::audio_montage(&project, sequence, Rational::new(5, 1)).is_empty());
assert!(RenderTask::audio_montage(&project, NodeId::INVALID, Rational::new(0, 1)).is_empty());
assert!(RenderTask::audio_montage(&project, folder, Rational::new(0, 1)).is_empty());
}
#[test]
fn build_audio_ticket_reads_sequence_params_and_montage() {
let project = project();
let sequence = add_sequence(&project);
let footage = add_footage(&project, "audio.mp4");
let clip = add_clip(
&project,
Some(footage),
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
);
let track = track_with(&project, TrackType::Audio, vec![clip]);
let list = track_list_with(&project, TrackType::Audio, vec![track]);
push_track_list(&project, sequence, list);
let task = RenderTask::new(
Task::new("t", None),
None,
(project.clone(), sequence),
ForceParams::default(),
None,
);
let params = task
.build_audio_ticket(TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)))
.unwrap();
assert_eq!(params.viewer, sequence.identity());
assert_eq!(params.sample_rate, 48000);
assert_eq!(params.channel_layout, 0x3);
assert_eq!(params.montage.len(), 1);
assert_eq!(params.montage[0].filename, "audio.mp4");
assert_eq!(params.range.in_(), Rational::new(0, 1));
// A footage viewer still yields a ticket (with an empty montage).
let footage_task = RenderTask::new(
Task::new("t", None),
None,
(project.clone(), footage),
ForceParams::default(),
None,
);
let params = footage_task
.build_audio_ticket(TimeRange::new(Rational::new(0, 1), Rational::new(1, 2)))
.unwrap();
assert!(params.montage.is_empty());
assert_eq!(params.sample_rate, 48000);
// A stale viewer falls back to the defaults too (no panic).
let stale = RenderTask::new(
Task::new("t", None),
None,
(project, NodeId::INVALID),
ForceParams::default(),
None,
);
let params = stale
.build_audio_ticket(TimeRange::new(Rational::new(0, 1), Rational::new(1, 2)))
.unwrap();
assert!(params.montage.is_empty());
}
#[test]
fn clip_effects_walks_chain_params_and_enable_state() {
let project = project();
let footage = add_footage(&project, "fx.mp4");
let folder = add_folder(&project);
// A node without an effect input ends the walk immediately.
{
let guard = project.lock().unwrap();
assert!(RenderTask::clip_effects(&guard.graph, footage).is_empty());
assert!(RenderTask::clip_effects(&guard.graph, folder).is_empty());
assert!(RenderTask::clip_effects(&guard.graph, NodeId::INVALID).is_empty());
}
// A clip whose effect input is set but not connected: the walk's
// no-upstream break.
let bare = add_clip(
&project,
None,
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
);
{
let guard = project.lock().unwrap();
assert!(RenderTask::clip_effects(&guard.graph, bare).is_empty());
}
// Direct footage -> clip connection: the source node is dropped
// (the montage decodes the footage itself).
let direct = add_clip(
&project,
Some(footage),
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
);
{
let mut guard = project.lock().unwrap();
guard
.graph
.connect(footage, direct, oak_node::block::clip_input::TEXTURE_INPUT, -1)
.expect("connect footage");
}
{
let guard = project.lock().unwrap();
assert!(RenderTask::clip_effects(&guard.graph, direct).is_empty());
}
for enabled in [true, false] {
let clip = add_clip(&project, None, TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)));
let (ecore, ebehavior) = oak_node::factory::Factory::global()
.create_any("org.olivevideoeditor.Olive.invert")
.expect("invert registered");
let mut guard = project.lock().unwrap();
let effect = guard.graph.add_node(ecore, ebehavior);
guard
.graph
.get_mut(effect)
.unwrap()
.core
.set_standard_value(
oak_node::node::ENABLED_INPUT,
-1,
NodeValue::Boolean(enabled),
);
guard
.graph
.connect(footage, effect, "tex_in", -1)
.expect("footage -> effect");
guard
.graph
.connect(
effect,
clip,
oak_node::block::clip_input::TEXTURE_INPUT,
-1,
)
.expect("effect -> clip");
let effects = RenderTask::clip_effects(&guard.graph, clip);
assert_eq!(effects.len(), 1);
assert_eq!(
effects[0].type_id,
"org.olivevideoeditor.Olive.invert"
);
assert_eq!(effects[0].enabled, enabled);
assert_eq!(effects[0].effect_input_id.as_deref(), Some("tex_in"));
assert!(
!effects[0].params.is_empty(),
"the four channel toggles are collected"
);
}
// A transform effect contributes scalars but skips its texture and
// matrix inputs; a blur effect additionally skips its hidden
// method-specific inputs; a volume effect skips its samples input.
for (type_name, connect_input, skipped) in [
("org.olivevideoeditor.Olive.transform", "tex_in", "parent_in"),
(
"org.olivevideoeditor.Olive.blur",
"tex_in",
"directional_degrees_in",
),
(
"org.olivevideoeditor.Olive.volume",
"samples_in",
"samples_in",
),
] {
let clip = add_clip(
&project,
None,
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
);
let (ecore, ebehavior) = oak_node::factory::Factory::global()
.create_any(type_name)
.expect("effect registered");
let mut guard = project.lock().unwrap();
let effect = guard.graph.add_node(ecore, ebehavior);
guard
.graph
.connect(footage, effect, connect_input, -1)
.expect("footage -> effect");
guard
.graph
.connect(
effect,
clip,
oak_node::block::clip_input::TEXTURE_INPUT,
-1,
)
.expect("effect -> clip");
let effects = RenderTask::clip_effects(&guard.graph, clip);
assert_eq!(effects.len(), 1, "{type_name}");
assert_eq!(effects[0].type_id, type_name);
assert!(
effects[0].params.iter().all(|(id, _)| id != skipped),
"{type_name}: {skipped} must be skipped"
);
assert!(
effects[0].params.iter().all(|(id, _)| id != "tex_in"),
"{type_name}: texture inputs must be skipped"
);
}
}
// ---- dispatch channel ------------------------------------------------
#[test]
fn dispatch_queue_pops_in_order_and_counts_running() {
let dispatch = Box::into_raw(Box::new(RenderDispatch::new()));
let audio = || {
TicketPayload::Audio(AudioSamples {
samples: vec![0.25, -0.25],
sample_rate: 48000,
channel_layout: 0x3,
channel_count: 2,
})
};
// Empty queue -> None.
assert!(unsafe { (*dispatch).pop_finished() }.is_none());
unsafe {
(*dispatch).running.store(2, Ordering::SeqCst);
}
push_finished(TicketId(1), Ok(audio()), DispatchPtr(dispatch));
push_finished(TicketId(2), Ok(audio()), DispatchPtr(dispatch));
assert_eq!(unsafe { (*dispatch).running.load(Ordering::SeqCst) }, 0);
let first = unsafe { (*dispatch).pop_finished() };
assert!(matches!(first, Some((TicketId(1), Ok(TicketPayload::Audio(_))))));
let second = unsafe { (*dispatch).pop_finished() };
assert!(matches!(second, Some((TicketId(2), Ok(TicketPayload::Audio(_))))));
assert!(unsafe { (*dispatch).pop_finished() }.is_none());
unsafe {
drop(Box::from_raw(dispatch));
}
}
#[test]
fn wait_idle_returns_when_callbacks_finish() {
let dispatch = RenderDispatch::new();
dispatch.running.store(1, Ordering::SeqCst);
let calls = AtomicUsize::new(0);
let pump = || {
// The first pump observes an in-flight ticket (so `wait_idle`
// takes its wait branch); the second one completes it. No
// ticket thread is involved, so the test stays deterministic.
if calls.fetch_add(1, Ordering::SeqCst) >= 1 {
dispatch.running.store(0, Ordering::SeqCst);
dispatch.cv.notify_all();
}
};
dispatch.wait_idle(&pump);
assert!(calls.load(Ordering::SeqCst) >= 2, "the wait branch ran");
}
#[test]
fn wait_idle_breaks_immediately_when_idle() {
let dispatch = RenderDispatch::new();
let calls = AtomicUsize::new(0);
dispatch.wait_idle(&|| {
calls.fetch_add(1, Ordering::SeqCst);
});
assert_eq!(calls.load(Ordering::SeqCst), 1, "pump once, then break");
}
}
+306
View File
@@ -0,0 +1,306 @@
// 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/>.
//! Boundary tests for the codec task submitter bridge: register/unregister
//! state handling and the synchronous `submit_codec_task` dispatch for
//! conform (real clip success, missing media, bad output name, failed
//! rename) and proxy (missing source) requests.
//!
//! The bridge collapses inner task failures into one codec error per kind
//! (the interim synchronous contract), so the failure-path tests assert
//! that outer variant/message here and the distinguishing reason on the
//! task base through `conform_directly`/`proxy_directly`.
//!
//! The oakcodec submit registry is process-global, so the tests in this
//! file serialize on one mutex.
use std::sync::{Mutex, MutexGuard};
use oak_codec::task::{submit_task, TaskKind, TaskRequest};
use oak_task::codecbridge::{
is_codec_task_submitter_registered, register_codec_task_submitter,
unregister_codec_task_submitter,
};
use oak_task::conform::ConformTask;
use oak_task::error::Error as TaskError;
use oak_task::proxy::{ProxyParams, ProxyTask};
use oak_task::task::{Task, TaskBehavior};
/// Tests that mutate oakcodec's global submit callback must not race.
static REG_LOCK: Mutex<()> = Mutex::new(());
fn serial() -> MutexGuard<'static, ()> {
REG_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// Run a conform request directly through [`ConformTask`], returning
/// `(result, task error message)`.
///
/// The bridge collapses every inner failure into one codec error (the
/// interim synchronous contract's "conform task failed"), so the
/// distinguishing reason is asserted here, on the task base the behavior
/// writes — a bad output name must fail the name contract, not the probe.
fn conform_directly(input: &str, output: &str) -> (oak_task::error::Result<()>, Option<String>) {
let req = request(TaskKind::Conform, input, output);
let mut inner = ConformTask::new(&req);
let mut driver = Task::new("conform", None);
let result = inner.run(&mut driver);
(result, driver.error().map(str::to_string))
}
/// Run a proxy request directly through [`ProxyTask`] (missing sources fail
/// in the ffmpeg launch/run), returning the task error message.
fn proxy_directly(input: &str, output: &str) -> Option<String> {
let req = request(TaskKind::Proxy, input, output);
let params = ProxyParams {
width: 320,
height: 180,
divider: 1,
version: 1,
crf: 23,
include_audio: false,
extension: "mp4".to_string(),
preset: "veryfast".to_string(),
};
let mut inner = ProxyTask::new(&req, params);
let mut driver = Task::new("proxy", None);
let _ = inner.run(&mut driver);
driver.error().map(str::to_string)
}
/// Assert the bridge rejected a submission with the expected `Failed`
/// message (the codec error variant is always `Failed`; the per-path
/// reason is asserted through [`conform_directly`]/[`proxy_directly`]).
fn assert_bridge_failed(context: &str, result: oak_codec::error::Result<bool>) -> String {
match result {
Err(oak_codec::error::Error::Failed(message)) => message,
other => panic!("{context}: expected a Failed submission error, got {other:?}"),
}
}
fn work_dir(tag: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!("oaktask_bridge_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("work dir");
path
}
fn write_clip(dir: &std::path::Path, name: &str) -> std::path::PathBuf {
let path = dir.join(name);
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip generation");
path
}
fn request<'a>(kind: TaskKind, input: &'a str, output: &'a str) -> TaskRequest<'a> {
TaskRequest {
kind,
input_filename: input,
output_filename: output,
stream_index: 1,
sample_rate: 48000,
channel_layout: 0x3,
sample_format: 4,
proxy_width: 0,
proxy_height: 0,
}
}
// ---- registration -----------------------------------------------------
/// Given no callback, `submit_task` reports "nothing submitted"; the bridge
/// registers exactly once and unregisters idempotently.
#[test]
fn submitter_registration_lifecycle() {
let _guard = serial();
unregister_codec_task_submitter().expect("clean registry");
assert!(!is_codec_task_submitter_registered());
let req = request(TaskKind::Conform, "in.mp4", "out.0.pcm");
assert!(
!submit_task(&req).expect("no callback is not an error"),
"nothing is submitted without a callback"
);
register_codec_task_submitter().expect("first registration");
assert!(is_codec_task_submitter_registered());
assert!(matches!(
register_codec_task_submitter(),
Err(TaskError::State)
));
unregister_codec_task_submitter().expect("unregister");
assert!(!is_codec_task_submitter_registered());
unregister_codec_task_submitter().expect("idempotent unregister");
assert!(!submit_task(&req).expect("no callback"));
}
// ---- conform ----------------------------------------------------------
/// A real test clip conforms into per-channel PCM files through the bridge.
#[test]
fn conform_submission_writes_pcm_files() {
let _guard = serial();
unregister_codec_task_submitter().expect("clean registry");
register_codec_task_submitter().expect("register");
let dir = work_dir("conform_ok");
let clip = write_clip(&dir, "clip.mp4");
let out = dir.join("audio.0.pcm");
let clip_str = clip.to_string_lossy();
let out_str = out.to_string_lossy();
let req = request(TaskKind::Conform, &clip_str, &out_str);
let submitted = submit_task(&req);
assert!(submitted.is_ok(), "conform must be accepted: {submitted:?}");
// The final per-channel files exist (renamed from the working names).
for channel in 0..2 {
let final_name = dir.join(format!("audio.{channel}.pcm"));
let meta = std::fs::metadata(&final_name)
.unwrap_or_else(|e| panic!("{} missing: {e}", final_name.display()));
assert!(meta.len() > 0, "conform output is empty");
assert!(
!dir.join(format!("audio.{channel}.pcm.working")).exists(),
"working files are renamed away"
);
}
unregister_codec_task_submitter().expect("unregister");
let _ = std::fs::remove_dir_all(&dir);
}
/// A missing source file is rejected (no decoder can probe it); the
/// path-specific reason is distinct from a bad-output-name failure.
#[test]
fn conform_submission_rejects_missing_media() {
let _guard = serial();
unregister_codec_task_submitter().expect("clean registry");
register_codec_task_submitter().expect("register");
let dir = work_dir("conform_missing");
let out = dir.join("audio.0.pcm");
let out_str = out.to_string_lossy();
let req = request(TaskKind::Conform, "/definitely/not/here.mp4", &out_str);
assert_eq!(
assert_bridge_failed("missing media", submit_task(&req)),
"conform task failed"
);
// The reason is the probe: no registered decoder accepts the input.
let (result, error) = conform_directly("/definitely/not/here.mp4", &out_str);
assert!(result.is_err());
assert_eq!(error.as_deref(), Some("Failed to create decoder"));
unregister_codec_task_submitter().expect("unregister");
let _ = std::fs::remove_dir_all(&dir);
}
/// An output name that does not follow the `.0.pcm` contract is rejected
/// before any media work: the failure is the name contract, not a probe
/// failure.
#[test]
fn conform_submission_rejects_a_bad_output_name() {
let _guard = serial();
unregister_codec_task_submitter().expect("clean registry");
register_codec_task_submitter().expect("register");
let dir = work_dir("conform_bad_name");
let clip = write_clip(&dir, "clip.mp4");
let out = dir.join("audio.pcm");
let clip_str = clip.to_string_lossy();
let out_str = out.to_string_lossy();
let req = request(TaskKind::Conform, &clip_str, &out_str);
assert_eq!(
assert_bridge_failed("bad output name", submit_task(&req)),
"conform task failed"
);
// Directly: the rejection names the filename contract, so a probe
// regression could not masquerade as this failure path.
let (result, error) = conform_directly(&clip_str, &out_str);
assert!(result.is_err());
assert_eq!(error.as_deref(), Some("Invalid conform output filename"));
unregister_codec_task_submitter().expect("unregister");
let _ = std::fs::remove_dir_all(&dir);
}
/// A rename failure while moving the working files into place fails the
/// task after the audio was conformed — a third, distinct reason.
#[test]
fn conform_submission_reports_a_failed_rename() {
let _guard = serial();
unregister_codec_task_submitter().expect("clean registry");
register_codec_task_submitter().expect("register");
let dir = work_dir("conform_rename");
let clip = write_clip(&dir, "clip.mp4");
// The final first-channel path is an existing directory: the file
// rename onto it must fail.
std::fs::create_dir(dir.join("audio.0.pcm")).expect("blocking directory");
let out = dir.join("audio.0.pcm");
let clip_str = clip.to_string_lossy();
let out_str = out.to_string_lossy();
let req = request(TaskKind::Conform, &clip_str, &out_str);
assert_eq!(
assert_bridge_failed("failed rename", submit_task(&req)),
"conform task failed"
);
// Directly: the conform succeeded and only the rename failed.
let (result, error) = conform_directly(&clip_str, &out_str);
assert!(result.is_err());
assert_eq!(
error.as_deref(),
Some("Failed to move conformed audio into place")
);
unregister_codec_task_submitter().expect("unregister");
let _ = std::fs::remove_dir_all(&dir);
}
// ---- proxy ------------------------------------------------------------
/// A proxy request with a missing source is rejected: the proxy params
/// conversion runs, then the transcode (or the ffmpeg lookup) fails with
/// an ffmpeg-side reason.
#[test]
fn proxy_submission_rejects_a_missing_source() {
let _guard = serial();
unregister_codec_task_submitter().expect("clean registry");
register_codec_task_submitter().expect("register");
let dir = work_dir("proxy_missing");
let out = dir.join("proxy.mp4");
let out_str = out.to_string_lossy();
let req = request(TaskKind::Proxy, "/definitely/not/here.mp4", &out_str);
assert_eq!(
assert_bridge_failed("missing source", submit_task(&req)),
"proxy task failed"
);
// Directly: the reason names the ffmpeg failure (missing binary or a
// non-zero transcode), never a conform-side message.
let error = proxy_directly("/definitely/not/here.mp4", &out_str);
assert!(
error
.as_deref()
.is_some_and(|message| message.contains("ffmpeg")),
"the proxy failure reason is ffmpeg-side: {error:?}"
);
unregister_codec_task_submitter().expect("unregister");
let _ = std::fs::remove_dir_all(&dir);
}
+241
View File
@@ -0,0 +1,241 @@
// 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/>.
//! Boundary tests for `ProjectImportTask` (media import): valid and
//! invalid files, directories (recursive folder creation), duplicate
//! paths, the produced undo command, and cancellation.
//!
//! Media comes from `oak_codec::testmedia`, so the tests run headless.
use oak_node::folder::FolderBehavior;
use oak_node::id::NodeId;
use oak_node::project::Project;
use oak_task::nodeops::{self, ProjectRef};
use oak_task::project::import::ProjectImportTask;
use oak_task::task::{Task, TaskBehavior};
fn work_dir(tag: &str) -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!(
"oaktask_import_{tag}_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("work dir");
path
}
fn write_clip(dir: &std::path::Path, name: &str) -> std::path::PathBuf {
let path = dir.join(name);
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip generation");
path
}
/// A fresh project with its initialized root folder.
fn project() -> (ProjectRef, NodeId) {
let project = Project::new();
let root = {
let mut p = project.lock().unwrap();
p.initialize().expect("root folder");
p.root
};
(project, root)
}
fn run_import(
task: &mut ProjectImportTask,
driver: &mut Task,
) -> oak_task::error::Result<()> {
task.run(driver)
}
fn folder_children(project: &ProjectRef, folder: NodeId) -> Vec<NodeId> {
let guard = project.lock().unwrap_or_else(|e| e.into_inner());
guard
.graph
.get(folder)
.and_then(|e| e.behavior.as_any())
.and_then(|a| a.downcast_ref::<FolderBehavior>())
.map(|f| f.children.clone())
.unwrap_or_default()
}
#[test]
fn import_mixes_valid_and_invalid_files() {
let dir = work_dir("mixed");
let media = write_clip(&dir, "clip.mp4");
let missing = dir.join("missing.mp4");
let (project, root) = project();
let mut task = ProjectImportTask::new(
Task::new("Import", None),
(project.clone(), root),
project.clone(),
vec![
media.to_string_lossy().into_owned(),
missing.to_string_lossy().into_owned(),
],
None,
2,
);
let mut driver = Task::new("Import", None);
assert!(run_import(&mut task, &mut driver).is_ok());
assert_eq!(task.file_count(), 2);
assert_eq!(task.get_file_count(), 1, "one footage imported");
assert_eq!(task.get_invalid_file_count(), 1, "one file failed");
assert!(task.has_invalid_files());
assert!(!task.get_imported_footage(0).is_err());
assert!(task.get_imported_footage(1).is_err(), "out of range");
let (project_ref, footage) = task.get_imported_footage(0).expect("footage");
assert_eq!(nodeops::footage_filename(&project_ref, footage), media.to_string_lossy());
assert!(nodeops::footage_is_valid(&project_ref, footage));
// The task's command adds the footage to the destination folder; undo
// removes it again.
let mut cmd = task.take_command().expect("command produced");
assert!(folder_children(&project, root).is_empty());
cmd.redo_now();
assert_eq!(folder_children(&project, root), vec![footage]);
cmd.undo_now();
assert!(folder_children(&project, root).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_duplicate_paths_create_one_footage_each() {
let dir = work_dir("dupes");
let media = write_clip(&dir, "clip.mp4");
let path = media.to_string_lossy().into_owned();
let (project, root) = project();
let mut task = ProjectImportTask::new(
Task::new("Import", None),
(project.clone(), root),
project.clone(),
vec![path.clone(), path],
None,
2,
);
let mut driver = Task::new("Import", None);
assert!(run_import(&mut task, &mut driver).is_ok());
assert_eq!(task.get_file_count(), 2, "each entry imports");
assert_eq!(task.get_invalid_file_count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_directory_creates_a_labeled_folder_recursively() {
let dir = work_dir("directory");
let sub = dir.join("Shots");
std::fs::create_dir_all(&sub).expect("subdir");
write_clip(&sub, "a.mp4");
write_clip(&sub, "b.mp4");
let (project, root) = project();
let mut task = ProjectImportTask::new(
Task::new("Import", None),
(project.clone(), root),
project.clone(),
vec![sub.to_string_lossy().into_owned()],
None,
2,
);
let mut driver = Task::new("Import", None);
assert!(run_import(&mut task, &mut driver).is_ok());
assert_eq!(task.get_file_count(), 2, "directory children imported");
assert_eq!(task.get_invalid_file_count(), 0);
// The directory became a labeled sub-folder holding both footages.
let mut cmd = task.take_command().expect("command produced");
cmd.redo_now();
let children = folder_children(&project, root);
assert_eq!(children.len(), 1, "one folder added");
let sub_folder = children[0];
assert_eq!(nodeops::node_label(&project, sub_folder), "Shots");
assert_eq!(folder_children(&project, sub_folder).len(), 2);
cmd.undo_now();
assert!(folder_children(&project, root).is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_empty_directory_imports_nothing() {
let dir = work_dir("empty");
let empty = dir.join("Nothing");
std::fs::create_dir_all(&empty).expect("empty dir");
let (project, root) = project();
let mut task = ProjectImportTask::new(
Task::new("Import", None),
(project.clone(), root),
project.clone(),
vec![empty.to_string_lossy().into_owned()],
None,
0,
);
let mut driver = Task::new("Import", None);
assert!(run_import(&mut task, &mut driver).is_ok());
assert_eq!(task.get_file_count(), 0);
assert_eq!(task.get_invalid_file_count(), 0);
assert!(task.take_command().is_ok(), "an (empty) command is still produced");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_cancel_reports_cancelled_and_yields_no_command() {
let dir = work_dir("cancel");
let media = write_clip(&dir, "clip.mp4");
let (project, root) = project();
let mut task = ProjectImportTask::new(
Task::new("Import", None),
(project.clone(), root),
project.clone(),
vec![media.to_string_lossy().into_owned()],
None,
1,
);
let mut driver = Task::new("Import", None);
driver.cancel();
let result = run_import(&mut task, &mut driver);
assert!(
matches!(result, Err(oak_task::error::Error::Cancelled)),
"a cancelled import must fail with Cancelled: {result:?}"
);
assert!(task.take_command().is_err(), "no command after cancellation");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn take_command_before_a_run_reports_state() {
let (project, root) = project();
let mut task = ProjectImportTask::new(
Task::new("Import", None),
(project.clone(), root),
project.clone(),
Vec::new(),
None,
0,
);
assert!(task.take_command().is_err());
}
+483
View File
@@ -0,0 +1,483 @@
// 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/>.
//! Boundary tests for the project load path:
//!
//! - `LoadOTIOTask` (`.otio` / `.fcpxml`) round-trips a saved project
//! through the real interchange files.
//! - `ProjectLoadTask` (the XML `.oakproj` path) handles valid, missing
//! and corrupt documents.
//! - Error contract: missing file, corrupt document, unsupported
//! extension, `take_project` before a run, and the import-confirmation
//! callback (accept / reject / global reset).
//!
//! The interchange save path is the production `SaveOTIOTask`; the media
//! is a generated test clip, so the tests run headless with no fixtures.
use std::sync::{Arc, Mutex};
use oak_core::{Rational, TimeRange};
use oak_node::block::ClipBlockBehavior;
use oak_node::footage::FootageBehavior;
use oak_node::id::NodeId;
use oak_node::node::NodeCore;
use oak_node::project::Project;
use oak_node::sequence::SequenceBehavior;
use oak_node::track::{TrackBehavior, TrackListBehavior, TrackType};
use oak_task::nodeops;
use oak_task::project::load::{ProjectLoadBaseTask, ProjectLoadTask};
use oak_task::project::loadotio::{set_import_confirm_callback, LoadOTIOTask};
use oak_task::project::saveotio::SaveOTIOTask;
use oak_task::task::{Task, TaskBehavior};
/// Tests that install the process-wide import-confirmation callback must
/// not race each other.
static CALLBACK_LOCK: Mutex<()> = Mutex::new(());
fn work_path(tag: &str, ext: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("oaktask_load_{tag}_{}.{ext}", std::process::id()))
}
fn write_clip(tag: &str) -> std::path::PathBuf {
let path = work_path(tag, "mp4");
oak_codec::testmedia::write_test_clip(&path, 64, 64, 10, 10).expect("test clip generation");
path
}
/// One video track carrying one clip of `footage` covering `[in, out)`.
fn add_track_with_clip(
p: &mut Project,
footage: NodeId,
kind: TrackType,
in_: Rational,
out: Rational,
) -> NodeId {
let (tcore, tbehavior) = TrackBehavior::create();
let track = p.graph.add_node(tcore, tbehavior);
p.graph
.get_mut(track)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackBehavior>()
.unwrap()
.kind = kind;
let (ccore, cbehavior) = oak_node::block::clip_create();
let clip = p.graph.add_node(ccore, cbehavior);
p.graph
.connect(footage, clip, oak_node::block::clip_input::TEXTURE_INPUT, -1)
.expect("connect footage to clip");
p.graph
.get_mut(clip)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<ClipBlockBehavior>()
.expect("clip block")
.core
.range = TimeRange::new(in_, out);
p.graph
.get_mut(track)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackBehavior>()
.unwrap()
.append_block(clip);
track
}
/// A project with one "Edited" sequence (one video track with one 1s clip,
/// one audio track with the same clip) rooted in the bin folder.
fn build_project(media: &str) -> Arc<Mutex<Project>> {
let project = Project::new();
{
let mut p = project.lock().unwrap();
p.initialize().expect("root folder");
let footage = {
let mut f = FootageBehavior::new(media);
f.probe().expect("probe the generated clip");
p.graph.add_node(NodeCore::new(), Box::new(f))
};
let (score, sbehavior) = SequenceBehavior::create();
let edited = p.graph.add_node(score, sbehavior);
{
let video_track = add_track_with_clip(
&mut p,
footage,
TrackType::Video,
Rational::new(0, 1),
Rational::new(1, 1),
);
let audio_track = add_track_with_clip(
&mut p,
footage,
TrackType::Audio,
Rational::new(0, 1),
Rational::new(1, 1),
);
for (kind, tracks) in [
(TrackType::Video, vec![video_track]),
(TrackType::Audio, vec![audio_track]),
] {
let (lcore, lbehavior) = TrackListBehavior::create();
let list = p.graph.add_node(lcore, lbehavior);
{
let list_b = p
.graph
.get_mut(list)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<TrackListBehavior>()
.unwrap();
list_b.kind = kind;
list_b.tracks = tracks;
}
p.graph
.get_mut(edited)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<SequenceBehavior>()
.unwrap()
.track_lists
.push(list);
}
p.graph.get_mut(edited).unwrap().core.label = "Edited".to_string();
}
let root = p.root;
p.graph
.get_mut(root)
.unwrap()
.behavior
.as_any_mut()
.unwrap()
.downcast_mut::<oak_node::folder::FolderBehavior>()
.expect("root folder")
.children
.push(edited);
}
project
}
/// Save `project` through the production interchange task.
fn save(project: &Arc<Mutex<Project>>, filename: &std::path::Path) {
let mut driver = Task::new("Saving project...", None);
driver.set_behavior(Box::new(SaveOTIOTask {
base: Task::new("Saving project...", None),
project: project.clone(),
filename: filename.to_string_lossy().into_owned(),
}));
if let Err(e) = driver.start() {
panic!(
"save to {} failed: {e:?} / {}",
filename.display(),
driver.error().unwrap_or("unknown error")
);
}
}
/// Run the OTIO loader on `filename`; `(task error, stored project)`.
fn load_otio(filename: &std::path::Path) -> (Option<String>, Option<Arc<Mutex<Project>>>) {
let mut task = Task::new("Loading project...", None);
let mut loader = LoadOTIOTask::new(ProjectLoadBaseTask::new(
Task::new("Loading project...", None),
filename.to_string_lossy().into_owned(),
));
let result = loader.run(&mut task);
let error = if result.is_err() {
Some(task.error().unwrap_or("unknown error").to_string())
} else {
None
};
(error, loader.base.take_project().ok())
}
/// The labels of every sequence in `project`.
fn sequence_labels(project: &Arc<Mutex<Project>>) -> Vec<String> {
let guard = project.lock().unwrap_or_else(|e| e.into_inner());
let mut labels = Vec::new();
for id in guard.graph.node_ids() {
if let Some(entry) = guard.graph.get(id) {
if entry
.behavior
.as_any()
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
.is_some()
{
labels.push(entry.core.label.clone());
}
}
}
labels
}
// ---- OTIO / FCPXML round trip -----------------------------------------
#[test]
fn otio_round_trip_preserves_the_sequence() {
let _guard = CALLBACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let media = write_clip("otio");
let project = build_project(&media.to_string_lossy());
let out = work_path("roundtrip", "otio");
let _ = std::fs::remove_file(&out);
save(&project, &out);
let (error, loaded) = load_otio(&out);
assert_eq!(error, None, "the saved document must load");
let loaded = loaded.expect("loaded project");
assert_eq!(sequence_labels(&loaded), vec!["Edited".to_string()]);
// The sequence carries the saved tracks and clips.
let (sequence, _) = {
let guard = loaded.lock().unwrap();
let mut found = None;
for id in guard.graph.node_ids() {
if guard
.graph
.get(id)
.and_then(|e| e.behavior.as_any())
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
.is_some()
{
found = Some(id);
break;
}
}
(found.expect("sequence"), ())
};
let video_list =
nodeops::sequence_track_list(&loaded, sequence, TrackType::Video).expect("video list");
let audio_list =
nodeops::sequence_track_list(&loaded, sequence, TrackType::Audio).expect("audio list");
assert_eq!(nodeops::tracklist_track_count(&loaded, video_list), 1);
assert_eq!(nodeops::tracklist_track_count(&loaded, audio_list), 1);
let track = nodeops::tracklist_track_at(&loaded, video_list, 0).expect("video track");
assert_eq!(nodeops::track_block_count(&loaded, track), 1);
let clip = nodeops::track_block_at(&loaded, track, 0).expect("clip");
assert_eq!(nodeops::block_in(&loaded, clip), Rational::new(0, 1));
assert_eq!(nodeops::block_length(&loaded, clip), Rational::new(1, 1));
let footage = nodeops::clip_footage(&loaded, clip).expect("clip footage link");
assert_eq!(
nodeops::footage_filename(&loaded, footage),
media.to_string_lossy()
);
let _ = std::fs::remove_file(&out);
let _ = std::fs::remove_file(&media);
}
#[test]
fn fcpxml_round_trip_preserves_the_sequence() {
let _guard = CALLBACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let media = write_clip("fcpxml");
let project = build_project(&media.to_string_lossy());
let out = work_path("roundtrip", "fcpxml");
let _ = std::fs::remove_file(&out);
save(&project, &out);
let (error, loaded) = load_otio(&out);
assert_eq!(error, None, "the saved FCPXML must load");
let loaded = loaded.expect("loaded project");
assert_eq!(sequence_labels(&loaded), vec!["Edited".to_string()]);
// The sequence carries the saved tracks and clips (the same structure
// the OTIO round trip asserts, through the FCPXML mapping).
let sequence = {
let guard = loaded.lock().unwrap();
let mut found = None;
for id in guard.graph.node_ids() {
if guard
.graph
.get(id)
.and_then(|e| e.behavior.as_any())
.and_then(|a| a.downcast_ref::<SequenceBehavior>())
.is_some()
{
found = Some(id);
break;
}
}
found.expect("sequence")
};
let video_list =
nodeops::sequence_track_list(&loaded, sequence, TrackType::Video).expect("video list");
let audio_list =
nodeops::sequence_track_list(&loaded, sequence, TrackType::Audio).expect("audio list");
assert_eq!(nodeops::tracklist_track_count(&loaded, video_list), 1);
assert_eq!(nodeops::tracklist_track_count(&loaded, audio_list), 1);
let track = nodeops::tracklist_track_at(&loaded, video_list, 0).expect("video track");
assert_eq!(nodeops::track_block_count(&loaded, track), 1);
let clip = nodeops::track_block_at(&loaded, track, 0).expect("clip");
assert_eq!(nodeops::block_in(&loaded, clip), Rational::new(0, 1));
assert_eq!(nodeops::block_length(&loaded, clip), Rational::new(1, 1));
let footage = nodeops::clip_footage(&loaded, clip).expect("clip footage link");
assert_eq!(
nodeops::footage_filename(&loaded, footage),
media.to_string_lossy()
);
let _ = std::fs::remove_file(&out);
let _ = std::fs::remove_file(&media);
}
// ---- error contract ---------------------------------------------------
#[test]
fn load_otio_rejects_a_missing_file() {
let _guard = CALLBACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let missing = work_path("missing", "otio");
let (error, loaded) = load_otio(&missing);
assert!(error.is_some(), "a missing document must fail");
assert!(loaded.is_none(), "no project is stored on failure");
}
#[test]
fn load_otio_rejects_a_corrupt_document() {
let _guard = CALLBACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let path = work_path("corrupt", "otio");
std::fs::write(&path, b"{ this is not json").expect("write corrupt file");
let (error, loaded) = load_otio(&path);
assert!(error.is_some(), "a corrupt document must fail");
assert!(loaded.is_none());
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_otio_rejects_an_unknown_extension() {
let _guard = CALLBACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let path = work_path("unknown", "txt");
std::fs::write(&path, b"{}").expect("write file");
let (error, loaded) = load_otio(&path);
let error = error.expect("unknown extension must fail");
assert!(
error.contains("Unknown project file format"),
"error names the supported formats: {error}"
);
assert!(loaded.is_none());
let _ = std::fs::remove_file(&path);
}
#[test]
fn take_project_before_a_run_reports_state() {
let mut base = ProjectLoadBaseTask::new(Task::new("load", None), "x.oakproj".into());
assert!(base.take_project().is_err());
}
#[test]
fn xml_loader_rejects_missing_and_corrupt_documents() {
let mut task = Task::new("load", None);
let mut load = ProjectLoadTask {
base: ProjectLoadBaseTask::new(Task::new("load", None), "/definitely/not/here.oakproj".into()),
};
assert!(load.run(&mut task).is_err());
assert!(task
.error()
.is_some_and(|e| e.contains("Failed to read file")));
let path = work_path("corrupt", "oakproj");
std::fs::write(&path, b"<not-oak>").expect("write corrupt xml");
let mut task = Task::new("load", None);
let mut load = ProjectLoadTask {
base: ProjectLoadBaseTask::new(
Task::new("load", None),
path.to_string_lossy().into_owned(),
),
};
assert!(load.run(&mut task).is_err());
assert!(task
.error()
.is_some_and(|e| e.contains("Failed to read XML document")));
let _ = std::fs::remove_file(&path);
}
#[test]
fn xml_loader_round_trips_a_saved_project() {
let media = write_clip("oakproj");
let project = build_project(&media.to_string_lossy());
let xml = {
let guard = project.lock().unwrap();
oak_node::serializer::save(&guard).expect("serialize project")
};
let path = work_path("roundtrip", "oakproj");
std::fs::write(&path, xml).expect("write project");
let mut task = Task::new("load", None);
let mut load = ProjectLoadTask {
base: ProjectLoadBaseTask::new(
Task::new("load", None),
path.to_string_lossy().into_owned(),
),
};
assert!(load.run(&mut task).is_ok(), "error: {:?}", task.error());
let loaded = load.base.take_project().expect("loaded project");
assert_eq!(sequence_labels(&loaded), vec!["Edited".to_string()]);
{
let guard = loaded.lock().unwrap();
assert_eq!(guard.filename(), path.to_string_lossy());
}
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&media);
}
// ---- import-confirmation callback -------------------------------------
#[test]
fn import_confirm_callback_can_reject_and_accept() {
let _guard = CALLBACK_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let media = write_clip("confirm");
let project = build_project(&media.to_string_lossy());
let out = work_path("confirm", "otio");
let _ = std::fs::remove_file(&out);
save(&project, &out);
// Rejecting: the task completes but hands no project to the caller.
let rejected: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let seen = rejected.clone();
set_import_confirm_callback(Some(Box::new(move |names| {
*seen.lock().unwrap() = names.to_vec();
false
})));
let (error, loaded) = load_otio(&out);
assert_eq!(error, None, "a user rejection is not a task error");
assert!(loaded.is_none(), "the rejected project is disposed of");
assert_eq!(*rejected.lock().unwrap(), vec!["Edited".to_string()]);
// Accepting: the same document loads normally.
set_import_confirm_callback(Some(Box::new(|_| true)));
let (error, loaded) = load_otio(&out);
assert_eq!(error, None);
assert!(loaded.is_some());
// Reset the global callback so other tests see the headless default.
set_import_confirm_callback(None);
let _ = std::fs::remove_file(&out);
let _ = std::fs::remove_file(&media);
}
+436
View File
@@ -0,0 +1,436 @@
// 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/>.
//! Boundary tests for the `TaskManager` singleton: init/shutdown state
//! machine, task ownership (add/cancel/drain/delete), not-found cases,
//! pointer lookups (find/take/cancel by address) and worker joins.
//!
//! The manager is process-wide, so every test serializes on one static
//! mutex and starts from a shut-down singleton. The worker behaviors use
//! atomics and `yield_now` spins — no sleeps.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use oak_task::error::Error;
use oak_task::manager::TaskManager;
use oak_task::task::{Task, TaskBehavior};
/// Tests that touch the process-wide manager must not race each other.
static MANAGER_LOCK: Mutex<()> = Mutex::new(());
fn serial() -> MutexGuard<'static, ()> {
MANAGER_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// Shut down any leftover singleton, then create a fresh one.
fn init_fresh() {
TaskManager::shutdown();
assert!(TaskManager::instance().is_none());
TaskManager::init().expect("manager init");
}
/// Bounded spin (no sleeps) until `flag` is set.
fn spin_until(flag: &AtomicBool) {
for _ in 0..4_000_000 {
if flag.load(Ordering::SeqCst) {
return;
}
std::thread::yield_now();
}
panic!("flag was not set in time");
}
/// Marks that the behavior entered `run`, then returns success. The flag
/// also makes `Task::wait_finished` race-free (it no-ops on a task whose
/// worker has not reached `start` yet).
struct Signal {
entered: Arc<AtomicBool>,
}
impl TaskBehavior for Signal {
fn run(&mut self, _task: &mut Task) -> Result<(), oak_task::error::Error> {
self.entered.store(true, Ordering::SeqCst);
Ok(())
}
}
/// Parks the worker until cancellation, recording both transitions.
struct Park {
started: Arc<AtomicBool>,
cancelled: Arc<AtomicBool>,
}
impl TaskBehavior for Park {
fn run(&mut self, task: &mut Task) -> Result<(), oak_task::error::Error> {
self.started.store(true, Ordering::SeqCst);
while !task.is_cancelled() {
std::thread::yield_now();
}
self.cancelled.store(true, Ordering::SeqCst);
Err(oak_task::task::cancelled())
}
}
/// Parks until an external release flag is set, then succeeds.
struct Gate {
started: Arc<AtomicBool>,
release: Arc<AtomicBool>,
}
impl TaskBehavior for Gate {
fn run(&mut self, _task: &mut Task) -> Result<(), oak_task::error::Error> {
self.started.store(true, Ordering::SeqCst);
while !self.release.load(Ordering::SeqCst) {
std::thread::yield_now();
}
Ok(())
}
}
/// Build a `Park` behavior task plus its observation flags.
fn park_task(tag: &str) -> (Task, Arc<AtomicBool>, Arc<AtomicBool>) {
let started = Arc::new(AtomicBool::new(false));
let cancelled = Arc::new(AtomicBool::new(false));
let mut task = Task::new(tag, None);
task.set_behavior(Box::new(Park {
started: started.clone(),
cancelled: cancelled.clone(),
}));
(task, started, cancelled)
}
/// Add `task` and return its raw pointer after waiting for the worker to
/// start running.
fn add_and_wait(task: Task, started: &AtomicBool) -> *mut Task {
TaskManager::with_manager_mut(|m| m.add_task(Box::new(task))).expect("manager present");
spin_until(started);
TaskManager::with_manager(|m| m.task_ptr_at(0))
.expect("manager present")
.expect("task 0")
}
fn task_ptr(index: usize) -> *mut Task {
TaskManager::with_manager(|m| m.task_ptr_at(index))
.expect("manager present")
.unwrap_or_else(|e| panic!("task {index} missing: {e:?}"))
}
// ---- singleton state --------------------------------------------------
/// Given a fresh singleton, `init` rejects duplicates and the accessors see
/// the manager; `shutdown` is idempotent and clears the instance.
#[test]
fn init_is_a_singleton_and_shutdown_is_idempotent() {
let _guard = serial();
init_fresh();
assert!(TaskManager::instance().is_some());
assert!(matches!(TaskManager::init(), Err(Error::State)));
assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(0));
assert_eq!(
TaskManager::with_manager_mut(|m| {
m.set_codec_submitter_registered(true);
m.codec_submitter_registered()
}),
Some(true)
);
TaskManager::shutdown();
assert!(TaskManager::instance().is_none());
assert!(TaskManager::with_manager(|m| m.get_task_count()).is_none());
assert!(TaskManager::with_manager_mut(|m| m.get_task_count()).is_none());
TaskManager::shutdown(); // idempotent
assert!(TaskManager::instance().is_none());
}
/// `init` does not register the codec task submitter: the manager flag
/// starts `false` while the oakcodec registry stays untouched, and the
/// flag is pure bookkeeping — flipping it installs nothing.
#[test]
fn init_does_not_register_the_codec_submitter() {
let _guard = serial();
init_fresh();
assert!(
!oak_task::codecbridge::is_codec_task_submitter_registered(),
"init() must not install the codec submitter"
);
assert_eq!(
TaskManager::with_manager(|m| m.codec_submitter_registered()),
Some(false),
"a fresh manager has not registered anything"
);
TaskManager::with_manager_mut(|m| m.set_codec_submitter_registered(true)).unwrap();
assert!(
!oak_task::codecbridge::is_codec_task_submitter_registered(),
"the bookkeeping flag never installs the oakcodec callback"
);
assert_eq!(
TaskManager::with_manager(|m| m.codec_submitter_registered()),
Some(true)
);
TaskManager::with_manager_mut(|m| m.set_codec_submitter_registered(false)).unwrap();
assert_eq!(
TaskManager::with_manager(|m| m.codec_submitter_registered()),
Some(false)
);
}
// ---- add / finish / drain --------------------------------------------
/// Given a task handed to the manager, the worker runs it; it can be found
/// by address, waited on, drained (with its join handle) and removed.
#[test]
fn add_task_runs_and_drain_finished_returns_the_pair() {
let _guard = serial();
init_fresh();
let entered = Arc::new(AtomicBool::new(false));
let mut task = Task::new("signal", None);
task.set_behavior(Box::new(Signal {
entered: entered.clone(),
}));
TaskManager::with_manager_mut(|m| m.add_task(Box::new(task))).expect("manager present");
spin_until(&entered);
let ptr = task_ptr(0);
// Deterministic completion wait through the task itself.
unsafe { (&*ptr).wait_finished() };
assert!(unsafe { (&*ptr).is_finished() });
assert!(unsafe { (&*ptr).succeeded() });
// The list still owns the task until drained.
assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(1));
assert_eq!(
TaskManager::with_manager(|m| m.find_index(ptr)),
Some(Some(0))
);
// An unrelated task is not in the list.
let unrelated = Task::new("unrelated", None);
let unrelated_ptr: *const Task = &unrelated;
assert_eq!(
TaskManager::with_manager(|m| m.find_index(unrelated_ptr)),
Some(None)
);
let drained = TaskManager::with_manager_mut(|m| m.drain_finished()).expect("manager present");
assert_eq!(drained.len(), 1, "the finished task and its thread");
for (_task, handle) in drained {
handle.join().expect("worker joined");
}
assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(0));
assert!(matches!(
TaskManager::with_manager(|m| m.task_ptr_at(0)),
Some(Err(Error::NotFound))
));
}
/// Ending a still-running task through `cancel_task` reports NotFound for
/// out-of-range indices; `cancel_task_and_wait` stops and joins the worker.
#[test]
fn cancel_task_and_wait_stops_a_running_task() {
let _guard = serial();
init_fresh();
let (task, started, cancelled) = park_task("park");
let ptr = add_and_wait(task, &started);
assert!(matches!(
TaskManager::with_manager_mut(|m| m.cancel_task(7)),
Some(Err(Error::NotFound))
));
assert!(matches!(
TaskManager::with_manager(|m| m.task_ptr_at(7)),
Some(Err(Error::NotFound))
));
TaskManager::with_manager_mut(|m| m.cancel_task_and_wait(0))
.expect("manager present")
.expect("cancel index 0");
assert!(cancelled.load(Ordering::SeqCst));
assert!(unsafe { (&*ptr).is_finished() });
assert!(!unsafe { (&*ptr).succeeded() });
// The handle was already joined by cancel_task_and_wait, so
// delete_finished silently removes the finished task.
assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(1));
TaskManager::with_manager_mut(|m| m.delete_finished()).expect("manager present");
assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(0));
}
/// `drain_finished` skips the still-running tasks and leaves them in the
/// list.
#[test]
fn drain_finished_keeps_running_tasks() {
let _guard = serial();
init_fresh();
// A finished task at index 0...
let entered = Arc::new(AtomicBool::new(false));
let mut done = Task::new("done", None);
done.set_behavior(Box::new(Signal {
entered: entered.clone(),
}));
TaskManager::with_manager_mut(|m| m.add_task(Box::new(done))).unwrap();
spin_until(&entered);
unsafe { (&*task_ptr(0)).wait_finished() };
// ...and a parked task at index 1.
let (parked, started, cancelled) = park_task("running");
TaskManager::with_manager_mut(|m| m.add_task(Box::new(parked))).unwrap();
spin_until(&started);
let drained = TaskManager::with_manager_mut(|m| m.drain_finished()).expect("manager present");
assert_eq!(drained.len(), 1, "only the finished task is drained");
for (_task, handle) in drained {
handle.join().expect("worker joined");
}
assert_eq!(
TaskManager::with_manager(|m| m.get_task_count()),
Some(1),
"the running task stays in the list"
);
TaskManager::shutdown();
assert!(cancelled.load(Ordering::SeqCst));
}
/// `delete_finished` also joins the handles `drain_finished` would return.
#[test]
fn delete_finished_removes_completed_tasks() {
let _guard = serial();
init_fresh();
for (index, tag) in ["a", "b"].into_iter().enumerate() {
let entered = Arc::new(AtomicBool::new(false));
let mut task = Task::new(tag, None);
task.set_behavior(Box::new(Signal {
entered: entered.clone(),
}));
TaskManager::with_manager_mut(|m| m.add_task(Box::new(task))).unwrap();
spin_until(&entered);
unsafe { (&*task_ptr(index)).wait_finished() };
}
assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(2));
TaskManager::with_manager_mut(|m| m.delete_finished()).unwrap();
assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(0));
}
// ---- pointer lookups --------------------------------------------------
/// `cancel_task_by_ptr` cancels a known task and no-ops for an absent one;
/// `take_thread_by_ptr` hands the handle out exactly once.
#[test]
fn cancel_and_take_by_pointer_handle_absent_targets() {
let _guard = serial();
init_fresh();
let (task, started, cancelled) = park_task("byptr");
let ptr = add_and_wait(task, &started);
// Known pointer: the worker thread moves out (once).
let handle = TaskManager::with_manager_mut(|m| m.take_thread_by_ptr(ptr))
.expect("manager present")
.expect("worker thread");
assert!(TaskManager::with_manager_mut(|m| m.take_thread_by_ptr(ptr))
.expect("manager present")
.is_none());
// Cancel through the pointer, then join lock-free.
TaskManager::with_manager_mut(|m| m.cancel_task_by_ptr(ptr)).expect("manager present");
handle.join().expect("worker joined");
assert!(cancelled.load(Ordering::SeqCst));
assert!(unsafe { (&*ptr).is_cancelled() });
// Absent pointer: both operations are no-ops.
let absent = Task::new("absent", None);
let absent_ptr: *const Task = &absent;
TaskManager::with_manager_mut(|m| m.cancel_task_by_ptr(absent_ptr)).expect("manager present");
assert!(
TaskManager::with_manager_mut(|m| m.take_thread_by_ptr(absent_ptr))
.expect("manager present")
.is_none()
);
assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(1));
TaskManager::with_manager_mut(|m| m.delete_finished()).unwrap();
assert_eq!(TaskManager::with_manager(|m| m.get_task_count()), Some(0));
}
/// `wait_finished` blocks on a live task until its behavior returns.
#[test]
fn wait_finished_blocks_until_the_behavior_returns() {
let _guard = serial();
init_fresh();
let started = Arc::new(AtomicBool::new(false));
let release = Arc::new(AtomicBool::new(false));
let mut task = Task::new("gate", None);
task.set_behavior(Box::new(Gate {
started: started.clone(),
release: release.clone(),
}));
TaskManager::with_manager_mut(|m| m.add_task(Box::new(task))).unwrap();
spin_until(&started);
let ptr = task_ptr(0);
// The waiter records whether the task was finished when
// `wait_finished` returned; it must not return while the gate is shut.
let entered = Arc::new(AtomicBool::new(false));
let waiter_entered = entered.clone();
let ptr_addr = ptr as usize;
let waiter = std::thread::spawn(move || {
let task = unsafe { &*(ptr_addr as *const Task) };
waiter_entered.store(true, Ordering::SeqCst);
task.wait_finished();
task.is_finished()
});
spin_until(&entered);
assert!(!waiter.is_finished(), "the gate is still shut");
release.store(true, Ordering::SeqCst);
assert!(
waiter.join().expect("waiter thread"),
"wait_finished must not return before the task finished"
);
assert!(unsafe { (&*ptr).succeeded() });
TaskManager::with_manager_mut(|m| m.delete_finished()).unwrap();
}
/// Dropping the manager (via `shutdown`) cancels every running task before
/// joining its worker.
#[test]
fn shutdown_cancels_running_tasks_before_joining() {
let _guard = serial();
init_fresh();
let (task, started, cancelled) = park_task("drop");
TaskManager::with_manager_mut(|m| m.add_task(Box::new(task))).unwrap();
spin_until(&started);
TaskManager::shutdown();
assert!(
cancelled.load(Ordering::SeqCst),
"the manager destructor cancels its tasks"
);
assert!(TaskManager::instance().is_none());
}
+293
View File
@@ -0,0 +1,293 @@
// 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/>.
//! Boundary tests for `PreCacheTask`: construction (probed footage, missing
//! media, invalid ids), a real one-frame render through the render loop, an
//! empty range, cancellation before the run, and the no-op download hooks.
//!
//! Renders run on the process-wide manager's test-only inline (`Threads`)
//! backend, serialized through one static mutex; the media is generated by
//! `oak_codec::testmedia` (no network, no fixtures). Nothing here needs a
//! GPU.
use std::sync::{Arc, Mutex, MutexGuard};
use oak_core::texture::{Frame, Texture};
use oak_core::Rational;
use oak_node::footage::FootageBehavior;
use oak_node::id::NodeId;
use oak_node::node::NodeCore;
use oak_node::project::Project;
use oak_node::sequence::SequenceBehavior;
use oak_render::manager::{RenderBackendChoice, RenderManager};
use oak_render::ticket::AudioSamples;
use oak_task::nodeops::{self, ProjectRef};
use oak_task::precache::PreCacheTask;
use oak_task::render::RenderTaskBehavior;
use oak_task::task::{Task, TaskBehavior, TaskEvent};
/// The manager is process-wide and the inline backend is test-only.
static RENDER_LOCK: Mutex<()> = Mutex::new(());
/// Serialize render-manager use and make sure the inline backend is up.
fn render_serial() -> MutexGuard<'static, ()> {
let guard = RENDER_LOCK.lock().unwrap_or_else(|e| e.into_inner());
if RenderManager::global().is_none() {
RenderManager::init_with_backend(RenderBackendChoice::Threads).expect("render manager");
}
guard
}
fn clip_path(tag: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!("oaktask_precache_{tag}_{}.mp4", std::process::id()))
}
/// A project with one probed footage node and one sequence whose video
/// timebase is 1 fps (so the 1 s test clip renders as exactly one frame).
fn project_with_footage(media: &str) -> (ProjectRef, NodeId, NodeId) {
let project = Project::new();
let mut p = project.lock().unwrap();
let mut footage = FootageBehavior::new(media);
footage.probe().expect("probe the generated clip");
let footage_id = p.graph.add_node(NodeCore::new(), Box::new(footage));
let (score, sbehavior) = SequenceBehavior::create();
let sequence = p.graph.add_node(score, sbehavior);
if let Some(seq) = p
.graph
.get_mut(sequence)
.and_then(|e| e.behavior.as_any_mut())
.and_then(|a| a.downcast_mut::<SequenceBehavior>())
{
seq.video_params[0].frame_rate = Rational::new(1, 1);
seq.video_params[0].width = 64;
seq.video_params[0].height = 64;
}
(project.clone(), footage_id, sequence)
}
/// Build a pre-cache task with a 64x64 forced render size (the sequence
/// default is 1080p; the forced size keeps the test frame small).
fn precache(project: &ProjectRef, footage: NodeId, sequence: NodeId, index: i32) -> PreCacheTask {
let mut task = PreCacheTask::new(
(project.clone(), footage),
index,
(project.clone(), sequence),
);
task.render.force_params.force_width = 64;
task.render.force_params.force_height = 64;
task
}
// ---- construction -----------------------------------------------------
/// The task label carries the footage filename and the frame index; the
/// public fields keep the inputs.
#[test]
fn new_labels_with_the_footage_filename_and_index() {
let media = clip_path("new");
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
let (project, footage, sequence) = project_with_footage(&media.to_string_lossy());
let task = PreCacheTask::new((project.clone(), footage), 3, (project.clone(), sequence));
assert_eq!(
task.render.base.title(),
format!("Pre-caching {}:3", media.to_string_lossy())
);
assert_eq!(task.index, 3);
assert!(Arc::ptr_eq(&task.footage.0, &project));
assert_eq!(task.footage.1, footage);
assert!(Arc::ptr_eq(&task.sequence.0, &project));
assert_eq!(task.sequence.1, sequence);
let _ = std::fs::remove_file(&media);
}
/// Missing media (an unprobed footage node) still constructs a labeled task
/// with a zero-length range.
#[test]
fn new_accepts_unprobed_and_missing_media() {
let (project, _, sequence) = project_with_footage_listless();
let footage =
nodeops::footage_create(&project, Some("/definitely/not/here.mp4")).expect("footage node");
let mut task = PreCacheTask::new((project.clone(), footage), 0, (project.clone(), sequence));
assert_eq!(
task.render.base.title(),
"Pre-caching /definitely/not/here.mp4:0"
);
assert_eq!(
nodeops::node_length(&project, footage),
Rational::new(0, 1),
"the unprobed footage has no length"
);
// Running over the empty range is a no-op success.
let _guard = render_serial();
let mut driver = Task::new("precache", None);
assert!(task.run(&mut driver).is_ok());
assert_eq!(task.render.total_frames(), 0);
assert!(driver.error().is_none());
}
/// A project with no footage; only the sequence is exercised here.
fn project_with_footage_listless() -> (ProjectRef, NodeId, NodeId) {
let project = Project::new();
let sequence = {
let mut p = project.lock().unwrap();
let (score, sbehavior) = SequenceBehavior::create();
p.graph.add_node(score, sbehavior)
};
(project, NodeId::INVALID, sequence)
}
// ---- run --------------------------------------------------------------
/// A probed 1 s clip renders exactly one frame and reports no error.
#[test]
fn run_renders_the_footage_frame() {
let media = clip_path("run");
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
let (project, footage, sequence) = project_with_footage(&media.to_string_lossy());
let mut task = precache(&project, footage, sequence, 0);
let _guard = render_serial();
let mut driver = Task::new("precache", None);
let result = task.run(&mut driver);
assert!(result.is_ok(), "run failed: {:?}", driver.error());
assert_eq!(
task.render.total_frames(),
1,
"the 1 s range at 1 fps holds one frame"
);
assert!(!driver.is_cancelled());
assert!(driver.error().is_none());
let _ = std::fs::remove_file(&media);
}
/// A stale footage id yields an empty range: the run succeeds without
/// submitting any frame.
#[test]
fn run_with_a_stale_footage_id_renders_nothing() {
let (project, _, sequence) = project_with_footage_listless();
let mut task = PreCacheTask::new(
(project.clone(), NodeId::INVALID),
0,
(project.clone(), sequence),
);
let _guard = render_serial();
let mut driver = Task::new("precache", None);
assert!(task.run(&mut driver).is_ok());
assert_eq!(task.render.total_frames(), 0);
assert_eq!(
nodeops::node_length(&project, NodeId::INVALID),
Rational::new(0, 1)
);
}
/// Cancelling before the run aborts the render with the cancellation error
/// and delivers no frame.
#[test]
fn run_cancelled_before_start_reports_cancelled() {
let media = clip_path("cancel");
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
let (project, footage, sequence) = project_with_footage(&media.to_string_lossy());
let mut task = precache(&project, footage, sequence, 0);
let _guard = render_serial();
let mut driver = Task::new("precache", None);
driver.cancel();
let result = task.run(&mut driver);
assert!(result.is_err(), "a cancelled render fails");
assert!(driver.is_cancelled());
assert_eq!(
task.render.total_frames(),
1,
"the frame count is computed before the cancellation check"
);
let _ = std::fs::remove_file(&media);
}
/// The download hooks are no-ops: each accepts its payload and reports
/// success without touching the driver.
#[test]
fn download_hooks_are_no_ops() {
let (project, _, sequence) = project_with_footage_listless();
let mut task = PreCacheTask::new(
(project.clone(), NodeId::INVALID),
0,
(project.clone(), sequence),
);
let mut driver = Task::new("precache", None);
let frame = Texture::Cpu(Frame::dummy());
assert!(task.frame_downloaded(&mut driver, &frame).is_ok());
let audio = AudioSamples {
samples: vec![0.0; 8],
sample_rate: 48000,
channel_layout: 0x3,
channel_count: 2,
};
assert!(task.audio_downloaded(&mut driver, &audio).is_ok());
assert!(task.encode_subtitle(&mut driver, "a subtitle").is_ok());
assert!(driver.error().is_none());
}
/// `run` plans the render over the full footage length and honors the
/// window/progress configurators: the single frame still renders with the
/// in-flight window pinned to one ticket, and disabled native progress
/// keeps the driver's progress silent.
#[test]
fn render_plan_covers_the_whole_footage() {
let media = clip_path("range");
oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip");
let (project, footage, sequence) = project_with_footage(&media.to_string_lossy());
let mut task = precache(&project, footage, sequence, 0);
let progress = Arc::new(Mutex::new(Vec::new()));
let sink = progress.clone();
task.render.set_max_inflight(1);
task.render.set_native_progress_signalling(false);
let _guard = render_serial();
let mut driver = Task::new("precache", None);
driver.set_event_listener(Box::new(move |event| {
if let TaskEvent::Progress(value) = event {
sink.lock().unwrap().push(value);
}
}));
let result = task.run(&mut driver);
assert!(result.is_ok(), "run failed: {:?}", driver.error());
// The 10-frame / 10 fps test clip is 1 s long, i.e. one frame at the
// sequence's 1 fps timebase; the one-ticket window still delivers it.
assert_eq!(
nodeops::node_length(&project, footage),
Rational::new(1, 1),
"the probed footage length"
);
assert_eq!(task.render.total_frames(), 1);
assert!(
progress.lock().unwrap().is_empty(),
"disabled native progress signalling emits nothing"
);
let _ = std::fs::remove_file(&media);
}
File diff suppressed because it is too large Load Diff
+324
View File
@@ -0,0 +1,324 @@
// 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/>.
//! Boundary tests for the `Task` base lifecycle: `start` with/without a
//! behavior, cancellation before/after the run, error propagation,
//! progress/event emission (including the one-shot listener), `reset`,
//! `wait_finished`, shared cancel atoms and subscriber state.
//!
//! Everything is synchronous and deterministic (no sleeps, no fixtures).
use std::sync::atomic::{AtomicI64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use oak_core::cancelatom::CancelAtom;
use oak_task::error::{Error, Result};
use oak_task::task::{cancelled, system_time_ms, SubscriberState, Task, TaskBehavior, TaskEvent};
// ---- behaviors --------------------------------------------------------
/// Returns success without doing anything.
struct Noop;
impl TaskBehavior for Noop {
fn run(&mut self, _task: &mut Task) -> Result<()> {
Ok(())
}
}
/// Records an error on the task and fails.
struct Fail;
impl TaskBehavior for Fail {
fn run(&mut self, task: &mut Task) -> Result<()> {
task.set_error("disk full");
Err(Error::Failed("disk full".to_string()))
}
}
/// Asserts cancellation was requested before the run and returns the
/// cancellation error.
struct CancelledBehavior;
impl TaskBehavior for CancelledBehavior {
fn run(&mut self, task: &mut Task) -> Result<()> {
assert!(
task.is_cancelled(),
"cancel must be visible to the behavior"
);
Err(cancelled())
}
}
/// Cancels itself mid-run and succeeds.
struct SelfCancel;
impl TaskBehavior for SelfCancel {
fn run(&mut self, task: &mut Task) -> Result<()> {
assert!(!task.is_cancelled());
task.cancel();
assert!(task.is_cancelled());
Ok(())
}
}
/// Emits out-of-range progress values so the clamping is observable.
struct Progressor;
impl TaskBehavior for Progressor {
fn run(&mut self, task: &mut Task) -> Result<()> {
task.emit_progress(0.25);
task.emit_progress(2.0);
task.emit_progress(-1.0);
Ok(())
}
}
fn task_with(behavior: impl TaskBehavior + Send + 'static) -> Task {
let mut task = Task::new("unit", None);
task.set_behavior(Box::new(behavior));
task
}
// ---- lifecycle --------------------------------------------------------
/// Given no behavior, `start` succeeds, marks the task finished/succeeded
/// and exposes an elapsed duration; the error and cancel state stay clean.
#[test]
fn start_without_a_behavior_succeeds() {
let mut task = Task::new("unit", None);
assert_eq!(task.title(), "unit");
assert!(!task.is_finished());
assert!(!task.succeeded());
assert!(!task.is_cancelled());
assert!(task.error().is_none());
assert!(task.elapsed().is_none(), "not started yet");
assert!(task.start().is_ok());
assert!(task.is_finished());
assert!(task.succeeded());
assert!(task.elapsed().is_some());
assert!(task.error().is_none());
}
/// Given a failing behavior, `start` returns the same error, the task is
/// finished but not succeeded, and the recorded error survives.
#[test]
fn failing_behavior_marks_failed_and_records_the_error() {
let mut task = task_with(Fail);
let result = task.start();
assert!(result.is_err());
assert!(task.is_finished());
assert!(!task.succeeded());
assert_eq!(task.error(), Some("disk full"));
}
/// Given a cancel before `start`, the behavior observes it and the task
/// fails with the cancellation error.
#[test]
fn cancel_before_start_is_visible_to_the_behavior() {
let mut task = task_with(CancelledBehavior);
task.cancel();
assert!(task.is_cancelled());
let result = task.start();
assert!(matches!(result, Err(Error::Cancelled)));
assert!(task.is_finished());
assert!(!task.succeeded());
}
/// Given a cancel event hook, cancelling fires it exactly once per call
/// (and the atom stays cancelled).
#[test]
fn cancel_fires_the_event_hook_and_is_idempotent() {
let fired = Arc::new(AtomicUsize::new(0));
let hook_flag = fired.clone();
let mut task = task_with(SelfCancel);
task.set_cancel_event(Box::new(move || {
hook_flag.fetch_add(1, Ordering::SeqCst);
}));
assert!(task.start().is_ok(), "the behavior itself succeeds");
assert_eq!(fired.load(Ordering::SeqCst), 1, "self-cancel fires once");
assert!(task.is_cancelled());
// A later cancel keeps firing the hook (the C++ cancel() contract).
task.cancel();
assert_eq!(fired.load(Ordering::SeqCst), 2);
}
/// Given a listener, the events arrive as Started, the clamped progress
/// values, then Finished — and the listener is dropped after the run
/// (one-shot subscription).
#[test]
fn events_are_ordered_clamped_and_one_shot() {
let events: Arc<Mutex<Vec<TaskEvent>>> = Arc::new(Mutex::new(Vec::new()));
let sink = events.clone();
let mut task = task_with(Progressor);
task.set_event_listener(Box::new(move |event| {
sink.lock().unwrap_or_else(|e| e.into_inner()).push(event);
}));
assert!(task.start().is_ok());
assert_eq!(
*events.lock().unwrap_or_else(|e| e.into_inner()),
vec![
TaskEvent::Started,
TaskEvent::Progress(0.25),
TaskEvent::Progress(1.0),
TaskEvent::Progress(0.0),
TaskEvent::Finished,
]
);
// One-shot: after `start` the listener is gone.
task.emit_progress(0.5);
assert_eq!(
events.lock().unwrap_or_else(|e| e.into_inner()).len(),
5,
"a finished task emits nothing to the dropped listener"
);
}
/// Given a failed run, `reset` clears every lifecycle flag and error so the
/// task can run again.
#[test]
fn reset_clears_state_and_allows_a_second_run() {
let mut task = task_with(Fail);
assert!(task.start().is_err());
assert!(task.error().is_some());
task.reset();
assert!(!task.is_finished());
assert!(!task.succeeded());
assert!(task.error().is_none());
assert!(task.elapsed().is_none());
task.set_behavior(Box::new(Noop));
assert!(task.start().is_ok());
assert!(task.succeeded());
}
/// `wait_finished` returns immediately for a task that was never started or
/// is already finished.
#[test]
fn wait_finished_returns_for_unstarted_and_finished_tasks() {
let never = Task::new("never", None);
never.wait_finished();
assert!(!never.is_finished());
let mut task = task_with(Noop);
assert!(task.start().is_ok());
task.wait_finished();
assert!(task.is_finished());
}
/// Given a subscriber state, `start` publishes the wall-clock start and
/// 1.0/0.0 for success/failure.
#[test]
fn subscriber_state_tracks_start_time_and_result() {
let state = Arc::new(SubscriberState::default());
let mut task = task_with(Noop);
task.set_subscriber(state.clone());
let before = system_time_ms();
assert!(task.start().is_ok());
assert!(
state.start_ms.load(Ordering::SeqCst) >= before,
"start timestamp is published"
);
assert_eq!(state.finished_value.load(Ordering::SeqCst), 1);
let state = Arc::new(SubscriberState::default());
let mut failed = task_with(Fail);
failed.set_subscriber(state.clone());
assert!(failed.start().is_err());
assert_eq!(state.finished_value.load(Ordering::SeqCst), 0);
}
/// A shared cancel atom links an outer task and its inner base: cancelling
/// either side is visible from the other.
#[test]
fn shared_cancel_atom_links_inner_and_outer_tasks() {
let atom = Arc::new(CancelAtom::new());
let mut outer = Task::new("outer", Some(atom.clone()));
assert!(Arc::ptr_eq(&outer.get_cancel_atom(), &atom));
outer.set_cancel_atom(atom.clone());
let mut inner = Task::new("inner", Some(atom));
inner.cancel();
assert!(outer.is_cancelled());
}
/// Titles and error messages round-trip through their setters.
#[test]
fn title_and_error_are_settable() {
let mut task = Task::new("old", None);
task.set_title("new");
assert_eq!(task.title(), "new");
assert!(task.error().is_none());
task.set_error("boom");
assert_eq!(task.error(), Some("boom"));
}
/// The module helpers: `cancelled()` is the cancellation error and
/// `system_time_ms()` reports a plausible epoch timestamp.
#[test]
fn helpers_report_cancellation_and_wall_clock() {
assert!(matches!(cancelled(), Error::Cancelled));
assert!(system_time_ms() > 0);
}
/// `emit_progress` without a listener must not panic or resurrect state;
/// a listener attached afterwards observes the clamped value (`2.0` is
/// reported as `1.0`).
#[test]
fn progress_without_a_listener_is_a_no_op() {
let mut task = task_with(Progressor);
task.emit_progress(0.5);
assert!(!task.is_finished(), "emitting progress does not finish");
assert!(task.start().is_ok());
assert!(task.is_finished());
// The one-shot listener is gone after the run; a fresh one still
// receives the clamped value.
let events: Arc<Mutex<Vec<TaskEvent>>> = Arc::new(Mutex::new(Vec::new()));
let sink = events.clone();
task.set_event_listener(Box::new(move |event| {
sink.lock().unwrap_or_else(|e| e.into_inner()).push(event);
}));
task.emit_progress(2.0);
assert_eq!(
*events.lock().unwrap_or_else(|e| e.into_inner()),
vec![TaskEvent::Progress(1.0)],
"the final progress value is clamped"
);
}
/// A subscriber is not required for the lifecycle; setting one after a
/// reset still receives the next run's values.
#[test]
fn subscriber_is_optional_and_reusable_after_reset() {
let mut task = task_with(Noop);
assert!(task.start().is_ok());
task.reset();
let state = Arc::new(SubscriberState {
start_ms: AtomicI64::new(0),
finished_value: AtomicI64::new(-1),
});
task.set_subscriber(state.clone());
assert!(task.start().is_ok());
assert!(state.start_ms.load(Ordering::SeqCst) > 0);
assert_eq!(state.finished_value.load(Ordering::SeqCst), 1);
}