Merge remote-tracking branch 'origin/dev' into feat/show-connected-robots

This commit is contained in:
Björn Otgaar
2025-10-30 14:57:18 +01:00
16 changed files with 1105 additions and 5 deletions

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# Coverage report
coverage

View File

@@ -0,0 +1,3 @@
jest.mock('@neodrag/react', () => ({
useDraggable: jest.fn(),
}));

View File

@@ -2,13 +2,15 @@ export default {
preset: 'ts-jest/presets/default-esm', preset: 'ts-jest/presets/default-esm',
testEnvironment: 'jsdom', testEnvironment: 'jsdom',
extensionsToTreatAsEsm: ['.ts', '.tsx'], extensionsToTreatAsEsm: ['.ts', '.tsx'],
setupFilesAfterEnv: ['<rootDir>/test/setupTests.ts'], setupFilesAfterEnv: ['<rootDir>/test/setupTests.ts', '<rootDir>/test/setupFlowTests.ts' ],
moduleNameMapper: { moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1', '^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|scss|sass)$': 'identity-obj-proxy' '\\.(css|scss|sass)$': 'identity-obj-proxy'
}, },
testMatch: ['<rootDir>/test/*.test.(ts|tsx)'], testMatch: ['<rootDir>/test/**/*.test.(ts|tsx)'],
transform: { transform: {
'^.+\\.(ts|tsx)$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.jest.json' }] '^.+\\.(ts|tsx)$': ['ts-jest', { useESM: true, tsconfig: 'tsconfig.jest.json' }]
} },
collectCoverage:true,
collectCoverageFrom: ['<rootDir>/src/**/*.{ts,tsx,js,jsx}'],
}; };

61
package-lock.json generated
View File

@@ -12,7 +12,8 @@
"@xyflow/react": "^12.8.6", "@xyflow/react": "^12.8.6",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router": "^7.9.3" "react-router": "^7.9.3",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
@@ -3337,6 +3338,37 @@
"react-dom": ">=17" "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": { "node_modules/@xyflow/system": {
"version": "0.0.70", "version": "0.0.70",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz", "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.70.tgz",
@@ -7709,9 +7741,15 @@
} }
}, },
"node_modules/use-sync-external-store": { "node_modules/use-sync-external-store": {
<<<<<<< HEAD
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "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", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -8201,6 +8239,7 @@
} }
}, },
"node_modules/zustand": { "node_modules/zustand": {
<<<<<<< HEAD
"version": "4.5.7", "version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
@@ -8215,6 +8254,20 @@
"@types/react": ">=16.8", "@types/react": ">=16.8",
"immer": ">=9.0.6", "immer": ">=9.0.6",
"react": ">=16.8" "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": { "peerDependenciesMeta": {
"@types/react": { "@types/react": {
@@ -8225,6 +8278,12 @@
}, },
"react": { "react": {
"optional": true "optional": true
<<<<<<< HEAD
=======
},
"use-sync-external-store": {
"optional": true
>>>>>>> origin/dev
} }
} }
} }

View File

@@ -14,7 +14,8 @@
"@xyflow/react": "^12.8.6", "@xyflow/react": "^12.8.6",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-router": "^7.9.3" "react-router": "^7.9.3",
"zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",

View File

