//! 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) { 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 = { let mut v: Vec = 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 = 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" ); }