Files
pepperplus-ui/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx

643 lines
18 KiB
TypeScript

import {act} from '@testing-library/react';
import type {Connection, Edge, 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);
});
});
})
});