diff --git a/.gitignore b/.gitignore index a547bf3..4147656 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# Coverage report +coverage \ No newline at end of file diff --git a/__mocks__/@neodrag/react.ts b/__mocks__/@neodrag/react.ts new file mode 100644 index 0000000..7ea5455 --- /dev/null +++ b/__mocks__/@neodrag/react.ts @@ -0,0 +1,3 @@ +jest.mock('@neodrag/react', () => ({ + useDraggable: jest.fn(), +})); \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index ea33067..819a05d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,13 +2,15 @@ export default { preset: 'ts-jest/presets/default-esm', testEnvironment: 'jsdom', extensionsToTreatAsEsm: ['.ts', '.tsx'], - setupFilesAfterEnv: ['/test/setupTests.ts'], + setupFilesAfterEnv: ['/test/setupTests.ts', '/test/setupFlowTests.ts' ], moduleNameMapper: { '^@/(.*)$': '/src/$1', '\\.(css|scss|sass)$': 'identity-obj-proxy' }, - testMatch: ['/test/*.test.(ts|tsx)'], + testMatch: ['/test/**/*.test.(ts|tsx)'], transform: { '^.+\\.(ts|tsx)$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.jest.json' }] - } + }, + collectCoverage:true, + collectCoverageFrom: ['/src/**/*.{ts,tsx,js,jsx}'], }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 665dad5..b3caba6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "@xyflow/react": "^12.8.6", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router": "^7.9.3" + "react-router": "^7.9.3", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -3337,6 +3338,37 @@ "react-dom": ">=17" } }, +<<<<<<< HEAD +======= + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, +>>>>>>> origin/dev "node_modules/@xyflow/system": { "version": "0.0.70", "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz", @@ -7709,9 +7741,15 @@ } }, "node_modules/use-sync-external-store": { +<<<<<<< HEAD "version": "1.6.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", +======= + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", +>>>>>>> origin/dev "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -8201,6 +8239,7 @@ } }, "node_modules/zustand": { +<<<<<<< HEAD "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", @@ -8215,6 +8254,20 @@ "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" +======= + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" +>>>>>>> origin/dev }, "peerDependenciesMeta": { "@types/react": { @@ -8225,6 +8278,12 @@ }, "react": { "optional": true +<<<<<<< HEAD +======= + }, + "use-sync-external-store": { + "optional": true +>>>>>>> origin/dev } } } diff --git a/package.json b/package.json index e92038e..cb88357 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "@xyflow/react": "^12.8.6", "react": "^19.1.1", "react-dom": "^19.1.1", - "react-router": "^7.9.3" + "react-router": "^7.9.3", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/src/App.tsx b/src/App.tsx index 78c273f..fd9404c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,11 @@ import './App.css' import TemplatePage from './pages/TemplatePage/Template.tsx' import Home from './pages/Home/Home.tsx' import Robot from './pages/Robot/Robot.tsx'; +<<<<<<< HEAD import ConnectedRobots from './pages/ConnectedRobots/ConnectedRobots.tsx' +======= +import VisProg from "./pages/VisProgPage/VisProg.tsx"; +>>>>>>> origin/dev function App(){ @@ -27,6 +31,7 @@ function App(){ } /> } /> + } /> } />
Robot Interaction → + Editor → Template → Connected Robots →
diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css new file mode 100644 index 0000000..f2f90c7 --- /dev/null +++ b/src/pages/VisProgPage/VisProg.module.css @@ -0,0 +1,129 @@ +/* editor UI */ + +.outer-editor-container { + margin-inline: auto; + display: flex; + justify-self: center; + padding: 10px; + align-items: center; + width: 80vw; + height: 80vh; +} + +.inner-editor-container { + outline-style: solid; + border-radius: 10pt; + width: 90%; + height: 100%; +} + + + + + +.dnd-panel { + margin-inline-start: auto; + margin-inline-end: auto; + background-color: canvas; + align-content: center; + margin-bottom: 0.5rem; + margin-top:auto; + width: 50%; + height:7%; +} + +.inner-dnd-panel { + outline: 2.5pt solid black; + border-radius: 0 0 5pt 5pt; + border-color: dimgrey; + background-color: canvas; + align-items: center; +} + +.dnd-node-container { + background-color: canvas; + justify-content: center; +} + +/* Node Styles */ + +.default-node { + padding: 10px 15px; + background-color: canvas; + border-radius: 5pt; + outline: black solid 2pt; + filter: drop-shadow(0 0 0.75rem black); +} + +.default-node-norm { + padding: 10px 15px; + background-color: canvas; + border-radius: 5pt; + outline: forestgreen solid 2pt; + filter: drop-shadow(0 0 0.25rem forestgreen); +} + +.default-node-phase { + padding: 10px 15px; + background-color: canvas; + border-radius: 5pt; + outline: dodgerblue solid 2pt; + filter: drop-shadow(0 0 0.25rem dodgerblue); +} + +.default-node-start { + padding: 10px 15px; + background-color: canvas; + border-radius: 5pt; + outline: orange solid 2pt; + filter: drop-shadow(0 0 0.25rem orange); +} + +.default-node-end { + padding: 10px 15px; + background-color: canvas; + border-radius: 5pt; + outline: red solid 2pt; + filter: drop-shadow(0 0 0.25rem red); +} + +.draggable-node { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: black solid 2pt; + filter: drop-shadow(0 0 0.75rem black); +} + +.draggable-node-norm { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: forestgreen solid 2pt; + filter: drop-shadow(0 0 0.25rem forestgreen); +} + +.draggable-node-phase { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: dodgerblue solid 2pt; + filter: drop-shadow(0 0 0.25rem dodgerblue); +} + +.draggable-node-start { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: orange solid 2pt; + filter: drop-shadow(0 0 0.25rem orange); +} + +.draggable-node-end { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: red solid 2pt; + filter: drop-shadow(0 0 0.25rem red); +} + diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index ec0055a..ec2078c 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -1,3 +1,4 @@ +<<<<<<< HEAD import VisProgUI from "../../visualProgrammingUI/VisProgUI.tsx"; function VisProgPage() { @@ -6,6 +7,144 @@ function VisProgPage() { ) +======= +import { + Background, + Controls, + Panel, + ReactFlow, + ReactFlowProvider, + MarkerType, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import {useShallow} from 'zustand/react/shallow'; + +import { + StartNode, + EndNode, + PhaseNode, + NormNode +} from './visualProgrammingUI/components/NodeDefinitions.tsx'; +import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx'; +import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; +import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; +import styles from './VisProg.module.css' + +// --| config starting params for flow |-- + +/** + * contains the types of all nodes that are available in the editor + */ +const NODE_TYPES = { + start: StartNode, + end: EndNode, + phase: PhaseNode, + norm: NormNode +}; + +/** + * defines how the default edge looks inside the editor + */ +const DEFAULT_EDGE_OPTIONS = { + type: 'default', + markerEnd: { + type: MarkerType.ArrowClosed, + color: '#505050', + }, +}; + + +/** + * defines what functions in the FlowState store map to which names, + * @param state + */ +const selector = (state: FlowState) => ({ + nodes: state.nodes, + edges: state.edges, + onNodesChange: state.onNodesChange, + onEdgesChange: state.onEdgesChange, + onConnect: state.onConnect, + onReconnectStart: state.onReconnectStart, + onReconnectEnd: state.onReconnectEnd, + onReconnect: state.onReconnect +}); + +// --| define ReactFlow editor |-- + +/** + * Defines the ReactFlow visual programming editor component + * any implementations of editor logic should be encapsulated where possible + * so the Component definition stays as readable as possible + * @constructor + */ +const VisProgUI = () => { + const { + nodes, edges, + onNodesChange, + onEdgesChange, + onConnect, + onReconnect, + onReconnectStart, + onReconnectEnd + } = useFlowStore(useShallow(selector)); // instructs the editor to use the corresponding functions from the FlowStore + + return ( +
+
+ + + {/* contains the drag and drop panel for nodes */} + + + + +
+
+ ); +}; + + +/** + * Places the VisProgUI component inside a ReactFlowProvider + * + * Wrapping the editor component inside a ReactFlowProvider + * allows us to access and interact with the components inside the editor, outside the editor definition, + * thus facilitating the addition of node specific functions inside their node definitions + */ +function VisualProgrammingUI() { + return ( + + + + ); +} + +/** + * houses the entire page, so also UI elements + * that are not a part of the Visual Programming UI + * @constructor + */ +function VisProgPage() { + return ( + <> + + + ) +>>>>>>> origin/dev } export default VisProgPage \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx new file mode 100644 index 0000000..10d0142 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -0,0 +1,111 @@ +import {create} from 'zustand'; +import { + applyNodeChanges, + applyEdgeChanges, + addEdge, + reconnectEdge, type Edge, type Connection +} from '@xyflow/react'; + +import {type FlowState} from './VisProgTypes.tsx'; + +/** + * contains the nodes that are created when the editor is loaded, + * should contain at least a start and an end node + */ +const initialNodes = [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'genericPhase', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } +]; + +/** + * contains the initial edges that are created when the editor is loaded + */ +const initialEdges = [ + { + id: 'start-end', + source: 'start', + target: 'end' + } +]; + +/** + * The useFlowStore hook contains the implementation for editor functionality and state + * we can use this inside our editor component to access the current state + * and use any implemented functionality + */ +const useFlowStore = create((set, get) => ({ + nodes: initialNodes, + edges: initialEdges, + edgeReconnectSuccessful: true, + onNodesChange: (changes) => { + set({ + nodes: applyNodeChanges(changes, get().nodes) + }); + }, + onEdgesChange: (changes) => { + set({ + edges: applyEdgeChanges(changes, get().edges) + }); + }, + // handles connection of newly created edges + onConnect: (connection) => { + set({ + edges: addEdge(connection, get().edges) + }); + }, + // handles attempted reconnections of a previously disconnected edge + onReconnect: (oldEdge: Edge, newConnection: Connection) => { + get().edgeReconnectSuccessful = true; + set({ + edges: reconnectEdge(oldEdge, newConnection, get().edges) + }); + }, + // Handles initiation of reconnection of edges that are manually disconnected from a node + onReconnectStart: () => { + set({ + edgeReconnectSuccessful: false + }); + }, + // Drops the edge from the set of edges, removing it from the flow, if no successful reconnection occurred + onReconnectEnd: (_: unknown, edge: { id: string; }) => { + if (!get().edgeReconnectSuccessful) { + set({ + edges: get().edges.filter((e) => e.id !== edge.id), + }); + } + set({ + edgeReconnectSuccessful: true + }); + }, + deleteNode: (nodeId: string) => { + set({ + nodes: get().nodes.filter((n) => n.id !== nodeId), + edges: get().edges.filter((e) => e.source !== nodeId && e.target !== nodeId) + }); + }, + setNodes: (nodes) => { + set({nodes}); + }, + setEdges: (edges) => { + set({edges}); + }, + }), +); + +export default useFlowStore; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx new file mode 100644 index 0000000..f5ede86 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -0,0 +1,34 @@ +import { + type Edge, + type Node, + type OnNodesChange, + type OnEdgesChange, + type OnConnect, + type OnReconnect, +} from '@xyflow/react'; + +/** + * a type meant to house different node types, currently not used + * but will allow us to more clearly define nodeTypes when we implement + * computation of the Graph inside the ReactFlow editor + */ +export type AppNode = Node; + + +/** + * The type for the Zustand store object used to manage the state of the ReactFlow editor + */ +export type FlowState = { + nodes: AppNode[]; + edges: Edge[]; + edgeReconnectSuccessful: boolean; + onNodesChange: OnNodesChange; + onEdgesChange: OnEdgesChange; + onConnect: OnConnect; + onReconnect: OnReconnect; + onReconnectStart: () => void; + onReconnectEnd: (_: unknown, edge: { id: string }) => void; + deleteNode: (nodeId: string) => void; + setNodes: (nodes: AppNode[]) => void; + setEdges: (edges: Edge[]) => void; +}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx new file mode 100644 index 0000000..383f72c --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -0,0 +1,148 @@ +import {useDraggable} from '@neodrag/react'; +import { + useReactFlow, + type XYPosition +} from '@xyflow/react'; +import { + type ReactNode, + useCallback, + useRef, + useState +} from 'react'; +import useFlowStore from "../VisProgStores.tsx"; +import styles from "../../VisProg.module.css" + + +/** + * DraggableNodeProps dictates the type properties of a DraggableNode + */ +interface DraggableNodeProps { + className?: string; + children: ReactNode; + nodeType: string; + onDrop: (nodeType: string, position: XYPosition) => void; +} + +/** + * Definition of a node inside the drag and drop toolbar, + * these nodes require an onDrop function to be supplied + * that dictates how the node is created in the graph. + * + * @param className + * @param children + * @param nodeType + * @param onDrop + * @constructor + */ +function DraggableNode({className, children, nodeType, onDrop}: DraggableNodeProps) { + const draggableRef = useRef(null); + const [position, setPosition] = useState({x: 0, y: 0}); + + // @ts-expect-error comes from a package and doesn't appear to play nicely with strict typescript typing + useDraggable(draggableRef, { + position: position, + onDrag: ({offsetX, offsetY}) => { + // Calculate position relative to the viewport + setPosition({ + x: offsetX, + y: offsetY, + }); + }, + onDragEnd: ({event}) => { + setPosition({x: 0, y: 0}); + onDrop(nodeType, { + x: event.clientX, + y: event.clientY, + }); + }, + }); + + return ( +
+ {children} +
+ ); +} + + +// eslint-disable-next-line react-refresh/only-export-components +export function addNode(nodeType: string, position: XYPosition) { + const {setNodes} = useFlowStore.getState(); + const nds = useFlowStore.getState().nodes; + const newNode = () => { + switch (nodeType) { + case "phase": + { + const phaseNumber = nds.filter((node) => node.type === 'phase').length; + return { + id: `phase-${phaseNumber}`, + type: nodeType, + position, + data: {label: 'new', number: phaseNumber}, + }; + } + case "norm": + { + const normNumber = nds.filter((node) => node.type === 'norm').length; + return { + id: `norm-${normNumber}`, + type: nodeType, + position, + data: {label: `new norm node`}, + }; + } + default: { + throw new Error(`Node ${nodeType} not found`); + } + } + } + + setNodes(nds.concat(newNode())); +} + +/** + * the DndToolbar defines how the drag and drop toolbar component works + * and includes the default onDrop behavior through handleNodeDrop + * @constructor + */ +export function DndToolbar() { + const {screenToFlowPosition} = useReactFlow(); + /** + * handleNodeDrop implements the default onDrop behavior + */ + const handleNodeDrop = useCallback( + (nodeType: string, screenPosition: XYPosition) => { + const flow = document.querySelector('.react-flow'); + const flowRect = flow?.getBoundingClientRect(); + const isInFlow = + flowRect && + screenPosition.x >= flowRect.left && + screenPosition.x <= flowRect.right && + screenPosition.y >= flowRect.top && + screenPosition.y <= flowRect.bottom; + + // Create a new node and add it to the flow + if (isInFlow) { + const position = screenToFlowPosition(screenPosition); + addNode(nodeType, position); + } + }, + [screenToFlowPosition], + ); + + return ( +
+
+ You can drag these nodes to the pane to create new nodes. +
+
+ + phase Node + + + norm Node + +
+
+ ); +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx new file mode 100644 index 0000000..63765b5 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx @@ -0,0 +1,124 @@ +import {Handle, NodeToolbar, Position} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import styles from '../../VisProg.module.css'; +import useFlowStore from "../VisProgStores.tsx"; + +// Contains the datatypes for the data inside our NodeTypes +// this has to be improved or adapted to suit our implementation for computing the graph +// into a format that is useful for the Control Backend + +type defaultNodeData = { + label: string; +}; + +type startNodeData = defaultNodeData; +type endNodeData = defaultNodeData; +type normNodeData = defaultNodeData; +type phaseNodeData = defaultNodeData & { + number: number; +}; + +export type nodeData = defaultNodeData | startNodeData | phaseNodeData | endNodeData; + +// Node Toolbar definition, contains node delete functionality + +type ToolbarProps = { + nodeId: string; + allowDelete: boolean; +}; + +export function Toolbar({nodeId, allowDelete}: ToolbarProps) { + const {deleteNode} = useFlowStore(); + + const deleteParentNode = ()=> { + deleteNode(nodeId); + } + return ( + + + ); +} + + +// Definitions of Nodes + +// Start Node definition: + +type StartNodeProps = { + id: string; + data: startNodeData; +}; + +export const StartNode = ({id, data}: StartNodeProps) => { + return ( + <> + +
+
data test {data.label}
+ +
+ + ); +}; + + +// End node definition: + +type EndNodeProps = { + id: string; + data: endNodeData; +}; + +export const EndNode = ({id, data}: EndNodeProps) => { + return ( + <> + +
+
{data.label}
+ +
+ + ); +}; + + +// Phase node definition: + +type PhaseNodeProps = { + id: string; + data: phaseNodeData; +}; + +export const PhaseNode = ({id, data}: PhaseNodeProps) => { + return ( + <> + +
+
phase {data.number} {data.label}
+ + + +
+ + ); +}; + + +// Norm node definition: + +type NormNodeProps = { + id: string; + data: normNodeData; +}; + +export const NormNode = ({id, data}: NormNodeProps) => { + return ( + <> + +
+
Norm {data.label}
+ +
+ + ); +}; \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx new file mode 100644 index 0000000..9b3ab80 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/VisProgStores.test.tsx @@ -0,0 +1,224 @@ +import {act} from '@testing-library/react'; +import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; +import { mockReactFlow } from '../../../setupFlowTests.ts'; + +beforeAll(() => { + mockReactFlow(); +}); + +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('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 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('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}); + + act(() => { + onReconnectEnd(null, {id: 'xy-edge__A-B'}); + }); + + const updatedState = useFlowStore.getState(); + expect(updatedState.edgeReconnectSuccessful).toBe(true); + expect(updatedState.edges).toHaveLength(0); + }); + + test('does not remove reconnecting edge if successful reconnect occurred', () => { + const {onReconnectEnd} = useFlowStore.getState(); + + act(() => { + onReconnectEnd(null, {id: 'xy-edge__A-B'}); + }); + + 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' + }] + ); + }); + }); + 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' + }); + }); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx new file mode 100644 index 0000000..ae9b88c --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx @@ -0,0 +1,33 @@ +import { mockReactFlow } from '../../../../setupFlowTests.ts'; +import {act} from "@testing-library/react"; +import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; +import {addNode} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx"; + + +beforeAll(() => { + mockReactFlow(); +}); + +describe('Drag-and-Drop sidebar', () => { + test.each(['phase', 'phase'])('new nodes get added correctly', (nodeType: string) => { + act(()=> { + addNode(nodeType, {x:100, y:100}); + }) + const updatedState = useFlowStore.getState(); + expect(updatedState.nodes.length).toBe(1); + expect(updatedState.nodes[0].type).toBe(nodeType); + }); + test.each(['phase', 'norm'])('new nodes get correct Id', (nodeType) => { + act(()=> { + addNode(nodeType, {x:100, y:100}); + addNode(nodeType, {x:100, y:100}); + }) + const updatedState = useFlowStore.getState(); + expect(updatedState.nodes.length).toBe(2); + expect(updatedState.nodes[0].id).toBe(`${nodeType}-0`); + expect(updatedState.nodes[1].id).toBe(`${nodeType}-1`); + }); + test('throws error on unexpected node type', () => { + expect(() => addNode('I do not Exist', {x:100, y:100})).toThrow("Node I do not Exist not found"); + }) +}); \ No newline at end of file diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts new file mode 100644 index 0000000..21a4945 --- /dev/null +++ b/test/setupFlowTests.ts @@ -0,0 +1,84 @@ +import '@testing-library/jest-dom'; +import { cleanup } from '@testing-library/react'; +import useFlowStore from '../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; + + +// To make sure that the tests are working, it's important that you are using +// this implementation of ResizeObserver and DOMMatrixReadOnly +class ResizeObserver { + callback: globalThis.ResizeObserverCallback; + + constructor(callback: globalThis.ResizeObserverCallback) { + this.callback = callback; + } + + observe(target: Element) { + this.callback([{ target } as globalThis.ResizeObserverEntry], this); + } + + unobserve() {} + + disconnect() {} +} + +class DOMMatrixReadOnly { + m22: number; + constructor(transform: string) { + const scale = transform?.match(/scale\(([1-9.])\)/)?.[1]; + this.m22 = scale !== undefined ? +scale : 1; + } +} + +// Only run the shim once when requested +let init = false; + +export const mockReactFlow = () => { + if (init) return; + init = true; + + globalThis.ResizeObserver = ResizeObserver; + + // @ts-expect-error included in advised setup code provided in ReactFlow documentation + global.DOMMatrixReadOnly = DOMMatrixReadOnly; + + Object.defineProperties(globalThis.HTMLElement.prototype, { + offsetHeight: { + get() { + return parseFloat(this.style.height) || 1; + }, + }, + offsetWidth: { + get() { + return parseFloat(this.style.width) || 1; + }, + }, + }); + + // @ts-expect-error included in advised setup code provided in ReactFlow documentation + (globalThis.SVGElement as never).prototype.getBBox = () => ({ + x: 0, + y: 0, + width: 200, + height: 200, + }); +}; + + + +beforeAll(() => { + useFlowStore.setState({ + nodes: [], + edges: [], + edgeReconnectSuccessful: true + }); +}); + +afterEach(() => { + cleanup(); + useFlowStore.setState({ + nodes: [], + edges: [], + edgeReconnectSuccessful: true + }); +}); +