651 lines
18 KiB
TypeScript
651 lines
18 KiB
TypeScript
// This program has been developed by students from the bachelor Computer Science at Utrecht
|
|
// University within the Software Project course.
|
|
// © Copyright Utrecht University (Department of Information and Computing Sciences)
|
|
import {act} from '@testing-library/react';
|
|
import {
|
|
type Connection,
|
|
type Edge,
|
|
type Node,
|
|
} from "@xyflow/react";
|
|
import type {HandleRule, RuleResult} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
|
|
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);
|
|
})
|
|
describe('Handle Rule Registry', () => {
|
|
it('should register and retrieve rules', () => {
|
|
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
|
|
|
|
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
|
|
const rules = useFlowStore.getState().getTargetRules('node1', 'handleA');
|
|
|
|
expect(rules).toEqual(mockRules);
|
|
});
|
|
|
|
it('should warn and return empty array if rules are missing', () => {
|
|
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
|
|
const rules = useFlowStore.getState().getTargetRules('missingNode', 'missingHandle');
|
|
expect(rules).toEqual([]);
|
|
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No rules were registered'));
|
|
|
|
consoleSpy.mockRestore();
|
|
});
|
|
|
|
it('should unregister a specific handle rule', () => {
|
|
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
|
|
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
|
|
|
|
useFlowStore.getState().unregisterHandleRules('node1', 'handleA');
|
|
const rules = useFlowStore.getState().getTargetRules('node1', 'handleA');
|
|
|
|
expect(rules).toEqual([]);
|
|
});
|
|
|
|
it('should unregister all rules for a node', () => {
|
|
const mockRules: HandleRule[] = [() => ({ isSatisfied: true } as RuleResult)];
|
|
useFlowStore.getState().registerRules('node1', 'handleA', mockRules);
|
|
useFlowStore.getState().registerRules('node1', 'handleB', mockRules);
|
|
useFlowStore.getState().registerRules('node2', 'handleC', mockRules);
|
|
|
|
useFlowStore.getState().unregisterNodeRules('node1');
|
|
|
|
expect(useFlowStore.getState().getTargetRules('node1', 'handleA')).toEqual([]);
|
|
expect(useFlowStore.getState().getTargetRules('node1', 'handleB')).toEqual([]);
|
|
expect(useFlowStore.getState().getTargetRules('node2', 'handleC')).toEqual(mockRules);
|
|
});
|
|
});
|
|
})
|
|
});
|