From 444e8b0289154f5c3feb4573cd2c2cf3a6592541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 17 Dec 2025 15:51:50 +0100 Subject: [PATCH] feat: fix a lot of small changes to match cb, add functionality for all plans, add tests for the new plan editor. even more i dont really know anymore. ref: N25B-412 --- src/components/TextField.module.css | 2 + src/components/TextField.tsx | 2 +- src/pages/VisProgPage/VisProg.module.css | 16 +- .../visualProgrammingUI/components/Plan.tsx | 51 +- .../components/PlanEditor.tsx | 237 +++++++++ .../nodes/BasicBeliefNode.tsx | 24 +- .../nodes/GoalNode.default.ts | 3 +- .../visualProgrammingUI/nodes/GoalNode.tsx | 44 +- .../nodes/NormNode.default.ts | 1 - .../visualProgrammingUI/nodes/PhaseNode.tsx | 2 +- .../nodes/TriggerNode.default.ts | 2 - .../visualProgrammingUI/nodes/TriggerNode.tsx | 385 +-------------- .../components/PlanEditor.test.tsx | 450 ++++++++++++++++++ .../nodes/NormNode.test.tsx | 23 +- .../nodes/TriggerNode.test.tsx | 201 ++------ .../nodes/UniversalNodes.test.tsx | 2 +- 16 files changed, 884 insertions(+), 561 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx diff --git a/src/components/TextField.module.css b/src/components/TextField.module.css index de66531..1f40d85 100644 --- a/src/components/TextField.module.css +++ b/src/components/TextField.module.css @@ -2,6 +2,8 @@ border: 1px solid transparent; border-radius: 5pt; padding: 4px 8px; + max-width: 50vw; + min-width: 10vw; outline: none; background-color: canvas; transition: border-color 0.2s, box-shadow 0.2s; diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx index 6dbc47b..6395e18 100644 --- a/src/components/TextField.tsx +++ b/src/components/TextField.tsx @@ -63,7 +63,7 @@ export function RealtimeTextField({ readOnly={readOnly} id={id} // ReactFlow uses the "drag" / "nodrag" classes to enable / disable dragging of nodes - className={`${readOnly ? "drag" : "nodrag"} ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`} + className={`${readOnly ? "drag" : "nodrag"} flex-1 ${styles.textField} ${invalid ? styles.invalid : ""} ${className}`} aria-label={ariaLabel} />; } diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index cd61b5e..7731e42 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -183,23 +183,33 @@ transition: text-decoration 0.2s; } + .planStep:hover { text-decoration: line-through; } .stepType { - margin-left: auto; opacity: 0.7; font-size: 0.85em; } + .stepIndex { opacity: 0.6; } - - .emptySteps { opacity: 0.5; font-style: italic; +} + +.stepSuggestion { + opacity: 0.5; + font-style: italic; +} + +.planNoIterate { + opacity: 0.5; + font-style: italic; + text-decoration: line-through; } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx index a74cbf2..4955d86 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/Plan.tsx @@ -16,10 +16,55 @@ export type Goal = { // Actions export type Action = SpeechAction | GestureAction | LLMAction -export type SpeechAction = { name: string, id: string, text: string, type:"speech" } -export type GestureAction = { name: string, id: string, gesture: string, type:"gesture" } -export type LLMAction = { name: string, id: string, goal: string, type:"llm" } +export type SpeechAction = { id: string, text: string, type:"speech" } +export type GestureAction = { id: string, gesture: string, type:"gesture" } +export type LLMAction = { id: string, goal: string, type:"llm" } export type ActionTypes = "speech" | "gesture" | "llm"; +// Extract the wanted information from a plan within the reducing of nodes +export function PlanReduce(plan?: Plan) { + if (!plan) return "" + return { + name: plan.name, + id: plan.id, + steps: plan.steps, + } +} + +/** + * Finds out whether the plan can iterate multiple times, or always stops after one action. + * This comes down to checking if the plan only has speech/ gesture actions, or others as well. + * @param plan: the plan to check + * @returns: a boolean + */ +export function DoesPlanIterate(plan?: Plan) : boolean { + // TODO: should recursively check plans that have goals (and thus more plans) in them. + if (!plan) return false + return plan.steps.filter((step) => step.type == "llm").length > 0; +} + +/** + * Returns the value of the action. + * Since typescript can't polymorphicly access the value field, + * we need to switch over the types and return the correct field. + * @param action: action to retrieve the value from + * @returns string | undefined + */ +export function GetActionValue(action: Action) { + let returnAction; + switch (action.type) { + case "gesture": + returnAction = action as GestureAction + return returnAction.gesture; + case "speech": + returnAction = action as SpeechAction + return returnAction.text; + case "llm": + returnAction = action as LLMAction + return returnAction.goal; + default: + break; + } +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx new file mode 100644 index 0000000..af05310 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor.tsx @@ -0,0 +1,237 @@ +import { useRef, useState } from "react"; +import styles from '../../VisProg.module.css'; +import { GetActionValue, type Action, type ActionTypes, type Plan } from "../components/Plan"; +import { defaultPlan } from "../components/Plan.default"; +import { TextField } from "../../../../components/TextField"; + +type PlanEditorDialogProps = { + plan?: Plan; + onSave: (plan: Plan | undefined) => void; + description? : string; +}; + +/** + * Adds an element to a React.JSX.Element that allows for the creation and editing of plans. + * Renders a dialog in the current screen with buttons and text fields for names, actions and other configurability. + * @param param0: Takes in a current plan, which can be undefined and a function which is called on saving with the potential plan. + * @returns: JSX.Element + * @example + * ``` + * // Within a Node's default JSX Element function + * { + * updateNodeData(props.id, { + * ...data, + * plan, + * }); + * }} + * /> + * ``` + */ +export default function PlanEditorDialog({ + plan, + onSave, + description, +}: PlanEditorDialogProps) { + // UseStates and references + const dialogRef = useRef(null); + const [draftPlan, setDraftPlan] = useState(null); + const [newActionType, setNewActionType] = useState("speech"); + const [newActionValue, setNewActionValue] = useState(""); + + //Button Actions + const openCreate = () => { + setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()}); + dialogRef.current?.showModal(); + }; + + const openCreateWithDescription = () => { + setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID(), name: description!}); + setNewActionType("llm") + setNewActionValue(description!) + dialogRef.current?.showModal(); + } + + + + const openEdit = () => { + if (!plan) return; + setDraftPlan(structuredClone(plan)); + dialogRef.current?.showModal(); + }; + + const close = () => { + dialogRef.current?.close(); + setDraftPlan(null); + }; + + const buildAction = (): Action => { + const id = crypto.randomUUID(); + switch (newActionType) { + case "speech": + return { id, text: newActionValue, type: "speech" }; + case "gesture": + return { id, gesture: newActionValue, type: "gesture" }; + case "llm": + return { id, goal: newActionValue, type: "llm" }; + } + }; + + return (<> + {/* Create and edit buttons */} + {!plan && ( + + )} + {plan && ( + + )} + + {/* Start of dialog (plan editor) */} + e.preventDefault()} + > +
+

{draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"}

+ + {/* Plan name text field */} + {draftPlan && ( + + setDraftPlan({ ...draftPlan, name })} + placeholder="Plan name" + data-testid="name_text_field"/> + )} + + {/* Entire "bottom" part (adder and steps) without cancel, confirm and reset */} + {draftPlan && (
+
+ {/* Left Side (Action Adder) */} +

Add Action

+ {(!plan && description && draftPlan.steps.length === 0) && (
+ + +
)} + + + {/* Action value editor */} + + + {/* Adding steps */} + +
+ + {/* Right Side (Steps shown) */} +
+

Steps

+ + {/* Show if there are no steps yet */} + {draftPlan.steps.length === 0 && ( +
+ No steps yet +
+ )} + + + {/* Map over all steps, create a div for them that deletes them + if clicked on and add the index, name and type. as spans */} + {draftPlan.steps.map((step, index) => ( +
{ + setDraftPlan({ + ...draftPlan, + steps: draftPlan.steps.filter((s) => s.id !== step.id),}); + }}> + {index + 1}. + {step.type}: + { + step.type == "goal" ? ""/* TODO: Add support for goals */ + : GetActionValue(step)} + +
+ ))} +
+
+ )} {/* End Action Editor and steps shower */} + + {/* Buttons */} +
+ {/* Close button */} + + + {/* Confirm/ Create button */} + + + {/* Reset button */} + +
+ +
+ + ); +} diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index a8f7ceb..9fa4017 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -181,11 +181,27 @@ export default function BasicBeliefNode(props: NodeProps) { */ export function BasicBeliefReduce(node: Node, _nodes: Node[]) { const data = node.data as BasicBeliefNodeData; - return { - id: node.id, - type: data.belief.type, - value: data.belief.value + const result: Record = { + id: node.id, + }; + + switch (data.belief.type) { + case "emotion": + result["emotion"] = data.belief.value; + break; + case "keyword": + result["keyword"] = data.belief.value; + break; + case "object": + result["object"] = data.belief.value; + break; + case "semantic": + result["description"] = data.belief.value; + break; + default: + break; } + return result } /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts index fc4d3aa..4cf314c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts @@ -6,7 +6,8 @@ import type { GoalNodeData } from "./GoalNode"; export const GoalNodeDefaults: GoalNodeData = { label: "Goal Node", droppable: true, - description: "The robot will strive towards this goal", + description: "", achieved: false, hasReduce: true, + can_fail: true, }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 1564969..75b8b99 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -8,6 +8,8 @@ import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import { TextField } from '../../../../components/TextField'; import useFlowStore from '../VisProgStores'; +import { DoesPlanIterate, PlanReduce, type Plan } from '../components/Plan'; +import PlanEditorDialog from '../components/PlanEditor'; /** * The default data dot a phase node @@ -22,6 +24,8 @@ export type GoalNodeData = { droppable: boolean; achieved: boolean; hasReduce: boolean; + can_fail: boolean; + plan?: Plan; }; export type GoalNode = Node @@ -37,13 +41,14 @@ export default function GoalNode({id, data}: NodeProps) { const text_input_id = `goal_${id}_text_input`; const checkbox_id = `goal_${id}_checkbox`; + const planIterate = DoesPlanIterate(data.plan); const setDescription = (value: string) => { updateNodeData(id, {...data, description: value}); } - const setAchieved = (value: boolean) => { - updateNodeData(id, {...data, achieved: value}); + const setFailable = (value: boolean) => { + updateNodeData(id, {...data, can_fail: value}); } return <> @@ -57,14 +62,34 @@ export default function GoalNode({id, data}: NodeProps) { setValue={(val) => setDescription(val)} placeholder={"To ..."} /> + -
- +
+ +
+ {data.plan && (
+ {planIterate ? "" : } + setAchieved(e.target.checked)} + disabled={!planIterate} + checked={!planIterate || data.can_fail} + onChange={(e) => planIterate ? setFailable(e.target.checked) : undefined} + /> +
+)} + +
+ { + updateNodeData(id, { + ...data, + plan, + }); + }} + description={data.description} />
@@ -80,11 +105,12 @@ export default function GoalNode({id, data}: NodeProps) { */ export function GoalReduce(node: Node, _nodes: Node[]) { const data = node.data as GoalNodeData; - return { + return { id: node.id, - label: data.label, + name: data.label, description: data.description, - achieved: data.achieved, + can_fail: data.can_fail, + plan: data.plan ? PlanReduce(data.plan) : "", } } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts index 8df25cc..4b4a3ed 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts @@ -6,7 +6,6 @@ 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/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 41679f1..d12ad62 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -86,7 +86,7 @@ export function PhaseReduce(node: Node, nodes: Node[]) { // Build the result object const result: Record = { id: thisnode.id, - label: data.label, + name: data.label, }; nodesInPhase.forEach((type) => { diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts index d1daf4a..2a63661 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts @@ -6,7 +6,5 @@ import type { TriggerNodeData } from "./TriggerNode"; export const TriggerNodeDefaults: TriggerNodeData = { label: "Trigger Node", droppable: true, - triggers: [], - triggerType: "keywords", hasReduce: true, }; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 6e3d940..1778d32 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -9,11 +9,9 @@ import { import { Toolbar } from '../components/NodeComponents'; import styles from '../../VisProg.module.css'; import useFlowStore from '../VisProgStores'; -import { useState, useRef } from 'react'; -import { RealtimeTextField, TextField } from '../../../../components/TextField'; -import duplicateIndices from '../../../../utils/duplicateIndices'; -import type { Action, ActionTypes, Plan } from '../components/Plan'; -import { defaultPlan } from '../components/Plan.default'; +import { PlanReduce, type Plan } from '../components/Plan'; +import PlanEditorDialog from '../components/PlanEditor'; +import { BasicBeliefReduce } from './BasicBeliefNode'; /** * The default data structure for a Trigger node @@ -23,8 +21,6 @@ import { defaultPlan } from '../components/Plan.default'; * * @property label: the display label of this Trigger node. * @property droppable: Whether this node can be dropped from the toolbar (default: true). - * @property triggerType - The type of trigger ("keywords" or a custom string). - * @property triggers - The list of keyword triggers (if applicable). * @property hasReduce - Whether this node supports reduction logic. */ export type TriggerNodeData = { @@ -44,6 +40,7 @@ export type TriggerNode = Node * * @param connection - The connection or edge being attempted to connect towards. * @returns `true` if the connection is defined; otherwise, `false`. + * */ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean { return (connection != undefined); @@ -57,64 +54,7 @@ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean { export default function TriggerNode(props: NodeProps) { const data = props.data; const {updateNodeData} = useFlowStore(); - const dialogRef = useRef(null); - const [draftPlan, setDraftPlan] = useState(null); - - // Helpers for inside plan creation - const [newActionType, setNewActionType] = useState("speech"); - const [newActionName, setNewActionName] = useState(""); - const [newActionValue, setNewActionValue] = useState(""); - - - // Create a new Plan - const openCreateDialog = () => { - setDraftPlan(JSON.parse(JSON.stringify(defaultPlan))); - dialogRef.current?.showModal(); - }; - - // Edit our current plan - const openEditDialog = () => { - if (!data.plan) return; - setDraftPlan(JSON.parse(JSON.stringify(data.plan))); - dialogRef.current?.showModal(); - }; - - // Close the creating/editing of our plan - const closeDialog = () => { - dialogRef.current?.close(); - }; - - - // Define the function for creating actions - const buildAction = (): Action => { - const id = crypto.randomUUID(); - - switch (newActionType) { - case "speech": - return { - id, - name: newActionName, - text: newActionValue, - type: "speech" - }; - case "gesture": - return { - id, - name: newActionName, - gesture: newActionValue, - type: "gesture" - }; - case "llm": - return { - id, - name: newActionName, - goal: newActionValue, - type: "llm" - }; - } - }; - - + return <>
@@ -123,199 +63,16 @@ export default function TriggerNode(props: NodeProps) {
Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}
- - {/* We don't have a plan yet, show our create plan button */} - {!data.plan && ( - - )} - - {/* We have a plan, show our edit plan button */} - {data.plan && ( - - )} + { + updateNodeData(props.id, { + ...data, + plan, + }); + }} + />
- - {/* Define how our dialog should work */} - e.preventDefault()} - > -
-

{draftPlan?.id === data.plan?.id ? "Edit Plan" : "Create Plan"}

- - {/*Text field to edit the name of our draft plan*/} -
- {/* LEFT: plan info + add action */} -
- {/* Plan name */} - {draftPlan && ( -
- - - setDraftPlan({ - ...draftPlan, - name, - }) - } - /> -
- )} - - {/* Add action UI */} - {draftPlan && ( -
-

Add Action

- - - - - - - - -
- )} -
- - {/* RIGHT: steps list */} -
-

Steps

- - {draftPlan && draftPlan.steps.length === 0 && ( -
- No steps yet -
- )} - - {draftPlan?.steps.map((step, index) => ( -
{ - if (!draftPlan) return; - setDraftPlan({ - ...draftPlan, - steps: draftPlan.steps.filter((s) => s.id !== step.id), - }); - }} - > - {index + 1}. - {step.name} - {step.type} -
- ))} -
-
- -
- {/*Button to close the plan editor.*/} - - - {/*Button to save the draftPlan to the plan in the Node.*/} - - - {/*Button to reset the plan*/} - -
-
-
; } @@ -325,22 +82,16 @@ export default function TriggerNode(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 triggers. */ -export function TriggerReduce(node: Node, _nodes: Node[]) { - const data = node.data; - switch (data.triggerType) { - case "keywords": - return { - id: node.id, - type: "keywords", - label: data.label, - keywords: data.triggers, - }; - default: - return { - ...data, - id: node.id, - }; +export function TriggerReduce(node: Node, nodes: Node[]) { + const data = node.data as TriggerNodeData; + const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined + const conditionData = conditionNode ? BasicBeliefReduce(conditionNode, nodes) : "" + return { + id: node.id, + condition: conditionData, // Make sure we have a condition before reducing, or default to "" + plan: !data.plan ? "" : PlanReduce(data.plan), // Make sure we have a plan when reducing, or default to "" } + } /** @@ -405,92 +156,4 @@ export type KeywordTriggerNodeProps = { } /** Union type for all possible Trigger node configurations. */ -export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps; - -/** - * Renders an input element that allows users to add new keyword triggers. - * - * When the input is committed, the `addKeyword` callback is called with the new keyword. - * - * @param param0 - An object containing the `addKeyword` function. - * @returns A React element(React.JSX.Element) providing an input for adding keywords. - */ -function KeywordAdder({ addKeyword }: { addKeyword: (keyword: string) => void }) { - const [input, setInput] = useState(""); - - const text_input_id = "keyword_adder_input"; - - return
- - { - if (!input) return; - addKeyword(input); - setInput(""); - }} - placeholder={"..."} - className={"flex-1"} - /> -
; -} - -/** - * Displays and manages a list of keyword triggers for a Trigger node. - * Handles adding, editing, and removing keywords, as well as detecting duplicate entries. - * - * @param keywords - The current list of keyword triggers. - * @param setKeywords - A callback to update the keyword list in the parent node. - * @returns A React element(React.JSX.Element) for editing keyword triggers. - */ -function Keywords({ - keywords, - setKeywords, -}: { - keywords: Keyword[]; - setKeywords: (keywords: Keyword[]) => void; -}) { - type Interpolatable = string | number | boolean | bigint | null | undefined; - - const inputElementId = (id: Interpolatable) => `keyword_${id}_input`; - - /** Indices of duplicates in the keyword array. */ - const [duplicates, setDuplicates] = useState([]); - - function replace(id: string, value: string) { - value = value.trim(); - const newKeywords = value === "" - ? keywords.filter((kw) => kw.id != id) - : keywords.map((kw) => kw.id === id ? {...kw, keyword: value} : kw); - setKeywords(newKeywords); - setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword))); - } - - function add(value: string) { - value = value.trim(); - if (value === "") return; - const newKeywords = [...keywords, {id: crypto.randomUUID(), keyword: value}]; - setKeywords(newKeywords); - setDuplicates(duplicateIndices(newKeywords.map((kw) => kw.keyword))); - } - - return <> - Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken. - {[...keywords].map(({id, keyword}, index) => { - return
- - replace(id, val)} - placeholder={"..."} - className={"flex-1"} - invalid={duplicates.includes(index)} - /> -
; - })} - - ; -} \ No newline at end of file +export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps; \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx new file mode 100644 index 0000000..c7de9a7 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/PlanEditor.test.tsx @@ -0,0 +1,450 @@ +// PlanEditorDialog.test.tsx +import { describe, it, beforeEach, jest } from '@jest/globals'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; +import PlanEditorDialog from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/PlanEditor'; +import type { Plan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan'; +import '@testing-library/jest-dom'; + +// Mock crypto.randomUUID for consistent IDs in tests +const mockUUID = 'test-uuid-123'; + +Object.defineProperty(globalThis, 'crypto', { + value: { + randomUUID: () => mockUUID, + }, + writable: true, +}); + +// Mock structuredClone +(globalThis as any).structuredClone = jest.fn((val) => JSON.parse(JSON.stringify(val))); + +// Mock HTMLDialogElement methods +const mockDialogMethods = { + showModal: jest.fn(), + close: jest.fn(), +}; + +describe('PlanEditorDialog', () => { + let user: ReturnType; + const mockOnSave = jest.fn(); + + beforeEach(() => { + user = userEvent.setup(); + jest.clearAllMocks(); + // Mock dialog element methods + HTMLDialogElement.prototype.showModal = mockDialogMethods.showModal; + HTMLDialogElement.prototype.close = mockDialogMethods.close; + }); + + const defaultPlan: Plan = { + id: 'plan-1', + name: 'Test Plan', + steps: [], + }; + + const planWithSteps: Plan = { + id: 'plan-2', + name: 'Existing Plan', + steps: [ + { id: 'step-1', text: 'Hello world', type: 'speech' as const }, + { id: 'step-2', gesture: 'Wave', type: 'gesture' as const }, + ], + }; + + const renderDialog = (props: Partial> = {}) => { + const defaultProps = { + plan: undefined, + onSave: mockOnSave, + description: undefined, + }; + + return renderWithProviders(); + }; + + describe('Rendering', () => { + it('should show "Create Plan" button when no plan is provided', () => { + renderDialog(); + // The button should be visible + expect(screen.getByRole('button', { name: 'Create Plan' })).toBeInTheDocument(); + // The dialog content should NOT be visible initially + expect(screen.queryByText(/Add Action/i)).not.toBeInTheDocument(); + }); + + it('should show "Edit Plan" button when a plan is provided', () => { + renderDialog({ plan: defaultPlan }); + expect(screen.getByRole('button', { name: 'Edit Plan' })).toBeInTheDocument(); + }); + + it('should not show "Create Plan" button when a plan exists', () => { + renderDialog({ plan: defaultPlan }); + // Query for the button text specifically, not dialog title + expect(screen.queryByRole('button', { name: 'Create Plan' })).not.toBeInTheDocument(); + }); + }); + + describe('Dialog Interactions', () => { + it('should open dialog with "Create Plan" title when creating new plan', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + expect(mockDialogMethods.showModal).toHaveBeenCalled(); + + // One for button, one for dialog. + expect(screen.getAllByText('Create Plan').length).toEqual(2); + }); + + it('should open dialog with "Edit Plan" title when editing existing plan', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + expect(mockDialogMethods.showModal).toHaveBeenCalled(); + // One for button, one for dialog + expect(screen.getAllByText('Edit Plan').length).toEqual(2); + }); + + it('should pre-fill plan name when editing', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; + expect(nameInput.value).toBe(defaultPlan.name); + }); + + it('should close dialog when cancel button is clicked', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + await user.click(screen.getByText('Cancel')); + + expect(mockDialogMethods.close).toHaveBeenCalled(); + }); + }); + + describe('Plan Creation', () => { + it('should create a new plan with default values', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + // One for the button, one for the dialog + expect(screen.getAllByText('Create Plan').length).toEqual(2); + + const nameInput = screen.getByPlaceholderText('Plan name'); + expect(nameInput).toBeInTheDocument(); + }); + + it('should auto-fill with description when provided', async () => { + const description = 'Achieve world peace'; + renderDialog({ description }); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + // Check if plan name is pre-filled with description + const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; + expect(nameInput.value).toBe(description); + + // Check if action type is set to LLM + const actionTypeSelect = screen.getByLabelText(/Action Type/i) as HTMLSelectElement; + expect(actionTypeSelect.value).toBe('llm'); + + // Check if suggestion text is shown + expect(screen.getByText('Filled in as a suggestion!')).toBeInTheDocument(); + expect(screen.getByText('Feel free to change!')).toBeInTheDocument(); + }); + + it('should allow changing plan name', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; + const newName = 'My Custom Plan'; + + // Instead of clear(), select all text and type new value + await user.click(nameInput); + await user.keyboard('{Control>}a{/Control}'); // Select all (Ctrl+A) + await user.keyboard(newName); + + expect(nameInput.value).toBe(newName); + }); + }); + + describe('Action Management', () => { + it('should add a speech action to the plan', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + const actionTypeSelect = screen.getByLabelText(/Action Type/i); + const actionValueInput = screen.getByPlaceholderText("Speech text") + const addButton = screen.getByText('Add Step'); + + // Set up a speech action + await user.selectOptions(actionTypeSelect, 'speech'); + await user.type(actionValueInput, 'Hello there!'); + + await user.click(addButton); + + // Check if step was added + expect(screen.getByText('speech:')).toBeInTheDocument(); + expect(screen.getByText('Hello there!')).toBeInTheDocument(); + }); + + it('should add a gesture action to the plan', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + const actionTypeSelect = screen.getByLabelText(/Action Type/i); + const addButton = screen.getByText('Add Step'); + + // Set up a gesture action + await user.selectOptions(actionTypeSelect, 'gesture'); + + // Find the input field after type change + const gestureInput = screen.getByPlaceholderText(/Gesture name|text/i); + await user.type(gestureInput, 'Wave hand'); + + await user.click(addButton); + + // Check if step was added + expect(screen.getByText('gesture:')).toBeInTheDocument(); + expect(screen.getByText('Wave hand')).toBeInTheDocument(); + }); + + it('should add an LLM action to the plan', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + const actionTypeSelect = screen.getByLabelText(/Action Type/i); + const addButton = screen.getByText('Add Step'); + + // Set up an LLM action + await user.selectOptions(actionTypeSelect, 'llm'); + + // Find the input field after type change + const llmInput = screen.getByPlaceholderText(/LLM goal|text/i); + await user.type(llmInput, 'Generate a story'); + + await user.click(addButton); + + // Check if step was added + expect(screen.getByText('llm:')).toBeInTheDocument(); + expect(screen.getByText('Generate a story')).toBeInTheDocument(); + }); + + it('should disable "Add Step" button when action value is empty', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + const addButton = screen.getByText('Add Step'); + expect(addButton).toBeDisabled(); + }); + + it('should reset action form after adding a step', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + + const actionValueInput = screen.getByPlaceholderText("Speech text") + const addButton = screen.getByText('Add Step'); + + await user.type(actionValueInput, 'Test speech'); + await user.click(addButton); + + // Action value should be cleared + expect(actionValueInput).toHaveValue(''); + // Action type should be reset to speech (default) + const actionTypeSelect = screen.getByLabelText(/Action Type/i) as HTMLSelectElement; + expect(actionTypeSelect.value).toBe('speech'); + }); + }); + + describe('Step Management', () => { + it('should show existing steps when editing a plan', async () => { + renderDialog({ plan: planWithSteps }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + // Check if existing steps are shown + expect(screen.getByText('speech:')).toBeInTheDocument(); + expect(screen.getByText('Hello world')).toBeInTheDocument(); + expect(screen.getByText('gesture:')).toBeInTheDocument(); + expect(screen.getByText('Wave')).toBeInTheDocument(); + }); + + it('should show "No steps yet" message when plan has no steps', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + expect(screen.getByText('No steps yet')).toBeInTheDocument(); + }); + + it('should remove a step when clicked', async () => { + renderDialog({ plan: planWithSteps }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + // Initially have 2 steps + expect(screen.getByText('speech:')).toBeInTheDocument(); + expect(screen.getByText('gesture:')).toBeInTheDocument(); + + // Click on the first step to remove it + await user.click(screen.getByText('Hello world')); + + // First step should be removed + expect(screen.queryByText('Hello world')).not.toBeInTheDocument(); + // Second step should still exist + expect(screen.getByText('Wave')).toBeInTheDocument(); + }); + }); + + describe('Save Functionality', () => { + it('should call onSave with new plan when creating', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + // Set plan name + const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; + await user.click(nameInput); + await user.keyboard('{Control>}a{/Control}'); + await user.keyboard('My New Plan'); + + // Add a step + const actionValueInput = screen.getByPlaceholderText(/text/i); + await user.type(actionValueInput, 'First step'); + await user.click(screen.getByText('Add Step')); + + // Save the plan + await user.click(screen.getByText('Create')); + + expect(mockOnSave).toHaveBeenCalledWith({ + id: mockUUID, + name: 'My New Plan', + steps: [ + { + id: mockUUID, + text: 'First step', + type: 'speech', + }, + ], + }); + expect(mockDialogMethods.close).toHaveBeenCalled(); + }); + + it('should call onSave with updated plan when editing', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + // Change plan name + const nameInput = screen.getByPlaceholderText('Plan name') as HTMLInputElement; + await user.click(nameInput); + await user.keyboard('{Control>}a{/Control}'); + await user.keyboard('Updated Plan Name'); + + // Add a step + const actionValueInput = screen.getByPlaceholderText(/text/i); + await user.type(actionValueInput, 'New speech action'); + await user.click(screen.getByText('Add Step')); + + // Save the plan + await user.click(screen.getByText('Confirm')); + + expect(mockOnSave).toHaveBeenCalledWith({ + id: defaultPlan.id, + name: 'Updated Plan Name', + steps: [ + { + id: mockUUID, + text: 'New speech action', + type: 'speech', + }, + ], + }); + expect(mockDialogMethods.close).toHaveBeenCalled(); + }); + + it('should call onSave with undefined when reset button is clicked', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + await user.click(screen.getByText('Reset')); + + expect(mockOnSave).toHaveBeenCalledWith(undefined); + expect(mockDialogMethods.close).toHaveBeenCalled(); + }); + + it('should disable save button when no draft plan exists', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + // The save button should be enabled since draftPlan exists after clicking Create Plan + const saveButton = screen.getByText('Create'); + expect(saveButton).not.toBeDisabled(); + }); + }); + + describe('Step Indexing', () => { + it('should show correct step numbers', async () => { + renderDialog({ plan: defaultPlan }); + + await user.click(screen.getByRole('button', { name: 'Edit Plan' })); + + // Add multiple steps + const actionValueInput = screen.getByPlaceholderText(/text/i); + const addButton = screen.getByText('Add Step'); + + await user.type(actionValueInput, 'First'); + await user.click(addButton); + + await user.type(actionValueInput, 'Second'); + await user.click(addButton); + + await user.type(actionValueInput, 'Third'); + await user.click(addButton); + + // Check step numbers + expect(screen.getByText('1.')).toBeInTheDocument(); + expect(screen.getByText('2.')).toBeInTheDocument(); + expect(screen.getByText('3.')).toBeInTheDocument(); + }); + }); + + describe('Action Type Switching', () => { + it('should update placeholder text when action type changes', async () => { + renderDialog(); + + await user.click(screen.getByRole('button', { name: 'Create Plan' })); + + const actionTypeSelect = screen.getByLabelText(/Action Type/i); + + // Check speech placeholder + await user.selectOptions(actionTypeSelect, 'speech'); + // The placeholder might be set dynamically, so we need to check the input + const speechInput = screen.getByPlaceholderText(/text/i); + expect(speechInput).toBeInTheDocument(); + + // Check gesture placeholder + await user.selectOptions(actionTypeSelect, 'gesture'); + const gestureInput = screen.getByPlaceholderText(/Gesture|text/i); + expect(gestureInput).toBeInTheDocument(); + + // Check LLM placeholder + await user.selectOptions(actionTypeSelect, 'llm'); + const llmInput = screen.getByPlaceholderText(/LLM|text/i); + expect(llmInput).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index c762fff..29e6a0c 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -425,7 +425,6 @@ describe('NormNode', () => { label: 'Safety Norm', norm: 'Never harm humans', critical: false, - basic_beliefs: [], }); }); @@ -917,17 +916,8 @@ describe('NormNode', () => { } }; - 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], + nodes: [mockNode, mockBelief1], edges: [], }); @@ -938,16 +928,11 @@ describe('NormNode', () => { 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"]); + expect(updatedNorm?.data.condition).toEqual("basic_belief-1"); }); }); }); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx index e3c40e0..6313258 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -1,6 +1,5 @@ import { describe, it, beforeEach } from '@jest/globals'; -import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/react'; import { renderWithProviders } from '../../../../test-utils/test-utils.tsx'; import TriggerNode, { TriggerReduce, @@ -11,12 +10,15 @@ import TriggerNode, { import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; import '@testing-library/jest-dom'; +import { TriggerNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.default.ts'; +import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts'; +import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts'; +import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts'; describe('TriggerNode', () => { - let user: ReturnType; beforeEach(() => { - user = userEvent.setup(); + jest.clearAllMocks(); }); describe('Rendering', () => { @@ -26,11 +28,7 @@ describe('TriggerNode', () => { type: 'trigger', position: { x: 0, y: 0 }, data: { - label: 'Keyword Trigger', - droppable: true, - triggerType: 'keywords', - triggers: [], - hasReduce: true, + ...JSON.parse(JSON.stringify(TriggerNodeDefaults)), }, }; @@ -51,161 +49,59 @@ describe('TriggerNode', () => { /> ); - expect(screen.getByText(/Triggers when the keyword is spoken/i)).toBeInTheDocument(); - expect(screen.getByPlaceholderText('...')).toBeInTheDocument(); - }); - - it('should render TriggerNode with emotion type', () => { - const mockNode: Node = { - id: 'trigger-2', - type: 'trigger', - position: { x: 0, y: 0 }, - data: { - label: 'Emotion Trigger', - droppable: true, - triggerType: 'emotion', - triggers: [], - hasReduce: true, - }, - }; - - renderWithProviders( - - ); - - expect(screen.getByText(/Emotion\?/i)).toBeInTheDocument(); - }); - }); - - describe('User Interactions', () => { - it('should add a new keyword', async () => { - const mockNode: Node = { - id: 'trigger-1', - type: 'trigger', - position: { x: 0, y: 0 }, - data: { - label: 'Keyword Trigger', - droppable: true, - triggerType: 'keywords', - triggers: [], - hasReduce: true, - }, - }; - - useFlowStore.setState({ nodes: [mockNode], edges: [] }); - - renderWithProviders( - - ); - - const input = screen.getByPlaceholderText('...'); - await user.type(input, 'hello{enter}'); - - await waitFor(() => { - const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node | undefined; - expect(node?.data.triggers.length).toBe(1); - expect(node?.data.triggers[0].keyword).toBe('hello'); - }); - - }); - - it('should remove a keyword when cleared', async () => { - const mockNode: Node = { - id: 'trigger-1', - type: 'trigger', - position: { x: 0, y: 0 }, - data: { - label: 'Keyword Trigger', - droppable: true, - triggerType: 'keywords', - triggers: [{ id: 'kw1', keyword: 'hello' }], - hasReduce: true, - }, - }; - - useFlowStore.setState({ nodes: [mockNode], edges: [] }); - - renderWithProviders( - - ); - - const input = screen.getByDisplayValue('hello'); - for (let i = 0; i < 'hello'.length; i++) { - await user.type(input, '{backspace}'); - } - await user.type(input, '{enter}'); - - await waitFor(() => { - const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node | undefined; - expect(node?.data.triggers.length).toBe(0); - }); - + expect(screen.getByText(/Triggers when the condition is met/i)).toBeInTheDocument(); + expect(screen.getByText(/Belief is currently/i)).toBeInTheDocument(); + expect(screen.getByText(/Plan is currently/i)).toBeInTheDocument(); }); }); describe('TriggerReduce Function', () => { it('should reduce a trigger node to its essential data', () => { + const conditionNode: Node = { + id: 'belief-1', + type: 'basic_belief', + position: { x: 0, y: 0 }, + data: { + ...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults)), + }, + }; + const triggerNode: Node = { id: 'trigger-1', type: 'trigger', position: { x: 0, y: 0 }, data: { - label: 'Keyword Trigger', - droppable: true, - triggerType: 'keywords', - triggers: [{ id: 'kw1', keyword: 'hello' }], - hasReduce: true, + ...JSON.parse(JSON.stringify(TriggerNodeDefaults)), + condition: "belief-1", + plan: defaultPlan }, }; - const allNodes: Node[] = [triggerNode]; - const result = TriggerReduce(triggerNode, allNodes); + useFlowStore.setState({ + nodes: [conditionNode, triggerNode], + edges: [], + }); + + useFlowStore.getState().onConnect({ + source: 'belief-1', + target: 'trigger-1', + sourceHandle: null, + targetHandle: null, + }); + + const result = TriggerReduce(triggerNode, useFlowStore.getState().nodes); expect(result).toEqual({ id: 'trigger-1', - type: 'keywords', - label: 'Keyword Trigger', - keywords: [{ id: 'kw1', keyword: 'hello' }], - }); + condition: { + id: "belief-1", + keyword: "help", + }, + plan: { + name: "Default Plan", + id: expect.anything(), + steps: [], + },}); }); }); @@ -217,11 +113,8 @@ describe('TriggerNode', () => { type: 'trigger', position: { x: 0, y: 0 }, data: { + ...JSON.parse(JSON.stringify(TriggerNodeDefaults)), label: 'Trigger 1', - droppable: true, - triggerType: 'keywords', - triggers: [], - hasReduce: true, }, }; @@ -230,10 +123,8 @@ describe('TriggerNode', () => { type: 'norm', position: { x: 100, y: 0 }, data: { + ...JSON.parse(JSON.stringify(NormNodeDefaults)), label: 'Norm 1', - droppable: true, - norm: 'test', - hasReduce: true, }, }; diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 48a3fb9..b2d6373 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -187,7 +187,7 @@ describe('Universal Nodes', () => { // Verify the correct structure is present using NodesInPhase expect(result).toHaveLength(nodeType !== 'phase' ? 1 : 2); expect(result[0]).toHaveProperty('id', 'phase-1'); - expect(result[0]).toHaveProperty('label', 'Test Phase'); + expect(result[0]).toHaveProperty('name', 'Test Phase'); // Restore mocks phaseReduceSpy.mockRestore();