- Right-clicking an unselected clip selects it first (C++ parity) — this is what made Cut/Delete appear to do nothing. - Cut/Copy/Paste clipboard: clipboard_copy/cut/paste on the engine, clipboard clips keep footage/range/speed/track kind and stay linked in the pasted group; paste lands at the playhead as one undo entry. - Dropping a video-with-audio footage places the video clip plus a linked audio clip at the same range in ONE 'Add Clip' undo entry. - Add Video/Audio Track buttons in the timeline toolbar and the track header context menu; new sequences start with 2 video + 2 audio tracks (not an undoable edit). - oaknode Graph::add_entry now reclaims the slot from the free list — before, a detached-then-reattached node left its slot in the free list, so node_count undercounted and the next add_node silently clobbered the restored node. This was the user's 'undo, redo, undo, redo and the result changed' bug; regression covered by cycle tests (move/trim/delete/split/add-track/linked-placement all converge).
257 lines
8.6 KiB
Rust
257 lines
8.6 KiB
Rust
// 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());
|
|
}
|
|
|
|
/// take_node + add_entry must restore the node count AND reclaim the
|
|
/// slot: before the fix, add_entry reused the slot without removing it
|
|
/// from the free list, so node_count kept undercounting and the next
|
|
/// add_node silently overwrote the restored node (the undo/redo
|
|
/// divergence the user hit: repeated undo/redo changed the result).
|
|
#[test]
|
|
fn add_entry_reclaims_the_free_slot() {
|
|
let (mut g, ids) = build(3);
|
|
let victim = ids[1];
|
|
let count_before = g.node_count();
|
|
|
|
// Detach and re-attach: the count must round-trip.
|
|
let entry = g.take_node(victim).expect("take the node");
|
|
assert_eq!(g.node_count(), count_before - 1, "detach drops the count");
|
|
let readded = g.add_entry(entry, victim);
|
|
assert_eq!(readded, victim, "identity is preserved");
|
|
assert_eq!(g.node_count(), count_before, "re-attach restores the count");
|
|
|
|
// A fresh add_node must NOT clobber the restored node (it must get a
|
|
// different slot).
|
|
let mut core = NodeCore::new();
|
|
core.label = "fresh".to_string();
|
|
let fresh = g.add_node(core, Box::new(TestNode { id: "fresh" }));
|
|
assert_ne!(fresh, victim, "the fresh node takes a different slot");
|
|
assert!(g.is_valid(victim), "the restored node survives add_node");
|
|
assert_eq!(
|
|
g.get(victim).map(|e| e.core.label.as_str()),
|
|
g.get(victim).map(|e| e.core.label.as_str()),
|
|
);
|
|
assert_eq!(g.node_count(), count_before + 1);
|
|
}
|