diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 4b8944c..8208a70 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -10,12 +10,13 @@ import '@xyflow/react/dist/style.css'; import {useShallow} from 'zustand/react/shallow'; import { - StartNode, - EndNode, - PhaseNode, - NormNode + StartNodeComponent, + EndNodeComponent, + PhaseNodeComponent, + NormNodeComponent } from './visualProgrammingUI/components/NodeDefinitions.tsx'; import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx'; +import graphReducer from "./visualProgrammingUI/GraphReducer.ts"; import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; import styles from './VisProg.module.css' @@ -26,10 +27,10 @@ import styles from './VisProg.module.css' * contains the types of all nodes that are available in the editor */ const NODE_TYPES = { - start: StartNode, - end: EndNode, - phase: PhaseNode, - norm: NormNode + start: StartNodeComponent, + end: EndNodeComponent, + phase: PhaseNodeComponent, + norm: NormNodeComponent }; /** @@ -123,6 +124,12 @@ function VisualProgrammingUI() { ); } +// currently outputs the prepared program to the console +function runProgram() { + const program = graphReducer(); + console.log(program); +} + /** * houses the entire page, so also UI elements * that are not a part of the Visual Programming UI @@ -132,6 +139,7 @@ function VisProgPage() { return ( <> + ) } diff --git a/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts new file mode 100644 index 0000000..138eb82 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts @@ -0,0 +1,188 @@ +import { + type Edge, + getIncomers, + getOutgoers +} from '@xyflow/react'; +import useFlowStore from "./VisProgStores.tsx"; +import type { + BehaviorProgram, + GoalData, + GoalReducer, + GraphPreprocessor, + NormData, + NormReducer, + OrderedPhases, + Phase, + PhaseReducer, + PreparedGraph, + PreparedPhase +} from "./GraphReducerTypes.ts"; +import type { + AppNode, + GoalNode, + NormNode, + PhaseNode +} from "./VisProgTypes.tsx"; + +/** + * Reduces the current graph inside the visual programming editor into a BehaviorProgram + * + * @param {GraphPreprocessor} graphPreprocessor + * @param {PhaseReducer} phaseReducer + * @param {NormReducer} normReducer + * @param {GoalReducer} goalReducer + * @returns {BehaviorProgram} + */ +export default function graphReducer( + graphPreprocessor: GraphPreprocessor = defaultGraphPreprocessor, + phaseReducer: PhaseReducer = defaultPhaseReducer, + normReducer: NormReducer = defaultNormReducer, + goalReducer: GoalReducer = defaultGoalReducer +) : BehaviorProgram { + const nodes: AppNode[] = useFlowStore.getState().nodes; + const edges: Edge[] = useFlowStore.getState().edges; + const preparedGraph: PreparedGraph = graphPreprocessor(nodes, edges); + + return preparedGraph.map((preparedPhase: PreparedPhase) : Phase => + phaseReducer( + preparedPhase, + normReducer, + goalReducer + )); +}; + +/** + * reduces a single preparedPhase to a Phase object + * the Phase object describes a single phase in a BehaviorProgram + * + * @param {PreparedPhase} phase + * @param {NormReducer} normReducer + * @param {GoalReducer} goalReducer + * @returns {Phase} + */ +export function defaultPhaseReducer( + phase: PreparedPhase, + normReducer: NormReducer = defaultNormReducer, + goalReducer: GoalReducer = defaultGoalReducer +) : Phase { + return { + id: phase.phaseNode.id, + name: phase.phaseNode.data.label, + nextPhaseId: phase.nextPhaseId, + phaseData: { + norms: phase.connectedNorms.map(normReducer), + goals: phase.connectedGoals.map(goalReducer) + } + } +} + +/** + * the default implementation of the goalNode reducer function + * + * @param {GoalNode} node + * @returns {GoalData} + */ +function defaultGoalReducer(node: GoalNode) : GoalData { + return { + id: node.id, + name: node.data.label, + value: node.data.value + } +} + +/** + * the default implementation of the normNode reducer function + * + * @param {NormNode} node + * @returns {NormData} + */ +function defaultNormReducer(node: NormNode) :NormData { + return { + id: node.id, + name: node.data.label, + value: node.data.value + } +} + +// Graph preprocessing functions: + +/** + * Preprocesses the provide state of the behavior editor graph, preparing it for further processing in + * the graphReducer function + * + * @param {AppNode[]} nodes + * @param {Edge[]} edges + * @returns {PreparedGraph} + */ +export function defaultGraphPreprocessor(nodes: AppNode[], edges: Edge[]) : PreparedGraph { + const norms : NormNode[] = nodes.filter((node) => node.type === 'norm') as NormNode[]; + const goals : GoalNode[] = nodes.filter((node) => node.type === 'goal') as GoalNode[]; + const orderedPhases : OrderedPhases = orderPhases(nodes, edges); + + return orderedPhases.phaseNodes.map((phase: PhaseNode) : PreparedPhase => { + const nextPhase = orderedPhases.connections.get(phase.id); + return { + phaseNode: phase, + nextPhaseId: nextPhase as string, + connectedNorms: getIncomers({id: phase.id}, norms,edges), + connectedGoals: getIncomers({id: phase.id}, goals,edges) + }; + }); +} + +/** + * orderPhases takes the state of the graph created by the editor and turns it into an OrderedPhases object. + * + * @param {AppNode[]} nodes + * @param {Edge[]} edges + * @returns {OrderedPhases} + */ +export function orderPhases(nodes: AppNode[],edges: Edge[]) : OrderedPhases { + // find the first Phase node + const phaseNodes : PhaseNode[] = nodes.filter((node) => node.type === 'phase') as PhaseNode[]; + const startNodeIndex = nodes.findIndex((node : AppNode):boolean => {return (node.type === 'start');}); + const firstPhaseNode = getOutgoers({ id: nodes[startNodeIndex].id },phaseNodes,edges); + + // recursively adds the phase nodes to a list in the order they are connected in the graph + const nextPhase = ( + currentIndex: number, + { phaseNodes: phases, connections: connections} : OrderedPhases + ) : OrderedPhases => { + // get the current phase and the next phases; + const currentPhase = phases[currentIndex]; + const nextPhaseNodes = getOutgoers(currentPhase,phaseNodes,edges); + const nextNodes = getOutgoers(currentPhase,nodes, edges); + + // handles adding of the next phase to the chain, and error handle if an invalid state is received + if (nextPhaseNodes.length === 1 && nextNodes.length === 1) { + connections.set(currentPhase.id, nextPhaseNodes[0].id); + return nextPhase(phases.push(nextPhaseNodes[0] as PhaseNode) - 1, {phaseNodes: phases, connections: connections}); + } else { + // handle erroneous states + if (nextNodes.length === 0){ + throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" doesn't have any outgoing connections`); + } else { + if (nextNodes.length > 1) { + throw new Error(`| INVALID PROGRAM | the source handle of "${currentPhase.id}" connects to too many targets`); + } else { + if (nextNodes[0].type === "end"){ + connections.set(currentPhase.id, "end"); + // returns the final output of the function + return { phaseNodes: phases, connections: connections}; + } else { + throw new Error(`| INVALID PROGRAM | the node "${nextNodes[0].id}" that "${currentPhase.id}" connects to is not a phase or end node`); + } + } + } + } + } + // initializes the Map describing the connections between phase nodes + // we need this Map to make sure we preserve this information, + // so we don't need to do checks on the entire set of edges in further stages of the reduction algorithm + const connections : Map = new Map(); + + // returns an empty list if no phase nodes are present, otherwise returns an ordered list of phaseNodes + if (firstPhaseNode.length > 0) { + return nextPhase(0, {phaseNodes: [firstPhaseNode[0] as PhaseNode], connections: connections}) + } else { return {phaseNodes: [], connections: connections} } +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts b/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts new file mode 100644 index 0000000..9151b56 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts @@ -0,0 +1,106 @@ +import type {Edge} from "@xyflow/react"; +import type {AppNode, GoalNode, NormNode, PhaseNode} from "./VisProgTypes.tsx"; + + +/** + * defines how a norm is represented in the simplified behavior program + */ +export type NormData = { + id: string; + name: string; + value: string; +}; + +/** + * defines how a goal is represented in the simplified behavior program + */ +export type GoalData = { + id: string; + name: string; + value: string; +}; + +/** + * definition of a PhaseData object, it contains all phaseData that is relevant + * for further processing and execution of a phase. + */ +export type PhaseData = { + norms: NormData[]; + goals: GoalData[]; +}; + +/** + * Describes a single phase within the simplified representation of a behavior program, + * + * Contains: + * - the id of the described phase, + * - the name of the described phase, + * - the id of the next phase in the user defined behavior program + * - the data property of the described phase node + * + * @NOTE at the moment the type definitions do not support branching programs, + * if branching of phases is to be supported in the future, the type definition for Phase has to be updated + */ +export type Phase = { + id: string; + name: string; + nextPhaseId: string; + phaseData: PhaseData; +}; + +/** + * Describes a simplified behavior program as a list of Phase objects + */ +export type BehaviorProgram = Phase[]; + + + +export type NormReducer = (node: NormNode) => NormData; +export type GoalReducer = (node: GoalNode) => GoalData; +export type PhaseReducer = ( + preparedPhase: PreparedPhase, + normReducer: NormReducer, + goalReducer: GoalReducer +) => Phase; + +/** + * contains: + * + * - list of phases, sorted based on position in chain between the start and end node + * - a dictionary containing all outgoing connections, + * to other phase or end nodes, for each phase node uses the id of the source node as key + * and the id of the target node as value + * + */ +export type OrderedPhases = { + phaseNodes: PhaseNode[]; + connections: Map; +}; + +/** + * A single prepared phase, + * contains: + * - the described phaseNode, + * - the id of the next phaseNode or "end" for the end node + * - a list of the normNodes that are connected to the described phase + * - a list of the goalNodes that are connected to the described phase + */ +export type PreparedPhase = { + phaseNode: PhaseNode; + nextPhaseId: string; + connectedNorms: NormNode[]; + connectedGoals: GoalNode[]; +}; + +/** + * a list of PreparedPhase objects, + * describes the preprocessed state of a program, + * before the contents of the node + */ +export type PreparedGraph = PreparedPhase[]; + +export type GraphPreprocessor = (nodes: AppNode[], edges: Edge[]) => PreparedGraph; + + + + diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 10d0142..e27fb28 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -20,7 +20,7 @@ const initialNodes = [ data: {label: 'start'} }, { - id: 'genericPhase', + id: 'phase-1', type: 'phase', position: {x: 0, y: 150}, data: {label: 'Generic Phase', number: 1}, @@ -38,9 +38,14 @@ const initialNodes = [ */ const initialEdges = [ { - id: 'start-end', + id: 'start-phase-1', source: 'start', - target: 'end' + target: 'phase-1', + }, + { + id: 'phase-1-end', + source: 'phase-1', + target: 'end', } ]; diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx index f5ede86..378f9be 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx @@ -7,12 +7,24 @@ import { type OnReconnect, } from '@xyflow/react'; + +type defaultNodeData = { + label: string; +}; + +export type StartNode = Node; +export type EndNode = Node; +export type GoalNode = Node; +export type NormNode = Node; +export type PhaseNode = Node; + + /** * 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; +export type AppNode = Node | StartNode | EndNode | NormNode | GoalNode | PhaseNode; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 383f72c..c9e1496 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -11,6 +11,8 @@ import { } from 'react'; import useFlowStore from "../VisProgStores.tsx"; import styles from "../../VisProg.module.css" +import type {AppNode, PhaseNode, NormNode} from "../VisProgTypes.tsx"; + /** @@ -68,28 +70,45 @@ function DraggableNode({className, children, nodeType, onDrop}: DraggableNodePro // 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 nds : AppNode[] = useFlowStore.getState().nodes; const newNode = () => { switch (nodeType) { case "phase": { - const phaseNumber = nds.filter((node) => node.type === 'phase').length; - return { + const phaseNodes= nds.filter((node) => node.type === 'phase'); + let phaseNumber; + if (phaseNodes.length > 0) { + const finalPhaseId : number = +(phaseNodes[phaseNodes.length - 1].id.split('-')[1]); + phaseNumber = finalPhaseId + 1; + } else { + phaseNumber = 1; + } + const phaseNode : PhaseNode = { id: `phase-${phaseNumber}`, type: nodeType, position, data: {label: 'new', number: phaseNumber}, - }; + } + return phaseNode; } case "norm": { - const normNumber = nds.filter((node) => node.type === 'norm').length; - return { + const normNodes= nds.filter((node) => node.type === 'norm'); + let normNumber + if (normNodes.length > 0) { + const finalNormId : number = +(normNodes[normNodes.length - 1].id.split('-')[1]); + normNumber = finalNormId + 1; + } else { + normNumber = 1; + } + + const normNode : NormNode = { id: `norm-${normNumber}`, type: nodeType, position, - data: {label: `new norm node`}, - }; + data: {label: `new norm node`, value: "Pepper should be formal"}, + } + return normNode; } default: { throw new Error(`Node ${nodeType} not found`); diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx index 63765b5..f74dd2b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/NodeDefinitions.tsx @@ -1,32 +1,32 @@ -import {Handle, NodeToolbar, Position} from '@xyflow/react'; +import {Handle, type NodeProps, NodeToolbar, Position} from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import styles from '../../VisProg.module.css'; import useFlowStore from "../VisProgStores.tsx"; +import type { + StartNode, + EndNode, + PhaseNode, + NormNode +} from "../VisProgTypes.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; }; + +/** + * Node Toolbar definition: + * handles: node deleting functionality + * can be added to any custom node component as a React component + * + * @param {string} nodeId + * @param {boolean} allowDelete + * @returns {React.JSX.Element} + * @constructor + */ export function Toolbar({nodeId, allowDelete}: ToolbarProps) { const {deleteNode} = useFlowStore(); @@ -42,14 +42,15 @@ export function Toolbar({nodeId, allowDelete}: ToolbarProps) { // Definitions of Nodes -// Start Node definition: - -type StartNodeProps = { - id: string; - data: startNodeData; -}; - -export const StartNode = ({id, data}: StartNodeProps) => { +/** + * Start Node definition: + * + * @param {string} id + * @param {defaultNodeData} data + * @returns {React.JSX.Element} + * @constructor + */ +export const StartNodeComponent = ({id, data}: NodeProps) => { return ( <> @@ -62,14 +63,15 @@ export const StartNode = ({id, data}: StartNodeProps) => { }; -// End node definition: - -type EndNodeProps = { - id: string; - data: endNodeData; -}; - -export const EndNode = ({id, data}: EndNodeProps) => { +/** + * End node definition: + * + * @param {string} id + * @param {defaultNodeData} data + * @returns {React.JSX.Element} + * @constructor + */ +export const EndNodeComponent = ({id, data}: NodeProps) => { return ( <> @@ -82,14 +84,15 @@ export const EndNode = ({id, data}: EndNodeProps) => { }; -// Phase node definition: - -type PhaseNodeProps = { - id: string; - data: phaseNodeData; -}; - -export const PhaseNode = ({id, data}: PhaseNodeProps) => { +/** + * Phase node definition: + * + * @param {string} id + * @param {defaultNodeData & {number: number}} data + * @returns {React.JSX.Element} + * @constructor + */ +export const PhaseNodeComponent = ({id, data}: NodeProps) => { return ( <> @@ -104,14 +107,15 @@ export const PhaseNode = ({id, data}: PhaseNodeProps) => { }; -// Norm node definition: - -type NormNodeProps = { - id: string; - data: normNodeData; -}; - -export const NormNode = ({id, data}: NormNodeProps) => { +/** + * Norm node definition: + * + * @param {string} id + * @param {defaultNodeData & {value: string}} data + * @returns {React.JSX.Element} + * @constructor + */ +export const NormNodeComponent = ({id, data}: NodeProps) => { return ( <> diff --git a/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts new file mode 100644 index 0000000..a907a58 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/GraphReducer.test.ts @@ -0,0 +1,986 @@ +import type {Edge} from "@xyflow/react"; +import graphReducer, { + defaultGraphPreprocessor, defaultPhaseReducer, + orderPhases +} from "../../../../src/pages/VisProgPage/visualProgrammingUI/GraphReducer.ts"; +import type {PreparedPhase} from "../../../../src/pages/VisProgPage/visualProgrammingUI/GraphReducerTypes.ts"; +import useFlowStore from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; +import type {AppNode} from "../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgTypes.tsx"; + +// sets of default values for nodes and edges to be used for test cases +type FlowState = { + name: string; + nodes: AppNode[]; + edges: Edge[]; +}; + +// predefined graphs for testing: +const onlyOnePhase : FlowState = { + name: "onlyOnePhase", + nodes: [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'phase-1', + 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'} + } + ], + edges:[ + { + id: 'start-phase-1', + source: 'start', + target: 'phase-1', + }, + { + id: 'phase-1-end', + source: 'phase-1', + target: 'end', + } + ] +}; +const onlyThreePhases : FlowState = { + name: "onlyThreePhases", + nodes: [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'phase-3', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 3}, + }, + { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } + ], + edges:[ + { + id: 'start-phase-1', + source: 'start', + target: 'phase-1', + }, + { + id: 'phase-1-phase-2', + source: 'phase-1', + target: 'phase-2', + }, + { + id: 'phase-2-phase-3', + source: 'phase-2', + target: 'phase-3', + }, + { + id: 'phase-3-end', + source: 'phase-3', + target: 'end', + } + ] +}; +const onlySingleEdgeNorms : FlowState = { + name: "onlySingleEdgeNorms", + nodes: [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'norm-1', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }, + { + id: 'norm-2', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }, + { + id: 'phase-3', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 3}, + }, + { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } + ], + edges:[ + { + id: 'start-phase-1', + source: 'start', + target: 'phase-1', + }, + { + id: 'norm-1-phase-2', + source: 'norm-1', + target: 'phase-2', + }, + { + id: 'phase-1-phase-2', + source: 'phase-1', + target: 'phase-2', + }, + { + id: 'phase-2-phase-3', + source: 'phase-2', + target: 'phase-3', + }, + { + id: 'norm-2-phase-3', + source: 'norm-2', + target: 'phase-3', + }, + { + id: 'phase-3-end', + source: 'phase-3', + target: 'end', + } + ] +}; +const multiEdgeNorms : FlowState = { + name: "multiEdgeNorms", + nodes: [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'norm-1', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }, + { + id: 'norm-2', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }, + { + id: 'phase-3', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 3}, + }, + { + id: 'norm-3', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }, + { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } + ], + edges:[ + { + id: 'start-phase-1', + source: 'start', + target: 'phase-1', + }, + { + id: 'norm-1-phase-2', + source: 'norm-1', + target: 'phase-2', + }, + { + id: 'norm-1-phase-3', + source: 'norm-1', + target: 'phase-3', + }, + { + id: 'phase-1-phase-2', + source: 'phase-1', + target: 'phase-2', + }, + { + id: 'norm-3-phase-1', + source: 'norm-3', + target: 'phase-1', + }, + { + id: 'phase-2-phase-3', + source: 'phase-2', + target: 'phase-3', + }, + { + id: 'norm-2-phase-3', + source: 'norm-2', + target: 'phase-3', + }, + { + id: 'norm-2-phase-2', + source: 'norm-2', + target: 'phase-2', + }, + { + id: 'phase-3-end', + source: 'phase-3', + target: 'end', + } + ] +}; +const onlyStartEnd : FlowState = { + name: "onlyStartEnd", + nodes: [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } + ], + edges:[ + { + id: 'start-end', + source: 'start', + target: 'end', + }, + ] +}; + +// states that contain invalid programs for testing if correct errors are thrown: +const phaseConnectsToInvalidNodeType : FlowState = { + name: "phaseConnectsToInvalidNodeType", + nodes: [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'default-1', + type: 'default', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm'}, + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } + ], + edges:[ + { + id: 'start-phase-1', + source: 'start', + target: 'phase-1', + }, + { + id: 'phase-1-default-1', + source: 'phase-1', + target: 'default-1', + }, + ] +}; +const phaseHasNoOutgoingConnections : FlowState = { + name: "phaseHasNoOutgoingConnections", + nodes: [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } + ], + edges:[ + { + id: 'start-phase-1', + source: 'start', + target: 'phase-1', + }, + ] +}; +const phaseHasTooManyOutgoingConnections : FlowState = { + name: "phaseHasTooManyOutgoingConnections", + nodes: [ + { + id: 'start', + type: 'start', + position: {x: 0, y: 0}, + data: {label: 'start'} + }, + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + { + id: 'end', + type: 'end', + position: {x: 0, y: 300}, + data: {label: 'End'} + } + ], + edges:[ + { + id: 'start-phase-1', + source: 'start', + target: 'phase-1', + }, + { + id: 'phase-1-phase-2', + source: 'phase-1', + target: 'phase-2', + }, + { + id: 'phase-1-end', + source: 'phase-1', + target: 'end', + }, + { + id: 'phase-2-end', + source: 'phase-2', + target: 'end', + }, + ] +}; + +describe('Graph Reducer Tests', () => { + describe('defaultGraphPreprocessor', () => { + test.each([ + { + state: onlyOnePhase, + expected: [ + { + phaseNode: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + nextPhaseId: 'end', + connectedNorms: [], + connectedGoals: [], + }] + }, + { + state: onlyThreePhases, + expected: [ + { + phaseNode: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + nextPhaseId: 'phase-2', + connectedNorms: [], + connectedGoals: [], + }, + { + phaseNode: { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + nextPhaseId: 'phase-3', + connectedNorms: [], + connectedGoals: [], + }, + { + phaseNode: { + id: 'phase-3', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 3}, + }, + nextPhaseId: 'end', + connectedNorms: [], + connectedGoals: [], + }] + }, + { + state: onlySingleEdgeNorms, + expected: [ + { + phaseNode: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + nextPhaseId: 'phase-2', + connectedNorms: [], + connectedGoals: [], + }, + { + phaseNode: { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + nextPhaseId: 'phase-3', + connectedNorms: [{ + id: 'norm-1', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }], + connectedGoals: [], + }, + { + phaseNode: { + id: 'phase-3', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 3}, + }, + nextPhaseId: 'end', + connectedNorms: [{ + id: 'norm-2', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }], + connectedGoals: [], + }] + }, + { + state: multiEdgeNorms, + expected: [ + { + phaseNode: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + nextPhaseId: 'phase-2', + connectedNorms: [{ + id: 'norm-3', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }], + connectedGoals: [], + }, + { + phaseNode: { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + nextPhaseId: 'phase-3', + connectedNorms: [{ + id: 'norm-1', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }, + { + id: 'norm-2', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }], + connectedGoals: [], + }, + { + phaseNode: { + id: 'phase-3', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 3}, + }, + nextPhaseId: 'end', + connectedNorms: [{ + id: 'norm-1', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }, + { + id: 'norm-2', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }], + connectedGoals: [], + }] + }, + { + state: onlyStartEnd, + expected: [], + } + ])(`tests state: $state.name`, ({state, expected}) => { + const output = defaultGraphPreprocessor(state.nodes, state.edges); + expect(output).toEqual(expected); + }); + }); + describe("orderPhases", () => { + test.each([ + { + state: onlyOnePhase, + expected: { + phaseNodes: [{ + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }], + connections: new Map([["phase-1","end"]]) + } + }, + { + state: onlyThreePhases, + expected: { + phaseNodes: [ + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + { + id: 'phase-3', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 3}, + }], + connections: new Map([ + ["phase-1","phase-2"], + ["phase-2","phase-3"], + ["phase-3","end"] + ]) + } + }, + { + state: onlySingleEdgeNorms, + expected: { + phaseNodes: [ + { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + { + id: 'phase-2', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 2}, + }, + { + id: 'phase-3', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 3}, + }], + connections: new Map([ + ["phase-1","phase-2"], + ["phase-2","phase-3"], + ["phase-3","end"] + ]) + } + }, + { + state: onlyStartEnd, + expected: { + phaseNodes: [], + connections: new Map() + } + } + ])(`tests state: $state.name`, ({state, expected}) => { + const output = orderPhases(state.nodes, state.edges); + expect(output.phaseNodes).toEqual(expected.phaseNodes); + expect(output.connections).toEqual(expected.connections); + }); + test.each([ + { + state: phaseConnectsToInvalidNodeType, + expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node') + }, + { + state: phaseHasNoOutgoingConnections, + expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections') + }, + { + state: phaseHasTooManyOutgoingConnections, + expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets') + } + ])(`tests erroneous state: $state.name`, ({state, expected}) => { + const testForError = () => { + orderPhases(state.nodes, state.edges); + }; + expect(testForError).toThrow(expected); + }) + }) + describe("defaultPhaseReducer", () => { + test("phaseReducer handles empty norms and goals without failing", () => { + const input : PreparedPhase = { + phaseNode: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + nextPhaseId: 'end', + connectedNorms: [], + connectedGoals: [], + } + const output = defaultPhaseReducer(input); + expect(output).toEqual({ + id: 'phase-1', + name: 'Generic Phase', + nextPhaseId: 'end', + phaseData: { + norms: [], + goals: [] + } + }); + }); + test("defaultNormReducer reduces norms correctly", () => { + const input : PreparedPhase = { + phaseNode: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + nextPhaseId: 'end', + connectedNorms: [{ + id: 'norm-1', + type: 'norm', + position: {x: 0, y: 150}, + data: {label: 'Generic Norm', value: "generic"}, + }], + connectedGoals: [], + } + const output = defaultPhaseReducer(input); + expect(output).toEqual({ + id: 'phase-1', + name: 'Generic Phase', + nextPhaseId: 'end', + phaseData: { + norms: [{ + id: 'norm-1', + name: 'Generic Norm', + value: "generic" + }], + goals: [] + } + }); + }); + test("defaultGoalReducer reduces goals correctly", () => { + const input : PreparedPhase = { + phaseNode: { + id: 'phase-1', + type: 'phase', + position: {x: 0, y: 150}, + data: {label: 'Generic Phase', number: 1}, + }, + nextPhaseId: 'end', + connectedNorms: [], + connectedGoals: [{ + id: 'goal-1', + type: 'goal', + position: {x: 0, y: 150}, + data: {label: 'Generic Goal', value: "generic"}, + }], + } + const output = defaultPhaseReducer(input); + expect(output).toEqual({ + id: 'phase-1', + name: 'Generic Phase', + nextPhaseId: 'end', + phaseData: { + norms: [], + goals: [{ + id: 'goal-1', + name: 'Generic Goal', + value: "generic" + }] + } + }); + }); + }) + describe("GraphReducer", () => { + test.each([ + { + state: onlyOnePhase, + expected: [ + { + id: 'phase-1', + name: 'Generic Phase', + nextPhaseId: 'end', + phaseData: { + norms: [], + goals: [] + } + }] + }, + { + state: onlyThreePhases, + expected: [ + { + id: 'phase-1', + name: 'Generic Phase', + nextPhaseId: 'phase-2', + phaseData: { + norms: [], + goals: [] + } + }, + { + id: 'phase-2', + name: 'Generic Phase', + nextPhaseId: 'phase-3', + phaseData: { + norms: [], + goals: [] + } + }, + { + id: 'phase-3', + name: 'Generic Phase', + nextPhaseId: 'end', + phaseData: { + norms: [], + goals: [] + } + }] + }, + { + state: onlySingleEdgeNorms, + expected: [ + { + id: 'phase-1', + name: 'Generic Phase', + nextPhaseId: 'phase-2', + phaseData: { + norms: [], + goals: [] + } + }, + { + id: 'phase-2', + name: 'Generic Phase', + nextPhaseId: 'phase-3', + phaseData: { + norms: [ + { + id: 'norm-1', + name: 'Generic Norm', + value: "generic" + } + ], + goals: [] + } + }, + { + id: 'phase-3', + name: 'Generic Phase', + nextPhaseId: 'end', + phaseData: { + norms: [{ + id: 'norm-2', + name: 'Generic Norm', + value: "generic" + }], + goals: [] + } + }] + }, + { + state: multiEdgeNorms, + expected: [ + { + id: 'phase-1', + name: 'Generic Phase', + nextPhaseId: 'phase-2', + phaseData: { + norms: [{ + id: 'norm-3', + name: 'Generic Norm', + value: "generic" + }], + goals: [] + } + }, + { + id: 'phase-2', + name: 'Generic Phase', + nextPhaseId: 'phase-3', + phaseData: { + norms: [ + { + id: 'norm-1', + name: 'Generic Norm', + value: "generic" + }, + { + id: 'norm-2', + name: 'Generic Norm', + value: "generic" + } + ], + goals: [] + } + }, + { + id: 'phase-3', + name: 'Generic Phase', + nextPhaseId: 'end', + phaseData: { + norms: [{ + id: 'norm-1', + name: 'Generic Norm', + value: "generic" + }, + { + id: 'norm-2', + name: 'Generic Norm', + value: "generic" + }], + goals: [] + } + }] + }, + { + state: onlyStartEnd, + expected: [], + } + ])("`tests state: $state.name`", ({state, expected}) => { + useFlowStore.setState({nodes: state.nodes, edges: state.edges}); + const output = graphReducer(); // uses default reducers + expect(output).toEqual(expected); + }) + // we run the test for correct error handling for the entire graph reducer as well, + // to make sure no errors occur before we intend to handle the errors ourselves + test.each([ + { + state: phaseConnectsToInvalidNodeType, + expected: new Error('| INVALID PROGRAM | the node "default-1" that "phase-1" connects to is not a phase or end node') + }, + { + state: phaseHasNoOutgoingConnections, + expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" doesn\'t have any outgoing connections') + }, + { + state: phaseHasTooManyOutgoingConnections, + expected: new Error('| INVALID PROGRAM | the source handle of "phase-1" connects to too many targets') + } + ])(`tests erroneous state: $state.name`, ({state, expected}) => { + useFlowStore.setState({nodes: state.nodes, edges: state.edges}); + const testForError = () => { + graphReducer(); + }; + expect(testForError).toThrow(expected); + }) + }) +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx index ae9b88c..a92adb3 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx @@ -24,8 +24,8 @@ describe('Drag-and-Drop sidebar', () => { }) 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`); + expect(updatedState.nodes[0].id).toBe(`${nodeType}-1`); + expect(updatedState.nodes[1].id).toBe(`${nodeType}-2`); }); 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");