@@ -4,7 +4,11 @@ import './App.css'
import TemplatePage from './pages/TemplatePage/Template.tsx' import TemplatePage from './pages/TemplatePage/Template.tsx'
import Home from './pages/Home/Home.tsx' import Home from './pages/Home/Home.tsx'
import Robot from './pages/Robot/Robot.tsx'; import Robot from './pages/Robot/Robot.tsx';
<<<<<<< HEAD
import ConnectedRobots from './pages/ConnectedRobots/ConnectedRobots.tsx' import ConnectedRobots from './pages/ConnectedRobots/ConnectedRobots.tsx'
=======
import VisProg from "./pages/VisProgPage/VisProg.tsx";
>>>>>>> origin/dev
function App(){ function App(){
@@ -27,6 +31,7 @@ function App(){
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/template" element={<TemplatePage />} /> <Route path="/template" element={<TemplatePage />} />
<Route path="/editor" element={<VisProg />} />
<Route path="/robot" element={<Robot />} /> <Route path="/robot" element={<Robot />} />
<Route <Route
path="/ConnectedRobots" path="/ConnectedRobots"

View File

@@ -12,6 +12,7 @@ function Home() {
</div> </div>
<div className={styles.links}> <div className={styles.links}>
<Link to={"/robot"}>Robot Interaction </Link> <Link to={"/robot"}>Robot Interaction </Link>
<Link to={"/editor"}>Editor </Link>
<Link to={"/template"}>Template </Link> <Link to={"/template"}>Template </Link>
<Link to={"/ConnectedRobots"}>Connected Robots </Link> <Link to={"/ConnectedRobots"}>Connected Robots </Link>
</div> </div>

View File

@@ -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);
}

View File

@@ -1,3 +1,4 @@
<<<<<<< HEAD
import VisProgUI from "../../visualProgrammingUI/VisProgUI.tsx"; import VisProgUI from "../../visualProgrammingUI/VisProgUI.tsx";
function VisProgPage() { function VisProgPage() {
@@ -6,6 +7,144 @@ function VisProgPage() {
<VisProgUI /> <VisProgUI />
</> </>
) )
=======
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 (
<div className={styles.outerEditorContainer}>
<div className={styles.innerEditorContainer}>
<ReactFlow
nodes={nodes}
edges={edges}
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
nodeTypes={NODE_TYPES}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
onConnect={onConnect}
snapToGrid
fitView
proOptions={{hideAttribution: true}}
>
<Panel position="top-center" className={styles.dndPanel}>
<DndToolbar/> {/* contains the drag and drop panel for nodes */}
</Panel>
<Controls/>
<Background/>
</ReactFlow>
</div>
</div>
);
};
/**
* 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 (
<ReactFlowProvider>
<VisProgUI/>
</ReactFlowProvider>
);
}
/**
* houses the entire page, so also UI elements
* that are not a part of the Visual Programming UI
* @constructor
*/
function VisProgPage() {
return (
<>
<VisualProgrammingUI/>
</>
)
>>>>>>> origin/dev
} }
export default VisProgPage export default VisProgPage

View File

@@ -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<FlowState>((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;

View File

@@ -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;
};

View File

@@ -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<HTMLDivElement>(null);
const [position, setPosition] = useState<XYPosition>({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 (
<div className={className} ref={draggableRef}>
{children}
</div>
);
}
// 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 (
<div className={`flex-col gap-lg padding-md ${styles.innerDndPanel}`}>
<div className="description">
You can drag these nodes to the pane to create new nodes.
</div>
<div className={`flex-row gap-lg ${styles.dndNodeContainer}`}>
<DraggableNode className={styles.draggableNodePhase} nodeType="phase" onDrop={handleNodeDrop}>
phase Node
</DraggableNode>
<DraggableNode className={styles.draggableNodeNorm} nodeType="norm" onDrop={handleNodeDrop}>
norm Node
</DraggableNode>
</div>
</div>
);
}

View File

@@ -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 (
<NodeToolbar>
<button className="Node-toolbar__deletebutton" onClick={deleteParentNode} disabled={!allowDelete}>delete</button>
</NodeToolbar>);
}
// Definitions of Nodes
// Start Node definition:
type StartNodeProps = {
id: string;
data: startNodeData;
};
export const StartNode = ({id, data}: StartNodeProps) => {
return (
<>
<Toolbar nodeId={id} allowDelete={false}/>
<div className={styles.defaultNodeStart}>
<div> data test {data.label} </div>
<Handle type="source" position={Position.Right} id="start"/>
</div>
</>
);
};
// End node definition:
type EndNodeProps = {
id: string;
data: endNodeData;
};
export const EndNode = ({id, data}: EndNodeProps) => {
return (
<>
<Toolbar nodeId={id} allowDelete={false}/>
<div className={styles.defaultNodeEnd}>
<div> {data.label} </div>
<Handle type="target" position={Position.Left} id="end"/>
</div>
</>
);
};
// Phase node definition:
type PhaseNodeProps = {
id: string;
data: phaseNodeData;
};
export const PhaseNode = ({id, data}: PhaseNodeProps) => {
return (
<>
<Toolbar nodeId={id} allowDelete={true}/>
<div className={styles.defaultNodePhase}>
<div> phase {data.number} {data.label} </div>
<Handle type="target" position={Position.Left} id="target"/>
<Handle type="target" position={Position.Bottom} id="norms"/>
<Handle type="source" position={Position.Right} id="source"/>
</div>
</>
);
};
// Norm node definition:
type NormNodeProps = {
id: string;
data: normNodeData;
};
export const NormNode = ({id, data}: NormNodeProps) => {
return (
<>
<Toolbar nodeId={id} allowDelete={true}/>
<div className={styles.defaultNodeNorm}>
<div> Norm {data.label} </div>
<Handle type="source" position={Position.Right} id="NormSource"/>
</div>
</>
);
};

View File

@@ -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'
});
});
});
});

View File

@@ -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");
})
});

84
test/setupFlowTests.ts Normal file
View File

@@ -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
});
});