From 58ab95eee14469189076b346b84d1dc3ede9e6b6 Mon Sep 17 00:00:00 2001 From: "Gerla, J. (Justin)" Date: Sun, 14 Dec 2025 21:56:18 +0000 Subject: [PATCH] fix: edge-disconnections-are-not-reflected-in-reduced-program --- src/pages/VisProgPage/VisProg.tsx | 3 + .../visualProgrammingUI/NodeRegistry.ts | 100 ++++++- .../visualProgrammingUI/VisProgStores.tsx | 78 +++-- .../visualProgrammingUI/VisProgTypes.tsx | 6 +- .../visualProgrammingUI/nodes/EndNode.tsx | 40 ++- .../visualProgrammingUI/nodes/GoalNode.tsx | 40 ++- .../visualProgrammingUI/nodes/NormNode.tsx | 39 ++- .../visualProgrammingUI/nodes/PhaseNode.tsx | 51 +++- .../visualProgrammingUI/nodes/StartNode.tsx | 35 ++- .../visualProgrammingUI/nodes/TriggerNode.tsx | 36 ++- .../VisProgStores.test.tsx | 266 +++++++++++++++++- .../nodes/NormNode.test.tsx | 15 +- .../nodes/StartNode.test.tsx | 11 +- .../nodes/TriggerNode.test.tsx | 13 +- .../nodes/UniversalNodes.test.tsx | 14 +- 15 files changed, 639 insertions(+), 108 deletions(-) diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 0933d28..06e072c 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -39,6 +39,7 @@ const selector = (state: FlowState) => ({ nodes: state.nodes, edges: state.edges, onNodesChange: state.onNodesChange, + onEdgesDelete: state.onEdgesDelete, onEdgesChange: state.onEdgesChange, onConnect: state.onConnect, onReconnectStart: state.onReconnectStart, @@ -62,6 +63,7 @@ const VisProgUI = () => { const { nodes, edges, onNodesChange, + onEdgesDelete, onEdgesChange, onConnect, onReconnect, @@ -91,6 +93,7 @@ const VisProgUI = () => { defaultEdgeOptions={DEFAULT_EDGE_OPTIONS} nodeTypes={NodeTypes} onNodesChange={onNodesChange} + onEdgesDelete={onEdgesDelete} onEdgesChange={onEdgesChange} onReconnect={onReconnect} onReconnectStart={onReconnectStart} diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 8812434..77a835d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -1,14 +1,50 @@ -import StartNode, { StartConnects, StartReduce } from "./nodes/StartNode"; -import EndNode, { EndConnects, EndReduce } from "./nodes/EndNode"; -import PhaseNode, { PhaseConnects, PhaseReduce } from "./nodes/PhaseNode"; -import NormNode, { NormConnects, NormReduce } from "./nodes/NormNode"; +import EndNode, { + EndConnectionTarget, + EndConnectionSource, + EndDisconnectionTarget, + EndDisconnectionSource, + EndReduce +} from "./nodes/EndNode"; import { EndNodeDefaults } from "./nodes/EndNode.default"; +import StartNode, { + StartConnectionTarget, + StartConnectionSource, + StartDisconnectionTarget, + StartDisconnectionSource, + StartReduce +} from "./nodes/StartNode"; import { StartNodeDefaults } from "./nodes/StartNode.default"; +import PhaseNode, { + PhaseConnectionTarget, + PhaseConnectionSource, + PhaseDisconnectionTarget, + PhaseDisconnectionSource, + PhaseReduce +} from "./nodes/PhaseNode"; import { PhaseNodeDefaults } from "./nodes/PhaseNode.default"; +import NormNode, { + NormConnectionTarget, + NormConnectionSource, + NormDisconnectionTarget, + NormDisconnectionSource, + NormReduce +} from "./nodes/NormNode"; import { NormNodeDefaults } from "./nodes/NormNode.default"; -import GoalNode, { GoalConnects, GoalReduce } from "./nodes/GoalNode"; +import GoalNode, { + GoalConnectionTarget, + GoalConnectionSource, + GoalDisconnectionTarget, + GoalDisconnectionSource, + GoalReduce +} from "./nodes/GoalNode"; import { GoalNodeDefaults } from "./nodes/GoalNode.default"; -import TriggerNode, { TriggerConnects, TriggerReduce } from "./nodes/TriggerNode"; +import TriggerNode, { + TriggerConnectionTarget, + TriggerConnectionSource, + TriggerDisconnectionTarget, + TriggerDisconnectionSource, + TriggerReduce +} from "./nodes/TriggerNode"; import { TriggerNodeDefaults } from "./nodes/TriggerNode.default"; /** @@ -60,15 +96,51 @@ export const NodeReduces = { /** * Connection functions for each node type. * - * These functions define how nodes of a particular type can connect to other nodes. + * These functions define any additional actions a node may perform + * when a new connection is made */ -export const NodeConnects = { - start: StartConnects, - end: EndConnects, - phase: PhaseConnects, - norm: NormConnects, - goal: GoalConnects, - trigger: TriggerConnects, +export const NodeConnections = { + Targets: { + start: StartConnectionTarget, + end: EndConnectionTarget, + phase: PhaseConnectionTarget, + norm: NormConnectionTarget, + goal: GoalConnectionTarget, + trigger: TriggerConnectionTarget, + }, + Sources: { + start: StartConnectionSource, + end: EndConnectionSource, + phase: PhaseConnectionSource, + norm: NormConnectionSource, + goal: GoalConnectionSource, + trigger: TriggerConnectionSource, + } +} + +/** + * Disconnection functions for each node type. + * + * These functions define any additional actions a node may perform + * when a connection is disconnected + */ +export const NodeDisconnections = { + Targets: { + start: StartDisconnectionTarget, + end: EndDisconnectionTarget, + phase: PhaseDisconnectionTarget, + norm: NormDisconnectionTarget, + goal: GoalDisconnectionTarget, + trigger: TriggerDisconnectionTarget, + }, + Sources: { + start: StartDisconnectionSource, + end: EndDisconnectionSource, + phase: PhaseDisconnectionSource, + norm: NormDisconnectionSource, + goal: GoalDisconnectionSource, + trigger: TriggerDisconnectionSource, + }, } /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 5bcd855..4bf91fe 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -9,7 +9,12 @@ import { type XYPosition, } from '@xyflow/react'; import type { FlowState } from './VisProgTypes'; -import { NodeDefaults, NodeConnects, NodeDeletes } from './NodeRegistry'; +import { + NodeDefaults, + NodeConnections as NodeCs, + NodeDisconnections as NodeDs, + NodeDeletes +} from './NodeRegistry'; import { UndoRedo } from "./EditorUndoRedo.ts"; @@ -71,10 +76,25 @@ const useFlowStore = create(UndoRedo((set, get) => ({ */ onNodesChange: (changes) => set({nodes: applyNodeChanges(changes, get().nodes)}), + onEdgesDelete: (edges) => { + + // we make sure any affected nodes get updated to reflect removal of edges + edges.forEach((edge) => { + const nodes = get().nodes; + + const sourceNode = nodes.find((n) => n.id == edge.source); + const targetNode = nodes.find((n) => n.id == edge.target); + + if (sourceNode) { NodeDs.Sources[sourceNode.type as keyof typeof NodeDs.Sources](sourceNode, edge.target); } + if (targetNode) { NodeDs.Targets[targetNode.type as keyof typeof NodeDs.Targets](targetNode, edge.source); } + }); + }, /** * Handles changes to edges triggered by ReactFlow. */ - onEdgesChange: (changes) => set({ edges: applyEdgeChanges(changes, get().edges) }), + onEdgesChange: (changes) => { + set({ edges: applyEdgeChanges(changes, get().edges) }) + }, /** * Handles creating a new connection between nodes. @@ -82,32 +102,16 @@ const useFlowStore = create(UndoRedo((set, get) => ({ */ onConnect: (connection) => { get().pushSnapshot(); + set({edges: addEdge(connection, get().edges)}); - const edges = addEdge(connection, get().edges); + // We make sure to perform any required data updates on the newly connected nodes const nodes = get().nodes; - // connection has: { source, sourceHandle, target, targetHandle } - // Let's find the source and target ID's. + const sourceNode = nodes.find((n) => n.id == connection.source); const targetNode = nodes.find((n) => n.id == connection.target); - // In case the nodes weren't found, return basic functionality. - if ( sourceNode == undefined - || targetNode == undefined - || sourceNode.type == undefined - || targetNode.type == undefined - ){ - set({ nodes, edges }); - return; - } - - // We should find out how their data changes by calling their respective functions. - const sourceConnectFunction = NodeConnects[sourceNode.type as keyof typeof NodeConnects] - const targetConnectFunction = NodeConnects[targetNode.type as keyof typeof NodeConnects] - - // We're going to have to update their data based on how they want to update it. - sourceConnectFunction(sourceNode, targetNode, true) - targetConnectFunction(targetNode, sourceNode, false) - set({ nodes, edges }); + if (sourceNode) { NodeCs.Sources[sourceNode.type as keyof typeof NodeCs.Sources](sourceNode, connection.target); } + if (targetNode) { NodeCs.Targets[targetNode.type as keyof typeof NodeCs.Targets](targetNode, connection.source); } }, /** @@ -116,6 +120,22 @@ const useFlowStore = create(UndoRedo((set, get) => ({ onReconnect: (oldEdge, newConnection) => { get().edgeReconnectSuccessful = true; set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) }); + + // We make sure to perform any required data updates on the newly reconnected nodes + const nodes = get().nodes; + + const oldSourceNode = nodes.find((n) => n.id == oldEdge.source)!; + const oldTargetNode = nodes.find((n) => n.id == oldEdge.target)!; + const newSourceNode = nodes.find((n) => n.id == newConnection.source)!; + const newTargetNode = nodes.find((n) => n.id == newConnection.target)!; + + if (oldSourceNode === newSourceNode && oldTargetNode === newTargetNode) return; + + NodeCs.Sources[newSourceNode.type as keyof typeof NodeCs.Sources](newSourceNode, newConnection.target); + NodeCs.Targets[newTargetNode.type as keyof typeof NodeCs.Targets](newTargetNode, newConnection.source); + + NodeDs.Sources[oldSourceNode.type as keyof typeof NodeDs.Sources](oldSourceNode, oldEdge.target); + NodeDs.Targets[oldTargetNode.type as keyof typeof NodeDs.Targets](oldTargetNode, oldEdge.source); }, onReconnectStart: () => { @@ -128,11 +148,21 @@ const useFlowStore = create(UndoRedo((set, get) => ({ * if it is not reconnected to a node after detaching it * * @param _evt - the event - * @param {{id: string}} edge - the described edge + * @param edge - the described edge */ onReconnectEnd: (_evt, edge) => { if (!get().edgeReconnectSuccessful) { + // delete the edge from the flowState set({ edges: get().edges.filter((e) => e.id !== edge.id) }); + + // update node data to reflect the dropped edge + const nodes = get().nodes; + + const sourceNode = nodes.find((n) => n.id == edge.source)!; + const targetNode = nodes.find((n) => n.id == edge.target)!; + + NodeDs.Sources[sourceNode.type as keyof typeof NodeDs.Sources](sourceNode, edge.target); + NodeDs.Targets[targetNode.type as keyof typeof NodeDs.Targets](targetNode, edge.source); } set({ edgeReconnectSuccessful: true }); }, diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index b35bbf2..d5d8c06 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -1,5 +1,5 @@ // VisProgTypes.ts -import type { Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node } from '@xyflow/react'; +import type {Edge, OnNodesChange, OnEdgesChange, OnConnect, OnReconnect, Node, OnEdgesDelete} from '@xyflow/react'; import type { NodeTypes } from './NodeRegistry'; import type {FlowSnapshot} from "./EditorUndoRedo.ts"; @@ -27,6 +27,8 @@ export type FlowState = { /** Handler for changes to nodes triggered by ReactFlow */ onNodesChange: OnNodesChange; + onEdgesDelete: OnEdgesDelete; + /** Handler for changes to edges triggered by ReactFlow */ onEdgesChange: OnEdgesChange; @@ -44,7 +46,7 @@ export type FlowState = { * @param _ - event or unused parameter * @param edge - the edge that finished reconnecting */ - onReconnectEnd: (_: unknown, edge: { id: string }) => void; + onReconnectEnd: (_: unknown, edge: Edge) => void; /** * Deletes a node and any connected edges. diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx index 9a496f2..57db571 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -5,7 +5,8 @@ import { type Node, } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; -import styles from '../../VisProg.module.css'; +import styles from '../../VisProg.module.css'; + /** * The typing of this node's data @@ -51,10 +52,37 @@ export function EndReduce(node: Node, _nodes: Node[]) { } /** - * Any connection functionality that should get called when a connection is made to this node type (end) - * @param _thisNode the node of which the functionality gets called - * @param _otherNode the other node which has connected - * @param _isThisSource whether this node is the one that is the source of the connection + * This function is called whenever a connection is made with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the received connection */ -export function EndConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { +export function EndConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is made with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function EndConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function EndDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function EndDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index bbacdf0..1564969 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -75,8 +75,8 @@ export default function GoalNode({id, data}: NodeProps) { /** * Reduces each Goal, including its children down into its relevant data. - * @param node: The Node Properties of this node. - * @param _nodes: all the nodes in the graph + * @param node The Node Properties of this node. + * @param _nodes all the nodes in the graph */ export function GoalReduce(node: Node, _nodes: Node[]) { const data = node.data as GoalNodeData; @@ -89,11 +89,37 @@ export function GoalReduce(node: Node, _nodes: Node[]) { } /** - * This function is called whenever a connection is made with this node type (Goal) + * This function is called whenever a connection is made with this node type as the target * @param _thisNode the node of this node type which function is called - * @param _otherNode the other node which was part of the connection - * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _sourceNodeId the source of the received connection */ -export function GoalConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { - // Replace this for connection logic +export function GoalConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is made with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function GoalConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function GoalDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function GoalDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 31d92a5..3b83fab 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -60,8 +60,8 @@ export default function NormNode(props: NodeProps) { /** * Reduces each Norm, including its children down into its relevant data. - * @param node: The Node Properties of this node. - * @param _nodes: all the nodes in the graph + * @param node The Node Properties of this node. + * @param _nodes all the nodes in the graph */ export function NormReduce(node: Node, _nodes: Node[]) { const data = node.data as NormNodeData; @@ -73,10 +73,37 @@ export function NormReduce(node: Node, _nodes: Node[]) { } /** - * This function is called whenever a connection is made with this node type (Norm) + * This function is called whenever a connection is made with this node type as the target * @param _thisNode the node of this node type which function is called - * @param _otherNode the other node which was part of the connection - * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _sourceNodeId the source of the received connection */ -export function NormConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { +export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is made with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function NormConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function NormDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index c8ea2c0..41679f1 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -2,11 +2,11 @@ import { Handle, type NodeProps, Position, - type Node, + type Node } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; -import { NodeReduces, NodesInPhase, NodeTypes } from '../NodeRegistry'; +import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry'; import useFlowStore from '../VisProgStores'; import { TextField } from '../../../../components/TextField'; @@ -104,14 +104,45 @@ export function PhaseReduce(node: Node, nodes: Node[]) { } /** - * This function is called whenever a connection is made with this node type (phase) - * @param thisNode the node of this node type which function is called - * @param otherNode the other node which was part of the connection - * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + * This function is called whenever a connection is made with this node type as the target (phase) + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the received connection */ -export function PhaseConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - const node = thisNode as PhaseNode +export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + const node = _thisNode as PhaseNode const data = node.data as PhaseNodeData - if (!isThisSource) - data.children.push(otherNode.id) + // we only add none phase nodes to the children + if (!(useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'phase'))) { + data.children.push(_sourceNodeId) + } + +} + +/** + * This function is called whenever a connection is made with this node type as the source (phase) + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function PhaseConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target (phase) + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + const node = _thisNode as PhaseNode + const data = node.data as PhaseNodeData + data.children = data.children.filter((child) => { if (child != _sourceNodeId) return child; }); +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source (phase) + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function PhaseDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index f994090..92ca6ed 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -51,10 +51,37 @@ export function StartReduce(node: Node, _nodes: Node[]) { } /** - * This function is called whenever a connection is made with this node type (start) + * This function is called whenever a connection is made with this node type as the target * @param _thisNode the node of this node type which function is called - * @param _otherNode the other node which was part of the connection - * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _sourceNodeId the source of the received connection */ -export function StartConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { +export function StartConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is made with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function StartConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function StartDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function StartDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 2e7b732..cad7015 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -102,13 +102,39 @@ export function TriggerReduce(node: Node, _nodes: Node[]) { } /** - * This function is called whenever a connection is made with this node type (trigger) + * This function is called whenever a connection is made with this node type as the target * @param _thisNode the node of this node type which function is called - * @param _otherNode the other node which was part of the connection - * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _sourceNodeId the source of the received connection */ -export function TriggerConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { - +export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is made with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function TriggerConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function TriggerDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function TriggerDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet } // Definitions for the possible triggers, being keywords and emotions diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx index 63fec3d..8ce8e18 100644 --- a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -1,4 +1,7 @@ import {act} from '@testing-library/react'; +import type {Connection, Edge, Node} from "@xyflow/react"; +import { NodeDisconnections } from "../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts"; +import type {PhaseNodeData} from "../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx"; import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; import { mockReactFlow } from '../../../setupFlowTests.ts'; @@ -6,18 +9,187 @@ beforeAll(() => { mockReactFlow(); }); +// default state values for testing, +const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, +}; + +const phaseNode: Node = { + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: ["norm-1"], + hasReduce: true, + }, +}; + +const testEdge: Edge = { + id: 'xy-edge__1-2', + source: 'norm-1', + target: 'phase-1', + sourceHandle: null, + targetHandle: null, +} + +const testStateReconnectEnd = { + nodes: [phaseNode, normNode], + edges: [testEdge], +} + +const phaseNodeUnconnected = { + id: 'phase-2', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 2', + droppable: true, + children: [], + hasReduce: true, + }, +}; + +const testConnection: Connection = { + source: 'norm-1', + target: 'phase-2', + sourceHandle: null, + targetHandle: null, +} +const testStateOnConnect = { + nodes: [phaseNodeUnconnected, normNode], + edges: [], +} + describe('FlowStore Functionality', () => { describe('Node changes', () => { // currently just using a single function from the ReactFlow library, // so testing would mean we are testing already tested behavior. // if implementation gets modified tests should be added for custom behavior }); + describe('ReactFlow onEdgesDelete', () => { + test('Deleted edge is reflected in removed phaseNode child', () => { + const {onEdgesDelete} = useFlowStore.getState(); + + useFlowStore.setState({ + nodes: [{ + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: ["norm-1"], + hasReduce: true, + }, + },{ + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }], + edges: [], // edges is empty as onEdgesDelete is triggered after the edges are deleted + }) + + act(() => { + onEdgesDelete([testEdge]) + }); + + const outcome = useFlowStore.getState(); + expect((outcome.nodes[0].data as PhaseNodeData).children.length).toBe(0); + }) + test('Deleted edge is reflected in phaseNode,even if normNode was already deleted and caused edge removal', () => { + const { onEdgesDelete } = useFlowStore.getState(); + useFlowStore.setState({ + nodes: [{ + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: ["norm-1"], + hasReduce: true, + }, + }], + edges: [], // edges is empty as onEdgesDelete is triggered after the edges are deleted + }) + + act(() => { + onEdgesDelete([testEdge]); + }) + + const outcome = useFlowStore.getState(); + expect((outcome.nodes[0].data as PhaseNodeData).children.length).toBe(0); + }) + test('edge removal resulting from deletion of targetNode calls only the connection function for the sourceNode', () => { + const { onEdgesDelete } = useFlowStore.getState(); + useFlowStore.setState({ + nodes: [{ + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }], + edges: [], // edges is empty as onEdgesDelete is triggered after the edges are deleted + }) + + const targetDisconnectSpy = jest.spyOn(NodeDisconnections.Targets, 'phase'); + const sourceDisconnectSpy = jest.spyOn(NodeDisconnections.Sources, 'norm'); + + act(() => { + onEdgesDelete([testEdge]); + }) + + expect(sourceDisconnectSpy).toHaveBeenCalledWith(normNode, 'phase-1'); + expect(targetDisconnectSpy).not.toHaveBeenCalled(); + + sourceDisconnectSpy.mockRestore(); + targetDisconnectSpy.mockRestore(); + }) + }) describe('Edge changes', () => { // currently just using a single function from the ReactFlow library, // so testing would mean we are testing already tested behavior. // if implementation gets modified tests should be added for custom behavior }) describe('ReactFlow onConnect', () => { + test('Adds connecting node to children of phaseNode', () => { + const {onConnect} = useFlowStore.getState(); + useFlowStore.setState({ + nodes: testStateOnConnect.nodes, + edges: testStateOnConnect.edges + }) + + act(() => { + onConnect(testConnection); + }) + + const outcome = useFlowStore.getState(); + + // phaseNode adds the normNode to its children + expect((outcome.nodes[0].data as PhaseNodeData).children).toEqual(['norm-1']); + + }) test('adds an edge when onConnect is triggered', () => { const {onConnect} = useFlowStore.getState(); @@ -39,6 +211,53 @@ describe('FlowStore Functionality', () => { }); }); describe('ReactFlow onReconnect', () => { + test('PhaseNodes correctly change their children', () => { + const {onReconnect} = useFlowStore.getState(); + useFlowStore.setState({ + nodes: [{ + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: ["norm-1"], + hasReduce: true, + }, + },{ + id: 'phase-2', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 2', + droppable: true, + children: [], + hasReduce: true, + }, + },{ + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }], + edges: [testEdge], + }) + + act(() => { + onReconnect(testEdge, testConnection); + }) + + const outcome = useFlowStore.getState(); + + // phaseNodes lose and gain children when norm node's connection is changed from phaseNode to PhaseNodeUnconnected + expect((outcome.nodes[1].data as PhaseNodeData).children).toEqual(['norm-1']); + expect((outcome.nodes[0].data as PhaseNodeData).children).toEqual([]); + }) test('reconnects an existing edge when onReconnect is triggered', () => { const {onReconnect} = useFlowStore.getState(); const oldEdge = { @@ -93,36 +312,63 @@ describe('FlowStore Functionality', () => { ); }); + + test('successfully removes edge if no successful reconnect occurred', () => { const {onReconnectEnd} = useFlowStore.getState(); - useFlowStore.setState({edgeReconnectSuccessful: false}); + useFlowStore.setState({ + edgeReconnectSuccessful: false, + edges: testStateReconnectEnd.edges, + nodes: testStateReconnectEnd.nodes + }); act(() => { - onReconnectEnd(null, {id: 'xy-edge__A-B'}); + onReconnectEnd(null, testEdge); }); const updatedState = useFlowStore.getState(); expect(updatedState.edgeReconnectSuccessful).toBe(true); expect(updatedState.edges).toHaveLength(0); + expect(updatedState.nodes[0].data.children).toEqual([]); }); test('does not remove reconnecting edge if successful reconnect occurred', () => { const {onReconnectEnd} = useFlowStore.getState(); + useFlowStore.setState({ + edgeReconnectSuccessful: true, + edges: [testEdge], + nodes: [{ + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: ["norm-1"], + hasReduce: true, + }, + },{ + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }] + }); act(() => { - onReconnectEnd(null, {id: 'xy-edge__A-B'}); + onReconnectEnd(null, testEdge); }); const updatedState = useFlowStore.getState(); expect(updatedState.edgeReconnectSuccessful).toBe(true); expect(updatedState.edges).toHaveLength(1); - expect(updatedState.edges).toMatchObject([ - { - id: 'xy-edge__A-B', - source: 'A', - target: 'B' - }] - ); + expect(updatedState.edges).toMatchObject([testEdge]); + expect(updatedState.nodes[0].data.children).toEqual(["norm-1"]); }); }); describe('ReactFlow deleteNode', () => { diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index 25c9947..45ae756 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -1,8 +1,12 @@ import { describe, it, beforeEach } from '@jest/globals'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; -import NormNode, { NormReduce, NormConnects, type NormNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode' +import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; +import NormNode, { + NormReduce, + type NormNodeData, + NormConnectionSource, NormConnectionTarget +} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; import '@testing-library/jest-dom' @@ -517,7 +521,7 @@ describe('NormNode', () => { }; expect(() => { - NormConnects(normNode, phaseNode, true); + NormConnectionSource(normNode, phaseNode.id); }).not.toThrow(); }); @@ -547,7 +551,7 @@ describe('NormNode', () => { }; expect(() => { - NormConnects(normNode, phaseNode, false); + NormConnectionTarget(normNode, phaseNode.id); }).not.toThrow(); }); @@ -565,7 +569,8 @@ describe('NormNode', () => { }; expect(() => { - NormConnects(normNode, normNode, true); + NormConnectionTarget(normNode, normNode.id); + NormConnectionSource(normNode, normNode.id); }).not.toThrow(); }); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx index c5ec43a..f1d468d 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx @@ -2,8 +2,11 @@ import { describe, it } from '@jest/globals'; import '@testing-library/jest-dom'; import { screen } from '@testing-library/react'; import type { Node } from '@xyflow/react'; -import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; -import StartNode, { StartReduce, StartConnects } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode'; +import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; +import StartNode, { + StartConnectionSource, StartConnectionTarget, + StartReduce +} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode'; describe('StartNode', () => { @@ -91,8 +94,8 @@ describe('StartNode', () => { }, }; - expect(() => StartConnects(startNode, otherNode, true)).not.toThrow(); - expect(() => StartConnects(startNode, otherNode, false)).not.toThrow(); + expect(() => StartConnectionSource(startNode, otherNode.id)).not.toThrow(); + expect(() => StartConnectionTarget(startNode, otherNode.id)).not.toThrow(); }); }); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx index 55a46e3..e3c40e0 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -1,8 +1,13 @@ import { describe, it, beforeEach } from '@jest/globals'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; -import TriggerNode, { TriggerReduce, TriggerConnects, TriggerNodeCanConnect, type TriggerNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode'; +import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; +import TriggerNode, { + TriggerReduce, + TriggerNodeCanConnect, + type TriggerNodeData, + TriggerConnectionSource, TriggerConnectionTarget +} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; import '@testing-library/jest-dom'; @@ -233,8 +238,8 @@ describe('TriggerNode', () => { }; expect(() => { - TriggerConnects(node1, node2, true); - TriggerConnects(node1, node2, false); + TriggerConnectionSource(node1, node2.id); + TriggerConnectionTarget(node1, node2.id); }).not.toThrow(); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 7fb0709..c6b9244 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -1,8 +1,8 @@ import { describe, beforeEach } from '@jest/globals'; import { screen } from '@testing-library/react'; -import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; +import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; import type { XYPosition } from '@xyflow/react'; -import { NodeTypes, NodeDefaults, NodeConnects, NodeReduces, NodesInPhase } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry'; +import { NodeTypes, NodeDefaults, NodeConnections, NodeReduces, NodesInPhase } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry'; import '@testing-library/jest-dom' import { createElement } from 'react'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; @@ -87,8 +87,8 @@ describe('NormNode', () => { useFlowStore.setState({ nodes: [sourceNode, targetNode] }); // Spy on the connect functions - const sourceConnectSpy = jest.spyOn(NodeConnects, nodeType as keyof typeof NodeConnects); - const targetConnectSpy = jest.spyOn(NodeConnects, 'end'); + const sourceConnectSpy = jest.spyOn(NodeConnections.Sources, nodeType as keyof typeof NodeConnections.Sources); + const targetConnectSpy = jest.spyOn(NodeConnections.Targets, 'end'); // Simulate connection useFlowStore.getState().onConnect({ @@ -99,8 +99,8 @@ describe('NormNode', () => { }); // Verify the connect functions were called - expect(sourceConnectSpy).toHaveBeenCalledWith(sourceNode, targetNode, true); - expect(targetConnectSpy).toHaveBeenCalledWith(targetNode, sourceNode, false); + expect(sourceConnectSpy).toHaveBeenCalledWith(sourceNode, targetNode.id); + expect(targetConnectSpy).toHaveBeenCalledWith(targetNode, sourceNode.id); sourceConnectSpy.mockRestore(); targetConnectSpy.mockRestore(); @@ -130,7 +130,7 @@ describe('NormNode', () => { expect(phaseReduceSpy).toHaveBeenCalledWith(phaseNode, [phaseNode, testNode]); // Check if this node type is in NodesInPhase and returns false const nodesInPhaseFunc = NodesInPhase[nodeType as keyof typeof NodesInPhase]; - if (nodesInPhaseFunc && nodesInPhaseFunc() === false && nodeType !== 'phase') { + if (nodesInPhaseFunc && !nodesInPhaseFunc() && nodeType !== 'phase') { // Node is NOT in phase, so it should NOT be called expect(nodeReduceSpy).not.toHaveBeenCalled(); } else {