184 lines
5.8 KiB
Rust
184 lines
5.8 KiB
Rust
//! Copy/paste as a graph operation.
|
|
//!
|
|
//! The editor had ⌘D duplicate and no clipboard. Duplicate covers copying within one
|
|
//! graph; it cannot move a fragment between documents, between subgraph levels, or out of
|
|
//! the app. It also had a bug this API is shaped to avoid: it copied a node's type and
|
|
//! properties but not its `group`, and `add_node` creates every node with `group: None`,
|
|
//! so a duplicate made inside a subgraph silently appeared at the top level.
|
|
|
|
use flowrs_core::graph::ir::{FRAGMENT_VERSION, WireMode};
|
|
use flowrs_core::{Graph, NodeId};
|
|
|
|
/// `a -> mul <- b`, `mul -> print`.
|
|
fn chain() -> (Graph, Vec<NodeId>) {
|
|
let mut g = Graph::new();
|
|
let a = g.add_node("IntLiteral", 0.0, 0.0);
|
|
let b = g.add_node("IntLiteral", 0.0, 100.0);
|
|
let mul = g.add_node("Multiply", 200.0, 50.0);
|
|
let print = g.add_node("PrintLn", 400.0, 50.0);
|
|
g.get_node_mut(a)
|
|
.unwrap()
|
|
.properties
|
|
.insert("value".into(), "7".into());
|
|
g.add_wire(a, "value", mul, "a", WireMode::Copy).unwrap();
|
|
g.add_wire(b, "value", mul, "b", WireMode::Copy).unwrap();
|
|
g.add_wire(mul, "result", print, "input", WireMode::Move)
|
|
.unwrap();
|
|
(g, vec![a, b, mul, print])
|
|
}
|
|
|
|
#[test]
|
|
fn a_fragment_carries_the_wiring_between_its_own_nodes() {
|
|
let (g, ids) = chain();
|
|
let f = g.extract_fragment(&ids);
|
|
assert_eq!(f.version, FRAGMENT_VERSION);
|
|
assert_eq!(f.nodes.len(), 4);
|
|
assert_eq!(
|
|
f.wires.len(),
|
|
3,
|
|
"all three wires are internal to this selection"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_wire_with_one_end_outside_the_selection_is_dropped() {
|
|
let (g, ids) = chain();
|
|
// Just the two literals and the Multiply — the wire on to PrintLn leaves the set.
|
|
let f = g.extract_fragment(&ids[0..3]);
|
|
assert_eq!(f.nodes.len(), 3);
|
|
assert_eq!(
|
|
f.wires.len(),
|
|
2,
|
|
"the mul -> print wire has no destination in this fragment and must not be carried",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pasting_allocates_fresh_ids_and_leaves_the_original_alone() {
|
|
let (mut g, ids) = chain();
|
|
let f = g.extract_fragment(&ids);
|
|
let before: Vec<NodeId> = {
|
|
let mut v: Vec<NodeId> = g.nodes.keys().copied().collect();
|
|
v.sort();
|
|
v
|
|
};
|
|
|
|
let new = g.insert_fragment(&f, None, 40.0, 40.0);
|
|
assert_eq!(new.len(), 4);
|
|
assert_eq!(g.nodes.len(), 8, "paste adds, it does not replace");
|
|
for id in &before {
|
|
assert!(
|
|
g.nodes.contains_key(id),
|
|
"original node {id:?} must survive the paste"
|
|
);
|
|
}
|
|
for id in &new {
|
|
assert!(
|
|
!before.contains(id),
|
|
"pasted node {id:?} reused an existing id"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pasted_nodes_keep_their_properties_and_internal_wiring() {
|
|
let (mut g, ids) = chain();
|
|
let f = g.extract_fragment(&ids);
|
|
let new = g.insert_fragment(&f, None, 40.0, 40.0);
|
|
|
|
let pasted_literal = new
|
|
.iter()
|
|
.filter_map(|id| g.get_node(*id))
|
|
.find(|n| n.properties.get("value").map(String::as_str) == Some("7"));
|
|
assert!(
|
|
pasted_literal.is_some(),
|
|
"a literal's value must survive the round trip"
|
|
);
|
|
|
|
// Three wires among the pasted four, and none of them crossing back to the originals.
|
|
let pasted: std::collections::HashSet<NodeId> = new.iter().copied().collect();
|
|
let internal = g
|
|
.wires
|
|
.values()
|
|
.filter(|w| pasted.contains(&w.from_node) && pasted.contains(&w.to_node))
|
|
.count();
|
|
assert_eq!(
|
|
internal, 3,
|
|
"the fragment's wiring must be rebuilt among the copies"
|
|
);
|
|
let crossing = g
|
|
.wires
|
|
.values()
|
|
.filter(|w| pasted.contains(&w.from_node) != pasted.contains(&w.to_node))
|
|
.count();
|
|
assert_eq!(
|
|
crossing, 0,
|
|
"a paste must not wire itself to the original nodes"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_paste_is_offset_so_it_does_not_land_exactly_on_the_original() {
|
|
let (mut g, ids) = chain();
|
|
let f = g.extract_fragment(&ids);
|
|
let origin = g.get_node(ids[0]).unwrap().position;
|
|
let new = g.insert_fragment(&f, None, 40.0, 40.0);
|
|
let moved = g.get_node(new[0]).unwrap().position;
|
|
assert_eq!(
|
|
(moved.0 - origin.0, moved.1 - origin.1),
|
|
(40.0, 40.0),
|
|
"a copy landing exactly on its original is indistinguishable from nothing happening",
|
|
);
|
|
}
|
|
|
|
/// The bug that motivated putting `group` in this API's signature at all.
|
|
#[test]
|
|
fn a_fragment_lands_in_the_level_it_is_pasted_into() {
|
|
let (mut g, ids) = chain();
|
|
let f = g.extract_fragment(&ids);
|
|
let gid = flowrs_core::graph::ir::GroupId(1);
|
|
g.groups.insert(
|
|
gid,
|
|
flowrs_core::graph::ir::Group {
|
|
id: gid,
|
|
name: "inner".into(),
|
|
parent: None,
|
|
position: (0.0, 0.0),
|
|
},
|
|
);
|
|
|
|
let new = g.insert_fragment(&f, Some(gid), 0.0, 0.0);
|
|
for id in &new {
|
|
assert_eq!(
|
|
g.get_node(*id).unwrap().group,
|
|
Some(gid),
|
|
"every pasted node belongs to the level it was pasted into",
|
|
);
|
|
}
|
|
// And the source nodes are untouched at the top level.
|
|
for id in &ids {
|
|
assert_eq!(g.get_node(*id).unwrap().group, None);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_selection_yields_an_empty_fragment_rather_than_the_whole_graph() {
|
|
let (g, _) = chain();
|
|
let f = g.extract_fragment(&[]);
|
|
assert!(f.nodes.is_empty() && f.wires.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_fragment_survives_json() {
|
|
let (mut g, ids) = chain();
|
|
let f = g.extract_fragment(&ids);
|
|
let json = serde_json::to_string(&f).expect("a fragment must serialize");
|
|
let back: flowrs_core::graph::ir::Fragment =
|
|
serde_json::from_str(&json).expect("and come back");
|
|
let new = g.insert_fragment(&back, None, 10.0, 10.0);
|
|
assert_eq!(
|
|
new.len(),
|
|
4,
|
|
"the clipboard round trip is JSON, so this is the real path"
|
|
);
|
|
}
|