diff --git a/document/graph-storage/src/crdt.rs b/document/graph-storage/src/crdt.rs index 110b4d3c19..bbe4710034 100644 --- a/document/graph-storage/src/crdt.rs +++ b/document/graph-storage/src/crdt.rs @@ -92,7 +92,7 @@ impl Delta { /// Op payload. Timestamps live on the wrapping `Delta` — one per delta, applied to all LWW-eligible /// writes within. See `notes/document-format-collaboration.md`. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum RegistryDelta { AddNode { id: NodeId, @@ -190,7 +190,7 @@ pub enum RegistryDelta { } /// `value: None` means remove. The timestamp comes from the wrapping `Delta`. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct AttributeDelta { pub key: String, pub value: Option, diff --git a/document/graph-storage/src/from_runtime.rs b/document/graph-storage/src/from_runtime.rs index 0c5937a04a..4bb24deb83 100644 --- a/document/graph-storage/src/from_runtime.rs +++ b/document/graph-storage/src/from_runtime.rs @@ -423,6 +423,27 @@ fn convert_node( }) } +pub fn encode_node_ui_attributes( + attributes: &mut crate::Attributes, + metadata: &M, + metadata_path: &[RuntimeNodeId], + runtime_node_id: RuntimeNodeId, + timestamp: TimeStamp, +) -> Result<(), ConversionError> { + write_ui_attributes(attributes, metadata, metadata_path, runtime_node_id, timestamp) +} + +pub fn encode_input_ui_attributes( + attributes: &mut crate::Attributes, + metadata: &M, + metadata_path: &[RuntimeNodeId], + runtime_node_id: RuntimeNodeId, + input_index: usize, + timestamp: TimeStamp, +) -> Result<(), ConversionError> { + write_ui_input_attributes(attributes, metadata, metadata_path, runtime_node_id, input_index, timestamp) +} + fn write_ui_attributes( attributes: &mut crate::Attributes, metadata: &M, @@ -613,6 +634,13 @@ impl PathResolver { child_path(owner.as_ref(), self.network_id(local_path), local_id).to_global_id(self.peer) } + /// Converts one runtime input inside the network at `local_path` to its storage form, resolving + /// node references to their stable global IDs. + pub fn convert_input_at(&self, input: &GraphCraftNodeInput, local_path: &[RuntimeNodeId]) -> Result { + let owner = self.owner_path(local_path); + convert_input(input, owner.as_ref(), self.network_id(local_path), self.peer) + } + /// The `NodePath` of the node owning the network at `local_path`, or `None` for the root network. fn owner_path(&self, local_path: &[RuntimeNodeId]) -> Option { let mut owner: Option = None; @@ -703,10 +731,15 @@ impl<'m, M: NodeMetadataSource + ?Sized> ScopedConversion<'m, M> { /// whole `TaggedValue`, which can be arbitrarily large. `resource_ref_shape_matches_serde` asserts /// this stays in lockstep with the real serialization. pub fn node_value_resource_refs(node: &Node) -> impl Iterator + '_ { - node.inputs.iter().filter_map(|slot| match &slot.input { + node.inputs.iter().filter_map(|slot| value_resource_ref(&slot.input)) +} + +/// The `TaggedValue::Resource` ID referenced by a stored value input, if any. +pub fn value_resource_ref(input: &NodeInput) -> Option { + match input { NodeInput::Value { value, .. } => value.get("Resource").and_then(|id| serde_json::from_value(id.clone()).ok()), _ => None, - }) + } } #[cfg(test)] diff --git a/document/graph-storage/src/lib.rs b/document/graph-storage/src/lib.rs index 37034537eb..f60c492b3d 100644 --- a/document/graph-storage/src/lib.rs +++ b/document/graph-storage/src/lib.rs @@ -29,7 +29,10 @@ pub use resources::*; pub use session::*; #[cfg(any(feature = "conversion", test))] -pub use from_runtime::{PathResolver, RuntimeConversion, ScopedConversion, convert_resource_entry, decode_declaration, encode_declaration, node_value_resource_refs}; +pub use from_runtime::{ + PathResolver, RuntimeConversion, ScopedConversion, convert_resource_entry, decode_declaration, encode_declaration, encode_input_ui_attributes, encode_node_ui_attributes, node_value_resource_refs, + value_resource_ref, +}; #[cfg(any(feature = "conversion", test))] pub use metadata_source::{InputMetadataEntry, NetworkMetadataEntry, NoMetadata, NodeMetadataEntry, NodeMetadataSource, Position}; #[cfg(any(feature = "conversion", test))] diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface.rs b/editor/src/messages/portfolio/document/utility_types/network_interface.rs index 729557e17b..8e05f37f45 100644 --- a/editor/src/messages/portfolio/document/utility_types/network_interface.rs +++ b/editor/src/messages/portfolio/document/utility_types/network_interface.rs @@ -2,6 +2,9 @@ mod caches; #[cfg(test)] mod characterization_tests; mod deserialization; +pub mod editor_delta; +#[cfg(test)] +mod editor_delta_tests; mod hit_tests; mod layout; mod memo_network; diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/editor_delta.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/editor_delta.rs new file mode 100644 index 0000000000..ef8281df0e --- /dev/null +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/editor_delta.rs @@ -0,0 +1,382 @@ +use super::{DocumentNodeMetadata, DocumentNodePersistentMetadata, LayerPosition, NodePosition, NodeTypePersistentMetadata}; +use document_graph_storage::attr::node as node_attr; +use document_graph_storage::from_runtime::{ConversionError, DeclarationBytes}; +use document_graph_storage::{AttributeDelta, Attributes, Implementation, NoMetadata, PathResolver, Position, Registry, RegistryDelta, ScopedConversion, TimeStamp}; +use document_graph_storage::{convert_resource_entry, encode_input_ui_attributes, encode_node_ui_attributes, node_value_resource_refs, value_resource_ref}; +use graph_craft::application_io::resource::{ResourceId, ResourceRegistry}; +use graph_craft::document::NodeId; +use graph_craft::runtime_delta::RuntimeDelta; +use std::collections::HashSet; + +#[derive(Debug, Clone, PartialEq)] +pub enum EditorDelta { + Graph(RuntimeDelta), + NodeMetadata { + network_path: Vec, + node_id: NodeId, + metadata: Box, + }, +} + +pub struct ConstructedOps { + pub ops: Vec, + pub declaration_bytes: DeclarationBytes, +} + +impl EditorDelta { + pub fn to_registry_deltas(&self, working: &Registry, resources: &ResourceRegistry, peer: document_graph_storage::PeerId) -> Result { + let resolver = PathResolver::new(peer); + let mut ops = Vec::new(); + let mut declaration_bytes = DeclarationBytes::new(); + + match self { + EditorDelta::Graph(RuntimeDelta::AddNode { network_path, node_id, node }) => { + declaration_bytes = construct_structural_additions(network_path, *node_id, node, working, resources, peer, &mut ops)?; + } + + EditorDelta::Graph(RuntimeDelta::ReplaceNode { network_path, node_id, node }) => { + construct_removals(resolver.node_id(network_path, *node_id), working, &mut ops); + declaration_bytes = construct_structural_additions(network_path, *node_id, node, working, resources, peer, &mut ops)?; + } + + EditorDelta::Graph(RuntimeDelta::RemoveNode { network_path, node_id }) => { + construct_removals(resolver.node_id(network_path, *node_id), working, &mut ops); + } + + EditorDelta::Graph(RuntimeDelta::SetInput { + network_path, + node_id, + input_index, + input, + }) => { + ops.push(RegistryDelta::ChangeNodeInput { + id: resolver.node_id(network_path, *node_id), + index: (*input_index).try_into().map_err(|_| ConversionError::IndexOverflow(*input_index))?, + new_input: resolver.convert_input_at(input, network_path)?, + }); + } + + EditorDelta::Graph(RuntimeDelta::SetExport { network_path, export_index, input }) => { + ops.push(RegistryDelta::SetNetworkExport { + id: resolver.network_id(network_path), + index: (*export_index).try_into().map_err(|_| ConversionError::IndexOverflow(*export_index))?, + export: Some(resolver.convert_input_at(input, network_path)?), + }); + } + + EditorDelta::NodeMetadata { network_path, node_id, metadata } => { + construct_metadata_changes(network_path, *node_id, metadata, working, &resolver, &mut ops)?; + } + } + + Ok(ConstructedOps { ops, declaration_bytes }) + } +} + +fn construct_structural_additions( + network_path: &[NodeId], + node_id: NodeId, + node: &graph_craft::document::DocumentNode, + working: &Registry, + resources: &ResourceRegistry, + peer: document_graph_storage::PeerId, + ops: &mut Vec, +) -> Result { + let mut scoped = ScopedConversion::new(&NoMetadata, peer); + let mut scratch = Registry::default(); + scoped.convert_node_at(&mut scratch, network_path, node_id, node, true)?; + let declaration_bytes = scoped.finish(); + + let mut networks: Vec<_> = scratch.networks.iter().collect(); + networks.sort_by_key(|(id, _)| **id); + for (id, network) in networks { + ops.push(RegistryDelta::AddNetwork { id: *id, network: network.clone() }); + } + + let mut nodes: Vec<_> = scratch.node_instances.iter().collect(); + nodes.sort_by_key(|(id, _)| **id); + for (id, node) in nodes { + ops.push(RegistryDelta::AddNode { id: *id, node: node.clone() }); + } + + let mut new_resources: Vec<_> = scratch.resources.iter().filter(|(id, _)| !working.resources.contains_key(id)).collect(); + new_resources.sort_by_key(|(id, _)| **id); + for (id, entry) in new_resources { + ops.push(RegistryDelta::AddResource { id: *id, entry: entry.clone() }); + } + let mut tagged: Vec = scratch.node_instances.values().flat_map(node_value_resource_refs).collect(); + tagged.sort(); + tagged.dedup(); + for id in tagged { + if !working.resources.contains_key(&id) + && !scratch.resources.contains_key(&id) + && let Some(entry) = convert_resource_entry(resources, id, peer)? + { + ops.push(RegistryDelta::AddResource { id, entry }); + } + } + + Ok(declaration_bytes) +} + +fn construct_metadata_changes( + network_path: &[NodeId], + node_id: NodeId, + metadata: &DocumentNodePersistentMetadata, + working: &Registry, + resolver: &PathResolver, + ops: &mut Vec, +) -> Result<(), ConversionError> { + let source = MetadataCopySource { + anchor_path: network_path, + anchor_id: node_id, + metadata, + }; + + let mut pending = vec![(network_path.to_vec(), node_id, metadata)]; + while let Some((path, id, node_metadata)) = pending.pop() { + let global_id = resolver.node_id(&path, id); + let working_node = working.node_instances.get(&global_id); + + let mut encoded = Attributes::new(); + encode_node_ui_attributes(&mut encoded, &source, &path, id, TimeStamp::ORIGIN)?; + for delta in ui_attribute_deltas(working_node.map(|node| node.attributes()), &encoded) { + ops.push(RegistryDelta::ChangeNodeAttribute { id: global_id, delta }); + } + + for input_index in 0..node_metadata.input_metadata.len() { + let mut encoded = Attributes::new(); + encode_input_ui_attributes(&mut encoded, &source, &path, id, input_index, TimeStamp::ORIGIN)?; + let current = working_node.and_then(|node| node.inputs().get(input_index)).map(|slot| &slot.attributes); + for delta in ui_attribute_deltas(current, &encoded) { + ops.push(RegistryDelta::ChangeNodeInputAttribute { + id: global_id, + index: input_index.try_into().map_err(|_| ConversionError::IndexOverflow(input_index))?, + delta, + }); + } + } + + if let Some(network_metadata) = &node_metadata.network_metadata { + let mut nested_path = path.clone(); + nested_path.push(id); + let network_id = resolver.network_id(&nested_path); + + let target = network_metadata.persistent_metadata.reference.clone().map(serde_json::Value::String); + let current = working + .networks + .get(&network_id) + .and_then(|network| network.attributes.get(node_attr::ui::REFERENCE)) + .map(|value| value.value.clone()); + if current != target { + ops.push(RegistryDelta::ChangeNetworkAttribute { + id: network_id, + delta: AttributeDelta { + key: node_attr::ui::REFERENCE.to_string(), + value: target, + }, + }); + } + + for (child_id, child) in &network_metadata.persistent_metadata.node_metadata { + pending.push((nested_path.clone(), *child_id, &child.persistent_metadata)); + } + } + } + + Ok(()) +} + +fn ui_attribute_deltas(current: Option<&Attributes>, encoded: &Attributes) -> Vec { + let owned = |key: &str| key.starts_with("ui::"); + let mut deltas = Vec::new(); + + if let Some(current) = current { + for key in current.keys() { + if owned(key) && !encoded.contains_key(key) { + deltas.push(AttributeDelta { key: key.clone(), value: None }); + } + } + } + for (key, value) in encoded { + let unchanged = current.and_then(|current| current.get(key)).is_some_and(|existing| existing.value == value.value); + if !unchanged { + deltas.push(AttributeDelta { + key: key.clone(), + value: Some(value.value.clone()), + }); + } + } + + deltas.sort_by(|a, b| a.key.cmp(&b.key)); + deltas +} + +fn construct_removals(node_id: document_graph_storage::NodeId, working: &Registry, ops: &mut Vec) { + let mut removed_nodes = Vec::new(); + let mut removed_networks = Vec::new(); + collect_removal_closure(node_id, working, &mut removed_nodes, &mut removed_networks); + + removed_nodes.sort(); + removed_networks.sort(); + for id in &removed_nodes { + ops.push(RegistryDelta::RemoveNode { + id: *id, + snapshot: working.node_instances[id].clone(), + }); + } + for id in &removed_networks { + ops.push(RegistryDelta::RemoveNetwork { + id: *id, + snapshot: working.networks[id].clone(), + }); + } + + let removed_node_set: HashSet<_> = removed_nodes.iter().copied().collect(); + let mut candidates: Vec = removed_nodes + .iter() + .flat_map(|id| { + let node = &working.node_instances[id]; + let declaration = match node.implementation() { + Implementation::ProtoNode(declaration) => Some(*declaration), + Implementation::Network(_) => None, + }; + declaration.into_iter().chain(node_value_resource_refs(node)) + }) + .collect(); + candidates.sort(); + candidates.dedup(); + + for candidate in candidates { + let still_referenced = working + .node_instances + .iter() + .filter(|(id, _)| !removed_node_set.contains(id)) + .any(|(_, node)| matches!(node.implementation(), Implementation::ProtoNode(declaration) if *declaration == candidate) || node_value_resource_refs(node).any(|id| id == candidate)) + || working + .networks + .values() + .any(|network| network.exports.iter().any(|slot| slot.target.as_ref().and_then(value_resource_ref) == Some(candidate))); + + if !still_referenced && let Some(entry) = working.resources.get(&candidate) { + ops.push(RegistryDelta::RemoveResource { + id: candidate, + snapshot: entry.clone(), + }); + } + } +} + +fn collect_removal_closure(node_id: document_graph_storage::NodeId, working: &Registry, nodes: &mut Vec, networks: &mut Vec) { + let Some(node) = working.node_instances.get(&node_id) else { return }; + nodes.push(node_id); + + if let &Implementation::Network(network_id) = node.implementation() { + networks.push(network_id); + for (child_id, child) in &working.node_instances { + if child.network() == network_id { + collect_removal_closure(*child_id, working, nodes, networks); + } + } + } +} + +struct MetadataCopySource<'a> { + anchor_path: &'a [NodeId], + anchor_id: NodeId, + metadata: &'a DocumentNodePersistentMetadata, +} + +impl MetadataCopySource<'_> { + fn metadata_for(&self, metadata_path: &[NodeId], node_id: NodeId) -> Option<&DocumentNodePersistentMetadata> { + let relative = metadata_path.strip_prefix(self.anchor_path)?; + + let (mut current, rest) = match relative.split_first() { + None => return (node_id == self.anchor_id).then_some(self.metadata), + Some((first, rest)) if *first == self.anchor_id => (self.metadata, rest), + Some(_) => return None, + }; + + for step in rest { + current = child_metadata(current, *step)?; + } + child_metadata(current, node_id) + } +} + +fn child_metadata(metadata: &DocumentNodePersistentMetadata, child_id: NodeId) -> Option<&DocumentNodePersistentMetadata> { + metadata + .network_metadata + .as_ref() + .and_then(|network| network.persistent_metadata.node_metadata.get(&child_id)) + .map(|child: &DocumentNodeMetadata| &child.persistent_metadata) +} + +impl document_graph_storage::NodeMetadataSource for MetadataCopySource<'_> { + fn position(&self, metadata_path: &[NodeId], node_id: NodeId) -> Option { + match &self.metadata_for(metadata_path, node_id)?.node_type_metadata { + NodeTypePersistentMetadata::Layer(layer) => Some(match layer.position { + LayerPosition::Absolute(offset) => Position::Absolute([offset.x, offset.y]), + LayerPosition::Stack(offset) => Position::Stack(offset), + }), + NodeTypePersistentMetadata::Node(node) => Some(match *node.position() { + NodePosition::Absolute(offset) => Position::Absolute([offset.x, offset.y]), + NodePosition::Chain => Position::Chain, + }), + } + } + + fn is_layer(&self, metadata_path: &[NodeId], node_id: NodeId) -> bool { + self.metadata_for(metadata_path, node_id) + .is_some_and(|metadata| matches!(metadata.node_type_metadata, NodeTypePersistentMetadata::Layer(_))) + } + + fn display_name(&self, metadata_path: &[NodeId], node_id: NodeId) -> Option<&str> { + self.metadata_for(metadata_path, node_id).map(|metadata| metadata.display_name.as_str()) + } + + fn locked(&self, metadata_path: &[NodeId], node_id: NodeId) -> bool { + self.metadata_for(metadata_path, node_id).is_some_and(|metadata| metadata.locked) + } + + fn pinned(&self, metadata_path: &[NodeId], node_id: NodeId) -> bool { + self.metadata_for(metadata_path, node_id).is_some_and(|metadata| metadata.pinned) + } + + fn output_names(&self, metadata_path: &[NodeId], node_id: NodeId) -> Vec { + self.metadata_for(metadata_path, node_id).map(|metadata| metadata.output_names.clone()).unwrap_or_default() + } + + fn input_name(&self, metadata_path: &[NodeId], node_id: NodeId, input_index: usize) -> Option<&str> { + self.metadata_for(metadata_path, node_id) + .and_then(|metadata| metadata.input_metadata.get(input_index)) + .map(|input| input.persistent_metadata.input_name.as_str()) + } + + fn input_description(&self, metadata_path: &[NodeId], node_id: NodeId, input_index: usize) -> Option<&str> { + self.metadata_for(metadata_path, node_id) + .and_then(|metadata| metadata.input_metadata.get(input_index)) + .map(|input| input.persistent_metadata.input_description.as_str()) + } + + fn widget_override(&self, metadata_path: &[NodeId], node_id: NodeId, input_index: usize) -> Option<&str> { + self.metadata_for(metadata_path, node_id) + .and_then(|metadata| metadata.input_metadata.get(input_index)) + .and_then(|input| input.persistent_metadata.widget_override.as_deref()) + } + + fn input_data(&self, metadata_path: &[NodeId], node_id: NodeId, input_index: usize) -> std::collections::HashMap { + self.metadata_for(metadata_path, node_id) + .and_then(|metadata| metadata.input_metadata.get(input_index)) + .map(|input| input.persistent_metadata.input_data.clone()) + .unwrap_or_default() + } + + fn reference(&self, network_path: &[NodeId]) -> Option<&str> { + let (owner_path, owner_id) = network_path.split_last().map(|(last, rest)| (rest, *last))?; + self.metadata_for(owner_path, owner_id)? + .network_metadata + .as_ref() + .and_then(|network| network.persistent_metadata.reference.as_deref()) + } +} diff --git a/editor/src/messages/portfolio/document/utility_types/network_interface/editor_delta_tests.rs b/editor/src/messages/portfolio/document/utility_types/network_interface/editor_delta_tests.rs new file mode 100644 index 0000000000..e51fcc4881 --- /dev/null +++ b/editor/src/messages/portfolio/document/utility_types/network_interface/editor_delta_tests.rs @@ -0,0 +1,238 @@ +use super::InputConnector; +use super::editor_delta::EditorDelta; +use super::storage_metadata::StorageMetadataView; +use crate::test_utils::test_prelude::*; +use document_graph_storage::delta::compute_deltas; +use document_graph_storage::{PeerId, Registry, RegistryDelta, Session}; +use graph_craft::document::NodeInput; +use graph_craft::document::value::TaggedValue; +use graph_craft::runtime_delta::RuntimeDelta; +use graphene_std::uuid::NodeId; + +const PEER: PeerId = PeerId(7); + +fn rectangle_definition() -> DefinitionIdentifier { + DefinitionIdentifier::ProtoNode(graphene_std::vector::generator_nodes::rectangle::IDENTIFIER) +} + +fn convert(editor: &EditorTestUtils) -> Registry { + let document = editor.active_document(); + Registry::convert_from_runtime( + document.network_interface.document_network(), + &StorageMetadataView::new(&document.network_interface), + &document.resources.registry, + PEER, + ) + .expect("conversion should succeed") + .registry +} + +fn construct(editor: &EditorTestUtils, delta: &EditorDelta, working: &Registry) -> Vec { + delta + .to_registry_deltas(working, &editor.active_document().resources.registry, PEER) + .expect("construction should succeed") + .ops +} + +fn node_metadata_delta(editor: &EditorTestUtils, node_id: NodeId) -> EditorDelta { + let metadata = editor + .active_document() + .network_interface + .node_metadata(&node_id, &[]) + .expect("node metadata should exist") + .persistent_metadata + .clone(); + EditorDelta::NodeMetadata { + network_path: Vec::new(), + node_id, + metadata: Box::new(metadata), + } +} + +fn assert_same_stored_effect(working: &Registry, constructed: Vec, diffed: Vec, at: &str) { + let baseline = compute_deltas(&Registry::default(), working); + + let mut from_construction = Session::with_peer(PEER); + from_construction.stage_computed_ops(baseline.clone()).expect("baseline should stage"); + from_construction.stage_computed_ops(constructed).expect("constructed ops should stage"); + + let mut from_diff = Session::with_peer(PEER); + from_diff.stage_computed_ops(baseline).expect("baseline should stage"); + from_diff.stage_computed_ops(diffed).expect("diffed ops should stage"); + + assert!( + from_construction.registry().value_equal(from_diff.registry()), + "Constructed ops must produce the same stored state as the whole-document diff: {at}\nresidual: {:#?}", + compute_deltas(from_construction.registry(), from_diff.registry()) + ); +} + +#[tokio::test] +async fn set_input_value_constructs_the_exact_diff_op() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + let node = editor.create_node_by_name(rectangle_definition()).await; + + let working = convert(&editor); + let input = NodeInput::value(TaggedValue::F64(42.), false); + editor.active_document_mut().network_interface.set_input(&InputConnector::node(node, 1), input.clone(), &[]); + + let delta = EditorDelta::Graph(RuntimeDelta::SetInput { + network_path: Vec::new(), + node_id: node, + input_index: 1, + input, + }); + + let constructed = construct(&editor, &delta, &working); + let diffed = compute_deltas(&working, &convert(&editor)); + assert_eq!(constructed, diffed, "A value edit should construct exactly the diff's op"); +} + +#[tokio::test] +async fn wiring_and_export_edits_construct_the_exact_diff_ops() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + let a = editor.create_node_by_name(rectangle_definition()).await; + let b = editor.create_node_by_name(rectangle_definition()).await; + + let working = convert(&editor); + let wire = NodeInput::node(b, 0); + editor.active_document_mut().network_interface.set_input(&InputConnector::node(a, 1), wire.clone(), &[]); + let delta = EditorDelta::Graph(RuntimeDelta::SetInput { + network_path: Vec::new(), + node_id: a, + input_index: 1, + input: wire, + }); + let constructed = construct(&editor, &delta, &working); + let diffed = compute_deltas(&working, &convert(&editor)); + assert_eq!(constructed, diffed, "A wiring edit should construct exactly the diff's op"); + + let working = convert(&editor); + let export = NodeInput::node(a, 0); + editor.active_document_mut().network_interface.set_input(&InputConnector::Export(0), export.clone(), &[]); + let delta = EditorDelta::Graph(RuntimeDelta::SetExport { + network_path: Vec::new(), + export_index: 0, + input: export, + }); + let constructed = construct(&editor, &delta, &working); + let diffed = compute_deltas(&working, &convert(&editor)); + assert_eq!(constructed, diffed, "An export edit should construct exactly the diff's op"); +} + +#[tokio::test] +async fn adding_a_node_as_structure_plus_metadata_matches_the_diff() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + + let working = convert(&editor); + let template = crate::messages::portfolio::document::node_graph::document_node_definitions::resolve_document_node_type(&rectangle_definition()) + .expect("rectangle definition") + .default_node_template(); + let node_id = NodeId(0xDE17A); + let (document_node, metadata) = template.clone().into_parts(); + editor.active_document_mut().network_interface.insert_node(node_id, template, &[]); + + let deltas = [ + EditorDelta::Graph(RuntimeDelta::AddNode { + network_path: Vec::new(), + node_id, + node: Box::new(document_node), + }), + EditorDelta::NodeMetadata { + network_path: Vec::new(), + node_id, + metadata: Box::new(metadata), + }, + ]; + + let constructed = deltas.iter().flat_map(|delta| construct(&editor, delta, &working)).collect(); + let diffed = compute_deltas(&working, &convert(&editor)); + assert_same_stored_effect(&working, constructed, diffed, "node addition"); +} + +#[tokio::test] +async fn metadata_edits_construct_the_exact_diff_ops() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + let node = editor.create_node_by_name(rectangle_definition()).await; + + let working = convert(&editor); + { + let network_interface = &mut editor.active_document_mut().network_interface; + network_interface.set_display_name(&node, "Renamed".to_string(), &[]); + network_interface.set_locked(&node, &[], true); + network_interface.set_pinned(&node, &[], true); + network_interface.shift_node(&node, glam::IVec2::new(3, 5), &[]); + } + + let constructed = construct(&editor, &node_metadata_delta(&editor, node), &working); + let diffed = compute_deltas(&working, &convert(&editor)); + assert_eq!(constructed, diffed, "Metadata edits should construct exactly the diff's attribute ops"); +} + +#[tokio::test] +async fn removing_a_nested_network_node_matches_the_diff() { + let mut editor = EditorTestUtils::create(); + editor.new_document().await; + editor.draw_rect(0., 0., 100., 100.).await; + editor + .handle_message(DocumentMessage::GroupSelectedLayers { + group_folder_type: crate::messages::portfolio::document::utility_types::misc::GroupFolderType::Layer, + }) + .await; + + let before: Vec = editor.active_document().network_interface.document_network().nodes.keys().copied().collect(); + let working = convert(&editor); + + let group = editor + .active_document() + .network_interface + .document_network() + .nodes + .iter() + .find(|(_, node)| matches!(node.implementation, graph_craft::document::DocumentNodeImplementation::Network(_))) + .map(|(id, _)| *id) + .expect("the group should be a network node"); + editor.active_document_mut().network_interface.delete_nodes(vec![group], true, &[]); + + let network = editor.active_document().network_interface.document_network().clone(); + let mut deltas: Vec = before + .iter() + .filter(|id| !network.nodes.contains_key(id)) + .map(|id| { + EditorDelta::Graph(RuntimeDelta::RemoveNode { + network_path: Vec::new(), + node_id: *id, + }) + }) + .collect(); + for (index, export) in network.exports.iter().enumerate() { + deltas.push(EditorDelta::Graph(RuntimeDelta::SetExport { + network_path: Vec::new(), + export_index: index, + input: export.clone(), + })); + } + for (node_id, node) in &network.nodes { + for (index, input) in node.inputs.iter().enumerate() { + deltas.push(EditorDelta::Graph(RuntimeDelta::SetInput { + network_path: Vec::new(), + node_id: *node_id, + input_index: index, + input: input.clone(), + })); + } + } + + let without_resource_ops = |ops: Vec| { + ops.into_iter() + .filter(|op| !matches!(op, RegistryDelta::RemoveResource { .. } | RegistryDelta::AddResource { .. })) + .collect::>() + }; + let constructed = without_resource_ops(deltas.iter().flat_map(|delta| construct(&editor, delta, &working)).collect()); + let diffed = without_resource_ops(compute_deltas(&working, &convert(&editor))); + assert_same_stored_effect(&working, constructed, diffed, "nested network removal"); +} diff --git a/node-graph/graph-craft/src/lib.rs b/node-graph/graph-craft/src/lib.rs index 27875c0e1b..6dbbdf6c4d 100644 --- a/node-graph/graph-craft/src/lib.rs +++ b/node-graph/graph-craft/src/lib.rs @@ -7,6 +7,7 @@ pub use core_types::{ProtoNodeIdentifier, Type, TypeDescriptor, concrete, descri pub mod application_io; pub mod document; +pub mod runtime_delta; pub use document::{DocumentNode, NodeNetwork}; pub mod graphene_compiler; pub mod proto; diff --git a/node-graph/graph-craft/src/runtime_delta.rs b/node-graph/graph-craft/src/runtime_delta.rs new file mode 100644 index 0000000000..e3eb24aff6 --- /dev/null +++ b/node-graph/graph-craft/src/runtime_delta.rs @@ -0,0 +1,30 @@ +use crate::document::{DocumentNode, NodeId, NodeInput}; + +#[derive(Debug, Clone, PartialEq)] +pub enum RuntimeDelta { + AddNode { + network_path: Vec, + node_id: NodeId, + node: Box, + }, + ReplaceNode { + network_path: Vec, + node_id: NodeId, + node: Box, + }, + RemoveNode { + network_path: Vec, + node_id: NodeId, + }, + SetInput { + network_path: Vec, + node_id: NodeId, + input_index: usize, + input: NodeInput, + }, + SetExport { + network_path: Vec, + export_index: usize, + input: NodeInput, + }, +}