jellyflow-core 0.2.0

Headless graph model, IDs, type descriptors, and interaction policy primitives for Jellyflow.
Documentation
use super::*;

#[test]
fn graph_diff_is_deterministic_and_roundtrips() {
    let mut from = Graph::default();
    let a = NodeId::new();
    let b = NodeId::new();
    from.nodes.insert(a, make_node("core.a"));
    from.nodes.insert(b, make_node("core.b"));

    let group_id = GroupId::new();
    from.groups.insert(
        group_id,
        Group {
            title: "G".to_string(),
            rect: CanvasRect {
                origin: CanvasPoint { x: 0.0, y: 0.0 },
                size: CanvasSize {
                    width: 100.0,
                    height: 100.0,
                },
            },
            color: None,
        },
    );
    from.nodes.get_mut(&a).unwrap().parent = Some(group_id);

    let out = PortId::from_u128(10);
    let inn = PortId::from_u128(11);
    from.ports
        .insert(out, make_port(a, "out", PortDirection::Out));
    from.ports
        .insert(inn, make_port(b, "in", PortDirection::In));
    from.nodes.get_mut(&a).unwrap().ports.push(out);
    from.nodes.get_mut(&b).unwrap().ports.push(inn);

    let edge_id = EdgeId::from_u128(123);
    from.edges.insert(
        edge_id,
        Edge {
            kind: EdgeKind::Data,
            from: out,
            to: inn,
            hidden: false,
            selectable: None,
            focusable: None,
            interaction_width: None,
            deletable: None,
            reconnectable: None,
        },
    );

    let imported = GraphId::from_u128(10);
    from.imports.insert(imported, GraphImport::default());

    let symbol_id = SymbolId::from_u128(1);
    from.symbols.insert(
        symbol_id,
        Symbol {
            name: "S".to_string(),
            ty: None,
            default_value: None,
            meta: serde_json::Value::Null,
        },
    );

    let note_id = StickyNoteId::new();
    from.sticky_notes.insert(
        note_id,
        StickyNote {
            text: "N".to_string(),
            rect: CanvasRect {
                origin: CanvasPoint { x: 5.0, y: 6.0 },
                size: CanvasSize {
                    width: 7.0,
                    height: 8.0,
                },
            },
            color: None,
        },
    );

    let mut to = from.clone();
    to.imports.insert(
        imported,
        GraphImport {
            alias: Some("stdlib".to_string()),
        },
    );
    to.symbols.insert(
        symbol_id,
        Symbol {
            name: "T".to_string(),
            ty: Some(TypeDesc::Int),
            default_value: Some(serde_json::json!(123)),
            meta: serde_json::json!({ "k": 1 }),
        },
    );
    if let Some(group) = to.groups.get_mut(&group_id) {
        group.color = Some("red".to_string());
    }
    if let Some(edge) = to.edges.get_mut(&edge_id) {
        edge.hidden = true;
        edge.interaction_width = Some(24.0);
        edge.deletable = Some(true);
        edge.reconnectable = Some(crate::core::EdgeReconnectable::Endpoint(
            crate::core::EdgeReconnectableEndpoint::Target,
        ));
    }
    if let Some(port) = to.ports.get_mut(&out) {
        port.connectable = Some(false);
        port.ty = Some(TypeDesc::String);
        port.data = serde_json::json!({ "p": 1 });
    }
    if let Some(node) = to.nodes.get_mut(&a) {
        node.pos.x = 42.0;
        node.origin = Some(crate::core::NodeOrigin { x: 0.5, y: 0.25 });
        node.selectable = Some(false);
        node.draggable = Some(false);
        node.connectable = Some(false);
        node.deletable = Some(false);
        node.extent = Some(crate::core::NodeExtent::Rect {
            rect: CanvasRect {
                origin: CanvasPoint { x: 1.0, y: 2.0 },
                size: CanvasSize {
                    width: 3.0,
                    height: 4.0,
                },
            },
        });
        node.expand_parent = Some(true);
        node.hidden = true;
    }
    if let Some(note) = to.sticky_notes.get_mut(&note_id) {
        note.text = "M".to_string();
        note.rect.origin.x = 9.0;
        note.color = Some("yellow".to_string());
    }

    let tx1 = graph_diff(&from, &to);
    let tx2 = graph_diff(&from, &to);
    assert_eq!(
        serde_json::to_string(tx1.ops()).unwrap(),
        serde_json::to_string(tx2.ops()).unwrap(),
        "diff must be deterministic"
    );

    assert!(
        tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::SetNodeSelectable { id, .. } if *id == a)),
        "diff must include node setter ops for changed fields"
    );
    assert!(
        tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::SetNodeExtent { id, .. } if *id == a)),
        "diff must include node setter ops for changed fields"
    );
    assert!(
        tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::SetNodeHidden { id, .. } if *id == a)),
        "diff must include node setter ops for changed fields"
    );
    assert!(
        tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::SetNodeOrigin { id, .. } if *id == a)),
        "diff must include node setter ops for changed fields"
    );
    assert!(
        !tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::RemoveNode { id, .. } if *id == a)),
        "diff must not use destructive node removal for soft field changes"
    );

    assert!(
        tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::SetGroupColor { id, .. } if *id == group_id)),
        "diff must prefer group setter ops over remove+add to preserve parent bindings"
    );
    assert!(
        !tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::RemoveGroup { id, .. } if *id == group_id)),
        "diff must not remove groups for color-only changes"
    );

    assert!(
        tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::SetStickyNoteText { id, .. } if *id == note_id)),
        "diff must use sticky note setter ops for field changes"
    );
    assert!(
        tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::SetStickyNoteRect { id, .. } if *id == note_id)),
        "diff must use sticky note setter ops for field changes"
    );
    assert!(
        tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::SetStickyNoteColor { id, .. } if *id == note_id)),
        "diff must use sticky note setter ops for field changes"
    );
    assert!(
        !tx1.ops().iter().any(|op| {
            matches!(op, GraphOp::RemoveStickyNote { id, .. } if *id == note_id)
                || matches!(op, GraphOp::AddStickyNote { id, .. } if *id == note_id)
        }),
        "diff must not fall back to remove+add for sticky note field changes"
    );

    assert!(
        tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::SetEdgeHidden { id, .. } if *id == edge_id)),
        "diff must use edge setter ops for hidden changes"
    );
    assert!(
        tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::SetEdgeInteractionWidth { id, .. } if *id == edge_id)),
        "diff must use edge setter ops for interaction width changes"
    );
    assert!(
        tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::SetEdgeDeletable { id, .. } if *id == edge_id)),
        "diff must use edge setter ops for policy changes"
    );
    assert!(
        tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::SetEdgeReconnectable { id, .. } if *id == edge_id)),
        "diff must use edge setter ops for policy changes"
    );
    assert!(
        !tx1.ops().iter().any(|op| {
            matches!(op, GraphOp::RemoveEdge { id, .. } if *id == edge_id)
                || matches!(op, GraphOp::AddEdge { id, .. } if *id == edge_id)
        }),
        "diff must not fall back to remove+add for soft edge changes"
    );

    assert!(
        tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::SetPortConnectable { id, .. } if *id == out)),
        "diff must use port setter ops for soft fields"
    );
    assert!(
        tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::SetPortType { id, .. } if *id == out)),
        "diff must use port setter ops for soft fields"
    );
    assert!(
        tx1.ops()
            .iter()
            .any(|op| matches!(op, GraphOp::SetPortData { id, .. } if *id == out)),
        "diff must use port setter ops for soft fields"
    );
    assert!(
        !tx1.ops().iter().any(|op| {
            matches!(op, GraphOp::RemovePort { id, .. } if *id == out)
                || matches!(op, GraphOp::AddPort { id, .. } if *id == out)
        }),
        "diff must not fall back to remove+add for soft port changes"
    );

    let mut patched = from.clone();
    apply_transaction(&mut patched, &tx1).expect("apply diff");
    assert_eq!(
        serde_json::to_value(&patched).unwrap(),
        serde_json::to_value(&to).unwrap(),
        "diff must roundtrip"
    );
}