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'; 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(); act(() => { onConnect({ source: 'A', target: 'B', sourceHandle: null, targetHandle: null, }); }); const updatedEdges = useFlowStore.getState().edges; expect(updatedEdges).toHaveLength(1); expect(updatedEdges[0]).toMatchObject({ source: 'A', target: 'B', }); }); }); 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 = { id: 'xy-edge__A-B', source: 'A', target: 'B' }; const newConnection = { source: 'A', target: 'C', sourceHandle: null, targetHandle: null, }; act(() => { useFlowStore.setState({ edges: [oldEdge] }); onReconnect(oldEdge, newConnection); }); const updatedEdges = useFlowStore.getState().edges; expect(updatedEdges).toHaveLength(1); expect(updatedEdges[0]).toMatchObject({ id: 'xy-edge__A-C', source: 'A', target: 'C', }); }); }); describe('ReactFlow onReconnectStart', () => { test('does correct setup for edge reconnection sequences', () => { const {onReconnectStart} = useFlowStore.getState(); act(() => { onReconnectStart(); }); const updatedState = useFlowStore.getState().edgeReconnectSuccessful; expect(updatedState).toEqual(false); }); }); describe('ReactFlow onReconnectEnd', () => { // prepares the state to have an edge in the edge array beforeEach(() => { useFlowStore.setState({edges: [ { id: 'xy-edge__A-B', source: 'A', target: 'B' } ]} ); }); test('successfully removes edge if no successful reconnect occurred', () => { const {onReconnectEnd} = useFlowStore.getState(); useFlowStore.setState({ edgeReconnectSuccessful: false, edges: testStateReconnectEnd.edges, nodes: testStateReconnectEnd.nodes }); act(() => { 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, testEdge); }); const updatedState = useFlowStore.getState(); expect(updatedState.edgeReconnectSuccessful).toBe(true); expect(updatedState.edges).toHaveLength(1); expect(updatedState.edges).toMatchObject([testEdge]); expect(updatedState.nodes[0].data.children).toEqual(["norm-1"]); }); }); describe('ReactFlow deleteNode', () => { // test deleting A and B, so we make sure the connecting edge gets deleted regardless of test.each([['A','B'],['B','A']])('deletes a node and its connected edges', (nodeId, undeletedNodeId) => { const {deleteNode} = useFlowStore.getState(); useFlowStore.setState({ nodes: [ { id: 'A', type: 'default', position: {x: 0, y: 0}, data: {label: 'A'} }, { id: 'B', type: 'default', position: {x: 0, y: 300}, data: {label: 'A'} }], edges: [ { id: 'xy-edge__A-B', source: 'A', target: 'B' }] }); act(()=> { deleteNode(nodeId); }); const updatedState = useFlowStore.getState(); expect(updatedState.edges).toHaveLength(0); expect(updatedState.nodes).toHaveLength(1); expect(updatedState.nodes[0].id).toBe(undeletedNodeId); }); }); describe('ReactFlow setNodes', () => { test('sets nodes to the provided list of nodes', () => { const {setNodes} = useFlowStore.getState(); act(() => { setNodes([ { id: 'start', type: 'start', position: {x: 0, y: 0}, data: {label: 'start'} }, { id: 'end', type: 'end', position: {x: 0, y: 300}, data: {label: 'End'} } ]); }); const updatedNodes = useFlowStore.getState().nodes; expect(updatedNodes).toHaveLength(2); expect(updatedNodes[0]).toMatchObject({ id: 'start', type: 'start', position: {x: 0, y: 0}, data: {label: 'start'} }); expect(updatedNodes[1]).toMatchObject({ id: 'end', type: 'end', position: {x: 0, y: 300}, data: {label: 'End'} }); }); }); describe('ReactFlow setEdges', () => { test('sets edges to the provided list of edges', () => { const {setEdges} = useFlowStore.getState(); act(() => { setEdges([ { id: 'start-end', source: 'start', target: 'end' } ]); }); const updatedEdges = useFlowStore.getState().edges; expect(updatedEdges).toHaveLength(1); expect(updatedEdges[0]).toMatchObject({ id: 'start-end', source: 'start', target: 'end' }); }); }); describe('ReactFlow updateNodeData', () => { test.each([ { state: { name: 'updateName', nodes: [{ id: 'phase-1', type: 'phase', position: {x: 0, y: 300}, data: {label: 'name', number: '2'} }] }, input: { id: 'phase-1', changedData: {label: 'new name'} }, expected: { node: { id: 'phase-1', type: 'phase', position: {x: 0, y: 300}, data: {label: 'new name', number: '2'} } } }, { state: { name: 'updateNumber', nodes: [{ id: 'phase-1', type: 'phase', position: {x: 0, y: 300}, data: {label: 'name', number: '2'} }] }, input: { id: 'phase-1', changedData: {number: '3'} }, expected: { node: { id: 'phase-1', type: 'phase', position: {x: 0, y: 300}, data: {label: 'name', number: '3'} } } }, { state: { name: 'updateNameAndNumber', nodes: [{ id: 'phase-1', type: 'phase', position: {x: 0, y: 300}, data: {label: 'name', number: '2'} }] }, input: { id: 'phase-1', changedData: {label: 'new name', number: '3'} }, expected: { node: { id: 'phase-1', type: 'phase', position: {x: 0, y: 300}, data: {label: 'new name', number: '3'} } } }, { state: { name: 'AddNewEntry', nodes: [{ id: 'phase-1', type: 'phase', position: {x: 0, y: 300}, data: {label: 'name', number: '2'} }] }, input: { id: 'phase-1', changedData: {newEntry: 20} }, expected: { node: { id: 'phase-1', type: 'phase', position: {x: 0, y: 300}, data: {label: 'name', number: '2', newEntry: 20} } } }, { state: { name: 'AddNewEntryAndUpdateOneValue_UnorderedInput', nodes: [{ id: 'phase-1', type: 'phase', position: {x: 0, y: 300}, data: {label: 'name', number: '2'} }] }, input: { id: 'phase-1', changedData: {newEntry: 20, number: '3'} }, expected: { node: { id: 'phase-1', type: 'phase', position: {x: 0, y: 300}, data: {label: 'name', number: '3', newEntry: 20} } } } ])(`tests state: $state.name`, ({state, input,expected}) => { useFlowStore.setState({ nodes: state.nodes }) const {updateNodeData} = useFlowStore.getState(); act(() => { updateNodeData(input.id, input.changedData); }) const updatedState = useFlowStore.getState(); expect(updatedState.nodes).toHaveLength(1); expect(updatedState.nodes[0]).toMatchObject(expected.node); }) }) });