From e04ce03058768fa0d8540f0d135a24ec9a4f4015 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Tue, 22 Sep 2026 20:54:04 +0800 Subject: [PATCH] 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. --- crates/oak-storage/tests/otio_backend_test.rs | 497 +++++++ crates/oak-storage/tests/writethrough_test.rs | 289 ++++ crates/oak-task/src/nodeops.rs | 571 +++++++- crates/oak-task/src/render.rs | 707 +++++++++- crates/oak-task/tests/codecbridge_test.rs | 306 ++++ crates/oak-task/tests/import_test.rs | 241 ++++ crates/oak-task/tests/loadotio_test.rs | 483 +++++++ crates/oak-task/tests/manager_test.rs | 436 ++++++ crates/oak-task/tests/precache_test.rs | 293 ++++ crates/oak-task/tests/render_test.rs | 1225 +++++++++++++++++ crates/oak-task/tests/task_test.rs | 324 +++++ 11 files changed, 5370 insertions(+), 2 deletions(-) create mode 100644 crates/oak-storage/tests/otio_backend_test.rs create mode 100644 crates/oak-storage/tests/writethrough_test.rs create mode 100644 crates/oak-task/tests/codecbridge_test.rs create mode 100644 crates/oak-task/tests/import_test.rs create mode 100644 crates/oak-task/tests/loadotio_test.rs create mode 100644 crates/oak-task/tests/manager_test.rs create mode 100644 crates/oak-task/tests/precache_test.rs create mode 100644 crates/oak-task/tests/render_test.rs create mode 100644 crates/oak-task/tests/task_test.rs diff --git a/crates/oak-storage/tests/otio_backend_test.rs b/crates/oak-storage/tests/otio_backend_test.rs new file mode 100644 index 000000000..0402c9c07 --- /dev/null +++ b/crates/oak-storage/tests/otio_backend_test.rs @@ -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 . + +//! 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, + 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::()) + .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::()) + { + 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::()) + .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::()) + { + 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::()) + .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::()) + { + 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::()) + { + t.kind = kind; + } + } + track +} + +fn attach_list(p: &mut Project, sequence: NodeId, kind: TrackType, tracks: Vec) -> 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::()) + { + 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::()) + { + 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> { + 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>, + 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 { + 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>) -> Vec { + 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::()) + .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::() { + // 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::() { + clips += 1; + if c.footage.is_some() { + footage_links += 1; + } else { + missing_links += 1; + } + } + if let Some(t) = any.downcast_ref::() { + 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::() { + 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::() { + clips += 1; + if c.footage.is_some() { + footage_links += 1; + } else { + missing_links += 1; + } + } + if let Some(t) = any.downcast_ref::() { + 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()); +} diff --git a/crates/oak-storage/tests/writethrough_test.rs b/crates/oak-storage/tests/writethrough_test.rs new file mode 100644 index 000000000..f034bedf5 --- /dev/null +++ b/crates/oak-storage/tests/writethrough_test.rs @@ -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 . + +//! 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>) { + 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 = 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(); +} diff --git a/crates/oak-task/src/nodeops.rs b/crates/oak-task/src/nodeops.rs index c7c6eb45a..b123f8b6f 100644 --- a/crates/oak-task/src/nodeops.rs +++ b/crates/oak-task/src/nodeops.rs @@ -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::()) + .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(project: &ProjectRef, f: impl FnOnce(&Graph) -> R) -> R { + let guard = lock_project(project); + f(&guard.graph) + } + + fn folder_children(project: &ProjectRef, folder: NodeId) -> Vec { + with_graph(project, |g| { + g.get(folder) + .and_then(|e| e.behavior.as_any()) + .and_then(|a| a.downcast_ref::()) + .map(|f| f.children.clone()) + .unwrap_or_default() + }) + } + + fn bin_folder_of(project: &ProjectRef, node: NodeId) -> Option { + 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::()) + .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, ¶ms); + 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, ¶ms); + + 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(); + } +} diff --git a/crates/oak-task/src/render.rs b/crates/oak-task/src/render.rs index cf8f9e3dd..a1ec6ffbc 100644 --- a/crates/oak-task/src/render.rs +++ b/crates/oak-task/src/render.rs @@ -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>` 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::()) + { + 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, 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::()) + { + 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| { + 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 { + 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::()) + { + t.blocks = blocks; + } + track + } + + fn track_list_with(project: &ProjectRef, kind: TrackType, tracks: Vec) -> 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::()) + { + 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::()) + { + 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::()) + { + 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::()) + { + 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"); + } +} diff --git a/crates/oak-task/tests/codecbridge_test.rs b/crates/oak-task/tests/codecbridge_test.rs new file mode 100644 index 000000000..a75bcdaba --- /dev/null +++ b/crates/oak-task/tests/codecbridge_test.rs @@ -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 . + +//! 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) { + 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 { + 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) -> 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); +} diff --git a/crates/oak-task/tests/import_test.rs b/crates/oak-task/tests/import_test.rs new file mode 100644 index 000000000..a1f1a51ef --- /dev/null +++ b/crates/oak-task/tests/import_test.rs @@ -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 . + +//! 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 { + 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::()) + .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()); +} diff --git a/crates/oak-task/tests/loadotio_test.rs b/crates/oak-task/tests/loadotio_test.rs new file mode 100644 index 000000000..c6a1184e3 --- /dev/null +++ b/crates/oak-task/tests/loadotio_test.rs @@ -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 . + +//! 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::() + .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::() + .expect("clip block") + .core + .range = TimeRange::new(in_, out); + p.graph + .get_mut(track) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .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> { + 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::() + .unwrap(); + list_b.kind = kind; + list_b.tracks = tracks; + } + p.graph + .get_mut(edited) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .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::() + .expect("root folder") + .children + .push(edited); + } + project +} + +/// Save `project` through the production interchange task. +fn save(project: &Arc>, 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, Option>>) { + 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>) -> Vec { + 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::()) + .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::()) + .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::()) + .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"").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>> = 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); +} diff --git a/crates/oak-task/tests/manager_test.rs b/crates/oak-task/tests/manager_test.rs new file mode 100644 index 000000000..8da97980e --- /dev/null +++ b/crates/oak-task/tests/manager_test.rs @@ -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 . + +//! 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, +} + +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, + cancelled: Arc, +} + +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, + release: Arc, +} + +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, Arc) { + 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()); +} diff --git a/crates/oak-task/tests/precache_test.rs b/crates/oak-task/tests/precache_test.rs new file mode 100644 index 000000000..af857b199 --- /dev/null +++ b/crates/oak-task/tests/precache_test.rs @@ -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 . + +//! 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::()) + { + 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); +} diff --git a/crates/oak-task/tests/render_test.rs b/crates/oak-task/tests/render_test.rs new file mode 100644 index 000000000..8520ed869 --- /dev/null +++ b/crates/oak-task/tests/render_test.rs @@ -0,0 +1,1225 @@ +// 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 . + +//! Boundary tests for the `RenderTask` base: empty ranges, stale viewer +//! nodes, hook errors, cancellation, forced sizes/formats, footage and +//! montage delivery (video + audio, audio first) and progress reporting. +//! +//! Renders run on the process-wide manager's test-only inline (`Threads`) +//! backend, serialized through one static mutex, so no GPU, no worker +//! processes and no sleeps are involved. Media is generated by +//! `oak_codec::testmedia`. + +use std::sync::{Arc, Mutex, MutexGuard}; + +use oak_core::texture::Texture; +use oak_core::videoparams::VideoParams; +use oak_core::{PixelFormat, 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_render::manager::{RenderBackendChoice, RenderManager}; +use oak_render::ticket::AudioSamples; +use oak_task::error::{Error, Result}; +use oak_task::nodeops::{self, ProjectRef}; +use oak_task::render::{ForceParams, RenderTask, RenderTaskBehavior}; +use oak_task::task::{Task, 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_render_{tag}_{}.mp4", std::process::id())) +} + +/// Locate an `oak-worker` binary for the private-dispatcher path: an +/// explicit `OAK_WORKER_BIN`, a sibling of the test executable, or the +/// workspace `target//oak-worker`. `None` means the path is +/// unavailable in this environment (the test then skips with a reason). +fn locate_oak_worker() -> Option { + if let Some(var) = std::env::var_os("OAK_WORKER_BIN") { + let path = std::path::PathBuf::from(var); + if path.is_file() { + return Some(path); + } + } + if let Some(dir) = std::env::current_exe().ok().and_then(|e| e.parent().map(|p| p.to_path_buf())) { + let path = dir.join("oak-worker"); + if path.is_file() { + return Some(path); + } + } + let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + for profile in ["debug", "release"] { + let path = manifest.join("../../target").join(profile).join("oak-worker"); + if path.is_file() { + return Some(path); + } + } + None +} + +// ---- fixture ---------------------------------------------------------- + +/// A project with one probed footage node (64x64, 1 s) and one sequence. +fn footage_project(media: &str, fps: i64) -> (ProjectRef, NodeId, VideoParams) { + let project = Project::new(); + let (footage_id, sequence) = { + 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::()) + { + seq.video_params[0].frame_rate = Rational::new(fps, 1); + seq.video_params[0].width = 64; + seq.video_params[0].height = 64; + } + (footage_id, sequence) + }; + let params = nodeops::sequence_video_params(&project, sequence, 0).expect("sequence params"); + (project, footage_id, params) +} + +/// A sequence with one video clip (and optionally an audio clip) of the +/// same media, both covering `[0, 1s)`. With `effect = Some(enabled)`, an +/// Opacity effect (factor 0) with that enable-state sits between the +/// footage and the clip so the effect-chain walk is exercised and its +/// effect is observable in the delivered pixels: the montage evaluator +/// applies the built-in Opacity on the CPU (unknown built-ins pass +/// through without a GPU, so those could not prove the chain ran). +fn sequence_project( + media: &str, + with_audio: bool, + effect: Option, +) -> (ProjectRef, NodeId, VideoParams) { + let project = Project::new(); + let sequence = { + 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::()) + { + seq.video_params[0].frame_rate = Rational::new(1, 1); + seq.video_params[0].width = 64; + seq.video_params[0].height = 64; + } + + let kinds: &[TrackType] = if with_audio { + &[TrackType::Video, TrackType::Audio] + } else { + &[TrackType::Video] + }; + for &kind in kinds { + 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::() + .unwrap() + .kind = kind; + + let (ccore, cbehavior) = oak_node::block::clip_create(); + let clip = p.graph.add_node(ccore, cbehavior); + if effect.is_some() && kind == TrackType::Video { + // The Opacity module is private; the factory is the public + // constructor. The value input is zeroed so an enabled + // effect zeroes every delivered channel (including alpha). + let (ecore, ebehavior) = oak_node::factory::Factory::global() + .create_any("org.olivevideoeditor.Olive.opacity") + .expect("opacity node registered"); + let effect_node = p.graph.add_node(ecore, ebehavior); + p.graph + .get_mut(effect_node) + .unwrap() + .core + .set_standard_value( + oak_node::node::ENABLED_INPUT, + -1, + oak_node::value::NodeValue::Boolean(effect.unwrap_or(false)), + ); + p.graph + .get_mut(effect_node) + .unwrap() + .core + .set_standard_value("opacity_in", -1, oak_node::value::NodeValue::Float(0.0)); + p.graph + .connect(footage_id, effect_node, "tex_in", -1) + .expect("connect footage to effect"); + p.graph + .connect( + effect_node, + clip, + oak_node::block::clip_input::TEXTURE_INPUT, + -1, + ) + .expect("connect effect to clip"); + } else { + p.graph + .connect( + footage_id, + 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::() + .expect("clip block") + .core + .range = TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)); + p.graph + .get_mut(track) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .append_block(clip); + + 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::() + .unwrap(); + list_b.kind = kind; + list_b.tracks.push(track); + } + p.graph + .get_mut(sequence) + .unwrap() + .behavior + .as_any_mut() + .unwrap() + .downcast_mut::() + .unwrap() + .track_lists + .push(list); + } + sequence + }; + + let params = nodeops::sequence_video_params(&project, sequence, 0).expect("sequence params"); + (project, sequence, params) +} + +/// Build a `RenderTask` for `viewer` with a 64x64 forced F32 render size. +fn render_task(project: &ProjectRef, viewer: NodeId, params: Option) -> RenderTask { + let mut task = RenderTask::new( + Task::new("render", None), + params, + (project.clone(), viewer), + ForceParams { + force_width: 64, + force_height: 64, + // The default leaves `force_format` off (-1), so the pipeline + // frame stays F32. + ..ForceParams::default() + }, + None, + ); + task.set_max_inflight(2); + task +} + +/// The first RGBA pixel of a delivered texture as f32 (downloads GPU +/// textures; `None` when the texture cannot be read back). +fn first_pixel(frame: &Texture) -> Option<[f32; 4]> { + let f = frame.to_frame().ok()?; + if f.width <= 0 || f.height <= 0 || f.data.len() < 16 { + return None; + } + let mut px = [0.0f32; 4]; + for (i, channel) in px.iter_mut().enumerate() { + let start = i * 4; + *channel = f32::from_le_bytes(f.data[start..start + 4].try_into().ok()?); + } + Some(px) +} + +/// A `RenderTaskBehavior` recording the delivered payloads in order. +#[derive(Default)] +struct Recorder { + order: Vec<&'static str>, + frames: Vec<(i32, i32)>, + /// Pixel format of each delivered frame (the forced-format contract). + formats: Vec, + /// First RGBA pixel of each delivered frame (effect and generated-frame + /// observability). + first_pixels: Vec>, + timestamps: Vec, + audio_samples: Vec, + fail_frames: bool, + fail_audio: bool, + /// Cancel the driving task from the first frame hook (exercises the + /// between-frames cancellation path deterministically). + cancel_on_first_frame: bool, + cancelled: bool, +} + +impl Recorder { + fn new() -> Recorder { + Recorder::default() + } +} + +impl RenderTaskBehavior for Recorder { + fn frame_downloaded(&mut self, task: &mut Task, frame: &Texture) -> Result<()> { + self.order.push("frame"); + self.frames.push(frame.size()); + self.formats.push(frame.format()); + self.first_pixels.push(first_pixel(frame)); + self.timestamps.push( + frame + .to_frame() + .map(|f| f.timestamp) + .unwrap_or(Rational::NULL), + ); + if self.fail_frames { + return Err(Error::Failed("frame hook failed".to_string())); + } + if self.cancel_on_first_frame && !self.cancelled { + self.cancelled = true; + task.cancel(); + } + Ok(()) + } + + fn audio_downloaded(&mut self, _task: &mut Task, samples: &AudioSamples) -> Result<()> { + self.order.push("audio"); + self.audio_samples.push(samples.samples.len()); + if self.fail_audio { + return Err(Error::Failed("audio hook failed".to_string())); + } + Ok(()) + } + + fn encode_subtitle(&mut self, _task: &mut Task, _text: &str) -> Result<()> { + Err(Error::Failed( + "subtitles are not rendered by this test".into(), + )) + } +} + +// ---- pure / prepare paths -------------------------------------------- + +/// A default-constructed render task exposes zero frames and every +/// configurator tolerates edge values. The window/progress configurators +/// have no getters; their observable effects are covered by +/// [`max_inflight_edges_still_deliver`] and +/// [`native_progress_signalling_can_be_disabled`]. +#[test] +fn new_defaults_and_configurators_accept_edges() { + let project = Project::new(); + let mut task = RenderTask::new( + Task::new("render", None), + None, + (project, NodeId::INVALID), + ForceParams::default(), + None, + ); + assert_eq!(task.total_frames(), 0); + assert!(!task.force_params.has_force_matrix); + // Defaults must leave every override off (-1, not 0 = U8). + assert_eq!(task.force_params.force_format, -1); + task.set_render_inputs(0, true, TimeRange::default()); + task.set_native_progress_signalling(false); + task.set_max_inflight(0); + task.set_max_inflight(usize::MAX); + assert_eq!(task.total_frames(), 0, "configuring does not plan frames"); + assert_eq!( + task.force_params.force_format, -1, + "configuring keeps defaults" + ); +} + +/// The edge in-flight windows (`0`, `usize::MAX`) still render: the loop +/// clamps the window to at least one ticket. There is no `max_inflight` +/// getter, so the delivered frame is the observable effect. +#[test] +fn max_inflight_edges_still_deliver() { + let media = clip_path("inflight"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + let (project, footage, params) = footage_project(&media.to_string_lossy(), 1); + + let _guard = render_serial(); + for max in [0usize, usize::MAX] { + let mut task = render_task(&project, footage, Some(params.clone())); + task.set_max_inflight(max); + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + assert!( + task.render(&mut driver, &mut recorder).is_ok(), + "max_inflight {max} must still render" + ); + assert_eq!( + recorder.frames, + vec![(64, 64)], + "max_inflight {max} delivers the frame" + ); + } + + let _ = std::fs::remove_file(&media); +} + +/// An empty export range computes no frames and submits nothing. +#[test] +fn empty_export_range_renders_nothing() { + let media = clip_path("empty"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + let (project, footage, params) = footage_project(&media.to_string_lossy(), 1); + + let _guard = render_serial(); + let mut task = render_task(&project, footage, Some(params)); + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(0, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + assert!(task.render(&mut driver, &mut recorder).is_ok()); + assert_eq!(task.total_frames(), 0); + assert!(recorder.frames.is_empty()); + assert!(recorder.audio_samples.is_empty()); + + let _ = std::fs::remove_file(&media); +} + +/// A viewer that is not a footage/sequence node fails the render with the +/// "no node connected to the viewer output" error. +#[test] +fn invalid_viewer_fails_the_render() { + let project = Project::new(); + let _guard = render_serial(); + let mut task = render_task(&project, NodeId::INVALID, None); + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + let error = task.render(&mut driver, &mut recorder).unwrap_err(); + assert!( + error.to_string().contains("viewer output"), + "the error names the viewer: {error}" + ); + assert_eq!(task.total_frames(), 1, "the frame plan is computed first"); + assert!(recorder.frames.is_empty()); +} + +/// A sequence viewer with no track lists renders a generated (transparent) +/// frame instead of a montage. +#[test] +fn sequence_viewer_without_tracks_renders_a_generated_frame() { + let project = Project::new(); + let sequence = { + let mut p = project.lock().unwrap(); + let (score, sbehavior) = SequenceBehavior::create(); + p.graph.add_node(score, sbehavior) + }; + + let _guard = render_serial(); + let mut task = render_task(&project, sequence, None); + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + assert!(task.render(&mut driver, &mut recorder).is_ok()); + assert_eq!(recorder.order, vec!["frame"]); + assert_eq!(recorder.frames, vec![(64, 64)]); + assert_eq!( + recorder.first_pixels, + vec![Some([0.0, 0.0, 0.0, 0.0])], + "the generated frame is fully transparent" + ); +} + +// ---- footage path ----------------------------------------------------- + +/// A single-footage render delivers exactly one frame and reports native +/// progress to the driver. +#[test] +fn footage_frame_is_delivered_with_progress() { + let media = clip_path("footage"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + let (project, footage, params) = footage_project(&media.to_string_lossy(), 1); + + let _guard = render_serial(); + let mut task = render_task(&project, footage, Some(params)); + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let progress = Arc::new(Mutex::new(Vec::new())); + let sink = progress.clone(); + let mut driver = Task::new("render", None); + driver.set_event_listener(Box::new(move |event| { + if let TaskEvent::Progress(value) = event { + sink.lock().unwrap().push(value); + } + })); + + let mut recorder = Recorder::new(); + assert!(task.render(&mut driver, &mut recorder).is_ok()); + assert_eq!(recorder.order, vec!["frame"]); + assert_eq!(recorder.frames, vec![(64, 64)]); + assert_eq!(recorder.timestamps, vec![Rational::new(0, 1)]); + assert_eq!( + *progress.lock().unwrap(), + vec![1.0], + "the native progress reaches 1.0 after the single frame" + ); + assert!(!driver.is_cancelled()); + + let _ = std::fs::remove_file(&media); +} + +/// Forced dimensions and an explicit F32 format reach the delivered +/// texture (size and pixel format are both asserted on the payload). +#[test] +fn forced_size_and_format_are_applied() { + let media = clip_path("force"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + let (project, footage, _) = footage_project(&media.to_string_lossy(), 1); + + let _guard = render_serial(); + let mut task = render_task(&project, footage, None); + task.force_params.force_width = 32; + task.force_params.force_height = 32; + task.force_params.force_format = PixelFormat::F32 as i32; + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + assert!(task.render(&mut driver, &mut recorder).is_ok()); + assert_eq!(recorder.frames, vec![(32, 32)]); + assert_eq!( + recorder.formats, + vec![PixelFormat::F32], + "the forced F32 format reaches the delivered texture" + ); + + let _ = std::fs::remove_file(&media); +} + +/// A forced **non-default** format reaches the delivered texture: the +/// generated-frame path supports U8, so if `force_format` were dropped the +/// delivered texture would be F32 and this fails. (The F32 case above pins +/// the delivery contract but cannot distinguish the forced value from the +/// pipeline default.) +#[test] +fn forced_non_default_format_reaches_the_delivered_texture() { + let project = Project::new(); + let sequence = { + let mut p = project.lock().unwrap(); + let (score, sbehavior) = SequenceBehavior::create(); + p.graph.add_node(score, sbehavior) + }; + + let _guard = render_serial(); + let mut task = render_task(&project, sequence, None); + task.force_params.force_format = PixelFormat::U8 as i32; + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + assert!(task.render(&mut driver, &mut recorder).is_ok()); + assert_eq!(recorder.frames, vec![(64, 64)]); + assert_eq!( + recorder.formats, + vec![PixelFormat::U8], + "the forced U8 format reaches the delivered texture" + ); +} + +/// A frame-hook error aborts the render after the first delivered frame. +#[test] +fn hook_error_stops_the_render() { + let media = clip_path("hookerr"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + // 2 fps over one second: two frames to schedule. + let (project, footage, params) = footage_project(&media.to_string_lossy(), 2); + + let _guard = render_serial(); + let mut task = render_task(&project, footage, Some(params)); + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + recorder.fail_frames = true; + let error = task.render(&mut driver, &mut recorder).unwrap_err(); + assert!(error.to_string().contains("frame hook failed")); + assert_eq!(task.total_frames(), 2); + assert_eq!( + recorder.frames, + vec![(64, 64)], + "delivery stopped at the error" + ); + + let _ = std::fs::remove_file(&media); +} + +/// With native progress signalling disabled (the export path), the render +/// loop delivers frames without emitting progress itself. +#[test] +fn native_progress_signalling_can_be_disabled() { + let media = clip_path("noprogress"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + let (project, footage, params) = footage_project(&media.to_string_lossy(), 1); + + let _guard = render_serial(); + let mut task = render_task(&project, footage, Some(params)); + task.set_native_progress_signalling(false); + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let progress = Arc::new(Mutex::new(Vec::new())); + let sink = progress.clone(); + let mut driver = Task::new("render", None); + driver.set_event_listener(Box::new(move |event| { + if let TaskEvent::Progress(value) = event { + sink.lock().unwrap().push(value); + } + })); + let mut recorder = Recorder::new(); + assert!(task.render(&mut driver, &mut recorder).is_ok()); + assert_eq!(recorder.frames.len(), 1, "frames are still delivered"); + assert!( + progress.lock().unwrap().is_empty(), + "the loop emits no progress of its own" + ); + assert!(driver.error().is_none()); + + let _ = std::fs::remove_file(&media); +} + +/// Cancelling before the render aborts with `Error::Cancelled` and delivers +/// nothing (the in-flight ticket is drained). +#[test] +fn cancel_before_render_reports_cancelled() { + let media = clip_path("cancel"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + let (project, footage, params) = footage_project(&media.to_string_lossy(), 1); + + let _guard = render_serial(); + let mut task = render_task(&project, footage, Some(params)); + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + driver.cancel(); + let mut recorder = Recorder::new(); + let result = task.render(&mut driver, &mut recorder); + assert!(matches!(result, Err(Error::Cancelled))); + assert_eq!(task.total_frames(), 1); + assert!( + recorder.order.is_empty(), + "nothing is delivered after abort" + ); + + let _ = std::fs::remove_file(&media); +} + +// ---- montage / audio path -------------------------------------------- + +/// A one-clip sequence renders through the montage compositor. +#[test] +fn sequence_montage_renders_a_frame() { + let media = clip_path("montage"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + let (project, sequence, params) = sequence_project(&media.to_string_lossy(), false, None); + + let _guard = render_serial(); + let mut task = render_task(&project, sequence, Some(params)); + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + assert!(task.render(&mut driver, &mut recorder).is_ok()); + assert_eq!(recorder.order, vec!["frame"]); + assert_eq!(recorder.frames, vec![(64, 64)]); + assert_eq!(recorder.timestamps, vec![Rational::new(0, 1)]); + + let _ = std::fs::remove_file(&media); +} + +/// An effect in the clip's chain is walked (params collected) and its +/// enable state applied: the fixture's Opacity (factor 0) zeroes every +/// delivered channel when enabled and passes the decoded clip through when +/// disabled, so the two branches differ observably — a regression that +/// drops the chain (or ignores the enable flag) cannot pass both. +#[test] +fn clip_effect_chain_is_walked_and_bypassed() { + let media = clip_path("effect"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + let _guard = render_serial(); + + for enabled in [false, true] { + let (project, sequence, params) = + sequence_project(&media.to_string_lossy(), false, Some(enabled)); + + // Fixture sanity: the clip's effect input is fed by the Opacity + // node, and the node carries the factor-zero input the walk needs. + let upstream_type = { + let guard = project.lock().unwrap_or_else(|e| e.into_inner()); + let clip = guard + .graph + .node_ids() + .into_iter() + .find(|id| { + guard + .graph + .get(*id) + .and_then(|e| e.behavior.as_any()) + .and_then(|a| a.downcast_ref::()) + .is_some() + }) + .expect("clip node"); + let input = guard.graph.get(clip).unwrap().core.effect_input.clone(); + let upstream = guard + .graph + .connected_output(clip, &input, -1) + .expect("effect upstream"); + let entry = guard.graph.get(upstream).unwrap(); + assert!( + matches!( + entry.core.standard_value(oak_node::node::ENABLED_INPUT, -1), + oak_node::value::NodeValue::Boolean(v) if v == enabled + ), + "the fixture enable state" + ); + assert!( + matches!( + entry.core.standard_value("opacity_in", -1), + oak_node::value::NodeValue::Float(v) if v == 0.0 + ), + "the fixture opacity factor" + ); + entry.behavior.type_id().to_string() + }; + assert_eq!(upstream_type, "org.olivevideoeditor.Olive.opacity"); + + let mut task = render_task(&project, sequence, Some(params)); + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + assert!(task.render(&mut driver, &mut recorder).is_ok()); + assert_eq!(recorder.order, vec!["frame"]); + assert_eq!(recorder.frames, vec![(64, 64)]); + + let pixel = recorder.first_pixels[0].expect("CPU frame with a readable pixel"); + if enabled { + assert_eq!( + pixel, + [0.0, 0.0, 0.0, 0.0], + "the enabled opacity zeroes every channel" + ); + } else { + assert!( + pixel != [0.0, 0.0, 0.0, 0.0] && pixel[3] > 0.0, + "the disabled effect must pass the decoded clip through unchanged: {pixel:?}" + ); + } + } + + let _ = std::fs::remove_file(&media); +} + +/// Audio is enabled: the audio ticket is delivered before the video frame +/// and carries the decoded samples. +#[test] +fn audio_is_delivered_before_the_video_frame() { + let media = clip_path("audio"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + let (project, sequence, params) = sequence_project(&media.to_string_lossy(), true, None); + + let _guard = render_serial(); + let mut task = render_task(&project, sequence, Some(params)); + task.set_render_inputs( + 0, + true, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + assert!(task.render(&mut driver, &mut recorder).is_ok()); + assert_eq!( + recorder.order, + vec!["audio", "frame"], + "the audio range is delivered first" + ); + assert_eq!(recorder.audio_samples.len(), 1); + assert!( + recorder.audio_samples[0] > 0, + "the decoded audio range is not empty" + ); + assert_eq!(recorder.frames, vec![(64, 64)]); + assert_eq!(task.total_frames(), 1); + + let _ = std::fs::remove_file(&media); +} + +/// The frame count covers the whole export range (not just the delivered +/// window) and the timestamps advance by the timebase. +#[test] +fn total_frames_and_timestamps_follow_the_range() { + let media = clip_path("count"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + // 4 fps over one second: frames at 0, 1/4, 1/2, 3/4. + let (project, footage, params) = footage_project(&media.to_string_lossy(), 4); + + let _guard = render_serial(); + let mut task = render_task(&project, footage, Some(params)); + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + assert!(task.render(&mut driver, &mut recorder).is_ok()); + assert_eq!(task.total_frames(), 4); + assert_eq!( + recorder.timestamps, + vec![ + Rational::new(0, 1), + Rational::new(1, 4), + Rational::new(1, 2), + Rational::new(3, 4), + ] + ); + + let _ = std::fs::remove_file(&media); +} + +/// Native progress is emitted once per delivered frame as a fraction of the +/// whole planned range (not just the in-flight window). +#[test] +fn progress_reaches_one_across_multiple_frames() { + let media = clip_path("multiprogress"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + let (project, footage, params) = footage_project(&media.to_string_lossy(), 4); + + let _guard = render_serial(); + let mut task = render_task(&project, footage, Some(params)); + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let progress = Arc::new(Mutex::new(Vec::new())); + let sink = progress.clone(); + let mut driver = Task::new("render", None); + driver.set_event_listener(Box::new(move |event| { + if let TaskEvent::Progress(value) = event { + sink.lock().unwrap().push(value); + } + })); + let mut recorder = Recorder::new(); + assert!(task.render(&mut driver, &mut recorder).is_ok()); + assert_eq!(recorder.frames.len(), 4); + assert_eq!( + *progress.lock().unwrap(), + vec![0.25, 0.5, 0.75, 1.0], + "one progress report per frame, normalized by the plan" + ); + + let _ = std::fs::remove_file(&media); +} + +/// A range that does not start at zero plans and delivers exactly the +/// frames inside it. +#[test] +fn range_with_non_zero_start_plans_offset_frames() { + let media = clip_path("offset"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + // 4 fps: the half-open range [1/2, 1) holds frames at 1/2 and 3/4. + let (project, footage, params) = footage_project(&media.to_string_lossy(), 4); + + let _guard = render_serial(); + let mut task = render_task(&project, footage, Some(params)); + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(1, 2), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + assert!(task.render(&mut driver, &mut recorder).is_ok()); + assert_eq!(task.total_frames(), 2); + assert_eq!( + recorder.timestamps, + vec![Rational::new(1, 2), Rational::new(3, 4)] + ); + + let _ = std::fs::remove_file(&media); +} + +/// An audio-hook error aborts the render at the audio delivery, before any +/// video frame is handed to the hooks. +#[test] +fn audio_hook_error_stops_the_render() { + let media = clip_path("audioerr"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + let (project, sequence, params) = sequence_project(&media.to_string_lossy(), true, None); + + let _guard = render_serial(); + let mut task = render_task(&project, sequence, Some(params)); + task.set_render_inputs( + 0, + true, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + recorder.fail_audio = true; + let error = task.render(&mut driver, &mut recorder).unwrap_err(); + assert!(error.to_string().contains("audio hook failed")); + assert_eq!( + recorder.order, + vec!["audio"], + "the error stops the render before the frame" + ); + + let _ = std::fs::remove_file(&media); +} + +/// A cancellation raised from the first frame hook aborts the render with +/// `Error::Cancelled`, delivers no further frames, and cancels the +/// still-in-flight tickets on the way out. +#[test] +fn cancel_between_frames_reports_cancelled() { + let media = clip_path("midcancel"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + // 4 fps over one second: four frames to schedule. + let (project, footage, params) = footage_project(&media.to_string_lossy(), 4); + + let _guard = render_serial(); + let mut task = render_task(&project, footage, Some(params)); + task.set_max_inflight(1); + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + recorder.cancel_on_first_frame = true; + let result = task.render(&mut driver, &mut recorder); + assert!( + matches!(result, Err(Error::Cancelled)), + "a cancel between frames maps to Cancelled: {result:?}" + ); + assert_eq!(task.total_frames(), 4, "the whole plan was computed"); + assert_eq!(recorder.frames.len(), 1, "delivery stopped at the cancel"); + assert!(driver.is_cancelled()); + + let _ = std::fs::remove_file(&media); +} + +// ---- private process dispatcher (no global manager) ------------------- + +/// Environment-only reason the private dispatcher cannot render here: the +/// worker pool itself could not be created or started (an unusable +/// `oak-worker`, exec denied, process-table exhaustion, ...). Everything +/// else — ticket errors, wrong payload kinds, unexpected timestamps, shm +/// conversion problems — is a product failure and must fail the test. +fn worker_pool_environment_error(error: &Error) -> bool { + matches!(error, Error::Failed(message) if message.starts_with("render worker pool")) +} + +/// Shuts the process-wide render manager down and points `OAK_WORKER_BIN` +/// at `worker` for the private-dispatcher render, restoring both on drop — +/// so a panicking render can never leak the shut-down manager or the env +/// override into later tests. [`DispatcherEnvGuard::restore`] restores +/// eagerly, before the assertions. +struct DispatcherEnvGuard { + previous_worker_bin: Option, + restored: bool, +} + +impl DispatcherEnvGuard { + fn install(worker: &std::path::Path) -> DispatcherEnvGuard { + let previous_worker_bin = std::env::var_os("OAK_WORKER_BIN"); + std::env::set_var("OAK_WORKER_BIN", worker); + let guard = DispatcherEnvGuard { + previous_worker_bin, + restored: false, + }; + RenderManager::shutdown(); + assert!( + RenderManager::global().is_none(), + "the private path needs no global manager" + ); + guard + } + + /// Restore the manager and the environment exactly once. + fn restore(&mut self) { + if self.restored { + return; + } + self.restored = true; + RenderManager::init_with_backend(RenderBackendChoice::Threads).expect("render manager"); + match self.previous_worker_bin.take() { + Some(value) => std::env::set_var("OAK_WORKER_BIN", value), + None => std::env::remove_var("OAK_WORKER_BIN"), + } + } +} + +impl Drop for DispatcherEnvGuard { + fn drop(&mut self) { + self.restore(); + } +} + +/// With the global render manager shut down, [`RenderTask::render`] stands +/// up its own process dispatcher and renders through `oak-worker` +/// processes: the `ShmFrame`/`ShmAudio` delivery seams, the poll pump and +/// the dispatcher teardown (`shm_frame_to_texture`'s BGRA8 and F32 +/// branches). +/// +/// Only the worker pool being unavailable is an environment skip (the +/// missing-binary case skips earlier): a pool that cannot be created or +/// started prints a reason. Every product failure — a `"Frame render +/// ticket failed"`, a bad shm payload/format, a poll timeout surfacing as +/// a ticket error — fails the test. +#[test] +fn private_dispatcher_renders_frames_and_audio_through_workers() { + let _guard = render_serial(); + let Some(worker) = locate_oak_worker() else { + eprintln!("oak-worker binary not built; skipping the private dispatcher render"); + return; + }; + + let media = clip_path("private"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + + // The dispatcher resolves the worker binary from the environment when + // it is constructed by the render call below. The guard restores the + // manager and the env also when a render panics. + let mut env = DispatcherEnvGuard::install(&worker); + + // Audio + video through the workers (the default BGRA8 wire format). + let (project, sequence, params) = sequence_project(&media.to_string_lossy(), true, None); + let mut task = render_task(&project, sequence, Some(params)); + task.set_max_inflight(2); + task.set_render_inputs( + 0, + true, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + let outcome = task.render(&mut driver, &mut recorder); + + // A forced F32 ticket takes the no-conversion shm branch. + let (fproject, footage, _) = footage_project(&media.to_string_lossy(), 1); + let mut f32_task = render_task(&fproject, footage, None); + f32_task.force_params.force_format = PixelFormat::F32 as i32; + f32_task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut f32_driver = Task::new("render", None); + let mut f32_recorder = Recorder::new(); + let f32_outcome = f32_task.render(&mut f32_driver, &mut f32_recorder); + + // Restore the manager and the environment before asserting. + env.restore(); + + let mut skipped_environment = Vec::new(); + match outcome { + Ok(()) => { + assert_eq!( + recorder.order, + vec!["audio", "frame"], + "the worker audio and frame land in order" + ); + assert_eq!(recorder.frames, vec![(64, 64)]); + assert_eq!( + recorder.formats, + vec![PixelFormat::F32], + "the BGRA8 wire frame is converted to the F32 delivery format" + ); + assert!(recorder.audio_samples[0] > 0); + } + Err(error) if worker_pool_environment_error(&error) => { + eprintln!("private dispatcher worker pool unavailable: {error}; skipping"); + skipped_environment.push("video+audio"); + } + Err(error) => panic!("the private dispatcher render failed (product error): {error}"), + } + match f32_outcome { + Ok(()) => { + assert_eq!(f32_recorder.frames, vec![(64, 64)]); + assert_eq!( + f32_recorder.formats, + vec![PixelFormat::F32], + "the forced F32 shm branch keeps the delivery format" + ); + } + Err(error) if worker_pool_environment_error(&error) => { + eprintln!("private dispatcher F32 render unavailable: {error}; skipping"); + skipped_environment.push("f32"); + } + Err(error) => panic!("the private dispatcher F32 render failed (product error): {error}"), + } + if !skipped_environment.is_empty() { + eprintln!( + "private dispatcher render skipped (environment unavailable): {}", + skipped_environment.join(", ") + ); + } + + let _ = std::fs::remove_file(&media); +} + +// ---- ticket failures -------------------------------------------------- + +/// A footage file removed after the probe makes the frame ticket's decode +/// fail; the render loop maps the ticket error (not a hook error). +#[test] +fn missing_media_frame_ticket_reports_failure() { + let media = clip_path("missingticket"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + let (project, footage, params) = footage_project(&media.to_string_lossy(), 1); + // Probe already happened; remove the payload so the decode fails. + let _ = std::fs::remove_file(&media); + + let _guard = render_serial(); + let mut task = render_task(&project, footage, Some(params)); + task.set_render_inputs( + 0, + false, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + let error = task.render(&mut driver, &mut recorder).unwrap_err(); + assert!( + error.to_string().contains("Frame render ticket failed"), + "the decode failure is a ticket error: {error}" + ); + assert!(recorder.frames.is_empty()); +} + +/// The same for the audio range: the audio ticket is queued first, so its +/// failure is reported as an audio ticket error before any frame. +#[test] +fn missing_media_audio_ticket_reports_failure() { + let media = clip_path("missingaudio"); + oak_codec::testmedia::write_test_clip(&media, 64, 64, 10, 10).expect("test clip"); + let (project, sequence, params) = sequence_project(&media.to_string_lossy(), true, None); + let _ = std::fs::remove_file(&media); + + let _guard = render_serial(); + let mut task = render_task(&project, sequence, Some(params)); + task.set_render_inputs( + 0, + true, + TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)), + ); + let mut driver = Task::new("render", None); + let mut recorder = Recorder::new(); + let error = task.render(&mut driver, &mut recorder).unwrap_err(); + assert!( + error.to_string().contains("Audio render ticket failed"), + "the audio decode failure names the ticket: {error}" + ); + assert!(recorder.order.is_empty()); +} diff --git a/crates/oak-task/tests/task_test.rs b/crates/oak-task/tests/task_test.rs new file mode 100644 index 000000000..ddbae176e --- /dev/null +++ b/crates/oak-task/tests/task_test.rs @@ -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 . + +//! 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>> = 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>> = 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); +}