From a5a345b9a94c241ae712f8c20aa2970832e86131 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 8 Jan 2026 14:38:37 +0100 Subject: [PATCH] test: add tests for goal and triggers --- .../visualProgrammingUI/nodes/TriggerNode.tsx | 14 - .../nodes/GoalNode.test.tsx | 253 ++++++++++++++++++ .../nodes/TriggerNode.test.tsx | 49 +++- 3 files changed, 298 insertions(+), 18 deletions(-) create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/GoalNode.test.tsx diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 9b47bd4..0cd5d6d 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'; @@ -40,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`. 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 9b6bd1d..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', () => { @@ -134,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(); }); }); });