From 10d5a15c886b09e849f4b9c85289beb76cbb0fab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 11 Dec 2025 14:12:26 +0100 Subject: [PATCH 01/19] feat: basic belief node with the basic belief types defined in KB. ref: N25B-408 --- src/App.css | 8 + src/pages/VisProgPage/VisProg.module.css | 13 ++ .../visualProgrammingUI/NodeRegistry.ts | 9 +- .../nodes/BasicBeliefNode.default.ts | 12 ++ .../nodes/BasicBeliefNode.tsx | 174 ++++++++++++++++++ 5 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts create mode 100644 src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx diff --git a/src/App.css b/src/App.css index a241d03..8e078f6 100644 --- a/src/App.css +++ b/src/App.css @@ -248,3 +248,11 @@ button.no-button { text-decoration: underline; } } + +.flex-center-x { + display: flex; + justify-content: center; /* horizontal centering */ + text-align: center; /* center multi-line text */ + width: 100%; /* allow it to stretch */ + flex-wrap: wrap; /* optional: let text wrap naturally */ +} \ No newline at end of file diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 5f2aa78..14619c5 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -71,6 +71,11 @@ filter: drop-shadow(0 0 0.25rem red); } +.node-basic_belief { + outline: plum solid 2pt; + filter: drop-shadow(0 0 0.25rem plum); +} + .draggable-node { padding: 3px 10px; background-color: canvas; @@ -126,3 +131,11 @@ outline: red solid 2pt; filter: drop-shadow(0 0 0.25rem red); } + +.draggable-node-basic_belief { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: plum solid 2pt; + filter: drop-shadow(0 0 0.25rem plum); +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 8812434..04dabf1 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -10,6 +10,8 @@ import GoalNode, { GoalConnects, GoalReduce } from "./nodes/GoalNode"; import { GoalNodeDefaults } from "./nodes/GoalNode.default"; import TriggerNode, { TriggerConnects, TriggerReduce } from "./nodes/TriggerNode"; import { TriggerNodeDefaults } from "./nodes/TriggerNode.default"; +import BasicBeliefNode, { BasicBeliefConnects, BasicBeliefReduce } from "./nodes/BasicBeliefNode"; +import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default"; /** * Registered node types in the visual programming system. @@ -24,6 +26,7 @@ export const NodeTypes = { norm: NormNode, goal: GoalNode, trigger: TriggerNode, + basic_belief: BasicBeliefNode, }; /** @@ -38,6 +41,7 @@ export const NodeDefaults = { norm: NormNodeDefaults, goal: GoalNodeDefaults, trigger: TriggerNodeDefaults, + basic_belief: BasicBeliefNodeDefaults, }; @@ -54,6 +58,7 @@ export const NodeReduces = { norm: NormReduce, goal: GoalReduce, trigger: TriggerReduce, + basic_belief: BasicBeliefReduce, } @@ -69,6 +74,7 @@ export const NodeConnects = { norm: NormConnects, goal: GoalConnects, trigger: TriggerConnects, + basic_belief: BasicBeliefConnects, } /** @@ -79,7 +85,6 @@ export const NodeConnects = { export const NodeDeletes = { start: () => false, end: () => false, - test: () => false, // Used for coverage of universal/ undefined nodes } /** @@ -92,5 +97,5 @@ export const NodesInPhase = { start: () => false, end: () => false, phase: () => false, - test: () => false, // Used for coverage of universal/ undefined nodes + // basic_belief: () => false, } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts new file mode 100644 index 0000000..72066c4 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts @@ -0,0 +1,12 @@ +import type { BasicBeliefNodeData } from "./BasicBeliefNode"; + + +/** + * Default data for this node + */ +export const BasicBeliefNodeDefaults: BasicBeliefNodeData = { + label: "Belief", + droppable: true, + belief: {type: "keyword", id: "help", value: "help", label: "Keyword said:"}, + hasReduce: true, +}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx new file mode 100644 index 0000000..317a1eb --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -0,0 +1,174 @@ +import { + Handle, + type NodeProps, + Position, + type Connection, + type Edge, + type Node, +} from '@xyflow/react'; +import { Toolbar } from '../components/NodeComponents'; +import styles from '../../VisProg.module.css'; +import useFlowStore from '../VisProgStores'; +import { TextField } from '../../../../components/TextField'; + +/** + * The default data structure for a BasicBelief node + * + * Represents configuration for a node that activates when a specific condition is met, + * such as keywords being spoken or emotions detected. + * + * @property label: the display label of this BasicBelief node. + * @property droppable: Whether this node can be dropped from the toolbar (default: true). + * @property BasicBeliefType - The type of BasicBelief ("keywords" or a custom string). + * @property BasicBeliefs - The list of keyword BasicBeliefs (if applicable). + * @property hasReduce - Whether this node supports reduction logic. + */ +export type BasicBeliefNodeData = { + label: string; + droppable: boolean; + belief: BasicBeliefType; + hasReduce: boolean; +}; + +// These are all the types a basic belief could be. +type BasicBeliefType = Keyword | Semantic | Object | Emotion +type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"}; +type Semantic = { type: "semantic", id: string, value: string, label: "Detected with LLM:"}; +type Object = { type: "object", id: string, value: string, label: "Object found:"}; +type Emotion = { type: "emotion", id: string, value: string, label: "Emotion recognised:"}; + +export type BasicBeliefNode = Node + + +/** + * Determines whether a BasicBelief 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 BasicBeliefNodeCanConnect(connection: Connection | Edge): boolean { + return (connection != undefined); +} + +/** + * Defines how a BasicBelief node should be rendered + * @param props - Node properties provided by React Flow, including `id` and `data`. + * @returns The rendered BasicBeliefNode React element (React.JSX.Element). + */ +export default function BasicBeliefNode(props: NodeProps) { + const data = props.data; + const {updateNodeData} = useFlowStore(); + const updateValue = (value: string) => updateNodeData(props.id, {...data, belief: {...data.belief, value: value}}); + const label_input_id = `basic_belief_${props.id}_label_input`; + + type BeliefString = BasicBeliefType["type"]; + + function updateBeliefType(newType: BeliefString) { + updateNodeData(props.id, { + ...data, + belief: { + ...data.belief, + type: newType, + }, + }); + } + + + // Use this + const emotionOptions = ["Happy", "Angry", "Sad", "Cheerful"] + + + let placeholder = "" + let wrapping = "" + switch (props.data.belief.type) { + case ("keyword"): + placeholder = "keyword..." + wrapping = '"' + break; + case ("semantic"): + placeholder = "word..." + wrapping = '"' + break; + case ("object"): + placeholder = "object..." + break; + case ("emotion"): + // TODO: emotion should probably be a drop-down menu rather than a string + // So this placeholder won't hold for always + placeholder = "emotion..." + break; + default: + break; + } + + return ( + <> + +
+
+ +
+
+ + {wrapping} + + {data.belief.type === "emotion" && ( + + )} + + + {data.belief.type !== "emotion" && + ()} + {wrapping} +
+ +
+ + ); +}; + +/** + * Reduces each BasicBelief, including its children down into its core data. + * @param node - The BasicBelief node to reduce. + * @param _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 BasicBeliefReduce(node: BasicBeliefNode, _nodes: Node[]) { + const data = node.data; + return { + id: node.id, + type: data.belief.type, + value: data.belief.value + } +} + +/** + * This function is called whenever a connection is made with this node type (BasicBelief) + * @param _thisNode the node of this node type which function is called + * @param _otherNode the other node which was part of the connection + * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. + */ +export function BasicBeliefConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { +} \ No newline at end of file From 9d4f10213e1011bd4486fef5f009f40255f41ed8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 15 Dec 2025 11:59:12 +0100 Subject: [PATCH 02/19] fix: update the recducer in phases to account for node-specific reducing ref: N25B-408 --- src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts | 2 +- src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 04dabf1..97f5de6 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -97,5 +97,5 @@ export const NodesInPhase = { start: () => false, end: () => false, phase: () => false, - // basic_belief: () => false, + basic_belief: () => false, } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index c8ea2c0..112cfbf 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -96,7 +96,7 @@ 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"] = typedChildren.map((child) => reducer(child as any, nodes)); } }); From f22fe38e225234de561671a6464a895c8053637a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 15 Dec 2025 12:01:39 +0100 Subject: [PATCH 03/19] fix: revert the reduce change for eslint- might be done later in other way ref: N25B-408 --- .../VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx | 4 ++-- src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index 317a1eb..4f73a2d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -155,8 +155,8 @@ export default function BasicBeliefNode(props: NodeProps) { * @param _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 BasicBeliefReduce(node: BasicBeliefNode, _nodes: Node[]) { - const data = node.data; +export function BasicBeliefReduce(node: Node, _nodes: Node[]) { + const data = node.data as BasicBeliefNodeData; return { id: node.id, type: data.belief.type, diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 112cfbf..c8ea2c0 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -96,7 +96,7 @@ 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 as any, nodes)); + result[type + "s"] = typedChildren.map((child) => reducer(child, nodes)); } }); From 757435e9f83e7511f17a4544a6e168d9a6029b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 15 Dec 2025 12:09:53 +0100 Subject: [PATCH 04/19] fix: fix the tests and creation of nodes. ref: N25B-408 --- .../visualProgrammingUI/VisProgStores.tsx | 22 ++++++++++--------- .../nodes/UniversalNodes.test.tsx | 16 ++++++++------ 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 5154a8e..3952e72 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -23,17 +23,19 @@ import { UndoRedo } from "./EditorUndoRedo.ts"; * @param deletable - Optional flag to indicate if the node can be deleted (can be deleted by default). * @returns A fully initialized Node object ready to be added to the flow. */ -function createNode(id: string, type: string, position: XYPosition, data: Record, deletable? : boolean) { - const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] - const newData = { - id: id, - type: type, - position: position, - data: data, - deletable: deletable, +function createNode(id: string, type: string, position: XYPosition, data: Record, deletable?: boolean) { + const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] + return { + id, + type, + position, + deletable, + data: { + ...defaultData, + ...data, + }, + } } - return {...defaultData, ...newData} -} //* Initial nodes, created by using createNode. */ const initialNodes : Node[] = [ diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 7fb0709..ba19230 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -15,14 +15,16 @@ describe('NormNode', () => { function createNode(id: string, type: string, position: XYPosition, data: Record, deletable?: boolean) { const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] - const newData = { - id: id, - type: type, - position: position, - data: data, - deletable: deletable, + return { + id, + type, + position, + deletable, + data: { + ...defaultData, + ...data, + }, } - return {...defaultData, ...newData} } From ae8ef317a4c814187ee994ca643a790abb280fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 15 Dec 2025 13:04:53 +0100 Subject: [PATCH 05/19] test: tests for belief node ref: N25B-408 --- .../nodes/BasicBeliefNode.tsx | 3 +- .../nodes/BeliefNode.test.tsx | 743 ++++++++++++++++++ 2 files changed, 744 insertions(+), 2 deletions(-) create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index 4f73a2d..4942d48 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -46,8 +46,7 @@ export type BasicBeliefNode = Node * @param connection - The connection or edge being attempted to connect towards. * @returns `true` if the connection is defined; otherwise, `false`. */ -export function BasicBeliefNodeCanConnect(connection: Connection | Edge): boolean { - return (connection != undefined); +export function BasicBeliefNodeCanConnect(_connection: Connection | Edge) { } /** diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx new file mode 100644 index 0000000..07a69ef --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx @@ -0,0 +1,743 @@ +// BasicBeliefNode.test.tsx +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 useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; +import type { Node } from '@xyflow/react'; +import '@testing-library/jest-dom'; + +describe('BasicBeliefNode', () => { + let user: ReturnType; + + beforeEach(() => { + user = userEvent.setup(); + }); + + describe('Rendering', () => { + it('should render the basic belief node with keyword type by default', () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'keyword', id: 'help', value: 'help', label: 'Keyword said:' }, + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByText('Belief:')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Keyword said:')).toBeInTheDocument(); + expect(screen.getByDisplayValue('help')).toBeInTheDocument(); + }); + + it('should render with semantic belief type', () => { + const mockNode: Node = { + id: 'belief-2', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'semantic', id: 'test', value: 'test value', label: 'Detected with LLM:' }, + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByDisplayValue('Detected with LLM:')).toBeInTheDocument(); + expect(screen.getByDisplayValue('test value')).toBeInTheDocument(); + }); + + it('should render with object belief type', () => { + const mockNode: Node = { + id: 'belief-3', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'object', id: 'obj1', value: 'cup', label: 'Object found:' }, + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByDisplayValue('Object found:')).toBeInTheDocument(); + expect(screen.getByDisplayValue('cup')).toBeInTheDocument(); + }); + + it('should render with emotion belief type and select dropdown', () => { + const mockNode: Node = { + id: 'belief-4', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' }, + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByDisplayValue('Emotion recognised:')).toBeInTheDocument(); + // For emotion type, we should check that the select has the correct value selected + const selectElement = screen.getByDisplayValue('Happy'); + expect(selectElement).toBeInTheDocument(); + expect((selectElement as HTMLSelectElement).value).toBe('happy'); + }); + + it('should render emotion dropdown with all emotion options', () => { + const mockNode: Node = { + id: 'belief-5', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' }, + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + const selectElement = screen.getByDisplayValue('Happy'); + expect(selectElement).toBeInTheDocument(); + + // Check that all emotion options are present + expect(screen.getByText('Happy')).toBeInTheDocument(); + expect(screen.getByText('Angry')).toBeInTheDocument(); + expect(screen.getByText('Sad')).toBeInTheDocument(); + expect(screen.getByText('Cheerful')).toBeInTheDocument(); + }); + + it('should render without wrapping quotes for object type', () => { + const mockNode: Node = { + id: 'belief-6', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'object', id: 'obj1', value: 'chair', label: 'Object found:' }, + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + // Object type should not have wrapping quotes + const inputs = screen.getAllByDisplayValue('chair'); + expect(inputs.length).toBe(1); // Only the text input, no extra quote elements + }); + }); + + describe('User Interactions', () => { + it('should update belief type when select is changed', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'keyword', id: 'kw1', value: 'hello', label: 'Keyword said:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const select = screen.getByDisplayValue('Keyword said:'); + await user.selectOptions(select, 'semantic'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node; + expect(updatedNode?.data.belief.type).toBe('semantic'); + // Note: The component doesn't update the label when changing type + // So we can't test for label change + }); + }); + + it('should update text value when typing for keyword type', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'keyword', id: 'kw1', value: '', label: 'Keyword said:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('keyword...'); + await user.type(input, 'help me{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node; + expect(updatedNode?.data.belief.value).toBe('help me'); + }); + }); + + it('should update text value when typing for semantic type', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'semantic', id: 'sem1', value: 'initial', label: 'Detected with LLM:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('initial') as HTMLInputElement; + + // Clear the input + for (let i = 0; i < 'initial'.length; i++) { + await user.type(input, '{backspace}'); + } + await user.type(input, 'new semantic value{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node; + expect(updatedNode?.data.belief.value).toBe('new semantic value'); + }); + }); + + it('should update emotion value when selecting from dropdown', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const select = screen.getByDisplayValue('Happy'); + await user.selectOptions(select, 'sad'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node; + expect(updatedNode?.data.belief.value).toBe('sad'); + }); + }); + + it('should preserve value when switching between text-based belief types', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'keyword', id: 'kw1', value: 'test value', label: 'Keyword said:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + // Switch from keyword to semantic + const typeSelect = screen.getByDisplayValue('Keyword said:'); + await user.selectOptions(typeSelect, 'semantic'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node; + expect(updatedNode?.data.belief.type).toBe('semantic'); + expect(updatedNode?.data.belief.value).toBe('test value'); // Value should be preserved + }); + }); + + it('should preserve value when switching from text type to emotion type', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'keyword', id: 'kw1', value: 'some text', label: 'Keyword said:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + // Switch from keyword to emotion + const typeSelect = screen.getByDisplayValue('Keyword said:'); + await user.selectOptions(typeSelect, 'emotion'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node; + expect(updatedNode?.data.belief.type).toBe('emotion'); + // The component doesn't reset the value when changing types + // So it keeps the old value even though it doesn't make sense for emotion type + expect(updatedNode?.data.belief.value).toBe('some text'); + }); + }); + }); + + // ... rest of the tests remain the same, just fixing the Integration with Store section ... + + describe('Integration with Store', () => { + it('should properly update the store when changing belief value', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'keyword', id: 'kw1', value: '', label: 'Keyword said:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('keyword...'); + await user.type(input, 'emergency{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + expect(state.nodes).toHaveLength(1); + expect(state.nodes[0].id).toBe('belief-1'); + const beliefData = state.nodes[0].data as BasicBeliefNodeData; + expect(beliefData.belief.value).toBe('emergency'); + expect(beliefData.belief.type).toBe('keyword'); + }); + }); + + it('should properly update the store when changing belief type', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'keyword', id: 'kw1', value: 'test', label: 'Keyword said:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const select = screen.getByDisplayValue('Keyword said:'); + await user.selectOptions(select, 'object'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const beliefData = state.nodes[0].data as BasicBeliefNodeData; + expect(beliefData.belief.type).toBe('object'); + // Note: The component doesn't update the label when changing type + expect(beliefData.belief.value).toBe('test'); // Value should be preserved + }); + }); + + it('should not affect other nodes when updating one belief node', async () => { + const belief1: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief 1', + droppable: true, + belief: { type: 'keyword', id: 'kw1', value: 'hello', label: 'Keyword said:' }, + hasReduce: true, + }, + }; + + const belief2: Node = { + id: 'belief-2', + type: 'basic_belief', + position: { x: 100, y: 0 }, + data: { + label: 'Belief 2', + droppable: true, + belief: { type: 'object', id: 'obj1', value: 'chair', label: 'Object found:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [belief1, belief2], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('hello') as HTMLInputElement; + + // Clear the input + for (let i = 0; i < 'hello'.length; i++) { + await user.type(input, '{backspace}'); + } + await user.type(input, 'goodbye{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedBelief1 = state.nodes.find(n => n.id === 'belief-1') as Node; + const unchangedBelief2 = state.nodes.find(n => n.id === 'belief-2') as Node; + + expect(updatedBelief1.data.belief.value).toBe('goodbye'); + expect(unchangedBelief2.data.belief.value).toBe('chair'); + expect(unchangedBelief2.data.belief.type).toBe('object'); + }); + }); + + it('should handle multiple rapid updates to belief value', async () => { + const mockNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + label: 'Belief', + droppable: true, + belief: { type: 'semantic', id: 'sem1', value: 'initial', label: 'Detected with LLM:' }, + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('initial') as HTMLInputElement; + + await user.type(input, '1'); + await waitFor(() => { + const state = useFlowStore.getState(); + const nodeData = state.nodes[0].data as BasicBeliefNodeData; + expect(nodeData.belief.value).toBe('initial'); + }); + + await user.type(input, '2'); + await waitFor(() => { + const state = useFlowStore.getState(); + const nodeData = state.nodes[0].data as BasicBeliefNodeData; + expect(nodeData.belief.value).toBe('initial'); + }); + + await user.type(input, '{enter}'); + await waitFor(() => { + const state = useFlowStore.getState(); + const nodeData = state.nodes[0].data as BasicBeliefNodeData; + expect(nodeData.belief.value).toBe('initial12'); + }); + }); + }); +}); \ No newline at end of file From 7925023f25984f79390a9548340143b600863759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 15 Dec 2025 14:51:58 +0100 Subject: [PATCH 06/19] fix: fix issues ariving from dev merge ref: N25B-408 --- .../visualProgrammingUI/NodeRegistry.ts | 6 ++- .../nodes/BasicBeliefNode.tsx | 39 +++++++++++++++---- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index d142663..023440c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -46,7 +46,7 @@ import TriggerNode, { TriggerReduce } from "./nodes/TriggerNode"; import { TriggerNodeDefaults } from "./nodes/TriggerNode.default"; -import BasicBeliefNode, { BasicBeliefConnects, BasicBeliefReduce } from "./nodes/BasicBeliefNode"; +import BasicBeliefNode, { BasicBeliefConnectionSource, BasicBeliefConnectionTarget, BasicBeliefDisconnectionSource, BasicBeliefDisconnectionTarget, BasicBeliefReduce } from "./nodes/BasicBeliefNode"; import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default"; /** @@ -112,6 +112,7 @@ export const NodeConnections = { norm: NormConnectionTarget, goal: GoalConnectionTarget, trigger: TriggerConnectionTarget, + basic_belief: BasicBeliefConnectionTarget, }, Sources: { start: StartConnectionSource, @@ -120,6 +121,7 @@ export const NodeConnections = { norm: NormConnectionSource, goal: GoalConnectionSource, trigger: TriggerConnectionSource, + basic_belief: BasicBeliefConnectionSource } } @@ -137,6 +139,7 @@ export const NodeDisconnections = { norm: NormDisconnectionTarget, goal: GoalDisconnectionTarget, trigger: TriggerDisconnectionTarget, + basic_belief: BasicBeliefDisconnectionTarget, }, Sources: { start: StartDisconnectionSource, @@ -145,6 +148,7 @@ export const NodeDisconnections = { norm: NormDisconnectionSource, goal: GoalDisconnectionSource, trigger: TriggerDisconnectionSource, + basic_belief: BasicBeliefDisconnectionSource, }, } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index 4942d48..a8f7ceb 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -2,8 +2,6 @@ import { Handle, type NodeProps, Position, - type Connection, - type Edge, type Node, } from '@xyflow/react'; import { Toolbar } from '../components/NodeComponents'; @@ -41,12 +39,39 @@ export type BasicBeliefNode = Node /** - * Determines whether a BasicBelief 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`. + * 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 BasicBeliefNodeCanConnect(_connection: Connection | Edge) { +export function BasicBeliefConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * 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 BasicBeliefConnectionSource(_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 BasicBeliefDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + // no additional connection logic exists yet +} + +/** + * 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 BasicBeliefDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet } /** From bd2ffe622f6ed79f6e29042930b85833920d7825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 5 Jan 2026 10:24:08 +0100 Subject: [PATCH 07/19] chore: remove old connect function from basic belief --- .../visualProgrammingUI/nodes/BasicBeliefNode.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index a8f7ceb..57c4d87 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -186,13 +186,4 @@ export function BasicBeliefReduce(node: Node, _nodes: Node[]) { type: data.belief.type, value: data.belief.value } -} - -/** - * This function is called whenever a connection is made with this node type (BasicBelief) - * @param _thisNode the node of this node type which function is called - * @param _otherNode the other node which was part of the connection - * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. - */ -export function BasicBeliefConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { } \ No newline at end of file From 8f1367ed83ad7983e3561750a82e611aa99ad4ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 5 Jan 2026 10:30:29 +0100 Subject: [PATCH 08/19] chore: emotion dropdown doesnt automatically assign new value --- .../VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index 57c4d87..183cfcb 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -93,6 +93,10 @@ export default function BasicBeliefNode(props: NodeProps) { belief: { ...data.belief, type: newType, + value: + newType === "emotion" + ? emotionOptions[0] + : data.belief.value, }, }); } From 9f26edb6ec9ae685f6a0817051f3815c74b60397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 5 Jan 2026 10:34:45 +0100 Subject: [PATCH 09/19] chore: dont use object, use detected object. --- .../VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index 183cfcb..821569f 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -29,10 +29,10 @@ export type BasicBeliefNodeData = { }; // These are all the types a basic belief could be. -type BasicBeliefType = Keyword | Semantic | Object | Emotion +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, label: "Detected with LLM:"}; -type Object = { type: "object", id: string, value: string, label: "Object found:"}; +type DetectedObject = { type: "object", id: string, value: string, label: "Object found:"}; type Emotion = { type: "emotion", id: string, value: string, label: "Emotion recognised:"}; export type BasicBeliefNode = Node From 01d73b777ab213c35f5c01c83ae6877fc0cf48cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Mon, 5 Jan 2026 10:37:05 +0100 Subject: [PATCH 10/19] chore: fix one test --- .../visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx index 07a69ef..98eaccc 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx @@ -467,7 +467,7 @@ describe('BasicBeliefNode', () => { }); }); - it('should preserve value when switching from text type to emotion type', async () => { + it('should automatically choose the first option when switching to emotion type, and carry on to the text values', async () => { const mockNode: Node = { id: 'belief-1', type: 'basic_belief', @@ -512,7 +512,7 @@ describe('BasicBeliefNode', () => { expect(updatedNode?.data.belief.type).toBe('emotion'); // The component doesn't reset the value when changing types // So it keeps the old value even though it doesn't make sense for emotion type - expect(updatedNode?.data.belief.value).toBe('some text'); + expect(updatedNode?.data.belief.value).toBe('Happy'); }); }); }); From bd93b04bfd6248868239a542a778bd565f53246b Mon Sep 17 00:00:00 2001 From: "Gerla, J. (Justin)" Date: Tue, 6 Jan 2026 12:27:22 +0000 Subject: [PATCH 11/19] feat: made (reduced) program data available on all pages --- src/pages/VisProgPage/VisProg.tsx | 5 ++ src/utils/programStore.ts | 81 +++++++++++++++++++++ test/setupFlowTests.ts | 5 ++ test/utils/programStore.test.ts | 116 ++++++++++++++++++++++++++++++ 4 files changed, 207 insertions(+) create mode 100644 src/utils/programStore.ts create mode 100644 test/utils/programStore.test.ts diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 06e072c..1a3720b 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -9,6 +9,7 @@ import { import '@xyflow/react/dist/style.css'; import {useEffect} from "react"; import {useShallow} from 'zustand/react/shallow'; +import useProgramStore from "../../utils/programStore.ts"; import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx'; import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; @@ -152,6 +153,10 @@ function runProgram() { ).then((res) => { if (!res.ok) throw new Error("Failed communicating with the backend.") console.log("Successfully sent the program to the backend."); + + // store reduced program in global program store for further use in the UI + // when the program was sent to the backend successfully: + useProgramStore.getState().setProgramState(structuredClone(program)); }).catch(() => console.log("Failed to send program to the backend.")); } diff --git a/src/utils/programStore.ts b/src/utils/programStore.ts new file mode 100644 index 0000000..e6bcc3a --- /dev/null +++ b/src/utils/programStore.ts @@ -0,0 +1,81 @@ +import {create} from "zustand"; + +// the type of a reduced program +export type ReducedProgram = { phases: Record[] }; + +/** + * the type definition of the programStore + */ +export type ProgramState = { + // Basic store functionality: + currentProgram: ReducedProgram; + setProgramState: (state: ReducedProgram) => void; + getProgramState: () => ReducedProgram; + + // Utility functions: + // to avoid having to manually go through the entire state for every instance where data is required + getPhaseIds: () => string[]; + getNormsInPhase: (currentPhaseId: string) => Record[]; + getGoalsInPhase: (currentPhaseId: string) => Record[]; + getTriggersInPhase: (currentPhaseId: string) => Record[]; + // if more specific utility functions are needed they can be added here: +} + +/** + * the ProgramStore can be used to access all information of the most recently sent program, + * it contains basic functions to set and get the current program. + * And it contains some utility functions that allow you to easily gain access + * to the norms, triggers and goals of a specific phase. + */ +const useProgramStore = create((set, get) => ({ + currentProgram: { phases: [] as Record[]}, + /** + * sets the current program by cloning the provided program using a structuredClone + */ + setProgramState: (program: ReducedProgram) => set({currentProgram: structuredClone(program)}), + /** + * gets the current program + */ + getProgramState: () => get().currentProgram, + + // utility functions: + /** + * gets the ids of all phases in the program + */ + getPhaseIds: () => get().currentProgram.phases.map(entry => entry["id"] as string), + /** + * gets the norms for the provided phase + */ + getNormsInPhase: (currentPhaseId) => { + const program = get().currentProgram; + const phase = program.phases.find(val => val["id"] === currentPhaseId); + if (phase) { + return phase["norms"] as Record[]; + } + throw new Error(`phase with id:"${currentPhaseId}" not found`) + }, + /** + * gets the goals for the provided phase + */ + getGoalsInPhase: (currentPhaseId) => { + const program = get().currentProgram; + const phase = program.phases.find(val => val["id"] === currentPhaseId); + if (phase) { + return phase["goals"] as Record[]; + } + throw new Error(`phase with id:"${currentPhaseId}" not found`) + }, + /** + * gets the triggers for the provided phase + */ + getTriggersInPhase: (currentPhaseId) => { + const program = get().currentProgram; + const phase = program.phases.find(val => val["id"] === currentPhaseId); + if (phase) { + return phase["triggers"] as Record[]; + } + throw new Error(`phase with id:"${currentPhaseId}" not found`) + } +})); + +export default useProgramStore; \ No newline at end of file diff --git a/test/setupFlowTests.ts b/test/setupFlowTests.ts index 3ce8c3a..c37cd0e 100644 --- a/test/setupFlowTests.ts +++ b/test/setupFlowTests.ts @@ -2,6 +2,11 @@ import '@testing-library/jest-dom'; import { cleanup } from '@testing-library/react'; import useFlowStore from '../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; +if (!globalThis.structuredClone) { + globalThis.structuredClone = (obj: any) => { + return JSON.parse(JSON.stringify(obj)); + }; +} // To make sure that the tests are working, it's important that you are using // this implementation of ResizeObserver and DOMMatrixReadOnly diff --git a/test/utils/programStore.test.ts b/test/utils/programStore.test.ts new file mode 100644 index 0000000..ba78b88 --- /dev/null +++ b/test/utils/programStore.test.ts @@ -0,0 +1,116 @@ +import useProgramStore, {type ReducedProgram} from "../../src/utils/programStore.ts"; + + +describe('useProgramStore', () => { + beforeEach(() => { + // Reset store before each test + useProgramStore.setState({ + currentProgram: { phases: [] }, + }); + }); + + const mockProgram: ReducedProgram = { + phases: [ + { + id: 'phase-1', + norms: [{ id: 'norm-1' }], + goals: [{ id: 'goal-1' }], + triggers: [{ id: 'trigger-1' }], + }, + { + id: 'phase-2', + norms: [{ id: 'norm-2' }], + goals: [{ id: 'goal-2' }], + triggers: [{ id: 'trigger-2' }], + }, + ], + }; + + it('should set and get the program state', () => { + useProgramStore.getState().setProgramState(mockProgram); + + const program = useProgramStore.getState().getProgramState(); + expect(program).toEqual(mockProgram); + }); + + it('should return the ids of all phases in the program', () => { + useProgramStore.getState().setProgramState(mockProgram); + + const phaseIds = useProgramStore.getState().getPhaseIds(); + expect(phaseIds).toEqual(['phase-1', 'phase-2']); + }); + + it('should return all norms for a given phase', () => { + useProgramStore.getState().setProgramState(mockProgram); + + const norms = useProgramStore.getState().getNormsInPhase('phase-1'); + expect(norms).toEqual([{ id: 'norm-1' }]); + }); + + it('should return all goals for a given phase', () => { + useProgramStore.getState().setProgramState(mockProgram); + + const goals = useProgramStore.getState().getGoalsInPhase('phase-2'); + expect(goals).toEqual([{ id: 'goal-2' }]); + }); + + it('should return all triggers for a given phase', () => { + useProgramStore.getState().setProgramState(mockProgram); + + const triggers = useProgramStore.getState().getTriggersInPhase('phase-1'); + expect(triggers).toEqual([{ id: 'trigger-1' }]); + }); + + it('throws if phase does not exist when getting norms', () => { + useProgramStore.getState().setProgramState(mockProgram); + + expect(() => + useProgramStore.getState().getNormsInPhase('missing-phase') + ).toThrow('phase with id:"missing-phase" not found'); + }); + + it('throws if phase does not exist when getting goals', () => { + useProgramStore.getState().setProgramState(mockProgram); + + expect(() => + useProgramStore.getState().getGoalsInPhase('missing-phase') + ).toThrow('phase with id:"missing-phase" not found'); + }); + + it('throws if phase does not exist when getting triggers', () => { + useProgramStore.getState().setProgramState(mockProgram); + + expect(() => + useProgramStore.getState().getTriggersInPhase('missing-phase') + ).toThrow('phase with id:"missing-phase" not found'); + }); + + it('should clone program state when setting it (no shared references should exist)', () => { + const changeableMockProgram: ReducedProgram = { + phases: [ + { + id: 'phase-1', + norms: [{ id: 'norm-1' }], + goals: [{ id: 'goal-1' }], + triggers: [{ id: 'trigger-1' }], + }, + { + id: 'phase-2', + norms: [{ id: 'norm-2' }], + goals: [{ id: 'goal-2' }], + triggers: [{ id: 'trigger-2' }], + }, + ], + }; + + useProgramStore.getState().setProgramState(changeableMockProgram); + + const storedProgram = useProgramStore.getState().getProgramState(); + + // mutate original + (changeableMockProgram.phases[0].norms as any[]).push({ id: 'norm-mutated' }); + + // store should NOT change + expect(storedProgram.phases[0]['norms']).toHaveLength(1); + }); +}); \ No newline at end of file From 9b3414ba986da6277a33ae337c698f5b0a6ba1a5 Mon Sep 17 00:00:00 2001 From: "Gerla, J. (Justin)" Date: Tue, 6 Jan 2026 15:12:00 +0000 Subject: [PATCH 12/19] fix: incorrect phase reduction order --- src/pages/VisProgPage/VisProg.tsx | 7 +- .../visualProgrammingUI/EditorUndoRedo.ts | 4 +- .../visualProgrammingUI/VisProgStores.tsx | 31 ++- .../components/CustomNodeHandles.tsx | 30 +++ .../visualProgrammingUI/nodes/EndNode.tsx | 10 +- .../nodes/PhaseNode.default.ts | 2 + .../visualProgrammingUI/nodes/PhaseNode.tsx | 89 ++++++-- .../visualProgrammingUI/nodes/StartNode.tsx | 10 +- src/utils/orderPhaseNodes.ts | 40 ++++ .../EditorUndoRedo.test.ts | 3 + .../nodes/PhaseNode.test.tsx | 199 +++++++++++++++++- .../nodes/UniversalNodes.test.tsx | 64 +++--- test/utils/orderPhaseNodes.test.ts | 110 ++++++++++ 13 files changed, 520 insertions(+), 79 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/CustomNodeHandles.tsx create mode 100644 src/utils/orderPhaseNodes.ts create mode 100644 test/utils/orderPhaseNodes.test.ts diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 1a3720b..7c1fa3a 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -9,8 +9,10 @@ import { import '@xyflow/react/dist/style.css'; import {useEffect} from "react"; import {useShallow} from 'zustand/react/shallow'; +import orderPhaseNodeArray from "../../utils/orderPhaseNodes.ts"; import useProgramStore from "../../utils/programStore.ts"; import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx'; +import type {PhaseNode} from "./visualProgrammingUI/nodes/PhaseNode.tsx"; import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; import styles from './VisProg.module.css' @@ -165,14 +167,15 @@ function runProgram() { */ function graphReducer() { const { nodes } = useFlowStore.getState(); - return nodes - .filter((n) => n.type == 'phase') + return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode []) .map((n) => { const reducer = NodeReduces['phase']; return reducer(n, nodes) }); } + + /** * houses the entire page, so also UI elements * that are not a part of the Visual Programming UI diff --git a/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts index 70c4c01..6ad705d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/EditorUndoRedo.ts @@ -39,10 +39,10 @@ export const UndoRedo = ( * @param {BaseFlowState} state - the current state of the editor * @returns {FlowSnapshot} - returns a snapshot of the current editor state */ - const getSnapshot = (state : BaseFlowState) : FlowSnapshot => ({ + const getSnapshot = (state : BaseFlowState) : FlowSnapshot => (structuredClone({ nodes: state.nodes, edges: state.edges - }); + })); const initialState = config(set, get, api); diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 0847945..25736cd 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -28,33 +28,28 @@ import { UndoRedo } from "./EditorUndoRedo.ts"; * @param deletable - Optional flag to indicate if the node can be deleted (can be deleted by default). * @returns A fully initialized Node object ready to be added to the flow. */ -function createNode(id: string, type: string, position: XYPosition, data: Record, deletable?: boolean) { - const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] - return { - id, - type, - position, - deletable, - data: { - ...defaultData, - ...data, - }, - } +function createNode(id: string, type: string, position: XYPosition, data: Record, deletable? : boolean) { + const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] + + return { + id: id, + type: type, + position: position, + data: {...defaultData, ...data}, + deletable: deletable } +} //* Initial nodes, created by using createNode. */ const initialNodes : Node[] = [ createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false), createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false), - createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children : []}), - createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"], critical:false}), + createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children : [], isFirstPhase: false, nextPhaseId: null}), + createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"], critical:false}), ]; // * Initial edges * / -const initialEdges: Edge[] = [ - { id: 'start-phase-1', source: 'start', target: 'phase-1' }, - { id: 'phase-1-end', source: 'phase-1', target: 'end' }, -]; +const initialEdges: Edge[] = []; // no initial edges as edge connect events don't fire when using initial edges /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/CustomNodeHandles.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/CustomNodeHandles.tsx new file mode 100644 index 0000000..853c488 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/CustomNodeHandles.tsx @@ -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 ( + + ); +}; + +export default LimitedConnectionCountHandle; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx index 57db571..116dc01 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -1,9 +1,9 @@ import { - Handle, type NodeProps, Position, type Node, } from '@xyflow/react'; +import LimitedConnectionCountHandle from "../components/CustomNodeHandles.tsx"; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; @@ -32,7 +32,13 @@ export default function EndNode(props: NodeProps) {
End
- + ); diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.default.ts index 0a96d6b..73697eb 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.default.ts @@ -8,4 +8,6 @@ export const PhaseNodeDefaults: PhaseNodeData = { droppable: true, children: [], hasReduce: true, + nextPhaseId: null, + isFirstPhase: false, }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 41679f1..9e1fb24 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -9,6 +9,7 @@ import styles from '../../VisProg.module.css'; import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry'; import useFlowStore from '../VisProgStores'; import { TextField } from '../../../../components/TextField'; +import LimitedConnectionCountHandle from "../components/CustomNodeHandles.tsx"; /** * 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 children: ID's of children of this node * @param hasReduce: whether this node has reducing functionality (true by default) + * @param nextPhaseId: */ export type PhaseNodeData = { label: string; droppable: boolean; children: string[]; hasReduce: boolean; + nextPhaseId: string | "end" | null; + isFirstPhase: boolean; }; export type PhaseNode = Node @@ -50,9 +54,21 @@ export default function PhaseNode(props: NodeProps) { placeholder={"Phase ..."} /> - + - + ); @@ -65,8 +81,8 @@ export default function PhaseNode(props: NodeProps) { * @returns A collection of all reduced nodes in this phase, starting with this phases' reduced data. */ export function PhaseReduce(node: Node, nodes: Node[]) { - const thisnode = node as PhaseNode; - const data = thisnode.data as PhaseNodeData; + const thisNode = node as PhaseNode; + const data = thisNode.data as PhaseNodeData; // node typings that are not in phase const nodesNotInPhase: string[] = Object.entries(NodesInPhase) @@ -85,8 +101,8 @@ export function PhaseReduce(node: Node, nodes: Node[]) { // Build the result object const result: Record = { - id: thisnode.id, - label: data.label, + id: thisNode.id, + label: data.label, }; nodesInPhase.forEach((type) => { @@ -109,13 +125,19 @@ export function PhaseReduce(node: Node, nodes: Node[]) { * @param _sourceNodeId the source of the received connection */ export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) { - const node = _thisNode as PhaseNode - 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 data = _thisNode.data as PhaseNodeData + 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 */ 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 */ export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { - const node = _thisNode as PhaseNode - const data = node.data as PhaseNodeData - data.children = data.children.filter((child) => { if (child != _sourceNodeId) return child; }); + const data = _thisNode.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; }); + break; + } + } /** @@ -144,5 +192,12 @@ export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) * @param _targetNodeId the target of the diconnected connection */ 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; + } } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index 92ca6ed..13f3fc8 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -1,9 +1,9 @@ import { - Handle, type NodeProps, Position, type Node, } from '@xyflow/react'; +import LimitedConnectionCountHandle from "../components/CustomNodeHandles.tsx"; import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; @@ -31,7 +31,13 @@ export default function StartNode(props: NodeProps) {
Start
- + ); diff --git a/src/utils/orderPhaseNodes.ts b/src/utils/orderPhaseNodes.ts new file mode 100644 index 0000000..00b7a26 --- /dev/null +++ b/src/utils/orderPhaseNodes.ts @@ -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; +} \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts b/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts index 76e7e96..f7233d8 100644 --- a/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts +++ b/test/pages/visProgPage/visualProgrammingUI/EditorUndoRedo.test.ts @@ -3,6 +3,9 @@ import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/ import { mockReactFlow } from '../../../setupFlowTests.ts'; + + + beforeAll(() => { mockReactFlow(); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx index 01de131..b94feaa 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx @@ -1,8 +1,10 @@ +import type { Node, Edge, Connection } from '@xyflow/react' import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; -import type { PhaseNodeData } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode"; -import { getByTestId, render } from '@testing-library/react'; +import type {PhaseNode, PhaseNodeData} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode"; +import {act, getByTestId, render} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg'; +import {mockReactFlow} from "../../../../setupFlowTests.ts"; class ResizeObserver { @@ -98,4 +100,195 @@ describe('PhaseNode', () => { expect(p1_data.children.length == 1); expect(p2_data.children.length == 2); }); -}); \ No newline at end of file +}); + +// --| Helper functions |-- + +function createPhaseNode( + id: string, + overrides: Partial = {}, +): Node { + 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() + }) + }) +}) \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index c023722..25a50b2 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -13,19 +13,17 @@ describe('NormNode', () => { jest.clearAllMocks(); }); - function createNode(id: string, type: string, position: XYPosition, data: Record, deletable?: boolean) { + function createNode(id: string, type: string, position: XYPosition, data: Record, deletable? : boolean) { const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] + return { - id, - type, - position, - deletable, - data: { - ...defaultData, - ...data, - }, + id: id, + type: type, + position: position, + data: {...defaultData, ...data}, + deletable: deletable } - } + } /** @@ -47,34 +45,34 @@ describe('NormNode', () => { describe('Rendering', () => { test.each(getAllTypes())('it should render %s node with the default data', (nodeType) => { - const lengthBefore = screen.getAllByText(/.*/).length; + const lengthBefore = screen.getAllByText(/.*/).length; - const newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {}); + const newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {}); - const found = Object.entries(NodeTypes).find(([t]) => t === nodeType); - const uiElement = found ? found[1] : null; + const found = Object.entries(NodeTypes).find(([t]) => t === nodeType); + const uiElement = found ? found[1] : null; - expect(uiElement).not.toBeNull(); - const props = { - id: newNode.id, - type: newNode.type as string, - data: newNode.data as any, - selected: false, - isConnectable: true, - zIndex: 0, - dragging: false, - selectable: true, - deletable: true, - draggable: true, - positionAbsoluteX: 0, - positionAbsoluteY: 0, - }; + expect(uiElement).not.toBeNull(); + const props = { + id: newNode.id, + type: newNode.type as string, + data: newNode.data as any, + selected: false, + isConnectable: true, + zIndex: 0, + dragging: false, + selectable: true, + deletable: true, + draggable: true, + positionAbsoluteX: 0, + positionAbsoluteY: 0, + }; - renderWithProviders(createElement(uiElement as React.ComponentType, props)); - const lengthAfter = screen.getAllByText(/.*/).length; + renderWithProviders(createElement(uiElement as React.ComponentType, props)); + const lengthAfter = screen.getAllByText(/.*/).length; - expect(lengthBefore + 1 === lengthAfter); - }); + expect(lengthBefore + 1 === lengthAfter); + }); }); diff --git a/test/utils/orderPhaseNodes.test.ts b/test/utils/orderPhaseNodes.test.ts new file mode 100644 index 0000000..5020378 --- /dev/null +++ b/test/utils/orderPhaseNodes.test.ts @@ -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); + }) +}) \ No newline at end of file From 4e9a048c90569d9c6385587c5587a23680196123 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 7 Jan 2026 09:27:23 +0000 Subject: [PATCH 13/19] Conditional Norms --- .../visualProgrammingUI/VisProgStores.tsx | 23 ++- .../nodes/NormNode.default.ts | 1 + .../visualProgrammingUI/nodes/NormNode.tsx | 50 ++++- .../nodes/NormNode.test.tsx | 185 ++++++++++++++++-- .../nodes/UniversalNodes.test.tsx | 46 ++++- 5 files changed, 270 insertions(+), 35 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx index 25736cd..48851bc 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx @@ -28,17 +28,20 @@ import { UndoRedo } from "./EditorUndoRedo.ts"; * @param deletable - Optional flag to indicate if the node can be deleted (can be deleted by default). * @returns A fully initialized Node object ready to be added to the flow. */ -function createNode(id: string, type: string, position: XYPosition, data: Record, deletable? : boolean) { - const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] - - return { - id: id, - type: type, - position: position, - data: {...defaultData, ...data}, - deletable: deletable +function createNode(id: string, type: string, position: XYPosition, data: Record, deletable?: boolean) { + const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] + return { + id, + type, + position, + deletable, + data: { + ...JSON.parse(JSON.stringify(defaultData)), + ...data, + }, + } } -} + //* Initial nodes, created by using createNode. */ const initialNodes : Node[] = [ diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts index 4b4a3ed..8df25cc 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts @@ -6,6 +6,7 @@ import type { NormNodeData } from "./NormNode"; export const NormNodeDefaults: NormNodeData = { label: "Norm Node", droppable: true, + conditions: [], norm: "", hasReduce: true, critical: false, diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 8f619a5..4e94834 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -8,6 +8,7 @@ import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import { TextField } from '../../../../components/TextField'; import useFlowStore from '../VisProgStores'; +import { BasicBeliefReduce } from './BasicBeliefNode'; /** * The default data dot a phase node @@ -19,6 +20,7 @@ import useFlowStore from '../VisProgStores'; export type NormNodeData = { label: string; droppable: boolean; + conditions: string[]; // List of (basic) belief nodes' ids. norm: string; hasReduce: boolean; critical: boolean; @@ -67,7 +69,14 @@ export default function NormNode(props: NodeProps) { onChange={(e) => setCritical(e.target.checked)} /> + + {data.conditions.length > 0 && (
+ +
)} + + + ; }; @@ -78,14 +87,29 @@ export default function NormNode(props: NodeProps) { * @param node The Node Properties of this node. * @param _nodes all the nodes in the graph */ -export function NormReduce(node: Node, _nodes: Node[]) { +export function NormReduce(node: Node, nodes: Node[]) { const data = node.data as NormNodeData; - return { - id: node.id, - label: data.label, - norm: data.norm, - critical: data.critical, - } + + // conditions nodes - make sure to check for empty arrays + let conditionNodes: Node[] = []; + if (data.conditions) + conditionNodes = nodes.filter((node) => data.conditions.includes(node.id)); + + // Build the result object + const result: Record = { + id: node.id, + label: data.label, + norm: data.norm, + critical: data.critical, + }; + + // Go over our conditionNodes. They should either be Basic (OR TODO: Inferred) + const reducer = BasicBeliefReduce; + result["basic_beliefs"] = conditionNodes.map((condition) => reducer(condition, nodes)) + + // When the Inferred is being implemented, you should follow the same kind of structure that PhaseNode has, + // dividing the conditions into basic and inferred, then calling the correct reducer on them. + return result } /** @@ -94,7 +118,11 @@ export function NormReduce(node: Node, _nodes: Node[]) { * @param _sourceNodeId the source of the received connection */ export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) { - // no additional connection logic exists yet + const data = _thisNode.data as NormNodeData; + // If we got a belief connected, this is a 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 */))) { + data.conditions.push(_sourceNodeId); + } } /** @@ -112,7 +140,11 @@ export function NormConnectionSource(_thisNode: Node, _targetNodeId: string) { * @param _sourceNodeId the source of the disconnected connection */ export function NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { - // no additional connection logic exists yet + const data = _thisNode.data as NormNodeData; + // If we got a belief connected, this is a 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 */))) { + data.conditions = data.conditions.filter(id => id != _sourceNodeId); + } } /** diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index a9848b2..c762fff 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -10,8 +10,9 @@ import NormNode, { import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; import '@testing-library/jest-dom' - - +import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts'; +import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts'; +import BasicBeliefNode, { BasicBeliefConnectionSource } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx'; describe('NormNode', () => { let user: ReturnType; @@ -26,12 +27,7 @@ describe('NormNode', () => { id: 'norm-1', type: 'norm', position: { x: 0, y: 0 }, - data: { - label: 'Test Norm', - droppable: true, - norm: '', - hasReduce: true, - }, + data: {...JSON.parse(JSON.stringify(NormNodeDefaults))}, }; renderWithProviders( @@ -60,6 +56,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'Be respectful to humans', @@ -94,8 +91,10 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, + conditions: [], norm: '', hasReduce: true, critical: false @@ -129,6 +128,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'Dragged norm', @@ -165,6 +165,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -210,6 +211,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'Initial norm text', @@ -261,6 +263,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -314,6 +317,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -358,6 +362,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -404,6 +409,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Safety Norm', droppable: true, norm: 'Never harm humans', @@ -418,6 +424,8 @@ describe('NormNode', () => { id: 'norm-1', label: 'Safety Norm', norm: 'Never harm humans', + critical: false, + basic_beliefs: [], }); }); @@ -427,6 +435,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Norm 1', droppable: true, norm: 'Be helpful', @@ -439,6 +448,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 100, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Norm 2', droppable: true, norm: 'Be honest', @@ -463,6 +473,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Empty Norm', droppable: true, norm: '', @@ -482,6 +493,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Custom Label', droppable: false, norm: 'Test norm', @@ -502,6 +514,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'Test', @@ -514,6 +527,7 @@ describe('NormNode', () => { type: 'phase', position: { x: 100, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Phase 1', droppable: true, children: [], @@ -532,6 +546,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: 'Test', @@ -544,6 +559,7 @@ describe('NormNode', () => { type: 'phase', position: { x: 100, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Phase 1', droppable: true, children: [], @@ -562,6 +578,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: 'Test', @@ -583,6 +600,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -634,6 +652,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Test Norm', droppable: true, norm: '', @@ -682,6 +701,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Norm 1', droppable: true, norm: 'Original norm 1', @@ -694,6 +714,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 100, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Norm 2', droppable: true, norm: 'Original norm 2', @@ -748,6 +769,7 @@ describe('NormNode', () => { type: 'norm', position: { x: 0, y: 0 }, data: { + ...NormNodeDefaults, label: 'Test Norm', droppable: true, norm: 'haa haa fuyaaah - link', @@ -778,21 +800,154 @@ describe('NormNode', () => { ); const input = screen.getByPlaceholderText('Pepper should ...'); + expect(input).toBeDefined() - await user.type(input, 'a'); + await user.type(input, 'a{enter}'); await waitFor(() => { - expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linka'); }); - await user.type(input, 'b'); + await user.type(input, 'b{enter}'); await waitFor(() => { - expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linkab'); }); - await user.type(input, 'c'); + await user.type(input, 'c{enter}'); await waitFor(() => { - expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); - }, { timeout: 3000 }); + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linkabc'); + }); + }); + }); + + describe('Integration beliefs', () => { + it('should update visually when adding beliefs', async () => { + // Setup state + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), + label: 'Test Norm', + droppable: true, + norm: 'haa haa fuyaaah - link', + hasReduce: true, + } + }; + + const mockBelief: Node = { + id: 'basic_belief-1', + type: 'basic_belief', + position: {x:100, y:100}, + data: { + ...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults)) + } + }; + + useFlowStore.setState({ + nodes: [mockNode, mockBelief], + edges: [], + }); + + // Simulate connecting + NormConnectionTarget(mockNode, mockBelief.id); + BasicBeliefConnectionSource(mockBelief, mockNode.id) + + renderWithProviders( +
+ + +
+ ); + + await waitFor(() => { + expect(screen.getByTestId('norm-condition-information')).toBeInTheDocument(); + }); + + + }); + + it('should update the data when adding beliefs', async () => { + // Setup state + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), + label: 'Test Norm', + droppable: true, + norm: 'haa haa fuyaaah - link', + hasReduce: true, + } + }; + + const mockBelief1: Node = { + id: 'basic_belief-1', + type: 'basic_belief', + position: {x:100, y:100}, + data: { + ...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults)) + } + }; + + const mockBelief2: Node = { + id: 'basic_belief-2', + type: 'basic_belief', + position: {x:300, y:300}, + data: { + ...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults)) + } + }; + + useFlowStore.setState({ + nodes: [mockNode, mockBelief1, mockBelief2], + edges: [], + }); + + // Simulate connecting + useFlowStore.getState().onConnect({ + source: 'basic_belief-1', + target: 'norm-1', + sourceHandle: null, + targetHandle: null, + }); + useFlowStore.getState().onConnect({ + source: 'basic_belief-2', + target: 'norm-1', + sourceHandle: null, + targetHandle: null, + }); + + const state = useFlowStore.getState(); + const updatedNorm = state.nodes.find(n => n.id === 'norm-1'); + expect(updatedNorm?.data.conditions).toEqual(["basic_belief-1", "basic_belief-2"]); }); }); }); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 25a50b2..40cd0e4 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -8,7 +8,7 @@ import { createElement } from 'react'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; -describe('NormNode', () => { +describe('Universal Nodes', () => { beforeEach(() => { jest.clearAllMocks(); }); @@ -107,6 +107,50 @@ describe('NormNode', () => { }); }); + describe('Disconnecting', () => { + test.each(getAllTypes())('it should remove the correct data when something is disconnected on a %s node.', (nodeType) => { + // Create two nodes - one of the current type and one to connect to + const sourceNode = createNode('source-1', nodeType, {x: 100, y: 100}, {}); + const targetNode = createNode('target-1', 'basic_belief', {x: 300, y: 100}, {}); + + // Add nodes to store + useFlowStore.setState({ nodes: [sourceNode, targetNode] }); + + // Spy on the connect functions + const sourceConnectSpy = jest.spyOn(NodeConnections.Sources, nodeType as keyof typeof NodeConnections.Sources); + const targetConnectSpy = jest.spyOn(NodeConnections.Targets, 'basic_belief'); + + // Simulate connection + useFlowStore.getState().onConnect({ + source: 'source-1', + target: 'target-1', + sourceHandle: null, + targetHandle: null, + }); + + + // Verify the connect functions were called + expect(sourceConnectSpy).toHaveBeenCalledWith(sourceNode, targetNode.id); + expect(targetConnectSpy).toHaveBeenCalledWith(targetNode, sourceNode.id); + + // Find this connection, and delete it + const edge = useFlowStore.getState().edges[0]; + useFlowStore.getState().onEdgesDelete([edge]); + + // Find the nodes in the flow + const newSourceNode = useFlowStore.getState().nodes.find((node) => node.id == "source-1"); + const newTargetNode = useFlowStore.getState().nodes.find((node) => node.id == "target-1"); + + // Expect them to be the same after deleting the edges + expect(newSourceNode).toBe(sourceNode); + expect(newTargetNode).toBe(targetNode); + + // Restore our spies + sourceConnectSpy.mockRestore(); + targetConnectSpy.mockRestore(); + }); + }); + describe('Reducing', () => { test.each(getAllTypes())('it should correctly call/ not call the reduce function when %s node is in a phase', (nodeType) => { // Create a phase node and a node of the current type From a2b4847ca404d1157a85f2353e9ff8a6cbec3548 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Thu, 8 Jan 2026 09:54:48 +0100 Subject: [PATCH 14/19] Merge remote-tracking branch 'origin/demo' into feat/monitoringpage-pim --- package-lock.json | 13 +- package.json | 1 + src/components/MultilineTextField.tsx | 75 +++ src/components/TextField.module.css | 12 + src/components/TextField.tsx | 2 +- src/index.css | 13 + src/pages/MonitoringPage/Components.tsx | 60 +- src/pages/MonitoringPage/MonitoringPage.tsx | 118 +++- src/pages/MonitoringPage/MonitoringPageAPI.ts | 17 +- src/pages/VisProgPage/VisProg.module.css | 6 + src/pages/VisProgPage/VisProg.tsx | 7 +- .../visualProgrammingUI/HandleRuleLogic.ts | 110 ++++ .../visualProgrammingUI/HandleRules.ts | 45 ++ .../visualProgrammingUI/VisProgStores.tsx | 106 ++- .../visualProgrammingUI/VisProgTypes.tsx | 45 +- .../components/CustomNodeHandles.tsx | 30 - .../components/DragDropSidebar.tsx | 16 +- .../components/GestureValueEditor.module.css | 164 +++++ .../components/GestureValueEditor.tsx | 611 ++++++++++++++++++ .../components/Plan.default.ts | 7 + .../visualProgrammingUI/components/Plan.tsx | 101 +++ .../components/PlanEditor.module.css | 71 ++ .../components/PlanEditor.tsx | 243 +++++++ .../components/RuleBasedHandle.module.css | 34 + .../components/RuleBasedHandle.tsx | 88 +++ .../nodes/BasicBeliefNode.default.ts | 2 +- .../nodes/BasicBeliefNode.tsx | 52 +- .../visualProgrammingUI/nodes/EndNode.tsx | 14 +- .../nodes/GoalNode.default.ts | 4 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 76 ++- .../nodes/NormNode.default.ts | 2 +- .../visualProgrammingUI/nodes/NormNode.tsx | 50 +- .../visualProgrammingUI/nodes/PhaseNode.tsx | 34 +- .../visualProgrammingUI/nodes/StartNode.tsx | 13 +- .../nodes/TriggerNode.default.ts | 2 - .../visualProgrammingUI/nodes/TriggerNode.tsx | 174 ++--- .../HandleRuleLogic.test.ts | 86 +++ .../visualProgrammingUI/HandleRules.test.ts | 84 +++ .../VisProgStores.test.tsx | 46 +- .../components/DragDropSidebar.test.tsx | 6 +- .../components/GestureValueEditor.test.tsx | 132 ++++ .../components/PlanEditor.test.tsx | 505 +++++++++++++++ .../nodes/BeliefNode.test.tsx | 19 +- .../nodes/NormNode.test.tsx | 41 +- .../nodes/PhaseNode.test.tsx | 6 +- .../nodes/TriggerNode.test.tsx | 200 ++---- .../nodes/UniversalNodes.test.tsx | 2 +- test/setupFlowTests.ts | 20 +- 48 files changed, 3038 insertions(+), 527 deletions(-) create mode 100644 src/components/MultilineTextField.tsx create mode 100644 src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts create mode 100644 src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts delete mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/CustomNodeHandles.tsx create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.module.css create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/GestureValueEditor.tsx create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.module.css create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.module.css create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/RuleBasedHandle.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/HandleRuleLogic.test.ts create mode 100644 test/pages/visProgPage/visualProgrammingUI/HandleRules.test.ts create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/GestureValueEditor.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx diff --git a/package-lock.json b/package-lock.json index b225239..a52343e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.3", + "baseline-browser-mapping": "^2.9.11", "eslint": "^9.36.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", @@ -3698,9 +3699,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.6.tgz", - "integrity": "sha512-wrH5NNqren/QMtKUEEJf7z86YjfqW/2uw3IL3/xpqZUC95SSVIFXYQeeGjL6FT/X68IROu6RMehZQS5foy2BXw==", + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4869,9 +4870,9 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { diff --git a/package.json b/package.json index cd08dca..45f66df 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.3", + "baseline-browser-mapping": "^2.9.11", "eslint": "^9.36.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", diff --git a/src/components/MultilineTextField.tsx b/src/components/MultilineTextField.tsx new file mode 100644 index 0000000..ad88513 --- /dev/null +++ b/src/components/MultilineTextField.tsx @@ -0,0 +1,75 @@ +import { useEffect, useRef, useState } from "react"; +import styles from "./TextField.module.css"; + +export function MultilineTextField({ + value = "", + setValue, + placeholder, + className, + id, + ariaLabel, + invalid = false, + minRows = 3, +}: { + value: string; + setValue: (value: string) => void; + placeholder?: string; + className?: string; + id?: string; + ariaLabel?: string; + invalid?: boolean; + minRows?: number; +}) { + const [readOnly, setReadOnly] = useState(true); + const [inputValue, setInputValue] = useState(value); + const textareaRef = useRef(null); + + useEffect(() => { + setInputValue(value); + }, [value]); + + // Auto-grow logic + useEffect(() => { + const el = textareaRef.current; + if (!el) return; + + el.style.height = "auto"; + el.style.height = `${el.scrollHeight}px`; + }, [inputValue]); + + const onCommit = () => { + setReadOnly(true); + setValue(inputValue); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + (e.target as HTMLTextAreaElement).blur(); + } + }; + + return ( +