feat: merged demo into dev #43
@@ -2,6 +2,8 @@
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
border-radius: 5pt;
|
border-radius: 5pt;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
|
max-width: 50vw;
|
||||||
|
min-width: 10vw;
|
||||||
outline: none;
|
outline: none;
|
||||||
background-color: canvas;
|
background-color: canvas;
|
||||||
transition: border-color 0.2s, box-shadow 0.2s;
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function RealtimeTextField({
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
id={id}
|
id={id}
|
||||||
// ReactFlow uses the "drag" / "nodrag" classes to enable / disable dragging of nodes
|
// 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}
|
aria-label={ariaLabel}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,23 +183,33 @@
|
|||||||
transition: text-decoration 0.2s;
|
transition: text-decoration 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.planStep:hover {
|
.planStep:hover {
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stepType {
|
.stepType {
|
||||||
margin-left: auto;
|
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.stepIndex {
|
.stepIndex {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.emptySteps {
|
.emptySteps {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stepSuggestion {
|
||||||
|
opacity: 0.5;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planNoIterate {
|
||||||
|
opacity: 0.5;
|
||||||
|
font-style: italic;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
@@ -16,10 +16,55 @@ export type Goal = {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
export type Action = SpeechAction | GestureAction | LLMAction
|
export type Action = SpeechAction | GestureAction | LLMAction
|
||||||
export type SpeechAction = { name: string, id: string, text: string, type:"speech" }
|
export type SpeechAction = { id: string, text: string, type:"speech" }
|
||||||
export type GestureAction = { name: string, id: string, gesture: string, type:"gesture" }
|
export type GestureAction = { id: string, gesture: string, type:"gesture" }
|
||||||
export type LLMAction = { name: string, id: string, goal: string, type:"llm" }
|
export type LLMAction = { id: string, goal: string, type:"llm" }
|
||||||
|
|
||||||
export type ActionTypes = "speech" | "gesture" | "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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
* <PlanEditorDialog
|
||||||
|
* plan={data.plan}
|
||||||
|
* onSave={(plan) => {
|
||||||
|
* updateNodeData(props.id, {
|
||||||
|
* ...data,
|
||||||
|
* plan,
|
||||||
|
* });
|
||||||
|
* }}
|
||||||
|
* />
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export default function PlanEditorDialog({
|
||||||
|
plan,
|
||||||
|
onSave,
|
||||||
|
description,
|
||||||
|
}: PlanEditorDialogProps) {
|
||||||
|
// UseStates and references
|
||||||
|
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||||
|
const [draftPlan, setDraftPlan] = useState<Plan | null>(null);
|
||||||
|
const [newActionType, setNewActionType] = useState<ActionTypes>("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 && (
|
||||||
|
<button className={styles.nodeButton} onClick={description ? openCreateWithDescription : openCreate}>
|
||||||
|
Create Plan
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{plan && (
|
||||||
|
<button className={styles.nodeButton} onClick={openEdit}>
|
||||||
|
Edit Plan
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Start of dialog (plan editor) */}
|
||||||
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
|
className={styles.planDialog}
|
||||||
|
onCancel={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<form method="dialog" className="flex-col gap-md">
|
||||||
|
<h3> {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"} </h3>
|
||||||
|
|
||||||
|
{/* Plan name text field */}
|
||||||
|
{draftPlan && (
|
||||||
|
<TextField
|
||||||
|
value={draftPlan.name}
|
||||||
|
setValue={(name) =>
|
||||||
|
setDraftPlan({ ...draftPlan, name })}
|
||||||
|
placeholder="Plan name"
|
||||||
|
data-testid="name_text_field"/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Entire "bottom" part (adder and steps) without cancel, confirm and reset */}
|
||||||
|
{draftPlan && (<div className={styles.planEditor}>
|
||||||
|
<div className={styles.planEditorLeft}>
|
||||||
|
{/* Left Side (Action Adder) */}
|
||||||
|
<h4>Add Action</h4>
|
||||||
|
{(!plan && description && draftPlan.steps.length === 0) && (<div className={styles.stepSuggestion}>
|
||||||
|
<label> Filled in as a suggestion! </label>
|
||||||
|
<label> Feel free to change! </label>
|
||||||
|
</div>)}
|
||||||
|
<label>
|
||||||
|
Action Type <wbr />
|
||||||
|
{/* Type selection */}
|
||||||
|
<select
|
||||||
|
value={newActionType}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewActionType(e.target.value as ActionTypes)}>
|
||||||
|
<option value="speech">Speech Action</option>
|
||||||
|
<option value="gesture">Gesture Action</option>
|
||||||
|
<option value="llm">LLM Action</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Action value editor */}
|
||||||
|
<TextField
|
||||||
|
value={newActionValue}
|
||||||
|
setValue={setNewActionValue}
|
||||||
|
|
||||||
|
// TODO: Can be done with a switch in case there's more types to come.
|
||||||
|
placeholder={
|
||||||
|
newActionType === "speech" ? "Speech text"
|
||||||
|
: newActionType === "gesture" ? "Gesture name"
|
||||||
|
: "LLM goal"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Adding steps */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!newActionValue}
|
||||||
|
onClick={() => {
|
||||||
|
if (!draftPlan) return;
|
||||||
|
// Add action to steps
|
||||||
|
const action = buildAction();
|
||||||
|
setDraftPlan({
|
||||||
|
...draftPlan,
|
||||||
|
steps: [...draftPlan.steps, action],});
|
||||||
|
|
||||||
|
// Reset current action building
|
||||||
|
setNewActionValue("");
|
||||||
|
setNewActionType("speech");
|
||||||
|
}}>
|
||||||
|
Add Step
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side (Steps shown) */}
|
||||||
|
<div className={styles.planEditorRight}>
|
||||||
|
<h4>Steps</h4>
|
||||||
|
|
||||||
|
{/* Show if there are no steps yet */}
|
||||||
|
{draftPlan.steps.length === 0 && (
|
||||||
|
<div className={styles.emptySteps}>
|
||||||
|
No steps yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* 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) => (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className={styles.planStep}
|
||||||
|
onClick={() => {
|
||||||
|
setDraftPlan({
|
||||||
|
...draftPlan,
|
||||||
|
steps: draftPlan.steps.filter((s) => s.id !== step.id),});
|
||||||
|
}}>
|
||||||
|
<span className={styles.stepIndex}>{index + 1}.</span>
|
||||||
|
<span className={styles.stepType}>{step.type}:</span>
|
||||||
|
<span className={styles.stepName}>{
|
||||||
|
step.type == "goal" ? ""/* TODO: Add support for goals */
|
||||||
|
: GetActionValue(step)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)} {/* End Action Editor and steps shower */}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div className="flex-row gap-md">
|
||||||
|
{/* Close button */}
|
||||||
|
<button type="button" onClick={close}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Confirm/ Create button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!draftPlan}
|
||||||
|
onClick={() => {
|
||||||
|
if (!draftPlan) return;
|
||||||
|
onSave(draftPlan);
|
||||||
|
close();
|
||||||
|
}}>
|
||||||
|
{draftPlan?.id === plan?.id ? "Confirm" : "Create"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Reset button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!draftPlan}
|
||||||
|
onClick={() => {
|
||||||
|
onSave(undefined);
|
||||||
|
close();
|
||||||
|
}}>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -181,11 +181,27 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
|
|||||||
*/
|
*/
|
||||||
export function BasicBeliefReduce(node: Node, _nodes: Node[]) {
|
export function BasicBeliefReduce(node: Node, _nodes: Node[]) {
|
||||||
const data = node.data as BasicBeliefNodeData;
|
const data = node.data as BasicBeliefNodeData;
|
||||||
return {
|
const result: Record<string, unknown> = {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
type: data.belief.type,
|
};
|
||||||
value: data.belief.value
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import type { GoalNodeData } from "./GoalNode";
|
|||||||
export const GoalNodeDefaults: GoalNodeData = {
|
export const GoalNodeDefaults: GoalNodeData = {
|
||||||
label: "Goal Node",
|
label: "Goal Node",
|
||||||
droppable: true,
|
droppable: true,
|
||||||
description: "The robot will strive towards this goal",
|
description: "",
|
||||||
achieved: false,
|
achieved: false,
|
||||||
hasReduce: true,
|
hasReduce: true,
|
||||||
|
can_fail: true,
|
||||||
};
|
};
|
||||||
@@ -8,6 +8,8 @@ import { Toolbar } from '../components/NodeComponents';
|
|||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
import { TextField } from '../../../../components/TextField';
|
import { TextField } from '../../../../components/TextField';
|
||||||
import useFlowStore from '../VisProgStores';
|
import useFlowStore from '../VisProgStores';
|
||||||
|
import { DoesPlanIterate, PlanReduce, type Plan } from '../components/Plan';
|
||||||
|
import PlanEditorDialog from '../components/PlanEditor';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default data dot a phase node
|
* The default data dot a phase node
|
||||||
@@ -22,6 +24,8 @@ export type GoalNodeData = {
|
|||||||
droppable: boolean;
|
droppable: boolean;
|
||||||
achieved: boolean;
|
achieved: boolean;
|
||||||
hasReduce: boolean;
|
hasReduce: boolean;
|
||||||
|
can_fail: boolean;
|
||||||
|
plan?: Plan;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GoalNode = Node<GoalNodeData>
|
export type GoalNode = Node<GoalNodeData>
|
||||||
@@ -37,13 +41,14 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
|||||||
|
|
||||||
const text_input_id = `goal_${id}_text_input`;
|
const text_input_id = `goal_${id}_text_input`;
|
||||||
const checkbox_id = `goal_${id}_checkbox`;
|
const checkbox_id = `goal_${id}_checkbox`;
|
||||||
|
const planIterate = DoesPlanIterate(data.plan);
|
||||||
|
|
||||||
const setDescription = (value: string) => {
|
const setDescription = (value: string) => {
|
||||||
updateNodeData(id, {...data, description: value});
|
updateNodeData(id, {...data, description: value});
|
||||||
}
|
}
|
||||||
|
|
||||||
const setAchieved = (value: boolean) => {
|
const setFailable = (value: boolean) => {
|
||||||
updateNodeData(id, {...data, achieved: value});
|
updateNodeData(id, {...data, can_fail: value});
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
@@ -57,14 +62,34 @@ export default function GoalNode({id, data}: NodeProps<GoalNode>) {
|
|||||||
setValue={(val) => setDescription(val)}
|
setValue={(val) => setDescription(val)}
|
||||||
placeholder={"To ..."}
|
placeholder={"To ..."}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className={"flex-row gap-md align-center"}>
|
<div>
|
||||||
<label htmlFor={checkbox_id}>Achieved:</label>
|
<label> {!data.plan ? "No plan set to execute while goal is not reached. 🔴" : "Will follow plan '" + data.plan.name + "' until goal is met. 🟢"} </label>
|
||||||
|
</div>
|
||||||
|
{data.plan && (<div className={"flex-row gap-md align-center " + (planIterate ? "" : styles.planNoIterate)}>
|
||||||
|
{planIterate ? "" : <s></s>}
|
||||||
|
<label htmlFor={checkbox_id}>Try this plan only once (allow critical abortion):</label>
|
||||||
<input
|
<input
|
||||||
id={checkbox_id}
|
id={checkbox_id}
|
||||||
type={"checkbox"}
|
type={"checkbox"}
|
||||||
checked={data.achieved || false}
|
disabled={!planIterate}
|
||||||
onChange={(e) => setAchieved(e.target.checked)}
|
checked={!planIterate || data.can_fail}
|
||||||
|
onChange={(e) => planIterate ? setFailable(e.target.checked) : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<PlanEditorDialog
|
||||||
|
plan={data.plan}
|
||||||
|
onSave={(plan) => {
|
||||||
|
updateNodeData(id, {
|
||||||
|
...data,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
description={data.description}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Handle type="source" position={Position.Right} id="GoalSource"/>
|
<Handle type="source" position={Position.Right} id="GoalSource"/>
|
||||||
@@ -82,9 +107,10 @@ export function GoalReduce(node: Node, _nodes: Node[]) {
|
|||||||
const data = node.data as GoalNodeData;
|
const data = node.data as GoalNodeData;
|
||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
label: data.label,
|
name: data.label,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
achieved: data.achieved,
|
can_fail: data.can_fail,
|
||||||
|
plan: data.plan ? PlanReduce(data.plan) : "",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import type { NormNodeData } from "./NormNode";
|
|||||||
export const NormNodeDefaults: NormNodeData = {
|
export const NormNodeDefaults: NormNodeData = {
|
||||||
label: "Norm Node",
|
label: "Norm Node",
|
||||||
droppable: true,
|
droppable: true,
|
||||||
conditions: [],
|
|
||||||
norm: "",
|
norm: "",
|
||||||
hasReduce: true,
|
hasReduce: true,
|
||||||
critical: false,
|
critical: false,
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function PhaseReduce(node: Node, nodes: Node[]) {
|
|||||||
// Build the result object
|
// Build the result object
|
||||||
const result: Record<string, unknown> = {
|
const result: Record<string, unknown> = {
|
||||||
id: thisnode.id,
|
id: thisnode.id,
|
||||||
label: data.label,
|
name: data.label,
|
||||||
};
|
};
|
||||||
|
|
||||||
nodesInPhase.forEach((type) => {
|
nodesInPhase.forEach((type) => {
|
||||||
|
|||||||
@@ -6,7 +6,5 @@ import type { TriggerNodeData } from "./TriggerNode";
|
|||||||
export const TriggerNodeDefaults: TriggerNodeData = {
|
export const TriggerNodeDefaults: TriggerNodeData = {
|
||||||
label: "Trigger Node",
|
label: "Trigger Node",
|
||||||
droppable: true,
|
droppable: true,
|
||||||
triggers: [],
|
|
||||||
triggerType: "keywords",
|
|
||||||
hasReduce: true,
|
hasReduce: true,
|
||||||
};
|
};
|
||||||
@@ -9,11 +9,9 @@ import {
|
|||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
import useFlowStore from '../VisProgStores';
|
import useFlowStore from '../VisProgStores';
|
||||||
import { useState, useRef } from 'react';
|
import { PlanReduce, type Plan } from '../components/Plan';
|
||||||
import { RealtimeTextField, TextField } from '../../../../components/TextField';
|
import PlanEditorDialog from '../components/PlanEditor';
|
||||||
import duplicateIndices from '../../../../utils/duplicateIndices';
|
import { BasicBeliefReduce } from './BasicBeliefNode';
|
||||||
import type { Action, ActionTypes, Plan } from '../components/Plan';
|
|
||||||
import { defaultPlan } from '../components/Plan.default';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default data structure for a Trigger node
|
* 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 label: the display label of this Trigger node.
|
||||||
* @property droppable: Whether this node can be dropped from the toolbar (default: true).
|
* @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.
|
* @property hasReduce - Whether this node supports reduction logic.
|
||||||
*/
|
*/
|
||||||
export type TriggerNodeData = {
|
export type TriggerNodeData = {
|
||||||
@@ -44,6 +40,7 @@ export type TriggerNode = Node<TriggerNodeData>
|
|||||||
*
|
*
|
||||||
* @param connection - The connection or edge being attempted to connect towards.
|
* @param connection - The connection or edge being attempted to connect towards.
|
||||||
* @returns `true` if the connection is defined; otherwise, `false`.
|
* @returns `true` if the connection is defined; otherwise, `false`.
|
||||||
|
*
|
||||||
*/
|
*/
|
||||||
export function TriggerNodeCanConnect(connection: Connection | Edge): boolean {
|
export function TriggerNodeCanConnect(connection: Connection | Edge): boolean {
|
||||||
return (connection != undefined);
|
return (connection != undefined);
|
||||||
@@ -57,63 +54,6 @@ export function TriggerNodeCanConnect(connection: Connection | Edge): boolean {
|
|||||||
export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
||||||
const data = props.data;
|
const data = props.data;
|
||||||
const {updateNodeData} = useFlowStore();
|
const {updateNodeData} = useFlowStore();
|
||||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
|
||||||
const [draftPlan, setDraftPlan] = useState<Plan | null>(null);
|
|
||||||
|
|
||||||
// Helpers for inside plan creation
|
|
||||||
const [newActionType, setNewActionType] = useState<ActionTypes>("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 <>
|
return <>
|
||||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||||
@@ -123,199 +63,16 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
|||||||
<div className={"flex-row gap-md"}>Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}</div>
|
<div className={"flex-row gap-md"}>Plan{data.plan ? (": " + data.plan.name) : ""} is currently {data.plan ? "" : "not"} set. {data.plan ? "🟢" : "🔴"}</div>
|
||||||
<Handle type="source" position={Position.Right} id="TriggerSource"/>
|
<Handle type="source" position={Position.Right} id="TriggerSource"/>
|
||||||
<Handle type="target" position={Position.Bottom} id="ConditionTarget"/>
|
<Handle type="target" position={Position.Bottom} id="ConditionTarget"/>
|
||||||
|
<PlanEditorDialog
|
||||||
{/* We don't have a plan yet, show our create plan button */}
|
plan={data.plan}
|
||||||
{!data.plan && (
|
onSave={(plan) => {
|
||||||
<button
|
updateNodeData(props.id, {
|
||||||
className={styles.nodeButton}
|
...data,
|
||||||
onClick={openCreateDialog}
|
plan,
|
||||||
>
|
});
|
||||||
Create Plan
|
}}
|
||||||
</button>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* We have a plan, show our edit plan button */}
|
|
||||||
{data.plan && (
|
|
||||||
<button
|
|
||||||
className={styles.nodeButton}
|
|
||||||
onClick={openEditDialog}
|
|
||||||
>
|
|
||||||
Edit Plan
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Define how our dialog should work */}
|
|
||||||
<dialog ref={dialogRef}
|
|
||||||
className={styles.planDialog}
|
|
||||||
onCancel={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<form method="dialog" className="flex-col gap-md">
|
|
||||||
<h3>{draftPlan?.id === data.plan?.id ? "Edit Plan" : "Create Plan"}</h3>
|
|
||||||
|
|
||||||
{/*Text field to edit the name of our draft plan*/}
|
|
||||||
<div className={styles.planEditor}>
|
|
||||||
{/* LEFT: plan info + add action */}
|
|
||||||
<div className={styles.planEditorLeft}>
|
|
||||||
{/* Plan name */}
|
|
||||||
{draftPlan && (
|
|
||||||
<div className="flex-col gap-sm">
|
|
||||||
<label htmlFor="plan-name">Plan Name</label>
|
|
||||||
<TextField
|
|
||||||
id="plan-name"
|
|
||||||
value={draftPlan.name}
|
|
||||||
setValue={(name) =>
|
|
||||||
setDraftPlan({
|
|
||||||
...draftPlan,
|
|
||||||
name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add action UI */}
|
|
||||||
{draftPlan && (
|
|
||||||
<div className="flex-col gap-sm">
|
|
||||||
<h4>Add Action</h4>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Action Type <wbr></wbr> {/* Just moves the selector over a bit :) */}
|
|
||||||
<select
|
|
||||||
value={newActionType}
|
|
||||||
onChange={(e) =>
|
|
||||||
setNewActionType(e.target.value as ActionTypes)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="speech">Speech Action</option>
|
|
||||||
<option value="gesture">Gesture Action</option>
|
|
||||||
<option value="llm">LLM Action</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
value={newActionName}
|
|
||||||
setValue={setNewActionName}
|
|
||||||
placeholder="Action name"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TextField
|
|
||||||
value={newActionValue}
|
|
||||||
setValue={setNewActionValue}
|
|
||||||
placeholder={
|
|
||||||
newActionType === "speech"
|
|
||||||
? "Speech text"
|
|
||||||
: newActionType === "gesture"
|
|
||||||
? "Gesture name"
|
|
||||||
: "LLM goal"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!newActionName || !newActionValue}
|
|
||||||
onClick={() => {
|
|
||||||
if (!draftPlan) return;
|
|
||||||
|
|
||||||
const action = buildAction();
|
|
||||||
|
|
||||||
setDraftPlan({
|
|
||||||
...draftPlan,
|
|
||||||
steps: [...draftPlan.steps, action],
|
|
||||||
});
|
|
||||||
|
|
||||||
setNewActionName("");
|
|
||||||
setNewActionValue("");
|
|
||||||
setNewActionType("speech");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Step
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* RIGHT: steps list */}
|
|
||||||
<div className={styles.planEditorRight}>
|
|
||||||
<h4>Steps</h4>
|
|
||||||
|
|
||||||
{draftPlan && draftPlan.steps.length === 0 && (
|
|
||||||
<div className={styles.emptySteps}>
|
|
||||||
No steps yet
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{draftPlan?.steps.map((step, index) => (
|
|
||||||
<div
|
|
||||||
key={step.id}
|
|
||||||
className={styles.planStep}
|
|
||||||
onClick={() => {
|
|
||||||
if (!draftPlan) return;
|
|
||||||
setDraftPlan({
|
|
||||||
...draftPlan,
|
|
||||||
steps: draftPlan.steps.filter((s) => s.id !== step.id),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className={styles.stepIndex}>{index + 1}.</span>
|
|
||||||
<span className={styles.stepName}>{step.name}</span>
|
|
||||||
<span className={styles.stepType}>{step.type}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-row gap-md">
|
|
||||||
{/*Button to close the plan editor.*/}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setDraftPlan(null);
|
|
||||||
closeDialog();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/*Button to save the draftPlan to the plan in the Node.*/}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!draftPlan}
|
|
||||||
onClick={() => {
|
|
||||||
if (!draftPlan) return;
|
|
||||||
|
|
||||||
updateNodeData(props.id, {
|
|
||||||
...data,
|
|
||||||
plan: draftPlan,
|
|
||||||
});
|
|
||||||
setDraftPlan(null);
|
|
||||||
closeDialog();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{draftPlan?.id === data.plan?.id ? "Confirm" : "Create"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/*Button to reset the plan*/}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!draftPlan}
|
|
||||||
onClick={() => {
|
|
||||||
if (!draftPlan) return;
|
|
||||||
updateNodeData(props.id, {
|
|
||||||
...data,
|
|
||||||
plan: undefined,
|
|
||||||
});
|
|
||||||
setNewActionName("")
|
|
||||||
setNewActionType("speech")
|
|
||||||
setNewActionValue("")
|
|
||||||
setDraftPlan(defaultPlan)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Reset
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
</>;
|
</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,22 +82,16 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
|||||||
* @param _nodes - The list of all nodes in the current flow graph.
|
* @param _nodes - The list of all nodes in the current flow graph.
|
||||||
* @returns A simplified object containing the node label and its list of triggers.
|
* @returns A simplified object containing the node label and its list of triggers.
|
||||||
*/
|
*/
|
||||||
export function TriggerReduce(node: Node, _nodes: Node[]) {
|
export function TriggerReduce(node: Node, nodes: Node[]) {
|
||||||
const data = node.data;
|
const data = node.data as TriggerNodeData;
|
||||||
switch (data.triggerType) {
|
const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined
|
||||||
case "keywords":
|
const conditionData = conditionNode ? BasicBeliefReduce(conditionNode, nodes) : ""
|
||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
type: "keywords",
|
condition: conditionData, // Make sure we have a condition before reducing, or default to ""
|
||||||
label: data.label,
|
plan: !data.plan ? "" : PlanReduce(data.plan), // Make sure we have a plan when reducing, or default to ""
|
||||||
keywords: data.triggers,
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
id: node.id,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -406,91 +157,3 @@ export type KeywordTriggerNodeProps = {
|
|||||||
|
|
||||||
/** Union type for all possible Trigger node configurations. */
|
/** Union type for all possible Trigger node configurations. */
|
||||||
export type TriggerNodeProps = EmotionTriggerNodeProps | KeywordTriggerNodeProps;
|
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 <div className={"flex-row gap-md"}>
|
|
||||||
<label htmlFor={text_input_id}>New Keyword:</label>
|
|
||||||
<RealtimeTextField
|
|
||||||
id={text_input_id}
|
|
||||||
value={input}
|
|
||||||
setValue={setInput}
|
|
||||||
onCommit={() => {
|
|
||||||
if (!input) return;
|
|
||||||
addKeyword(input);
|
|
||||||
setInput("");
|
|
||||||
}}
|
|
||||||
placeholder={"..."}
|
|
||||||
className={"flex-1"}
|
|
||||||
/>
|
|
||||||
</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<number[]>([]);
|
|
||||||
|
|
||||||
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 <>
|
|
||||||
<span>Triggers when {keywords.length <= 1 ? "the keyword is" : "all keywords are"} spoken.</span>
|
|
||||||
{[...keywords].map(({id, keyword}, index) => {
|
|
||||||
return <div key={id} className={"flex-row gap-md"}>
|
|
||||||
<label htmlFor={inputElementId(id)}>Keyword:</label>
|
|
||||||
<TextField
|
|
||||||
id={inputElementId(id)}
|
|
||||||
value={keyword}
|
|
||||||
setValue={(val) => replace(id, val)}
|
|
||||||
placeholder={"..."}
|
|
||||||
className={"flex-1"}
|
|
||||||
invalid={duplicates.includes(index)}
|
|
||||||
/>
|
|
||||||
</div>;
|
|
||||||
})}
|
|
||||||
<KeywordAdder addKeyword={add} />
|
|
||||||
</>;
|
|
||||||
}
|
|
||||||
@@ -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<typeof userEvent.setup>;
|
||||||
|
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<React.ComponentProps<typeof PlanEditorDialog>> = {}) => {
|
||||||
|
const defaultProps = {
|
||||||
|
plan: undefined,
|
||||||
|
onSave: mockOnSave,
|
||||||
|
description: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return renderWithProviders(<PlanEditorDialog {...defaultProps} {...props} />);
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -425,7 +425,6 @@ describe('NormNode', () => {
|
|||||||
label: 'Safety Norm',
|
label: 'Safety Norm',
|
||||||
norm: 'Never harm humans',
|
norm: 'Never harm humans',
|
||||||
critical: false,
|
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({
|
useFlowStore.setState({
|
||||||
nodes: [mockNode, mockBelief1, mockBelief2],
|
nodes: [mockNode, mockBelief1],
|
||||||
edges: [],
|
edges: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -938,16 +928,11 @@ describe('NormNode', () => {
|
|||||||
sourceHandle: null,
|
sourceHandle: null,
|
||||||
targetHandle: null,
|
targetHandle: null,
|
||||||
});
|
});
|
||||||
useFlowStore.getState().onConnect({
|
|
||||||
source: 'basic_belief-2',
|
|
||||||
target: 'norm-1',
|
|
||||||
sourceHandle: null,
|
|
||||||
targetHandle: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const state = useFlowStore.getState();
|
const state = useFlowStore.getState();
|
||||||
const updatedNorm = state.nodes.find(n => n.id === 'norm-1');
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, it, beforeEach } from '@jest/globals';
|
import { describe, it, beforeEach } from '@jest/globals';
|
||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
import { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
|
||||||
import TriggerNode, {
|
import TriggerNode, {
|
||||||
TriggerReduce,
|
TriggerReduce,
|
||||||
@@ -11,12 +10,15 @@ import TriggerNode, {
|
|||||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||||
import type { Node } from '@xyflow/react';
|
import type { Node } from '@xyflow/react';
|
||||||
import '@testing-library/jest-dom';
|
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', () => {
|
describe('TriggerNode', () => {
|
||||||
let user: ReturnType<typeof userEvent.setup>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
user = userEvent.setup();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Rendering', () => {
|
describe('Rendering', () => {
|
||||||
@@ -26,11 +28,7 @@ describe('TriggerNode', () => {
|
|||||||
type: 'trigger',
|
type: 'trigger',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Keyword Trigger',
|
...JSON.parse(JSON.stringify(TriggerNodeDefaults)),
|
||||||
droppable: true,
|
|
||||||
triggerType: 'keywords',
|
|
||||||
triggers: [],
|
|
||||||
hasReduce: true,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,161 +49,59 @@ describe('TriggerNode', () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText(/Triggers when the keyword is spoken/i)).toBeInTheDocument();
|
expect(screen.getByText(/Triggers when the condition is met/i)).toBeInTheDocument();
|
||||||
expect(screen.getByPlaceholderText('...')).toBeInTheDocument();
|
expect(screen.getByText(/Belief is currently/i)).toBeInTheDocument();
|
||||||
});
|
expect(screen.getByText(/Plan is currently/i)).toBeInTheDocument();
|
||||||
|
|
||||||
it('should render TriggerNode with emotion type', () => {
|
|
||||||
const mockNode: Node<TriggerNodeData> = {
|
|
||||||
id: 'trigger-2',
|
|
||||||
type: 'trigger',
|
|
||||||
position: { x: 0, y: 0 },
|
|
||||||
data: {
|
|
||||||
label: 'Emotion Trigger',
|
|
||||||
droppable: true,
|
|
||||||
triggerType: 'emotion',
|
|
||||||
triggers: [],
|
|
||||||
hasReduce: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
renderWithProviders(
|
|
||||||
<TriggerNode
|
|
||||||
id={mockNode.id}
|
|
||||||
type={mockNode.type as string}
|
|
||||||
data={mockNode.data as any}
|
|
||||||
selected={false}
|
|
||||||
isConnectable={true}
|
|
||||||
zIndex={0}
|
|
||||||
dragging={false}
|
|
||||||
selectable={true}
|
|
||||||
deletable={true}
|
|
||||||
draggable={true}
|
|
||||||
positionAbsoluteX={0}
|
|
||||||
positionAbsoluteY={0}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Emotion\?/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('User Interactions', () => {
|
|
||||||
it('should add a new keyword', async () => {
|
|
||||||
const mockNode: Node<TriggerNodeData> = {
|
|
||||||
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(
|
|
||||||
<TriggerNode
|
|
||||||
id={mockNode.id}
|
|
||||||
type={mockNode.type as string}
|
|
||||||
data={mockNode.data as any}
|
|
||||||
selected={false}
|
|
||||||
isConnectable={true}
|
|
||||||
zIndex={0}
|
|
||||||
dragging={false}
|
|
||||||
selectable={true}
|
|
||||||
deletable={true}
|
|
||||||
draggable={true}
|
|
||||||
positionAbsoluteX={0}
|
|
||||||
positionAbsoluteY={0}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
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<TriggerNodeData> | 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<TriggerNodeData> = {
|
|
||||||
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(
|
|
||||||
<TriggerNode
|
|
||||||
id={mockNode.id}
|
|
||||||
type={mockNode.type as string}
|
|
||||||
data={mockNode.data as any}
|
|
||||||
selected={false}
|
|
||||||
isConnectable={true}
|
|
||||||
zIndex={0}
|
|
||||||
dragging={false}
|
|
||||||
selectable={true}
|
|
||||||
deletable={true}
|
|
||||||
draggable={true}
|
|
||||||
positionAbsoluteX={0}
|
|
||||||
positionAbsoluteY={0}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
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<TriggerNodeData> | undefined;
|
|
||||||
expect(node?.data.triggers.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('TriggerReduce Function', () => {
|
describe('TriggerReduce Function', () => {
|
||||||
it('should reduce a trigger node to its essential data', () => {
|
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 = {
|
const triggerNode: Node = {
|
||||||
id: 'trigger-1',
|
id: 'trigger-1',
|
||||||
type: 'trigger',
|
type: 'trigger',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
label: 'Keyword Trigger',
|
...JSON.parse(JSON.stringify(TriggerNodeDefaults)),
|
||||||
droppable: true,
|
condition: "belief-1",
|
||||||
triggerType: 'keywords',
|
plan: defaultPlan
|
||||||
triggers: [{ id: 'kw1', keyword: 'hello' }],
|
|
||||||
hasReduce: true,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const allNodes: Node[] = [triggerNode];
|
useFlowStore.setState({
|
||||||
const result = TriggerReduce(triggerNode, allNodes);
|
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({
|
expect(result).toEqual({
|
||||||
id: 'trigger-1',
|
id: 'trigger-1',
|
||||||
type: 'keywords',
|
condition: {
|
||||||
label: 'Keyword Trigger',
|
id: "belief-1",
|
||||||
keywords: [{ id: 'kw1', keyword: 'hello' }],
|
keyword: "help",
|
||||||
});
|
},
|
||||||
|
plan: {
|
||||||
|
name: "Default Plan",
|
||||||
|
id: expect.anything(),
|
||||||
|
steps: [],
|
||||||
|
},});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -217,11 +113,8 @@ describe('TriggerNode', () => {
|
|||||||
type: 'trigger',
|
type: 'trigger',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(TriggerNodeDefaults)),
|
||||||
label: 'Trigger 1',
|
label: 'Trigger 1',
|
||||||
droppable: true,
|
|
||||||
triggerType: 'keywords',
|
|
||||||
triggers: [],
|
|
||||||
hasReduce: true,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -230,10 +123,8 @@ describe('TriggerNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 100, y: 0 },
|
position: { x: 100, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Norm 1',
|
label: 'Norm 1',
|
||||||
droppable: true,
|
|
||||||
norm: 'test',
|
|
||||||
hasReduce: true,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ describe('Universal Nodes', () => {
|
|||||||
// Verify the correct structure is present using NodesInPhase
|
// Verify the correct structure is present using NodesInPhase
|
||||||
expect(result).toHaveLength(nodeType !== 'phase' ? 1 : 2);
|
expect(result).toHaveLength(nodeType !== 'phase' ? 1 : 2);
|
||||||
expect(result[0]).toHaveProperty('id', 'phase-1');
|
expect(result[0]).toHaveProperty('id', 'phase-1');
|
||||||
expect(result[0]).toHaveProperty('label', 'Test Phase');
|
expect(result[0]).toHaveProperty('name', 'Test Phase');
|
||||||
|
|
||||||
// Restore mocks
|
// Restore mocks
|
||||||
phaseReduceSpy.mockRestore();
|
phaseReduceSpy.mockRestore();
|
||||||
|
|||||||
Reference in New Issue
Block a user