node/app: keep clip links symmetric and intact across paste, delete, and undo
- take_node clears the partner's dangling link references - are_linked checks both directions; link() repairs asymmetric pairs - paste writes links through the graph API instead of BlockCore.links - drop link undo removes only its own pair instead of restoring a snapshot - move_clip_with_links skips off-track partners instead of failing
This commit is contained in:
@@ -166,6 +166,18 @@ impl Graph {
|
||||
let taken = std::mem::replace(&mut self.entries[idx], vacant_entry);
|
||||
self.free_list.push(id.index());
|
||||
self.drop_edges_touching(id);
|
||||
// Symmetric link cleanup: partners drop their reference to the
|
||||
// detached node, so no dangling link outlives it. The taken entry
|
||||
// keeps its own links, but `add_entry` (the undo path) does NOT
|
||||
// re-establish the partners' back-references — a delete-then-undo
|
||||
// leaves the link one-way (known trade-off: a restorable asymmetric
|
||||
// link beats a stale reference; `are_linked` reads both ways and
|
||||
// `link()` repairs the missing direction).
|
||||
for partner in taken.core.links.clone() {
|
||||
if let Some(entry) = self.get_mut(partner) {
|
||||
entry.core.links.retain(|&l| l != id);
|
||||
}
|
||||
}
|
||||
Some(taken)
|
||||
}
|
||||
|
||||
@@ -468,9 +480,16 @@ impl Graph {
|
||||
}
|
||||
|
||||
/// Link two nodes bidirectionally (C++ `Node::link`); false when
|
||||
/// already linked or `a == b`.
|
||||
/// already linked both ways or `a == b`. A one-way link (e.g. left
|
||||
/// behind by a delete-then-undo) is repaired: only the missing
|
||||
/// direction is added.
|
||||
pub fn link(&mut self, a: NodeId, b: NodeId) -> bool {
|
||||
if a == b || !self.is_valid(a) || !self.is_valid(b) || self.are_linked(a, b) {
|
||||
if a == b || !self.is_valid(a) || !self.is_valid(b) {
|
||||
return false;
|
||||
}
|
||||
let a_has = self.get(a).map(|e| e.core.links.contains(&b)).unwrap_or(false);
|
||||
let b_has = self.get(b).map(|e| e.core.links.contains(&a)).unwrap_or(false);
|
||||
if a_has && b_has {
|
||||
return false;
|
||||
}
|
||||
let (a_idx, b_idx) = (a.index() as usize, b.index() as usize);
|
||||
@@ -478,8 +497,12 @@ impl Graph {
|
||||
let b_placeholder = vacant_entry(self.entries[b_idx].generation);
|
||||
let mut a_entry = std::mem::replace(&mut self.entries[a_idx], a_placeholder);
|
||||
let mut b_entry = std::mem::replace(&mut self.entries[b_idx], b_placeholder);
|
||||
a_entry.core.links.push(b);
|
||||
b_entry.core.links.push(a);
|
||||
if !a_has {
|
||||
a_entry.core.links.push(b);
|
||||
}
|
||||
if !b_has {
|
||||
b_entry.core.links.push(a);
|
||||
}
|
||||
self.entries[a_idx] = a_entry;
|
||||
self.entries[b_idx] = b_entry;
|
||||
true
|
||||
@@ -502,11 +525,17 @@ impl Graph {
|
||||
true
|
||||
}
|
||||
|
||||
/// True when `a` and `b` are linked (C++ `Node::are_linked`).
|
||||
/// True when `a` and `b` are linked in EITHER direction (C++
|
||||
/// `Node::are_linked`); the bidirectional read tolerates the one-way
|
||||
/// links a delete-then-undo can leave behind.
|
||||
pub fn are_linked(&self, a: NodeId, b: NodeId) -> bool {
|
||||
self.get(a)
|
||||
.map(|e| e.core.links.contains(&b))
|
||||
.unwrap_or(false)
|
||||
|| self
|
||||
.get(b)
|
||||
.map(|e| e.core.links.contains(&a))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Linked node ids of `id` (C++ `Node::links`).
|
||||
@@ -619,6 +648,20 @@ impl Graph {
|
||||
let to = *map.get(&to).unwrap_or(&to);
|
||||
self.connect(from, to, &input, element).ok();
|
||||
}
|
||||
// `take_node`'s symmetric link cleanup stripped the back-references
|
||||
// of nodes that were still waiting in `other`; now that every live
|
||||
// node moved over, re-establish the missing directions (identities
|
||||
// are preserved, so the stored links still name the right nodes).
|
||||
let moved: Vec<NodeId> = map.values().copied().collect();
|
||||
for id in moved {
|
||||
for partner in self.links_of(id) {
|
||||
if let Some(entry) = self.get_mut(partner) {
|
||||
if !entry.core.links.contains(&id) {
|
||||
entry.core.links.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
|
||||
@@ -254,3 +254,60 @@ fn add_entry_reclaims_the_free_slot() {
|
||||
);
|
||||
assert_eq!(g.node_count(), count_before + 1);
|
||||
}
|
||||
|
||||
/// Link topology: link/unlink are symmetric and idempotent, `are_linked`
|
||||
/// reads BOTH directions, and a one-way link (e.g. left behind by a
|
||||
/// delete-then-undo) is repaired by `link()` instead of deadlocking the
|
||||
/// pair (the old `are_linked` read only `a.links`, so a one-way link made
|
||||
/// `link()` refuse to fix the missing direction).
|
||||
#[test]
|
||||
fn link_repairs_asymmetry() {
|
||||
let (mut g, ids) = build(2);
|
||||
let [a, b] = [ids[0], ids[1]];
|
||||
|
||||
// The symmetric happy path.
|
||||
assert!(g.link(a, b));
|
||||
assert!(g.are_linked(a, b) && g.are_linked(b, a));
|
||||
assert!(!g.link(a, b), "already linked both ways: no-op");
|
||||
assert!(g.unlink(a, b));
|
||||
assert!(!g.are_linked(a, b) && !g.are_linked(b, a));
|
||||
assert!(!g.unlink(a, b), "not linked: no-op");
|
||||
assert!(!g.link(a, a), "self-link rejected");
|
||||
|
||||
// Hand-craft a one-way link: only a.links names b.
|
||||
g.get_mut(a).expect("a exists").core.links.push(b);
|
||||
assert!(g.are_linked(a, b), "a -> b reads as linked");
|
||||
assert!(g.are_linked(b, a), "the reverse read is linked too");
|
||||
assert!(g.link(a, b), "link() repairs the missing direction");
|
||||
assert!(g.links_of(a).contains(&b) && g.links_of(b).contains(&a));
|
||||
assert!(!g.link(b, a), "fully linked after the repair");
|
||||
assert!(g.unlink(a, b), "unlink clears a one-way link too");
|
||||
assert!(g.links_of(a).is_empty() && g.links_of(b).is_empty());
|
||||
}
|
||||
|
||||
/// take_node cleans up symmetrically: the partners of a detached node
|
||||
/// drop their reference to it, so no dangling link outlives the node
|
||||
/// (the delete-one-of-a-pair regression: the survivor kept a stale id
|
||||
/// that later edits tripped over).
|
||||
#[test]
|
||||
fn take_node_clears_partner_links() {
|
||||
let (mut g, ids) = build(3);
|
||||
let [a, b, c] = [ids[0], ids[1], ids[2]];
|
||||
g.link(a, b);
|
||||
g.link(a, c);
|
||||
|
||||
let entry = g.take_node(a).expect("take the node");
|
||||
assert!(!g.links_of(b).contains(&a), "b dropped the stale reference");
|
||||
assert!(!g.links_of(c).contains(&a), "c dropped the stale reference");
|
||||
assert!(entry.core.links.contains(&b), "the entry keeps its own links");
|
||||
|
||||
// Re-inserting restores the node's own links but not the partners'
|
||||
// back-references (the documented undo trade-off); `are_linked` still
|
||||
// reads the pair as linked and `link()` repairs the direction.
|
||||
let readded = g.add_entry(entry, a);
|
||||
assert_eq!(readded, a);
|
||||
assert!(g.are_linked(a, b));
|
||||
assert!(!g.links_of(b).contains(&a), "add_entry does not restore the back-reference");
|
||||
assert!(g.link(b, a), "link() re-establishes symmetry");
|
||||
assert!(g.links_of(b).contains(&a) && g.links_of(a).contains(&b));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user