Merge branch 'demo' into refactor/nodes-match-functionality
This commit is contained in:
@@ -9,8 +9,10 @@ import {
|
|||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import {useEffect} from "react";
|
import {useEffect} from "react";
|
||||||
import {useShallow} from 'zustand/react/shallow';
|
import {useShallow} from 'zustand/react/shallow';
|
||||||
|
import orderPhaseNodeArray from "../../utils/orderPhaseNodes.ts";
|
||||||
import useProgramStore from "../../utils/programStore.ts";
|
import useProgramStore from "../../utils/programStore.ts";
|
||||||
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
||||||
|
import type {PhaseNode} from "./visualProgrammingUI/nodes/PhaseNode.tsx";
|
||||||
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
||||||
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
||||||
import styles from './VisProg.module.css'
|
import styles from './VisProg.module.css'
|
||||||
@@ -168,14 +170,15 @@ function runProgram() {
|
|||||||
*/
|
*/
|
||||||
function graphReducer() {
|
function graphReducer() {
|
||||||
const { nodes } = useFlowStore.getState();
|
const { nodes } = useFlowStore.getState();
|
||||||
return nodes
|
return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode [])
|
||||||
.filter((n) => n.type == 'phase')
|
|
||||||
.map((n) => {
|
.map((n) => {
|
||||||
const reducer = NodeReduces['phase'];
|
const reducer = NodeReduces['phase'];
|
||||||
return reducer(n, nodes)
|
return reducer(n, nodes)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* houses the entire page, so also UI elements
|
* houses the entire page, so also UI elements
|
||||||
* that are not a part of the Visual Programming UI
|
* that are not a part of the Visual Programming UI
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ export const UndoRedo = (
|
|||||||
* @param {BaseFlowState} state - the current state of the editor
|
* @param {BaseFlowState} state - the current state of the editor
|
||||||
* @returns {FlowSnapshot} - returns a snapshot of the current editor state
|
* @returns {FlowSnapshot} - returns a snapshot of the current editor state
|
||||||
*/
|
*/
|
||||||
const getSnapshot = (state : BaseFlowState) : FlowSnapshot => ({
|
const getSnapshot = (state : BaseFlowState) : FlowSnapshot => (structuredClone({
|
||||||
nodes: state.nodes,
|
nodes: state.nodes,
|
||||||
edges: state.edges
|
edges: state.edges
|
||||||
});
|
}));
|
||||||
|
|
||||||
const initialState = config(set, get, api);
|
const initialState = config(set, get, api);
|
||||||
|
|
||||||
|
|||||||
@@ -46,15 +46,12 @@ function createNode(id: string, type: string, position: XYPosition, data: Record
|
|||||||
// Start and End don't need to apply the UUID, since they are technically never compiled into a program.
|
// Start and End don't need to apply the UUID, since they are technically never compiled into a program.
|
||||||
const startNode = createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false)
|
const startNode = createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false)
|
||||||
const endNode = createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false)
|
const endNode = createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false)
|
||||||
const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:200, y:100}, {label: "Phase 1", children : []})
|
const initialPhaseNode = createNode(crypto.randomUUID(), 'phase', {x:200, y:100}, {label: "Phase 1", children : [], isFirstPhase: false, nextPhaseId: null})
|
||||||
|
|
||||||
const initialNodes : Node[] = [startNode, endNode, initialPhaseNode,];
|
const initialNodes : Node[] = [startNode, endNode, initialPhaseNode,];
|
||||||
|
|
||||||
// * Initial edges * /
|
// * Initial edges * /
|
||||||
const initialEdges: Edge[] = [
|
const initialEdges: Edge[] = []; // no initial edges as edge connect events don't fire when using initial edges
|
||||||
{ id: 'start-phase-1', source: startNode.id, target: initialPhaseNode.id },
|
|
||||||
{ id: 'phase-1-end', source: initialPhaseNode.id, target: endNode.id },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
Handle,
|
||||||
|
useNodeConnections,
|
||||||
|
type HandleType,
|
||||||
|
type Position
|
||||||
|
} from '@xyflow/react';
|
||||||
|
|
||||||
|
|
||||||
|
const LimitedConnectionCountHandle = (props: {
|
||||||
|
node_id: string,
|
||||||
|
type: HandleType,
|
||||||
|
position: Position,
|
||||||
|
connection_count: number,
|
||||||
|
id?: string
|
||||||
|
}) => {
|
||||||
|
const connections = useNodeConnections({
|
||||||
|
id: props.node_id,
|
||||||
|
handleType: props.type,
|
||||||
|
handleId: props.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Handle
|
||||||
|
{...props}
|
||||||
|
isConnectable={connections.length < props.connection_count}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LimitedConnectionCountHandle;
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
Handle,
|
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
Position,
|
Position,
|
||||||
type Node,
|
type Node,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
|
import LimitedConnectionCountHandle from "../components/CustomNodeHandles.tsx";
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
|
|
||||||
@@ -32,7 +32,13 @@ export default function EndNode(props: NodeProps<EndNode>) {
|
|||||||
<div className={"flex-row gap-sm"}>
|
<div className={"flex-row gap-sm"}>
|
||||||
End
|
End
|
||||||
</div>
|
</div>
|
||||||
<Handle type="target" position={Position.Left} id="target"/>
|
<LimitedConnectionCountHandle
|
||||||
|
node_id={props.id}
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
connection_count={1}
|
||||||
|
id="target"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { NormNodeData } from "./NormNode";
|
|||||||
export const NormNodeDefaults: NormNodeData = {
|
export const NormNodeDefaults: NormNodeData = {
|
||||||
label: "Norm Node",
|
label: "Norm Node",
|
||||||
droppable: true,
|
droppable: true,
|
||||||
|
conditions: [],
|
||||||
norm: "",
|
norm: "",
|
||||||
hasReduce: true,
|
hasReduce: true,
|
||||||
critical: false,
|
critical: false,
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ import { BasicBeliefReduce } from './BasicBeliefNode';
|
|||||||
export type NormNodeData = {
|
export type NormNodeData = {
|
||||||
label: string;
|
label: string;
|
||||||
droppable: boolean;
|
droppable: boolean;
|
||||||
|
<<<<<<< HEAD
|
||||||
condition?: string; // id of this node's belief.
|
condition?: string; // id of this node's belief.
|
||||||
|
=======
|
||||||
|
>>>>>>> demo
|
||||||
norm: string;
|
norm: string;
|
||||||
hasReduce: boolean;
|
hasReduce: boolean;
|
||||||
critical: boolean;
|
critical: boolean;
|
||||||
@@ -70,12 +73,15 @@ export default function NormNode(props: NodeProps<NormNode>) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
{data.condition && (<div className={"flex-row gap-md align-center"} data-testid="norm-condition-information">
|
{data.condition && (<div className={"flex-row gap-md align-center"} data-testid="norm-condition-information">
|
||||||
<label htmlFor={checkbox_id}>Condition/ Belief attached.</label>
|
<label htmlFor={checkbox_id}>Condition/ Belief attached.</label>
|
||||||
</div>)}
|
</div>)}
|
||||||
|
=======
|
||||||
|
|
||||||
<Handle type="source" position={Position.Right} id="phase"/>
|
<Handle type="source" position={Position.Right} id="norms"/>
|
||||||
<Handle type="target" position={Position.Bottom} id="belief"/>
|
<Handle type="target" position={Position.Bottom} id="norms"/>
|
||||||
|
>>>>>>> demo
|
||||||
</div>
|
</div>
|
||||||
</>;
|
</>;
|
||||||
};
|
};
|
||||||
@@ -89,7 +95,9 @@ export default function NormNode(props: NodeProps<NormNode>) {
|
|||||||
export function NormReduce(node: Node, nodes: Node[]) {
|
export function NormReduce(node: Node, nodes: Node[]) {
|
||||||
const data = node.data as NormNodeData;
|
const data = node.data as NormNodeData;
|
||||||
|
|
||||||
// Build the result object
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
|
// conditions nodes - make sure to check for empty arrays
|
||||||
const result: Record<string, unknown> = {
|
const result: Record<string, unknown> = {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
label: data.label,
|
label: data.label,
|
||||||
@@ -97,6 +105,7 @@ export function NormReduce(node: Node, nodes: Node[]) {
|
|||||||
critical: data.critical,
|
critical: data.critical,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
if (data.condition) {
|
if (data.condition) {
|
||||||
const reducer = BasicBeliefReduce; // TODO: also add inferred.
|
const reducer = BasicBeliefReduce; // TODO: also add inferred.
|
||||||
const conditionNode = nodes.find((node) => node.id === data.condition);
|
const conditionNode = nodes.find((node) => node.id === data.condition);
|
||||||
@@ -104,7 +113,6 @@ export function NormReduce(node: Node, nodes: Node[]) {
|
|||||||
if (conditionNode == undefined) return result;
|
if (conditionNode == undefined) return result;
|
||||||
result["condition"] = reducer(conditionNode, nodes)
|
result["condition"] = reducer(conditionNode, nodes)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,4 +8,6 @@ export const PhaseNodeDefaults: PhaseNodeData = {
|
|||||||
droppable: true,
|
droppable: true,
|
||||||
children: [],
|
children: [],
|
||||||
hasReduce: true,
|
hasReduce: true,
|
||||||
|
nextPhaseId: null,
|
||||||
|
isFirstPhase: false,
|
||||||
};
|
};
|
||||||
@@ -9,6 +9,7 @@ import styles from '../../VisProg.module.css';
|
|||||||
import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry';
|
import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry';
|
||||||
import useFlowStore from '../VisProgStores';
|
import useFlowStore from '../VisProgStores';
|
||||||
import { TextField } from '../../../../components/TextField';
|
import { TextField } from '../../../../components/TextField';
|
||||||
|
import LimitedConnectionCountHandle from "../components/CustomNodeHandles.tsx";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default data dot a phase node
|
* The default data dot a phase node
|
||||||
@@ -16,12 +17,15 @@ import { TextField } from '../../../../components/TextField';
|
|||||||
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
||||||
* @param children: ID's of children of this node
|
* @param children: ID's of children of this node
|
||||||
* @param hasReduce: whether this node has reducing functionality (true by default)
|
* @param hasReduce: whether this node has reducing functionality (true by default)
|
||||||
|
* @param nextPhaseId:
|
||||||
*/
|
*/
|
||||||
export type PhaseNodeData = {
|
export type PhaseNodeData = {
|
||||||
label: string;
|
label: string;
|
||||||
droppable: boolean;
|
droppable: boolean;
|
||||||
children: string[];
|
children: string[];
|
||||||
hasReduce: boolean;
|
hasReduce: boolean;
|
||||||
|
nextPhaseId: string | "end" | null;
|
||||||
|
isFirstPhase: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PhaseNode = Node<PhaseNodeData>
|
export type PhaseNode = Node<PhaseNodeData>
|
||||||
@@ -50,9 +54,21 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
|||||||
placeholder={"Phase ..."}
|
placeholder={"Phase ..."}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Handle type="target" position={Position.Left} id="target"/>
|
<LimitedConnectionCountHandle
|
||||||
|
node_id={props.id}
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
connection_count={1}
|
||||||
|
id="target"
|
||||||
|
/>
|
||||||
<Handle type="target" position={Position.Bottom} id="norms"/>
|
<Handle type="target" position={Position.Bottom} id="norms"/>
|
||||||
<Handle type="source" position={Position.Right} id="source"/>
|
<LimitedConnectionCountHandle
|
||||||
|
node_id={props.id}
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
connection_count={1}
|
||||||
|
id="source"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -65,8 +81,8 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
|||||||
* @returns A collection of all reduced nodes in this phase, starting with this phases' reduced data.
|
* @returns A collection of all reduced nodes in this phase, starting with this phases' reduced data.
|
||||||
*/
|
*/
|
||||||
export function PhaseReduce(node: Node, nodes: Node[]) {
|
export function PhaseReduce(node: Node, nodes: Node[]) {
|
||||||
const thisnode = node as PhaseNode;
|
const thisNode = node as PhaseNode;
|
||||||
const data = thisnode.data as PhaseNodeData;
|
const data = thisNode.data as PhaseNodeData;
|
||||||
|
|
||||||
// node typings that are not in phase
|
// node typings that are not in phase
|
||||||
const nodesNotInPhase: string[] = Object.entries(NodesInPhase)
|
const nodesNotInPhase: string[] = Object.entries(NodesInPhase)
|
||||||
@@ -109,13 +125,19 @@ export function PhaseReduce(node: Node, nodes: Node[]) {
|
|||||||
* @param _sourceNodeId the source of the received connection
|
* @param _sourceNodeId the source of the received connection
|
||||||
*/
|
*/
|
||||||
export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
const node = _thisNode as PhaseNode
|
const data = _thisNode.data as PhaseNodeData
|
||||||
const data = node.data as PhaseNodeData
|
|
||||||
// we only add none phase nodes to the children
|
|
||||||
if (!(useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'phase'))) {
|
|
||||||
data.children.push(_sourceNodeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const nodes = useFlowStore.getState().nodes;
|
||||||
|
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)!
|
||||||
|
switch (sourceNode.type) {
|
||||||
|
case "phase": break;
|
||||||
|
case "start": data.isFirstPhase = true; break;
|
||||||
|
// we only add none phase or start nodes to the children
|
||||||
|
// endNodes cannot be the source of an outgoing connection
|
||||||
|
// so we don't need to cover them with a special case
|
||||||
|
// before handling the default behavior
|
||||||
|
default: data.children.push(_sourceNodeId); break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,7 +146,19 @@ export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
|||||||
* @param _targetNodeId the target of the created connection
|
* @param _targetNodeId the target of the created connection
|
||||||
*/
|
*/
|
||||||
export function PhaseConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
export function PhaseConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
// no additional connection logic exists yet
|
const data = _thisNode.data as PhaseNodeData
|
||||||
|
const nodes = useFlowStore.getState().nodes;
|
||||||
|
|
||||||
|
const targetNode = nodes.find((node) => node.id === _targetNodeId)
|
||||||
|
if (!targetNode) {throw new Error("Source node not found")}
|
||||||
|
|
||||||
|
// we set the nextPhaseId to the next target's id if the target is a phaseNode,
|
||||||
|
// or "end" if the target node is the end node
|
||||||
|
switch (targetNode.type) {
|
||||||
|
case 'phase': data.nextPhaseId = _targetNodeId; break;
|
||||||
|
case 'end': data.nextPhaseId = "end"; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,9 +167,23 @@ export function PhaseConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
|||||||
* @param _sourceNodeId the source of the disconnected connection
|
* @param _sourceNodeId the source of the disconnected connection
|
||||||
*/
|
*/
|
||||||
export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
const node = _thisNode as PhaseNode
|
const data = _thisNode.data as PhaseNodeData
|
||||||
const data = node.data as PhaseNodeData
|
|
||||||
|
const nodes = useFlowStore.getState().nodes;
|
||||||
|
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)
|
||||||
|
const sourceType = sourceNode ? sourceNode.type : "deleted";
|
||||||
|
switch (sourceType) {
|
||||||
|
case "phase": break;
|
||||||
|
case "start": data.isFirstPhase = false; break;
|
||||||
|
// we only add none phase or start nodes to the children
|
||||||
|
// endNodes cannot be the source of an outgoing connection
|
||||||
|
// so we don't need to cover them with a special case
|
||||||
|
// before handling the default behavior
|
||||||
|
default:
|
||||||
data.children = data.children.filter((child) => { if (child != _sourceNodeId) return child; });
|
data.children = data.children.filter((child) => { if (child != _sourceNodeId) return child; });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,5 +192,12 @@ export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string)
|
|||||||
* @param _targetNodeId the target of the diconnected connection
|
* @param _targetNodeId the target of the diconnected connection
|
||||||
*/
|
*/
|
||||||
export function PhaseDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
export function PhaseDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
// no additional connection logic exists yet
|
const data = _thisNode.data as PhaseNodeData
|
||||||
|
const nodes = useFlowStore.getState().nodes;
|
||||||
|
|
||||||
|
// if the target is a phase or end node set the nextPhaseId to null,
|
||||||
|
// as we are no longer connected to a subsequent phaseNode or to the endNode
|
||||||
|
if (nodes.some((node) => node.id === _targetNodeId && ['phase', 'end'].includes(node.type!))){
|
||||||
|
data.nextPhaseId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
Handle,
|
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
Position,
|
Position,
|
||||||
type Node,
|
type Node,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
|
import LimitedConnectionCountHandle from "../components/CustomNodeHandles.tsx";
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
|
|
||||||
@@ -31,7 +31,13 @@ export default function StartNode(props: NodeProps<StartNode>) {
|
|||||||
<div className={"flex-row gap-sm"}>
|
<div className={"flex-row gap-sm"}>
|
||||||
Start
|
Start
|
||||||
</div>
|
</div>
|
||||||
<Handle type="source" position={Position.Right} id="source"/>
|
<LimitedConnectionCountHandle
|
||||||
|
node_id={props.id}
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
connection_count={1}
|
||||||
|
id="source"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
40
src/utils/orderPhaseNodes.ts
Normal file
40
src/utils/orderPhaseNodes.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type {PhaseNode} from "../pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* takes an array of phaseNodes and orders them according to their nextPhaseId attributes,
|
||||||
|
* starting with the phase that has isFirstPhase = true
|
||||||
|
*
|
||||||
|
* @param {PhaseNode[]} nodes an unordered phaseNode array
|
||||||
|
* @returns {PhaseNode[]} the ordered phaseNode array
|
||||||
|
*/
|
||||||
|
export default function orderPhaseNodeArray(nodes: PhaseNode[]) : PhaseNode[] {
|
||||||
|
// find the first phaseNode of the sequence
|
||||||
|
const start = nodes.find(node => node.data.isFirstPhase);
|
||||||
|
if (!start) {
|
||||||
|
throw new Error('No phaseNode with isFirstObject = true found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepare for ordering of phaseNodes
|
||||||
|
const orderedPhaseNodes: PhaseNode[] = [];
|
||||||
|
const IdMap = new Map(nodes.map(node => [node.id, node]));
|
||||||
|
let currentNode: PhaseNode | undefined = start;
|
||||||
|
|
||||||
|
// populate orderedPhaseNodes array with the phaseNodes in the correct order
|
||||||
|
while (currentNode) {
|
||||||
|
orderedPhaseNodes.push(currentNode);
|
||||||
|
|
||||||
|
if (!currentNode.data.nextPhaseId) {
|
||||||
|
throw new Error("Incomplete phase sequence, program does not reach the end node");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentNode.data.nextPhaseId === "end") break;
|
||||||
|
|
||||||
|
currentNode = IdMap.get(currentNode.data.nextPhaseId);
|
||||||
|
|
||||||
|
if (!currentNode) {
|
||||||
|
throw new Error(`Incomplete phase sequence, phaseNode with id "${orderedPhaseNodes.at(-1)?.data.nextPhaseId}" not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedPhaseNodes;
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/
|
|||||||
import { mockReactFlow } from '../../../setupFlowTests.ts';
|
import { mockReactFlow } from '../../../setupFlowTests.ts';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
mockReactFlow();
|
mockReactFlow();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import type { Node, Edge, Connection } from '@xyflow/react'
|
||||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||||
import type { PhaseNodeData } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode";
|
import type {PhaseNode, PhaseNodeData} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode";
|
||||||
import { getByTestId, render } from '@testing-library/react';
|
import {act, getByTestId, render} from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg';
|
import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg';
|
||||||
|
import {mockReactFlow} from "../../../../setupFlowTests.ts";
|
||||||
|
|
||||||
|
|
||||||
class ResizeObserver {
|
class ResizeObserver {
|
||||||
@@ -101,3 +103,194 @@ describe('PhaseNode', () => {
|
|||||||
expect(p2_data.children.length == 2);
|
expect(p2_data.children.length == 2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --| Helper functions |--
|
||||||
|
|
||||||
|
function createPhaseNode(
|
||||||
|
id: string,
|
||||||
|
overrides: Partial<PhaseNodeData> = {},
|
||||||
|
): Node<PhaseNodeData> {
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
type: 'phase',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Phase',
|
||||||
|
droppable: true,
|
||||||
|
children: [],
|
||||||
|
hasReduce: true,
|
||||||
|
nextPhaseId: null,
|
||||||
|
isFirstPhase: false,
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNode(id: string, type: string): Node {
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
type: type,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(source: string, target: string): Connection {
|
||||||
|
return {
|
||||||
|
source: source,
|
||||||
|
target: target,
|
||||||
|
sourceHandle: null,
|
||||||
|
targetHandle: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function edge(source: string, target: string): Edge {
|
||||||
|
return {
|
||||||
|
id: `${source}-${target}`,
|
||||||
|
source: source,
|
||||||
|
target: target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --| Connection Tests |--
|
||||||
|
|
||||||
|
describe('PhaseNode Connection logic', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
mockReactFlow();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PhaseConnections', () => {
|
||||||
|
test('connecting start => phase sets isFirstPhase to true', () => {
|
||||||
|
const phase = createPhaseNode('phase-1')
|
||||||
|
const start = createNode('start', 'start')
|
||||||
|
|
||||||
|
useFlowStore.setState({ nodes: [phase, start] })
|
||||||
|
|
||||||
|
// verify it starts of false
|
||||||
|
expect(phase.data.isFirstPhase).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useFlowStore.getState().onConnect(connect('start', 'phase-1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedPhase = useFlowStore
|
||||||
|
.getState()
|
||||||
|
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||||
|
|
||||||
|
expect(updatedPhase.data.isFirstPhase).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('connecting task => phase adds child', () => {
|
||||||
|
const phase = createPhaseNode('phase-1')
|
||||||
|
const norm = createNode('norm-1', 'norm')
|
||||||
|
|
||||||
|
useFlowStore.setState({ nodes: [phase, norm] })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useFlowStore.getState().onConnect(connect('norm-1', 'phase-1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedPhase = useFlowStore
|
||||||
|
.getState()
|
||||||
|
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||||
|
|
||||||
|
expect(updatedPhase.data.children).toEqual(['norm-1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('connecting phase => phase sets nextPhaseId', () => {
|
||||||
|
const p1 = createPhaseNode('phase-1')
|
||||||
|
const p2 = createPhaseNode('phase-2')
|
||||||
|
|
||||||
|
useFlowStore.setState({ nodes: [p1, p2] })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useFlowStore.getState().onConnect(connect('phase-1', 'phase-2'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedP1 = useFlowStore
|
||||||
|
.getState()
|
||||||
|
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||||
|
|
||||||
|
expect(updatedP1.data.nextPhaseId).toBe('phase-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('connecting phase to end => phase sets nextPhaseId to "end"', () => {
|
||||||
|
const phase = createPhaseNode('phase-1')
|
||||||
|
const end = createNode('end', 'end')
|
||||||
|
|
||||||
|
useFlowStore.setState({ nodes: [phase, end] })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useFlowStore.getState().onConnect(connect('phase-1', 'end'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedPhase = useFlowStore
|
||||||
|
.getState()
|
||||||
|
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||||
|
|
||||||
|
expect(updatedPhase.data.nextPhaseId).toBe('end')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PhaseDisconnections', () => {
|
||||||
|
test('disconnecting task => phase removes child', () => {
|
||||||
|
const phase = createPhaseNode('phase-1', { children: ['norm-1'] })
|
||||||
|
const norm = createNode('norm-1', 'norm')
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [phase, norm],
|
||||||
|
edges: [edge('norm-1', 'phase-1')]
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useFlowStore.getState().onEdgesDelete([edge('norm-1', 'phase-1')])
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedPhase = useFlowStore
|
||||||
|
.getState()
|
||||||
|
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||||
|
|
||||||
|
expect(updatedPhase.data.children).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('disconnecting start => phase sets isFirstPhase to false', () => {
|
||||||
|
const phase = createPhaseNode('phase-1', { isFirstPhase: true })
|
||||||
|
const start = createNode('start', 'start')
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [phase, start],
|
||||||
|
edges: [edge('start', 'phase-1')]
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useFlowStore.getState().onEdgesDelete([edge('start', 'phase-1')])
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedPhase = useFlowStore
|
||||||
|
.getState()
|
||||||
|
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||||
|
|
||||||
|
expect(updatedPhase.data.isFirstPhase).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('disconnecting phase => phase sets nextPhaseId to null', () => {
|
||||||
|
const p1 = createPhaseNode('phase-1', { nextPhaseId: 'phase-2' })
|
||||||
|
const p2 = createPhaseNode('phase-2')
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [p1, p2],
|
||||||
|
edges: [edge('phase-1', 'phase-2')]
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useFlowStore.getState().onEdgesDelete([edge('phase-1', 'phase-2')])
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedP1 = useFlowStore
|
||||||
|
.getState()
|
||||||
|
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||||
|
|
||||||
|
expect(updatedP1.data.nextPhaseId).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -15,15 +15,13 @@ describe('Universal Nodes', () => {
|
|||||||
|
|
||||||
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable? : boolean) {
|
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable? : boolean) {
|
||||||
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
|
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id: id,
|
||||||
type,
|
type: type,
|
||||||
position,
|
position: position,
|
||||||
deletable,
|
data: {...defaultData, ...data},
|
||||||
data: {
|
deletable: deletable
|
||||||
...defaultData,
|
|
||||||
...data,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
110
test/utils/orderPhaseNodes.test.ts
Normal file
110
test/utils/orderPhaseNodes.test.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type {PhaseNode} from "../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
|
||||||
|
import orderPhaseNodeArray from "../../src/utils/orderPhaseNodes.ts";
|
||||||
|
|
||||||
|
function createPhaseNode(
|
||||||
|
id: string,
|
||||||
|
isFirst: boolean = false,
|
||||||
|
nextPhaseId: string | null = null
|
||||||
|
): PhaseNode {
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
type: 'phase',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Phase',
|
||||||
|
droppable: true,
|
||||||
|
children: [],
|
||||||
|
hasReduce: true,
|
||||||
|
nextPhaseId: nextPhaseId,
|
||||||
|
isFirstPhase: isFirst,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("orderPhaseNodes", () => {
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
testCase: {
|
||||||
|
testName: "Throws correct error when there is no first phase (empty input array)",
|
||||||
|
input: [],
|
||||||
|
expected: "No phaseNode with isFirstObject = true found"
|
||||||
|
}
|
||||||
|
},{
|
||||||
|
testCase: {
|
||||||
|
testName: "Throws correct error when there is no first phase",
|
||||||
|
input: [
|
||||||
|
createPhaseNode("phase-1", false, "phase-2"),
|
||||||
|
createPhaseNode("phase-2", false, "phase-3"),
|
||||||
|
createPhaseNode("phase-3", false, "end")
|
||||||
|
],
|
||||||
|
expected: "No phaseNode with isFirstObject = true found"
|
||||||
|
}
|
||||||
|
},{
|
||||||
|
testCase: {
|
||||||
|
testName: "Throws correct error when the program doesn't lead to an end node (missing phase-phase connection)",
|
||||||
|
input: [
|
||||||
|
createPhaseNode("phase-1", true, "phase-2"),
|
||||||
|
createPhaseNode("phase-2", false, null),
|
||||||
|
createPhaseNode("phase-3", false, "end")
|
||||||
|
],
|
||||||
|
expected: "Incomplete phase sequence, program does not reach the end node"
|
||||||
|
}
|
||||||
|
},{
|
||||||
|
testCase: {
|
||||||
|
testName: "Throws correct error when the program doesn't lead to an end node (missing phase-end connection)",
|
||||||
|
input: [
|
||||||
|
createPhaseNode("phase-1", true, "phase-2"),
|
||||||
|
createPhaseNode("phase-2", false, "phase-3"),
|
||||||
|
createPhaseNode("phase-3", false, null)
|
||||||
|
],
|
||||||
|
expected: "Incomplete phase sequence, program does not reach the end node"
|
||||||
|
}
|
||||||
|
},{
|
||||||
|
testCase: {
|
||||||
|
testName: "Throws correct error when the program leads to a non-existent phase",
|
||||||
|
input: [
|
||||||
|
createPhaseNode("phase-1", true, "phase-2"),
|
||||||
|
createPhaseNode("phase-2", false, "phase-3"),
|
||||||
|
createPhaseNode("phase-3", false, "phase-4")
|
||||||
|
],
|
||||||
|
expected: "Incomplete phase sequence, phaseNode with id \"phase-4\" not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])(`Error Handling: $testCase.testName`, ({testCase}) => {
|
||||||
|
expect(() => { orderPhaseNodeArray(testCase.input) }).toThrow(testCase.expected);
|
||||||
|
})
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
testCase: {
|
||||||
|
testName: "Already correctly ordered phases stay ordered",
|
||||||
|
input: [
|
||||||
|
createPhaseNode("phase-1", true, "phase-2"),
|
||||||
|
createPhaseNode("phase-2", false, "phase-3"),
|
||||||
|
createPhaseNode("phase-3", false, "end")
|
||||||
|
],
|
||||||
|
expected: [
|
||||||
|
createPhaseNode("phase-1", true, "phase-2"),
|
||||||
|
createPhaseNode("phase-2", false, "phase-3"),
|
||||||
|
createPhaseNode("phase-3", false, "end")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},{
|
||||||
|
testCase: {
|
||||||
|
testName: "Incorrectly ordered phases get ordered correctly",
|
||||||
|
input: [
|
||||||
|
createPhaseNode("phase-3", false, "end"),
|
||||||
|
createPhaseNode("phase-1", true, "phase-2"),
|
||||||
|
createPhaseNode("phase-2", false, "phase-3"),
|
||||||
|
],
|
||||||
|
expected: [
|
||||||
|
createPhaseNode("phase-1", true, "phase-2"),
|
||||||
|
createPhaseNode("phase-2", false, "phase-3"),
|
||||||
|
createPhaseNode("phase-3", false, "end")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])(`Functional: $testCase.testName`, ({testCase}) => {
|
||||||
|
const output = orderPhaseNodeArray(testCase.input);
|
||||||
|
expect(output).toEqual(testCase.expected);
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user