diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 509615e..00268eb 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -164,6 +164,14 @@ text-decoration: line-through; } +.bottomLeftHandle { + left: 40% !important; +} + +.bottomRightHandle { + left: 60% !important; +} + .node-toolbar-tooltip { background-color: darkgray; color: white; diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts index b2ea31b..87d7d94 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts @@ -1,7 +1,7 @@ -import type { Plan } from "./Plan"; +import type { Plan, PlanElement } from "./Plan"; export const defaultPlan: Plan = { name: "Default Plan", id: "-1", - steps: [], + steps: [] as PlanElement[], } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx index a385ca5..3bc2825 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx @@ -1,3 +1,7 @@ +import { type Node } from "@xyflow/react" +import { GoalReduce } from "../nodes/GoalNode" + + export type Plan = { name: string, id: string, @@ -7,10 +11,7 @@ export type Plan = { export type PlanElement = Goal | Action export type Goal = { - id: string, - name: string, - plan: Plan, - can_fail: boolean, + id: string // we let the reducer figure out the rest dynamically type: "goal" } @@ -19,23 +20,24 @@ export type Action = SpeechAction | GestureAction | LLMAction export type SpeechAction = { id: string, text: string, type:"speech" } export type GestureAction = { id: string, gesture: string, isTag: boolean, type:"gesture" } export type LLMAction = { id: string, goal: string, type:"llm" } - export type ActionTypes = "speech" | "gesture" | "llm"; // Extract the wanted information from a plan within the reducing of nodes -export function PlanReduce(plan?: Plan) { +export function PlanReduce(_nodes: Node[], plan?: Plan, ) { if (!plan) return "" return { id: plan.id, - steps: plan.steps.map((x) => StepReduce(x)) + steps: plan.steps.map((x) => StepReduce(x, _nodes)) } } // Extract the wanted information from a plan element. -function StepReduce(planElement: PlanElement) { +function StepReduce(planElement: PlanElement, _nodes: Node[]) : Record { // We have different types of plan elements, requiring differnt types of output + const nodes = _nodes + const thisNode = _nodes.find((x) => x.id === planElement.id) switch (planElement.type) { case ("speech"): return { @@ -56,12 +58,7 @@ function StepReduce(planElement: PlanElement) { goal: planElement.goal, } case ("goal"): - return { - id: planElement.id, - plan: planElement.plan, - can_fail: planElement.can_fail, - }; - default: + return thisNode ? GoalReduce(thisNode, nodes) : {} } } @@ -71,10 +68,36 @@ function StepReduce(planElement: PlanElement) { * @param plan: the plan to check * @returns: a boolean */ -export function DoesPlanIterate(plan?: Plan) : boolean { +export function DoesPlanIterate( _nodes: Node[], plan?: Plan,) : boolean { // TODO: should recursively check plans that have goals (and thus more plans) in them. if (!plan) return false - return plan.steps.filter((step) => step.type == "llm").length > 0; + return plan.steps.filter((step) => step.type == "llm").length > 0 || + ( + // Find the goal node of this step + plan.steps.filter((step) => step.type == "goal").map((goalStep) => { + const goalId = goalStep.id; + const goalNode = _nodes.find((x) => x.id === goalId); + // In case we don't find any valid plan, this node doesn't iterate + if (!goalNode || !goalNode.data.plan) return false; + // Otherwise, check if this node can fail - if so, we should have the option to iterate + return (goalNode && goalNode.data.plan && goalNode.data.can_fail) + }) + ).includes(true); +} + +/** + * Checks if any of the plan's goal steps has its can_fail value set to true. + * @param plan: plan to check + * @param _nodes: nodes in flow store. + */ +export function HasCheckingSubGoal(plan: Plan, _nodes: Node[]) { + const goalSteps = plan.steps.filter((x) => x.type == "goal"); + return goalSteps.map((goalStep) => { + // Find the goal node and check its can_fail data boolean. + const goalId = goalStep.id; + const goalNode = _nodes.find((x) => x.id === goalId); + return (goalNode && goalNode.data.can_fail) + }).includes(true); } /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditingFunctions.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditingFunctions.tsx new file mode 100644 index 0000000..9e7e446 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditingFunctions.tsx @@ -0,0 +1,35 @@ +// This file is to avoid sharing both functions and components which eslint dislikes. :) +import type { GoalNode } from "../nodes/GoalNode" +import type { Goal, Plan } from "./Plan" + +/** + * Inserts a goal into a plan + * @param plan: plan to insert goal into + * @param goalNode: the goal node to insert into the plan. + * @returns: a new plan with the goal inside. + */ +export function insertGoalInPlan(plan: Plan, goalNode: GoalNode): Plan { + const planElement : Goal = { + id: goalNode.id, + type: "goal", + } + + return { + ...plan, + steps: [...plan.steps, planElement], + } +} + + +/** + * Deletes a goal from a plan + * @param plan: plan to delete goal from + * @param goalID: the goal node to delete. + * @returns: a new plan with the goal removed. + */ +export function deleteGoalInPlanByID(plan: Plan, goalID: string) { + const updatedPlan = {...plan, + steps: plan.steps.filter((x) => x.id !== goalID) + } + return updatedPlan.steps.length == 0 ? undefined : updatedPlan +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx index 19b590b..2c2d098 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx @@ -23,7 +23,9 @@ export default function PlanEditorDialog({ const [newActionType, setNewActionType] = useState("speech"); const [newActionGestureType, setNewActionGestureType] = useState(true); const [newActionValue, setNewActionValue] = useState(""); + const [hasInteractedWithPlan, setHasInteractedWithPlan] = useState(false) const { setScrollable } = useFlowStore(); + const nodes = useFlowStore().nodes; //Button Actions const openCreate = () => { @@ -55,6 +57,7 @@ export default function PlanEditorDialog({ const buildAction = (): Action => { const id = crypto.randomUUID(); + setHasInteractedWithPlan(true) switch (newActionType) { case "speech": return { id, text: newActionValue, type: "speech" }; @@ -102,7 +105,7 @@ export default function PlanEditorDialog({
{/* Left Side (Action Adder) */}

Add Action

- {(!plan && description && draftPlan.steps.length === 0) && (
+ {(!plan && description && draftPlan.steps.length === 0 && !hasInteractedWithPlan) && (
)} @@ -196,9 +199,13 @@ export default function PlanEditorDialog({ {index + 1}. {step.type}: - { - step.type == "goal" ? ""/* TODO: Add support for goals */ - : GetActionValue(step)} + + { + // This just tries to find the goals name, i know it looks ugly:( + step.type === "goal" + ? ((nodes.find(x => x.id === step.id)?.data.name as string) == "" ? + "unnamed goal": (nodes.find(x => x.id === step.id)?.data.name as string)) + : (GetActionValue(step) ?? "")}
))} diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index 3594c33..bed642f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -6,7 +6,7 @@ import { import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx"; -import {allowOnlyConnectionsFromType} from "../HandleRules.ts"; +import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts"; import useFlowStore from '../VisProgStores'; import { TextField } from '../../../../components/TextField'; import { MultilineTextField } from '../../../../components/MultilineTextField'; @@ -124,7 +124,7 @@ export default function BasicBeliefNode(props: NodeProps) { wrapping = '"' break; case ("semantic"): - placeholder = "description..." + placeholder = "short description..." wrapping = '"' break; case ("object"): @@ -184,12 +184,12 @@ export default function BasicBeliefNode(props: NodeProps) {
)} diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 4d4c455..fea9914 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -7,11 +7,13 @@ import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import { TextField } from '../../../../components/TextField'; import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx"; -import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts"; +import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts"; import useFlowStore from '../VisProgStores'; -import { DoesPlanIterate, PlanReduce, type Plan } from '../components/Plan'; +import {DoesPlanIterate, HasCheckingSubGoal, PlanReduce, type Plan } from '../components/Plan'; import PlanEditorDialog from '../components/PlanEditor'; import { MultilineTextField } from '../../../../components/MultilineTextField'; +import { defaultPlan } from '../components/Plan.default.ts'; +import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx'; /** * The default data dot a phase node @@ -43,10 +45,12 @@ export type GoalNode = Node */ export default function GoalNode({id, data}: NodeProps) { const {updateNodeData} = useFlowStore(); + const _nodes = useFlowStore().nodes; const text_input_id = `goal_${id}_text_input`; const checkbox_id = `goal_${id}_checkbox`; - const planIterate = DoesPlanIterate(data.plan); + const planIterate = DoesPlanIterate(_nodes, data.plan); + const hasCheckSubGoal = data.plan !== undefined && HasCheckingSubGoal(data.plan, _nodes) const setDescription = (value: string) => { updateNodeData(id, {...data, description: value}); @@ -73,7 +77,7 @@ export default function GoalNode({id, data}: NodeProps) { /> - {data.can_fail && (
+ {(data.can_fail || hasCheckSubGoal) && (
) { planIterate ? setFailable(e.target.checked) : setFailable(false)} />
@@ -115,6 +119,10 @@ export default function GoalNode({id, data}: NodeProps) { + + + +
; } @@ -131,8 +139,8 @@ export function GoalReduce(node: Node, _nodes: Node[]) { id: node.id, name: data.name, description: data.description, - can_fail: data.can_fail, - plan: data.plan ? PlanReduce(data.plan) : "", + can_fail: data.can_fail || (data.plan && HasCheckingSubGoal(data.plan, _nodes)), + plan: data.plan ? PlanReduce(_nodes, data.plan) : "", } } @@ -147,7 +155,22 @@ export const GoalTooltip = ` * @param _sourceNodeId the source of the received connection */ export function GoalConnectionTarget(_thisNode: Node, _sourceNodeId: string) { - // no additional connection logic exists yet + // Goals should only be targeted by other goals, for them to be part of our plan. + const nodes = useFlowStore.getState().nodes; + const otherNode = nodes.find((x) => x.id === _sourceNodeId) + if (!otherNode || otherNode.type !== "goal") return; + + const data = _thisNode.data as GoalNodeData + + // First, let's see if we have a plan currently. If not, let's create a default plan with this goal inside.:) + if (!data.plan) { + data.plan = insertGoalInPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()} as Plan, otherNode as GoalNode) + } + + // Else, lets just insert this goal into our current plan. + else { + data.plan = insertGoalInPlan(structuredClone(data.plan), otherNode as GoalNode) + } } /** @@ -165,7 +188,9 @@ export function GoalConnectionSource(_thisNode: Node, _targetNodeId: string) { * @param _sourceNodeId the source of the disconnected connection */ export function GoalDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { - // no additional connection logic exists yet + // We should probably check if our disconnection was by a goal, since it would mean we have to remove it from our plan list. + const data = _thisNode.data as GoalNodeData + data.plan = deleteGoalInPlanByID(structuredClone(data.plan) as Plan, _sourceNodeId) } /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 7428d51..6cde46a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -80,7 +80,7 @@ export default function NormNode(props: NodeProps) { -
diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 9a48103..50e81b6 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -108,7 +108,10 @@ export function PhaseReduce(node: Node, nodes: Node[]) { console.warn(`No reducer found for node type ${type}`); result[type + "s"] = []; } else { - result[type + "s"] = typedChildren.map((child) => reducer(child, nodes)); + result[type + "s"] = []; + for (const typedChild of typedChildren) { + (result[type + "s"] as object[]).push(reducer(typedChild, nodes)) + } } }); diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts index 2a63661..9c1b92f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts @@ -5,6 +5,7 @@ import type { TriggerNodeData } from "./TriggerNode"; */ export const TriggerNodeDefaults: TriggerNodeData = { label: "Trigger Node", + name: "", droppable: true, hasReduce: true, }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index a4d35b0..8b3378a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -1,8 +1,6 @@ import { type NodeProps, Position, - type Connection, - type Edge, type Node, } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; @@ -10,9 +8,13 @@ import styles from '../../VisProg.module.css'; import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts"; import useFlowStore from '../VisProgStores'; -import { PlanReduce, type Plan } from '../components/Plan'; +import {PlanReduce, type Plan } from '../components/Plan'; import PlanEditorDialog from '../components/PlanEditor'; import { BasicBeliefReduce } from './BasicBeliefNode'; +import type { GoalNode } from './GoalNode.tsx'; +import { defaultPlan } from '../components/Plan.default.ts'; +import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx'; +import { TextField } from '../../../../components/TextField.tsx'; /** * The default data structure for a Trigger node @@ -26,6 +28,7 @@ import { BasicBeliefReduce } from './BasicBeliefNode'; */ export type TriggerNodeData = { label: string; + name: string; droppable: boolean; condition?: string; // id of the belief plan?: Plan; @@ -35,18 +38,6 @@ export type TriggerNodeData = { export type TriggerNode = Node - -/** - * Determines whether a Trigger node can connect to another node or edge. - * - * @param connection - The connection or edge being attempted to connect towards. - * @returns `true` if the connection is defined; otherwise, `false`. - * - */ -export function TriggerNodeCanConnect(connection: Connection | Edge): boolean { - return (connection != undefined); -} - /** * Defines how a Trigger node should be rendered * @param props - Node properties provided by React Flow, including `id` and `data`. @@ -55,20 +46,45 @@ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean { export default function TriggerNode(props: NodeProps) { const data = props.data; const {updateNodeData} = useFlowStore(); + + const setName= (value: string) => { + updateNodeData(props.id, {...data, name: value}) + } return <> +
+ setName(val)} + placeholder={"Name of this trigger..."} + />
Triggers when the condition is met.
Condition/ Belief is currently {data.condition ? "" : "not"} set. {data.condition ? "🟢" : "🔴"}
Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}
- + + node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { + const nodes = useFlowStore.getState().nodes; + const otherNode = nodes.find((x) => x.id === _sourceNodeId) + if (!otherNode) return; + + if (otherNode.type === 'basic_belief' /* TODO: Add the option for an inferred belief */) { data.condition = _sourceNodeId; + } + + else if (otherNode.type === 'goal') { + // First, let's see if we have a plan currently. If not, let's create a default plan with this goal inside.:) + if (!data.plan) { + data.plan = insertGoalInPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()} as Plan, otherNode as GoalNode) } + + // Else, lets just insert this goal into our current plan. + else { + data.plan = insertGoalInPlan(structuredClone(data.plan), otherNode as GoalNode) + } + } } /** @@ -139,6 +172,8 @@ 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/components/PlanEditor.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx index dd9b149..671a115 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx @@ -1,11 +1,15 @@ -// PlanEditorDialog.test.tsx import { describe, it, beforeEach, jest } from '@jest/globals'; import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { Node } from '@xyflow/react'; import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; import PlanEditorDialog from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor'; import { PlanReduce, type Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan'; import '@testing-library/jest-dom'; +import { GoalReduce, type GoalNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx'; +import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts'; +import { insertGoalInPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditingFunctions.tsx'; + // Mock structuredClone (globalThis as any).structuredClone = jest.fn((val) => JSON.parse(JSON.stringify(val))); @@ -468,38 +472,50 @@ describe('PlanEditorDialog', () => { describe('Plan reducing', () => { it('should correctly reduce the plan given the elements of the plan', () => { - const testplan = extendedPlan - const expectedResult = { - id: "extended-plan-1", - steps: [ - { - id: "firststep", - gesture: { - type: "tag", - name: "hello" - } - }, - { - id: "secondstep", - gesture: { - type: "single", - name: "somefolder/somegesture" - } - }, - { - id: "thirdstep", - goal: "ask the user something or whatever" - }, - { - id: "fourthstep", - text: "I'm a cyborg ninja :>" + // Create a plan for testing + const testplan = extendedPlan + const mockGoalNode: Node = { + id: 'goal-1', + type: 'goal', + position: { x: 0, y: 0 }, + data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'mock goal', plan: defaultPlan }, + }; + + // Insert the goal and retrieve its expected data + const newTestPlan = insertGoalInPlan(testplan, mockGoalNode) + const goalReduced = GoalReduce(mockGoalNode, [mockGoalNode]) + const expectedResult = { + id: "extended-plan-1", + steps: [ + { + id: "firststep", + gesture: { + type: "tag", + name: "hello" } - ] - } - - const actualResult = PlanReduce(testplan) - - expect(actualResult).toEqual(expectedResult) + }, + { + id: "secondstep", + gesture: { + type: "single", + name: "somefolder/somegesture" + } + }, + { + id: "thirdstep", + goal: "ask the user something or whatever" + }, + { + id: "fourthstep", + text: "I'm a cyborg ninja :>" + }, + goalReduced, + ] + } + + // Check to see it the goal got added, and its reduced data was added to the goals' + const actualResult = PlanReduce([mockGoalNode], newTestPlan) + expect(actualResult).toEqual(expectedResult) }); }) }); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/GoalNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/GoalNode.test.tsx new file mode 100644 index 0000000..96f7fd9 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/GoalNode.test.tsx @@ -0,0 +1,253 @@ +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.tsx'; +import GoalNode, { GoalReduce, type GoalNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode'; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; +import type { Node } from '@xyflow/react'; +import '@testing-library/jest-dom'; +import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts'; +import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts'; + +describe('GoalNode', () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + jest.clearAllMocks(); + }); + + it('renders the Goal node with default data', () => { + const mockNode: Node = { + id: 'goal-1', + type: 'goal', + position: { x: 0, y: 0 }, + data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)) }, + }; + + renderWithProviders( + + ); + + expect(screen.getByPlaceholderText('To ...')).toBeInTheDocument(); + }); + + it('updates goal name when user types and commits', async () => { + const mockNode: Node = { + id: 'goal-2', + type: 'goal', + position: { x: 0, y: 0 }, + data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: '' }, + }; + + useFlowStore.setState({ nodes: [mockNode], edges: [] }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('To ...'); + + await user.type(input, 'Save the world{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updated = state.nodes.find(n => n.id === 'goal-2'); + expect(updated?.data.name).toBe('Save the world'); + }); + }); + + it('shows plan message and disabled checked checkbox when plan does not iterate', () => { + const mockNode: Node = { + id: 'goal-3', + type: 'goal', + position: { x: 0, y: 0 }, + data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), plan: defaultPlan, name: 'G' }, + }; + + useFlowStore.setState({ nodes: [mockNode], edges: [] }); + + renderWithProviders( + + ); + + expect(screen.getByText(/Will follow plan 'Default Plan' until all steps complete./i)).toBeInTheDocument(); + + const checkbox = screen.getByLabelText(/This plan always succeeds!/i) as HTMLInputElement; + expect(checkbox).toBeDisabled(); + expect(checkbox.checked).toBe(true); + }); + + it('allows toggling can_fail when plan iterates', async () => { + // plan with an llm-step will make DoesPlanIterate return true + const iterPlan = { ...defaultPlan, id: 'p-iter', steps: [{ id: 'a-1', type: 'llm', goal: 'do' }] } as any; + const mockNode: Node = { + id: 'goal-4', + type: 'goal', + position: { x: 0, y: 0 }, + data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), plan: iterPlan, name: 'Iterating' }, + }; + + useFlowStore.setState({ nodes: [mockNode], edges: [] }); + + renderWithProviders( + + ); + + const checkbox = screen.getByLabelText(/Check if this plan fails/i) as HTMLInputElement; + expect(checkbox).not.toBeDisabled(); + expect(checkbox.checked).toBe(false); + + await user.click(checkbox); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updated = state.nodes.find(n => n.id === 'goal-4'); + expect(updated?.data.can_fail).toBe(true); + }); + }); + + it('disables the checkbox and shows description when plan includes a checking sub-goal', () => { + const childGoal: Node = { + id: 'child-1', + type: 'goal', + position: { x: 0, y: 0 }, + data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), can_fail: true }, + }; + + const p = { ...defaultPlan, id: 'p-2', steps: [{ id: 'child-1', type: 'goal' } as any] } as any; + + const mockNode: Node = { + id: 'goal-5', + type: 'goal', + position: { x: 0, y: 0 }, + data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), plan: p, name: 'HasCheck' }, + }; + + useFlowStore.setState({ nodes: [mockNode, childGoal], edges: [] }); + + renderWithProviders( + + ); + + const checkbox = screen.getByRole('checkbox') as HTMLInputElement; + expect(checkbox).toBeDisabled(); + expect(checkbox.checked).toBe(true); + + // description box should be visible because there's a checking subgoal + expect(screen.getByPlaceholderText('Describe the condition of this goal...')).toBeInTheDocument(); + }); + + it('reduces its data correctly (GoalReduce)', () => { + const childGoal: Node = { + id: 'child-2', + type: 'goal', + position: { x: 0, y: 0 }, + data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), can_fail: true }, + }; + + const p = { ...defaultPlan, id: 'p-3', steps: [{ id: 'child-2', type: 'goal' } as any] } as any; + + const mockNode: Node = { + id: 'goal-6', + type: 'goal', + position: { x: 0, y: 0 }, + data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), plan: p, name: 'ReduceMe', description: 'desc', can_fail: false }, + }; + + const reduced = GoalReduce(mockNode, [mockNode, childGoal]); + expect(reduced).toEqual({ + id: 'goal-6', + name: 'ReduceMe', + description: 'desc', + can_fail: true, + plan: { + id: expect.anything(), + steps: expect.any(Array), + } + }); + }); + + it('adds a goal into a plan when a goal is connected to another', () => { + const source: Node = { id: 'g-src', type: 'goal', position: { x: 0, y: 0 }, data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'Source' } }; + const target: Node = { id: 'g-target', type: 'goal', position: { x: 0, y: 0 }, data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'Target' } }; + + useFlowStore.setState({ nodes: [source, target], edges: [] }); + + // Simulate react-flow connect + useFlowStore.getState().onConnect({ source: 'g-src', target: 'g-target', sourceHandle: null, targetHandle: null }); + + const state = useFlowStore.getState(); + const updatedTarget = state.nodes.find(n => n.id === 'g-target'); + + expect(updatedTarget?.data.plan).toBeDefined(); + const plan = updatedTarget?.data.plan as any; + expect(plan.steps.length).toBe(1); + expect(plan.steps[0].id).toBe('g-src'); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx index b850fa0..83bcf34 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -3,7 +3,6 @@ import { screen } from '@testing-library/react'; import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; import TriggerNode, { TriggerReduce, - TriggerNodeCanConnect, type TriggerNodeData, TriggerConnectionSource, TriggerConnectionTarget } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode'; @@ -14,6 +13,8 @@ import { TriggerNodeDefaults } from '../../../../../src/pages/VisProgPage/visual import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts'; import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts'; import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts'; +import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts'; +import { act } from 'react-dom/test-utils'; describe('TriggerNode', () => { @@ -73,7 +74,8 @@ describe('TriggerNode', () => { data: { ...JSON.parse(JSON.stringify(TriggerNodeDefaults)), condition: "belief-1", - plan: defaultPlan + plan: defaultPlan, + name: "trigger-1" }, }; @@ -93,6 +95,7 @@ describe('TriggerNode', () => { expect(result).toEqual({ id: 'trigger-1', + name: "trigger-1", condition: { id: "belief-1", keyword: "", @@ -132,10 +135,50 @@ describe('TriggerNode', () => { TriggerConnectionTarget(node1, node2.id); }).not.toThrow(); }); + }); - it('should return true for TriggerNodeCanConnect if connection exists', () => { - const connection = { source: 'trigger-1', target: 'norm-1' }; - expect(TriggerNodeCanConnect(connection as any)).toBe(true); + + describe('TriggerConnects Function', () => { + it('should correctly remove a goal from the triggers plan after it has been disconnected', () => { + // first, define the goal node and trigger node. + const goal: Node = { + id: 'g-1', + type: 'goal', + position: { x: 0, y: 0 }, + data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'Goal 1' }, + }; + + const trigger: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { ...JSON.parse(JSON.stringify(TriggerNodeDefaults)) }, + }; + + // set initial store + useFlowStore.setState({ nodes: [goal, trigger], edges: [] }); + + // then, connect the goal to the trigger. + act(() => { + useFlowStore.getState().onConnect({ source: 'g-1', target: 'trigger-1', sourceHandle: null, targetHandle: null }); + }); + + // expect the goal id to be part of a goal step of the plan. + let updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1'); + expect(updatedTrigger?.data.plan).toBeDefined(); + const plan = updatedTrigger?.data.plan as any; + expect(plan.steps.find((s: any) => s.id === 'g-1')).toBeDefined(); + + // then, disconnect the goal from the trigger. + act(() => { + useFlowStore.getState().onEdgesDelete([{ id: 'g-1-trigger-1', source: 'g-1', target: 'trigger-1' } as any]); + }); + + // finally, expect the goal id to NOT be part of the goal step of the plan. + updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1'); + const planAfter = updatedTrigger?.data.plan as any; + const stillHas = planAfter?.steps?.find((s: any) => s.id === 'g-1'); + expect(stillHas).toBeUndefined(); }); }); });