Add experiment logs to the monitoring page #48
@@ -248,3 +248,11 @@ button.no-button {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.flex-center-x {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center; /* horizontal centering */
|
||||||
|
text-align: center; /* center multi-line text */
|
||||||
|
width: 100%; /* allow it to stretch */
|
||||||
|
flex-wrap: wrap; /* optional: let text wrap naturally */
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import VisProg from "./pages/VisProgPage/VisProg.tsx";
|
|||||||
import {useState} from "react";
|
import {useState} from "react";
|
||||||
import Logging from "./components/Logging/Logging.tsx";
|
import Logging from "./components/Logging/Logging.tsx";
|
||||||
|
|
||||||
|
|
||||||
function App(){
|
function App(){
|
||||||
const [showLogs, setShowLogs] = useState(false);
|
const [showLogs, setShowLogs] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,113 @@
|
|||||||
import { useState } from 'react'
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
/**
|
export default function Counter() {
|
||||||
* A minimal counter component that demonstrates basic React state handling.
|
const [customText, setCustomText] = useState("");
|
||||||
*
|
const [selectedGesture, setSelectedGesture] = useState("animations/Stand/BodyTalk/Speaking/BodyTalk_1");
|
||||||
* Maintains an internal count value and provides buttons to increment and reset it.
|
|
||||||
*
|
// Reusable helper to talk to the backend
|
||||||
* @returns A JSX element rendering the counter UI.
|
const send = async (type: string, context: string) => {
|
||||||
*/
|
try {
|
||||||
function Counter() {
|
await fetch("http://localhost:8000/button_pressed", {
|
||||||
/** The current counter value. */
|
method: "POST",
|
||||||
const [count, setCount] = useState(0)
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type, context }),
|
||||||
|
});
|
||||||
|
// Backticks restored here:
|
||||||
|
console.log(`Sent ${type}: ${context}`);
|
||||||
|
} catch (err) {
|
||||||
|
// Backticks restored here:
|
||||||
|
console.error(`Error sending ${type}:`, err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function list_generator(header: string, items: string[]) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, minWidth: '200px' }}>
|
||||||
|
<strong style={{ display: 'block', marginBottom: '10px', borderBottom: '1px solid #eee' }}>{header}</strong>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<li key={index} style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '8px',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}>
|
||||||
|
<span>{item}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => send("override", "2")}
|
||||||
|
style={{
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
padding: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
title="Send Override 2"
|
||||||
|
>
|
||||||
|
▶
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', padding: '20px' }}>
|
||||||
<button onClick={() => setCount((count) => count + 1)}>
|
|
||||||
count is {count}
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
</button>
|
<input
|
||||||
<button className='reset' onClick={() => setCount(0)}>
|
type="text"
|
||||||
Reset Counter
|
value={customText}
|
||||||
|
onChange={(e) => setCustomText(e.target.value)}
|
||||||
|
placeholder="Type something for Pepper to say..."
|
||||||
|
style={{ padding: '8px', flex: 1 }}
|
||||||
|
/>
|
||||||
|
<button onClick={() => send("speech", customText)} style={{ padding: '8px 16px' }}>
|
||||||
|
Say Custom Text
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
|
<div style={{ display: 'flex', gap: '10px' }}>
|
||||||
|
<button onClick={() => send("speech", "Hello, I'm Pepper")}>
|
||||||
|
"Hello, I'm Pepper"
|
||||||
|
</button>
|
||||||
|
<button onClick={() => send("speech", "Could you repeat that please")}>
|
||||||
|
"Repeat please"
|
||||||
|
</button>
|
||||||
|
<button onClick={() => send("speech", "Tell me something about yourself")}>
|
||||||
|
"About yourself"
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||||
|
<label htmlFor="gesture-select">Select Gesture:</label>
|
||||||
|
<select
|
||||||
|
id="gesture-select"
|
||||||
|
value={selectedGesture}
|
||||||
|
onChange={(e) => setSelectedGesture(e.target.value)}
|
||||||
|
style={{ padding: '8px', borderRadius: '4px', flex: 1 }}
|
||||||
|
>
|
||||||
|
<option value="animations/Stand/BodyTalk/Speaking/BodyTalk_1">Body Talk 1</option>
|
||||||
|
<option value="animations/Stand/Gestures/Thinking_8">Thinking "8"</option>
|
||||||
|
<option value="animations/Stand/Gestures/Thinking_1">Thinking "1"</option>
|
||||||
|
<option value="animations/Stand/Emotions/Positive/Happy_1">Happy</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={() => send("gesture", selectedGesture)} style={{ padding: '8px 16px' }}>
|
||||||
|
Actuate Gesture
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '30px', flexWrap: 'wrap' }}>
|
||||||
|
{list_generator("Goals to complete:", ["Greet user", "Introduce self", "Ask for name"])}
|
||||||
|
{list_generator("Conditional norms:", ["If {user_tired}, be less energetic", "If {play_mundo}, go where you please"])}
|
||||||
|
{list_generator("Activate triggers:", ["If {user_thirsty}, offer drink", "If {name == Karen}, be triggered"])}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
export default Counter
|
|
||||||
@@ -3,7 +3,6 @@ import styles from './MonitoringPage.module.css';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* HELPER: Unified sender function
|
* HELPER: Unified sender function
|
||||||
* In a real app, you might move this to a /services or /hooks folder
|
|
||||||
*/
|
*/
|
||||||
const sendUserInterrupt = async (type: string, context: string) => {
|
const sendUserInterrupt = async (type: string, context: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import React from 'react';
|
import React, { use } from 'react';
|
||||||
import styles from './MonitoringPage.module.css';
|
import styles from './MonitoringPage.module.css';
|
||||||
import useProgramStore from "../../utils/programStore.ts";
|
import useProgramStore from "../../utils/programStore.ts";
|
||||||
import { GestureControls, SpeechPresets, DirectSpeechInput, StatusList } from './Components';
|
import { GestureControls, SpeechPresets, DirectSpeechInput, StatusList } from './Components';
|
||||||
import { nextPhase } from ".//MonitoringPageAPI.ts"
|
import { nextPhase, useExperimentLogger } from ".//MonitoringPageAPI.ts"
|
||||||
|
|
||||||
type Goal = { id?: string | number; description?: string; achieved?: boolean };
|
type Goal = { id?: string | number; description?: string; achieved?: boolean };
|
||||||
type Trigger = { id?: string | number; label?: string ; achieved?: boolean };
|
type Trigger = { id?: string | number; label?: string ; achieved?: boolean };
|
||||||
type Norm = { id?: string | number; norm?: string };
|
type Norm = { id?: string | number; norm?: string };
|
||||||
|
|
||||||
|
|
||||||
const MonitoringPage: React.FC = () => {
|
const MonitoringPage: React.FC = () => {
|
||||||
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
|
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
|
||||||
const getNormsInPhase = useProgramStore((s) => s.getNormsInPhase);
|
const getNormsInPhase = useProgramStore((s) => s.getNormsInPhase);
|
||||||
@@ -43,7 +42,7 @@ const MonitoringPage: React.FC = () => {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
useExperimentLogger();
|
||||||
return (
|
return (
|
||||||
<div className={styles.dashboardContainer}>
|
<div className={styles.dashboardContainer}>
|
||||||
{/* HEADER */}
|
{/* HEADER */}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const API_BASE = "http://localhost:8000/"; // Change depending on Pims interup agent/ correct endpoint
|
import React, { useEffect } from 'react';
|
||||||
|
const API_BASE = "http://localhost:8000"; // Change depending on Pims interup agent/ correct endpoint
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -17,3 +18,36 @@ export async function nextPhase(): Promise<void> {
|
|||||||
throw new Error("Failed to advance to next phase");
|
throw new Error("Failed to advance to next phase");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A hook that listens to the experiment stream and logs data to the console.
|
||||||
|
* It does not render anything.
|
||||||
|
*/
|
||||||
|
export function useExperimentLogger() {
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("Starting Experiment Stream listener...");
|
||||||
|
|
||||||
|
const eventSource = new EventSource(`${API_BASE}/experiment_stream`);
|
||||||
|
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const parsedData = JSON.parse(event.data);
|
||||||
|
|
||||||
|
console.log(" [Experiment Update Received] ", parsedData);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Received non-JSON experiment data:", event.data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = (err) => {
|
||||||
|
console.error("SSE Connection Error (Experiment Stream):", err);
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log("Closing Experiment Stream listener...");
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -71,6 +71,11 @@
|
|||||||
filter: drop-shadow(0 0 0.25rem red);
|
filter: drop-shadow(0 0 0.25rem red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-basic_belief {
|
||||||
|
outline: plum solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem plum);
|
||||||
|
}
|
||||||
|
|
||||||
.draggable-node {
|
.draggable-node {
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
background-color: canvas;
|
background-color: canvas;
|
||||||
@@ -126,3 +131,11 @@
|
|||||||
outline: red solid 2pt;
|
outline: red solid 2pt;
|
||||||
filter: drop-shadow(0 0 0.25rem red);
|
filter: drop-shadow(0 0 0.25rem red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.draggable-node-basic_belief {
|
||||||
|
padding: 3px 10px;
|
||||||
|
background-color: canvas;
|
||||||
|
border-radius: 5pt;
|
||||||
|
outline: plum solid 2pt;
|
||||||
|
filter: drop-shadow(0 0 0.25rem plum);
|
||||||
|
}
|
||||||
@@ -9,8 +9,10 @@ import {
|
|||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import {useEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {useShallow} from 'zustand/react/shallow';
|
import {useShallow} from 'zustand/react/shallow';
|
||||||
|
import orderPhaseNodeArray from "../../utils/orderPhaseNodes.ts";
|
||||||
import useProgramStore from "../../utils/programStore.ts";
|
import useProgramStore from "../../utils/programStore.ts";
|
||||||
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx';
|
||||||
|
import type {PhaseNode} from "./visualProgrammingUI/nodes/PhaseNode.tsx";
|
||||||
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
import useFlowStore from './visualProgrammingUI/VisProgStores.tsx';
|
||||||
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx';
|
||||||
import styles from './VisProg.module.css'
|
import styles from './VisProg.module.css'
|
||||||
@@ -167,14 +169,15 @@ function runProgramm() {
|
|||||||
*/
|
*/
|
||||||
function graphReducer() {
|
function graphReducer() {
|
||||||
const { nodes } = useFlowStore.getState();
|
const { nodes } = useFlowStore.getState();
|
||||||
return nodes
|
return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode [])
|
||||||
.filter((n) => n.type == 'phase')
|
|
||||||
.map((n) => {
|
.map((n) => {
|
||||||
const reducer = NodeReduces['phase'];
|
const reducer = NodeReduces['phase'];
|
||||||
return reducer(n, nodes)
|
return reducer(n, nodes)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* houses the entire page, so also UI elements
|
* houses the entire page, so also UI elements
|
||||||
* that are not a part of the Visual Programming UI
|
* that are not a part of the Visual Programming UI
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ export const UndoRedo = (
|
|||||||
* @param {BaseFlowState} state - the current state of the editor
|
* @param {BaseFlowState} state - the current state of the editor
|
||||||
* @returns {FlowSnapshot} - returns a snapshot of the current editor state
|
* @returns {FlowSnapshot} - returns a snapshot of the current editor state
|
||||||
*/
|
*/
|
||||||
const getSnapshot = (state : BaseFlowState) : FlowSnapshot => ({
|
const getSnapshot = (state : BaseFlowState) : FlowSnapshot => (structuredClone({
|
||||||
nodes: state.nodes,
|
nodes: state.nodes,
|
||||||
edges: state.edges
|
edges: state.edges
|
||||||
});
|
}));
|
||||||
|
|
||||||
const initialState = config(set, get, api);
|
const initialState = config(set, get, api);
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ import TriggerNode, {
|
|||||||
TriggerReduce
|
TriggerReduce
|
||||||
} from "./nodes/TriggerNode";
|
} from "./nodes/TriggerNode";
|
||||||
import { TriggerNodeDefaults } from "./nodes/TriggerNode.default";
|
import { TriggerNodeDefaults } from "./nodes/TriggerNode.default";
|
||||||
|
import BasicBeliefNode, { BasicBeliefConnectionSource, BasicBeliefConnectionTarget, BasicBeliefDisconnectionSource, BasicBeliefDisconnectionTarget, BasicBeliefReduce } from "./nodes/BasicBeliefNode";
|
||||||
|
import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registered node types in the visual programming system.
|
* Registered node types in the visual programming system.
|
||||||
@@ -60,6 +62,7 @@ export const NodeTypes = {
|
|||||||
norm: NormNode,
|
norm: NormNode,
|
||||||
goal: GoalNode,
|
goal: GoalNode,
|
||||||
trigger: TriggerNode,
|
trigger: TriggerNode,
|
||||||
|
basic_belief: BasicBeliefNode,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -74,6 +77,7 @@ export const NodeDefaults = {
|
|||||||
norm: NormNodeDefaults,
|
norm: NormNodeDefaults,
|
||||||
goal: GoalNodeDefaults,
|
goal: GoalNodeDefaults,
|
||||||
trigger: TriggerNodeDefaults,
|
trigger: TriggerNodeDefaults,
|
||||||
|
basic_belief: BasicBeliefNodeDefaults,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -90,6 +94,7 @@ export const NodeReduces = {
|
|||||||
norm: NormReduce,
|
norm: NormReduce,
|
||||||
goal: GoalReduce,
|
goal: GoalReduce,
|
||||||
trigger: TriggerReduce,
|
trigger: TriggerReduce,
|
||||||
|
basic_belief: BasicBeliefReduce,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -107,6 +112,7 @@ export const NodeConnections = {
|
|||||||
norm: NormConnectionTarget,
|
norm: NormConnectionTarget,
|
||||||
goal: GoalConnectionTarget,
|
goal: GoalConnectionTarget,
|
||||||
trigger: TriggerConnectionTarget,
|
trigger: TriggerConnectionTarget,
|
||||||
|
basic_belief: BasicBeliefConnectionTarget,
|
||||||
},
|
},
|
||||||
Sources: {
|
Sources: {
|
||||||
start: StartConnectionSource,
|
start: StartConnectionSource,
|
||||||
@@ -115,6 +121,7 @@ export const NodeConnections = {
|
|||||||
norm: NormConnectionSource,
|
norm: NormConnectionSource,
|
||||||
goal: GoalConnectionSource,
|
goal: GoalConnectionSource,
|
||||||
trigger: TriggerConnectionSource,
|
trigger: TriggerConnectionSource,
|
||||||
|
basic_belief: BasicBeliefConnectionSource
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +139,7 @@ export const NodeDisconnections = {
|
|||||||
norm: NormDisconnectionTarget,
|
norm: NormDisconnectionTarget,
|
||||||
goal: GoalDisconnectionTarget,
|
goal: GoalDisconnectionTarget,
|
||||||
trigger: TriggerDisconnectionTarget,
|
trigger: TriggerDisconnectionTarget,
|
||||||
|
basic_belief: BasicBeliefDisconnectionTarget,
|
||||||
},
|
},
|
||||||
Sources: {
|
Sources: {
|
||||||
start: StartDisconnectionSource,
|
start: StartDisconnectionSource,
|
||||||
@@ -140,6 +148,7 @@ export const NodeDisconnections = {
|
|||||||
norm: NormDisconnectionSource,
|
norm: NormDisconnectionSource,
|
||||||
goal: GoalDisconnectionSource,
|
goal: GoalDisconnectionSource,
|
||||||
trigger: TriggerDisconnectionSource,
|
trigger: TriggerDisconnectionSource,
|
||||||
|
basic_belief: BasicBeliefDisconnectionSource,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +160,6 @@ export const NodeDisconnections = {
|
|||||||
export const NodeDeletes = {
|
export const NodeDeletes = {
|
||||||
start: () => false,
|
start: () => false,
|
||||||
end: () => false,
|
end: () => false,
|
||||||
test: () => false, // Used for coverage of universal/ undefined nodes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -164,5 +172,5 @@ export const NodesInPhase = {
|
|||||||
start: () => false,
|
start: () => false,
|
||||||
end: () => false,
|
end: () => false,
|
||||||
phase: () => false,
|
phase: () => false,
|
||||||
test: () => false, // Used for coverage of universal/ undefined nodes
|
basic_belief: () => false,
|
||||||
}
|
}
|
||||||
@@ -28,31 +28,31 @@ import { UndoRedo } from "./EditorUndoRedo.ts";
|
|||||||
* @param deletable - Optional flag to indicate if the node can be deleted (can be deleted by default).
|
* @param deletable - Optional flag to indicate if the node can be deleted (can be deleted by default).
|
||||||
* @returns A fully initialized Node object ready to be added to the flow.
|
* @returns A fully initialized Node object ready to be added to the flow.
|
||||||
*/
|
*/
|
||||||
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable? : boolean) {
|
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable?: boolean) {
|
||||||
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
|
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
|
||||||
const newData = {
|
return {
|
||||||
id: id,
|
id,
|
||||||
type: type,
|
type,
|
||||||
position: position,
|
position,
|
||||||
data: data,
|
deletable,
|
||||||
deletable: deletable,
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(defaultData)),
|
||||||
|
...data,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return {...defaultData, ...newData}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
//* Initial nodes, created by using createNode. */
|
//* Initial nodes, created by using createNode. */
|
||||||
const initialNodes : Node[] = [
|
const initialNodes : Node[] = [
|
||||||
createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false),
|
createNode('start', 'start', {x: 100, y: 100}, {label: "Start"}, false),
|
||||||
createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false),
|
createNode('end', 'end', {x: 500, y: 100}, {label: "End"}, false),
|
||||||
createNode('phase-1', 'phase', {x:200, y:100}, {label: "Phase 1", children : []}),
|
createNode(crypto.randomUUID(), 'phase', {x:200, y:100}, {label: "Phase 1", children : []}),
|
||||||
createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"], critical:false}),
|
createNode('norms-1', 'norm', {x:-200, y:100}, {label: "Initial Norms", normList: ["Be a robot", "get good"], critical:false}),
|
||||||
];
|
];
|
||||||
|
|
||||||
// * Initial edges * /
|
// * Initial edges * /
|
||||||
const initialEdges: Edge[] = [
|
const initialEdges: Edge[] = []; // no initial edges as edge connect events don't fire when using initial edges
|
||||||
{ id: 'start-phase-1', source: 'start', target: 'phase-1' },
|
|
||||||
{ id: 'phase-1-end', source: 'phase-1', target: 'end' },
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
Handle,
|
||||||
|
useNodeConnections,
|
||||||
|
type HandleType,
|
||||||
|
type Position
|
||||||
|
} from '@xyflow/react';
|
||||||
|
|
||||||
|
|
||||||
|
const LimitedConnectionCountHandle = (props: {
|
||||||
|
node_id: string,
|
||||||
|
type: HandleType,
|
||||||
|
position: Position,
|
||||||
|
connection_count: number,
|
||||||
|
id?: string
|
||||||
|
}) => {
|
||||||
|
const connections = useNodeConnections({
|
||||||
|
id: props.node_id,
|
||||||
|
handleType: props.type,
|
||||||
|
handleId: props.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Handle
|
||||||
|
{...props}
|
||||||
|
isConnectable={connections.length < props.connection_count}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LimitedConnectionCountHandle;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { BasicBeliefNodeData } from "./BasicBeliefNode";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default data for this node
|
||||||
|
*/
|
||||||
|
export const BasicBeliefNodeDefaults: BasicBeliefNodeData = {
|
||||||
|
label: "Belief",
|
||||||
|
droppable: true,
|
||||||
|
belief: {type: "keyword", id: "help", value: "help", label: "Keyword said:"},
|
||||||
|
hasReduce: true,
|
||||||
|
};
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import {
|
||||||
|
Handle,
|
||||||
|
type NodeProps,
|
||||||
|
Position,
|
||||||
|
type Node,
|
||||||
|
} from '@xyflow/react';
|
||||||
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
|
import styles from '../../VisProg.module.css';
|
||||||
|
import useFlowStore from '../VisProgStores';
|
||||||
|
import { TextField } from '../../../../components/TextField';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default data structure for a BasicBelief node
|
||||||
|
*
|
||||||
|
* Represents configuration for a node that activates when a specific condition is met,
|
||||||
|
* such as keywords being spoken or emotions detected.
|
||||||
|
*
|
||||||
|
* @property label: the display label of this BasicBelief node.
|
||||||
|
* @property droppable: Whether this node can be dropped from the toolbar (default: true).
|
||||||
|
* @property BasicBeliefType - The type of BasicBelief ("keywords" or a custom string).
|
||||||
|
* @property BasicBeliefs - The list of keyword BasicBeliefs (if applicable).
|
||||||
|
* @property hasReduce - Whether this node supports reduction logic.
|
||||||
|
*/
|
||||||
|
export type BasicBeliefNodeData = {
|
||||||
|
label: string;
|
||||||
|
droppable: boolean;
|
||||||
|
belief: BasicBeliefType;
|
||||||
|
hasReduce: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// These are all the types a basic belief could be.
|
||||||
|
type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion
|
||||||
|
type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"};
|
||||||
|
type Semantic = { type: "semantic", id: string, value: string, label: "Detected with LLM:"};
|
||||||
|
type DetectedObject = { type: "object", id: string, value: string, label: "Object found:"};
|
||||||
|
type Emotion = { type: "emotion", id: string, value: string, label: "Emotion recognised:"};
|
||||||
|
|
||||||
|
export type BasicBeliefNode = Node<BasicBeliefNodeData>
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the target
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the received connection
|
||||||
|
*/
|
||||||
|
export function BasicBeliefConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is made with this node type as the source
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the created connection
|
||||||
|
*/
|
||||||
|
export function BasicBeliefConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the target
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _sourceNodeId the source of the disconnected connection
|
||||||
|
*/
|
||||||
|
export function BasicBeliefDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function is called whenever a connection is disconnected with this node type as the source
|
||||||
|
* @param _thisNode the node of this node type which function is called
|
||||||
|
* @param _targetNodeId the target of the diconnected connection
|
||||||
|
*/
|
||||||
|
export function BasicBeliefDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
|
// no additional connection logic exists yet
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how a BasicBelief node should be rendered
|
||||||
|
* @param props - Node properties provided by React Flow, including `id` and `data`.
|
||||||
|
* @returns The rendered BasicBeliefNode React element (React.JSX.Element).
|
||||||
|
*/
|
||||||
|
export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
|
||||||
|
const data = props.data;
|
||||||
|
const {updateNodeData} = useFlowStore();
|
||||||
|
const updateValue = (value: string) => updateNodeData(props.id, {...data, belief: {...data.belief, value: value}});
|
||||||
|
const label_input_id = `basic_belief_${props.id}_label_input`;
|
||||||
|
|
||||||
|
type BeliefString = BasicBeliefType["type"];
|
||||||
|
|
||||||
|
function updateBeliefType(newType: BeliefString) {
|
||||||
|
updateNodeData(props.id, {
|
||||||
|
...data,
|
||||||
|
belief: {
|
||||||
|
...data.belief,
|
||||||
|
type: newType,
|
||||||
|
value:
|
||||||
|
newType === "emotion"
|
||||||
|
? emotionOptions[0]
|
||||||
|
: data.belief.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Use this
|
||||||
|
const emotionOptions = ["Happy", "Angry", "Sad", "Cheerful"]
|
||||||
|
|
||||||
|
|
||||||
|
let placeholder = ""
|
||||||
|
let wrapping = ""
|
||||||
|
switch (props.data.belief.type) {
|
||||||
|
case ("keyword"):
|
||||||
|
placeholder = "keyword..."
|
||||||
|
wrapping = '"'
|
||||||
|
break;
|
||||||
|
case ("semantic"):
|
||||||
|
placeholder = "word..."
|
||||||
|
wrapping = '"'
|
||||||
|
break;
|
||||||
|
case ("object"):
|
||||||
|
placeholder = "object..."
|
||||||
|
break;
|
||||||
|
case ("emotion"):
|
||||||
|
// TODO: emotion should probably be a drop-down menu rather than a string
|
||||||
|
// So this placeholder won't hold for always
|
||||||
|
placeholder = "emotion..."
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toolbar nodeId={props.id} allowDelete={true}/>
|
||||||
|
<div className={`${styles.defaultNode} ${styles.nodeBasicBelief /*TODO: Change this*/}`}>
|
||||||
|
<div className={"flex-center-x gap-sm"}>
|
||||||
|
<label htmlFor={label_input_id}>Belief:</label>
|
||||||
|
</div>
|
||||||
|
<div className={"flex-row gap-sm"}>
|
||||||
|
<select
|
||||||
|
value={data.belief.type}
|
||||||
|
onChange={(e) => updateBeliefType(e.target.value as BeliefString)}
|
||||||
|
>
|
||||||
|
<option value="keyword">Keyword said:</option>
|
||||||
|
<option value="semantic">Detected with LLM:</option>
|
||||||
|
<option value="object">Object found:</option>
|
||||||
|
<option value="emotion">Emotion recognised:</option>
|
||||||
|
</select>
|
||||||
|
{wrapping}
|
||||||
|
|
||||||
|
{data.belief.type === "emotion" && (
|
||||||
|
<select
|
||||||
|
value={data.belief.value}
|
||||||
|
onChange={(e) => updateValue(e.target.value)}
|
||||||
|
>
|
||||||
|
{emotionOptions.map((emotion) => (
|
||||||
|
<option key={emotion} value={emotion.toLowerCase()}>
|
||||||
|
{emotion}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{data.belief.type !== "emotion" &&
|
||||||
|
(<TextField
|
||||||
|
id={label_input_id}
|
||||||
|
value={data.belief.value}
|
||||||
|
setValue={updateValue}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>)}
|
||||||
|
{wrapping}
|
||||||
|
</div>
|
||||||
|
<Handle type="source" position={Position.Right} id="source"/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces each BasicBelief, including its children down into its core data.
|
||||||
|
* @param node - The BasicBelief node to reduce.
|
||||||
|
* @param _nodes - The list of all nodes in the current flow graph.
|
||||||
|
* @returns A simplified object containing the node label and its list of BasicBeliefs.
|
||||||
|
*/
|
||||||
|
export function BasicBeliefReduce(node: Node, _nodes: Node[]) {
|
||||||
|
const data = node.data as BasicBeliefNodeData;
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
type: data.belief.type,
|
||||||
|
value: data.belief.value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
Handle,
|
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
Position,
|
Position,
|
||||||
type Node,
|
type Node,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
|
import LimitedConnectionCountHandle from "../components/CustomNodeHandles.tsx";
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
|
|
||||||
@@ -32,7 +32,13 @@ export default function EndNode(props: NodeProps<EndNode>) {
|
|||||||
<div className={"flex-row gap-sm"}>
|
<div className={"flex-row gap-sm"}>
|
||||||
End
|
End
|
||||||
</div>
|
</div>
|
||||||
<Handle type="target" position={Position.Left} id="target"/>
|
<LimitedConnectionCountHandle
|
||||||
|
node_id={props.id}
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
connection_count={1}
|
||||||
|
id="target"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ 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 { BasicBeliefReduce } from './BasicBeliefNode';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default data dot a phase node
|
* The default data dot a phase node
|
||||||
@@ -19,6 +20,7 @@ import useFlowStore from '../VisProgStores';
|
|||||||
export type NormNodeData = {
|
export type NormNodeData = {
|
||||||
label: string;
|
label: string;
|
||||||
droppable: boolean;
|
droppable: boolean;
|
||||||
|
conditions: string[]; // List of (basic) belief nodes' ids.
|
||||||
norm: string;
|
norm: string;
|
||||||
hasReduce: boolean;
|
hasReduce: boolean;
|
||||||
critical: boolean;
|
critical: boolean;
|
||||||
@@ -67,7 +69,14 @@ export default function NormNode(props: NodeProps<NormNode>) {
|
|||||||
onChange={(e) => setCritical(e.target.checked)}
|
onChange={(e) => setCritical(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{data.conditions.length > 0 && (<div className={"flex-row gap-md align-center"} data-testid="norm-condition-information">
|
||||||
|
<label htmlFor={checkbox_id}>{data.conditions.length} condition{data.conditions.length > 1 ? "s" : ""}/ belief{data.conditions.length > 1 ? "s" : ""} attached.</label>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
|
||||||
<Handle type="source" position={Position.Right} id="norms"/>
|
<Handle type="source" position={Position.Right} id="norms"/>
|
||||||
|
<Handle type="target" position={Position.Bottom} id="norms"/>
|
||||||
</div>
|
</div>
|
||||||
</>;
|
</>;
|
||||||
};
|
};
|
||||||
@@ -78,14 +87,29 @@ export default function NormNode(props: NodeProps<NormNode>) {
|
|||||||
* @param node The Node Properties of this node.
|
* @param node The Node Properties of this node.
|
||||||
* @param _nodes all the nodes in the graph
|
* @param _nodes all the nodes in the graph
|
||||||
*/
|
*/
|
||||||
export function NormReduce(node: Node, _nodes: Node[]) {
|
export function NormReduce(node: Node, nodes: Node[]) {
|
||||||
const data = node.data as NormNodeData;
|
const data = node.data as NormNodeData;
|
||||||
return {
|
|
||||||
|
// conditions nodes - make sure to check for empty arrays
|
||||||
|
let conditionNodes: Node[] = [];
|
||||||
|
if (data.conditions)
|
||||||
|
conditionNodes = nodes.filter((node) => data.conditions.includes(node.id));
|
||||||
|
|
||||||
|
// Build the result object
|
||||||
|
const result: Record<string, unknown> = {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
label: data.label,
|
label: data.label,
|
||||||
norm: data.norm,
|
norm: data.norm,
|
||||||
critical: data.critical,
|
critical: data.critical,
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// Go over our conditionNodes. They should either be Basic (OR TODO: Inferred)
|
||||||
|
const reducer = BasicBeliefReduce;
|
||||||
|
result["basic_beliefs"] = conditionNodes.map((condition) => reducer(condition, nodes))
|
||||||
|
|
||||||
|
// When the Inferred is being implemented, you should follow the same kind of structure that PhaseNode has,
|
||||||
|
// dividing the conditions into basic and inferred, then calling the correct reducer on them.
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,7 +118,11 @@ export function NormReduce(node: Node, _nodes: Node[]) {
|
|||||||
* @param _sourceNodeId the source of the received connection
|
* @param _sourceNodeId the source of the received connection
|
||||||
*/
|
*/
|
||||||
export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
// no additional connection logic exists yet
|
const data = _thisNode.data as NormNodeData;
|
||||||
|
// If we got a belief connected, this is a condition for the norm.
|
||||||
|
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) {
|
||||||
|
data.conditions.push(_sourceNodeId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -112,7 +140,11 @@ export function NormConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
|||||||
* @param _sourceNodeId the source of the disconnected connection
|
* @param _sourceNodeId the source of the disconnected connection
|
||||||
*/
|
*/
|
||||||
export function NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
export function NormDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
// no additional connection logic exists yet
|
const data = _thisNode.data as NormNodeData;
|
||||||
|
// If we got a belief connected, this is a condition for the norm.
|
||||||
|
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) {
|
||||||
|
data.conditions = data.conditions.filter(id => id != _sourceNodeId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,4 +8,6 @@ export const PhaseNodeDefaults: PhaseNodeData = {
|
|||||||
droppable: true,
|
droppable: true,
|
||||||
children: [],
|
children: [],
|
||||||
hasReduce: true,
|
hasReduce: true,
|
||||||
|
nextPhaseId: null,
|
||||||
|
isFirstPhase: false,
|
||||||
};
|
};
|
||||||
@@ -9,6 +9,7 @@ import styles from '../../VisProg.module.css';
|
|||||||
import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry';
|
import { NodeReduces, NodesInPhase, NodeTypes} from '../NodeRegistry';
|
||||||
import useFlowStore from '../VisProgStores';
|
import useFlowStore from '../VisProgStores';
|
||||||
import { TextField } from '../../../../components/TextField';
|
import { TextField } from '../../../../components/TextField';
|
||||||
|
import LimitedConnectionCountHandle from "../components/CustomNodeHandles.tsx";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default data dot a phase node
|
* The default data dot a phase node
|
||||||
@@ -16,12 +17,15 @@ import { TextField } from '../../../../components/TextField';
|
|||||||
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
* @param droppable: whether this node is droppable from the drop bar (initialized as true)
|
||||||
* @param children: ID's of children of this node
|
* @param children: ID's of children of this node
|
||||||
* @param hasReduce: whether this node has reducing functionality (true by default)
|
* @param hasReduce: whether this node has reducing functionality (true by default)
|
||||||
|
* @param nextPhaseId:
|
||||||
*/
|
*/
|
||||||
export type PhaseNodeData = {
|
export type PhaseNodeData = {
|
||||||
label: string;
|
label: string;
|
||||||
droppable: boolean;
|
droppable: boolean;
|
||||||
children: string[];
|
children: string[];
|
||||||
hasReduce: boolean;
|
hasReduce: boolean;
|
||||||
|
nextPhaseId: string | "end" | null;
|
||||||
|
isFirstPhase: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PhaseNode = Node<PhaseNodeData>
|
export type PhaseNode = Node<PhaseNodeData>
|
||||||
@@ -50,9 +54,21 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
|||||||
placeholder={"Phase ..."}
|
placeholder={"Phase ..."}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Handle type="target" position={Position.Left} id="target"/>
|
<LimitedConnectionCountHandle
|
||||||
|
node_id={props.id}
|
||||||
|
type="target"
|
||||||
|
position={Position.Left}
|
||||||
|
connection_count={1}
|
||||||
|
id="target"
|
||||||
|
/>
|
||||||
<Handle type="target" position={Position.Bottom} id="norms"/>
|
<Handle type="target" position={Position.Bottom} id="norms"/>
|
||||||
<Handle type="source" position={Position.Right} id="source"/>
|
<LimitedConnectionCountHandle
|
||||||
|
node_id={props.id}
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
connection_count={1}
|
||||||
|
id="source"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -65,8 +81,8 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
|||||||
* @returns A collection of all reduced nodes in this phase, starting with this phases' reduced data.
|
* @returns A collection of all reduced nodes in this phase, starting with this phases' reduced data.
|
||||||
*/
|
*/
|
||||||
export function PhaseReduce(node: Node, nodes: Node[]) {
|
export function PhaseReduce(node: Node, nodes: Node[]) {
|
||||||
const thisnode = node as PhaseNode;
|
const thisNode = node as PhaseNode;
|
||||||
const data = thisnode.data as PhaseNodeData;
|
const data = thisNode.data as PhaseNodeData;
|
||||||
|
|
||||||
// node typings that are not in phase
|
// node typings that are not in phase
|
||||||
const nodesNotInPhase: string[] = Object.entries(NodesInPhase)
|
const nodesNotInPhase: string[] = Object.entries(NodesInPhase)
|
||||||
@@ -85,7 +101,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,
|
label: data.label,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,13 +125,19 @@ export function PhaseReduce(node: Node, nodes: Node[]) {
|
|||||||
* @param _sourceNodeId the source of the received connection
|
* @param _sourceNodeId the source of the received connection
|
||||||
*/
|
*/
|
||||||
export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
const node = _thisNode as PhaseNode
|
const data = _thisNode.data as PhaseNodeData
|
||||||
const data = node.data as PhaseNodeData
|
|
||||||
// we only add none phase nodes to the children
|
|
||||||
if (!(useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'phase'))) {
|
|
||||||
data.children.push(_sourceNodeId)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const nodes = useFlowStore.getState().nodes;
|
||||||
|
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)!
|
||||||
|
switch (sourceNode.type) {
|
||||||
|
case "phase": break;
|
||||||
|
case "start": data.isFirstPhase = true; break;
|
||||||
|
// we only add none phase or start nodes to the children
|
||||||
|
// endNodes cannot be the source of an outgoing connection
|
||||||
|
// so we don't need to cover them with a special case
|
||||||
|
// before handling the default behavior
|
||||||
|
default: data.children.push(_sourceNodeId); break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,7 +146,19 @@ export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
|||||||
* @param _targetNodeId the target of the created connection
|
* @param _targetNodeId the target of the created connection
|
||||||
*/
|
*/
|
||||||
export function PhaseConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
export function PhaseConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
// no additional connection logic exists yet
|
const data = _thisNode.data as PhaseNodeData
|
||||||
|
const nodes = useFlowStore.getState().nodes;
|
||||||
|
|
||||||
|
const targetNode = nodes.find((node) => node.id === _targetNodeId)
|
||||||
|
if (!targetNode) {throw new Error("Source node not found")}
|
||||||
|
|
||||||
|
// we set the nextPhaseId to the next target's id if the target is a phaseNode,
|
||||||
|
// or "end" if the target node is the end node
|
||||||
|
switch (targetNode.type) {
|
||||||
|
case 'phase': data.nextPhaseId = _targetNodeId; break;
|
||||||
|
case 'end': data.nextPhaseId = "end"; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,9 +167,23 @@ export function PhaseConnectionSource(_thisNode: Node, _targetNodeId: string) {
|
|||||||
* @param _sourceNodeId the source of the disconnected connection
|
* @param _sourceNodeId the source of the disconnected connection
|
||||||
*/
|
*/
|
||||||
export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
const node = _thisNode as PhaseNode
|
const data = _thisNode.data as PhaseNodeData
|
||||||
const data = node.data as PhaseNodeData
|
|
||||||
|
const nodes = useFlowStore.getState().nodes;
|
||||||
|
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)
|
||||||
|
const sourceType = sourceNode ? sourceNode.type : "deleted";
|
||||||
|
switch (sourceType) {
|
||||||
|
case "phase": break;
|
||||||
|
case "start": data.isFirstPhase = false; break;
|
||||||
|
// we only add none phase or start nodes to the children
|
||||||
|
// endNodes cannot be the source of an outgoing connection
|
||||||
|
// so we don't need to cover them with a special case
|
||||||
|
// before handling the default behavior
|
||||||
|
default:
|
||||||
data.children = data.children.filter((child) => { if (child != _sourceNodeId) return child; });
|
data.children = data.children.filter((child) => { if (child != _sourceNodeId) return child; });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,5 +192,12 @@ export function PhaseDisconnectionTarget(_thisNode: Node, _sourceNodeId: string)
|
|||||||
* @param _targetNodeId the target of the diconnected connection
|
* @param _targetNodeId the target of the diconnected connection
|
||||||
*/
|
*/
|
||||||
export function PhaseDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
export function PhaseDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
||||||
// no additional connection logic exists yet
|
const data = _thisNode.data as PhaseNodeData
|
||||||
|
const nodes = useFlowStore.getState().nodes;
|
||||||
|
|
||||||
|
// if the target is a phase or end node set the nextPhaseId to null,
|
||||||
|
// as we are no longer connected to a subsequent phaseNode or to the endNode
|
||||||
|
if (nodes.some((node) => node.id === _targetNodeId && ['phase', 'end'].includes(node.type!))){
|
||||||
|
data.nextPhaseId = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
Handle,
|
|
||||||
type NodeProps,
|
type NodeProps,
|
||||||
Position,
|
Position,
|
||||||
type Node,
|
type Node,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
|
import LimitedConnectionCountHandle from "../components/CustomNodeHandles.tsx";
|
||||||
import { Toolbar } from '../components/NodeComponents';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
|
|
||||||
@@ -31,7 +31,13 @@ export default function StartNode(props: NodeProps<StartNode>) {
|
|||||||
<div className={"flex-row gap-sm"}>
|
<div className={"flex-row gap-sm"}>
|
||||||
Start
|
Start
|
||||||
</div>
|
</div>
|
||||||
<Handle type="source" position={Position.Right} id="source"/>
|
<LimitedConnectionCountHandle
|
||||||
|
node_id={props.id}
|
||||||
|
type="source"
|
||||||
|
position={Position.Right}
|
||||||
|
connection_count={1}
|
||||||
|
id="source"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
40
src/utils/orderPhaseNodes.ts
Normal file
40
src/utils/orderPhaseNodes.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type {PhaseNode} from "../pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* takes an array of phaseNodes and orders them according to their nextPhaseId attributes,
|
||||||
|
* starting with the phase that has isFirstPhase = true
|
||||||
|
*
|
||||||
|
* @param {PhaseNode[]} nodes an unordered phaseNode array
|
||||||
|
* @returns {PhaseNode[]} the ordered phaseNode array
|
||||||
|
*/
|
||||||
|
export default function orderPhaseNodeArray(nodes: PhaseNode[]) : PhaseNode[] {
|
||||||
|
// find the first phaseNode of the sequence
|
||||||
|
const start = nodes.find(node => node.data.isFirstPhase);
|
||||||
|
if (!start) {
|
||||||
|
throw new Error('No phaseNode with isFirstObject = true found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepare for ordering of phaseNodes
|
||||||
|
const orderedPhaseNodes: PhaseNode[] = [];
|
||||||
|
const IdMap = new Map(nodes.map(node => [node.id, node]));
|
||||||
|
let currentNode: PhaseNode | undefined = start;
|
||||||
|
|
||||||
|
// populate orderedPhaseNodes array with the phaseNodes in the correct order
|
||||||
|
while (currentNode) {
|
||||||
|
orderedPhaseNodes.push(currentNode);
|
||||||
|
|
||||||
|
if (!currentNode.data.nextPhaseId) {
|
||||||
|
throw new Error("Incomplete phase sequence, program does not reach the end node");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentNode.data.nextPhaseId === "end") break;
|
||||||
|
|
||||||
|
currentNode = IdMap.get(currentNode.data.nextPhaseId);
|
||||||
|
|
||||||
|
if (!currentNode) {
|
||||||
|
throw new Error(`Incomplete phase sequence, phaseNode with id "${orderedPhaseNodes.at(-1)?.data.nextPhaseId}" not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return orderedPhaseNodes;
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ import useFlowStore from '../../../../src/pages/VisProgPage/visualProgrammingUI/
|
|||||||
import { mockReactFlow } from '../../../setupFlowTests.ts';
|
import { mockReactFlow } from '../../../setupFlowTests.ts';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
mockReactFlow();
|
mockReactFlow();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,743 @@
|
|||||||
|
// BasicBeliefNode.test.tsx
|
||||||
|
import { describe, it, beforeEach } from '@jest/globals';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { renderWithProviders } from '../.././/./../../test-utils/test-utils';
|
||||||
|
import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode';
|
||||||
|
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||||
|
import type { Node } from '@xyflow/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
describe('BasicBeliefNode', () => {
|
||||||
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
user = userEvent.setup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should render the basic belief node with keyword type by default', () => {
|
||||||
|
const mockNode: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-1',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'keyword', id: 'help', value: 'help', label: 'Keyword said:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<BasicBeliefNode
|
||||||
|
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('Belief:')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('Keyword said:')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('help')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with semantic belief type', () => {
|
||||||
|
const mockNode: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-2',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'semantic', id: 'test', value: 'test value', label: 'Detected with LLM:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<BasicBeliefNode
|
||||||
|
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.getByDisplayValue('Detected with LLM:')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('test value')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with object belief type', () => {
|
||||||
|
const mockNode: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-3',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'object', id: 'obj1', value: 'cup', label: 'Object found:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<BasicBeliefNode
|
||||||
|
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.getByDisplayValue('Object found:')).toBeInTheDocument();
|
||||||
|
expect(screen.getByDisplayValue('cup')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render with emotion belief type and select dropdown', () => {
|
||||||
|
const mockNode: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-4',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<BasicBeliefNode
|
||||||
|
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.getByDisplayValue('Emotion recognised:')).toBeInTheDocument();
|
||||||
|
// For emotion type, we should check that the select has the correct value selected
|
||||||
|
const selectElement = screen.getByDisplayValue('Happy');
|
||||||
|
expect(selectElement).toBeInTheDocument();
|
||||||
|
expect((selectElement as HTMLSelectElement).value).toBe('happy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render emotion dropdown with all emotion options', () => {
|
||||||
|
const mockNode: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-5',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<BasicBeliefNode
|
||||||
|
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 selectElement = screen.getByDisplayValue('Happy');
|
||||||
|
expect(selectElement).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check that all emotion options are present
|
||||||
|
expect(screen.getByText('Happy')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Angry')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Sad')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Cheerful')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render without wrapping quotes for object type', () => {
|
||||||
|
const mockNode: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-6',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'object', id: 'obj1', value: 'chair', label: 'Object found:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<BasicBeliefNode
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Object type should not have wrapping quotes
|
||||||
|
const inputs = screen.getAllByDisplayValue('chair');
|
||||||
|
expect(inputs.length).toBe(1); // Only the text input, no extra quote elements
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User Interactions', () => {
|
||||||
|
it('should update belief type when select is changed', async () => {
|
||||||
|
const mockNode: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-1',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'keyword', id: 'kw1', value: 'hello', label: 'Keyword said:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [mockNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<BasicBeliefNode
|
||||||
|
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 select = screen.getByDisplayValue('Keyword said:');
|
||||||
|
await user.selectOptions(select, 'semantic');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const state = useFlowStore.getState();
|
||||||
|
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
|
||||||
|
expect(updatedNode?.data.belief.type).toBe('semantic');
|
||||||
|
// Note: The component doesn't update the label when changing type
|
||||||
|
// So we can't test for label change
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update text value when typing for keyword type', async () => {
|
||||||
|
const mockNode: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-1',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'keyword', id: 'kw1', value: '', label: 'Keyword said:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [mockNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<BasicBeliefNode
|
||||||
|
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('keyword...');
|
||||||
|
await user.type(input, 'help me{enter}');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const state = useFlowStore.getState();
|
||||||
|
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
|
||||||
|
expect(updatedNode?.data.belief.value).toBe('help me');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update text value when typing for semantic type', async () => {
|
||||||
|
const mockNode: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-1',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'semantic', id: 'sem1', value: 'initial', label: 'Detected with LLM:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [mockNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<BasicBeliefNode
|
||||||
|
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('initial') as HTMLInputElement;
|
||||||
|
|
||||||
|
// Clear the input
|
||||||
|
for (let i = 0; i < 'initial'.length; i++) {
|
||||||
|
await user.type(input, '{backspace}');
|
||||||
|
}
|
||||||
|
await user.type(input, 'new semantic value{enter}');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const state = useFlowStore.getState();
|
||||||
|
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
|
||||||
|
expect(updatedNode?.data.belief.value).toBe('new semantic value');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update emotion value when selecting from dropdown', async () => {
|
||||||
|
const mockNode: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-1',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [mockNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<BasicBeliefNode
|
||||||
|
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 select = screen.getByDisplayValue('Happy');
|
||||||
|
await user.selectOptions(select, 'sad');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const state = useFlowStore.getState();
|
||||||
|
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
|
||||||
|
expect(updatedNode?.data.belief.value).toBe('sad');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve value when switching between text-based belief types', async () => {
|
||||||
|
const mockNode: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-1',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'keyword', id: 'kw1', value: 'test value', label: 'Keyword said:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [mockNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<BasicBeliefNode
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Switch from keyword to semantic
|
||||||
|
const typeSelect = screen.getByDisplayValue('Keyword said:');
|
||||||
|
await user.selectOptions(typeSelect, 'semantic');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const state = useFlowStore.getState();
|
||||||
|
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
|
||||||
|
expect(updatedNode?.data.belief.type).toBe('semantic');
|
||||||
|
expect(updatedNode?.data.belief.value).toBe('test value'); // Value should be preserved
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should automatically choose the first option when switching to emotion type, and carry on to the text values', async () => {
|
||||||
|
const mockNode: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-1',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'keyword', id: 'kw1', value: 'some text', label: 'Keyword said:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [mockNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<BasicBeliefNode
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Switch from keyword to emotion
|
||||||
|
const typeSelect = screen.getByDisplayValue('Keyword said:');
|
||||||
|
await user.selectOptions(typeSelect, 'emotion');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const state = useFlowStore.getState();
|
||||||
|
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
|
||||||
|
expect(updatedNode?.data.belief.type).toBe('emotion');
|
||||||
|
// The component doesn't reset the value when changing types
|
||||||
|
// So it keeps the old value even though it doesn't make sense for emotion type
|
||||||
|
expect(updatedNode?.data.belief.value).toBe('Happy');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ... rest of the tests remain the same, just fixing the Integration with Store section ...
|
||||||
|
|
||||||
|
describe('Integration with Store', () => {
|
||||||
|
it('should properly update the store when changing belief value', async () => {
|
||||||
|
const mockNode: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-1',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'keyword', id: 'kw1', value: '', label: 'Keyword said:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [mockNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<BasicBeliefNode
|
||||||
|
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('keyword...');
|
||||||
|
await user.type(input, 'emergency{enter}');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const state = useFlowStore.getState();
|
||||||
|
expect(state.nodes).toHaveLength(1);
|
||||||
|
expect(state.nodes[0].id).toBe('belief-1');
|
||||||
|
const beliefData = state.nodes[0].data as BasicBeliefNodeData;
|
||||||
|
expect(beliefData.belief.value).toBe('emergency');
|
||||||
|
expect(beliefData.belief.type).toBe('keyword');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should properly update the store when changing belief type', async () => {
|
||||||
|
const mockNode: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-1',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'keyword', id: 'kw1', value: 'test', label: 'Keyword said:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [mockNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<BasicBeliefNode
|
||||||
|
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 select = screen.getByDisplayValue('Keyword said:');
|
||||||
|
await user.selectOptions(select, 'object');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const state = useFlowStore.getState();
|
||||||
|
const beliefData = state.nodes[0].data as BasicBeliefNodeData;
|
||||||
|
expect(beliefData.belief.type).toBe('object');
|
||||||
|
// Note: The component doesn't update the label when changing type
|
||||||
|
expect(beliefData.belief.value).toBe('test'); // Value should be preserved
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not affect other nodes when updating one belief node', async () => {
|
||||||
|
const belief1: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-1',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief 1',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'keyword', id: 'kw1', value: 'hello', label: 'Keyword said:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const belief2: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-2',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 100, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief 2',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'object', id: 'obj1', value: 'chair', label: 'Object found:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [belief1, belief2],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<BasicBeliefNode
|
||||||
|
id={belief1.id}
|
||||||
|
type={belief1.type as string}
|
||||||
|
data={belief1.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') as HTMLInputElement;
|
||||||
|
|
||||||
|
// Clear the input
|
||||||
|
for (let i = 0; i < 'hello'.length; i++) {
|
||||||
|
await user.type(input, '{backspace}');
|
||||||
|
}
|
||||||
|
await user.type(input, 'goodbye{enter}');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const state = useFlowStore.getState();
|
||||||
|
const updatedBelief1 = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
|
||||||
|
const unchangedBelief2 = state.nodes.find(n => n.id === 'belief-2') as Node<BasicBeliefNodeData>;
|
||||||
|
|
||||||
|
expect(updatedBelief1.data.belief.value).toBe('goodbye');
|
||||||
|
expect(unchangedBelief2.data.belief.value).toBe('chair');
|
||||||
|
expect(unchangedBelief2.data.belief.type).toBe('object');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple rapid updates to belief value', async () => {
|
||||||
|
const mockNode: Node<BasicBeliefNodeData> = {
|
||||||
|
id: 'belief-1',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Belief',
|
||||||
|
droppable: true,
|
||||||
|
belief: { type: 'semantic', id: 'sem1', value: 'initial', label: 'Detected with LLM:' },
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [mockNode],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<BasicBeliefNode
|
||||||
|
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('initial') as HTMLInputElement;
|
||||||
|
|
||||||
|
await user.type(input, '1');
|
||||||
|
await waitFor(() => {
|
||||||
|
const state = useFlowStore.getState();
|
||||||
|
const nodeData = state.nodes[0].data as BasicBeliefNodeData;
|
||||||
|
expect(nodeData.belief.value).toBe('initial');
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.type(input, '2');
|
||||||
|
await waitFor(() => {
|
||||||
|
const state = useFlowStore.getState();
|
||||||
|
const nodeData = state.nodes[0].data as BasicBeliefNodeData;
|
||||||
|
expect(nodeData.belief.value).toBe('initial');
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.type(input, '{enter}');
|
||||||
|
await waitFor(() => {
|
||||||
|
const state = useFlowStore.getState();
|
||||||
|
const nodeData = state.nodes[0].data as BasicBeliefNodeData;
|
||||||
|
expect(nodeData.belief.value).toBe('initial12');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,8 +10,9 @@ import NormNode, {
|
|||||||
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 { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts';
|
||||||
|
import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts';
|
||||||
|
import BasicBeliefNode, { BasicBeliefConnectionSource } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx';
|
||||||
|
|
||||||
describe('NormNode', () => {
|
describe('NormNode', () => {
|
||||||
let user: ReturnType<typeof userEvent.setup>;
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
@@ -26,12 +27,7 @@ describe('NormNode', () => {
|
|||||||
id: 'norm-1',
|
id: 'norm-1',
|
||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {...JSON.parse(JSON.stringify(NormNodeDefaults))},
|
||||||
label: 'Test Norm',
|
|
||||||
droppable: true,
|
|
||||||
norm: '',
|
|
||||||
hasReduce: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
renderWithProviders(
|
renderWithProviders(
|
||||||
@@ -60,6 +56,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Test Norm',
|
label: 'Test Norm',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: 'Be respectful to humans',
|
norm: 'Be respectful to humans',
|
||||||
@@ -94,8 +91,10 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Test Norm',
|
label: 'Test Norm',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
|
conditions: [],
|
||||||
norm: '',
|
norm: '',
|
||||||
hasReduce: true,
|
hasReduce: true,
|
||||||
critical: false
|
critical: false
|
||||||
@@ -129,6 +128,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Test Norm',
|
label: 'Test Norm',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: 'Dragged norm',
|
norm: 'Dragged norm',
|
||||||
@@ -165,6 +165,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Test Norm',
|
label: 'Test Norm',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: '',
|
norm: '',
|
||||||
@@ -210,6 +211,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Test Norm',
|
label: 'Test Norm',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: 'Initial norm text',
|
norm: 'Initial norm text',
|
||||||
@@ -261,6 +263,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Test Norm',
|
label: 'Test Norm',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: '',
|
norm: '',
|
||||||
@@ -314,6 +317,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Test Norm',
|
label: 'Test Norm',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: '',
|
norm: '',
|
||||||
@@ -358,6 +362,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Test Norm',
|
label: 'Test Norm',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: '',
|
norm: '',
|
||||||
@@ -404,6 +409,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Safety Norm',
|
label: 'Safety Norm',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: 'Never harm humans',
|
norm: 'Never harm humans',
|
||||||
@@ -418,6 +424,8 @@ describe('NormNode', () => {
|
|||||||
id: 'norm-1',
|
id: 'norm-1',
|
||||||
label: 'Safety Norm',
|
label: 'Safety Norm',
|
||||||
norm: 'Never harm humans',
|
norm: 'Never harm humans',
|
||||||
|
critical: false,
|
||||||
|
basic_beliefs: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -427,6 +435,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Norm 1',
|
label: 'Norm 1',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: 'Be helpful',
|
norm: 'Be helpful',
|
||||||
@@ -439,6 +448,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 100, y: 0 },
|
position: { x: 100, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Norm 2',
|
label: 'Norm 2',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: 'Be honest',
|
norm: 'Be honest',
|
||||||
@@ -463,6 +473,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Empty Norm',
|
label: 'Empty Norm',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: '',
|
norm: '',
|
||||||
@@ -482,6 +493,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Custom Label',
|
label: 'Custom Label',
|
||||||
droppable: false,
|
droppable: false,
|
||||||
norm: 'Test norm',
|
norm: 'Test norm',
|
||||||
@@ -502,6 +514,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Test Norm',
|
label: 'Test Norm',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: 'Test',
|
norm: 'Test',
|
||||||
@@ -514,6 +527,7 @@ describe('NormNode', () => {
|
|||||||
type: 'phase',
|
type: 'phase',
|
||||||
position: { x: 100, y: 0 },
|
position: { x: 100, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Phase 1',
|
label: 'Phase 1',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
children: [],
|
children: [],
|
||||||
@@ -532,6 +546,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Test Norm',
|
label: 'Test Norm',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: 'Test',
|
norm: 'Test',
|
||||||
@@ -544,6 +559,7 @@ describe('NormNode', () => {
|
|||||||
type: 'phase',
|
type: 'phase',
|
||||||
position: { x: 100, y: 0 },
|
position: { x: 100, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Phase 1',
|
label: 'Phase 1',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
children: [],
|
children: [],
|
||||||
@@ -562,6 +578,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...NormNodeDefaults,
|
||||||
label: 'Test Norm',
|
label: 'Test Norm',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: 'Test',
|
norm: 'Test',
|
||||||
@@ -583,6 +600,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Test Norm',
|
label: 'Test Norm',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: '',
|
norm: '',
|
||||||
@@ -634,6 +652,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Test Norm',
|
label: 'Test Norm',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: '',
|
norm: '',
|
||||||
@@ -682,6 +701,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Norm 1',
|
label: 'Norm 1',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: 'Original norm 1',
|
norm: 'Original norm 1',
|
||||||
@@ -694,6 +714,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 100, y: 0 },
|
position: { x: 100, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
label: 'Norm 2',
|
label: 'Norm 2',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: 'Original norm 2',
|
norm: 'Original norm 2',
|
||||||
@@ -748,6 +769,7 @@ describe('NormNode', () => {
|
|||||||
type: 'norm',
|
type: 'norm',
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
data: {
|
data: {
|
||||||
|
...NormNodeDefaults,
|
||||||
label: 'Test Norm',
|
label: 'Test Norm',
|
||||||
droppable: true,
|
droppable: true,
|
||||||
norm: 'haa haa fuyaaah - link',
|
norm: 'haa haa fuyaaah - link',
|
||||||
@@ -778,21 +800,154 @@ describe('NormNode', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText('Pepper should ...');
|
const input = screen.getByPlaceholderText('Pepper should ...');
|
||||||
|
expect(input).toBeDefined()
|
||||||
|
|
||||||
await user.type(input, 'a');
|
await user.type(input, 'a{enter}');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link');
|
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linka');
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.type(input, 'b');
|
await user.type(input, 'b{enter}');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link');
|
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linkab');
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.type(input, 'c');
|
await user.type(input, 'c{enter}');
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link');
|
expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - linkabc');
|
||||||
}, { timeout: 3000 });
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Integration beliefs', () => {
|
||||||
|
it('should update visually when adding beliefs', async () => {
|
||||||
|
// Setup state
|
||||||
|
const mockNode: Node = {
|
||||||
|
id: 'norm-1',
|
||||||
|
type: 'norm',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
|
label: 'Test Norm',
|
||||||
|
droppable: true,
|
||||||
|
norm: 'haa haa fuyaaah - link',
|
||||||
|
hasReduce: true,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockBelief: Node = {
|
||||||
|
id: 'basic_belief-1',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: {x:100, y:100},
|
||||||
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [mockNode, mockBelief],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate connecting
|
||||||
|
NormConnectionTarget(mockNode, mockBelief.id);
|
||||||
|
BasicBeliefConnectionSource(mockBelief, mockNode.id)
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<div>
|
||||||
|
<NormNode
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
<BasicBeliefNode
|
||||||
|
id={mockBelief.id}
|
||||||
|
type={mockBelief.type as string}
|
||||||
|
data={mockBelief.data as any}
|
||||||
|
selected={false}
|
||||||
|
isConnectable={true}
|
||||||
|
zIndex={0}
|
||||||
|
dragging={false}
|
||||||
|
selectable={true}
|
||||||
|
deletable={true}
|
||||||
|
draggable={true}
|
||||||
|
positionAbsoluteX={0}
|
||||||
|
positionAbsoluteY={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('norm-condition-information')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update the data when adding beliefs', async () => {
|
||||||
|
// Setup state
|
||||||
|
const mockNode: Node = {
|
||||||
|
id: 'norm-1',
|
||||||
|
type: 'norm',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(NormNodeDefaults)),
|
||||||
|
label: 'Test Norm',
|
||||||
|
droppable: true,
|
||||||
|
norm: 'haa haa fuyaaah - link',
|
||||||
|
hasReduce: true,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockBelief1: Node = {
|
||||||
|
id: 'basic_belief-1',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: {x:100, y:100},
|
||||||
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockBelief2: Node = {
|
||||||
|
id: 'basic_belief-2',
|
||||||
|
type: 'basic_belief',
|
||||||
|
position: {x:300, y:300},
|
||||||
|
data: {
|
||||||
|
...JSON.parse(JSON.stringify(BasicBeliefNodeDefaults))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [mockNode, mockBelief1, mockBelief2],
|
||||||
|
edges: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate connecting
|
||||||
|
useFlowStore.getState().onConnect({
|
||||||
|
source: 'basic_belief-1',
|
||||||
|
target: 'norm-1',
|
||||||
|
sourceHandle: null,
|
||||||
|
targetHandle: null,
|
||||||
|
});
|
||||||
|
useFlowStore.getState().onConnect({
|
||||||
|
source: 'basic_belief-2',
|
||||||
|
target: 'norm-1',
|
||||||
|
sourceHandle: null,
|
||||||
|
targetHandle: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = useFlowStore.getState();
|
||||||
|
const updatedNorm = state.nodes.find(n => n.id === 'norm-1');
|
||||||
|
expect(updatedNorm?.data.conditions).toEqual(["basic_belief-1", "basic_belief-2"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import type { Node, Edge, Connection } from '@xyflow/react'
|
||||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||||
import type { PhaseNodeData } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode";
|
import type {PhaseNode, PhaseNodeData} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode";
|
||||||
import { getByTestId, render } from '@testing-library/react';
|
import {act, getByTestId, render} from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg';
|
import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg';
|
||||||
|
import {mockReactFlow} from "../../../../setupFlowTests.ts";
|
||||||
|
|
||||||
|
|
||||||
class ResizeObserver {
|
class ResizeObserver {
|
||||||
@@ -99,3 +101,194 @@ describe('PhaseNode', () => {
|
|||||||
expect(p2_data.children.length == 2);
|
expect(p2_data.children.length == 2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --| Helper functions |--
|
||||||
|
|
||||||
|
function createPhaseNode(
|
||||||
|
id: string,
|
||||||
|
overrides: Partial<PhaseNodeData> = {},
|
||||||
|
): Node<PhaseNodeData> {
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
type: 'phase',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Phase',
|
||||||
|
droppable: true,
|
||||||
|
children: [],
|
||||||
|
hasReduce: true,
|
||||||
|
nextPhaseId: null,
|
||||||
|
isFirstPhase: false,
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNode(id: string, type: string): Node {
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
type: type,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(source: string, target: string): Connection {
|
||||||
|
return {
|
||||||
|
source: source,
|
||||||
|
target: target,
|
||||||
|
sourceHandle: null,
|
||||||
|
targetHandle: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function edge(source: string, target: string): Edge {
|
||||||
|
return {
|
||||||
|
id: `${source}-${target}`,
|
||||||
|
source: source,
|
||||||
|
target: target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --| Connection Tests |--
|
||||||
|
|
||||||
|
describe('PhaseNode Connection logic', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
mockReactFlow();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PhaseConnections', () => {
|
||||||
|
test('connecting start => phase sets isFirstPhase to true', () => {
|
||||||
|
const phase = createPhaseNode('phase-1')
|
||||||
|
const start = createNode('start', 'start')
|
||||||
|
|
||||||
|
useFlowStore.setState({ nodes: [phase, start] })
|
||||||
|
|
||||||
|
// verify it starts of false
|
||||||
|
expect(phase.data.isFirstPhase).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useFlowStore.getState().onConnect(connect('start', 'phase-1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedPhase = useFlowStore
|
||||||
|
.getState()
|
||||||
|
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||||
|
|
||||||
|
expect(updatedPhase.data.isFirstPhase).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('connecting task => phase adds child', () => {
|
||||||
|
const phase = createPhaseNode('phase-1')
|
||||||
|
const norm = createNode('norm-1', 'norm')
|
||||||
|
|
||||||
|
useFlowStore.setState({ nodes: [phase, norm] })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useFlowStore.getState().onConnect(connect('norm-1', 'phase-1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedPhase = useFlowStore
|
||||||
|
.getState()
|
||||||
|
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||||
|
|
||||||
|
expect(updatedPhase.data.children).toEqual(['norm-1'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('connecting phase => phase sets nextPhaseId', () => {
|
||||||
|
const p1 = createPhaseNode('phase-1')
|
||||||
|
const p2 = createPhaseNode('phase-2')
|
||||||
|
|
||||||
|
useFlowStore.setState({ nodes: [p1, p2] })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useFlowStore.getState().onConnect(connect('phase-1', 'phase-2'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedP1 = useFlowStore
|
||||||
|
.getState()
|
||||||
|
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||||
|
|
||||||
|
expect(updatedP1.data.nextPhaseId).toBe('phase-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('connecting phase to end => phase sets nextPhaseId to "end"', () => {
|
||||||
|
const phase = createPhaseNode('phase-1')
|
||||||
|
const end = createNode('end', 'end')
|
||||||
|
|
||||||
|
useFlowStore.setState({ nodes: [phase, end] })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useFlowStore.getState().onConnect(connect('phase-1', 'end'))
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedPhase = useFlowStore
|
||||||
|
.getState()
|
||||||
|
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||||
|
|
||||||
|
expect(updatedPhase.data.nextPhaseId).toBe('end')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('PhaseDisconnections', () => {
|
||||||
|
test('disconnecting task => phase removes child', () => {
|
||||||
|
const phase = createPhaseNode('phase-1', { children: ['norm-1'] })
|
||||||
|
const norm = createNode('norm-1', 'norm')
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [phase, norm],
|
||||||
|
edges: [edge('norm-1', 'phase-1')]
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useFlowStore.getState().onEdgesDelete([edge('norm-1', 'phase-1')])
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedPhase = useFlowStore
|
||||||
|
.getState()
|
||||||
|
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||||
|
|
||||||
|
expect(updatedPhase.data.children).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('disconnecting start => phase sets isFirstPhase to false', () => {
|
||||||
|
const phase = createPhaseNode('phase-1', { isFirstPhase: true })
|
||||||
|
const start = createNode('start', 'start')
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [phase, start],
|
||||||
|
edges: [edge('start', 'phase-1')]
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useFlowStore.getState().onEdgesDelete([edge('start', 'phase-1')])
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedPhase = useFlowStore
|
||||||
|
.getState()
|
||||||
|
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||||
|
|
||||||
|
expect(updatedPhase.data.isFirstPhase).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('disconnecting phase => phase sets nextPhaseId to null', () => {
|
||||||
|
const p1 = createPhaseNode('phase-1', { nextPhaseId: 'phase-2' })
|
||||||
|
const p2 = createPhaseNode('phase-2')
|
||||||
|
|
||||||
|
useFlowStore.setState({
|
||||||
|
nodes: [p1, p2],
|
||||||
|
edges: [edge('phase-1', 'phase-2')]
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
useFlowStore.getState().onEdgesDelete([edge('phase-1', 'phase-2')])
|
||||||
|
})
|
||||||
|
|
||||||
|
const updatedP1 = useFlowStore
|
||||||
|
.getState()
|
||||||
|
.nodes.find((n) => n.id === 'phase-1') as PhaseNode
|
||||||
|
|
||||||
|
expect(updatedP1.data.nextPhaseId).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,21 +8,21 @@ import { createElement } from 'react';
|
|||||||
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||||
|
|
||||||
|
|
||||||
describe('NormNode', () => {
|
describe('Universal Nodes', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable?: boolean) {
|
function createNode(id: string, type: string, position: XYPosition, data: Record<string, unknown>, deletable? : boolean) {
|
||||||
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
|
const defaultData = NodeDefaults[type as keyof typeof NodeDefaults]
|
||||||
const newData = {
|
|
||||||
|
return {
|
||||||
id: id,
|
id: id,
|
||||||
type: type,
|
type: type,
|
||||||
position: position,
|
position: position,
|
||||||
data: data,
|
data: {...defaultData, ...data},
|
||||||
deletable: deletable,
|
deletable: deletable
|
||||||
}
|
}
|
||||||
return {...defaultData, ...newData}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -107,6 +107,50 @@ describe('NormNode', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Disconnecting', () => {
|
||||||
|
test.each(getAllTypes())('it should remove the correct data when something is disconnected on a %s node.', (nodeType) => {
|
||||||
|
// Create two nodes - one of the current type and one to connect to
|
||||||
|
const sourceNode = createNode('source-1', nodeType, {x: 100, y: 100}, {});
|
||||||
|
const targetNode = createNode('target-1', 'basic_belief', {x: 300, y: 100}, {});
|
||||||
|
|
||||||
|
// Add nodes to store
|
||||||
|
useFlowStore.setState({ nodes: [sourceNode, targetNode] });
|
||||||
|
|
||||||
|
// Spy on the connect functions
|
||||||
|
const sourceConnectSpy = jest.spyOn(NodeConnections.Sources, nodeType as keyof typeof NodeConnections.Sources);
|
||||||
|
const targetConnectSpy = jest.spyOn(NodeConnections.Targets, 'basic_belief');
|
||||||
|
|
||||||
|
// Simulate connection
|
||||||
|
useFlowStore.getState().onConnect({
|
||||||
|
source: 'source-1',
|
||||||
|
target: 'target-1',
|
||||||
|
sourceHandle: null,
|
||||||
|
targetHandle: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Verify the connect functions were called
|
||||||
|
expect(sourceConnectSpy).toHaveBeenCalledWith(sourceNode, targetNode.id);
|
||||||
|
expect(targetConnectSpy).toHaveBeenCalledWith(targetNode, sourceNode.id);
|
||||||
|
|
||||||
|
// Find this connection, and delete it
|
||||||
|
const edge = useFlowStore.getState().edges[0];
|
||||||
|
useFlowStore.getState().onEdgesDelete([edge]);
|
||||||
|
|
||||||
|
// Find the nodes in the flow
|
||||||
|
const newSourceNode = useFlowStore.getState().nodes.find((node) => node.id == "source-1");
|
||||||
|
const newTargetNode = useFlowStore.getState().nodes.find((node) => node.id == "target-1");
|
||||||
|
|
||||||
|
// Expect them to be the same after deleting the edges
|
||||||
|
expect(newSourceNode).toBe(sourceNode);
|
||||||
|
expect(newTargetNode).toBe(targetNode);
|
||||||
|
|
||||||
|
// Restore our spies
|
||||||
|
sourceConnectSpy.mockRestore();
|
||||||
|
targetConnectSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Reducing', () => {
|
describe('Reducing', () => {
|
||||||
test.each(getAllTypes())('it should correctly call/ not call the reduce function when %s node is in a phase', (nodeType) => {
|
test.each(getAllTypes())('it should correctly call/ not call the reduce function when %s node is in a phase', (nodeType) => {
|
||||||
// Create a phase node and a node of the current type
|
// Create a phase node and a node of the current type
|
||||||
|
|||||||
110
test/utils/orderPhaseNodes.test.ts
Normal file
110
test/utils/orderPhaseNodes.test.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type {PhaseNode} from "../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
|
||||||
|
import orderPhaseNodeArray from "../../src/utils/orderPhaseNodes.ts";
|
||||||
|
|
||||||
|
function createPhaseNode(
|
||||||
|
id: string,
|
||||||
|
isFirst: boolean = false,
|
||||||
|
nextPhaseId: string | null = null
|
||||||
|
): PhaseNode {
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
type: 'phase',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Phase',
|
||||||
|
droppable: true,
|
||||||
|
children: [],
|
||||||
|
hasReduce: true,
|
||||||
|
nextPhaseId: nextPhaseId,
|
||||||
|
isFirstPhase: isFirst,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("orderPhaseNodes", () => {
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
testCase: {
|
||||||
|
testName: "Throws correct error when there is no first phase (empty input array)",
|
||||||
|
input: [],
|
||||||
|
expected: "No phaseNode with isFirstObject = true found"
|
||||||
|
}
|
||||||
|
},{
|
||||||
|
testCase: {
|
||||||
|
testName: "Throws correct error when there is no first phase",
|
||||||
|
input: [
|
||||||
|
createPhaseNode("phase-1", false, "phase-2"),
|
||||||
|
createPhaseNode("phase-2", false, "phase-3"),
|
||||||
|
createPhaseNode("phase-3", false, "end")
|
||||||
|
],
|
||||||
|
expected: "No phaseNode with isFirstObject = true found"
|
||||||
|
}
|
||||||
|
},{
|
||||||
|
testCase: {
|
||||||
|
testName: "Throws correct error when the program doesn't lead to an end node (missing phase-phase connection)",
|
||||||
|
input: [
|
||||||
|
createPhaseNode("phase-1", true, "phase-2"),
|
||||||
|
createPhaseNode("phase-2", false, null),
|
||||||
|
createPhaseNode("phase-3", false, "end")
|
||||||
|
],
|
||||||
|
expected: "Incomplete phase sequence, program does not reach the end node"
|
||||||
|
}
|
||||||
|
},{
|
||||||
|
testCase: {
|
||||||
|
testName: "Throws correct error when the program doesn't lead to an end node (missing phase-end connection)",
|
||||||
|
input: [
|
||||||
|
createPhaseNode("phase-1", true, "phase-2"),
|
||||||
|
createPhaseNode("phase-2", false, "phase-3"),
|
||||||
|
createPhaseNode("phase-3", false, null)
|
||||||
|
],
|
||||||
|
expected: "Incomplete phase sequence, program does not reach the end node"
|
||||||
|
}
|
||||||
|
},{
|
||||||
|
testCase: {
|
||||||
|
testName: "Throws correct error when the program leads to a non-existent phase",
|
||||||
|
input: [
|
||||||
|
createPhaseNode("phase-1", true, "phase-2"),
|
||||||
|
createPhaseNode("phase-2", false, "phase-3"),
|
||||||
|
createPhaseNode("phase-3", false, "phase-4")
|
||||||
|
],
|
||||||
|
expected: "Incomplete phase sequence, phaseNode with id \"phase-4\" not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])(`Error Handling: $testCase.testName`, ({testCase}) => {
|
||||||
|
expect(() => { orderPhaseNodeArray(testCase.input) }).toThrow(testCase.expected);
|
||||||
|
})
|
||||||
|
test.each([
|
||||||
|
{
|
||||||
|
testCase: {
|
||||||
|
testName: "Already correctly ordered phases stay ordered",
|
||||||
|
input: [
|
||||||
|
createPhaseNode("phase-1", true, "phase-2"),
|
||||||
|
createPhaseNode("phase-2", false, "phase-3"),
|
||||||
|
createPhaseNode("phase-3", false, "end")
|
||||||
|
],
|
||||||
|
expected: [
|
||||||
|
createPhaseNode("phase-1", true, "phase-2"),
|
||||||
|
createPhaseNode("phase-2", false, "phase-3"),
|
||||||
|
createPhaseNode("phase-3", false, "end")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},{
|
||||||
|
testCase: {
|
||||||
|
testName: "Incorrectly ordered phases get ordered correctly",
|
||||||
|
input: [
|
||||||
|
createPhaseNode("phase-3", false, "end"),
|
||||||
|
createPhaseNode("phase-1", true, "phase-2"),
|
||||||
|
createPhaseNode("phase-2", false, "phase-3"),
|
||||||
|
],
|
||||||
|
expected: [
|
||||||
|
createPhaseNode("phase-1", true, "phase-2"),
|
||||||
|
createPhaseNode("phase-2", false, "phase-3"),
|
||||||
|
createPhaseNode("phase-3", false, "end")
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])(`Functional: $testCase.testName`, ({testCase}) => {
|
||||||
|
const output = orderPhaseNodeArray(testCase.input);
|
||||||
|
expect(output).toEqual(testCase.expected);
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -85,14 +85,30 @@ describe('useProgramStore', () => {
|
|||||||
).toThrow('phase with id:"missing-phase" not found');
|
).toThrow('phase with id:"missing-phase" not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
// this test should be at the bottom to avoid conflicts with the previous tests
|
|
||||||
it('should clone program state when setting it (no shared references should exist)', () => {
|
it('should clone program state when setting it (no shared references should exist)', () => {
|
||||||
useProgramStore.getState().setProgramState(mockProgram);
|
const changeableMockProgram: ReducedProgram = {
|
||||||
|
phases: [
|
||||||
|
{
|
||||||
|
id: 'phase-1',
|
||||||
|
norms: [{ id: 'norm-1' }],
|
||||||
|
goals: [{ id: 'goal-1' }],
|
||||||
|
triggers: [{ id: 'trigger-1' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'phase-2',
|
||||||
|
norms: [{ id: 'norm-2' }],
|
||||||
|
goals: [{ id: 'goal-2' }],
|
||||||
|
triggers: [{ id: 'trigger-2' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
useProgramStore.getState().setProgramState(changeableMockProgram);
|
||||||
|
|
||||||
const storedProgram = useProgramStore.getState().getProgramState();
|
const storedProgram = useProgramStore.getState().getProgramState();
|
||||||
|
|
||||||
// mutate original
|
// mutate original
|
||||||
(mockProgram.phases[0].norms as any[]).push({ id: 'norm-mutated' });
|
(changeableMockProgram.phases[0].norms as any[]).push({ id: 'norm-mutated' });
|
||||||
|
|
||||||
// store should NOT change
|
// store should NOT change
|
||||||
expect(storedProgram.phases[0]['norms']).toHaveLength(1);
|
expect(storedProgram.phases[0]['norms']).toHaveLength(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user