diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 00268eb..3e099d8 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -84,7 +84,10 @@ filter: drop-shadow(0 0 0.25rem plum); } - +.node-inferred_belief { + outline: mediumpurple solid 2pt; + filter: drop-shadow(0 0 0.25rem mediumpurple); +} .draggable-node { padding: 3px 10px; @@ -158,6 +161,14 @@ filter: drop-shadow(0 0 0.25rem plum); } +.draggable-node-inferred_belief { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: mediumpurple solid 2pt; + filter: drop-shadow(0 0 0.25rem mediumpurple); +} + .planNoIterate { opacity: 0.5; font-style: italic; diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index a083bbf..753dcbe 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -164,6 +164,7 @@ function runProgram() { // when the program was sent to the backend successfully: useProgramStore.getState().setProgramState(structuredClone(program)); }).catch(() => console.log("Failed to send program to the backend.")); + console.log(program); } /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts b/src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts index 427542a..e212ed2 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts @@ -107,4 +107,16 @@ export function useHandleRules( // finally we return a function that evaluates all rules using the created context return evaluateRules(targetRules, connection, context); }; +} + +export function validateConnectionWithRules( + connection: Connection, + context: ConnectionContext +): RuleResult { + const rules = useFlowStore.getState().getTargetRules( + connection.target!, + connection.targetHandle! + ); + + return evaluateRules(rules,connection, context); } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts b/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts index a04282c..aca415e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts @@ -43,3 +43,4 @@ export const noSelfConnections : HandleRule = } + diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 54f0241..a4285ec 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -52,15 +52,24 @@ import TriggerNode, { TriggerTooltip } from "./nodes/TriggerNode"; import { TriggerNodeDefaults } from "./nodes/TriggerNode.default"; +import InferredBeliefNode, { + InferredBeliefConnectionTarget, + InferredBeliefConnectionSource, + InferredBeliefDisconnectionTarget, + InferredBeliefDisconnectionSource, + InferredBeliefReduce, InferredBeliefTooltip +} from "./nodes/InferredBeliefNode"; +import { InferredBeliefNodeDefaults } from "./nodes/InferredBeliefNode.default"; import BasicBeliefNode, { BasicBeliefConnectionSource, BasicBeliefConnectionTarget, BasicBeliefDisconnectionSource, BasicBeliefDisconnectionTarget, - BasicBeliefReduce, + BasicBeliefReduce +, BasicBeliefTooltip -} from "./nodes/BasicBeliefNode"; -import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default"; +} from "./nodes/BasicBeliefNode.tsx"; +import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default.ts"; /** * Registered node types in the visual programming system. @@ -76,6 +85,7 @@ export const NodeTypes = { goal: GoalNode, trigger: TriggerNode, basic_belief: BasicBeliefNode, + inferred_belief: InferredBeliefNode, }; /** @@ -91,6 +101,7 @@ export const NodeDefaults = { goal: GoalNodeDefaults, trigger: TriggerNodeDefaults, basic_belief: BasicBeliefNodeDefaults, + inferred_belief: InferredBeliefNodeDefaults, }; @@ -108,6 +119,7 @@ export const NodeReduces = { goal: GoalReduce, trigger: TriggerReduce, basic_belief: BasicBeliefReduce, + inferred_belief: InferredBeliefReduce, } @@ -126,6 +138,7 @@ export const NodeConnections = { goal: GoalConnectionTarget, trigger: TriggerConnectionTarget, basic_belief: BasicBeliefConnectionTarget, + inferred_belief: InferredBeliefConnectionTarget, }, Sources: { start: StartConnectionSource, @@ -134,7 +147,8 @@ export const NodeConnections = { norm: NormConnectionSource, goal: GoalConnectionSource, trigger: TriggerConnectionSource, - basic_belief: BasicBeliefConnectionSource + basic_belief: BasicBeliefConnectionSource, + inferred_belief: InferredBeliefConnectionSource, } } @@ -153,6 +167,7 @@ export const NodeDisconnections = { goal: GoalDisconnectionTarget, trigger: TriggerDisconnectionTarget, basic_belief: BasicBeliefDisconnectionTarget, + inferred_belief: InferredBeliefDisconnectionTarget, }, Sources: { start: StartDisconnectionSource, @@ -162,6 +177,7 @@ export const NodeDisconnections = { goal: GoalDisconnectionSource, trigger: TriggerDisconnectionSource, basic_belief: BasicBeliefDisconnectionSource, + inferred_belief: InferredBeliefDisconnectionSource, }, } @@ -186,6 +202,7 @@ export const NodesInPhase = { end: () => false, phase: () => false, basic_belief: () => false, + inferred_belief: () => false, } /** @@ -199,4 +216,5 @@ export const NodeTooltips = { goal: GoalTooltip, trigger: TriggerTooltip, basic_belief: BasicBeliefTooltip, + inferred_belief: InferredBeliefTooltip, } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index defa934..2831748 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -9,6 +9,7 @@ import { type XYPosition, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; +import {type ConnectionContext, validateConnectionWithRules} from "./HandleRuleLogic.ts"; import type { FlowState } from './VisProgTypes'; import { NodeDefaults, @@ -129,7 +130,41 @@ const useFlowStore = create(UndoRedo((set, get) => ({ * Handles reconnecting an edge between nodes. */ onReconnect: (oldEdge, newConnection) => { - get().edgeReconnectSuccessful = true; + + function createContext( + source: {id: string, handleId: string}, + target: {id: string, handleId: string} + ) : ConnectionContext { + const edges = get().edges; + const targetConnections = edges.filter(edge => edge.target === target.id && edge.targetHandle === target.handleId).length + return { + connectionCount: targetConnections, + source: source, + target: target + } + } + + // connection validation + const context: ConnectionContext = oldEdge.source === newConnection.source + ? createContext({id: newConnection.source, handleId: newConnection.sourceHandle!}, {id: newConnection.target, handleId: newConnection.targetHandle!}) + : createContext({id: newConnection.target, handleId: newConnection.targetHandle!}, {id: newConnection.source, handleId: newConnection.sourceHandle!}); + + const result = validateConnectionWithRules( + newConnection, + context + ); + + if (!result.isSatisfied) { + set({ + edges: get().edges.map(e => + e.id === oldEdge.id ? oldEdge : e + ), + }); + return; + } + + // further reconnect logic + set({ edgeReconnectSuccessful: true }); set({ edges: reconnectEdge(oldEdge, newConnection, get().edges) }); // We make sure to perform any required data updates on the newly reconnected nodes @@ -188,7 +223,7 @@ const useFlowStore = create(UndoRedo((set, get) => ({ // Let's find our node to check if they have a special deletion function const ourNode = get().nodes.find((n)=>n.id==nodeId); const ourFunction = Object.entries(NodeDeletes).find(([t])=>t==ourNode?.type)?.[1] - + // If there's no function, OR, our function tells us we can delete it, let's do so... if (ourFunction == undefined || ourFunction()) { set({ diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts index 655aaaa..01f1cfa 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts @@ -1,4 +1,4 @@ -import type { BasicBeliefNodeData } from "./BasicBeliefNode"; +import type { BasicBeliefNodeData } from "./BasicBeliefNode.tsx"; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index bed642f..5348b06 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -3,13 +3,14 @@ import { Position, type Node, } from '@xyflow/react'; -import { Toolbar } from '../components/NodeComponents'; +import { Toolbar } from '../components/NodeComponents.tsx'; import styles from '../../VisProg.module.css'; import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx"; import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts"; -import useFlowStore from '../VisProgStores'; -import { TextField } from '../../../../components/TextField'; -import { MultilineTextField } from '../../../../components/MultilineTextField'; +import useFlowStore from '../VisProgStores.tsx'; +import { TextField } from '../../../../components/TextField.tsx'; +import { MultilineTextField } from '../../../../components/MultilineTextField.tsx'; +import {noMatchingLeftRightBelief} from "./BeliefGlobals.ts"; /** * The default data structure for a BasicBelief node @@ -31,7 +32,7 @@ export type BasicBeliefNodeData = { }; // These are all the types a basic belief could be. -type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion +export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"}; type Semantic = { type: "semantic", id: string, value: string, description: string, label: "Detected with LLM:"}; type DetectedObject = { type: "object", id: string, value: string, label: "Object found:"}; @@ -189,7 +190,8 @@ export default function BasicBeliefNode(props: NodeProps) { )} diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts new file mode 100644 index 0000000..b92c5b2 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts @@ -0,0 +1,63 @@ +import {getOutgoers, type Node} from '@xyflow/react'; +import {type HandleRule, type RuleResult, ruleResult} from "../HandleRuleLogic.ts"; +import useFlowStore from "../VisProgStores.tsx"; +import {BasicBeliefReduce} from "./BasicBeliefNode.tsx"; +import {type InferredBeliefNodeData, InferredBeliefReduce} from "./InferredBeliefNode.tsx"; + +export function BeliefGlobalReduce(beliefNode: Node, nodes: Node[]) { + switch (beliefNode.type) { + case 'basic_belief': + return BasicBeliefReduce(beliefNode, nodes); + case 'inferred_belief': + return InferredBeliefReduce(beliefNode, nodes); + } +} + +export const noMatchingLeftRightBelief : HandleRule = (connection, _)=> { + const { nodes } = useFlowStore.getState(); + const thisNode = nodes.find(node => node.id === connection.target && node.type === 'inferred_belief'); + if (!thisNode) return ruleResult.satisfied; + + const iBelief = (thisNode.data as InferredBeliefNodeData).inferredBelief; + return (iBelief.left === connection.source || iBelief.right === connection.source) + ? ruleResult.notSatisfied("Connecting one belief to both input handles of an inferred belief node is not allowed") + : ruleResult.satisfied; +} +/** + * makes it impossible to connect Inferred belief nodes + * if the connection would create a cyclical connection between inferred beliefs + */ +export const noBeliefCycles: HandleRule = (connection, _): RuleResult => { + const {nodes, edges} = useFlowStore.getState(); + const defaultErrorMessage = "Cyclical connection exists between inferred beliefs"; + + /** + * recursively checks for cyclical connections between InferredBelief nodes + * + * to check for a cycle provide the source of an attempted connection as the targetNode for the cycle check, + * the currentNodeId should be initialised with the id of the targetNode of the attempted connection. + * + * @param {string} targetNodeId - the id of the node we are looking for as the endpoint of a cyclical connection + * @param {string} currentNodeId - the id of the node we are checking for outgoing connections to the provided target node + * @returns {RuleResult} + */ + function checkForCycle(targetNodeId: string, currentNodeId: string): RuleResult { + const outgoingBeliefs = getOutgoers({id: currentNodeId}, nodes, edges) + .filter(node => node.type === 'inferred_belief'); + + if (outgoingBeliefs.length === 0) return ruleResult.satisfied; + if (outgoingBeliefs.some(node => node.id === targetNodeId)) return ruleResult + .notSatisfied(defaultErrorMessage); + + const next = outgoingBeliefs.map(node => checkForCycle(targetNodeId, node.id)) + .find(result => !result.isSatisfied); + + return next + ? next + : ruleResult.satisfied; + } + + return connection.source === connection.target + ? ruleResult.notSatisfied(defaultErrorMessage) + : checkForCycle(connection.source, connection.target); +}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts new file mode 100644 index 0000000..976ee16 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts @@ -0,0 +1,16 @@ +import type { InferredBeliefNodeData } from "./InferredBeliefNode.tsx"; + + +/** + * Default data for this node + */ +export const InferredBeliefNodeDefaults: InferredBeliefNodeData = { + label: "AND/OR", + droppable: true, + inferredBelief: { + left: undefined, + operator: true, + right: undefined + }, + hasReduce: true, +}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css new file mode 100644 index 0000000..2f9b7ae --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css @@ -0,0 +1,80 @@ +.operator-switch { + display: inline-flex; + align-items: center; + gap: 0.5em; + cursor: pointer; + font-family: sans-serif; + /* Change this font-size to scale the whole component */ + font-size: 12px; +} + +/* hide the default checkbox */ +.operator-switch input { + display: none; +} + +/* The Track */ +.switch-visual { + position: relative; + /* height is now 3x the font size */ + height: 3em; + aspect-ratio: 1 / 2; + background-color: ButtonFace; + border-radius: 2em; + transition: 0.2s; +} + +/* The Knob */ +.switch-visual::after { + content: ""; + position: absolute; + top: 0.1em; + left: 0.1em; + width: 1em; + height: 1em; + background: Canvas; + border: 0.175em solid mediumpurple; + border-radius: 50%; + transition: transform 0.2s ease-in-out, border-color 0.2s; +} + +/* Labels */ +.switch-labels { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 3em; /* Matches the track height */ + font-weight: 800; + color: Canvas; + line-height: 1.4; + padding: 0.2em 0; +} + +.operator-switch input:checked + .switch-visual::after { + /* Moves the slider down */ + transform: translateY(1.4em); +} + +/*change the colours to highlight the selected operator*/ +.operator-switch input:checked ~ .switch-labels{ + :first-child { + transition: ease-in-out color 0.2s; + color: ButtonFace; + } + :last-child { + transition: ease-in-out color 0.2s; + color: mediumpurple; + } +} + +.operator-switch input:not(:checked) ~ .switch-labels{ + :first-child { + transition: ease-in-out color 0.2s; + color: mediumpurple; + } + :last-child { + transition: ease-in-out color 0.2s; + color: ButtonFace; + } + +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx new file mode 100644 index 0000000..be5d4ec --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx @@ -0,0 +1,176 @@ +import {getConnectedEdges, type Node, type NodeProps, Position} from '@xyflow/react'; +import {useState} from "react"; +import styles from '../../VisProg.module.css'; +import {Toolbar} from '../components/NodeComponents.tsx'; +import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; +import {allowOnlyConnectionsFromType} from "../HandleRules.ts"; +import useFlowStore from "../VisProgStores.tsx"; +import {BeliefGlobalReduce, noBeliefCycles, noMatchingLeftRightBelief} from "./BeliefGlobals.ts"; +import switchStyles from './InferredBeliefNode.module.css'; + + +/** + * The default data structure for an InferredBelief node + */ +export type InferredBeliefNodeData = { + label: string; + droppable: boolean; + inferredBelief: InferredBelief; + hasReduce: boolean; +}; + +/** + * stores a boolean to represent the operator + * and a left and right BeliefNode (can be both an inferred and a basic belief) + * in the form of their corresponding id's + */ +export type InferredBelief = { + left: string | undefined, + operator: boolean, + right: string | undefined, +} + +export type InferredBeliefNode = Node; + +/** + * This function is called whenever a connection is made with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the received connection + */ +export function InferredBeliefConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + const data = _thisNode.data as InferredBeliefNodeData; + + if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId + && ['basic_belief', 'inferred_belief'].includes(node.type!))) + ) { + const connectedEdges = getConnectedEdges([_thisNode], useFlowStore.getState().edges); + switch(connectedEdges.find(edge => edge.source === _sourceNodeId)?.targetHandle){ + case 'beliefLeft': data.inferredBelief.left = _sourceNodeId; break; + case 'beliefRight': data.inferredBelief.right = _sourceNodeId; break; + } + } +} + +/** + * This function is called whenever a connection is made with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function InferredBeliefConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function InferredBeliefDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + const data = _thisNode.data as InferredBeliefNodeData; + + if (_sourceNodeId === data.inferredBelief.left) data.inferredBelief.left = undefined; + if (_sourceNodeId === data.inferredBelief.right) data.inferredBelief.right = undefined; +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function InferredBeliefDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +export const InferredBeliefTooltip = ` + Combines two beliefs into a single belief using logical inference, + the node can be toggled between using "AND" and "OR" mode for inference`; +/** + * Defines how an InferredBelief node should be rendered + * @param {NodeProps} props - Node properties provided by React Flow, including `id` and `data`. + * @returns The rendered InferredBeliefNode React element. (React.JSX.Element) + */ +export default function InferredBeliefNode(props: NodeProps) { + const data = props.data; + const { updateNodeData } = useFlowStore(); + // start of as an AND operator, true: "AND", false: "OR" + const [enforceAllBeliefs, setEnforceAllBeliefs] = useState(true); + + // used to toggle operator + function onToggle() { + const newOperator = !enforceAllBeliefs; // compute the new value + setEnforceAllBeliefs(newOperator); + + updateNodeData(props.id, { + ...data, + inferredBelief: { + ...data.inferredBelief, + operator: enforceAllBeliefs, + } + }); + } + + return ( + <> + +
+ {/* The checkbox used to toggle the operator between 'AND' and 'OR' */} + + + + {/* outgoing connections */} + + + {/* incoming connections */} + + +
+ + ); +}; + +/** + * Reduces each BasicBelief, including its children down into its core data. + * @param {Node} node - The BasicBelief node to reduce. + * @param {Node[]} nodes - The list of all nodes in the current flow graph. + * @returns A simplified object containing the node label and its list of BasicBeliefs. + */ +export function InferredBeliefReduce(node: Node, nodes: Node[]) { + const data = node.data as InferredBeliefNodeData; + const leftBelief = nodes.find((node) => node.id === data.inferredBelief.left); + const rightBelief = nodes.find((node) => node.id === data.inferredBelief.right); + + if (!leftBelief) { throw new Error("No Left belief found")} + if (!rightBelief) { throw new Error("No Right Belief found")} + + const result: Record = { + id: node.id, + left: BeliefGlobalReduce(leftBelief, nodes), + operator: data.inferredBelief.operator ? "AND" : "OR", + right: BeliefGlobalReduce(rightBelief, nodes), + }; + + return result +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 6cde46a..8ee5462 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -9,7 +9,7 @@ import { TextField } from '../../../../components/TextField'; import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts"; import useFlowStore from '../VisProgStores'; -import { BasicBeliefReduce } from './BasicBeliefNode'; +import {BeliefGlobalReduce} from "./BeliefGlobals.ts"; /** * The default data dot a phase node @@ -81,7 +81,7 @@ export default function NormNode(props: NodeProps) { allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]) ]}/> ; @@ -105,11 +105,10 @@ export function NormReduce(node: Node, nodes: Node[]) { }; if (data.condition) { - const reducer = BasicBeliefReduce; // TODO: also add inferred. const conditionNode = nodes.find((node) => node.id === data.condition); // In case something went wrong, and our condition doesn't actually exist; if (conditionNode == undefined) return result; - result["condition"] = reducer(conditionNode, nodes) + result["condition"] = BeliefGlobalReduce(conditionNode, nodes) } return result } @@ -126,7 +125,7 @@ export const NormTooltip = ` export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) { const data = _thisNode.data as NormNodeData; // If we got a belief connected, this is the condition for the norm. - if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { + if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && ['basic_belief', 'inferred_belief'].includes(node.type!)))) { data.condition = _sourceNodeId; } } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 8b3378a..3004fe8 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -9,8 +9,8 @@ import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleB import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts"; import useFlowStore from '../VisProgStores'; import {PlanReduce, type Plan } from '../components/Plan'; -import PlanEditorDialog from '../components/PlanEditor'; -import { BasicBeliefReduce } from './BasicBeliefNode'; +import PlanEditorDialog from '../components/PlanEditor'; +import {BeliefGlobalReduce} from "./BeliefGlobals.ts"; import type { GoalNode } from './GoalNode.tsx'; import { defaultPlan } from '../components/Plan.default.ts'; import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx'; @@ -50,9 +50,9 @@ export default function TriggerNode(props: NodeProps) { const setName= (value: string) => { updateNodeData(props.id, {...data, name: value}) } - + return <> - +
) { type="target" position={Position.Bottom} id="TriggerBeliefs" - style={{ left: '40%' }} + style={{ left: '40%' }} rules={[ - allowOnlyConnectionsFromType(['basic_belief']), + allowOnlyConnectionsFromType(["basic_belief","inferred_belief"]), ]} /> @@ -102,13 +102,13 @@ export default function TriggerNode(props: NodeProps) { /** * Reduces each Trigger, including its children down into its core data. * @param node - The Trigger node to reduce. - * @param _nodes - The list of all nodes in the current flow graph. + * @param nodes - The list of all nodes in the current flow graph. * @returns A simplified object containing the node label and its list of triggers. */ export function TriggerReduce(node: Node, nodes: Node[]) { const data = node.data as TriggerNodeData; const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined - const conditionData = conditionNode ? BasicBeliefReduce(conditionNode, nodes) : "" + const conditionData = conditionNode ? BeliefGlobalReduce(conditionNode, nodes) : "" return { id: node.id, name: node.data.name, @@ -136,7 +136,7 @@ export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string) const otherNode = nodes.find((x) => x.id === _sourceNodeId) if (!otherNode) return; - if (otherNode.type === 'basic_belief' /* TODO: Add the option for an inferred belief */) { + if (['basic_belief', 'inferred_belief'].includes(otherNode.type!)) { data.condition = _sourceNodeId; } @@ -172,7 +172,7 @@ export function TriggerDisconnectionTarget(_thisNode: Node, _sourceNodeId: strin const data = _thisNode.data as TriggerNodeData; // remove if the target of disconnection was our condition if (_sourceNodeId == data.condition) data.condition = undefined - + data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId) } diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefGlobals.test.ts b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefGlobals.test.ts new file mode 100644 index 0000000..54cfa7f --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefGlobals.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import {type Connection, getOutgoers, type Node} from '@xyflow/react'; +import {ruleResult} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts"; +import {BasicBeliefReduce} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx"; +import { + BeliefGlobalReduce, noBeliefCycles, + noMatchingLeftRightBelief +} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts"; +import { InferredBeliefReduce } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx"; +import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; +import * as BasicModule from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode'; +import * as InferredModule from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx'; +import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; + + +describe('BeliefGlobalReduce', () => { + const nodes: Node[] = []; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('delegates to BasicBeliefReduce for basic_belief nodes', () => { + const spy = jest + .spyOn(BasicModule, 'BasicBeliefReduce') + .mockReturnValue('basic-result' as any); + + const node = { id: '1', type: 'basic_belief' } as Node; + + const result = BeliefGlobalReduce(node, nodes); + + expect(spy).toHaveBeenCalledWith(node, nodes); + expect(result).toBe('basic-result'); + }); + + it('delegates to InferredBeliefReduce for inferred_belief nodes', () => { + const spy = jest + .spyOn(InferredModule, 'InferredBeliefReduce') + .mockReturnValue('inferred-result' as any); + + const node = { id: '2', type: 'inferred_belief' } as Node; + + const result = BeliefGlobalReduce(node, nodes); + + expect(spy).toHaveBeenCalledWith(node, nodes); + expect(result).toBe('inferred-result'); + }); + + it('returns undefined for unknown node types', () => { + const node = { id: '3', type: 'other' } as Node; + + const result = BeliefGlobalReduce(node, nodes); + + expect(result).toBeUndefined(); + expect(BasicBeliefReduce).not.toHaveBeenCalled(); + expect(InferredBeliefReduce).not.toHaveBeenCalled(); + }); +}); + +describe('noMatchingLeftRightBelief rule', () => { + let getStateSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + getStateSpy = jest.spyOn(FlowStore.default, 'getState'); + }); + + it('is satisfied when target node is not an inferred belief', () => { + getStateSpy.mockReturnValue({ + nodes: [{ id: 't1', type: 'basic_belief' }], + } as any); + + const result = noMatchingLeftRightBelief( + { source: 's1', target: 't1' } as Connection, + null as any + ); + + expect(result).toBe(ruleResult.satisfied); + }); + + it('is satisfied when inferred belief has no matching left/right', () => { + getStateSpy.mockReturnValue({ + nodes: [ + { + id: 't1', + type: 'inferred_belief', + data: { + inferredBelief: { + left: 'a', + right: 'b', + }, + }, + }, + ], + } as any); + + const result = noMatchingLeftRightBelief( + { source: 'c', target: 't1' } as Connection, + null as any + ); + + expect(result).toBe(ruleResult.satisfied); + }); + + it('is NOT satisfied when source matches left input', () => { + getStateSpy.mockReturnValue({ + nodes: [ + { + id: 't1', + type: 'inferred_belief', + data: { + inferredBelief: { + left: 's1', + right: 's2', + }, + }, + }, + ], + } as any); + + const result = noMatchingLeftRightBelief( + { source: 's1', target: 't1' } as Connection, + null as any + ); + + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain( + 'Connecting one belief to both input handles of an inferred belief node is not allowed' + ); + } + }); + + it('is NOT satisfied when source matches right input', () => { + getStateSpy.mockReturnValue({ + nodes: [ + { + id: 't1', + type: 'inferred_belief', + data: { + inferredBelief: { + left: 's1', + right: 's2', + }, + }, + }, + ], + } as any); + + const result = noMatchingLeftRightBelief( + { source: 's2', target: 't1' } as Connection, + null as any + ); + + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain( + 'Connecting one belief to both input handles of an inferred belief node is not allowed' + ); + } + }); +}); + + +jest.mock('@xyflow/react', () => ({ + getOutgoers: jest.fn(), + getConnectedEdges: jest.fn(), // include if some tests require it +})); + +describe('noBeliefCycles rule', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns notSatisfied when source === target', () => { + const result = noBeliefCycles({ source: 'n1', target: 'n1' } as any, null as any); + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain('Cyclical connection exists'); + } + }); + + it('returns satisfied when there are no outgoing inferred beliefs', () => { + jest.spyOn(useFlowStore, 'getState').mockReturnValue({ + nodes: [{ id: 'n1', type: 'inferred_belief' }], + edges: [], + } as any); + + (getOutgoers as jest.Mock).mockReturnValue([]); + + const result = noBeliefCycles({ source: 'n1', target: 'n2' } as any, null as any); + expect(result).toBe(ruleResult.satisfied); + }); + + it('returns notSatisfied for direct cycle', () => { + jest.spyOn(useFlowStore, 'getState').mockReturnValue({ + nodes: [ + { id: 'n1', type: 'inferred_belief' }, + { id: 'n2', type: 'inferred_belief' }, + ], + edges: [{ source: 'n2', target: 'n1' }], + } as any); + + // @ts-expect-error is acting up + (getOutgoers as jest.Mock).mockImplementation(({ id }) => { + if (id === 'n2') return [{ id: 'n1', type: 'inferred_belief' }]; + return []; + }); + + const result = noBeliefCycles({ source: 'n1', target: 'n2' } as any, null as any); + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain('Cyclical connection exists'); + } + }); + + it('returns notSatisfied for indirect cycle', () => { + jest.spyOn(useFlowStore, 'getState').mockReturnValue({ + nodes: [ + { id: 'A', type: 'inferred_belief' }, + { id: 'B', type: 'inferred_belief' }, + { id: 'C', type: 'inferred_belief' }, + ], + edges: [ + { source: 'A', target: 'B' }, + { source: 'B', target: 'C' }, + { source: 'C', target: 'A' }, + ], + } as any); + + // @ts-expect-error is acting up + (getOutgoers as jest.Mock).mockImplementation(({ id }) => { + const mapping: Record = { + A: [{ id: 'B', type: 'inferred_belief' }], + B: [{ id: 'C', type: 'inferred_belief' }], + C: [{ id: 'A', type: 'inferred_belief' }], + }; + return mapping[id] || []; + }); + + const result = noBeliefCycles({ source: 'A', target: 'B' } as any, null as any); + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain('Cyclical connection exists'); + } + }); + + it('returns satisfied when no cycle exists in a multi-node graph', () => { + jest.spyOn(useFlowStore, 'getState').mockReturnValue({ + nodes: [ + { id: 'A', type: 'inferred_belief' }, + { id: 'B', type: 'inferred_belief' }, + { id: 'C', type: 'inferred_belief' }, + ], + edges: [ + { source: 'A', target: 'B' }, + { source: 'B', target: 'C' }, + ], + } as any); + + // @ts-expect-error is acting up + (getOutgoers as jest.Mock).mockImplementation(({ id }) => { + const mapping: Record = { + A: [{ id: 'B', type: 'inferred_belief' }], + B: [{ id: 'C', type: 'inferred_belief' }], + C: [], + }; + return mapping[id] || []; + }); + + const result = noBeliefCycles({ source: 'A', target: 'B' } as any, null as any); + expect(result).toBe(ruleResult.satisfied); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx index 34872c9..a023769 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx @@ -3,7 +3,7 @@ import { describe, it, beforeEach } from '@jest/globals'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; -import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode'; +import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; import '@testing-library/jest-dom'; diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx new file mode 100644 index 0000000..d683b23 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import type {Node, Edge} from '@xyflow/react'; +import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; +import { + type InferredBelief, + InferredBeliefConnectionTarget, + InferredBeliefDisconnectionTarget, + InferredBeliefReduce, +} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx'; + +// helper functions +function inferredNode(overrides = {}): Node { + return { + id: 'i1', + type: 'inferred_belief', + position: {x: 0, y: 0}, + data: { + inferredBelief: { + left: undefined, + operator: true, + right: undefined, + }, + ...overrides, + }, + } as Node; +} + +describe('InferredBelief connection logic', () => { + let getStateSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + getStateSpy = jest.spyOn(FlowStore.default, 'getState'); + }); + + it('sets left belief when connected on beliefLeft handle', () => { + const node = inferredNode(); + + getStateSpy.mockReturnValue({ + nodes: [{ id: 'b1', type: 'basic_belief' }], + edges: [ + { + source: 'b1', + target: 'i1', + targetHandle: 'beliefLeft', + } as Edge, + ], + } as any); + + InferredBeliefConnectionTarget(node, 'b1'); + + expect((node.data.inferredBelief as InferredBelief).left).toBe('b1'); + expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined(); + }); + + it('sets right belief when connected on beliefRight handle', () => { + const node = inferredNode(); + + getStateSpy.mockReturnValue({ + nodes: [{ id: 'b2', type: 'basic_belief' }], + edges: [ + { + source: 'b2', + target: 'i1', + targetHandle: 'beliefRight', + } as Edge, + ], + } as any); + + InferredBeliefConnectionTarget(node, 'b2'); + + expect((node.data.inferredBelief as InferredBelief).right).toBe('b2'); + }); + + it('ignores connections from unsupported node types', () => { + const node = inferredNode(); + + getStateSpy.mockReturnValue({ + nodes: [{ id: 'x', type: 'norm' }], + edges: [], + } as any); + + InferredBeliefConnectionTarget(node, 'x'); + + expect((node.data.inferredBelief as InferredBelief).left).toBeUndefined(); + expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined(); + }); + + it('clears left or right belief on disconnection', () => { + const node = inferredNode({ + inferredBelief: { left: 'a', right: 'b', operator: true }, + }); + + InferredBeliefDisconnectionTarget(node, 'a'); + expect((node.data.inferredBelief as InferredBelief).left).toBeUndefined(); + + InferredBeliefDisconnectionTarget(node, 'b'); + expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined(); + }); +}); + +describe('InferredBeliefReduce', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws if left belief is missing', () => { + const node = inferredNode({ + inferredBelief: { left: 'l', right: 'r', operator: true }, + }); + + expect(() => + InferredBeliefReduce(node, [{ id: 'r' } as Node]) + ).toThrow('No Left belief found'); + }); + + it('throws if right belief is missing', () => { + const node = inferredNode({ + inferredBelief: { left: 'l', right: 'r', operator: true }, + }); + + expect(() => + InferredBeliefReduce(node, [{ id: 'l' } as Node]) + ).toThrow('No Right Belief found'); + }); + +}); + +