refactor: workspace layout — crates/, app at root, legacy C++ removed
Single mechanical restructure commit: - root Cargo.toml = oakapp bin + workspace; one cargo build produces oakapp, oak-cli, oak-worker, liboakengine.dylib - app/rust/src -> src/ (app at repo root, no rust/ nesting) - src/<mod>/rust -> crates/oak<mod>; src/oakcore-rs -> crates/oakcore; src/bindings/oakotio -> crates/oakotio; src/engine/rust -> crates/oakengine (keeps cdylib+staticlib+rlib) - public C headers include/<mod>/ -> crates/oakengine/include/<mod>/ - OFX SDK headers vendored into crates/oakplugin/ofx/ (HostSupport gone) - legacy deleted: old src/ C++ modules, engine/, core/, ffmpeg_bridge/, app/ (Qt), cli/worker C++, root CMakeLists, third_party/KDDockWidgets submodule, otio-install, all build-* output (~40GB) - oakstorage kept but excluded from the workspace (skeleton w/ todos); gpui excluded (own workspace) - verified: cargo build green, cargo test --workspace 1845/0 (with the documented OCIO_RS_* env override for the homebrew OCIO)
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
#[test]
|
||||
fn dbg_dlsym() {
|
||||
let p = oaknode::bridge::dlsym::resolve("oaknode_xml_writer_init");
|
||||
eprintln!("writer init symbol: {:?}", p);
|
||||
let p2 = oaknode::bridge::dlsym::resolve("oakundo_command_init_multi");
|
||||
eprintln!("undo multi symbol: {:?}", p2);
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Direct unit tests for the crate-internal engine pieces (value.rs,
|
||||
//! node.rs, folder.rs, ops.rs, handle.rs, error.rs, input.rs). These
|
||||
//! drive every conversion/interpolation/query arm so the implemented
|
||||
//! modules hold the ≥80% coverage gate.
|
||||
|
||||
use oakcore_rs::{Rational, TimeRange};
|
||||
|
||||
use oaknode::error::{Error, OAKNODE_E_FAILED, OAKNODE_E_INVALID};
|
||||
use oaknode::handle::{self, CHandle, RefBox};
|
||||
use oaknode::id::NodeId;
|
||||
use oaknode::input::{flags, Input, ValueHint};
|
||||
use oaknode::keyframe::{Interpolation, Keyframe};
|
||||
use oaknode::node::{Category, NodeBehavior, NodeCore};
|
||||
use oaknode::value::{
|
||||
oak, AudioParams, NodeValue, NodeValueTable, OakNodeValue, SampleBuffer, ValueType,
|
||||
VideoParams,
|
||||
};
|
||||
|
||||
fn float(v: f64) -> NodeValue {
|
||||
NodeValue::Float(v)
|
||||
}
|
||||
|
||||
/// value.rs: value_type / to_double / split / combine / with_scalar
|
||||
/// across every variant.
|
||||
#[test]
|
||||
fn value_type_and_conversions() {
|
||||
// value_type for every variant.
|
||||
assert_eq!(NodeValue::None.value_type(), ValueType::None);
|
||||
assert_eq!(NodeValue::Int(1).value_type(), ValueType::Int);
|
||||
assert_eq!(float(1.0).value_type(), ValueType::Float);
|
||||
assert_eq!(NodeValue::Color([0.0; 4]).value_type(), ValueType::Color);
|
||||
assert_eq!(NodeValue::Text("x".into()).value_type(), ValueType::Text);
|
||||
assert_eq!(NodeValue::Boolean(true).value_type(), ValueType::Boolean);
|
||||
assert_eq!(NodeValue::Samples(SampleBuffer::default()).value_type(), ValueType::Samples);
|
||||
assert_eq!(NodeValue::Rational(Rational::new(1, 2)).value_type(), ValueType::Rational);
|
||||
assert_eq!(NodeValue::Vec2([0.0; 2]).value_type(), ValueType::Vec2);
|
||||
assert_eq!(NodeValue::Vec3([0.0; 3]).value_type(), ValueType::Vec3);
|
||||
assert_eq!(NodeValue::Vec4([0.0; 4]).value_type(), ValueType::Vec4);
|
||||
assert_eq!(NodeValue::Combo(0).value_type(), ValueType::Combo);
|
||||
assert_eq!(NodeValue::StrCombo("s".into()).value_type(), ValueType::StrCombo);
|
||||
assert_eq!(
|
||||
NodeValue::VideoParams(VideoParams::default()).value_type(),
|
||||
ValueType::VideoParams
|
||||
);
|
||||
assert_eq!(
|
||||
NodeValue::AudioParams(AudioParams::default()).value_type(),
|
||||
ValueType::AudioParams
|
||||
);
|
||||
assert_eq!(NodeValue::Binary(vec![1]).value_type(), ValueType::Binary);
|
||||
assert_eq!(
|
||||
NodeValue::NodeRef(NodeId::from_identity(2).unwrap()).value_type(),
|
||||
ValueType::NodeRef
|
||||
);
|
||||
assert_eq!(NodeValue::PushButton.value_type(), ValueType::PushButton);
|
||||
|
||||
// to_double across the numeric surface.
|
||||
assert_eq!(NodeValue::Int(3).to_double(), 3.0);
|
||||
assert_eq!(NodeValue::Int(-4).to_double(), -4.0);
|
||||
assert_eq!(float(2.5).to_double(), 2.5);
|
||||
assert_eq!(NodeValue::Color([7.0, 0.0, 0.0, 0.0]).to_double(), 7.0);
|
||||
assert_eq!(NodeValue::Boolean(true).to_double(), 1.0);
|
||||
assert_eq!(NodeValue::Boolean(false).to_double(), 0.0);
|
||||
assert_eq!(
|
||||
NodeValue::Rational(Rational::new(1, 4)).to_double(),
|
||||
0.25
|
||||
);
|
||||
assert_eq!(NodeValue::Vec2([9.0, 0.0]).to_double(), 9.0);
|
||||
assert_eq!(NodeValue::Vec3([8.0, 0.0, 0.0]).to_double(), 8.0);
|
||||
assert_eq!(NodeValue::Vec4([6.0, 0.0, 0.0, 0.0]).to_double(), 6.0);
|
||||
assert_eq!(NodeValue::Combo(5).to_double(), 5.0);
|
||||
assert_eq!(NodeValue::Text("x".into()).to_double(), 0.0, "non-numeric -> 0");
|
||||
assert_eq!(NodeValue::None.to_double(), 0.0);
|
||||
|
||||
// can_interpolate.
|
||||
assert!(float(1.0).can_interpolate());
|
||||
assert!(NodeValue::Vec2([0.0; 2]).can_interpolate());
|
||||
assert!(!NodeValue::Int(1).can_interpolate());
|
||||
assert!(!NodeValue::Text("x".into()).can_interpolate());
|
||||
|
||||
// ValueType helpers.
|
||||
assert_eq!(ValueType::Text.to_oak(), oak::STRING);
|
||||
assert_eq!(ValueType::StrCombo.to_oak(), oak::STRING);
|
||||
assert_eq!(ValueType::Combo.to_oak(), oak::COMBO);
|
||||
assert_eq!(ValueType::Texture.to_oak(), oak::NONE);
|
||||
assert!(ValueType::Text.is_string());
|
||||
assert!(ValueType::StrCombo.is_string());
|
||||
assert!(!ValueType::Float.is_string());
|
||||
assert_eq!(ValueType::Vec2.keyframe_track_count(), 2);
|
||||
assert_eq!(ValueType::Vec3.keyframe_track_count(), 3);
|
||||
assert_eq!(ValueType::Vec4.keyframe_track_count(), 4);
|
||||
assert_eq!(ValueType::Color.keyframe_track_count(), 4);
|
||||
assert_eq!(ValueType::Float.keyframe_track_count(), 1);
|
||||
assert!(ValueType::Rational.can_interpolate());
|
||||
assert!(ValueType::Color.can_interpolate());
|
||||
assert!(ValueType::Vec4.can_interpolate());
|
||||
assert!(!ValueType::Int.can_interpolate());
|
||||
assert!(!ValueType::Text.can_interpolate());
|
||||
}
|
||||
|
||||
/// value.rs: split/combine/with_scalar/lerp arms.
|
||||
#[test]
|
||||
fn value_split_combine_lerp() {
|
||||
let eps = 1e-12;
|
||||
|
||||
// split_into_tracks.
|
||||
let vec2 = NodeValue::Vec2([1.0, 2.0]);
|
||||
assert_eq!(
|
||||
vec2.split_into_tracks(ValueType::Vec2),
|
||||
vec![float(1.0), float(2.0)]
|
||||
);
|
||||
assert_eq!(
|
||||
NodeValue::Vec3([1.0, 2.0, 3.0]).split_into_tracks(ValueType::Vec3),
|
||||
vec![float(1.0), float(2.0), float(3.0)]
|
||||
);
|
||||
assert_eq!(
|
||||
NodeValue::Vec4([1.0, 2.0, 3.0, 4.0]).split_into_tracks(ValueType::Vec4),
|
||||
vec![float(1.0), float(2.0), float(3.0), float(4.0)]
|
||||
);
|
||||
assert_eq!(
|
||||
NodeValue::Color([1.0, 2.0, 3.0, 4.0]).split_into_tracks(ValueType::Color),
|
||||
vec![float(1.0), float(2.0), float(3.0), float(4.0)]
|
||||
);
|
||||
// Scalar types hold the whole value in track 0.
|
||||
assert_eq!(float(9.0).split_into_tracks(ValueType::Float), vec![float(9.0)]);
|
||||
assert_eq!(
|
||||
NodeValue::Int(7).split_into_tracks(ValueType::Int),
|
||||
vec![NodeValue::Int(7)]
|
||||
);
|
||||
|
||||
// combine_tracks.
|
||||
assert_eq!(
|
||||
NodeValue::combine_tracks(&[float(1.0), float(2.0)], ValueType::Vec2),
|
||||
NodeValue::Vec2([1.0, 2.0])
|
||||
);
|
||||
assert_eq!(
|
||||
NodeValue::combine_tracks(&[float(1.0), float(2.0), float(3.0)], ValueType::Vec3),
|
||||
NodeValue::Vec3([1.0, 2.0, 3.0])
|
||||
);
|
||||
{
|
||||
let v = vec![float(1.0); 4];
|
||||
assert_eq!(
|
||||
NodeValue::combine_tracks(&v, ValueType::Vec4),
|
||||
NodeValue::Vec4([1.0; 4])
|
||||
);
|
||||
assert_eq!(
|
||||
NodeValue::combine_tracks(&v, ValueType::Color),
|
||||
NodeValue::Color([1.0; 4])
|
||||
);
|
||||
}
|
||||
// Short track lists pad with zeros; empty -> None.
|
||||
assert_eq!(
|
||||
NodeValue::combine_tracks(&[float(1.0)], ValueType::Vec2),
|
||||
NodeValue::Vec2([1.0, 0.0])
|
||||
);
|
||||
assert_eq!(NodeValue::combine_tracks(&[], ValueType::Float), NodeValue::None);
|
||||
assert_eq!(
|
||||
NodeValue::combine_tracks(&[float(3.0)], ValueType::Float),
|
||||
float(3.0)
|
||||
);
|
||||
|
||||
// with_scalar for every declared type.
|
||||
assert_eq!(float(0.0).with_scalar(ValueType::Int, 4.0), NodeValue::Int(4));
|
||||
assert_eq!(float(0.0).with_scalar(ValueType::Float, 4.0), float(4.0));
|
||||
assert_eq!(
|
||||
float(0.0).with_scalar(ValueType::Boolean, 1.0),
|
||||
NodeValue::Boolean(true)
|
||||
);
|
||||
assert_eq!(
|
||||
float(0.0).with_scalar(ValueType::Boolean, 0.0),
|
||||
NodeValue::Boolean(false)
|
||||
);
|
||||
assert_eq!(float(0.0).with_scalar(ValueType::Combo, 2.0), NodeValue::Combo(2));
|
||||
assert_eq!(
|
||||
float(0.0).with_scalar(ValueType::Color, 0.5),
|
||||
NodeValue::Color([0.5, 0.0, 0.0, 0.0])
|
||||
);
|
||||
assert_eq!(
|
||||
float(0.0).with_scalar(ValueType::Vec2, 0.5),
|
||||
NodeValue::Vec2([0.5, 0.0])
|
||||
);
|
||||
assert_eq!(
|
||||
float(0.0).with_scalar(ValueType::Vec3, 0.5),
|
||||
NodeValue::Vec3([0.5, 0.0, 0.0])
|
||||
);
|
||||
assert_eq!(
|
||||
float(0.0).with_scalar(ValueType::Vec4, 0.5),
|
||||
NodeValue::Vec4([0.5, 0.0, 0.0, 0.0])
|
||||
);
|
||||
assert_eq!(
|
||||
float(0.0).with_scalar(ValueType::Texture, 0.5),
|
||||
float(0.0),
|
||||
"non-numeric declared type keeps the value"
|
||||
);
|
||||
|
||||
// lerp component-wise.
|
||||
assert_eq!(float(0.0).lerp(&float(10.0), 0.5), float(5.0));
|
||||
match float(0.0).lerp(&float(10.0), 0.5) {
|
||||
NodeValue::Float(f) => assert!((f - 5.0).abs() < eps),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
assert_eq!(
|
||||
NodeValue::Vec2([0.0, 0.0]).lerp(&NodeValue::Vec2([2.0, 4.0]), 0.5),
|
||||
NodeValue::Vec2([1.0, 2.0])
|
||||
);
|
||||
assert_eq!(
|
||||
NodeValue::Vec3([0.0; 3]).lerp(&NodeValue::Vec3([2.0; 3]), 0.5),
|
||||
NodeValue::Vec3([1.0; 3])
|
||||
);
|
||||
assert_eq!(
|
||||
NodeValue::Vec4([0.0; 4]).lerp(&NodeValue::Vec4([2.0; 4]), 0.5),
|
||||
NodeValue::Vec4([1.0; 4])
|
||||
);
|
||||
assert_eq!(
|
||||
NodeValue::Color([0.0; 4]).lerp(&NodeValue::Color([2.0; 4]), 0.5),
|
||||
NodeValue::Color([1.0; 4])
|
||||
);
|
||||
match NodeValue::Rational(Rational::new(0, 1)).lerp(&NodeValue::Rational(Rational::new(1, 1)), 0.5) {
|
||||
NodeValue::Rational(r) => assert!((r.to_f64() - 0.5).abs() < eps),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
// Non-interpolable types snap to self.
|
||||
assert_eq!(NodeValue::Int(3).lerp(&NodeValue::Int(9), 0.5), NodeValue::Int(3));
|
||||
assert_eq!(NodeValue::Text("a".into()).lerp(&NodeValue::Text("b".into()), 0.5), NodeValue::Text("a".into()));
|
||||
}
|
||||
|
||||
/// value.rs: NodeValue equality + SampleBuffer default + Drop on
|
||||
/// texture.
|
||||
#[test]
|
||||
fn value_equality_and_buffer() {
|
||||
assert_eq!(NodeValue::None, NodeValue::None);
|
||||
assert_ne!(NodeValue::None, float(1.0));
|
||||
assert_eq!(NodeValue::Int(1), NodeValue::Int(1));
|
||||
assert_ne!(NodeValue::Int(1), NodeValue::Int(2));
|
||||
assert_eq!(float(1.0), float(1.0));
|
||||
assert_eq!(NodeValue::Color([0.0; 4]), NodeValue::Color([0.0; 4]));
|
||||
assert_eq!(NodeValue::Text("a".into()), NodeValue::Text("a".into()));
|
||||
assert_eq!(NodeValue::Boolean(true), NodeValue::Boolean(true));
|
||||
assert_eq!(
|
||||
NodeValue::Rational(Rational::new(1, 2)),
|
||||
NodeValue::Rational(Rational::new(1, 2))
|
||||
);
|
||||
assert_eq!(NodeValue::Vec2([0.0; 2]), NodeValue::Vec2([0.0; 2]));
|
||||
assert_eq!(NodeValue::Vec3([0.0; 3]), NodeValue::Vec3([0.0; 3]));
|
||||
assert_eq!(NodeValue::Vec4([0.0; 4]), NodeValue::Vec4([0.0; 4]));
|
||||
assert_eq!(NodeValue::Combo(1), NodeValue::Combo(1));
|
||||
assert_eq!(NodeValue::StrCombo("s".into()), NodeValue::StrCombo("s".into()));
|
||||
assert_eq!(
|
||||
NodeValue::VideoParams(VideoParams::default()),
|
||||
NodeValue::VideoParams(VideoParams::default())
|
||||
);
|
||||
assert_eq!(
|
||||
NodeValue::AudioParams(AudioParams::default()),
|
||||
NodeValue::AudioParams(AudioParams::default())
|
||||
);
|
||||
assert_eq!(NodeValue::Binary(vec![1, 2]), NodeValue::Binary(vec![1, 2]));
|
||||
assert_eq!(
|
||||
NodeValue::NodeRef(NodeId::from_identity(4).unwrap()),
|
||||
NodeValue::NodeRef(NodeId::from_identity(4).unwrap())
|
||||
);
|
||||
assert_eq!(NodeValue::PushButton, NodeValue::PushButton);
|
||||
// Mismatched variants never equal.
|
||||
assert_ne!(float(1.0), NodeValue::Int(1));
|
||||
assert_ne!(NodeValue::Text("a".into()), NodeValue::StrCombo("a".into()));
|
||||
|
||||
// SampleBuffer::default.
|
||||
let sb = SampleBuffer::default();
|
||||
assert_eq!(sb.channels, 0);
|
||||
assert_eq!(sb.sample_count, 0);
|
||||
assert!(sb.data.is_empty());
|
||||
|
||||
// Samples equality compares payload.
|
||||
let a = SampleBuffer {
|
||||
format: oakcore_rs::SampleFormat::F32,
|
||||
channels: 2,
|
||||
sample_count: 4,
|
||||
data: vec![0u8; 32],
|
||||
};
|
||||
let b = SampleBuffer {
|
||||
format: oakcore_rs::SampleFormat::F32,
|
||||
channels: 2,
|
||||
sample_count: 4,
|
||||
data: vec![0u8; 32],
|
||||
};
|
||||
assert_eq!(NodeValue::Samples(a.clone()), NodeValue::Samples(b));
|
||||
}
|
||||
|
||||
/// value.rs: the oaknode_value POD conversions.
|
||||
#[test]
|
||||
fn oaknode_value_pod_roundtrip() {
|
||||
// to_node_value for every POD kind.
|
||||
assert_eq!(
|
||||
OakNodeValue { kind: oak::INT, num: 42, den: 0, f: [0.0; 4] }
|
||||
.to_node_value(ValueType::Int)
|
||||
.unwrap(),
|
||||
NodeValue::Int(42)
|
||||
);
|
||||
assert_eq!(
|
||||
OakNodeValue { kind: oak::COMBO, num: 1, den: 0, f: [0.0; 4] }
|
||||
.to_node_value(ValueType::Combo)
|
||||
.unwrap(),
|
||||
NodeValue::Int(1)
|
||||
);
|
||||
assert_eq!(
|
||||
OakNodeValue { kind: oak::FLOAT, num: 0, den: 0, f: [2.5, 0.0, 0.0, 0.0] }
|
||||
.to_node_value(ValueType::Float)
|
||||
.unwrap(),
|
||||
float(2.5)
|
||||
);
|
||||
assert_eq!(
|
||||
OakNodeValue { kind: oak::BOOL, num: 1, den: 0, f: [0.0; 4] }
|
||||
.to_node_value(ValueType::Boolean)
|
||||
.unwrap(),
|
||||
NodeValue::Boolean(true)
|
||||
);
|
||||
assert_eq!(
|
||||
OakNodeValue { kind: oak::RATIONAL, num: 1, den: 2, f: [0.0; 4] }
|
||||
.to_node_value(ValueType::Rational)
|
||||
.unwrap(),
|
||||
NodeValue::Rational(Rational::new(1, 2))
|
||||
);
|
||||
assert_eq!(
|
||||
OakNodeValue { kind: oak::COLOR, num: 0, den: 0, f: [1.0, 2.0, 3.0, 4.0] }
|
||||
.to_node_value(ValueType::Color)
|
||||
.unwrap(),
|
||||
NodeValue::Color([1.0, 2.0, 3.0, 4.0])
|
||||
);
|
||||
assert_eq!(
|
||||
OakNodeValue { kind: oak::VEC2, num: 0, den: 0, f: [1.0, 2.0, 0.0, 0.0] }
|
||||
.to_node_value(ValueType::Vec2)
|
||||
.unwrap(),
|
||||
NodeValue::Vec2([1.0, 2.0])
|
||||
);
|
||||
assert_eq!(
|
||||
OakNodeValue { kind: oak::VEC3, num: 0, den: 0, f: [1.0, 2.0, 3.0, 0.0] }
|
||||
.to_node_value(ValueType::Vec3)
|
||||
.unwrap(),
|
||||
NodeValue::Vec3([1.0, 2.0, 3.0])
|
||||
);
|
||||
assert_eq!(
|
||||
OakNodeValue { kind: oak::VEC4, num: 0, den: 0, f: [1.0, 2.0, 3.0, 4.0] }
|
||||
.to_node_value(ValueType::Vec4)
|
||||
.unwrap(),
|
||||
NodeValue::Vec4([1.0, 2.0, 3.0, 4.0])
|
||||
);
|
||||
// Invalid kinds are rejected.
|
||||
assert!(OakNodeValue::none().to_node_value(ValueType::Float).is_err());
|
||||
assert!(
|
||||
OakNodeValue { kind: oak::STRING, num: 0, den: 0, f: [0.0; 4] }
|
||||
.to_node_value(ValueType::Text)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// from_node_value: round-trips and error arms.
|
||||
let pod = OakNodeValue::from_node_value(ValueType::Float, &float(3.0)).unwrap();
|
||||
assert_eq!(pod.kind, oak::FLOAT);
|
||||
assert_eq!(pod.f[0], 3.0);
|
||||
|
||||
let pod = OakNodeValue::from_node_value(ValueType::Int, &NodeValue::Int(7)).unwrap();
|
||||
assert_eq!(pod.kind, oak::INT);
|
||||
assert_eq!(pod.num, 7);
|
||||
let pod = OakNodeValue::from_node_value(ValueType::Combo, &NodeValue::Combo(2)).unwrap();
|
||||
assert_eq!(pod.num, 2);
|
||||
let pod = OakNodeValue::from_node_value(ValueType::Boolean, &NodeValue::Boolean(true)).unwrap();
|
||||
assert_eq!(pod.num, 1);
|
||||
let pod = OakNodeValue::from_node_value(
|
||||
ValueType::Rational,
|
||||
&NodeValue::Rational(Rational::new(3, 4)),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((pod.num, pod.den), (3, 4));
|
||||
// Rational declared with a non-rational payload coerces via to_double.
|
||||
let pod = OakNodeValue::from_node_value(ValueType::Rational, &float(1.5)).unwrap();
|
||||
assert_eq!((pod.num, pod.den), (1, 1));
|
||||
let pod = OakNodeValue::from_node_value(ValueType::None, &NodeValue::None).unwrap();
|
||||
assert_eq!(pod.kind, oak::NONE);
|
||||
let pod = OakNodeValue::from_node_value(
|
||||
ValueType::Color,
|
||||
&NodeValue::Color([1.0, 2.0, 3.0, 4.0]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(pod.f, [1.0, 2.0, 3.0, 4.0]);
|
||||
let pod = OakNodeValue::from_node_value(ValueType::Vec2, &NodeValue::Vec2([1.0, 2.0])).unwrap();
|
||||
assert_eq!((pod.f[0], pod.f[1]), (1.0, 2.0));
|
||||
let pod = OakNodeValue::from_node_value(
|
||||
ValueType::Vec3,
|
||||
&NodeValue::Vec3([1.0, 2.0, 3.0]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(pod.f[2], 3.0);
|
||||
let pod = OakNodeValue::from_node_value(
|
||||
ValueType::Vec4,
|
||||
&NodeValue::Vec4([1.0, 2.0, 3.0, 4.0]),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(pod.f[3], 4.0);
|
||||
// Error arms: string declared -> Invalid; wrong payload -> Failed;
|
||||
// non-POD declared -> Failed.
|
||||
assert!(OakNodeValue::from_node_value(ValueType::Text, &NodeValue::Text("x".into())).is_err());
|
||||
assert!(OakNodeValue::from_node_value(ValueType::Color, &float(1.0)).is_err());
|
||||
assert!(OakNodeValue::from_node_value(ValueType::Texture, &NodeValue::None).is_err());
|
||||
}
|
||||
|
||||
/// node.rs: NodeCore helper surface.
|
||||
#[test]
|
||||
fn node_core_helpers() {
|
||||
let mut core = NodeCore::new();
|
||||
// new() adds enabled_in first.
|
||||
assert!(core.has_input("enabled_in"));
|
||||
assert_eq!(core.input_index("enabled_in"), Some(0));
|
||||
assert_eq!(core.inputs.len(), 1);
|
||||
assert_eq!(core.input_data_type("enabled_in"), Some(ValueType::Boolean));
|
||||
assert_eq!(core.input_flags("enabled_in"), 0);
|
||||
assert_eq!(core.input_display_name("enabled_in"), "enabled_in");
|
||||
|
||||
// add_input + queries.
|
||||
let mut input = Input::new("val_in", ValueType::Float, float(0.0));
|
||||
input.flags |= flags::ARRAY | flags::NOT_KEYFRAMABLE;
|
||||
input.display_name = "Value".to_string();
|
||||
core.add_input(input);
|
||||
assert!(core.has_input("val_in"));
|
||||
assert_eq!(core.input_index("val_in"), Some(1));
|
||||
assert_eq!(core.input_data_type("val_in"), Some(ValueType::Float));
|
||||
assert_eq!(
|
||||
core.input_flags("val_in"),
|
||||
flags::ARRAY | flags::NOT_KEYFRAMABLE
|
||||
);
|
||||
assert_eq!(core.input_display_name("val_in"), "Value");
|
||||
assert_eq!(core.input_display_name("missing"), "missing");
|
||||
assert!(core.get_input("missing").is_none());
|
||||
assert!(core.get_input_mut("missing").is_none());
|
||||
|
||||
// Standard values: fallback to default, then override.
|
||||
assert_eq!(core.standard_value("val_in", -1), float(0.0));
|
||||
core.set_standard_value("val_in", -1, float(5.0));
|
||||
assert_eq!(core.standard_value("val_in", -1), float(5.0));
|
||||
core.set_standard_value("val_in", 2, float(9.0));
|
||||
assert_eq!(core.standard_value("val_in", 2), float(9.0));
|
||||
assert_eq!(core.standard_value("val_in", 3), float(0.0), "unset element -> default");
|
||||
|
||||
// Array size / insert / remove.
|
||||
assert_eq!(core.input_array_size("val_in"), 0);
|
||||
core.input_array_insert("val_in", 0);
|
||||
core.input_array_insert("val_in", 1);
|
||||
assert_eq!(core.input_array_size("val_in"), 2);
|
||||
core.set_standard_value("val_in", 1, float(7.0));
|
||||
core.input_array_insert("val_in", 0); // shifts element 1 -> 2
|
||||
assert_eq!(core.standard_value("val_in", 2), float(7.0), "values shift on insert");
|
||||
assert_eq!(core.standard_value("val_in", 1), float(0.0), "inserted slot cleared");
|
||||
core.input_array_remove("val_in", 0);
|
||||
assert_eq!(core.standard_value("val_in", 1), float(7.0), "values shift on remove");
|
||||
assert_eq!(core.input_array_size("val_in"), 2);
|
||||
core.input_array_remove("val_in", 99); // out of range: no-op
|
||||
assert_eq!(core.input_array_size("val_in"), 2);
|
||||
|
||||
// value_at_time uses the standard value when the track is empty
|
||||
// (element 1 holds 7.0 after the shifts above).
|
||||
assert_eq!(core.value_at_time("val_in", 1, Rational::new(0, 1)), float(7.0));
|
||||
assert_eq!(core.value_at_time("val_in", 9, Rational::new(0, 1)), float(0.0));
|
||||
|
||||
// Keyframe tracks.
|
||||
{
|
||||
let track = core.keyframe_track_mut("val_in", -1);
|
||||
track.set_key(Keyframe {
|
||||
time: Rational::new(0, 1),
|
||||
value: float(1.0),
|
||||
interpolation: Interpolation::Linear,
|
||||
bezier_in: (0.0, 0.0),
|
||||
bezier_out: (0.0, 0.0),
|
||||
});
|
||||
}
|
||||
// Element keyframes shift with array insert/remove.
|
||||
{
|
||||
let track = core.keyframe_track_mut("val_in", 0);
|
||||
track.set_key(Keyframe {
|
||||
time: Rational::new(1, 1),
|
||||
value: float(5.0),
|
||||
interpolation: Interpolation::Hold,
|
||||
bezier_in: (0.0, 0.0),
|
||||
bezier_out: (0.0, 0.0),
|
||||
});
|
||||
}
|
||||
core.input_array_insert("val_in", 0); // element-0 track shifts to 1
|
||||
assert!(core.keyframe_track("val_in", 0).is_none());
|
||||
assert!(core.keyframe_track("val_in", 1).is_some());
|
||||
core.input_array_remove("val_in", 0); // element-1 track shifts back to 0
|
||||
assert!(core.keyframe_track("val_in", 0).is_some());
|
||||
assert!(core.keyframe_track("val_in", 1).is_none());
|
||||
core.input_array_remove("val_in", 0);
|
||||
assert!(core.keyframe_track("val_in", 0).is_none(), "removed element drops its track");
|
||||
assert!(core.keyframe_track("val_in", -1).is_some());
|
||||
assert!(core.keyframe_track("missing", -1).is_none());
|
||||
// value_at_time uses keyframes when the track is non-empty.
|
||||
assert_eq!(core.value_at_time("val_in", -1, Rational::new(0, 1)), float(1.0));
|
||||
|
||||
// Value hints.
|
||||
assert!(core.value_hint("val_in", -1).is_none());
|
||||
let hint = ValueHint {
|
||||
types: vec![ValueType::Texture],
|
||||
index: 0,
|
||||
tag: "0:1".to_string(),
|
||||
};
|
||||
core.set_value_hint("val_in", -1, hint.clone());
|
||||
match core.value_hint("val_in", -1) {
|
||||
Some(h) => {
|
||||
assert_eq!(h.types, &[ValueType::Texture]);
|
||||
assert_eq!(h.index, 0);
|
||||
assert_eq!(h.tag, "0:1");
|
||||
}
|
||||
None => panic!("hint missing"),
|
||||
}
|
||||
core.set_value_hint("val_in", -1, hint.clone()); // replace
|
||||
assert!(core.value_hint("val_in", -1).is_some());
|
||||
|
||||
// Context positions.
|
||||
let ctx = NodeId::from_identity(7).unwrap();
|
||||
assert!(!core.context_contains(ctx));
|
||||
assert!(core.set_context_position(ctx, 1.0, 2.0, true));
|
||||
assert!(core.context_contains(ctx));
|
||||
assert!(!core.set_context_position(ctx, 3.0, 4.0, false), "replace returns false");
|
||||
assert_eq!(core.context_positions.len(), 1);
|
||||
assert_eq!(core.context_positions[0].1, (3.0, 4.0));
|
||||
assert!(core.remove_from_context(ctx));
|
||||
assert!(!core.remove_from_context(ctx));
|
||||
|
||||
// Links.
|
||||
core.links.push(NodeId::from_identity(9).unwrap());
|
||||
assert_eq!(core.links, vec![NodeId::from_identity(9).unwrap()]);
|
||||
|
||||
// NodeCore::empty has no enabled_in.
|
||||
assert!(!NodeCore::empty().has_input("enabled_in"));
|
||||
}
|
||||
|
||||
/// node.rs: every NodeBehavior default trait method runs without panic
|
||||
/// and returns its documented neutral value.
|
||||
#[test]
|
||||
fn node_behavior_defaults() {
|
||||
use oaknode::node::NodeBehavior;
|
||||
|
||||
struct Minimal;
|
||||
impl NodeBehavior for Minimal {
|
||||
fn name(&self) -> &str {
|
||||
"N"
|
||||
}
|
||||
fn type_id(&self) -> &str {
|
||||
"t"
|
||||
}
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(Minimal))
|
||||
}
|
||||
}
|
||||
|
||||
let mut b = Minimal;
|
||||
let core = NodeCore::new();
|
||||
assert_eq!(b.short_name(), "N");
|
||||
assert_eq!(b.categories(), &[] as &[Category]);
|
||||
assert_eq!(b.sub_category(), "");
|
||||
assert_eq!(b.description(), "");
|
||||
assert_eq!(b.input_name("x"), "x");
|
||||
assert_eq!(b.input_name("enabled_in"), "Enabled");
|
||||
assert_eq!(b.ignore_inputs_for_rendering(), &[] as &[String]);
|
||||
assert!(b.active_elements_at_time("in", Rational::new(0, 1)).is_empty());
|
||||
assert_eq!(b.video_cache_range(&core), TimeRange::default());
|
||||
assert_eq!(b.audio_cache_range(&core), TimeRange::default());
|
||||
assert!(b.value_hint_for_input("in").is_none());
|
||||
assert_eq!(b.connected_render_output(&core, "in", -1), None);
|
||||
let tr = TimeRange::new(Rational::new(0, 1), Rational::new(5, 1));
|
||||
assert_eq!(b.input_time_adjustment("in", -1, tr, true), tr);
|
||||
assert_eq!(b.output_time_adjustment("in", -1, tr, false), tr);
|
||||
|
||||
// value / process_samples / generate_frame no-ops.
|
||||
let mut table = NodeValueTable::default();
|
||||
let mut row = std::collections::BTreeMap::new();
|
||||
b.value(&core, &row, Rational::new(0, 1), &mut table);
|
||||
assert!(table.is_empty());
|
||||
let mut samples = SampleBuffer::default();
|
||||
b.process_samples(&core, &row, tr, &mut samples);
|
||||
assert!(samples.data.is_empty());
|
||||
b.generate_frame(&core, &mut CHandle::null(), Rational::new(0, 1));
|
||||
assert!(b.shader_code("any").is_none());
|
||||
|
||||
// gizmo / event defaults are inert.
|
||||
b.gizmo_update(&core, &row);
|
||||
let mut core_mut = NodeCore::new();
|
||||
b.gizmo_drag(&mut core_mut, true, 1.0, 2.0, 0);
|
||||
b.input_value_changed(&mut core_mut, "in", -1);
|
||||
b.input_connected(&mut core_mut, "in", -1, NodeId::from_identity(1).unwrap());
|
||||
b.input_disconnected(&mut core_mut, "in", -1, NodeId::from_identity(1).unwrap());
|
||||
b.output_connected(&mut core_mut, NodeId::from_identity(2).unwrap(), "in", -1);
|
||||
b.output_disconnected(&mut core_mut, NodeId::from_identity(2).unwrap(), "in", -1);
|
||||
b.connected_to_preview(&mut core_mut);
|
||||
b.added_to_graph(&mut core_mut);
|
||||
b.removed_from_graph(&mut core_mut);
|
||||
b.link_changed(&mut core_mut);
|
||||
assert!(b.duplicate(&core).is_some());
|
||||
|
||||
// load_custom/save_custom/post_load/legacy id mapping.
|
||||
assert!(b.load_custom(&mut core_mut, &mut NoopReader));
|
||||
b.save_custom(&core, &mut NoopWriter);
|
||||
b.post_load(&mut core_mut);
|
||||
assert_eq!(b.map_legacy_input_id("old"), "old");
|
||||
}
|
||||
|
||||
/// A reader/writer pair for the serializer-default coverage (the real
|
||||
/// XML adapter lands with the serializer milestone).
|
||||
struct NoopReader;
|
||||
impl oaknode::serializer::XmlRead for NoopReader {
|
||||
fn next_start_element(&mut self) -> bool {
|
||||
false
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
""
|
||||
}
|
||||
fn attribute(&self, _name: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn read_element_text(&mut self) -> String {
|
||||
String::new()
|
||||
}
|
||||
fn skip_current_element(&mut self) {}
|
||||
}
|
||||
|
||||
struct NoopWriter;
|
||||
impl oaknode::serializer::XmlWrite for NoopWriter {
|
||||
fn start_element(&mut self, _name: &str) {}
|
||||
fn end_element(&mut self) {}
|
||||
fn attribute(&mut self, _name: &str, _value: &str) {}
|
||||
fn text_element(&mut self, _name: &str, _text: &str) {}
|
||||
}
|
||||
|
||||
/// node.rs: NodeCaches default + clone + enabled-in default value.
|
||||
#[test]
|
||||
fn node_caches_and_defaults() {
|
||||
let caches = oaknode::node::NodeCaches::default();
|
||||
assert!(caches.video.ctx.is_null());
|
||||
assert!(caches.thumbnail.ctx.is_null());
|
||||
assert!(caches.audio.ctx.is_null());
|
||||
assert!(caches.waveform.ctx.is_null());
|
||||
|
||||
let core = NodeCore::new();
|
||||
assert_eq!(
|
||||
core.standard_value("enabled_in", -1),
|
||||
NodeValue::Boolean(true),
|
||||
"enabled defaults to true"
|
||||
);
|
||||
}
|
||||
|
||||
/// folder.rs: FolderBehavior surface.
|
||||
#[test]
|
||||
fn folder_behavior_surface() {
|
||||
let mut folder = oaknode::folder::FolderBehavior::new("My Folder");
|
||||
assert_eq!(folder.name, "My Folder");
|
||||
assert!(folder.children.is_empty());
|
||||
folder.children.push(NodeId::from_identity(1).unwrap());
|
||||
|
||||
let b: Box<dyn NodeBehavior> = Box::new(oaknode::folder::FolderBehavior::new("X"));
|
||||
assert_eq!(b.name(), "X");
|
||||
assert_eq!(b.type_id(), "org.olivevideoeditor.Olive.folder");
|
||||
assert_eq!(b.categories(), &[Category::Timeline]);
|
||||
assert!(b.duplicate(&NodeCore::new()).is_some());
|
||||
|
||||
// create() builds a folder node (bare core, no enabled_in).
|
||||
let (core, behavior) = oaknode::folder::create("Root");
|
||||
assert!(!core.has_input("enabled_in"));
|
||||
assert_eq!(behavior.name(), "Root");
|
||||
|
||||
// register() adds a folder entry to the registry table.
|
||||
let mut meta = Vec::new();
|
||||
oaknode::folder::register(&mut meta);
|
||||
assert_eq!(meta.len(), 1);
|
||||
assert_eq!(meta[0].type_id, "org.olivevideoeditor.Olive.folder");
|
||||
let (_, b2) = (meta[0].create)();
|
||||
assert_eq!(b2.name(), "Folder");
|
||||
}
|
||||
|
||||
/// ops.rs: category names and copy_inputs.
|
||||
#[test]
|
||||
fn ops_category_and_copy_inputs() {
|
||||
use oaknode::graph::Graph;
|
||||
use oaknode::ops;
|
||||
|
||||
assert_eq!(ops::category_name(Category::Output), "Output");
|
||||
assert_eq!(ops::category_name(Category::Effect), "Effect");
|
||||
assert_eq!(ops::category_name(Category::Generator), "Generator");
|
||||
assert_eq!(ops::category_name(Category::Input), "Input");
|
||||
assert_eq!(ops::category_name(Category::Math), "Math");
|
||||
assert_eq!(ops::category_name(Category::Color), "Color");
|
||||
assert_eq!(ops::category_name(Category::Distort), "Distort");
|
||||
assert_eq!(ops::category_name(Category::Filter), "Filter");
|
||||
assert_eq!(ops::category_name(Category::Keying), "Keying");
|
||||
assert_eq!(ops::category_name(Category::OpenFx), "OpenFX");
|
||||
assert_eq!(ops::category_name(Category::Timeline), "Timeline");
|
||||
assert_eq!(ops::category_name(Category::Group), "Group");
|
||||
|
||||
// copy_inputs copies standard values (and connections when asked).
|
||||
let mut g = Graph::new();
|
||||
let mk = |g: &mut Graph, id: &str| {
|
||||
let mut core = NodeCore::new();
|
||||
core.add_input(Input::new(id, ValueType::Float, float(0.0)));
|
||||
g.add_node(core, Box::new(oaknode::nodes::EmptyBehavior))
|
||||
};
|
||||
let src = mk(&mut g, "val_in");
|
||||
let dst = mk(&mut g, "val_in");
|
||||
let other = mk(&mut g, "other_in");
|
||||
g.connect(src, other, "other_in", -1).unwrap();
|
||||
|
||||
// Without connections: values copy, edges do not.
|
||||
g.get_mut(src).unwrap().core.set_standard_value("val_in", -1, float(42.0));
|
||||
ops::copy_inputs(&mut g, src, dst, false).unwrap();
|
||||
assert_eq!(g.get(dst).unwrap().core.standard_value("val_in", -1), float(42.0));
|
||||
assert!(!g.is_input_connected(dst, "val_in", -1));
|
||||
|
||||
// With connections: dst's matching input reconnects to src's sources.
|
||||
let up = mk(&mut g, "val_in");
|
||||
g.connect(up, src, "val_in", -1).unwrap();
|
||||
let dst2 = mk(&mut g, "val_in");
|
||||
ops::copy_inputs(&mut g, src, dst2, true).unwrap();
|
||||
assert_eq!(g.connected_output(dst2, "val_in", -1), Some(up));
|
||||
|
||||
// Missing source/dest -> E_NOT_FOUND.
|
||||
assert!(ops::copy_inputs(&mut g, NodeId::INVALID, dst, false).is_err());
|
||||
assert!(ops::copy_inputs(&mut g, src, NodeId::INVALID, false).is_err());
|
||||
}
|
||||
|
||||
/// handle.rs: null/is_null/guards + refcount discipline.
|
||||
#[test]
|
||||
fn handle_helpers_and_guards() {
|
||||
use oaknode::error::OAKNODE_OK;
|
||||
|
||||
let null = CHandle::null();
|
||||
assert!(null.is_null());
|
||||
assert!(unsafe { handle::get::<u32>(&null) }.is_none());
|
||||
|
||||
// guard: Ok -> OK; Err -> mapped code; panic -> E_FAILED.
|
||||
assert_eq!(
|
||||
handle::guard(|| Ok(())),
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(
|
||||
handle::guard(|| Err(Error::Invalid)),
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
assert_eq!(
|
||||
handle::guard(|| -> Result<(), Error> { panic!("boom") }),
|
||||
OAKNODE_E_FAILED
|
||||
);
|
||||
|
||||
// guard_handle: Ok -> handle; Err/panic -> empty.
|
||||
let h = handle::guard_handle(|| Ok(handle::make_owned(5u32)));
|
||||
assert!(!h.ctx.is_null());
|
||||
assert!(handle::guard_handle(|| -> Result<CHandle, Error> { Err(Error::NotFound) }).ctx.is_null());
|
||||
assert!(handle::guard_handle(|| -> Result<CHandle, Error> { panic!("x") }).ctx.is_null());
|
||||
|
||||
// guard_void swallows panics.
|
||||
handle::guard_void(|| panic!("swallowed"));
|
||||
|
||||
// make_owned / make_owned_with / make_borrowed round-trip.
|
||||
let owned = handle::make_owned(7u32);
|
||||
let rb = owned.ctx as *const RefBox<u32>;
|
||||
unsafe {
|
||||
assert_eq!((*rb).refs.load(std::sync::atomic::Ordering::Relaxed), 1);
|
||||
}
|
||||
let value = 9u32;
|
||||
let borrowed = unsafe { handle::make_borrowed(&value as *const u32 as *mut u32) };
|
||||
assert!(!borrowed.ctx.is_null());
|
||||
assert_eq!(unsafe { handle::get::<u32>(&borrowed) }, Some(&9u32));
|
||||
assert!(unsafe { handle::make_borrowed::<u32>(std::ptr::null_mut()) }.is_null());
|
||||
|
||||
// make_owned_with uses a custom release.
|
||||
unsafe extern "C" fn custom_release(ctx: *mut std::ffi::c_void) {
|
||||
unsafe {
|
||||
let rb = ctx as *mut RefBox<String>;
|
||||
if (*rb).refs.fetch_sub(1, std::sync::atomic::Ordering::AcqRel) == 1 {
|
||||
drop(Box::from_raw(rb));
|
||||
}
|
||||
}
|
||||
}
|
||||
let custom = handle::make_owned_with("hello".to_string(), custom_release);
|
||||
assert_eq!(unsafe { handle::get::<String>(&custom) }, Some(&"hello".to_string()));
|
||||
|
||||
// Release everything (single release each).
|
||||
for h in [owned, borrowed, custom] {
|
||||
unsafe { (h.release.unwrap())(h.ctx) };
|
||||
}
|
||||
}
|
||||
|
||||
/// error.rs: code mapping for every variant.
|
||||
#[test]
|
||||
fn error_codes_map() {
|
||||
assert_eq!(Error::Invalid.code(), OAKNODE_E_INVALID);
|
||||
assert_eq!(Error::State.code(), oaknode::error::OAKNODE_E_STATE);
|
||||
assert_eq!(Error::Failed("x".to_string()).code(), oaknode::error::OAKNODE_E_FAILED);
|
||||
assert_eq!(Error::NotFound.code(), oaknode::error::OAKNODE_E_NOT_FOUND);
|
||||
assert_eq!(Error::NoMem.code(), oaknode::error::OAKNODE_E_NOMEM);
|
||||
assert_eq!(oaknode::error::OAKNODE_OK, 0);
|
||||
}
|
||||
|
||||
/// id.rs: identity packing and INVALID sentinel.
|
||||
#[test]
|
||||
fn node_id_identity_packing() {
|
||||
// from_identity(5) = index 5, generation 0.
|
||||
let id = NodeId::from_identity(5).unwrap();
|
||||
assert_eq!(id.index(), 5);
|
||||
assert_eq!(id.generation(), 0);
|
||||
assert_eq!(id.identity(), 5);
|
||||
let gen = NodeId::from_identity((3u64 << 32) | 5).unwrap();
|
||||
assert_eq!(gen.generation(), 3);
|
||||
assert_eq!(gen.index(), 5);
|
||||
assert_eq!(NodeId::from_identity(gen.identity()), Some(gen));
|
||||
assert!(NodeId::from_identity(0xdead).is_some());
|
||||
assert!(NodeId::from_identity(u32::MAX as u64).is_none(), "invalid index rejected");
|
||||
assert!(!NodeId::INVALID.valid());
|
||||
assert!(id.valid());
|
||||
}
|
||||
|
||||
/// value.rs: NodeValueTable row helpers (count/is_empty/clear/get).
|
||||
#[test]
|
||||
fn value_table_rows() {
|
||||
let mut t = NodeValueTable::default();
|
||||
assert!(t.is_empty());
|
||||
t.push(ValueType::Int, NodeValue::Int(1), None);
|
||||
t.push(ValueType::Float, float(2.0), Some("tag".to_string()));
|
||||
assert_eq!(t.count(), 2);
|
||||
assert!(!t.is_empty());
|
||||
assert_eq!(t.get(ValueType::Float), Some(&float(2.0)));
|
||||
assert_eq!(t.get(ValueType::Combo), None);
|
||||
t.clear();
|
||||
assert!(t.is_empty());
|
||||
assert_eq!(t.count(), 0);
|
||||
}
|
||||
|
||||
/// TimeRange sanity (used by caches) — a smoke through oakcore-rs.
|
||||
#[test]
|
||||
fn time_range_smoke() {
|
||||
let r = TimeRange::new(Rational::new(0, 1), Rational::new(10, 1));
|
||||
assert_eq!(r.length(), Rational::new(10, 1));
|
||||
assert!(r.contains(Rational::new(5, 1)));
|
||||
assert!(!r.contains(Rational::new(10, 1)));
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Built-in node registration smoke tests: `nodes::register_all()`
|
||||
//! builds the factory entry table in C++ registration order
|
||||
//! (`factory.cpp::create_from_factory_index` — the switch order, NOT
|
||||
//! the `factory.h` InternalID enum order: `initialize()` iterates the
|
||||
//! enum indices but the switch reorders log/white-balance after
|
||||
//! linear, and tile/swirl/ripple after wave).
|
||||
|
||||
use oaknode::factory::Factory;
|
||||
use oaknode::node::Category;
|
||||
|
||||
/// Expected `type_id` sequence, in C++ registration order
|
||||
/// (`factory.cpp::create_from_factory_index`; the plugin nodes that
|
||||
/// follow are registered at runtime by the oakplugin bridge and are not
|
||||
/// part of this table).
|
||||
const EXPECTED_ORDER: &[&str] = &[
|
||||
"org.olivevideoeditor.Olive.polygon",
|
||||
"org.olivevideoeditor.Olive.ortho",
|
||||
"org.olivevideoeditor.Olive.transform",
|
||||
"org.olivevideoeditor.Olive.volume",
|
||||
"org.olivevideoeditor.Olive.pan",
|
||||
"org.olivevideoeditor.Olive.math",
|
||||
"org.olivevideoeditor.Olive.time",
|
||||
"org.olivevideoeditor.Olive.trigonometry",
|
||||
"org.olivevideoeditor.Olive.blur",
|
||||
"org.olivevideoeditor.Olive.solidgenerator",
|
||||
"org.olivevideoeditor.Olive.merge",
|
||||
"org.olivevideoeditor.Olive.stroke",
|
||||
"org.olivevideoeditor.Olive.textgenerator",
|
||||
"org.olivevideoeditor.Olive.text2",
|
||||
"org.olivevideoeditor.Olive.text3",
|
||||
"org.olivevideoeditor.Olive.mosaicfilter",
|
||||
"org.olivevideoeditor.Olive.crop",
|
||||
"org.olivevideoeditor.Olive.value",
|
||||
"org.olivevideoeditor.Olive.timeremap",
|
||||
"org.olivevideoeditor.Olive.shape",
|
||||
"org.olivevideoeditor.Olive.colordifferencekey",
|
||||
"org.olivevideoeditor.Olive.despill",
|
||||
"org.olivevideoeditor.Olive.group",
|
||||
"org.olivevideoeditor.Olive.opacity",
|
||||
"org.olivevideoeditor.Olive.flip",
|
||||
"org.olivevideoeditor.Olive.noise",
|
||||
"org.olivevideoeditor.Olive.timeoffset",
|
||||
"org.olivevideoeditor.Olive.cornerpin",
|
||||
"org.olivevideoeditor.Olive.displaytransform",
|
||||
"org.olivevideoeditor.Olive.ociogradingtransformlinear",
|
||||
"org.olivevideoeditor.Olive.OCIO_NAMESPACEgradingtransformlog",
|
||||
"org.olivevideoeditor.Olive.whitebalance",
|
||||
"org.olivevideoeditor.Olive.ociolut",
|
||||
"org.olivevideoeditor.Olive.threewaycolor",
|
||||
"org.olivevideoeditor.Olive.chromakey",
|
||||
"org.olivevideoeditor.Olive.mask",
|
||||
"org.olivevideoeditor.Olive.dropshadow",
|
||||
"org.olivevideoeditor.Olive.timeformat",
|
||||
"org.olivevideoeditor.Olive.wave",
|
||||
"org.olivevideoeditor.Olive.tile",
|
||||
"org.olivevideoeditor.Olive.swirl",
|
||||
"org.olivevideoeditor.Olive.ripple",
|
||||
"org.olivevideoeditor.Olive.multicam",
|
||||
];
|
||||
|
||||
/// `register_all()` installs every built-in node, exactly once, in C++
|
||||
/// factory order.
|
||||
#[test]
|
||||
fn registered_entries_match_cpp_order() {
|
||||
let entries = Factory::global().entries();
|
||||
assert_eq!(entries.len(), EXPECTED_ORDER.len());
|
||||
for (i, (entry, expected)) in entries.iter().zip(EXPECTED_ORDER.iter()).enumerate() {
|
||||
assert_eq!(entry.type_id, *expected, "mismatch at entry {i}");
|
||||
}
|
||||
}
|
||||
|
||||
/// First and last entries are polygon and multi-cam respectively
|
||||
/// (multi-cam is the last built-in in `factory.cpp` switch order).
|
||||
#[test]
|
||||
fn first_and_last_entries() {
|
||||
let entries = Factory::global().entries();
|
||||
assert_eq!(entries.first().unwrap().type_id, "org.olivevideoeditor.Olive.polygon");
|
||||
assert_eq!(entries.first().unwrap().name, "Polygon");
|
||||
assert_eq!(entries.last().unwrap().type_id, "org.olivevideoeditor.Olive.multicam");
|
||||
assert_eq!(entries.last().unwrap().name, "Multi-Cam");
|
||||
}
|
||||
|
||||
/// `find()` resolves a type id to its metadata (pan, index 4 in the
|
||||
/// table).
|
||||
#[test]
|
||||
fn find_pan_entry() {
|
||||
let meta = Factory::global()
|
||||
.find("org.olivevideoeditor.Olive.pan")
|
||||
.expect("pan is a built-in node");
|
||||
assert_eq!(meta.name, "Pan");
|
||||
assert_eq!(meta.categories, &[Category::Filter]);
|
||||
}
|
||||
@@ -0,0 +1,893 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! C ABI contract tests (ffi.rs). One normal + one error path per
|
||||
//! export family; the exhaustive matrix is driven from the existing
|
||||
//! C++ gtest suite (`src/node/tests`, unchanged) running against this
|
||||
//! crate — these tests only pin Rust-side specifics.
|
||||
//!
|
||||
//! Handles follow the C by-value contract: every function call that
|
||||
//! consumes a handle receives `dup(&h)` (addref + copy), and every dup
|
||||
//! is eventually released. Tests serialize on [`ffi_lock`] because they
|
||||
//! mutate the process-wide debug alive counter.
|
||||
|
||||
use std::ffi::{c_char, CString};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
use oaknode::error::{OAKNODE_E_INVALID, OAKNODE_E_NOT_FOUND, OAKNODE_OK};
|
||||
use oaknode::ffi::factory::{
|
||||
oaknode_factory_create_from_id, oaknode_factory_id_at, oaknode_factory_id_count,
|
||||
oaknode_factory_initialize, oaknode_factory_name_from_id, oaknode_factory_node_at,
|
||||
};
|
||||
use oaknode::ffi::keyframe::oaknode_keyframe_opposing_bezier_type;
|
||||
use oaknode::ffi::node::{
|
||||
oaknode_debug_alive_count, oaknode_node_are_linked, oaknode_node_connect,
|
||||
oaknode_node_context_count, oaknode_node_context_node_at, oaknode_node_copy_inputs,
|
||||
oaknode_node_create_copy, oaknode_node_disconnect, oaknode_node_free,
|
||||
oaknode_node_from_identity, oaknode_node_get_context_position, oaknode_node_get_id,
|
||||
oaknode_node_get_input, oaknode_node_get_input_at_time, oaknode_node_get_input_name,
|
||||
oaknode_node_get_input_string, oaknode_node_get_label, oaknode_node_get_name,
|
||||
oaknode_node_get_override_color, oaknode_node_get_project, oaknode_node_identity,
|
||||
oaknode_node_input_array_insert, oaknode_node_input_array_remove,
|
||||
oaknode_node_input_count, oaknode_node_input_get_connected_node,
|
||||
oaknode_node_input_get_type, oaknode_node_input_id, oaknode_node_input_is_connectable,
|
||||
oaknode_node_input_is_connected, oaknode_node_is_enabled, oaknode_node_link,
|
||||
oaknode_node_link_at, oaknode_node_link_count, oaknode_node_output_connection_count,
|
||||
oaknode_node_output_connection_element_at, oaknode_node_output_connection_input_id_at,
|
||||
oaknode_node_output_connection_node_at, oaknode_node_remove_from_context,
|
||||
oaknode_node_set_context_position, oaknode_node_set_enabled, oaknode_node_set_input,
|
||||
oaknode_node_set_input_string, oaknode_node_set_label, oaknode_node_set_override_color,
|
||||
oaknode_node_set_value_hint_track, oaknode_node_unlink,
|
||||
};
|
||||
use oaknode::ffi::project::{
|
||||
oaknode_project_add_node, oaknode_project_cache_path, oaknode_project_clear,
|
||||
oaknode_project_copy_settings, oaknode_project_filename, oaknode_project_free,
|
||||
oaknode_project_get_cache_location_setting, oaknode_project_get_custom_cache_path,
|
||||
oaknode_project_get_uuid, oaknode_project_init, oaknode_project_initialize,
|
||||
oaknode_project_is_modified, oaknode_project_is_new, oaknode_project_name,
|
||||
oaknode_project_node_at, oaknode_project_node_count, oaknode_project_pretty_filename,
|
||||
oaknode_project_remove_node, oaknode_project_root, oaknode_project_set_cache_location_setting,
|
||||
oaknode_project_set_custom_cache_path, oaknode_project_set_filename,
|
||||
oaknode_project_set_modified,
|
||||
};
|
||||
use oaknode::handle::CHandle;
|
||||
use oaknode::value::{oak, OakNodeValue};
|
||||
|
||||
/// Serialize all tests touching the global alive counter / factory.
|
||||
static FFI_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn ffi_lock() -> MutexGuard<'static, ()> {
|
||||
FFI_LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// By-value handle copy. The ffi callees never release the handles they
|
||||
/// receive, so a bitwise clone is safe here: every box is released
|
||||
/// exactly once, via the original handle's free call.
|
||||
fn dup(h: &CHandle) -> CHandle {
|
||||
h.clone()
|
||||
}
|
||||
|
||||
fn cs(s: &str) -> CString {
|
||||
CString::new(s).unwrap()
|
||||
}
|
||||
|
||||
/// Read a two-stage getter's result into a String; the caller passes
|
||||
/// the getter closure (returns required size / error code).
|
||||
fn two_stage<F: Fn(*mut c_char, i32) -> i32>(getter: F) -> Result<String, i32> {
|
||||
let needed = getter(std::ptr::null_mut(), 0);
|
||||
if needed < 0 {
|
||||
return Err(needed);
|
||||
}
|
||||
let mut buf = vec![0u8; needed as usize];
|
||||
let rc = getter(buf.as_mut_ptr() as *mut c_char, needed);
|
||||
if rc < 0 {
|
||||
return Err(rc);
|
||||
}
|
||||
buf.pop(); // trailing NUL
|
||||
Ok(String::from_utf8(buf).unwrap())
|
||||
}
|
||||
|
||||
fn init_project() -> CHandle {
|
||||
let p = unsafe { oaknode_project_init() };
|
||||
assert!(!p.ctx.is_null());
|
||||
assert_eq!(p.abi_version, 1);
|
||||
assert!(p.addref.is_some() && p.release.is_some());
|
||||
assert_eq!(unsafe { oaknode_project_initialize(dup(&p)) }, OAKNODE_OK);
|
||||
p
|
||||
}
|
||||
|
||||
/// Every implemented handle-returning function returns ctx==NULL on
|
||||
/// failure and a valid refcounted handle on success (abi_version
|
||||
/// stamped).
|
||||
#[test]
|
||||
fn handle_contract_all_exports() {
|
||||
let _g = ffi_lock();
|
||||
let mut p = init_project();
|
||||
|
||||
// Borrowed root folder handle.
|
||||
let mut root = unsafe { oaknode_project_root(dup(&p)) };
|
||||
assert!(!root.ctx.is_null());
|
||||
assert_eq!(root.abi_version, 1);
|
||||
|
||||
// node_at: valid index -> borrowed handle; out-of-range / negative ->
|
||||
// empty; empty project handle -> empty.
|
||||
let mut n0 = unsafe { oaknode_project_node_at(dup(&p), 0) };
|
||||
assert!(!n0.ctx.is_null());
|
||||
assert!(unsafe { oaknode_project_node_at(dup(&p), 1) }.ctx.is_null());
|
||||
assert!(unsafe { oaknode_project_node_at(dup(&p), -1) }.ctx.is_null());
|
||||
assert!(unsafe { oaknode_project_node_at(CHandle::null(), 0) }.ctx.is_null());
|
||||
|
||||
// Factory node handle: owned, valid.
|
||||
let mut math = unsafe { oaknode_factory_create_from_id(cs("org.olivevideoeditor.Olive.math").as_ptr()) };
|
||||
assert!(!math.ctx.is_null());
|
||||
assert_eq!(math.abi_version, 1);
|
||||
|
||||
// create_copy: owned copy of an orphan node.
|
||||
let mut copy = unsafe { oaknode_node_create_copy(dup(&math)) };
|
||||
assert!(!copy.ctx.is_null());
|
||||
|
||||
// from_identity of a live node -> valid; garbage identity -> empty.
|
||||
let id = unsafe { oaknode_node_identity(dup(&math)) };
|
||||
let mut back = unsafe { oaknode_node_from_identity(id) };
|
||||
assert!(!back.ctx.is_null());
|
||||
assert!(unsafe { oaknode_node_from_identity(0xdead) }.ctx.is_null());
|
||||
|
||||
unsafe { oaknode_node_free(&mut back) };
|
||||
unsafe { oaknode_node_free(&mut copy) };
|
||||
unsafe { oaknode_node_free(&mut math) };
|
||||
unsafe { oaknode_node_free(&mut n0) };
|
||||
unsafe { oaknode_node_free(&mut root) };
|
||||
unsafe { oaknode_project_free(&mut p) };
|
||||
}
|
||||
|
||||
/// free(NULL)/free(empty) are no-ops across every free export.
|
||||
#[test]
|
||||
fn free_null_noop_all_exports() {
|
||||
let _g = ffi_lock();
|
||||
unsafe {
|
||||
oaknode_project_free(std::ptr::null_mut());
|
||||
let mut empty = CHandle::null();
|
||||
oaknode_project_free(&mut empty);
|
||||
assert!(empty.ctx.is_null());
|
||||
|
||||
oaknode_node_free(std::ptr::null_mut());
|
||||
let mut empty = CHandle::null();
|
||||
oaknode_node_free(&mut empty);
|
||||
assert!(empty.ctx.is_null());
|
||||
}
|
||||
|
||||
// A live project frees cleanly and clears the handle.
|
||||
let mut p = init_project();
|
||||
unsafe { oaknode_project_free(&mut p) };
|
||||
assert!(p.ctx.is_null());
|
||||
}
|
||||
|
||||
/// Two-stage string functions: size query, short buffer truncation
|
||||
/// rule, and exact-fit write — for every string getter.
|
||||
#[test]
|
||||
fn two_stage_string_contract() {
|
||||
let _g = ffi_lock();
|
||||
let mut p = init_project();
|
||||
|
||||
// project_name: "(untitled)" -> 10 + 1 bytes.
|
||||
let name = two_stage(|buf, size| unsafe { oaknode_project_name(dup(&p), buf, size) }).unwrap();
|
||||
assert_eq!(name, "(untitled)");
|
||||
|
||||
// Short buffer truncates and NUL-terminates.
|
||||
let mut buf = [0u8; 4];
|
||||
let rc = unsafe { oaknode_project_name(dup(&p), buf.as_mut_ptr() as *mut c_char, buf.len() as i32) };
|
||||
assert_eq!(rc, "(untitled)".len() as i32 + 1, "required size regardless of buffer");
|
||||
assert_eq!(&buf[..3], b"(un");
|
||||
assert_eq!(buf[3], 0);
|
||||
|
||||
// Exact-fit write (required size) is NUL-terminated.
|
||||
let needed = unsafe { oaknode_project_name(dup(&p), std::ptr::null_mut(), 0) };
|
||||
let mut buf = vec![0u8; needed as usize];
|
||||
assert_eq!(
|
||||
unsafe { oaknode_project_name(dup(&p), buf.as_mut_ptr() as *mut c_char, needed) },
|
||||
needed
|
||||
);
|
||||
assert_eq!(buf[needed as usize - 1], 0, "terminating NUL");
|
||||
buf.pop();
|
||||
assert_eq!(String::from_utf8(buf).unwrap(), "(untitled)");
|
||||
|
||||
// filename: "" initially (size 1); after set_filename -> full path.
|
||||
let filename = two_stage(|buf, size| unsafe { oaknode_project_filename(dup(&p), buf, size) }).unwrap();
|
||||
assert_eq!(filename, "");
|
||||
unsafe { oaknode_project_set_filename(dup(&p), cs("/tmp/demo.ove").as_ptr()) };
|
||||
let filename = two_stage(|buf, size| unsafe { oaknode_project_filename(dup(&p), buf, size) }).unwrap();
|
||||
assert_eq!(filename, "/tmp/demo.ove");
|
||||
let pretty =
|
||||
two_stage(|buf, size| unsafe { oaknode_project_pretty_filename(dup(&p), buf, size) }).unwrap();
|
||||
assert_eq!(pretty, "/tmp/demo.ove");
|
||||
|
||||
// uuid is the 36-char braced format.
|
||||
let uuid = two_stage(|buf, size| unsafe { oaknode_project_get_uuid(dup(&p), buf, size) }).unwrap();
|
||||
assert_eq!(uuid.len(), 38);
|
||||
assert!(uuid.starts_with('{') && uuid.ends_with('}'));
|
||||
|
||||
// cache_path is a two-stage getter too.
|
||||
let _ = two_stage(|buf, size| unsafe { oaknode_project_cache_path(dup(&p), buf, size) }).unwrap();
|
||||
|
||||
// Node string getters.
|
||||
let mut math = unsafe { oaknode_factory_create_from_id(cs("org.olivevideoeditor.Olive.math").as_ptr()) };
|
||||
let id = two_stage(|buf, size| unsafe { oaknode_node_get_id(dup(&math), buf, size) }).unwrap();
|
||||
assert_eq!(id, "org.olivevideoeditor.Olive.math");
|
||||
let name = two_stage(|buf, size| unsafe { oaknode_node_get_name(dup(&math), buf, size) }).unwrap();
|
||||
assert_eq!(name, "Math");
|
||||
let label = two_stage(|buf, size| unsafe { oaknode_node_get_label(dup(&math), buf, size) }).unwrap();
|
||||
assert_eq!(label, "");
|
||||
let iname = two_stage(|buf, size| {
|
||||
unsafe { oaknode_node_get_input_name(dup(&math), cs("enabled_in").as_ptr(), buf, size) }
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(iname, "Enabled");
|
||||
|
||||
unsafe { oaknode_node_free(&mut math) };
|
||||
unsafe { oaknode_project_free(&mut p) };
|
||||
}
|
||||
|
||||
/// Identity registry: node_identity / node_from_identity round-trip;
|
||||
/// freed nodes are rejected by from_identity.
|
||||
#[test]
|
||||
fn identity_registry_roundtrip() {
|
||||
let _g = ffi_lock();
|
||||
let mut math = unsafe { oaknode_factory_create_from_id(cs("org.olivevideoeditor.Olive.math").as_ptr()) };
|
||||
let id = unsafe { oaknode_node_identity(dup(&math)) };
|
||||
|
||||
// Round-trip: the looked-up node has the same type id.
|
||||
let mut back = unsafe { oaknode_node_from_identity(id) };
|
||||
assert!(!back.ctx.is_null());
|
||||
let bid = two_stage(|buf, size| unsafe { oaknode_node_get_id(dup(&back), buf, size) }).unwrap();
|
||||
assert_eq!(bid, "org.olivevideoeditor.Olive.math");
|
||||
unsafe { oaknode_node_free(&mut back) };
|
||||
|
||||
// Move the node into a project: its identity is re-registered and
|
||||
// still resolvable while the project lives.
|
||||
let mut p = unsafe { oaknode_project_init() };
|
||||
assert_eq!(unsafe { oaknode_project_add_node(dup(&p), dup(&math)) }, OAKNODE_OK);
|
||||
let id2 = unsafe { oaknode_node_identity(dup(&math)) };
|
||||
let mut back2 = unsafe { oaknode_node_from_identity(id2) };
|
||||
assert!(!back2.ctx.is_null());
|
||||
unsafe { oaknode_node_free(&mut back2) };
|
||||
|
||||
// Free the node handle (drops its project reference) and the project:
|
||||
// both identities must then reject.
|
||||
unsafe { oaknode_node_free(&mut math) };
|
||||
unsafe { oaknode_project_free(&mut p) };
|
||||
let ghost = unsafe { oaknode_node_from_identity(id) };
|
||||
assert!(ghost.ctx.is_null(), "freed node must be rejected by from_identity");
|
||||
let ghost2 = unsafe { oaknode_node_from_identity(id2) };
|
||||
assert!(ghost2.ctx.is_null());
|
||||
}
|
||||
|
||||
/// alive count: project create/destroy moves oaknode_debug_alive_count
|
||||
/// predictably and returns to baseline.
|
||||
#[test]
|
||||
fn alive_count_accounting() {
|
||||
let _g = ffi_lock();
|
||||
let base = unsafe { oaknode_debug_alive_count() };
|
||||
|
||||
let mut p = init_project();
|
||||
assert_eq!(unsafe { oaknode_debug_alive_count() }, base + 1);
|
||||
|
||||
let mut math =
|
||||
unsafe { oaknode_factory_create_from_id(cs("org.olivevideoeditor.Olive.math").as_ptr()) };
|
||||
assert_eq!(unsafe { oaknode_debug_alive_count() }, base + 2);
|
||||
|
||||
// A borrowed view does not count.
|
||||
let mut n0 = unsafe { oaknode_project_node_at(dup(&p), 0) };
|
||||
assert_eq!(unsafe { oaknode_debug_alive_count() }, base + 2);
|
||||
unsafe { oaknode_node_free(&mut n0) };
|
||||
|
||||
// Adding the owned node to the project transfers its accounting to
|
||||
// the project.
|
||||
unsafe { oaknode_project_add_node(dup(&p), dup(&math)) };
|
||||
assert_eq!(unsafe { oaknode_debug_alive_count() }, base + 1);
|
||||
// The node handle is now a borrowed view: freeing it changes nothing.
|
||||
unsafe { oaknode_node_free(&mut math) };
|
||||
assert_eq!(unsafe { oaknode_debug_alive_count() }, base + 1);
|
||||
|
||||
unsafe { oaknode_project_free(&mut p) };
|
||||
assert_eq!(unsafe { oaknode_debug_alive_count() }, base);
|
||||
|
||||
// Detach: remove_node returns ownership (counted again), free
|
||||
// returns to baseline.
|
||||
let mut p2 = init_project();
|
||||
let mut node =
|
||||
unsafe { oaknode_factory_create_from_id(cs("org.olivevideoeditor.Olive.math").as_ptr()) };
|
||||
unsafe { oaknode_project_add_node(dup(&p2), dup(&node)) };
|
||||
assert_eq!(unsafe { oaknode_debug_alive_count() }, base + 1);
|
||||
unsafe { oaknode_project_remove_node(dup(&p2), dup(&node)) };
|
||||
assert_eq!(unsafe { oaknode_debug_alive_count() }, base + 2);
|
||||
unsafe { oaknode_node_free(&mut node) };
|
||||
assert_eq!(unsafe { oaknode_debug_alive_count() }, base + 1);
|
||||
unsafe { oaknode_project_free(&mut p2) };
|
||||
assert_eq!(unsafe { oaknode_debug_alive_count() }, base);
|
||||
}
|
||||
|
||||
/// Project family: success + failure path for every implemented export.
|
||||
#[test]
|
||||
fn project_family_contract() {
|
||||
let _g = ffi_lock();
|
||||
let mut p = init_project();
|
||||
|
||||
// initialize: E_STATE on second call; E_INVALID on empty handle.
|
||||
assert_eq!(unsafe { oaknode_project_initialize(dup(&p)) }, oaknode::error::OAKNODE_E_STATE);
|
||||
assert_eq!(unsafe { oaknode_project_initialize(CHandle::null()) }, OAKNODE_E_INVALID);
|
||||
|
||||
// root: valid after initialize.
|
||||
let mut root = unsafe { oaknode_project_root(dup(&p)) };
|
||||
assert!(!root.ctx.is_null());
|
||||
assert!(unsafe { oaknode_project_root(CHandle::null()) }.ctx.is_null());
|
||||
unsafe { oaknode_node_free(&mut root) };
|
||||
|
||||
// name/filename/pretty/is_modified/is_new + empty-handle failures.
|
||||
assert_eq!(
|
||||
unsafe { oaknode_project_name(CHandle::null(), std::ptr::null_mut(), 0) },
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_project_filename(CHandle::null(), std::ptr::null_mut(), 0) },
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
assert_eq!(unsafe { oaknode_project_is_modified(CHandle::null()) }, OAKNODE_E_INVALID);
|
||||
assert_eq!(unsafe { oaknode_project_is_new(CHandle::null()) }, OAKNODE_E_INVALID);
|
||||
|
||||
// set_filename: success; NULL failure.
|
||||
assert_eq!(
|
||||
unsafe { oaknode_project_set_filename(dup(&p), cs("/tmp/x.ove").as_ptr()) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(unsafe { oaknode_project_set_filename(dup(&p), std::ptr::null()) }, OAKNODE_E_INVALID);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_project_set_filename(CHandle::null(), cs("x").as_ptr()) },
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
assert_eq!(unsafe { oaknode_project_is_new(dup(&p)) }, 0, "has a filename now");
|
||||
|
||||
// modified flag.
|
||||
assert_eq!(unsafe { oaknode_project_set_modified(dup(&p), 1) }, OAKNODE_OK);
|
||||
assert_eq!(unsafe { oaknode_project_is_modified(dup(&p)) }, 1);
|
||||
assert_eq!(unsafe { oaknode_project_set_modified(CHandle::null(), 0) }, OAKNODE_E_INVALID);
|
||||
|
||||
// cache settings.
|
||||
assert_eq!(unsafe { oaknode_project_get_cache_location_setting(dup(&p)) }, 0);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_project_set_cache_location_setting(dup(&p), 2) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_project_set_cache_location_setting(dup(&p), 99) },
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_project_set_cache_location_setting(CHandle::null(), 0) },
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_project_set_custom_cache_path(dup(&p), cs("/tmp/cache").as_ptr()) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
let custom = two_stage(|buf, size| unsafe { oaknode_project_get_custom_cache_path(dup(&p), buf, size) })
|
||||
.unwrap();
|
||||
assert_eq!(custom, "/tmp/cache");
|
||||
unsafe { oaknode_project_set_custom_cache_path(dup(&p), std::ptr::null()) }; // NULL clears
|
||||
let custom = two_stage(|buf, size| unsafe { oaknode_project_get_custom_cache_path(dup(&p), buf, size) })
|
||||
.unwrap();
|
||||
assert_eq!(custom, "");
|
||||
|
||||
// copy_settings: dst inherits src settings; empty handle fails.
|
||||
let mut p2 = unsafe { oaknode_project_init() };
|
||||
unsafe { oaknode_project_set_cache_location_setting(dup(&p2), 1) };
|
||||
assert_eq!(unsafe { oaknode_project_copy_settings(dup(&p), dup(&p2)) }, OAKNODE_OK);
|
||||
assert_eq!(unsafe { oaknode_project_get_cache_location_setting(dup(&p)) }, 1);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_project_copy_settings(CHandle::null(), dup(&p2)) },
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_project_copy_settings(dup(&p), CHandle::null()) },
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
|
||||
// node add/remove/count/at.
|
||||
let base_count = unsafe { oaknode_project_node_count(dup(&p)) };
|
||||
let mut node =
|
||||
unsafe { oaknode_factory_create_from_id(cs("org.olivevideoeditor.Olive.solidgenerator").as_ptr()) };
|
||||
assert_eq!(unsafe { oaknode_project_add_node(dup(&p), dup(&node)) }, OAKNODE_OK);
|
||||
assert_eq!(unsafe { oaknode_project_node_count(dup(&p)) }, base_count + 1);
|
||||
assert_eq!(unsafe { oaknode_project_add_node(CHandle::null(), dup(&node)) }, OAKNODE_E_INVALID);
|
||||
assert_eq!(unsafe { oaknode_project_add_node(dup(&p), CHandle::null()) }, OAKNODE_E_INVALID);
|
||||
assert_eq!(unsafe { oaknode_project_remove_node(dup(&p), dup(&node)) }, OAKNODE_OK);
|
||||
assert_eq!(unsafe { oaknode_project_node_count(dup(&p)) }, base_count);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_project_remove_node(dup(&p), dup(&node)) },
|
||||
OAKNODE_E_NOT_FOUND,
|
||||
"double remove fails"
|
||||
);
|
||||
assert_eq!(unsafe { oaknode_project_remove_node(CHandle::null(), dup(&node)) }, OAKNODE_E_INVALID);
|
||||
assert_eq!(unsafe { oaknode_project_node_count(CHandle::null()) }, OAKNODE_E_INVALID);
|
||||
unsafe { oaknode_node_free(&mut node) };
|
||||
|
||||
// clear: resets; empty handle fails.
|
||||
assert_eq!(unsafe { oaknode_project_clear(CHandle::null()) }, OAKNODE_E_INVALID);
|
||||
assert_eq!(unsafe { oaknode_project_clear(dup(&p)) }, OAKNODE_OK);
|
||||
assert!(unsafe { oaknode_project_root(dup(&p)) }.ctx.is_null(), "root gone after clear");
|
||||
assert_eq!(unsafe { oaknode_project_initialize(dup(&p)) }, OAKNODE_OK, "re-initializable");
|
||||
|
||||
unsafe { oaknode_project_free(&mut p) };
|
||||
unsafe { oaknode_project_free(&mut p2) };
|
||||
}
|
||||
|
||||
/// Node family: metadata, inputs, params, graph editing, links,
|
||||
/// contexts, arrays, copies — success + failure per export.
|
||||
#[test]
|
||||
fn node_family_contract() {
|
||||
let _g = ffi_lock();
|
||||
let mut p = init_project();
|
||||
|
||||
// Two factory nodes in the same project.
|
||||
let mut a =
|
||||
unsafe { oaknode_factory_create_from_id(cs("org.olivevideoeditor.Olive.math").as_ptr()) };
|
||||
let mut b =
|
||||
unsafe { oaknode_factory_create_from_id(cs("org.olivevideoeditor.Olive.ortho").as_ptr()) };
|
||||
unsafe { oaknode_project_add_node(dup(&p), dup(&a)) };
|
||||
unsafe { oaknode_project_add_node(dup(&p), dup(&b)) };
|
||||
|
||||
// Metadata.
|
||||
assert_eq!(unsafe { oaknode_node_get_id(CHandle::null(), std::ptr::null_mut(), 0) }, OAKNODE_E_INVALID);
|
||||
assert_eq!(unsafe { oaknode_node_get_name(CHandle::null(), std::ptr::null_mut(), 0) }, OAKNODE_E_INVALID);
|
||||
let id = two_stage(|buf, size| unsafe { oaknode_node_get_id(dup(&a), buf, size) }).unwrap();
|
||||
assert_eq!(id, "org.olivevideoeditor.Olive.math");
|
||||
|
||||
// Label.
|
||||
assert_eq!(unsafe { oaknode_node_set_label(dup(&a), cs("my node").as_ptr()) }, OAKNODE_OK);
|
||||
let label = two_stage(|buf, size| unsafe { oaknode_node_get_label(dup(&a), buf, size) }).unwrap();
|
||||
assert_eq!(label, "my node");
|
||||
assert_eq!(unsafe { oaknode_node_set_label(CHandle::null(), cs("x").as_ptr()) }, OAKNODE_E_INVALID);
|
||||
assert_eq!(unsafe { oaknode_node_set_label(dup(&a), std::ptr::null()) }, OAKNODE_E_INVALID);
|
||||
|
||||
// Override color.
|
||||
let mut oc = 0;
|
||||
assert_eq!(unsafe { oaknode_node_get_override_color(dup(&a), &mut oc) }, OAKNODE_OK);
|
||||
assert_eq!(oc, -1);
|
||||
assert_eq!(unsafe { oaknode_node_set_override_color(dup(&a), 3) }, OAKNODE_OK);
|
||||
assert_eq!(unsafe { oaknode_node_get_override_color(dup(&a), &mut oc) }, OAKNODE_OK);
|
||||
assert_eq!(oc, 3);
|
||||
assert_eq!(unsafe { oaknode_node_get_override_color(CHandle::null(), &mut oc) }, OAKNODE_E_INVALID);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_get_override_color(dup(&a), std::ptr::null_mut()) },
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
|
||||
// Enabled.
|
||||
let mut en = 0;
|
||||
assert_eq!(unsafe { oaknode_node_is_enabled(dup(&a), &mut en) }, OAKNODE_OK);
|
||||
assert_eq!(en, 1);
|
||||
assert_eq!(unsafe { oaknode_node_set_enabled(dup(&a), 0) }, OAKNODE_OK);
|
||||
assert_eq!(unsafe { oaknode_node_is_enabled(dup(&a), &mut en) }, OAKNODE_OK);
|
||||
assert_eq!(en, 0);
|
||||
assert_eq!(unsafe { oaknode_node_is_enabled(CHandle::null(), &mut en) }, OAKNODE_E_INVALID);
|
||||
|
||||
// Input introspection.
|
||||
let mut count = 0;
|
||||
assert_eq!(unsafe { oaknode_node_input_count(dup(&a), &mut count) }, OAKNODE_OK);
|
||||
assert_eq!(count, 4, "enabled_in + method_in + param_a_in + param_b_in");
|
||||
let first = two_stage(|buf, size| unsafe { oaknode_node_input_id(dup(&a), 0, buf, size) }).unwrap();
|
||||
assert_eq!(first, "enabled_in");
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_input_id(dup(&a), 99, std::ptr::null_mut(), 0) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_input_id(dup(&a), -1, std::ptr::null_mut(), 0) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
|
||||
let mut ty = 0;
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_input_get_type(dup(&a), cs("enabled_in").as_ptr(), &mut ty) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(ty, oak::BOOL);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_input_get_type(dup(&a), cs("nope").as_ptr(), &mut ty) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_input_get_type(dup(&a), cs("enabled_in").as_ptr(), std::ptr::null_mut()) },
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
|
||||
let mut conn = 0;
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_input_is_connected(dup(&a), cs("param_a_in").as_ptr(), &mut conn) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(conn, 0);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_input_is_connected(dup(&a), cs("nope").as_ptr(), &mut conn) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
|
||||
let mut connectable = 0;
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_input_is_connectable(dup(&a), cs("param_a_in").as_ptr(), &mut connectable) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(connectable, 1);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_input_is_connectable(dup(&a), cs("method_in").as_ptr(), &mut connectable) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(connectable, 0, "method_in is not connectable");
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_input_is_connectable(dup(&a), cs("nope").as_ptr(), &mut connectable) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
|
||||
let iname = two_stage(|buf, size| {
|
||||
unsafe { oaknode_node_get_input_name(dup(&a), cs("enabled_in").as_ptr(), buf, size) }
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(iname, "Enabled");
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_get_input_name(dup(&a), cs("nope").as_ptr(), std::ptr::null_mut(), 0) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
|
||||
// Params: get/set float, unknown id, string input, bad POD type.
|
||||
let mut pod = OakNodeValue::none();
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_get_input(dup(&a), cs("param_a_in").as_ptr(), &mut pod) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(pod.kind, oak::FLOAT);
|
||||
assert_eq!(pod.f[0], 0.0);
|
||||
let v = OakNodeValue {
|
||||
kind: oak::FLOAT,
|
||||
num: 0,
|
||||
den: 0,
|
||||
f: [3.5, 0.0, 0.0, 0.0],
|
||||
};
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_set_input(dup(&a), cs("param_a_in").as_ptr(), &v) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_get_input(dup(&a), cs("param_a_in").as_ptr(), &mut pod) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(pod.f[0], 3.5);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_get_input(dup(&a), cs("nope").as_ptr(), &mut pod) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_set_input(dup(&a), cs("nope").as_ptr(), &v) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
let wrong_type = OakNodeValue {
|
||||
kind: oak::INT,
|
||||
num: 1,
|
||||
den: 0,
|
||||
f: [0.0; 4],
|
||||
};
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_set_input(dup(&a), cs("param_a_in").as_ptr(), &wrong_type) },
|
||||
OAKNODE_E_INVALID,
|
||||
"POD type must match the declared input type"
|
||||
);
|
||||
|
||||
// String input: the timeformat node's format_in.
|
||||
let mut tf = unsafe { oaknode_factory_create_from_id(cs("org.olivevideoeditor.Olive.timeformat").as_ptr()) };
|
||||
unsafe { oaknode_project_add_node(dup(&p), dup(&tf)) };
|
||||
let s = two_stage(|buf, size| unsafe {
|
||||
oaknode_node_get_input_string(dup(&tf), cs("format_in").as_ptr(), buf, size)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s, "hh:mm:ss");
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_set_input_string(dup(&tf), cs("format_in").as_ptr(), cs("yyyy").as_ptr()) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
let s = two_stage(|buf, size| unsafe {
|
||||
oaknode_node_get_input_string(dup(&tf), cs("format_in").as_ptr(), buf, size)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(s, "yyyy");
|
||||
// Non-string input -> E_INVALID; unknown -> E_NOT_FOUND.
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_get_input_string(dup(&tf), cs("time_in").as_ptr(), std::ptr::null_mut(), 0) },
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_set_input_string(dup(&tf), cs("time_in").as_ptr(), cs("x").as_ptr()) },
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_get_input_string(dup(&tf), cs("nope").as_ptr(), std::ptr::null_mut(), 0) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
|
||||
// Graph editing: connect a -> b.param_a_in.
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_connect(dup(&a), dup(&b), cs("rot_in").as_ptr()) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
let mut connected = 0;
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_input_is_connected(dup(&b), cs("rot_in").as_ptr(), &mut connected) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(connected, 1);
|
||||
// Duplicate connect -> E_STATE.
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_connect(dup(&a), dup(&b), cs("rot_in").as_ptr()) },
|
||||
oaknode::error::OAKNODE_E_STATE
|
||||
);
|
||||
// Not connectable -> E_INVALID.
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_connect(dup(&a), dup(&a), cs("method_in").as_ptr()) },
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
// Unknown input -> E_NOT_FOUND.
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_connect(dup(&a), dup(&b), cs("nope").as_ptr()) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
// Empty handle -> E_INVALID.
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_connect(CHandle::null(), dup(&b), cs("param_a_in").as_ptr()) },
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
|
||||
// input_get_connected_node.
|
||||
let mut src = CHandle::null();
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_input_get_connected_node(dup(&b), cs("rot_in").as_ptr(), &mut src) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert!(!src.ctx.is_null());
|
||||
let src_id = two_stage(|buf, size| unsafe { oaknode_node_get_id(dup(&src), buf, size) }).unwrap();
|
||||
assert_eq!(src_id, "org.olivevideoeditor.Olive.math");
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_input_get_connected_node(dup(&b), cs("nope").as_ptr(), &mut src) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
|
||||
// Output connections.
|
||||
let mut count = 0;
|
||||
assert_eq!(unsafe { oaknode_node_output_connection_count(dup(&a), &mut count) }, OAKNODE_OK);
|
||||
assert_eq!(count, 1);
|
||||
let mut node_out = CHandle::null();
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_output_connection_node_at(dup(&a), 0, &mut node_out) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert!(!node_out.ctx.is_null());
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_output_connection_node_at(dup(&a), 1, &mut node_out) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
let input_id =
|
||||
two_stage(|buf, size| unsafe { oaknode_node_output_connection_input_id_at(dup(&a), 0, buf, size) })
|
||||
.unwrap();
|
||||
assert_eq!(input_id, "rot_in");
|
||||
let mut element = 0;
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_output_connection_element_at(dup(&a), 0, &mut element) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(element, -1);
|
||||
|
||||
// Disconnect.
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_disconnect(dup(&b), cs("rot_in").as_ptr()) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_disconnect(dup(&b), cs("rot_in").as_ptr()) },
|
||||
OAKNODE_E_NOT_FOUND,
|
||||
"disconnect of an unconnected input fails"
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_disconnect(dup(&b), cs("nope").as_ptr()) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
|
||||
// Links.
|
||||
let mut linked = 0;
|
||||
assert_eq!(unsafe { oaknode_node_link(dup(&a), dup(&b), &mut linked) }, OAKNODE_OK);
|
||||
assert_eq!(linked, 1);
|
||||
assert_eq!(unsafe { oaknode_node_link(dup(&a), dup(&b), &mut linked) }, OAKNODE_OK);
|
||||
assert_eq!(linked, 0, "already linked");
|
||||
let mut linked_ok = 0;
|
||||
assert_eq!(unsafe { oaknode_node_are_linked(dup(&a), dup(&b), &mut linked_ok) }, OAKNODE_OK);
|
||||
assert_eq!(linked_ok, 1);
|
||||
assert_eq!(unsafe { oaknode_node_link_count(dup(&a), &mut count) }, OAKNODE_OK);
|
||||
assert_eq!(count, 1);
|
||||
let mut link_node = CHandle::null();
|
||||
assert_eq!(unsafe { oaknode_node_link_at(dup(&a), 0, &mut link_node) }, OAKNODE_OK);
|
||||
assert!(!link_node.ctx.is_null());
|
||||
assert_eq!(unsafe { oaknode_node_link_at(dup(&a), 1, &mut link_node) }, OAKNODE_E_NOT_FOUND);
|
||||
assert_eq!(unsafe { oaknode_node_unlink(dup(&a), dup(&b), &mut linked) }, OAKNODE_OK);
|
||||
assert_eq!(linked, 1);
|
||||
assert_eq!(unsafe { oaknode_node_unlink(dup(&a), dup(&b), &mut linked) }, OAKNODE_OK);
|
||||
assert_eq!(linked, 0, "already unlinked");
|
||||
|
||||
// Context positions.
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_set_context_position(dup(&a), dup(&b), 10.0, 20.0, 1) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(unsafe { oaknode_node_context_count(dup(&a), &mut count) }, OAKNODE_OK);
|
||||
assert_eq!(count, 1);
|
||||
let mut x = 0.0;
|
||||
let mut y = 0.0;
|
||||
let mut expanded = 0;
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_get_context_position(dup(&a), dup(&b), &mut x, &mut y, &mut expanded) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!((x, y, expanded), (10.0, 20.0, 1));
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_get_context_position(dup(&a), dup(&a), &mut x, &mut y, &mut expanded) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
let mut ctx_node = CHandle::null();
|
||||
assert_eq!(unsafe { oaknode_node_context_node_at(dup(&a), 0, &mut ctx_node) }, OAKNODE_OK);
|
||||
assert!(!ctx_node.ctx.is_null());
|
||||
assert_eq!(unsafe { oaknode_node_remove_from_context(dup(&a), dup(&b)) }, OAKNODE_OK);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_remove_from_context(dup(&a), dup(&b)) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
|
||||
// get_project.
|
||||
let mut proj = CHandle::null();
|
||||
assert_eq!(unsafe { oaknode_node_get_project(dup(&a), &mut proj) }, OAKNODE_OK);
|
||||
assert!(!proj.ctx.is_null());
|
||||
assert_eq!(unsafe { oaknode_node_get_project(CHandle::null(), &mut proj) }, OAKNODE_E_INVALID);
|
||||
|
||||
// Array input insert/remove on a non-array input fails E_INVALID;
|
||||
// on an unknown input E_NOT_FOUND.
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_input_array_insert(dup(&a), cs("param_a_in").as_ptr(), 0) },
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_input_array_insert(dup(&a), cs("nope").as_ptr(), 0) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
|
||||
// copy_inputs: dst (matrix) takes src (math)'s values where the ids
|
||||
// match (enabled_in only) and preserves its own otherwise.
|
||||
assert_eq!(unsafe { oaknode_node_copy_inputs(dup(&b), dup(&a), 0) }, OAKNODE_OK);
|
||||
assert_eq!(unsafe { oaknode_node_copy_inputs(CHandle::null(), dup(&a), 0) }, OAKNODE_E_INVALID);
|
||||
|
||||
// set_value_hint_track.
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_set_value_hint_track(dup(&a), cs("param_a_in").as_ptr(), 0, 1) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_set_value_hint_track(dup(&a), cs("nope").as_ptr(), 0, 1) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
|
||||
// get_input_at_time: no keyframes -> standard value.
|
||||
let mut at = OakNodeValue::none();
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_get_input_at_time(dup(&a), cs("param_a_in").as_ptr(), 5, 1, &mut at) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(at.kind, oak::FLOAT);
|
||||
assert!((at.f[0] - 3.5).abs() < 1e-9);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_node_get_input_at_time(dup(&a), cs("nope").as_ptr(), 5, 1, &mut at) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
oaknode_node_get_input_at_time(dup(&a), cs("param_a_in").as_ptr(), 5, 1, std::ptr::null_mut())
|
||||
},
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
|
||||
// create_copy of a graph-owned node.
|
||||
let mut copy = unsafe { oaknode_node_create_copy(dup(&a)) };
|
||||
assert!(!copy.ctx.is_null());
|
||||
let copy_id = two_stage(|buf, size| unsafe { oaknode_node_get_id(dup(©), buf, size) }).unwrap();
|
||||
assert_eq!(copy_id, "org.olivevideoeditor.Olive.math");
|
||||
|
||||
// Cleanup (free every borrowed view and the owned nodes).
|
||||
unsafe { oaknode_node_free(&mut copy) };
|
||||
unsafe { oaknode_node_free(&mut src) };
|
||||
unsafe { oaknode_node_free(&mut node_out) };
|
||||
unsafe { oaknode_node_free(&mut link_node) };
|
||||
unsafe { oaknode_node_free(&mut ctx_node) };
|
||||
unsafe { oaknode_node_free(&mut proj) };
|
||||
unsafe { oaknode_node_free(&mut tf) };
|
||||
unsafe { oaknode_node_free(&mut b) };
|
||||
unsafe { oaknode_node_free(&mut a) };
|
||||
unsafe { oaknode_project_free(&mut p) };
|
||||
}
|
||||
|
||||
/// Factory family + the pure keyframe helper.
|
||||
#[test]
|
||||
fn factory_and_keyframe_helpers() {
|
||||
let _g = ffi_lock();
|
||||
assert_eq!(unsafe { oaknode_factory_initialize() }, OAKNODE_OK);
|
||||
assert_eq!(unsafe { oaknode_factory_initialize() }, OAKNODE_OK, "idempotent");
|
||||
|
||||
let mut count = 0;
|
||||
assert_eq!(unsafe { oaknode_factory_id_count(&mut count) }, OAKNODE_OK);
|
||||
assert!(count >= 4, "at least the implemented node types registered");
|
||||
|
||||
let first = two_stage(|buf, size| unsafe { oaknode_factory_id_at(0, buf, size) }).unwrap();
|
||||
assert_eq!(first, "org.olivevideoeditor.Olive.polygon");
|
||||
assert_eq!(
|
||||
unsafe { oaknode_factory_id_at(-1, std::ptr::null_mut(), 0) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_factory_id_at(count, std::ptr::null_mut(), 0) },
|
||||
OAKNODE_E_NOT_FOUND
|
||||
);
|
||||
|
||||
let name = two_stage(|buf, size| {
|
||||
unsafe { oaknode_factory_name_from_id(cs("org.olivevideoeditor.Olive.pan").as_ptr(), buf, size) }
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(name, "Pan");
|
||||
let missing = two_stage(|buf, size| {
|
||||
unsafe { oaknode_factory_name_from_id(cs("org.example.missing").as_ptr(), buf, size) }
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(missing, "", "unknown id -> empty string");
|
||||
|
||||
// create_from_id: known -> valid, unknown -> empty.
|
||||
let mut node =
|
||||
unsafe { oaknode_factory_create_from_id(cs("org.olivevideoeditor.Olive.solidgenerator").as_ptr()) };
|
||||
assert!(!node.ctx.is_null());
|
||||
assert!(
|
||||
unsafe { oaknode_factory_create_from_id(cs("org.example.missing").as_ptr()) }
|
||||
.ctx
|
||||
.is_null()
|
||||
);
|
||||
assert!(unsafe { oaknode_factory_create_from_id(std::ptr::null()) }.ctx.is_null());
|
||||
|
||||
// node_at: borrowed prototype (index 5 = math, whose constructor is
|
||||
// implemented; prototypes at todo!()-constructor indices are only
|
||||
// reachable once Phase 3 lands); out-of-range -> E_NOT_FOUND.
|
||||
let mut proto = CHandle::null();
|
||||
assert_eq!(unsafe { oaknode_factory_node_at(5, &mut proto) }, OAKNODE_OK);
|
||||
assert!(!proto.ctx.is_null());
|
||||
assert_eq!(unsafe { oaknode_factory_node_at(count, &mut proto) }, OAKNODE_E_NOT_FOUND);
|
||||
|
||||
// Pure keyframe helper: IN(0) <-> OUT(1), invalid -> E_INVALID.
|
||||
assert_eq!(unsafe { oaknode_keyframe_opposing_bezier_type(0) }, 1);
|
||||
assert_eq!(unsafe { oaknode_keyframe_opposing_bezier_type(1) }, 0);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_keyframe_opposing_bezier_type(2) },
|
||||
OAKNODE_E_INVALID
|
||||
);
|
||||
|
||||
unsafe { oaknode_node_free(&mut node) };
|
||||
unsafe { oaknode_node_free(&mut proto) };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Graph arena contract tests (graph.rs / id.rs).
|
||||
|
||||
use oaknode::error::Error;
|
||||
use oaknode::graph::Graph;
|
||||
use oaknode::id::NodeId;
|
||||
use oaknode::input::{flags, Input};
|
||||
use oaknode::node::{Category, NodeBehavior, NodeCore};
|
||||
use oaknode::value::{NodeValue, ValueType};
|
||||
|
||||
/// A minimal test node: `enabled_in` + one connectable float input.
|
||||
struct TestNode {
|
||||
id: &'static str,
|
||||
}
|
||||
|
||||
impl NodeBehavior for TestNode {
|
||||
fn name(&self) -> &str {
|
||||
"TestNode"
|
||||
}
|
||||
|
||||
fn type_id(&self) -> &str {
|
||||
self.id
|
||||
}
|
||||
|
||||
fn categories(&self) -> &[Category] {
|
||||
&[]
|
||||
}
|
||||
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(TestNode { id: self.id }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a graph holding `n` labeled test nodes, returning their ids.
|
||||
fn build(n: usize) -> (Graph, Vec<NodeId>) {
|
||||
let mut g = Graph::new();
|
||||
let mut ids = Vec::new();
|
||||
for i in 0..n {
|
||||
let mut core = NodeCore::new();
|
||||
core.add_input(Input::new(
|
||||
"val_in",
|
||||
ValueType::Float,
|
||||
NodeValue::Float(0.0),
|
||||
));
|
||||
// Second input so a node can have two parents (the diamond
|
||||
// shape) — scalar inputs are single-connection.
|
||||
core.add_input(Input::new(
|
||||
"val_in2",
|
||||
ValueType::Float,
|
||||
NodeValue::Float(0.0),
|
||||
));
|
||||
ids.push(g.add_node(core, Box::new(TestNode { id: "test" })));
|
||||
}
|
||||
(g, ids)
|
||||
}
|
||||
|
||||
/// add/remove nodes: ids are generation-checked; a stale NodeId fails
|
||||
/// `get` instead of aliasing a reused slot.
|
||||
#[test]
|
||||
fn generational_ids_reject_stale() {
|
||||
let (mut g, ids) = build(2);
|
||||
let [a, b] = [ids[0], ids[1]];
|
||||
|
||||
assert!(g.is_valid(a));
|
||||
assert!(g.get(a).is_some());
|
||||
|
||||
// Remove `a`; its slot is freed and later reused with a bumped
|
||||
// generation.
|
||||
assert!(g.remove_node(a).is_some());
|
||||
assert!(!g.is_valid(a));
|
||||
assert!(g.get(a).is_none());
|
||||
|
||||
// A new node reuses the slot; the stale id must not alias it.
|
||||
let c = g.add_node(NodeCore::new(), Box::new(TestNode { id: "test" }));
|
||||
assert_eq!(c.index(), a.index());
|
||||
assert_ne!(c.generation(), a.generation());
|
||||
assert!(g.get(c).is_some());
|
||||
assert!(g.get(a).is_none(), "stale id aliased the reused slot");
|
||||
|
||||
// Invalid sentinel and huge indices never resolve.
|
||||
assert!(g.get(NodeId::INVALID).is_none());
|
||||
assert!(!g.is_valid(NodeId::INVALID));
|
||||
let _ = b;
|
||||
}
|
||||
|
||||
/// connect/disconnect round-trip; disconnect of a missing edge is a
|
||||
/// no-op; duplicate connect is rejected (C++ behavior).
|
||||
#[test]
|
||||
fn edge_lifecycle() {
|
||||
let (mut g, ids) = build(3);
|
||||
let [a, b, c] = [ids[0], ids[1], ids[2]];
|
||||
|
||||
assert!(g.connect(a, b, "val_in", -1).is_ok());
|
||||
assert_eq!(g.connected_output(b, "val_in", -1), Some(a));
|
||||
assert!(g.is_input_connected(b, "val_in", -1));
|
||||
assert_eq!(g.upstream(b), vec![a]);
|
||||
|
||||
// Duplicate connect on the same input is rejected with E_STATE.
|
||||
assert_eq!(g.connect(a, b, "val_in", -1), Err(Error::State));
|
||||
assert_eq!(g.connect(c, b, "val_in", -1), Err(Error::State));
|
||||
|
||||
// Unknown input id -> E_NOT_FOUND; non-connectable -> E_INVALID.
|
||||
assert_eq!(g.connect(a, b, "nope", -1), Err(Error::NotFound));
|
||||
let (mut g2, ids2) = build(2);
|
||||
{
|
||||
let mut core = NodeCore::new();
|
||||
let mut input = Input::new("locked", ValueType::Float, NodeValue::Float(0.0));
|
||||
input.flags |= flags::NOT_CONNECTABLE;
|
||||
core.add_input(input);
|
||||
let n = g2.add_node(core, Box::new(TestNode { id: "test" }));
|
||||
assert_eq!(g2.connect(ids2[0], n, "locked", -1), Err(Error::Invalid));
|
||||
}
|
||||
|
||||
// Disconnect round-trip; missing edge disconnect is a no-op.
|
||||
g.disconnect(a, b, "val_in", -1);
|
||||
assert!(!g.is_input_connected(b, "val_in", -1));
|
||||
g.disconnect(a, b, "val_in", -1); // no-op, no panic
|
||||
g.disconnect(c, b, "val_in", -1); // never existed
|
||||
}
|
||||
|
||||
/// Cycle rejection: connecting A→B→C→A fails with E_STATE and leaves
|
||||
/// the graph unchanged (C++ connect_edge cycle check).
|
||||
#[test]
|
||||
fn cycle_rejection() {
|
||||
let (mut g, ids) = build(3);
|
||||
let [a, b, c] = [ids[0], ids[1], ids[2]];
|
||||
|
||||
g.connect(a, b, "val_in", -1).unwrap();
|
||||
g.connect(b, c, "val_in", -1).unwrap();
|
||||
|
||||
// Closing the cycle is rejected.
|
||||
assert_eq!(g.connect(c, a, "val_in", -1), Err(Error::State));
|
||||
|
||||
// Self-connection is a trivial cycle.
|
||||
assert_eq!(g.connect(a, a, "val_in", -1), Err(Error::State));
|
||||
|
||||
// The graph is unchanged: the two valid edges remain, topology intact.
|
||||
assert_eq!(g.connected_output(b, "val_in", -1), Some(a));
|
||||
assert_eq!(g.connected_output(c, "val_in", -1), Some(b));
|
||||
assert_eq!(g.output_connections(c).len(), 0);
|
||||
}
|
||||
|
||||
/// Topological order: every edge goes earlier→later; empty graph
|
||||
/// yields empty order; diamond graph has a valid (stable) order.
|
||||
#[test]
|
||||
fn topological_order() {
|
||||
let mut g = Graph::new();
|
||||
assert!(g.topological_order().is_empty());
|
||||
|
||||
let (mut g, ids) = build(4);
|
||||
let [a, b, c, d] = [ids[0], ids[1], ids[2], ids[3]];
|
||||
g.connect(a, b, "val_in", -1).unwrap();
|
||||
g.connect(a, c, "val_in", -1).unwrap();
|
||||
g.connect(b, d, "val_in", -1).unwrap();
|
||||
g.connect(c, d, "val_in2", -1).unwrap();
|
||||
|
||||
let order = g.topological_order();
|
||||
assert_eq!(order.len(), 4);
|
||||
assert_eq!(order[0], a, "source first");
|
||||
// Every edge goes earlier -> later.
|
||||
let pos = |n: NodeId| order.iter().position(|x| *x == n).unwrap();
|
||||
assert!(pos(a) < pos(b) && pos(a) < pos(c));
|
||||
assert!(pos(b) < pos(d) && pos(c) < pos(d));
|
||||
|
||||
// Deterministic across calls.
|
||||
assert_eq!(order, g.topological_order());
|
||||
}
|
||||
|
||||
/// remove_node cascades: all edges to/from the node disappear and
|
||||
/// downstream invalidation fires exactly once (C++ ~Node parity —
|
||||
/// `// CPP-PARITY: node.cpp` disconnect fan-out).
|
||||
#[test]
|
||||
fn remove_node_cascades() {
|
||||
let (mut g, ids) = build(4);
|
||||
let [a, b, c, d] = [ids[0], ids[1], ids[2], ids[3]];
|
||||
g.connect(a, b, "val_in", -1).unwrap();
|
||||
g.connect(b, c, "val_in", -1).unwrap();
|
||||
g.connect(b, d, "val_in", -1).unwrap();
|
||||
|
||||
// Removing the middle node drops all four edges.
|
||||
let behavior = g.remove_node(b).expect("node exists");
|
||||
assert!(behavior.type_id() == "test");
|
||||
assert!(g.get(b).is_none());
|
||||
assert_eq!(g.output_connections(a).len(), 0);
|
||||
assert!(!g.is_input_connected(c, "val_in", -1));
|
||||
assert!(!g.is_input_connected(d, "val_in", -1));
|
||||
assert!(g.downstream(b).is_empty());
|
||||
assert!(g.upstream(b).is_empty());
|
||||
|
||||
// The graph is still fully usable (slot reused cleanly).
|
||||
let e = g.add_node(NodeCore::new(), Box::new(TestNode { id: "test" }));
|
||||
assert!(g.is_valid(e));
|
||||
}
|
||||
|
||||
/// Upstream/downstream queries on a diamond graph.
|
||||
#[test]
|
||||
fn adjacency_queries() {
|
||||
let (mut g, ids) = build(4);
|
||||
let [a, b, c, d] = [ids[0], ids[1], ids[2], ids[3]];
|
||||
g.connect(a, b, "val_in", -1).unwrap();
|
||||
g.connect(a, c, "val_in", -1).unwrap();
|
||||
g.connect(b, d, "val_in", -1).unwrap();
|
||||
g.connect(c, d, "val_in2", -1).unwrap();
|
||||
|
||||
assert_eq!(g.upstream(a), Vec::<NodeId>::new());
|
||||
assert_eq!(g.upstream(d), vec![b, c]);
|
||||
assert_eq!(g.downstream(a), vec![b, c]);
|
||||
assert_eq!(g.downstream(d), Vec::<NodeId>::new());
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Phase-2 internal unit tests: block/track/colormanager/footage/
|
||||
//! serializer/bridge::undo engine internals, closing the coverage gaps
|
||||
//! the ffi contract tests leave open.
|
||||
|
||||
use oakcore_rs::{Rational, TimeRange};
|
||||
|
||||
use oaknode::block::{BlockCore, ClipBlockBehavior, GapBlockBehavior, TransitionBlockBehavior};
|
||||
use oaknode::colormanager::ColorManager;
|
||||
use oaknode::footage::FootageBehavior;
|
||||
use oaknode::id::NodeId;
|
||||
use oaknode::serializer::{string_to_value, value_to_string};
|
||||
use oaknode::track::{
|
||||
pixel_height_to_internal_height, TrackBehavior, TrackListBehavior, TrackType,
|
||||
};
|
||||
use oaknode::value::{NodeValue, ValueType};
|
||||
|
||||
/// block.rs: BlockCore range/media arithmetic.
|
||||
#[test]
|
||||
fn block_core_ranges() {
|
||||
let mut core = BlockCore::default();
|
||||
assert_eq!(core.in_(), Rational::new(0, 1));
|
||||
assert_eq!(core.out(), Rational::new(1, 1));
|
||||
assert_eq!(core.length(), Rational::new(1, 1));
|
||||
assert_eq!(core.media_out(), Rational::new(1, 1));
|
||||
|
||||
core.set_in(Rational::new(5, 1));
|
||||
assert_eq!(core.in_(), Rational::new(5, 1));
|
||||
assert_eq!(core.out(), Rational::new(6, 1), "length preserved");
|
||||
|
||||
core.set_out(Rational::new(9, 1));
|
||||
assert_eq!(core.length(), Rational::new(4, 1));
|
||||
|
||||
// media_out anchored: in shifts so out stays.
|
||||
core.media_in = Rational::new(2, 1);
|
||||
core.set_length_and_media_out(Rational::new(3, 1));
|
||||
assert_eq!(core.out(), Rational::new(9, 1), "out stays put");
|
||||
assert_eq!(core.length(), Rational::new(3, 1));
|
||||
|
||||
// media_in anchored: in stays, out shifts.
|
||||
core.set_length_and_media_in(Rational::new(4, 1));
|
||||
assert_eq!(core.in_(), Rational::new(6, 1), "in stays put");
|
||||
assert_eq!(core.length(), Rational::new(4, 1));
|
||||
}
|
||||
|
||||
/// block.rs: behavior constructors + node inputs.
|
||||
#[test]
|
||||
fn block_behaviors_and_inputs() {
|
||||
// Clip: new + the static inputs.
|
||||
let (core, behavior) = oaknode::block::clip_create();
|
||||
assert!(core.has_input("enabled_in"));
|
||||
assert!(core.has_input("media_in_in"));
|
||||
assert!(core.has_input("speed_in"));
|
||||
assert!(core.has_input("reverse_in"));
|
||||
assert!(core.has_input("maintain_audio_pitch_in"));
|
||||
assert!(core.has_input("loop_in"));
|
||||
assert_eq!(behavior.name(), "Clip");
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.clipblock");
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Clip");
|
||||
|
||||
let clip = ClipBlockBehavior::new();
|
||||
assert_eq!(clip.core.length(), Rational::new(1, 1));
|
||||
assert!(clip.footage.is_none());
|
||||
|
||||
// Gap.
|
||||
let (core, behavior) = oaknode::block::gap_create();
|
||||
assert_eq!(behavior.name(), "Gap");
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.gapblock");
|
||||
let gap = GapBlockBehavior::new();
|
||||
assert_eq!(gap.core.speed, 1.0);
|
||||
|
||||
// Transition.
|
||||
let (core, behavior) = oaknode::block::transition_create();
|
||||
assert!(core.has_input("out_block_in"));
|
||||
assert!(core.has_input("in_block_in"));
|
||||
assert_eq!(behavior.type_id(), "org.olivevideoeditor.Olive.transitionblock");
|
||||
let t = TransitionBlockBehavior::new();
|
||||
assert_eq!(t.in_offset, Rational::new(0, 1));
|
||||
assert_eq!(t.out_offset, Rational::new(0, 1));
|
||||
assert!(!t.is_dual());
|
||||
let dup = behavior.duplicate(&core).unwrap();
|
||||
assert_eq!(dup.name(), "Transition");
|
||||
}
|
||||
|
||||
/// track.rs: block-list ops with a stub range accessor.
|
||||
#[test]
|
||||
fn track_behavior_ops() {
|
||||
struct Ranges;
|
||||
impl oaknode::track::BlockRange for Ranges {
|
||||
fn in_(&self, b: NodeId) -> Rational {
|
||||
match b.index() {
|
||||
0 => Rational::new(0, 1),
|
||||
1 => Rational::new(5, 1),
|
||||
_ => Rational::new(10, 1),
|
||||
}
|
||||
}
|
||||
fn out(&self, b: NodeId) -> Rational {
|
||||
match b.index() {
|
||||
0 => Rational::new(5, 1),
|
||||
1 => Rational::new(10, 1),
|
||||
_ => Rational::new(20, 1),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let b0 = NodeId::from_identity(0).unwrap();
|
||||
let b1 = NodeId::from_identity(1).unwrap();
|
||||
let b2 = NodeId::from_identity(2).unwrap();
|
||||
|
||||
let mut track = TrackBehavior::new(TrackType::Video);
|
||||
assert_eq!(track.kind, TrackType::Video);
|
||||
track.append_block(b0);
|
||||
track.append_block(b1);
|
||||
assert_eq!(track.block_at(1), Some(b1));
|
||||
assert_eq!(track.block_index(b1), Some(1));
|
||||
assert!(track.insert_block_after(b2, b0));
|
||||
assert_eq!(track.blocks, vec![b0, b2, b1]);
|
||||
let missing = NodeId::from_identity(99).unwrap();
|
||||
assert!(!track.insert_block_after(b2, missing), "missing ref rejected");
|
||||
assert!(!track.insert_block_before(b1, missing), "missing ref rejected");
|
||||
let mut track = TrackBehavior::new(TrackType::Audio);
|
||||
track.append_block(b0);
|
||||
track.append_block(b1);
|
||||
assert!(track.insert_block_after(b2, b0), "insert after a present ref");
|
||||
assert_eq!(track.blocks, vec![b0, b2, b1]);
|
||||
track.insert_block_at_index(NodeId::from_identity(7).unwrap(), 99); // clamped
|
||||
|
||||
// Range queries via the stub.
|
||||
assert_eq!(
|
||||
track.block_containing_time(Rational::new(7, 1), &Ranges),
|
||||
Some(b1)
|
||||
);
|
||||
assert_eq!(
|
||||
track.visible_block_at_time(Rational::new(5, 1), &Ranges),
|
||||
Some(b1),
|
||||
"inclusive in"
|
||||
);
|
||||
assert!(track.is_range_free(
|
||||
TimeRange::new(Rational::new(20, 1), Rational::new(25, 1)),
|
||||
&Ranges
|
||||
));
|
||||
assert!(!track.is_range_free(
|
||||
TimeRange::new(Rational::new(4, 1), Rational::new(6, 1)),
|
||||
&Ranges
|
||||
));
|
||||
assert_eq!(track.length(&Ranges), Rational::new(20, 1));
|
||||
assert_eq!(track.reference(), (1, 0));
|
||||
|
||||
// Ripple remove + replace (the clamped-inserted id-7 block remains).
|
||||
let b7 = NodeId::from_identity(7).unwrap();
|
||||
assert!(track.ripple_remove_block(b2));
|
||||
assert_eq!(track.blocks, vec![b0, b1, b7]);
|
||||
assert!(track.replace_block(b0, b2));
|
||||
assert_eq!(track.blocks, vec![b2, b1, b7]);
|
||||
}
|
||||
|
||||
/// track.rs: track list length + height conversions.
|
||||
#[test]
|
||||
fn tracklist_and_height() {
|
||||
struct Ranges;
|
||||
impl oaknode::track::TrackRange for Ranges {
|
||||
fn length(&self, t: NodeId) -> Rational {
|
||||
Rational::new(t.index() as i64 + 1, 1)
|
||||
}
|
||||
}
|
||||
let mut list = TrackListBehavior::new(TrackType::Audio);
|
||||
assert_eq!(list.kind, TrackType::Audio);
|
||||
list.tracks.push(NodeId::from_identity(2).unwrap());
|
||||
list.tracks.push(NodeId::from_identity(5).unwrap());
|
||||
assert_eq!(list.track_at(0), Some(NodeId::from_identity(2).unwrap()));
|
||||
assert_eq!(list.track_index(NodeId::from_identity(5).unwrap()), Some(1));
|
||||
assert_eq!(list.total_length(&Ranges), Rational::new(6, 1), "longest track");
|
||||
|
||||
// Height conversions (C++ Track::internal_height_to_pixel_height).
|
||||
assert_eq!(
|
||||
oaknode::track::internal_height_to_pixel_height(3.0),
|
||||
39,
|
||||
"default height in px"
|
||||
);
|
||||
assert_eq!(
|
||||
oaknode::track::internal_height_to_pixel_height(1.5),
|
||||
20,
|
||||
"minimum height in px"
|
||||
);
|
||||
assert_eq!(pixel_height_to_internal_height(39), 3.0);
|
||||
}
|
||||
|
||||
/// footage.rs: remaining stream queries + cancel.
|
||||
#[test]
|
||||
fn footage_queries() {
|
||||
let mut f = FootageBehavior::new("x.mov");
|
||||
f.streams = vec![
|
||||
oaknode::footage::StreamInfo {
|
||||
index: 0,
|
||||
is_video: true,
|
||||
video: Some(oaknode::value::VideoParams::default()),
|
||||
audio: None,
|
||||
duration: Rational::new(10, 1),
|
||||
},
|
||||
oaknode::footage::StreamInfo {
|
||||
index: 1,
|
||||
is_video: true,
|
||||
video: Some(oaknode::value::VideoParams::default()),
|
||||
audio: None,
|
||||
duration: Rational::new(20, 1),
|
||||
},
|
||||
oaknode::footage::StreamInfo {
|
||||
index: 2,
|
||||
is_video: false,
|
||||
video: None,
|
||||
audio: Some(oaknode::value::AudioParams::default()),
|
||||
duration: Rational::new(30, 1),
|
||||
},
|
||||
];
|
||||
assert_eq!(f.video_stream_count(), 2);
|
||||
assert_eq!(f.audio_stream_count(), 1);
|
||||
assert_eq!(f.subtitle_stream_count(), 0);
|
||||
assert_eq!(f.duration(), Rational::new(30, 1));
|
||||
assert_eq!(f.video_length(), Rational::new(20, 1), "longest video stream");
|
||||
assert!(f.video_params(1).is_some());
|
||||
assert!(f.video_params(5).is_none());
|
||||
assert!(f.audio_params(0).is_some());
|
||||
assert!(f.audio_params(5).is_none());
|
||||
f.set_cancel(true);
|
||||
assert!(f.is_cancelled());
|
||||
f.set_cancel(false);
|
||||
assert!(!f.is_cancelled());
|
||||
}
|
||||
|
||||
/// colormanager.rs: state machine + listing fallbacks.
|
||||
#[test]
|
||||
fn color_manager_state() {
|
||||
let mut cm = ColorManager::new();
|
||||
assert!(!cm.is_loaded());
|
||||
assert!(cm.list_colorspaces().is_empty());
|
||||
assert!(cm.list_displays().is_empty());
|
||||
assert!(cm.list_views("").is_empty());
|
||||
assert!(cm.list_looks().is_empty());
|
||||
|
||||
cm.initialize().unwrap();
|
||||
assert!(cm.is_loaded());
|
||||
assert_eq!(cm.list_colorspaces(), vec!["linear"]);
|
||||
assert_eq!(cm.list_displays(), vec!["sRGB"]);
|
||||
assert_eq!(cm.list_views("sRGB"), vec!["Standard"]);
|
||||
assert!(cm.list_looks().is_empty());
|
||||
|
||||
cm.config_filename = "/custom.ocio".to_string();
|
||||
cm.update_config_from_filename().unwrap();
|
||||
assert!(cm.is_loaded(), "invalid file keeps the previous config");
|
||||
|
||||
cm.set_up_default_config().unwrap();
|
||||
assert!(cm.is_loaded());
|
||||
|
||||
let mut empty = ColorManager::new();
|
||||
assert!(empty.list_colorspaces().is_empty());
|
||||
}
|
||||
|
||||
/// serializer.rs: value text codec round-trips for every declared type.
|
||||
#[test]
|
||||
fn serializer_value_codecs() {
|
||||
let cases = [
|
||||
(ValueType::Float, NodeValue::Float(3.5)),
|
||||
(ValueType::Int, NodeValue::Int(7)),
|
||||
(ValueType::Combo, NodeValue::Combo(2)),
|
||||
(ValueType::Boolean, NodeValue::Boolean(true)),
|
||||
(ValueType::Rational, NodeValue::Rational(Rational::new(3, 4))),
|
||||
(ValueType::Text, NodeValue::Text("hello".to_string())),
|
||||
(ValueType::StrCombo, NodeValue::StrCombo("choice".to_string())),
|
||||
];
|
||||
for (declared, value) in cases {
|
||||
let text = value_to_string(declared, &value, true);
|
||||
let back = string_to_value(declared, &text);
|
||||
match declared {
|
||||
ValueType::Float => assert_eq!(back.to_double(), 3.5),
|
||||
ValueType::Int | ValueType::Combo => assert_eq!(back.to_double(), value.to_double()),
|
||||
ValueType::Boolean => assert_eq!(back.to_double(), 1.0),
|
||||
ValueType::Rational => assert_eq!(back.to_double(), 0.75),
|
||||
ValueType::Text => assert_eq!(back, NodeValue::Text("hello".to_string())),
|
||||
ValueType::StrCombo => assert_eq!(back, NodeValue::StrCombo("choice".to_string())),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Whole (non-key-track) vec/color forms use ":" separators.
|
||||
let v2 = NodeValue::Vec2([1.5, 2.5]);
|
||||
assert_eq!(value_to_string(ValueType::Vec2, &v2, false), "1.5:2.5");
|
||||
assert_eq!(
|
||||
string_to_value(ValueType::Vec2, "1.5:2.5"),
|
||||
NodeValue::Vec2([1.5, 2.5])
|
||||
);
|
||||
let c = NodeValue::Color([0.1, 0.2, 0.3, 0.4]);
|
||||
assert_eq!(
|
||||
value_to_string(ValueType::Color, &c, false),
|
||||
"0.1:0.2:0.3:0.4"
|
||||
);
|
||||
assert_eq!(
|
||||
string_to_value(ValueType::Color, "0.1:0.2:0.3:0.4"),
|
||||
NodeValue::Color([0.1, 0.2, 0.3, 0.4])
|
||||
);
|
||||
// Key-track form is the plain first component.
|
||||
assert_eq!(value_to_string(ValueType::Vec2, &v2, true), "1.5");
|
||||
|
||||
// Interpolation mapping.
|
||||
assert_eq!(
|
||||
oaknode::serializer::interpolation_from_c(0),
|
||||
oaknode::keyframe::Interpolation::Linear
|
||||
);
|
||||
assert_eq!(
|
||||
oaknode::serializer::interpolation_from_c(1),
|
||||
oaknode::keyframe::Interpolation::Hold
|
||||
);
|
||||
assert_eq!(
|
||||
oaknode::serializer::interpolation_from_c(2),
|
||||
oaknode::keyframe::Interpolation::Bezier
|
||||
);
|
||||
}
|
||||
|
||||
/// bridge::undo: vtable commands + multi commands through the stubs.
|
||||
///
|
||||
/// Needs the `test-stubs` feature: the bridge resolves `oakundo_*`
|
||||
/// symbols via dlsym, which only resolves in the test binary when the
|
||||
/// in-crate stubs are compiled in.
|
||||
#[cfg(feature = "test-stubs")]
|
||||
#[test]
|
||||
fn undo_command_roundtrip() {
|
||||
use std::sync::atomic::{AtomicI32, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
let value = Arc::new(AtomicI32::new(0));
|
||||
let value_redo = value.clone();
|
||||
let value_undo = value.clone();
|
||||
let value_check = value.clone();
|
||||
let mut cmd = oaknode::bridge::undo::command_from_closures(
|
||||
move || {
|
||||
value_redo.fetch_add(1, Ordering::SeqCst);
|
||||
},
|
||||
move || {
|
||||
value_undo.fetch_sub(1, Ordering::SeqCst);
|
||||
},
|
||||
)
|
||||
.expect("undo stub available");
|
||||
|
||||
// redo applies, undo reverts; redo_now is idempotent.
|
||||
assert_eq!(oaknode::bridge::undo::command_redo_now(cmd.clone()).unwrap(), 0);
|
||||
assert_eq!(value_check.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(oaknode::bridge::undo::command_redo_now(cmd.clone()).unwrap(), 0);
|
||||
assert_eq!(value_check.load(Ordering::SeqCst), 1, "redo no-ops when done");
|
||||
assert_eq!(oaknode::bridge::undo::command_undo_now(cmd.clone()).unwrap(), 0);
|
||||
assert_eq!(value_check.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(oaknode::bridge::undo::command_undo_now(cmd.clone()).unwrap(), 0);
|
||||
assert_eq!(value_check.load(Ordering::SeqCst), 0, "undo no-ops when not done");
|
||||
|
||||
// Multi command batches children.
|
||||
let mut multi = oaknode::bridge::undo::command_init_multi().unwrap();
|
||||
let mut child = oaknode::bridge::undo::command_from_closures(
|
||||
{
|
||||
let value = value_check.clone();
|
||||
move || {
|
||||
value.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
},
|
||||
{
|
||||
let value = value_check.clone();
|
||||
move || {
|
||||
value.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
oaknode::bridge::undo::command_multi_add_child(multi.clone(), child.clone()).unwrap(),
|
||||
0
|
||||
);
|
||||
assert_eq!(oaknode::bridge::undo::command_redo_now(multi.clone()).unwrap(), 0);
|
||||
assert_eq!(value_check.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(oaknode::bridge::undo::command_undo_now(multi.clone()).unwrap(), 0);
|
||||
assert_eq!(value_check.load(Ordering::SeqCst), 0);
|
||||
|
||||
oaknode::bridge::undo::command_free(&mut cmd);
|
||||
oaknode::bridge::undo::command_free(&mut multi);
|
||||
oaknode::bridge::undo::command_free(&mut child);
|
||||
}
|
||||
|
||||
/// track.rs: branch coverage the ffi contract tests leave open —
|
||||
/// `TrackType::from_c` invalid values, insert/remove/replace failure
|
||||
/// paths, empty range queries, duplicates, and naming.
|
||||
#[test]
|
||||
fn track_edge_cases() {
|
||||
use oaknode::node::NodeBehavior;
|
||||
|
||||
struct Ranges;
|
||||
impl oaknode::track::BlockRange for Ranges {
|
||||
fn in_(&self, b: NodeId) -> Rational {
|
||||
Rational::new(b.index() as i64, 1)
|
||||
}
|
||||
fn out(&self, b: NodeId) -> Rational {
|
||||
Rational::new(b.index() as i64 + 1, 1)
|
||||
}
|
||||
}
|
||||
struct Tr;
|
||||
impl oaknode::track::TrackRange for Tr {
|
||||
fn length(&self, t: NodeId) -> Rational {
|
||||
Rational::new(t.index() as i64, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// TrackType::from_c round-trip + invalid values.
|
||||
assert_eq!(TrackType::from_c(0), Some(TrackType::Video));
|
||||
assert_eq!(TrackType::from_c(1), Some(TrackType::Audio));
|
||||
assert_eq!(TrackType::from_c(2), Some(TrackType::Subtitle));
|
||||
assert_eq!(TrackType::from_c(3), None);
|
||||
assert_eq!(TrackType::from_c(-1), None);
|
||||
assert_eq!(TrackType::Subtitle.to_c(), 2);
|
||||
|
||||
let b0 = NodeId::from_identity(0).unwrap();
|
||||
let b1 = NodeId::from_identity(1).unwrap();
|
||||
let b9 = NodeId::from_identity(9).unwrap();
|
||||
|
||||
let mut track = TrackBehavior::new(TrackType::Subtitle);
|
||||
assert_eq!(track.name(), "Subtitle Track");
|
||||
assert!(!track.insert_block_before(b1, b9), "absent reference rejected");
|
||||
track.append_block(b0);
|
||||
assert!(track.insert_block_before(b1, b0));
|
||||
assert_eq!(track.blocks, vec![b1, b0]);
|
||||
assert!(!track.remove_block(b9), "absent block");
|
||||
assert!(!track.replace_block(b9, b0), "absent replace");
|
||||
|
||||
// Empty-track range queries.
|
||||
let empty = TrackBehavior::new(TrackType::Video);
|
||||
assert_eq!(empty.length(&Ranges), Rational::new(0, 1));
|
||||
assert_eq!(empty.block_containing_time(Rational::new(1, 1), &Ranges), None);
|
||||
assert_eq!(empty.visible_block_at_time(Rational::new(1, 1), &Ranges), None);
|
||||
assert!(empty.is_range_free(
|
||||
TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)),
|
||||
&Ranges
|
||||
));
|
||||
assert_eq!(empty.reference(), (0, 0), "video type, default index");
|
||||
|
||||
// duplicate preserves the block list and kind.
|
||||
let dup = track.duplicate(&oaknode::node::NodeCore::new()).unwrap();
|
||||
let d = dup.as_any().unwrap().downcast_ref::<TrackBehavior>().unwrap();
|
||||
assert_eq!(d.blocks, vec![b1, b0]);
|
||||
assert_eq!(d.kind, TrackType::Subtitle);
|
||||
assert_eq!(dup.type_id(), "org.olivevideoeditor.Olive.track");
|
||||
|
||||
// TrackListBehavior empty queries + duplicate.
|
||||
let mut list = TrackListBehavior::new(TrackType::Video);
|
||||
assert_eq!(list.name(), "Video Tracks");
|
||||
assert_eq!(list.track_at(0), None);
|
||||
assert_eq!(list.track_index(b0), None);
|
||||
assert_eq!(list.total_length(&Tr), Rational::new(0, 1));
|
||||
list.tracks.push(b0);
|
||||
let dup = list.duplicate(&oaknode::node::NodeCore::new()).unwrap();
|
||||
let d = dup.as_any().unwrap().downcast_ref::<TrackListBehavior>().unwrap();
|
||||
assert_eq!(d.tracks, vec![b0]);
|
||||
assert_eq!(d.kind, TrackType::Video);
|
||||
assert_eq!(dup.type_id(), "org.olivevideoeditor.Olive.tracklist");
|
||||
}
|
||||
|
||||
/// footage.rs: probe failure path, proxy field round-trip, duplicate.
|
||||
#[test]
|
||||
fn footage_edge_cases() {
|
||||
use oaknode::node::NodeBehavior;
|
||||
|
||||
let mut f = FootageBehavior::new("nonexistent.mov");
|
||||
// probe errors when oakcodec is unavailable (test build).
|
||||
assert!(f.probe().is_err());
|
||||
assert!(!f.valid, "failed probe leaves valid unset");
|
||||
assert_eq!(f.total_stream_count(), 0);
|
||||
|
||||
// set_proxy / clear_proxy field round-trip.
|
||||
f.set_proxy("/p.mov", 2, 3, 4, true);
|
||||
assert_eq!(f.proxy, "/p.mov");
|
||||
assert_eq!(f.proxy_state, 2);
|
||||
assert_eq!(f.proxy_video_stream_index, 3);
|
||||
assert_eq!(f.proxy_preset_version, 4);
|
||||
assert!(f.proxy_enabled);
|
||||
f.clear_proxy();
|
||||
assert_eq!(f.proxy, "");
|
||||
assert_eq!(f.proxy_state, 0);
|
||||
assert_eq!(f.proxy_video_stream_index, -1);
|
||||
assert_eq!(f.proxy_preset_version, 0);
|
||||
assert!(!f.proxy_enabled);
|
||||
|
||||
// duplicate preserves the fields.
|
||||
f.set_proxy("/p2.mov", 1, 0, 0, true);
|
||||
let dup = f.duplicate(&oaknode::node::NodeCore::new()).unwrap();
|
||||
let d = dup.as_any().unwrap().downcast_ref::<FootageBehavior>().unwrap();
|
||||
assert_eq!(d.filename, "nonexistent.mov");
|
||||
assert_eq!(d.proxy, "/p2.mov");
|
||||
assert_eq!(d.proxy_state, 1);
|
||||
assert!(d.proxy_enabled);
|
||||
assert_eq!(dup.type_id(), "org.olivevideoeditor.Olive.footage");
|
||||
}
|
||||
|
||||
/// sequence.rs: stream counts, verify_length, defaults, duplicate.
|
||||
#[test]
|
||||
fn sequence_edge_cases() {
|
||||
use oaknode::node::NodeBehavior;
|
||||
use oaknode::sequence::SequenceBehavior;
|
||||
|
||||
let mut seq = SequenceBehavior::new();
|
||||
assert_eq!(seq.name(), "Sequence");
|
||||
assert_eq!(seq.type_id(), "org.olivevideoeditor.Olive.sequence");
|
||||
assert_eq!(seq.video_stream_count(), 0);
|
||||
assert_eq!(seq.audio_stream_count(), 0);
|
||||
seq.verify_length((
|
||||
Rational::new(10, 1),
|
||||
Rational::new(20, 1),
|
||||
Rational::new(30, 1),
|
||||
));
|
||||
assert_eq!(seq.last_length, Rational::new(30, 1));
|
||||
seq.set_default_parameters();
|
||||
assert_eq!(seq.video_stream_count(), 1, "default video params");
|
||||
assert_eq!(seq.audio_stream_count(), 1, "default audio params");
|
||||
|
||||
let dup = seq.duplicate(&oaknode::node::NodeCore::new()).unwrap();
|
||||
let d = dup.as_any().unwrap().downcast_ref::<SequenceBehavior>().unwrap();
|
||||
// C++ `copy()` clones without values/params; the duplicate is empty.
|
||||
assert_eq!(d.video_stream_count(), 0);
|
||||
let def = SequenceBehavior::default();
|
||||
assert_eq!(def.audio_stream_count(), 0);
|
||||
}
|
||||
|
||||
/// group.rs: direct behavior edges the ffi contract tests do not reach —
|
||||
/// metadata, id lookup, force-id minting, resolution through a real
|
||||
/// passthrough, and duplication.
|
||||
#[test]
|
||||
fn group_behavior_edges() {
|
||||
use oaknode::node::NodeBehavior;
|
||||
use oaknode::nodes::group::{InnerInput, NodeGroup};
|
||||
use oaknode::project::Project;
|
||||
|
||||
let project = Project::new();
|
||||
let (inner_id, group_id) = {
|
||||
let mut p = project.lock().unwrap();
|
||||
let (core, behavior) = (oaknode::factory::Factory::global()
|
||||
.find("org.olivevideoeditor.Olive.math")
|
||||
.unwrap()
|
||||
.create)();
|
||||
let inner = p.graph.add_node(core, behavior);
|
||||
let (gcore, gbehavior) = oaknode::nodes::group::create();
|
||||
let group = p.graph.add_node(gcore, gbehavior);
|
||||
(inner, group)
|
||||
};
|
||||
|
||||
// Metadata + default state.
|
||||
{
|
||||
let p = project.lock().unwrap();
|
||||
let entry = p.graph.get(group_id).unwrap();
|
||||
let gb = entry.behavior.as_any().unwrap().downcast_ref::<NodeGroup>().unwrap();
|
||||
assert_eq!(gb.name(), "Group");
|
||||
assert_eq!(gb.type_id(), "org.olivevideoeditor.Olive.group");
|
||||
assert_eq!(gb.description(), "A group of nodes that is represented as a single node.");
|
||||
assert!(gb.categories().is_empty());
|
||||
assert_eq!(gb.output_passthrough(), None);
|
||||
assert!(gb.passthroughs().is_empty());
|
||||
assert!(!gb.contains_input_passthrough(&InnerInput {
|
||||
node: inner_id,
|
||||
input: "param_a_in".to_string(),
|
||||
element: -1,
|
||||
}));
|
||||
assert_eq!(gb.id_of_passthrough(&InnerInput {
|
||||
node: inner_id,
|
||||
input: "param_a_in".to_string(),
|
||||
element: -1,
|
||||
}), "");
|
||||
assert_eq!(gb.input_name("param_a_in"), "param_a_in");
|
||||
assert_eq!(gb.input_from_id("param_a_in"), None);
|
||||
}
|
||||
|
||||
// Add two passthroughs of the same inner input id from different
|
||||
// inner nodes: the second mints `param_a_in_2` (id-collision loop).
|
||||
let (g1, g2) = {
|
||||
let mut p = project.lock().unwrap();
|
||||
let (core2, behavior2) = (oaknode::factory::Factory::global()
|
||||
.find("org.olivevideoeditor.Olive.math")
|
||||
.unwrap()
|
||||
.create)();
|
||||
let inner2 = p.graph.add_node(core2, behavior2);
|
||||
let descriptor = p
|
||||
.graph
|
||||
.get(inner_id)
|
||||
.unwrap()
|
||||
.core
|
||||
.get_input("param_a_in")
|
||||
.unwrap()
|
||||
.clone();
|
||||
let descriptor2 = p
|
||||
.graph
|
||||
.get(inner2)
|
||||
.unwrap()
|
||||
.core
|
||||
.get_input("param_a_in")
|
||||
.unwrap()
|
||||
.clone();
|
||||
let entry = p.graph.get_mut(group_id).unwrap();
|
||||
let gb = entry.behavior.as_any_mut().unwrap().downcast_mut::<NodeGroup>().unwrap();
|
||||
let id1 = gb.add_input_passthrough(
|
||||
&mut entry.core,
|
||||
InnerInput { node: inner_id, input: "param_a_in".to_string(), element: -1 },
|
||||
"",
|
||||
&descriptor,
|
||||
);
|
||||
let id2 = gb.add_input_passthrough(
|
||||
&mut entry.core,
|
||||
InnerInput { node: inner2, input: "param_a_in".to_string(), element: -1 },
|
||||
"",
|
||||
&descriptor2,
|
||||
);
|
||||
let id3 = gb.add_input_passthrough(
|
||||
&mut entry.core,
|
||||
InnerInput { node: inner2, input: "param_b_in".to_string(), element: -1 },
|
||||
"forced",
|
||||
&descriptor2,
|
||||
);
|
||||
(id1, (id2, id3, inner2))
|
||||
};
|
||||
assert_eq!(g1, "param_a_in");
|
||||
assert_eq!(g2.0, "param_a_in_2", "collision mints a suffixed id");
|
||||
assert_eq!(g2.1, "forced", "force_id is used verbatim");
|
||||
|
||||
// id_of_passthrough / input_from_id / contains now resolve.
|
||||
{
|
||||
let p = project.lock().unwrap();
|
||||
let entry = p.graph.get(group_id).unwrap();
|
||||
let gb = entry.behavior.as_any().unwrap().downcast_ref::<NodeGroup>().unwrap();
|
||||
let target = InnerInput { node: inner_id, input: "param_a_in".to_string(), element: -1 };
|
||||
assert!(gb.contains_input_passthrough(&target));
|
||||
assert_eq!(gb.id_of_passthrough(&target), "param_a_in");
|
||||
assert!(gb.input_from_id("param_a_in").is_some());
|
||||
assert!(gb.input_from_id("nope").is_none());
|
||||
}
|
||||
|
||||
// get_inner rewrites a group-passthrough input to the inner node;
|
||||
// resolve_input follows it to the end.
|
||||
{
|
||||
let p = project.lock().unwrap();
|
||||
let mut input = InnerInput { node: group_id, input: "param_a_in".to_string(), element: -1 };
|
||||
assert!(NodeGroup::get_inner(&p.graph, &mut input));
|
||||
assert_eq!(input.node, inner_id);
|
||||
assert_eq!(input.input, "param_a_in");
|
||||
let resolved = NodeGroup::resolve_input(
|
||||
&p.graph,
|
||||
InnerInput { node: group_id, input: "param_a_in".to_string(), element: -1 },
|
||||
);
|
||||
assert_eq!(resolved.node, inner_id);
|
||||
// A non-group node does not resolve through.
|
||||
let mut noop = InnerInput { node: inner_id, input: "param_a_in".to_string(), element: -1 };
|
||||
assert!(!NodeGroup::get_inner(&p.graph, &mut noop));
|
||||
}
|
||||
|
||||
// duplicate clones the passthrough table and output reference.
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
p.graph.get_mut(group_id).unwrap().behavior.as_any_mut().unwrap()
|
||||
.downcast_mut::<NodeGroup>().unwrap()
|
||||
.set_output_passthrough(Some(inner_id));
|
||||
let entry = p.graph.get(group_id).unwrap();
|
||||
let gb = entry.behavior.as_any().unwrap().downcast_ref::<NodeGroup>().unwrap();
|
||||
assert_eq!(gb.output_passthrough(), Some(inner_id));
|
||||
let dup = gb.duplicate(&entry.core).unwrap();
|
||||
let d = dup.as_any().unwrap().downcast_ref::<NodeGroup>().unwrap();
|
||||
assert_eq!(d.output_passthrough(), Some(inner_id));
|
||||
assert_eq!(d.passthroughs().len(), 3);
|
||||
}
|
||||
|
||||
// remove_input_passthrough clears the table entry.
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
let entry = p.graph.get_mut(group_id).unwrap();
|
||||
let gb = entry.behavior.as_any_mut().unwrap().downcast_mut::<NodeGroup>().unwrap();
|
||||
gb.remove_input_passthrough(
|
||||
&mut entry.core,
|
||||
&InnerInput { node: inner_id, input: "param_a_in".to_string(), element: -1 },
|
||||
);
|
||||
assert_eq!(gb.passthroughs().len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
/// group.rs: the custom XML serialization half (`save_custom` /
|
||||
/// `load_custom` / `post_load`), which needs the oakcommon XML bridge.
|
||||
#[cfg(feature = "test-stubs")]
|
||||
#[test]
|
||||
fn group_serialization() {
|
||||
use oaknode::node::NodeBehavior;
|
||||
use oaknode::nodes::group::NodeGroup;
|
||||
|
||||
let mut gb = oaknode::nodes::group::create().1;
|
||||
let gb = gb.as_any_mut().unwrap().downcast_mut::<NodeGroup>().unwrap();
|
||||
|
||||
// save_custom writes the passthrough elements.
|
||||
let mut writer = oaknode::serializer::XmlWriterBridge::new().unwrap();
|
||||
{
|
||||
use oaknode::node::NodeBehavior;
|
||||
let core = oaknode::node::NodeCore::new();
|
||||
gb.save_custom(&core, &mut writer);
|
||||
}
|
||||
let xml = writer.output();
|
||||
assert!(xml.contains("inputpassthroughs"), "writes the container");
|
||||
|
||||
// load_custom parses them back (node references deferred).
|
||||
let mut gb = oaknode::nodes::group::create().1;
|
||||
let gb = gb.as_any_mut().unwrap().downcast_mut::<NodeGroup>().unwrap();
|
||||
let mut reader = oaknode::serializer::XmlReaderBridge::new(&xml).unwrap();
|
||||
{
|
||||
use oaknode::node::NodeBehavior;
|
||||
let mut core = oaknode::node::NodeCore::new();
|
||||
assert!(gb.load_custom(&mut core, &mut reader));
|
||||
}
|
||||
gb.post_load(&mut oaknode::node::NodeCore::new());
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Serializer tests. The C++ writer's golden files do not exist in this
|
||||
//! tree (`tests/golden/` is empty), so the byte-exact comparison is
|
||||
//! ignored with a doc reason; everything else (round-trip idempotence,
|
||||
//! version rejection, corrupt input) is live.
|
||||
|
||||
/// Save format parity: a fixture project saves byte-identical XML to
|
||||
/// the C++ 230220 writer output (attribute order included).
|
||||
///
|
||||
/// Ignored: the C++ golden fixtures are not committed in this tree
|
||||
/// (src/node/tests/ has no .xml/.ove files) and the value text codecs
|
||||
/// use Rust formatting, so byte-exact parity cannot be pinned here. The
|
||||
/// C++ gtest suite (`src/node/tests/serializer_test.cpp`) pins the
|
||||
/// writer; this crate's serializer_test covers structure + round-trip.
|
||||
#[test]
|
||||
#[ignore = "C++ golden fixtures unavailable in this tree"]
|
||||
fn save_matches_cpp_byte_exact() {
|
||||
todo!()
|
||||
}
|
||||
|
||||
/// Round-trip: load(save(p)) yields a project whose re-saved XML is
|
||||
/// identical (idempotence).
|
||||
///
|
||||
/// Needs the `test-stubs` feature: save/load route XML through the
|
||||
/// oakcommon bridge, whose symbols only resolve in the test binary when
|
||||
/// the in-crate stubs are compiled in.
|
||||
#[cfg(feature = "test-stubs")]
|
||||
#[test]
|
||||
fn roundtrip_idempotent() {
|
||||
use oaknode::project::Project;
|
||||
use oaknode::node::NodeCore;
|
||||
use oaknode::value::{NodeValue, ValueType};
|
||||
use oaknode::input::Input;
|
||||
|
||||
let project = Project::new();
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
p.initialize().unwrap();
|
||||
// A math node with a set value + a keyframe.
|
||||
let (core, behavior) = (oaknode::factory::Factory::global()
|
||||
.find("org.olivevideoeditor.Olive.math")
|
||||
.unwrap()
|
||||
.create)();
|
||||
let id = p.graph.add_node(core, behavior);
|
||||
p.graph
|
||||
.get_mut(id)
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value("param_a_in", -1, NodeValue::Float(2.5));
|
||||
p.graph
|
||||
.get_mut(id)
|
||||
.unwrap()
|
||||
.core
|
||||
.keyframe_track_mut("param_a_in", -1)
|
||||
.set_key(oaknode::keyframe::Keyframe {
|
||||
time: oakcore_rs::Rational::new(0, 1),
|
||||
value: NodeValue::Float(1.0),
|
||||
interpolation: oaknode::keyframe::Interpolation::Linear,
|
||||
bezier_in: (0.0, 0.0),
|
||||
bezier_out: (0.0, 0.0),
|
||||
});
|
||||
// A second node connected to the first.
|
||||
let (core2, behavior2) = (oaknode::factory::Factory::global()
|
||||
.find("org.olivevideoeditor.Olive.math")
|
||||
.unwrap()
|
||||
.create)();
|
||||
let id2 = p.graph.add_node(core2, behavior2);
|
||||
p.graph.connect(id, id2, "param_a_in", -1).unwrap();
|
||||
}
|
||||
|
||||
let xml = {
|
||||
let p = project.lock().unwrap();
|
||||
oaknode::serializer::save(&p).unwrap()
|
||||
};
|
||||
let loaded = oaknode::serializer::load(&xml).unwrap();
|
||||
let xml2 = {
|
||||
let p = loaded.lock().unwrap();
|
||||
oaknode::serializer::save(&p).unwrap()
|
||||
};
|
||||
assert_eq!(xml, xml2, "re-save is idempotent");
|
||||
|
||||
// Structural equivalence: node count + edges.
|
||||
{
|
||||
let a = project.lock().unwrap();
|
||||
let b = loaded.lock().unwrap();
|
||||
assert_eq!(a.graph.node_count(), b.graph.node_count());
|
||||
assert_eq!(
|
||||
a.graph.output_connections_all().len(),
|
||||
b.graph.output_connections_all().len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Version ladder: historical `<olive ...>` roots with known versions
|
||||
/// load (the body upgrades to the current model); unknown roots are
|
||||
/// rejected.
|
||||
///
|
||||
/// Needs the `test-stubs` feature (same XML-bridge rationale as
|
||||
/// [`roundtrip_idempotent`]).
|
||||
#[cfg(feature = "test-stubs")]
|
||||
#[test]
|
||||
fn historical_versions_upgrade() {
|
||||
// A current-format document round-trips.
|
||||
let xml = "<project version=\"1\"><uuid>{test}</uuid><nodes></nodes><settings></settings></project>";
|
||||
let project = oaknode::serializer::load(xml).unwrap();
|
||||
assert_eq!(project.lock().unwrap().uuid, "{test}");
|
||||
|
||||
// An unknown root is rejected.
|
||||
assert!(oaknode::serializer::load("<olive-unknown/>").is_err());
|
||||
assert!(oaknode::serializer::load("").is_err());
|
||||
}
|
||||
|
||||
/// Corrupt XML and newer-than-build versions are rejected with the
|
||||
/// documented error codes, never a panic.
|
||||
#[test]
|
||||
fn corrupt_and_future_files_rejected() {
|
||||
// A newer-than-build version is rejected.
|
||||
let future = "<olive version=\"999999\"></olive>";
|
||||
assert!(oaknode::serializer::load(future).is_err());
|
||||
|
||||
// Malformed XML (unbalanced) is rejected without a panic.
|
||||
let corrupt = "<project><nodes><node></project>";
|
||||
assert!(oaknode::serializer::load(corrupt).is_err());
|
||||
|
||||
// Garbage text.
|
||||
assert!(oaknode::serializer::load("not xml at all").is_err());
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Project / sequence / track / block contract tests.
|
||||
//!
|
||||
//! Phase 1 covers the project engine (lifecycle / deep_copy / sync_copy).
|
||||
//! The sequence / track / clip-cache / footage tests need the Phase 2
|
||||
//! timeline and footage modules and stay ignored until then.
|
||||
|
||||
use oaknode::error::Error;
|
||||
use oaknode::graph::Graph;
|
||||
use oaknode::id::NodeId;
|
||||
use oaknode::input::Input;
|
||||
use oaknode::node::{NodeBehavior, NodeCore};
|
||||
use oaknode::project::{ChangeRecord, Project};
|
||||
use oaknode::value::{NodeValue, ValueType};
|
||||
|
||||
/// Minimal test behavior.
|
||||
struct TestNode;
|
||||
|
||||
impl NodeBehavior for TestNode {
|
||||
fn name(&self) -> &str {
|
||||
"Test"
|
||||
}
|
||||
|
||||
fn type_id(&self) -> &str {
|
||||
"org.olivevideoeditor.Olive.test"
|
||||
}
|
||||
|
||||
fn duplicate(&self, _core: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(TestNode))
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a float-input test node to `g`, returning its id.
|
||||
fn add_test_node(g: &mut Graph) -> NodeId {
|
||||
let mut core = NodeCore::new();
|
||||
core.add_input(Input::new(
|
||||
"val_in",
|
||||
ValueType::Float,
|
||||
NodeValue::Float(0.0),
|
||||
));
|
||||
core.add_input(Input::new(
|
||||
"val_in2",
|
||||
ValueType::Float,
|
||||
NodeValue::Float(0.0),
|
||||
));
|
||||
g.add_node(core, Box::new(TestNode))
|
||||
}
|
||||
|
||||
/// Project lifecycle: init → initialize → add nodes → clear →
|
||||
/// re-initialize; modified flag transitions match C++.
|
||||
#[test]
|
||||
fn project_lifecycle() {
|
||||
let project = Project::new();
|
||||
let mut p = project.lock().unwrap();
|
||||
|
||||
// Fresh project: new, unmodified, no root.
|
||||
assert!(p.is_new());
|
||||
assert!(!p.is_modified());
|
||||
assert!(!p.root.valid());
|
||||
|
||||
// initialize() creates the root folder; second call is E_STATE.
|
||||
assert!(p.initialize().is_ok());
|
||||
assert!(p.root.valid());
|
||||
assert_eq!(p.initialize(), Err(Error::State));
|
||||
|
||||
// A fresh project that is not new after touching state.
|
||||
let a = add_test_node(&mut p.graph);
|
||||
let b = add_test_node(&mut p.graph);
|
||||
assert!(p.graph.connect(a, b, "val_in", -1).is_ok());
|
||||
p.set_modified(true);
|
||||
assert!(p.is_modified());
|
||||
assert!(!p.is_new());
|
||||
p.set_modified(false);
|
||||
assert!(!p.is_modified());
|
||||
|
||||
// clear() empties the graph and resets the root.
|
||||
assert!(p.clear().is_ok());
|
||||
assert_eq!(p.graph.node_count(), 0);
|
||||
assert!(!p.root.valid());
|
||||
|
||||
// Re-initialize after clear works (C++ `Project::clear()` +
|
||||
// `initialize()`).
|
||||
assert!(p.initialize().is_ok());
|
||||
assert!(p.root.valid());
|
||||
assert_eq!(p.initialize(), Err(Error::State));
|
||||
}
|
||||
|
||||
/// deep_copy: the copy is structurally identical (nodes/edges/params)
|
||||
/// but shares no mutable state; editing the original does not leak
|
||||
/// into the copy before sync_copy.
|
||||
#[test]
|
||||
fn project_deep_copy_isolation() {
|
||||
let project = Project::new();
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
p.initialize().unwrap();
|
||||
let a = add_test_node(&mut p.graph);
|
||||
let b = add_test_node(&mut p.graph);
|
||||
p.graph.connect(a, b, "val_in", -1).unwrap();
|
||||
p.graph
|
||||
.get_mut(a)
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value("val_in", -1, NodeValue::Float(42.0));
|
||||
p.set_filename("/tmp/demo.ove");
|
||||
p.set_modified(true);
|
||||
}
|
||||
|
||||
let copy = project.lock().unwrap().deep_copy().unwrap();
|
||||
let orig_guard = project.lock().unwrap();
|
||||
let copy_guard = copy.lock().unwrap();
|
||||
|
||||
// Structural identity: same node count, same edge count, same
|
||||
// parameters.
|
||||
assert_eq!(orig_guard.graph.node_count(), copy_guard.graph.node_count());
|
||||
assert_eq!(
|
||||
orig_guard.graph.output_connections_all().len(),
|
||||
copy_guard.graph.output_connections_all().len()
|
||||
);
|
||||
assert_eq!(orig_guard.filename, copy_guard.filename);
|
||||
assert_eq!(orig_guard.modified, copy_guard.modified);
|
||||
assert_eq!(orig_guard.uuid, copy_guard.uuid);
|
||||
assert!(copy_guard.root.valid());
|
||||
|
||||
// The copy shares no mutable state: mutate the original, the copy
|
||||
// must not see it (no sync has happened yet).
|
||||
let copy_val = copy_guard.graph.node_ids()[1];
|
||||
let copy_val = copy_guard
|
||||
.graph
|
||||
.get(copy_val)
|
||||
.unwrap()
|
||||
.core
|
||||
.standard_value("val_in", -1)
|
||||
.to_double();
|
||||
assert_eq!(copy_val, 42.0, "deep copy preserves parameters");
|
||||
}
|
||||
|
||||
/// sync_copy applies a recorded change set (add/remove node, edge
|
||||
/// change, value change) and produces the same graph as a fresh
|
||||
/// deep_copy.
|
||||
#[test]
|
||||
fn project_sync_copy_consistency() {
|
||||
let project = Project::new();
|
||||
{
|
||||
let mut p = project.lock().unwrap();
|
||||
p.initialize().unwrap();
|
||||
let a = add_test_node(&mut p.graph);
|
||||
let b = add_test_node(&mut p.graph);
|
||||
p.graph.connect(a, b, "val_in", -1).unwrap();
|
||||
p.graph
|
||||
.get_mut(a)
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value("val_in", -1, NodeValue::Float(7.0));
|
||||
}
|
||||
|
||||
// Copy, then mutate the original and replay the change set.
|
||||
let mut original = project.lock().unwrap();
|
||||
let copied = original.deep_copy().unwrap();
|
||||
let mut copy_guard = copied.lock().unwrap();
|
||||
let ids = original.graph.node_ids();
|
||||
let a = ids[1];
|
||||
let b = ids[2];
|
||||
|
||||
// 1. add a new node c + edge c->b, 2. change a's value.
|
||||
let c = add_test_node(&mut original.graph);
|
||||
original.graph.connect(c, b, "val_in2", -1).unwrap();
|
||||
original
|
||||
.graph
|
||||
.get_mut(a)
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value("val_in", -1, NodeValue::Float(11.0));
|
||||
|
||||
let changes = [
|
||||
ChangeRecord::NodeAdded(c),
|
||||
ChangeRecord::EdgeChanged {
|
||||
from: c,
|
||||
to: b,
|
||||
input: "val_in2".to_string(),
|
||||
element: -1,
|
||||
connected: true,
|
||||
},
|
||||
ChangeRecord::ValueChanged {
|
||||
node: a,
|
||||
input: "val_in".to_string(),
|
||||
element: -1,
|
||||
},
|
||||
];
|
||||
original.sync_copy(&mut copy_guard, &changes).unwrap();
|
||||
|
||||
assert_eq!(copy_guard.graph.node_count(), original.graph.node_count());
|
||||
assert_eq!(
|
||||
copy_guard.graph.connected_output(b, "val_in", -1),
|
||||
Some(a),
|
||||
"sync applies edge changes"
|
||||
);
|
||||
assert_eq!(
|
||||
copy_guard.graph.connected_output(b, "val_in2", -1),
|
||||
Some(c),
|
||||
"sync applies new-node edges"
|
||||
);
|
||||
let sync_val = copy_guard
|
||||
.graph
|
||||
.get(a)
|
||||
.unwrap()
|
||||
.core
|
||||
.standard_value("val_in", -1)
|
||||
.to_double();
|
||||
assert_eq!(sync_val, 11.0, "sync applies value changes");
|
||||
|
||||
// A fresh deep_copy after the edits agrees with the synced copy.
|
||||
drop(copy_guard);
|
||||
let fresh = original.deep_copy().unwrap();
|
||||
let fresh_guard = fresh.lock().unwrap();
|
||||
assert_eq!(
|
||||
fresh_guard.graph.node_count(),
|
||||
original.graph.node_count()
|
||||
);
|
||||
assert_eq!(
|
||||
fresh_guard.graph.output_connections_all().len(),
|
||||
original.graph.output_connections_all().len()
|
||||
);
|
||||
assert_eq!(
|
||||
fresh_guard.graph.connected_output(b, "val_in", -1),
|
||||
Some(a)
|
||||
);
|
||||
assert_eq!(
|
||||
fresh_guard.graph.get(a).unwrap().core.standard_value("val_in", -1).to_double(),
|
||||
11.0
|
||||
);
|
||||
}
|
||||
|
||||
/// Sequence defaults: create → three track lists (video/audio/
|
||||
/// subtitle) with zero tracks; default parameters populate one video
|
||||
/// and one audio stream (set_default_parameters parity).
|
||||
#[test]
|
||||
fn sequence_default_structure() {
|
||||
use oaknode::sequence::SequenceBehavior;
|
||||
|
||||
let mut seq = SequenceBehavior::new();
|
||||
assert!(seq.track_lists.is_empty());
|
||||
seq.set_default_parameters();
|
||||
assert_eq!(seq.video_stream_count(), 1);
|
||||
assert_eq!(seq.audio_stream_count(), 1);
|
||||
assert_eq!(
|
||||
seq.video_params[0].width, 1920,
|
||||
"default width from the config fallback"
|
||||
);
|
||||
assert_eq!(seq.video_params[0].height, 1080);
|
||||
assert_eq!(seq.audio_params[0].sample_rate, 48000);
|
||||
assert_eq!(seq.playhead, oakcore_rs::Rational::new(0, 1));
|
||||
seq.playhead = oakcore_rs::Rational::new(30, 1);
|
||||
assert_eq!(seq.playhead, oakcore_rs::Rational::new(30, 1));
|
||||
}
|
||||
|
||||
/// Track block ordering: append/prepend/insert keep timeline order;
|
||||
/// removing a middle block preserves the rest; indexes and neighbours
|
||||
/// stay consistent (C++ Track semantics).
|
||||
#[test]
|
||||
fn track_block_ordering() {
|
||||
use oaknode::track::{BlockRange, TrackBehavior, TrackType};
|
||||
use oakcore_rs::{Rational, TimeRange};
|
||||
|
||||
struct Ranges;
|
||||
impl BlockRange for Ranges {
|
||||
fn in_(&self, _b: NodeId) -> Rational {
|
||||
Rational::new(0, 1)
|
||||
}
|
||||
fn out(&self, _b: NodeId) -> Rational {
|
||||
Rational::new(10, 1)
|
||||
}
|
||||
}
|
||||
|
||||
let mut track = TrackBehavior::new(TrackType::Video);
|
||||
let a = NodeId::from_identity(1).unwrap();
|
||||
let b = NodeId::from_identity(2).unwrap();
|
||||
let c = NodeId::from_identity(3).unwrap();
|
||||
|
||||
track.append_block(a);
|
||||
track.prepend_block(b); // [b, a]
|
||||
track.insert_block_at_index(c, 1); // [b, c, a]
|
||||
assert_eq!(track.blocks, vec![b, c, a]);
|
||||
assert_eq!(track.block_index(c), Some(1));
|
||||
|
||||
// Remove the middle block; the rest keep their order.
|
||||
assert!(track.remove_block(c));
|
||||
assert_eq!(track.blocks, vec![b, a]);
|
||||
assert!(!track.remove_block(c), "double remove fails");
|
||||
|
||||
// replace_block swaps a block in place.
|
||||
track.replace_block(a, c);
|
||||
assert_eq!(track.blocks, vec![b, c]);
|
||||
|
||||
// Length = end of the last block (via the range accessor).
|
||||
assert_eq!(track.length(&Ranges), Rational::new(10, 1));
|
||||
assert!(track.is_range_free(
|
||||
TimeRange::new(Rational::new(20, 1), Rational::new(30, 1)),
|
||||
&Ranges
|
||||
));
|
||||
assert!(!track.is_range_free(
|
||||
TimeRange::new(Rational::new(5, 1), Rational::new(15, 1)),
|
||||
&Ranges
|
||||
));
|
||||
assert_eq!(
|
||||
track.visible_block_at_time(Rational::new(5, 1), &Ranges),
|
||||
Some(b)
|
||||
);
|
||||
}
|
||||
|
||||
/// ClipBlock cache passthrough: the C ABI export accepts the call (the
|
||||
/// cache UUID copy is inert until the oakrender bridge creates per-node
|
||||
/// caches).
|
||||
#[test]
|
||||
fn clip_cache_passthrough() {
|
||||
use oaknode::ffi::block::oaknode_clip_add_cache_passthrough_from;
|
||||
use oaknode::ffi::block::oaknode_block_clip_create;
|
||||
use oaknode::ffi::block::oaknode_block_free;
|
||||
use oaknode::ffi::project::oaknode_project_init;
|
||||
use oaknode::ffi::project::oaknode_project_free;
|
||||
use oaknode::handle::CHandle;
|
||||
use oaknode::error::OAKNODE_OK;
|
||||
|
||||
let mut p = unsafe { oaknode_project_init() };
|
||||
let mut clip = unsafe { oaknode_block_clip_create() };
|
||||
let mut other = unsafe { oaknode_block_clip_create() };
|
||||
assert_eq!(
|
||||
unsafe { oaknode_clip_add_cache_passthrough_from(clip.clone(), other.clone()) },
|
||||
OAKNODE_OK
|
||||
);
|
||||
assert_eq!(
|
||||
unsafe { oaknode_clip_add_cache_passthrough_from(CHandle::null(), other.clone()) },
|
||||
oaknode::error::OAKNODE_E_INVALID
|
||||
);
|
||||
unsafe { oaknode_block_free(&mut clip) };
|
||||
unsafe { oaknode_block_free(&mut other) };
|
||||
unsafe { oaknode_project_free(&mut p) };
|
||||
}
|
||||
|
||||
/// Footage behavior: state without a codec module (probe fails
|
||||
/// gracefully without partial state); proxy fields, counts, duration.
|
||||
#[test]
|
||||
fn footage_probe() {
|
||||
use oaknode::footage::{FootageBehavior, StreamInfo};
|
||||
use oaknode::value::VideoParams;
|
||||
use oakcore_rs::Rational;
|
||||
|
||||
let mut f = FootageBehavior::new("/nonexistent/file.mov");
|
||||
assert!(!f.valid);
|
||||
// Probing without the codec module fails without partial state.
|
||||
assert!(f.probe().is_err());
|
||||
assert!(!f.valid);
|
||||
assert!(f.streams.is_empty());
|
||||
|
||||
// Stream-derived queries with manually populated streams.
|
||||
f.streams = vec![
|
||||
StreamInfo {
|
||||
index: 0,
|
||||
is_video: true,
|
||||
video: Some(VideoParams {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
frame_rate: Rational::new(30, 1),
|
||||
pixel_format: 4,
|
||||
channels: 4,
|
||||
}),
|
||||
audio: None,
|
||||
duration: Rational::new(600, 1),
|
||||
},
|
||||
StreamInfo {
|
||||
index: 1,
|
||||
is_video: false,
|
||||
video: None,
|
||||
audio: Some(oaknode::value::AudioParams {
|
||||
sample_rate: 48000,
|
||||
channel_layout: 3,
|
||||
format: 4,
|
||||
}),
|
||||
duration: Rational::new(601, 1),
|
||||
},
|
||||
];
|
||||
f.valid = true;
|
||||
assert_eq!(f.total_stream_count(), 2);
|
||||
assert_eq!(f.video_stream_count(), 1);
|
||||
assert_eq!(f.audio_stream_count(), 1);
|
||||
assert_eq!(f.duration(), Rational::new(601, 1), "longest stream");
|
||||
assert!(f.video_params(0).is_some());
|
||||
assert!(f.audio_params(0).is_some());
|
||||
|
||||
// Proxy fields round-trip.
|
||||
f.set_proxy("/tmp/proxy.mov", 2, 0, 1, true);
|
||||
assert!(f.proxy_enabled);
|
||||
assert_eq!(f.proxy, "/tmp/proxy.mov");
|
||||
assert_eq!(f.proxy_state, 2);
|
||||
assert_eq!(f.proxy_video_stream_index, 0);
|
||||
f.clear_proxy();
|
||||
assert!(f.proxy.is_empty());
|
||||
assert!(!f.proxy_enabled);
|
||||
|
||||
// Cancel flag.
|
||||
f.set_cancel(true);
|
||||
assert!(f.is_cancelled());
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Traverser (evaluation engine) contract tests.
|
||||
|
||||
use oakcore_rs::{Rational, TimeRange};
|
||||
|
||||
use oaknode::error::Error;
|
||||
use oaknode::graph::Graph;
|
||||
use oaknode::id::NodeId;
|
||||
use oaknode::input::Input;
|
||||
use oaknode::node::{NodeBehavior, NodeCore};
|
||||
use oaknode::traverser::{EvalRequest, RenderHooks, Traverser};
|
||||
use oaknode::value::{NodeValue, NodeValueRow, NodeValueTable, ValueType};
|
||||
|
||||
/// A source node: pushes its `val_in` standard value into the table.
|
||||
struct Src;
|
||||
impl NodeBehavior for Src {
|
||||
fn name(&self) -> &str {
|
||||
"Src"
|
||||
}
|
||||
fn type_id(&self) -> &str {
|
||||
"test.src"
|
||||
}
|
||||
fn duplicate(&self, _c: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(Src))
|
||||
}
|
||||
fn value(&self, core: &NodeCore, _i: &NodeValueRow, _t: Rational, table: &mut NodeValueTable) {
|
||||
table.push(ValueType::Float, core.standard_value("val_in", -1), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// A node that pushes `input + 1`.
|
||||
struct Inc;
|
||||
impl NodeBehavior for Inc {
|
||||
fn name(&self) -> &str {
|
||||
"Inc"
|
||||
}
|
||||
fn type_id(&self) -> &str {
|
||||
"test.inc"
|
||||
}
|
||||
fn duplicate(&self, _c: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(Inc))
|
||||
}
|
||||
fn value(&self, _c: &NodeCore, inputs: &NodeValueRow, _t: Rational, table: &mut NodeValueTable) {
|
||||
let v = inputs.get("val_in").cloned().unwrap_or(NodeValue::Float(0.0));
|
||||
table.push(ValueType::Float, NodeValue::Float(v.to_double() + 1.0), None);
|
||||
}
|
||||
}
|
||||
|
||||
struct Noop;
|
||||
impl RenderHooks for Noop {}
|
||||
|
||||
/// A behavior that counts its evaluations.
|
||||
struct Count(std::sync::Arc<std::sync::atomic::AtomicUsize>);
|
||||
impl NodeBehavior for Count {
|
||||
fn name(&self) -> &str {
|
||||
"Count"
|
||||
}
|
||||
fn type_id(&self) -> &str {
|
||||
"test.count"
|
||||
}
|
||||
fn duplicate(&self, _c: &NodeCore) -> Option<Box<dyn NodeBehavior>> {
|
||||
Some(Box::new(Count(self.0.clone())))
|
||||
}
|
||||
fn value(&self, _c: &NodeCore, _i: &NodeValueRow, _t: Rational, table: &mut NodeValueTable) {
|
||||
self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
table.push(ValueType::Int, NodeValue::Int(1), None);
|
||||
}
|
||||
}
|
||||
|
||||
fn node_with_input(g: &mut Graph, behavior: Box<dyn NodeBehavior>) -> NodeId {
|
||||
let mut core = NodeCore::new();
|
||||
core.add_input(Input::new("val_in", ValueType::Float, NodeValue::Float(0.0)));
|
||||
core.add_input(Input::new("val_in2", ValueType::Float, NodeValue::Float(0.0)));
|
||||
g.add_node(core, behavior)
|
||||
}
|
||||
|
||||
/// A linear chain of test nodes evaluates in topological order and the
|
||||
/// root table contains the expected value.
|
||||
#[test]
|
||||
fn linear_chain_evaluation_order() {
|
||||
let mut g = Graph::new();
|
||||
let src = node_with_input(&mut g, Box::new(Src));
|
||||
g.get_mut(src)
|
||||
.unwrap()
|
||||
.core
|
||||
.set_standard_value("val_in", -1, NodeValue::Float(1.0));
|
||||
let a = node_with_input(&mut g, Box::new(Inc));
|
||||
let b = node_with_input(&mut g, Box::new(Inc));
|
||||
let root = node_with_input(&mut g, Box::new(Inc));
|
||||
g.connect(src, a, "val_in", -1).unwrap();
|
||||
g.connect(a, b, "val_in", -1).unwrap();
|
||||
g.connect(b, root, "val_in", -1).unwrap();
|
||||
|
||||
let mut t = Traverser::new();
|
||||
let mut hooks = Noop;
|
||||
let table = t
|
||||
.evaluate(&g, &EvalRequest::new(root, Rational::new(0, 1)), &mut hooks)
|
||||
.unwrap();
|
||||
assert_eq!(table.get(ValueType::Float), Some(&NodeValue::Float(4.0)));
|
||||
}
|
||||
|
||||
/// Diamond graph: shared upstream evaluates once (memoization).
|
||||
#[test]
|
||||
fn diamond_evaluates_shared_node_once() {
|
||||
let mut g = Graph::new();
|
||||
let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let shared = {
|
||||
let mut core = NodeCore::new();
|
||||
core.add_input(Input::new("val_in", ValueType::Int, NodeValue::Int(0)));
|
||||
core.add_input(Input::new("val_in2", ValueType::Int, NodeValue::Int(0)));
|
||||
g.add_node(core, Box::new(Count(counter.clone())))
|
||||
};
|
||||
let leaf = {
|
||||
let mut core = NodeCore::new();
|
||||
core.add_input(Input::new("a", ValueType::Int, NodeValue::Int(0)));
|
||||
core.add_input(Input::new("b", ValueType::Int, NodeValue::Int(0)));
|
||||
g.add_node(core, Box::new(Count(std::sync::Arc::new(
|
||||
std::sync::atomic::AtomicUsize::new(0),
|
||||
))))
|
||||
};
|
||||
g.connect(shared, leaf, "a", -1).unwrap();
|
||||
g.connect(shared, leaf, "b", -1).unwrap();
|
||||
|
||||
let mut t = Traverser::new();
|
||||
let mut hooks = Noop;
|
||||
let _ = t
|
||||
.evaluate(&g, &EvalRequest::new(leaf, Rational::new(0, 1)), &mut hooks)
|
||||
.unwrap();
|
||||
assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
/// Cancellation: hook returning cancelled stops evaluation with E_STATE.
|
||||
#[test]
|
||||
fn cancellation_stops_evaluation() {
|
||||
struct Cancel;
|
||||
impl RenderHooks for Cancel {
|
||||
fn is_cancelled(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
let mut g = Graph::new();
|
||||
let id = node_with_input(&mut g, Box::new(Src));
|
||||
let mut t = Traverser::new();
|
||||
let mut hooks = Cancel;
|
||||
let r = t.evaluate(&g, &EvalRequest::new(id, Rational::new(0, 1)), &mut hooks);
|
||||
match r {
|
||||
Err(Error::State) => {}
|
||||
other => panic!("expected E_STATE, got {:?}", other.map(|_| ())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep chain (10k nodes) completes without recursion (stack-safe).
|
||||
#[test]
|
||||
fn deep_graph_is_iterative() {
|
||||
let mut g = Graph::new();
|
||||
let mut prev = node_with_input(&mut g, Box::new(Inc));
|
||||
for _ in 0..10_000 {
|
||||
let next = node_with_input(&mut g, Box::new(Inc));
|
||||
g.connect(prev, next, "val_in", -1).unwrap();
|
||||
prev = next;
|
||||
}
|
||||
let mut t = Traverser::new();
|
||||
let mut hooks = Noop;
|
||||
let table = t
|
||||
.evaluate(&g, &EvalRequest::new(prev, Rational::new(0, 1)), &mut hooks)
|
||||
.unwrap();
|
||||
assert!(table.get(ValueType::Float).is_some());
|
||||
}
|
||||
|
||||
/// invalidate_downstream marks exactly the downstream caches and only
|
||||
/// once per node on a diamond (signal-free fan-out parity).
|
||||
#[test]
|
||||
fn invalidation_fanout() {
|
||||
let mut g = Graph::new();
|
||||
let a = node_with_input(&mut g, Box::new(Src));
|
||||
let b = node_with_input(&mut g, Box::new(Inc));
|
||||
let c = node_with_input(&mut g, Box::new(Inc));
|
||||
let d = node_with_input(&mut g, Box::new(Inc));
|
||||
g.connect(a, b, "val_in", -1).unwrap();
|
||||
g.connect(a, c, "val_in", -1).unwrap();
|
||||
g.connect(b, d, "val_in", -1).unwrap();
|
||||
g.connect(c, d, "val_in2", -1).unwrap();
|
||||
|
||||
let mut t = Traverser::new();
|
||||
t.invalidate_downstream(&g, a, TimeRange::new(Rational::new(0, 1), Rational::new(1, 1)));
|
||||
let walked = t.last_invalidation();
|
||||
assert_eq!(walked.len(), 4, "a, b, c, d each exactly once");
|
||||
assert!(walked.contains(&a) && walked.contains(&d));
|
||||
let _ = NodeId::INVALID;
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
// Oak Video Editor - Non-Linear Video Editor
|
||||
// Copyright (C) 2026 Oak Team
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
//! Value system and keyframe contract tests.
|
||||
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use oaknode::handle::{self, CHandle, RefBox};
|
||||
use oaknode::keyframe::{Interpolation, Keyframe, KeyframeTrack};
|
||||
use oaknode::value::{NodeValue, NodeValueTable, ValueType};
|
||||
use oakcore_rs::Rational;
|
||||
|
||||
/// NodeValueTable: last-push-wins per type, tag preserved, `get` of an
|
||||
/// absent type returns None (C++ NodeValueTable semantics).
|
||||
#[test]
|
||||
fn value_table_last_push_wins() {
|
||||
let mut t = NodeValueTable::default();
|
||||
assert!(t.is_empty());
|
||||
assert!(t.get(ValueType::Float).is_none());
|
||||
|
||||
t.push(
|
||||
ValueType::Float,
|
||||
NodeValue::Float(1.0),
|
||||
Some("a".to_string()),
|
||||
);
|
||||
t.push(
|
||||
ValueType::Float,
|
||||
NodeValue::Float(2.0),
|
||||
Some("b".to_string()),
|
||||
);
|
||||
t.push(ValueType::Int, NodeValue::Int(7), None);
|
||||
|
||||
assert_eq!(t.count(), 3);
|
||||
assert_eq!(t.get(ValueType::Float), Some(&NodeValue::Float(2.0)));
|
||||
assert_eq!(t.get(ValueType::Int), Some(&NodeValue::Int(7)));
|
||||
|
||||
t.clear();
|
||||
assert!(t.is_empty());
|
||||
assert!(t.get(ValueType::Int).is_none());
|
||||
}
|
||||
|
||||
/// Texture values release their handle reference on drop (refcount
|
||||
/// discipline: no C++ Variant shared_ptr aliasing exists here).
|
||||
#[test]
|
||||
fn texture_value_drop_releases() {
|
||||
let h = handle::make_owned(7u32);
|
||||
// Add one reference for the texture payload (CHandle copies are
|
||||
// bitwise; addref is the caller's contract).
|
||||
unsafe { (h.addref.unwrap())(h.ctx) };
|
||||
let refs = |h: &CHandle| -> u32 {
|
||||
unsafe { (*(h.ctx as *const RefBox<u32>)).refs.load(Ordering::Relaxed) }
|
||||
};
|
||||
assert_eq!(refs(&h), 2);
|
||||
|
||||
{
|
||||
let v = NodeValue::Texture(h.clone());
|
||||
assert_eq!(refs(&h), 2, "no extra reference taken on clone");
|
||||
drop(v); // must release the payload's reference
|
||||
assert_eq!(refs(&h), 1);
|
||||
}
|
||||
|
||||
// Dropping the payload twice would underflow the counter — the
|
||||
// single release above is the whole contract.
|
||||
unsafe { (h.release.unwrap())(h.ctx) }; // back to 0, box freed
|
||||
}
|
||||
|
||||
/// NodeValue::clone addrefs texture handles (C++ shared_ptr-in-Variant
|
||||
/// semantics): each clone owns one reference, so clone + double drop is
|
||||
/// balanced. A bitwise clone would double-release the box.
|
||||
#[test]
|
||||
fn texture_value_clone_addrefs() {
|
||||
let h = handle::make_owned(7u32);
|
||||
let refs = |h: &CHandle| -> u32 {
|
||||
unsafe { (*(h.ctx as *const RefBox<u32>)).refs.load(Ordering::Relaxed) }
|
||||
};
|
||||
assert_eq!(refs(&h), 1);
|
||||
|
||||
{
|
||||
let a = NodeValue::Texture(h.clone());
|
||||
assert_eq!(refs(&h), 1, "construction takes the caller's reference");
|
||||
{
|
||||
let b = a.clone();
|
||||
assert_eq!(refs(&h), 2, "clone addrefs");
|
||||
drop(b);
|
||||
assert_eq!(refs(&h), 1);
|
||||
}
|
||||
// Dropping `a` must release the last reference and free the box
|
||||
// exactly once — a bitwise clone would have made refs hit zero
|
||||
// too early and double-released here (use-after-free).
|
||||
drop(a);
|
||||
}
|
||||
}
|
||||
|
||||
/// KeyframeTrack: insert keeps order; replace at same time overwrites;
|
||||
/// remove missing key returns false.
|
||||
#[test]
|
||||
fn keyframe_track_ordering() {
|
||||
let mut track = KeyframeTrack::default();
|
||||
assert!(track.keys().is_empty());
|
||||
|
||||
track.set_key(key(10, 1.0));
|
||||
track.set_key(key(0, 0.0));
|
||||
track.set_key(key(5, 0.5));
|
||||
assert_eq!(times(&track), vec![Rational::new(0, 1), Rational::new(5, 1), Rational::new(10, 1)]);
|
||||
|
||||
// Replace at an existing time overwrites in place (still sorted).
|
||||
track.set_key(key(5, 9.0));
|
||||
assert_eq!(track.keys().len(), 3);
|
||||
assert_eq!(
|
||||
track.value_at(Rational::new(5, 1)),
|
||||
Some(NodeValue::Float(9.0))
|
||||
);
|
||||
|
||||
// Remove: existing key true, missing false.
|
||||
assert!(track.remove_key(Rational::new(10, 1)));
|
||||
assert!(!track.remove_key(Rational::new(10, 1)));
|
||||
assert_eq!(track.keys().len(), 2);
|
||||
}
|
||||
|
||||
/// Interpolation parity: linear/bezier/hold values at sampled times
|
||||
/// match the C++ lerp/bezier math within 1e-9 (control points and the
|
||||
/// bisection solver included — `// CPP-PARITY: node.cpp:465`,
|
||||
/// `// CPP-PARITY: core/src/util/bezier.cpp`).
|
||||
#[test]
|
||||
fn interpolation_matches_cpp() {
|
||||
let eps = 1e-9;
|
||||
|
||||
// Linear between (0, 0) and (10, 1): t=5 -> 0.5 exactly
|
||||
// (lerp(a,b,t) = a*(1-t)+b*t).
|
||||
let mut track = KeyframeTrack::default();
|
||||
track.set_key(key(0, 0.0));
|
||||
track.set_key(key(10, 1.0));
|
||||
assert!(track.value_at(Rational::new(5, 1)).is_some());
|
||||
let v = track.value_at(Rational::new(5, 1)).unwrap();
|
||||
assert!((v.to_double() - 0.5).abs() < eps);
|
||||
assert!((track.value_at(Rational::new(3, 1)).unwrap().to_double() - 0.3).abs() < eps);
|
||||
|
||||
// Hold: the first key's value holds until the next key.
|
||||
let mut hold = KeyframeTrack::default();
|
||||
hold.set_key(Keyframe {
|
||||
time: Rational::new(0, 1),
|
||||
value: NodeValue::Float(2.0),
|
||||
interpolation: Interpolation::Hold,
|
||||
bezier_in: (0.0, 0.0),
|
||||
bezier_out: (0.0, 0.0),
|
||||
});
|
||||
hold.set_key(key(10, 9.0));
|
||||
assert_eq!(
|
||||
hold.value_at(Rational::new(7, 1)),
|
||||
Some(NodeValue::Float(2.0)),
|
||||
"hold keeps the before value"
|
||||
);
|
||||
|
||||
// Cubic bezier with symmetric handles: at the curve's midpoint the
|
||||
// value equals the exact cubic evaluation (5.0 by symmetry).
|
||||
let mut cubic = KeyframeTrack::default();
|
||||
cubic.set_key(Keyframe {
|
||||
time: Rational::new(0, 1),
|
||||
value: NodeValue::Float(0.0),
|
||||
interpolation: Interpolation::Bezier,
|
||||
bezier_in: (0.0, 0.0),
|
||||
bezier_out: (1.0, 1.0),
|
||||
});
|
||||
cubic.set_key(Keyframe {
|
||||
time: Rational::new(10, 1),
|
||||
value: NodeValue::Float(10.0),
|
||||
interpolation: Interpolation::Bezier,
|
||||
bezier_in: (-1.0, -1.0),
|
||||
bezier_out: (0.0, 0.0),
|
||||
});
|
||||
let v = cubic.value_at(Rational::new(5, 1)).unwrap();
|
||||
assert!(
|
||||
(v.to_double() - 5.0).abs() < eps,
|
||||
"cubic midpoint: {}",
|
||||
v.to_double()
|
||||
);
|
||||
|
||||
// Quadratic bezier with a linear x map: before=(0,0) bezier with
|
||||
// out=(2,2), after=(4,4) linear. x(t)=4t so x=2 -> t=0.5 and
|
||||
// y(0.5)=2.0 exactly.
|
||||
let mut quad = KeyframeTrack::default();
|
||||
quad.set_key(Keyframe {
|
||||
time: Rational::new(0, 1),
|
||||
value: NodeValue::Float(0.0),
|
||||
interpolation: Interpolation::Bezier,
|
||||
bezier_in: (0.0, 0.0),
|
||||
bezier_out: (2.0, 2.0),
|
||||
});
|
||||
quad.set_key(key(4, 4.0));
|
||||
let v = quad.value_at(Rational::new(2, 1)).unwrap();
|
||||
assert!((v.to_double() - 2.0).abs() < eps, "quadratic midpoint: {}", v.to_double());
|
||||
|
||||
// Rational type re-quantizes through Rational::from_double.
|
||||
let mut rt = KeyframeTrack::default();
|
||||
rt.set_key(Keyframe {
|
||||
time: Rational::new(0, 1),
|
||||
value: NodeValue::Rational(Rational::new(0, 1)),
|
||||
interpolation: Interpolation::Linear,
|
||||
bezier_in: (0.0, 0.0),
|
||||
bezier_out: (0.0, 0.0),
|
||||
});
|
||||
rt.set_key(Keyframe {
|
||||
time: Rational::new(10, 1),
|
||||
value: NodeValue::Rational(Rational::new(1, 1)),
|
||||
interpolation: Interpolation::Linear,
|
||||
bezier_in: (0.0, 0.0),
|
||||
bezier_out: (0.0, 0.0),
|
||||
});
|
||||
match rt.value_at(Rational::new(5, 1)) {
|
||||
Some(NodeValue::Rational(r)) => assert!((r.to_f64() - 0.5).abs() < eps),
|
||||
other => panic!("expected rational, got {:?}", other),
|
||||
}
|
||||
|
||||
// Vec2 interpolates component-wise.
|
||||
let mut vec = KeyframeTrack::default();
|
||||
vec.set_key(Keyframe {
|
||||
time: Rational::new(0, 1),
|
||||
value: NodeValue::Vec2([0.0, 0.0]),
|
||||
interpolation: Interpolation::Linear,
|
||||
bezier_in: (0.0, 0.0),
|
||||
bezier_out: (0.0, 0.0),
|
||||
});
|
||||
vec.set_key(Keyframe {
|
||||
time: Rational::new(10, 1),
|
||||
value: NodeValue::Vec2([10.0, 20.0]),
|
||||
interpolation: Interpolation::Linear,
|
||||
bezier_in: (0.0, 0.0),
|
||||
bezier_out: (0.0, 0.0),
|
||||
});
|
||||
match vec.value_at(Rational::new(5, 1)) {
|
||||
Some(NodeValue::Vec2(v)) => {
|
||||
assert!((v[0] - 5.0).abs() < eps);
|
||||
assert!((v[1] - 10.0).abs() < eps);
|
||||
}
|
||||
other => panic!("expected vec2, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty track value_at returns None; single-key track holds
|
||||
/// constant before and after the key.
|
||||
#[test]
|
||||
fn keyframe_edge_cases() {
|
||||
let empty = KeyframeTrack::default();
|
||||
assert_eq!(empty.value_at(Rational::new(0, 1)), None);
|
||||
|
||||
let mut one = KeyframeTrack::default();
|
||||
one.set_key(key(5, 3.0));
|
||||
assert_eq!(one.value_at(Rational::new(0, 1)), Some(NodeValue::Float(3.0)));
|
||||
assert_eq!(one.value_at(Rational::new(5, 1)), Some(NodeValue::Float(3.0)));
|
||||
assert_eq!(one.value_at(Rational::new(99, 1)), Some(NodeValue::Float(3.0)));
|
||||
|
||||
// Exact key time returns the exact key value (before-holds branch).
|
||||
let mut two = KeyframeTrack::default();
|
||||
two.set_key(key(0, 1.0));
|
||||
two.set_key(key(10, 2.0));
|
||||
assert_eq!(two.value_at(Rational::new(0, 1)), Some(NodeValue::Float(1.0)));
|
||||
assert_eq!(two.value_at(Rational::new(10, 1)), Some(NodeValue::Float(2.0)));
|
||||
}
|
||||
|
||||
fn key(time: i64, value: f64) -> Keyframe {
|
||||
Keyframe {
|
||||
time: Rational::new(time, 1),
|
||||
value: NodeValue::Float(value),
|
||||
interpolation: Interpolation::Linear,
|
||||
bezier_in: (0.0, 0.0),
|
||||
bezier_out: (0.0, 0.0),
|
||||
}
|
||||
}
|
||||
|
||||
fn times(track: &KeyframeTrack) -> Vec<Rational> {
|
||||
track.keys().iter().map(|k| k.time).collect()
|
||||
}
|
||||
Reference in New Issue
Block a user