Compare commits
1 Commits
feat/monit
...
feat/step-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47e7207c32 |
@@ -1,23 +1,34 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import styles from './MonitoringPage.module.css';
|
import styles from './MonitoringPage.module.css';
|
||||||
import { sendAPICall } from './MonitoringPageAPI';
|
|
||||||
|
/**
|
||||||
|
* HELPER: Unified sender function
|
||||||
|
*/
|
||||||
|
const sendUserInterrupt = async (type: string, context: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("http://localhost:8000/button_pressed", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type, context }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Backend response error");
|
||||||
|
console.log(`Interrupt Sent - Type: ${type}, Context: ${context}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to send interrupt:`, err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// --- GESTURE COMPONENT ---
|
// --- GESTURE COMPONENT ---
|
||||||
export const GestureControls: React.FC = () => {
|
export const GestureControls: React.FC = () => {
|
||||||
const [selectedGesture, setSelectedGesture] = useState("animations/Stand/BodyTalk/Speaking/BodyTalk_1");
|
const [selectedGesture, setSelectedGesture] = useState("animations/Stand/BodyTalk/Speaking/BodyTalk_1");
|
||||||
|
|
||||||
const gestures = [
|
const gestures = [
|
||||||
{ label: "Wave", value: "animations/Stand/Gestures/Hey_1" },
|
{ label: "Body Talk 1", value: "animations/Stand/BodyTalk/Speaking/BodyTalk_1" },
|
||||||
{ label: "Think", value: "animations/Stand/Emotions/Neutral/Puzzled_1" },
|
{ label: "Thinking 8", value: "animations/Stand/Gestures/Thinking_8" },
|
||||||
{ label: "Explain", value: "animations/Stand/Gestures/Explain_4" },
|
{ label: "Thinking 1", value: "animations/Stand/Gestures/Thinking_1" },
|
||||||
{ label: "You", value: "animations/Stand/Gestures/You_1" },
|
|
||||||
{ label: "Happy", value: "animations/Stand/Emotions/Positive/Happy_1" },
|
{ label: "Happy", value: "animations/Stand/Emotions/Positive/Happy_1" },
|
||||||
{ label: "Laugh", value: "animations/Stand/Emotions/Positive/Laugh_2" },
|
|
||||||
{ label: "Lonely", value: "animations/Stand/Emotions/Neutral/Lonely_1" },
|
|
||||||
{ label: "Suprise", value: "animations/Stand/Emotions/Negative/Surprise_1" },
|
|
||||||
{ label: "Hurt", value: "animations/Stand/Emotions/Negative/Hurt_2" },
|
|
||||||
{ label: "Angry", value: "animations/Stand/Emotions/Negative/Angry_4" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.gestures}>
|
<div className={styles.gestures}>
|
||||||
<h4>Gestures</h4>
|
<h4>Gestures</h4>
|
||||||
@@ -28,7 +39,7 @@ export const GestureControls: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{gestures.map(g => <option key={g.value} value={g.value}>{g.label}</option>)}
|
{gestures.map(g => <option key={g.value} value={g.value}>{g.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<button onClick={() => sendAPICall("gesture", selectedGesture)}>
|
<button onClick={() => sendUserInterrupt("gesture", selectedGesture)}>
|
||||||
Actuate
|
Actuate
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +63,7 @@ export const SpeechPresets: React.FC = () => {
|
|||||||
<li key={i}>
|
<li key={i}>
|
||||||
<button
|
<button
|
||||||
className={styles.speechBtn}
|
className={styles.speechBtn}
|
||||||
onClick={() => sendAPICall("speech", phrase.text)}
|
onClick={() => sendUserInterrupt("speech", phrase.text)}
|
||||||
>
|
>
|
||||||
"{phrase.label}"
|
"{phrase.label}"
|
||||||
</button>
|
</button>
|
||||||
@@ -69,7 +80,7 @@ export const DirectSpeechInput: React.FC = () => {
|
|||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
if (!text.trim()) return;
|
if (!text.trim()) return;
|
||||||
sendAPICall("speech", text);
|
sendUserInterrupt("speech", text);
|
||||||
setText(""); // Clear after sending
|
setText(""); // Clear after sending
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -97,7 +108,6 @@ type StatusItem = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
norm?: string;
|
norm?: string;
|
||||||
name?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface StatusListProps {
|
interface StatusListProps {
|
||||||
@@ -105,7 +115,6 @@ interface StatusListProps {
|
|||||||
items: StatusItem[];
|
items: StatusItem[];
|
||||||
type: 'goal' | 'trigger' | 'norm'| 'cond_norm';
|
type: 'goal' | 'trigger' | 'norm'| 'cond_norm';
|
||||||
activeIds: Record<string, boolean>;
|
activeIds: Record<string, boolean>;
|
||||||
setActiveIds?: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
|
||||||
currentGoalIndex?: number;
|
currentGoalIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,7 +124,6 @@ export const StatusList: React.FC<StatusListProps> = ({
|
|||||||
items,
|
items,
|
||||||
type,
|
type,
|
||||||
activeIds,
|
activeIds,
|
||||||
setActiveIds,
|
|
||||||
currentGoalIndex // Destructure this prop
|
currentGoalIndex // Destructure this prop
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
@@ -126,24 +134,13 @@ export const StatusList: React.FC<StatusListProps> = ({
|
|||||||
if (item.id === undefined) return null;
|
if (item.id === undefined) return null;
|
||||||
const isActive = !!activeIds[item.id];
|
const isActive = !!activeIds[item.id];
|
||||||
const showIndicator = type !== 'norm';
|
const showIndicator = type !== 'norm';
|
||||||
|
const canOverride = showIndicator && !isActive;
|
||||||
|
|
||||||
const isCurrentGoal = type === 'goal' && idx === currentGoalIndex;
|
const isCurrentGoal = type === 'goal' && idx === currentGoalIndex;
|
||||||
const canOverride = (showIndicator && !isActive) || (type === 'cond_norm' && isActive);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleOverrideClick = () => {
|
const handleOverrideClick = () => {
|
||||||
if (!canOverride) return;
|
if (!canOverride) return;
|
||||||
if (type === 'cond_norm' && isActive){
|
sendUserInterrupt("override", String(item.id));
|
||||||
{/* Unachieve conditional norm */}
|
|
||||||
sendAPICall("override_unachieve", String(item.id));
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if(type === 'goal')
|
|
||||||
if(setActiveIds)
|
|
||||||
{setActiveIds(prev => ({ ...prev, [String(item.id)]: true }));}
|
|
||||||
|
|
||||||
sendAPICall("override", String(item.id));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -168,7 +165,7 @@ export const StatusList: React.FC<StatusListProps> = ({
|
|||||||
borderRadius: '4px'
|
borderRadius: '4px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.name || item.norm}
|
{item.description || item.label || item.norm}
|
||||||
{isCurrentGoal && " (Current)"}
|
{isCurrentGoal && " (Current)"}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -198,8 +195,7 @@ export const RobotConnected = () => {
|
|||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
|
|
||||||
// Expecting messages in JSON format: `true` or `false`
|
// Expecting messages in JSON format: `true` or `false`
|
||||||
//commented out this log as it clutters console logs, but might be useful to debug
|
console.log("received message:", event.data);
|
||||||
//console.log("received message:", event.data);
|
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
@@ -164,14 +164,6 @@
|
|||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.statusIndicator.clickable {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.statusIndicator.clickable:hover {
|
|
||||||
transform: scale(1.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.clickable {
|
.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
@@ -181,6 +173,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
|
cursor: default;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +1,68 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React from 'react';
|
||||||
import styles from './MonitoringPage.module.css';
|
import styles from './MonitoringPage.module.css';
|
||||||
|
import useProgramStore from "../../utils/programStore.ts";
|
||||||
|
import { GestureControls, SpeechPresets, DirectSpeechInput, StatusList, RobotConnected } from './Components';
|
||||||
|
import { nextPhase, useExperimentLogger, pauseExperiment, playExperiment, resetExperiment, resetPhase, type ExperimentStreamData, type GoalUpdate, type TriggerUpdate, type CondNormsStateUpdate, type PhaseUpdate } from ".//MonitoringPageAPI.ts"
|
||||||
|
|
||||||
// Store & API
|
import type { NormNodeData } from '../VisProgPage/visualProgrammingUI/nodes/NormNode.tsx';
|
||||||
import useProgramStore from "../../utils/programStore";
|
|
||||||
import {
|
|
||||||
nextPhase,
|
|
||||||
useExperimentLogger,
|
|
||||||
useStatusLogger,
|
|
||||||
pauseExperiment,
|
|
||||||
playExperiment,
|
|
||||||
type ExperimentStreamData,
|
|
||||||
type GoalUpdate,
|
|
||||||
type TriggerUpdate,
|
|
||||||
type CondNormsStateUpdate,
|
|
||||||
type PhaseUpdate
|
|
||||||
} from "./MonitoringPageAPI";
|
|
||||||
import { graphReducer, runProgramm } from '../VisProgPage/VisProgLogic.ts';
|
|
||||||
|
|
||||||
// Types
|
// Stream message types are defined in MonitoringPageAPI as `ExperimentStreamData`.
|
||||||
import type { NormNodeData } from '../VisProgPage/visualProgrammingUI/nodes/NormNode';
|
// Types for reduced program items (output from node reducers):
|
||||||
import type { GoalNode } from '../VisProgPage/visualProgrammingUI/nodes/GoalNode';
|
export type ReducedPlanStep = {
|
||||||
import type { TriggerNode } from '../VisProgPage/visualProgrammingUI/nodes/TriggerNode';
|
id: string;
|
||||||
|
text?: string;
|
||||||
|
gesture?: { type: string; name?: string };
|
||||||
|
goal?: string;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
// Sub-components
|
export type ReducedPlan = { id: string; steps: ReducedPlanStep[] } | "";
|
||||||
import {
|
|
||||||
GestureControls,
|
|
||||||
SpeechPresets,
|
|
||||||
DirectSpeechInput,
|
|
||||||
StatusList,
|
|
||||||
RobotConnected
|
|
||||||
} from './MonitoringPageComponents';
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
export type ReducedGoal = { id: string; name: string; description?: string; can_fail?: boolean; plan?: ReducedPlan };
|
||||||
// 1. State management
|
|
||||||
// ----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
export type ReducedCondition = {
|
||||||
* Manages the state of the active experiment, including phase progression,
|
id: string;
|
||||||
* goal tracking, and stream event listeners.
|
keyword?: string;
|
||||||
*/
|
emotion?: string;
|
||||||
function useExperimentLogic() {
|
object?: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
|
export type ReducedTrigger = { id: string; name: string; condition?: ReducedCondition | ""; plan?: ReducedPlan };
|
||||||
|
|
||||||
|
export type ReducedNorm = { id: string; label?: string; norm?: string; condition?: ReducedCondition | "" };
|
||||||
|
|
||||||
|
|
||||||
|
const MonitoringPage: React.FC = () => {
|
||||||
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
|
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
|
||||||
const getPhaseNames = useProgramStore((s) => s.getPhaseNames);
|
const getNormsInPhase = useProgramStore((s) => s.getNormsInPhase);
|
||||||
const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase);
|
const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase);
|
||||||
const setProgramState = useProgramStore((state) => state.setProgramState);
|
const getTriggersInPhase = useProgramStore((s) => s.getTriggersInPhase);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
// Can be used to block actions until feedback from CB.
|
||||||
const [activeIds, setActiveIds] = useState<Record<string, boolean>>({});
|
const [loading, setLoading] = React.useState(false);
|
||||||
const [goalIndex, setGoalIndex] = useState(0);
|
const [activeIds, setActiveIds] = React.useState<Record<string, boolean>>({});
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [goalIndex, setGoalIndex] = React.useState(0);
|
||||||
const [phaseIndex, setPhaseIndex] = useState(0);
|
const [isPlaying, setIsPlaying] = React.useState(false);
|
||||||
const [isFinished, setIsFinished] = useState(false);
|
|
||||||
|
|
||||||
const phaseIds = getPhaseIds();
|
const phaseIds = getPhaseIds();
|
||||||
const phaseNames = getPhaseNames();
|
|
||||||
|
|
||||||
// --- Stream Handlers ---
|
const [phaseIndex, setPhaseIndex] = React.useState(0);
|
||||||
|
|
||||||
const handleStreamUpdate = useCallback((data: ExperimentStreamData) => {
|
//see if we reached end node
|
||||||
|
const [isFinished, setIsFinished] = React.useState(false);
|
||||||
|
|
||||||
|
const handleStreamUpdate = React.useCallback((data: ExperimentStreamData) => {
|
||||||
|
// Check for phase updates
|
||||||
if (data.type === 'phase_update' && data.id) {
|
if (data.type === 'phase_update' && data.id) {
|
||||||
const payload = data as PhaseUpdate;
|
const payload = data as PhaseUpdate;
|
||||||
console.log(`${data.type} received, id : ${data.id}`);
|
|
||||||
|
|
||||||
if (payload.id === "end") {
|
if (payload.id === "end") {
|
||||||
setIsFinished(true);
|
setIsFinished(true);
|
||||||
} else {
|
} else {
|
||||||
setIsFinished(false);
|
setIsFinished(false);
|
||||||
const newIndex = getPhaseIds().indexOf(payload.id);
|
|
||||||
|
const allIds = getPhaseIds();
|
||||||
|
const newIndex = allIds.indexOf(payload.id);
|
||||||
if (newIndex !== -1) {
|
if (newIndex !== -1) {
|
||||||
setPhaseIndex(newIndex);
|
setPhaseIndex(newIndex);
|
||||||
setGoalIndex(0);
|
setGoalIndex(0);
|
||||||
@@ -75,318 +71,258 @@ function useExperimentLogic() {
|
|||||||
}
|
}
|
||||||
else if (data.type === 'goal_update') {
|
else if (data.type === 'goal_update') {
|
||||||
const payload = data as GoalUpdate;
|
const payload = data as GoalUpdate;
|
||||||
const currentPhaseGoals = getGoalsInPhase(phaseIds[phaseIndex]) as GoalNode[];
|
const currentPhaseGoals = getGoalsInPhase(phaseIds[phaseIndex]) as ReducedGoal[];
|
||||||
const gIndex = currentPhaseGoals.findIndex((g) => g.id === payload.id);
|
const gIndex = currentPhaseGoals.findIndex((g: ReducedGoal) => g.id === payload.id);
|
||||||
|
|
||||||
console.log(`${data.type} received, id : ${data.id}`);
|
if (gIndex !== -1) {
|
||||||
|
//set current goal to the goal that is just started
|
||||||
if (gIndex === -1) {
|
|
||||||
console.warn(`Goal ${payload.id} not found in phase ${phaseNames[phaseIndex]}`);
|
|
||||||
} else {
|
|
||||||
setGoalIndex(gIndex);
|
setGoalIndex(gIndex);
|
||||||
// Mark all previous goals as achieved
|
|
||||||
|
// All previous goals are set to "active" which means they are achieved
|
||||||
setActiveIds((prev) => {
|
setActiveIds((prev) => {
|
||||||
const nextState = { ...prev };
|
const nextState = { ...prev };
|
||||||
|
|
||||||
|
// We loop until i is LESS than gIndex.
|
||||||
|
// This leaves currentPhaseGoals[gIndex] as isActive: false.
|
||||||
for (let i = 0; i < gIndex; i++) {
|
for (let i = 0; i < gIndex; i++) {
|
||||||
nextState[currentPhaseGoals[i].id] = true;
|
nextState[currentPhaseGoals[i].id ] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return nextState;
|
return nextState;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(`Now pursuing goal: ${payload.id}. Previous goals marked achieved.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else if (data.type === 'trigger_update') {
|
else if (data.type === 'trigger_update') {
|
||||||
const payload = data as TriggerUpdate;
|
const payload = data as TriggerUpdate;
|
||||||
setActiveIds((prev) => ({ ...prev, [payload.id]: payload.achieved }));
|
setActiveIds((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[payload.id]: payload.achieved
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex, phaseNames]);
|
else if (data.type === 'cond_norms_state_update') {
|
||||||
|
|
||||||
const handleStatusUpdate = useCallback((data: unknown) => {
|
|
||||||
|
|
||||||
const payload = data as CondNormsStateUpdate;
|
const payload = data as CondNormsStateUpdate;
|
||||||
if (payload.type !== 'cond_norms_state_update') return;
|
|
||||||
|
|
||||||
setActiveIds((prev) => {
|
setActiveIds((prev) => {
|
||||||
const hasChanges = payload.norms.some((u) => prev[u.id] !== u.active);
|
|
||||||
if (!hasChanges) return prev;
|
|
||||||
|
|
||||||
const nextState = { ...prev };
|
const nextState = { ...prev };
|
||||||
payload.norms.forEach((u) => { nextState[u.id] = u.active; });
|
// payload.norms is typed on the union, so safe to use directly
|
||||||
|
payload.norms.forEach((normUpdate) => {
|
||||||
|
nextState[normUpdate.id] = normUpdate.active;
|
||||||
|
console.log(`Conditional norm ${normUpdate.id} set to active: ${normUpdate.active}`);
|
||||||
|
});
|
||||||
|
|
||||||
return nextState;
|
return nextState;
|
||||||
});
|
});
|
||||||
}, []);
|
console.log("Updated conditional norms state:", payload.norms);
|
||||||
|
|
||||||
// Connect listeners
|
|
||||||
useExperimentLogger(handleStreamUpdate);
|
|
||||||
useStatusLogger(handleStatusUpdate);
|
|
||||||
|
|
||||||
// --- Actions ---
|
|
||||||
|
|
||||||
const resetExperiment = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const phases = graphReducer();
|
|
||||||
setProgramState({ phases });
|
|
||||||
|
|
||||||
setActiveIds({});
|
|
||||||
setPhaseIndex(0);
|
|
||||||
setGoalIndex(0);
|
|
||||||
setIsFinished(false);
|
|
||||||
|
|
||||||
await runProgramm();
|
|
||||||
console.log("Experiment & UI successfully reset.");
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to reset program:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [setProgramState]);
|
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex]);
|
||||||
|
|
||||||
const handleControlAction = async (action: "pause" | "play" | "nextPhase" | "resetPhase") => {
|
useExperimentLogger(handleStreamUpdate);
|
||||||
|
|
||||||
|
if (phaseIds.length === 0) {
|
||||||
|
return <p className={styles.empty}>No program loaded.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const phaseId = phaseIds[phaseIndex];
|
||||||
|
|
||||||
|
const goals = (getGoalsInPhase(phaseId) as ReducedGoal[]).map(g => ({
|
||||||
|
...g,
|
||||||
|
label: g.name,
|
||||||
|
achieved: activeIds[g.id] ?? false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
const triggers = (getTriggersInPhase(phaseId) as ReducedTrigger[]).map(t => ({
|
||||||
|
...t,
|
||||||
|
label: (() => {
|
||||||
|
|
||||||
|
let prefix = "";
|
||||||
|
if (t.condition && typeof t.condition !== "string" && "keyword" in t.condition && typeof t.condition.keyword === "string") {
|
||||||
|
prefix = `if keywords said: "${t.condition.keyword}"`;
|
||||||
|
} else if (t.condition && typeof t.condition !== "string" && "name" in t.condition && typeof t.condition.name === "string") {
|
||||||
|
prefix = `if LLM belief: ${t.condition.name}`;
|
||||||
|
} else { //fallback
|
||||||
|
prefix = t.name || "Trigger"; // use typed `name` as a reliable fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const stepLabels = (t.plan && typeof t.plan !== "string" ? t.plan.steps : []).map((step: ReducedPlanStep) => {
|
||||||
|
if ("text" in step && typeof step.text === "string") {
|
||||||
|
return `say: "${step.text}"`;
|
||||||
|
}
|
||||||
|
if ("gesture" in step && step.gesture) {
|
||||||
|
const g = step.gesture;
|
||||||
|
return `perform gesture: ${g.name || g.type}`;
|
||||||
|
}
|
||||||
|
if ("goal" in step && typeof step.goal === "string") {
|
||||||
|
return `perform LLM: ${step.goal}`;
|
||||||
|
}
|
||||||
|
return "Action"; // Fallback
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
const planText = stepLabels.length > 0
|
||||||
|
? `➔ Do: ${stepLabels.join(", ")}`
|
||||||
|
: "➔ (No actions set)";
|
||||||
|
|
||||||
|
return `${prefix} ${planText}`;
|
||||||
|
})(),
|
||||||
|
isActive: activeIds[t.id] ?? false
|
||||||
|
}));
|
||||||
|
|
||||||
|
const norms = (getNormsInPhase(phaseId) as NormNodeData[])
|
||||||
|
.filter(n => !n.condition)
|
||||||
|
.map(n => ({
|
||||||
|
...n,
|
||||||
|
label: n.norm,
|
||||||
|
}));
|
||||||
|
const conditionalNorms = (getNormsInPhase(phaseId) as ReducedNorm[])
|
||||||
|
.filter(n => !!n.condition) // Only items with a condition
|
||||||
|
.map(n => ({
|
||||||
|
...n,
|
||||||
|
label: (() => {
|
||||||
|
let prefix = "";
|
||||||
|
if (n.condition && typeof n.condition !== "string" && "keyword" in n.condition && typeof n.condition.keyword === "string") {
|
||||||
|
prefix = `if keywords said: "${n.condition.keyword}"`;
|
||||||
|
} else if (n.condition && typeof n.condition !== "string" && "name" in n.condition && typeof n.condition.name === "string") {
|
||||||
|
prefix = `if LLM belief: ${n.condition.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return `${prefix} ➔ Norm: ${n.norm}`;
|
||||||
|
})(),
|
||||||
|
achieved: activeIds[n.id] ?? false
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Handle logic of 'next' button.
|
||||||
|
|
||||||
|
const handleButton = async (button: string, _context?: string, _endpoint?: string) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
switch (action) {
|
switch (button) {
|
||||||
case "pause":
|
case "pause":
|
||||||
setIsPlaying(false);
|
|
||||||
await pauseExperiment();
|
await pauseExperiment();
|
||||||
break;
|
break;
|
||||||
case "play":
|
case "play":
|
||||||
setIsPlaying(true);
|
|
||||||
await playExperiment();
|
await playExperiment();
|
||||||
break;
|
break;
|
||||||
case "nextPhase":
|
case "nextPhase":
|
||||||
await nextPhase();
|
await nextPhase();
|
||||||
break;
|
break;
|
||||||
// Case for resetPhase if implemented in API
|
case "resetPhase":
|
||||||
|
await resetPhase();
|
||||||
|
break;
|
||||||
|
case "resetExperiment":
|
||||||
|
await resetExperiment();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
loading,
|
|
||||||
isPlaying,
|
|
||||||
isFinished,
|
|
||||||
phaseIds,
|
|
||||||
phaseNames,
|
|
||||||
phaseIndex,
|
|
||||||
goalIndex,
|
|
||||||
activeIds,
|
|
||||||
setActiveIds,
|
|
||||||
resetExperiment,
|
|
||||||
handleControlAction,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
|
||||||
// 2. Smaller Presentation Components
|
|
||||||
// ----------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Visual indicator of progress through experiment phases.
|
|
||||||
*/
|
|
||||||
function PhaseProgressBar({
|
|
||||||
phaseIds,
|
|
||||||
phaseIndex,
|
|
||||||
isFinished
|
|
||||||
}: {
|
|
||||||
phaseIds: string[],
|
|
||||||
phaseIndex: number,
|
|
||||||
isFinished: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={styles.phaseProgress}>
|
|
||||||
{phaseIds.map((id, index) => {
|
|
||||||
let statusClass = "";
|
|
||||||
if (isFinished || index < phaseIndex) statusClass = styles.completed;
|
|
||||||
else if (index === phaseIndex) statusClass = styles.current;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span key={id} className={`${styles.phase} ${statusClass}`}>
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main control buttons (Play, Pause, Next, Reset).
|
|
||||||
*/
|
|
||||||
function ControlPanel({
|
|
||||||
loading,
|
|
||||||
isPlaying,
|
|
||||||
onAction,
|
|
||||||
onReset
|
|
||||||
}: {
|
|
||||||
loading: boolean,
|
|
||||||
isPlaying: boolean,
|
|
||||||
onAction: (a: "pause" | "play" | "nextPhase" | "resetPhase") => void,
|
|
||||||
onReset: () => void
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={styles.experimentControls}>
|
|
||||||
<h3>Experiment Controls</h3>
|
|
||||||
<div className={styles.controlsButtons}>
|
|
||||||
<button
|
|
||||||
className={!isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}
|
|
||||||
onClick={() => onAction("pause")}
|
|
||||||
disabled={loading}
|
|
||||||
>❚❚</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}
|
|
||||||
onClick={() => onAction("play")}
|
|
||||||
disabled={loading}
|
|
||||||
>▶</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={styles.next}
|
|
||||||
onClick={() => onAction("nextPhase")}
|
|
||||||
disabled={loading}
|
|
||||||
>⏭</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={styles.restartPhase}
|
|
||||||
onClick={() => onAction("resetPhase")}
|
|
||||||
disabled={loading}
|
|
||||||
>↩</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className={styles.restartExperiment}
|
|
||||||
onClick={onReset}
|
|
||||||
disabled={loading}
|
|
||||||
>⟲</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays lists of Goals, Triggers, and Norms for the current phase.
|
|
||||||
*/
|
|
||||||
function PhaseDashboard({
|
|
||||||
phaseId,
|
|
||||||
activeIds,
|
|
||||||
setActiveIds,
|
|
||||||
goalIndex
|
|
||||||
}: {
|
|
||||||
phaseId: string,
|
|
||||||
activeIds: Record<string, boolean>,
|
|
||||||
setActiveIds: React.Dispatch<React.SetStateAction<Record<string, boolean>>>,
|
|
||||||
goalIndex: number
|
|
||||||
}) {
|
|
||||||
const getGoals = useProgramStore((s) => s.getGoalsInPhase);
|
|
||||||
const getTriggers = useProgramStore((s) => s.getTriggersInPhase);
|
|
||||||
const getNorms = useProgramStore((s) => s.getNormsInPhase);
|
|
||||||
|
|
||||||
// Prepare data view models
|
|
||||||
const goals = (getGoals(phaseId) as GoalNode[]).map(g => ({
|
|
||||||
...g,
|
|
||||||
achieved: activeIds[g.id] ?? false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const triggers = (getTriggers(phaseId) as TriggerNode[]).map(t => ({
|
|
||||||
...t,
|
|
||||||
achieved: activeIds[t.id] ?? false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const norms = (getNorms(phaseId) as NormNodeData[])
|
|
||||||
.filter(n => !n.condition)
|
|
||||||
.map(n => ({ ...n, label: n.norm }));
|
|
||||||
|
|
||||||
const conditionalNorms = (getNorms(phaseId) as (NormNodeData & { id: string })[])
|
|
||||||
.filter(n => !!n.condition)
|
|
||||||
.map(n => ({
|
|
||||||
...n,
|
|
||||||
achieved: activeIds[n.id] ?? false
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<StatusList title="Goals" items={goals} type="goal" activeIds={activeIds} setActiveIds={setActiveIds} currentGoalIndex={goalIndex} />
|
|
||||||
<StatusList title="Triggers" items={triggers} type="trigger" activeIds={activeIds} />
|
|
||||||
<StatusList title="Norms" items={norms} type="norm" activeIds={activeIds} />
|
|
||||||
<StatusList title="Conditional Norms" items={conditionalNorms} type="cond_norm" activeIds={activeIds} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
|
||||||
// 3. Main Component
|
|
||||||
// ----------------------------------------------------------------------
|
|
||||||
|
|
||||||
const MonitoringPage: React.FC = () => {
|
|
||||||
const {
|
|
||||||
loading,
|
|
||||||
isPlaying,
|
|
||||||
isFinished,
|
|
||||||
phaseIds,
|
|
||||||
phaseNames,
|
|
||||||
phaseIndex,
|
|
||||||
goalIndex,
|
|
||||||
activeIds,
|
|
||||||
setActiveIds,
|
|
||||||
resetExperiment,
|
|
||||||
handleControlAction
|
|
||||||
} = useExperimentLogic();
|
|
||||||
|
|
||||||
if (phaseIds.length === 0) {
|
|
||||||
return <p className={styles.empty}>No program loaded.</p>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.dashboardContainer}>
|
<div className={styles.dashboardContainer}>
|
||||||
{/* HEADER */}
|
{/* HEADER */}
|
||||||
<header className={styles.experimentOverview}>
|
<header className={styles.experimentOverview}>
|
||||||
<div className={styles.phaseName}>
|
<div className={styles.phaseName}>
|
||||||
<h2>Experiment Overview</h2>
|
<h2>Experiment Overview</h2>
|
||||||
<p>
|
<p><strong>Phase</strong> {` ${phaseIndex + 1}`} </p>
|
||||||
{isFinished ? (
|
<div className={styles.phaseProgress}>
|
||||||
<strong>Experiment finished</strong>
|
{phaseIds.map((id, index) => (
|
||||||
) : (
|
<span
|
||||||
<><strong>Phase {phaseIndex + 1}:</strong> {phaseNames[phaseIndex]}</>
|
key={id}
|
||||||
)}
|
className={`${styles.phase} ${
|
||||||
</p>
|
index < phaseIndex ? styles.completed :
|
||||||
<PhaseProgressBar phaseIds={phaseIds} phaseIndex={phaseIndex} isFinished={isFinished} />
|
index === phaseIndex ? styles.current : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ControlPanel
|
<div className={styles.experimentControls}>
|
||||||
loading={loading}
|
<h3>Experiment Controls</h3>
|
||||||
isPlaying={isPlaying}
|
<div className={styles.controlsButtons}>
|
||||||
onAction={handleControlAction}
|
{/*Pause button*/}
|
||||||
onReset={resetExperiment}
|
<button
|
||||||
/>
|
className={`${!isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
handleButton("pause");}
|
||||||
|
}
|
||||||
|
disabled={loading}
|
||||||
|
>❚❚</button>
|
||||||
|
|
||||||
|
{/*Play button*/}
|
||||||
|
<button
|
||||||
|
className={`${isPlaying ? styles.pausePlayActive : styles.pausePlayInactive}`}
|
||||||
|
onClick={() => {
|
||||||
|
setIsPlaying(true);
|
||||||
|
handleButton("play");}
|
||||||
|
}
|
||||||
|
disabled={loading}
|
||||||
|
>▶</button>
|
||||||
|
|
||||||
|
{/*Next button*/}
|
||||||
|
<button
|
||||||
|
className={styles.next}
|
||||||
|
onClick={() => handleButton("nextPhase")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
⏭
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/*Restart Phase button*/}
|
||||||
|
<button
|
||||||
|
className={styles.restartPhase}
|
||||||
|
onClick={() => handleButton("resetPhase")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
↩
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/*Restart Experiment button*/}
|
||||||
|
<button
|
||||||
|
className={styles.restartExperiment}
|
||||||
|
onClick={() => handleButton("resetExperiment")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
⟲
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.connectionStatus}>
|
<div className={styles.connectionStatus}>
|
||||||
<RobotConnected />
|
{RobotConnected()}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* MAIN GRID */}
|
{/* MAIN GRID */}
|
||||||
|
|
||||||
<main className={styles.phaseOverview}>
|
<main className={styles.phaseOverview}>
|
||||||
<section className={styles.phaseOverviewText}>
|
<section className={styles.phaseOverviewText}>
|
||||||
<h3>Phase Overview</h3>
|
<h3>Phase Overview</h3>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{isFinished ? (
|
{isFinished ? (
|
||||||
<div className={styles.finishedMessage}>
|
<div className={styles.finishedMessage}>
|
||||||
<p>All phases have been successfully completed.</p>
|
<p> All phases have been successfully completed.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<PhaseDashboard
|
<>
|
||||||
phaseId={phaseIds[phaseIndex]}
|
<StatusList title="Goals" items={goals} type="goal" activeIds={activeIds} currentGoalIndex={goalIndex} />
|
||||||
activeIds={activeIds}
|
<StatusList title="Triggers" items={triggers} type="trigger" activeIds={activeIds} />
|
||||||
setActiveIds={setActiveIds}
|
<StatusList title="Norms" items={norms} type="norm" activeIds={activeIds} />
|
||||||
goalIndex={goalIndex}
|
<StatusList title="Conditional Norms" items={conditionalNorms} type="cond_norm" activeIds={activeIds} />
|
||||||
/>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* LOGS TODO: add actual logs */}
|
{/* LOGS */}
|
||||||
<aside className={styles.logs}>
|
<aside className={styles.logs}>
|
||||||
<h3>Logs</h3>
|
<h3>Logs</h3>
|
||||||
<div className={styles.logHeader}>
|
<div className={styles.logHeader}>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import React, { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
const API_BASE_BP = "http://localhost:8000/button_pressed"; // Change depending on Pims interup agent/ correct endpoint
|
||||||
const API_BASE = "http://localhost:8000";
|
const API_BASE = "http://localhost:8000";
|
||||||
const API_BASE_BP = API_BASE + "/button_pressed"; //UserInterruptAgent endpoint
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HELPER: Unified sender function
|
* HELPER: Unified sender function
|
||||||
|
* In a real app, you might move this to a /services or /hooks folder
|
||||||
*/
|
*/
|
||||||
export const sendAPICall = async (type: string, context: string, endpoint?: string) => {
|
const sendAPICall = async (type: string, context: string, endpoint?: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_BP}${endpoint ?? ""}`, {
|
const response = await fetch(`${API_BASE_BP}${endpoint ?? ""}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -43,17 +44,21 @@ export async function resetPhase(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sends an API call to the CB for going to pause experiment
|
* Sends an API call to the CB for going to reset the experiment
|
||||||
*/
|
* In case we can't go to the next phase, the function will throw an error.
|
||||||
|
*/
|
||||||
|
export async function resetExperiment(): Promise<void> {
|
||||||
|
const type = "reset_experiment"
|
||||||
|
const context = ""
|
||||||
|
sendAPICall(type, context)
|
||||||
|
}
|
||||||
|
|
||||||
export async function pauseExperiment(): Promise<void> {
|
export async function pauseExperiment(): Promise<void> {
|
||||||
const type = "pause"
|
const type = "pause"
|
||||||
const context = "true"
|
const context = "true"
|
||||||
sendAPICall(type, context)
|
sendAPICall(type, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sends an API call to the CB for going to resume experiment
|
|
||||||
*/
|
|
||||||
export async function playExperiment(): Promise<void> {
|
export async function playExperiment(): Promise<void> {
|
||||||
const type = "pause"
|
const type = "pause"
|
||||||
const context = "false"
|
const context = "false"
|
||||||
@@ -71,25 +76,20 @@ export type CondNormsStateUpdate = { type: 'cond_norms_state_update'; norms: { i
|
|||||||
export type ExperimentStreamData = PhaseUpdate | GoalUpdate | TriggerUpdate | CondNormsStateUpdate | Record<string, unknown>;
|
export type ExperimentStreamData = PhaseUpdate | GoalUpdate | TriggerUpdate | CondNormsStateUpdate | Record<string, unknown>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A hook that listens to the experiment stream that updates current state of the program
|
* A hook that listens to the experiment stream and logs data to the console.
|
||||||
* via updates sent from the backend
|
* It does not render anything.
|
||||||
*/
|
*/
|
||||||
export function useExperimentLogger(onUpdate?: (data: ExperimentStreamData) => void) {
|
export function useExperimentLogger(onUpdate?: (data: ExperimentStreamData) => void) {
|
||||||
const callbackRef = React.useRef(onUpdate);
|
|
||||||
// Ref is updated every time with on update
|
|
||||||
React.useEffect(() => {
|
|
||||||
callbackRef.current = onUpdate;
|
|
||||||
}, [onUpdate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("Connecting to Experiment Stream...");
|
|
||||||
const eventSource = new EventSource(`${API_BASE}/experiment_stream`);
|
const eventSource = new EventSource(`${API_BASE}/experiment_stream`);
|
||||||
|
|
||||||
eventSource.onmessage = (event) => {
|
eventSource.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const parsedData = JSON.parse(event.data) as ExperimentStreamData;
|
const parsedData = JSON.parse(event.data) as ExperimentStreamData;
|
||||||
//call function using the ref
|
if (onUpdate) {
|
||||||
callbackRef.current?.(parsedData);
|
console.log(event.data);
|
||||||
|
onUpdate(parsedData);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("Stream parse error:", err);
|
console.warn("Stream parse error:", err);
|
||||||
}
|
}
|
||||||
@@ -101,31 +101,7 @@ export function useExperimentLogger(onUpdate?: (data: ExperimentStreamData) => v
|
|||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log("Closing Experiment Stream...");
|
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A hook that listens to the status stream that updates active conditional norms
|
|
||||||
* via updates sent from the backend
|
|
||||||
*/
|
|
||||||
export function useStatusLogger(onUpdate?: (data: ExperimentStreamData) => void) {
|
|
||||||
const callbackRef = React.useRef(onUpdate);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
callbackRef.current = onUpdate;
|
|
||||||
}, [onUpdate]);
|
}, [onUpdate]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const eventSource = new EventSource(`${API_BASE}/status_stream`);
|
|
||||||
eventSource.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const parsedData = JSON.parse(event.data);
|
|
||||||
callbackRef.current?.(parsedData);
|
|
||||||
} catch (err) { console.warn("Status stream error:", err); }
|
|
||||||
};
|
|
||||||
return () => eventSource.close();
|
|
||||||
}, []);
|
|
||||||
}
|
}
|
||||||
@@ -84,10 +84,7 @@
|
|||||||
filter: drop-shadow(0 0 0.25rem plum);
|
filter: drop-shadow(0 0 0.25rem plum);
|
||||||
}
|
}
|
||||||
|
|
||||||
.node-inferred_belief {
|
|
||||||
outline: mediumpurple solid 2pt;
|
|
||||||
filter: drop-shadow(0 0 0.25rem mediumpurple);
|
|
||||||
}
|
|
||||||
|
|
||||||
.draggable-node {
|
.draggable-node {
|
||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
@@ -161,14 +158,6 @@
|
|||||||
filter: drop-shadow(0 0 0.25rem plum);
|
filter: drop-shadow(0 0 0.25rem plum);
|
||||||
}
|
}
|
||||||
|
|
||||||
.draggable-node-inferred_belief {
|
|
||||||
padding: 3px 10px;
|
|
||||||
background-color: canvas;
|
|
||||||
border-radius: 5pt;
|
|
||||||
outline: mediumpurple solid 2pt;
|
|
||||||
filter: drop-shadow(0 0 0.25rem mediumpurple);
|
|
||||||
}
|
|
||||||
|
|
||||||
.planNoIterate {
|
.planNoIterate {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|||||||
@@ -9,15 +9,16 @@ import {
|
|||||||
import '@xyflow/react/dist/style.css';
|
import '@xyflow/react/dist/style.css';
|
||||||
import {type CSSProperties, useEffect, useState} from "react";
|
import {type CSSProperties, 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'
|
||||||
import {NodeTypes} from './visualProgrammingUI/NodeRegistry.ts';
|
import { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
|
||||||
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
|
import SaveLoadPanel from './visualProgrammingUI/components/SaveLoadPanel.tsx';
|
||||||
import MonitoringPage from '../MonitoringPage/MonitoringPage.tsx';
|
import MonitoringPage from '../MonitoringPage/MonitoringPage.tsx';
|
||||||
import { graphReducer, runProgramm } from './VisProgLogic.ts';
|
|
||||||
|
|
||||||
// --| config starting params for flow |--
|
// --| config starting params for flow |--
|
||||||
|
|
||||||
@@ -144,6 +145,41 @@ function VisualProgrammingUI() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currently outputs the prepared program to the console
|
||||||
|
function runProgramm() {
|
||||||
|
const phases = graphReducer();
|
||||||
|
const program = {phases}
|
||||||
|
console.log(JSON.stringify(program, null, 2));
|
||||||
|
fetch(
|
||||||
|
"http://localhost:8000/program",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {"Content-Type": "application/json"},
|
||||||
|
body: JSON.stringify(program),
|
||||||
|
}
|
||||||
|
).then((res) => {
|
||||||
|
if (!res.ok) throw new Error("Failed communicating with the backend.")
|
||||||
|
console.log("Successfully sent the program to the backend.");
|
||||||
|
|
||||||
|
// store reduced program in global program store for further use in the UI
|
||||||
|
// when the program was sent to the backend successfully:
|
||||||
|
useProgramStore.getState().setProgramState(structuredClone(program));
|
||||||
|
}).catch(() => console.log("Failed to send program to the backend."));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduces the graph into its phases' information and recursively calls their reducing function
|
||||||
|
*/
|
||||||
|
function graphReducer() {
|
||||||
|
const { nodes } = useFlowStore.getState();
|
||||||
|
return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode [])
|
||||||
|
.map((n) => {
|
||||||
|
const reducer = NodeReduces['phase'];
|
||||||
|
return reducer(n, nodes)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* houses the entire page, so also UI elements
|
* houses the entire page, so also UI elements
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import useProgramStore from "../../utils/programStore";
|
|
||||||
import orderPhaseNodeArray from "../../utils/orderPhaseNodes";
|
|
||||||
import useFlowStore from './visualProgrammingUI/VisProgStores';
|
|
||||||
import { NodeReduces } from './visualProgrammingUI/NodeRegistry';
|
|
||||||
import type { PhaseNode } from "./visualProgrammingUI/nodes/PhaseNode";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reduces the graph into its phases' information and recursively calls their reducing function
|
|
||||||
*/
|
|
||||||
export function graphReducer() {
|
|
||||||
const { nodes } = useFlowStore.getState();
|
|
||||||
return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode [])
|
|
||||||
.map((n) => {
|
|
||||||
const reducer = NodeReduces['phase'];
|
|
||||||
return reducer(n, nodes)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Outputs the prepared program to the console and sends it to the backend
|
|
||||||
*/
|
|
||||||
export function runProgramm() {
|
|
||||||
const phases = graphReducer();
|
|
||||||
const program = {phases}
|
|
||||||
console.log(JSON.stringify(program, null, 2));
|
|
||||||
fetch(
|
|
||||||
"http://localhost:8000/program",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {"Content-Type": "application/json"},
|
|
||||||
body: JSON.stringify(program),
|
|
||||||
}
|
|
||||||
).then((res) => {
|
|
||||||
if (!res.ok) throw new Error("Failed communicating with the backend.")
|
|
||||||
console.log("Successfully sent the program to the backend.");
|
|
||||||
|
|
||||||
// store reduced program in global program store for further use in the UI
|
|
||||||
// when the program was sent to the backend successfully:
|
|
||||||
useProgramStore.getState().setProgramState(structuredClone(program));
|
|
||||||
}).catch(() => console.log("Failed to send program to the backend."));
|
|
||||||
console.log(program);
|
|
||||||
}
|
|
||||||
@@ -43,4 +43,3 @@ export const noSelfConnections : HandleRule =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -52,24 +52,15 @@ import TriggerNode, {
|
|||||||
TriggerTooltip
|
TriggerTooltip
|
||||||
} from "./nodes/TriggerNode";
|
} from "./nodes/TriggerNode";
|
||||||
import { TriggerNodeDefaults } from "./nodes/TriggerNode.default";
|
import { TriggerNodeDefaults } from "./nodes/TriggerNode.default";
|
||||||
import InferredBeliefNode, {
|
|
||||||
InferredBeliefConnectionTarget,
|
|
||||||
InferredBeliefConnectionSource,
|
|
||||||
InferredBeliefDisconnectionTarget,
|
|
||||||
InferredBeliefDisconnectionSource,
|
|
||||||
InferredBeliefReduce, InferredBeliefTooltip
|
|
||||||
} from "./nodes/InferredBeliefNode";
|
|
||||||
import { InferredBeliefNodeDefaults } from "./nodes/InferredBeliefNode.default";
|
|
||||||
import BasicBeliefNode, {
|
import BasicBeliefNode, {
|
||||||
BasicBeliefConnectionSource,
|
BasicBeliefConnectionSource,
|
||||||
BasicBeliefConnectionTarget,
|
BasicBeliefConnectionTarget,
|
||||||
BasicBeliefDisconnectionSource,
|
BasicBeliefDisconnectionSource,
|
||||||
BasicBeliefDisconnectionTarget,
|
BasicBeliefDisconnectionTarget,
|
||||||
BasicBeliefReduce
|
BasicBeliefReduce,
|
||||||
,
|
|
||||||
BasicBeliefTooltip
|
BasicBeliefTooltip
|
||||||
} from "./nodes/BasicBeliefNode.tsx";
|
} from "./nodes/BasicBeliefNode";
|
||||||
import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default.ts";
|
import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Registered node types in the visual programming system.
|
* Registered node types in the visual programming system.
|
||||||
@@ -85,7 +76,6 @@ export const NodeTypes = {
|
|||||||
goal: GoalNode,
|
goal: GoalNode,
|
||||||
trigger: TriggerNode,
|
trigger: TriggerNode,
|
||||||
basic_belief: BasicBeliefNode,
|
basic_belief: BasicBeliefNode,
|
||||||
inferred_belief: InferredBeliefNode,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -101,7 +91,6 @@ export const NodeDefaults = {
|
|||||||
goal: GoalNodeDefaults,
|
goal: GoalNodeDefaults,
|
||||||
trigger: TriggerNodeDefaults,
|
trigger: TriggerNodeDefaults,
|
||||||
basic_belief: BasicBeliefNodeDefaults,
|
basic_belief: BasicBeliefNodeDefaults,
|
||||||
inferred_belief: InferredBeliefNodeDefaults,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -119,7 +108,6 @@ export const NodeReduces = {
|
|||||||
goal: GoalReduce,
|
goal: GoalReduce,
|
||||||
trigger: TriggerReduce,
|
trigger: TriggerReduce,
|
||||||
basic_belief: BasicBeliefReduce,
|
basic_belief: BasicBeliefReduce,
|
||||||
inferred_belief: InferredBeliefReduce,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -138,7 +126,6 @@ export const NodeConnections = {
|
|||||||
goal: GoalConnectionTarget,
|
goal: GoalConnectionTarget,
|
||||||
trigger: TriggerConnectionTarget,
|
trigger: TriggerConnectionTarget,
|
||||||
basic_belief: BasicBeliefConnectionTarget,
|
basic_belief: BasicBeliefConnectionTarget,
|
||||||
inferred_belief: InferredBeliefConnectionTarget,
|
|
||||||
},
|
},
|
||||||
Sources: {
|
Sources: {
|
||||||
start: StartConnectionSource,
|
start: StartConnectionSource,
|
||||||
@@ -147,8 +134,7 @@ export const NodeConnections = {
|
|||||||
norm: NormConnectionSource,
|
norm: NormConnectionSource,
|
||||||
goal: GoalConnectionSource,
|
goal: GoalConnectionSource,
|
||||||
trigger: TriggerConnectionSource,
|
trigger: TriggerConnectionSource,
|
||||||
basic_belief: BasicBeliefConnectionSource,
|
basic_belief: BasicBeliefConnectionSource
|
||||||
inferred_belief: InferredBeliefConnectionSource,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +153,6 @@ export const NodeDisconnections = {
|
|||||||
goal: GoalDisconnectionTarget,
|
goal: GoalDisconnectionTarget,
|
||||||
trigger: TriggerDisconnectionTarget,
|
trigger: TriggerDisconnectionTarget,
|
||||||
basic_belief: BasicBeliefDisconnectionTarget,
|
basic_belief: BasicBeliefDisconnectionTarget,
|
||||||
inferred_belief: InferredBeliefDisconnectionTarget,
|
|
||||||
},
|
},
|
||||||
Sources: {
|
Sources: {
|
||||||
start: StartDisconnectionSource,
|
start: StartDisconnectionSource,
|
||||||
@@ -177,7 +162,6 @@ export const NodeDisconnections = {
|
|||||||
goal: GoalDisconnectionSource,
|
goal: GoalDisconnectionSource,
|
||||||
trigger: TriggerDisconnectionSource,
|
trigger: TriggerDisconnectionSource,
|
||||||
basic_belief: BasicBeliefDisconnectionSource,
|
basic_belief: BasicBeliefDisconnectionSource,
|
||||||
inferred_belief: InferredBeliefDisconnectionSource,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +186,6 @@ export const NodesInPhase = {
|
|||||||
end: () => false,
|
end: () => false,
|
||||||
phase: () => false,
|
phase: () => false,
|
||||||
basic_belief: () => false,
|
basic_belief: () => false,
|
||||||
inferred_belief: () => false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,5 +199,4 @@ export const NodeTooltips = {
|
|||||||
goal: GoalTooltip,
|
goal: GoalTooltip,
|
||||||
trigger: TriggerTooltip,
|
trigger: TriggerTooltip,
|
||||||
basic_belief: BasicBeliefTooltip,
|
basic_belief: BasicBeliefTooltip,
|
||||||
inferred_belief: InferredBeliefTooltip,
|
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,6 @@
|
|||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.planDialog::backdrop {
|
.planDialog::backdrop {
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
@@ -68,4 +67,17 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dragHandle {
|
||||||
|
margin-left: auto;
|
||||||
|
cursor: grab;
|
||||||
|
opacity: 0.5;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragHandle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planStepDragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
@@ -6,16 +6,26 @@ import { defaultPlan } from "../components/Plan.default";
|
|||||||
import { TextField } from "../../../../components/TextField";
|
import { TextField } from "../../../../components/TextField";
|
||||||
import GestureValueEditor from "./GestureValueEditor";
|
import GestureValueEditor from "./GestureValueEditor";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The properties of a plan editor.
|
||||||
|
* @property plan: The optional plan loaded into this editor.
|
||||||
|
* @property onSave: The function that will be called upon save.
|
||||||
|
* @property description: Optional description which is already set.
|
||||||
|
* @property onlyEditPhasing: Optional boolean to toggle
|
||||||
|
* whether or not this editor is part of the phase editing.
|
||||||
|
*/
|
||||||
type PlanEditorDialogProps = {
|
type PlanEditorDialogProps = {
|
||||||
plan?: Plan;
|
plan?: Plan;
|
||||||
onSave: (plan: Plan | undefined) => void;
|
onSave: (plan: Plan | undefined) => void;
|
||||||
description? : string;
|
description? : string;
|
||||||
|
onlyEditPhasing? : boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PlanEditorDialog({
|
export default function PlanEditorDialog({
|
||||||
plan,
|
plan,
|
||||||
onSave,
|
onSave,
|
||||||
description,
|
description,
|
||||||
|
onlyEditPhasing = false,
|
||||||
}: PlanEditorDialogProps) {
|
}: PlanEditorDialogProps) {
|
||||||
// UseStates and references
|
// UseStates and references
|
||||||
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
const dialogRef = useRef<HTMLDialogElement | null>(null);
|
||||||
@@ -24,10 +34,11 @@ export default function PlanEditorDialog({
|
|||||||
const [newActionGestureType, setNewActionGestureType] = useState<boolean>(true);
|
const [newActionGestureType, setNewActionGestureType] = useState<boolean>(true);
|
||||||
const [newActionValue, setNewActionValue] = useState("");
|
const [newActionValue, setNewActionValue] = useState("");
|
||||||
const [hasInteractedWithPlan, setHasInteractedWithPlan] = useState<boolean>(false)
|
const [hasInteractedWithPlan, setHasInteractedWithPlan] = useState<boolean>(false)
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
const { setScrollable } = useFlowStore();
|
const { setScrollable } = useFlowStore();
|
||||||
const nodes = useFlowStore().nodes;
|
const nodes = useFlowStore().nodes;
|
||||||
|
|
||||||
//Button Actions
|
// Button Actions
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setScrollable(false);
|
setScrollable(false);
|
||||||
setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()});
|
setDraftPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()});
|
||||||
@@ -89,9 +100,9 @@ export default function PlanEditorDialog({
|
|||||||
data-testid={"PlanEditorDialogTestID"}
|
data-testid={"PlanEditorDialogTestID"}
|
||||||
>
|
>
|
||||||
<form method="dialog" className="flex-col gap-md">
|
<form method="dialog" className="flex-col gap-md">
|
||||||
<h3> {draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan"} </h3>
|
<h3> {onlyEditPhasing ? "Editing Phase Ordering" : (draftPlan?.id === plan?.id ? "Edit Plan" : "Create Plan")} </h3>
|
||||||
{/* Plan name text field */}
|
{/* Plan name text field */}
|
||||||
{draftPlan && (
|
{(draftPlan && !onlyEditPhasing) && (
|
||||||
<TextField
|
<TextField
|
||||||
value={draftPlan.name}
|
value={draftPlan.name}
|
||||||
setValue={(name) =>
|
setValue={(name) =>
|
||||||
@@ -104,12 +115,14 @@ export default function PlanEditorDialog({
|
|||||||
{draftPlan && (<div className={styles.planEditor}>
|
{draftPlan && (<div className={styles.planEditor}>
|
||||||
<div className={styles.planEditorLeft}>
|
<div className={styles.planEditorLeft}>
|
||||||
{/* Left Side (Action Adder) */}
|
{/* Left Side (Action Adder) */}
|
||||||
<h4>Add Action</h4>
|
<h4>{onlyEditPhasing ? "You can't add any actions, only rearrange the steps." : "Add Action"}</h4>
|
||||||
{(!plan && description && draftPlan.steps.length === 0 && !hasInteractedWithPlan) && (<div className={styles.stepSuggestion}>
|
{(!plan && description && draftPlan.steps.length === 0 && !hasInteractedWithPlan) && (<div className={styles.stepSuggestion}>
|
||||||
<label> Filled in as a suggestion! </label>
|
<label> Filled in as a suggestion! </label>
|
||||||
<label> Feel free to change! </label>
|
<label> Feel free to change! </label>
|
||||||
</div>)}
|
</div>)}
|
||||||
<label>
|
|
||||||
|
|
||||||
|
{(!onlyEditPhasing) && (<label>
|
||||||
Action Type <wbr />
|
Action Type <wbr />
|
||||||
{/* Type selection */}
|
{/* Type selection */}
|
||||||
<select
|
<select
|
||||||
@@ -123,10 +136,10 @@ export default function PlanEditorDialog({
|
|||||||
<option value="gesture">Gesture Action</option>
|
<option value="gesture">Gesture Action</option>
|
||||||
<option value="llm">LLM Action</option>
|
<option value="llm">LLM Action</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>)}
|
||||||
|
|
||||||
{/* Action value editor*/}
|
{/* Action value editor*/}
|
||||||
{newActionType === "gesture" ? (
|
{!onlyEditPhasing && newActionType === "gesture" ? (
|
||||||
// Gesture get their own editor component
|
// Gesture get their own editor component
|
||||||
<GestureValueEditor
|
<GestureValueEditor
|
||||||
value={newActionValue}
|
value={newActionValue}
|
||||||
@@ -134,15 +147,18 @@ export default function PlanEditorDialog({
|
|||||||
setType={setNewActionGestureType}
|
setType={setNewActionGestureType}
|
||||||
placeholder="Gesture name"
|
placeholder="Gesture name"
|
||||||
/>
|
/>
|
||||||
) : (
|
)
|
||||||
<TextField
|
:
|
||||||
|
// Only show the text field if we're not just rearranging.
|
||||||
|
(!onlyEditPhasing &&
|
||||||
|
(<TextField
|
||||||
value={newActionValue}
|
value={newActionValue}
|
||||||
setValue={setNewActionValue}
|
setValue={setNewActionValue}
|
||||||
placeholder={
|
placeholder={
|
||||||
newActionType === "speech" ? "Speech text"
|
newActionType === "speech" ? "Speech text"
|
||||||
: "LLM goal"
|
: "LLM goal"
|
||||||
}
|
}
|
||||||
/>
|
/>)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Adding steps */}
|
{/* Adding steps */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { BasicBeliefNodeData } from "./BasicBeliefNode.tsx";
|
import type { BasicBeliefNodeData } from "./BasicBeliefNode";
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import {
|
|||||||
Position,
|
Position,
|
||||||
type Node,
|
type Node,
|
||||||
} from '@xyflow/react';
|
} from '@xyflow/react';
|
||||||
import { Toolbar } from '../components/NodeComponents.tsx';
|
import { Toolbar } from '../components/NodeComponents';
|
||||||
import styles from '../../VisProg.module.css';
|
import styles from '../../VisProg.module.css';
|
||||||
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
|
import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts";
|
||||||
import useFlowStore from '../VisProgStores.tsx';
|
import useFlowStore from '../VisProgStores';
|
||||||
import { TextField } from '../../../../components/TextField.tsx';
|
import { TextField } from '../../../../components/TextField';
|
||||||
import { MultilineTextField } from '../../../../components/MultilineTextField.tsx';
|
import { MultilineTextField } from '../../../../components/MultilineTextField';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default data structure for a BasicBelief node
|
* The default data structure for a BasicBelief node
|
||||||
@@ -31,7 +31,7 @@ export type BasicBeliefNodeData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// These are all the types a basic belief could be.
|
// These are all the types a basic belief could be.
|
||||||
export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion
|
type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion
|
||||||
type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"};
|
type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"};
|
||||||
type Semantic = { type: "semantic", id: string, value: string, description: string, label: "Detected with LLM:"};
|
type Semantic = { type: "semantic", id: string, value: string, description: string, label: "Detected with LLM:"};
|
||||||
type DetectedObject = { type: "object", id: string, value: string, label: "Object found:"};
|
type DetectedObject = { type: "object", id: string, value: string, label: "Object found:"};
|
||||||
@@ -189,7 +189,7 @@ export default function BasicBeliefNode(props: NodeProps<BasicBeliefNode>) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
||||||
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"},{nodeType:"InferredBelief",handleId:"inferred_belief"}]),
|
allowOnlyConnectionsFromHandle([{nodeType:"trigger",handleId:"TriggerBeliefs"}, {nodeType:"norm",handleId:"NormBeliefs"}]),
|
||||||
]}/>
|
]}/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import {getOutgoers, type Node} from '@xyflow/react';
|
|
||||||
import {type HandleRule, type RuleResult, ruleResult} from "../HandleRuleLogic.ts";
|
|
||||||
import useFlowStore from "../VisProgStores.tsx";
|
|
||||||
import {BasicBeliefReduce} from "./BasicBeliefNode.tsx";
|
|
||||||
import {type InferredBeliefNodeData, InferredBeliefReduce} from "./InferredBeliefNode.tsx";
|
|
||||||
|
|
||||||
export function BeliefGlobalReduce(beliefNode: Node, nodes: Node[]) {
|
|
||||||
switch (beliefNode.type) {
|
|
||||||
case 'basic_belief':
|
|
||||||
return BasicBeliefReduce(beliefNode, nodes);
|
|
||||||
case 'inferred_belief':
|
|
||||||
return InferredBeliefReduce(beliefNode, nodes);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const noMatchingLeftRightBelief : HandleRule = (connection, _)=> {
|
|
||||||
const { nodes } = useFlowStore.getState();
|
|
||||||
const thisNode = nodes.find(node => node.id === connection.target && node.type === 'inferred_belief');
|
|
||||||
if (!thisNode) return ruleResult.satisfied;
|
|
||||||
|
|
||||||
const iBelief = (thisNode.data as InferredBeliefNodeData).inferredBelief;
|
|
||||||
return (iBelief.left === connection.source || iBelief.right === connection.source)
|
|
||||||
? ruleResult.notSatisfied("Connecting one belief to both input handles of an inferred belief node is not allowed")
|
|
||||||
: ruleResult.satisfied;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* makes it impossible to connect Inferred belief nodes
|
|
||||||
* if the connection would create a cyclical connection between inferred beliefs
|
|
||||||
*/
|
|
||||||
export const noBeliefCycles: HandleRule = (connection, _): RuleResult => {
|
|
||||||
const {nodes, edges} = useFlowStore.getState();
|
|
||||||
const defaultErrorMessage = "Cyclical connection exists between inferred beliefs";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* recursively checks for cyclical connections between InferredBelief nodes
|
|
||||||
*
|
|
||||||
* to check for a cycle provide the source of an attempted connection as the targetNode for the cycle check,
|
|
||||||
* the currentNodeId should be initialised with the id of the targetNode of the attempted connection.
|
|
||||||
*
|
|
||||||
* @param {string} targetNodeId - the id of the node we are looking for as the endpoint of a cyclical connection
|
|
||||||
* @param {string} currentNodeId - the id of the node we are checking for outgoing connections to the provided target node
|
|
||||||
* @returns {RuleResult}
|
|
||||||
*/
|
|
||||||
function checkForCycle(targetNodeId: string, currentNodeId: string): RuleResult {
|
|
||||||
const outgoingBeliefs = getOutgoers({id: currentNodeId}, nodes, edges)
|
|
||||||
.filter(node => node.type === 'inferred_belief');
|
|
||||||
|
|
||||||
if (outgoingBeliefs.length === 0) return ruleResult.satisfied;
|
|
||||||
if (outgoingBeliefs.some(node => node.id === targetNodeId)) return ruleResult
|
|
||||||
.notSatisfied(defaultErrorMessage);
|
|
||||||
|
|
||||||
const next = outgoingBeliefs.map(node => checkForCycle(targetNodeId, node.id))
|
|
||||||
.find(result => !result.isSatisfied);
|
|
||||||
|
|
||||||
return next
|
|
||||||
? next
|
|
||||||
: ruleResult.satisfied;
|
|
||||||
}
|
|
||||||
|
|
||||||
return connection.source === connection.target
|
|
||||||
? ruleResult.notSatisfied(defaultErrorMessage)
|
|
||||||
: checkForCycle(connection.source, connection.target);
|
|
||||||
};
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import type { InferredBeliefNodeData } from "./InferredBeliefNode.tsx";
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default data for this node
|
|
||||||
*/
|
|
||||||
export const InferredBeliefNodeDefaults: InferredBeliefNodeData = {
|
|
||||||
label: "Inferred Belief",
|
|
||||||
droppable: true,
|
|
||||||
inferredBelief: {
|
|
||||||
left: undefined,
|
|
||||||
operator: true,
|
|
||||||
right: undefined
|
|
||||||
},
|
|
||||||
hasReduce: true,
|
|
||||||
};
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
.operator-switch {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5em;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: sans-serif;
|
|
||||||
/* Change this font-size to scale the whole component */
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* hide the default checkbox */
|
|
||||||
.operator-switch input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The Track */
|
|
||||||
.switch-visual {
|
|
||||||
position: relative;
|
|
||||||
/* height is now 3x the font size */
|
|
||||||
height: 3em;
|
|
||||||
aspect-ratio: 1 / 2;
|
|
||||||
background-color: ButtonFace;
|
|
||||||
border-radius: 2em;
|
|
||||||
transition: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The Knob */
|
|
||||||
.switch-visual::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 0.1em;
|
|
||||||
left: 0.1em;
|
|
||||||
width: 1em;
|
|
||||||
height: 1em;
|
|
||||||
background: Canvas;
|
|
||||||
border: 0.175em solid mediumpurple;
|
|
||||||
border-radius: 50%;
|
|
||||||
transition: transform 0.2s ease-in-out, border-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Labels */
|
|
||||||
.switch-labels {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
height: 3em; /* Matches the track height */
|
|
||||||
font-weight: 800;
|
|
||||||
color: Canvas;
|
|
||||||
line-height: 1.4;
|
|
||||||
padding: 0.2em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.operator-switch input:checked + .switch-visual::after {
|
|
||||||
/* Moves the slider down */
|
|
||||||
transform: translateY(1.4em);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*change the colours to highlight the selected operator*/
|
|
||||||
.operator-switch input:checked ~ .switch-labels{
|
|
||||||
:first-child {
|
|
||||||
transition: ease-in-out color 0.2s;
|
|
||||||
color: ButtonFace;
|
|
||||||
}
|
|
||||||
:last-child {
|
|
||||||
transition: ease-in-out color 0.2s;
|
|
||||||
color: mediumpurple;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.operator-switch input:not(:checked) ~ .switch-labels{
|
|
||||||
:first-child {
|
|
||||||
transition: ease-in-out color 0.2s;
|
|
||||||
color: mediumpurple;
|
|
||||||
}
|
|
||||||
:last-child {
|
|
||||||
transition: ease-in-out color 0.2s;
|
|
||||||
color: ButtonFace;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import {getConnectedEdges, type Node, type NodeProps, Position} from '@xyflow/react';
|
|
||||||
import {useState} from "react";
|
|
||||||
import styles from '../../VisProg.module.css';
|
|
||||||
import {Toolbar} from '../components/NodeComponents.tsx';
|
|
||||||
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
|
||||||
import {allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
|
||||||
import useFlowStore from "../VisProgStores.tsx";
|
|
||||||
import {BeliefGlobalReduce, noBeliefCycles, noMatchingLeftRightBelief} from "./BeliefGlobals.ts";
|
|
||||||
import switchStyles from './InferredBeliefNode.module.css';
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The default data structure for an InferredBelief node
|
|
||||||
*/
|
|
||||||
export type InferredBeliefNodeData = {
|
|
||||||
label: string;
|
|
||||||
droppable: boolean;
|
|
||||||
inferredBelief: InferredBelief;
|
|
||||||
hasReduce: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* stores a boolean to represent the operator
|
|
||||||
* and a left and right BeliefNode (can be both an inferred and a basic belief)
|
|
||||||
* in the form of their corresponding id's
|
|
||||||
*/
|
|
||||||
export type InferredBelief = {
|
|
||||||
left: string | undefined,
|
|
||||||
operator: boolean,
|
|
||||||
right: string | undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
export type InferredBeliefNode = Node<InferredBeliefNodeData>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 InferredBeliefConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
|
||||||
const data = _thisNode.data as InferredBeliefNodeData;
|
|
||||||
|
|
||||||
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId
|
|
||||||
&& ['basic_belief', 'inferred_belief'].includes(node.type!)))
|
|
||||||
) {
|
|
||||||
const connectedEdges = getConnectedEdges([_thisNode], useFlowStore.getState().edges);
|
|
||||||
switch(connectedEdges.find(edge => edge.source === _sourceNodeId)?.targetHandle){
|
|
||||||
case 'beliefLeft': data.inferredBelief.left = _sourceNodeId; break;
|
|
||||||
case 'beliefRight': data.inferredBelief.right = _sourceNodeId; break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 InferredBeliefConnectionSource(_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 InferredBeliefDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
|
||||||
const data = _thisNode.data as InferredBeliefNodeData;
|
|
||||||
|
|
||||||
if (_sourceNodeId === data.inferredBelief.left) data.inferredBelief.left = undefined;
|
|
||||||
if (_sourceNodeId === data.inferredBelief.right) data.inferredBelief.right = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 InferredBeliefDisconnectionSource(_thisNode: Node, _targetNodeId: string) {
|
|
||||||
// no additional connection logic exists yet
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InferredBeliefTooltip = `
|
|
||||||
Combines two beliefs into a single belief using logical inference,
|
|
||||||
the node can be toggled between using "AND" and "OR" mode for inference`;
|
|
||||||
/**
|
|
||||||
* Defines how an InferredBelief node should be rendered
|
|
||||||
* @param {NodeProps<InferredBeliefNode>} props - Node properties provided by React Flow, including `id` and `data`.
|
|
||||||
* @returns The rendered InferredBeliefNode React element. (React.JSX.Element)
|
|
||||||
*/
|
|
||||||
export default function InferredBeliefNode(props: NodeProps<InferredBeliefNode>) {
|
|
||||||
const data = props.data;
|
|
||||||
const { updateNodeData } = useFlowStore();
|
|
||||||
// start of as an AND operator, true: "AND", false: "OR"
|
|
||||||
const [enforceAllBeliefs, setEnforceAllBeliefs] = useState(true);
|
|
||||||
|
|
||||||
// used to toggle operator
|
|
||||||
function onToggle() {
|
|
||||||
const newOperator = !enforceAllBeliefs; // compute the new value
|
|
||||||
setEnforceAllBeliefs(newOperator);
|
|
||||||
|
|
||||||
updateNodeData(props.id, {
|
|
||||||
...data,
|
|
||||||
inferredBelief: {
|
|
||||||
...data.inferredBelief,
|
|
||||||
operator: enforceAllBeliefs,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Toolbar nodeId={props.id} allowDelete={true}/>
|
|
||||||
<div className={`${styles.defaultNode} ${styles.nodeInferredBelief}`}>
|
|
||||||
{/* The checkbox used to toggle the operator between 'AND' and 'OR' */}
|
|
||||||
<label className={switchStyles.operatorSwitch}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={data.inferredBelief.operator}
|
|
||||||
onChange={onToggle}
|
|
||||||
/>
|
|
||||||
<div className={switchStyles.switchVisual}></div>
|
|
||||||
<div className={switchStyles.switchLabels}>
|
|
||||||
<span title={"Belief is fulfilled if either of the supplied beliefs is true"}>OR</span>
|
|
||||||
<span title={"Belief is fulfilled if all of the supplied beliefs are true"}>AND</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
|
|
||||||
{/* outgoing connections */}
|
|
||||||
<MultiConnectionHandle type="source" position={Position.Right} id="source" rules={[
|
|
||||||
allowOnlyConnectionsFromType(["norm", "trigger"]),
|
|
||||||
noBeliefCycles,
|
|
||||||
noMatchingLeftRightBelief
|
|
||||||
]}/>
|
|
||||||
|
|
||||||
{/* incoming connections */}
|
|
||||||
<SingleConnectionHandle type="target" position={Position.Left} style={{top: '30%'}} id="beliefLeft" rules={[
|
|
||||||
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"]),
|
|
||||||
noBeliefCycles,
|
|
||||||
noMatchingLeftRightBelief
|
|
||||||
]}/>
|
|
||||||
<SingleConnectionHandle type="target" position={Position.Left} style={{top: '70%'}} id="beliefRight" rules={[
|
|
||||||
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"]),
|
|
||||||
noBeliefCycles,
|
|
||||||
noMatchingLeftRightBelief
|
|
||||||
]}/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reduces each BasicBelief, including its children down into its core data.
|
|
||||||
* @param {Node} node - The BasicBelief node to reduce.
|
|
||||||
* @param {Node[]} 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 InferredBeliefReduce(node: Node, nodes: Node[]) {
|
|
||||||
const data = node.data as InferredBeliefNodeData;
|
|
||||||
const leftBelief = nodes.find((node) => node.id === data.inferredBelief.left);
|
|
||||||
const rightBelief = nodes.find((node) => node.id === data.inferredBelief.right);
|
|
||||||
|
|
||||||
if (!leftBelief) { throw new Error("No Left belief found")}
|
|
||||||
if (!rightBelief) { throw new Error("No Right Belief found")}
|
|
||||||
|
|
||||||
const result: Record<string, unknown> = {
|
|
||||||
id: node.id,
|
|
||||||
left: BeliefGlobalReduce(leftBelief, nodes),
|
|
||||||
operator: data.inferredBelief.operator ? "AND" : "OR",
|
|
||||||
right: BeliefGlobalReduce(rightBelief, nodes),
|
|
||||||
};
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ import { TextField } from '../../../../components/TextField';
|
|||||||
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx";
|
||||||
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts";
|
||||||
import useFlowStore from '../VisProgStores';
|
import useFlowStore from '../VisProgStores';
|
||||||
import {BeliefGlobalReduce} from "./BeliefGlobals.ts";
|
import { BasicBeliefReduce } from './BasicBeliefNode';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default data dot a phase node
|
* The default data dot a phase node
|
||||||
@@ -81,7 +81,7 @@ export default function NormNode(props: NodeProps<NormNode>) {
|
|||||||
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}])
|
allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}])
|
||||||
]}/>
|
]}/>
|
||||||
<SingleConnectionHandle type="target" position={Position.Bottom} id="NormBeliefs" rules={[
|
<SingleConnectionHandle type="target" position={Position.Bottom} id="NormBeliefs" rules={[
|
||||||
allowOnlyConnectionsFromType(["basic_belief", "inferred_belief"])
|
allowOnlyConnectionsFromType(["basic_belief"])
|
||||||
]}/>
|
]}/>
|
||||||
</div>
|
</div>
|
||||||
</>;
|
</>;
|
||||||
@@ -105,10 +105,11 @@ export function NormReduce(node: Node, nodes: Node[]) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (data.condition) {
|
if (data.condition) {
|
||||||
|
const reducer = BasicBeliefReduce; // TODO: also add inferred.
|
||||||
const conditionNode = nodes.find((node) => node.id === data.condition);
|
const conditionNode = nodes.find((node) => node.id === data.condition);
|
||||||
// In case something went wrong, and our condition doesn't actually exist;
|
// In case something went wrong, and our condition doesn't actually exist;
|
||||||
if (conditionNode == undefined) return result;
|
if (conditionNode == undefined) return result;
|
||||||
result["condition"] = BeliefGlobalReduce(conditionNode, nodes)
|
result["condition"] = reducer(conditionNode, nodes)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@@ -125,7 +126,7 @@ export const NormTooltip = `
|
|||||||
export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
const data = _thisNode.data as NormNodeData;
|
const data = _thisNode.data as NormNodeData;
|
||||||
// If we got a belief connected, this is the condition for the norm.
|
// If we got a belief connected, this is the condition for the norm.
|
||||||
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && ['basic_belief', 'inferred_belief'].includes(node.type!)))) {
|
if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) {
|
||||||
data.condition = _sourceNodeId;
|
data.condition = _sourceNodeId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ export const PhaseNodeDefaults: PhaseNodeData = {
|
|||||||
hasReduce: true,
|
hasReduce: true,
|
||||||
nextPhaseId: null,
|
nextPhaseId: null,
|
||||||
isFirstPhase: false,
|
isFirstPhase: false,
|
||||||
|
plan: undefined,
|
||||||
};
|
};
|
||||||
@@ -10,6 +10,11 @@ import {allowOnlyConnectionsFromType, noSelfConnections} from "../HandleRules.ts
|
|||||||
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 PlanEditorDialog from '../components/PlanEditor.tsx';
|
||||||
|
import type { Plan } from '../components/Plan.tsx';
|
||||||
|
import { insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
|
||||||
|
import { defaultPlan } from '../components/Plan.default.ts';
|
||||||
|
import type { GoalNode } from './GoalNode.tsx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The default data dot a phase node
|
* The default data dot a phase node
|
||||||
@@ -26,6 +31,7 @@ export type PhaseNodeData = {
|
|||||||
hasReduce: boolean;
|
hasReduce: boolean;
|
||||||
nextPhaseId: string | "end" | null;
|
nextPhaseId: string | "end" | null;
|
||||||
isFirstPhase: boolean;
|
isFirstPhase: boolean;
|
||||||
|
plan?: Plan;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PhaseNode = Node<PhaseNodeData>
|
export type PhaseNode = Node<PhaseNodeData>
|
||||||
@@ -54,6 +60,24 @@ export default function PhaseNode(props: NodeProps<PhaseNode>) {
|
|||||||
placeholder={"Phase ..."}
|
placeholder={"Phase ..."}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
{(data.plan && data.plan.steps.length > 0) && (<div>
|
||||||
|
<PlanEditorDialog
|
||||||
|
plan={data.plan}
|
||||||
|
onSave={(plan) => {
|
||||||
|
updateNodeData(props.id, {
|
||||||
|
...data,
|
||||||
|
plan,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
description={props.data.label}
|
||||||
|
onlyEditPhasing={true}
|
||||||
|
/>
|
||||||
|
</div>)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
|
<SingleConnectionHandle type="target" position={Position.Left} id="target" rules={[
|
||||||
noSelfConnections,
|
noSelfConnections,
|
||||||
allowOnlyConnectionsFromType(["phase", "start"]),
|
allowOnlyConnectionsFromType(["phase", "start"]),
|
||||||
@@ -129,9 +153,9 @@ export const PhaseTooltip = `
|
|||||||
*/
|
*/
|
||||||
export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
||||||
const data = _thisNode.data as PhaseNodeData
|
const data = _thisNode.data as PhaseNodeData
|
||||||
|
|
||||||
const nodes = useFlowStore.getState().nodes;
|
const nodes = useFlowStore.getState().nodes;
|
||||||
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)!
|
const sourceNode = nodes.find((node) => node.id === _sourceNodeId)!
|
||||||
|
|
||||||
switch (sourceNode.type) {
|
switch (sourceNode.type) {
|
||||||
case "phase": break;
|
case "phase": break;
|
||||||
case "start": data.isFirstPhase = true; break;
|
case "start": data.isFirstPhase = true; break;
|
||||||
@@ -139,6 +163,18 @@ export function PhaseConnectionTarget(_thisNode: Node, _sourceNodeId: string) {
|
|||||||
// endNodes cannot be the source of an outgoing connection
|
// endNodes cannot be the source of an outgoing connection
|
||||||
// so we don't need to cover them with a special case
|
// so we don't need to cover them with a special case
|
||||||
// before handling the default behavior
|
// before handling the default behavior
|
||||||
|
case "goal": {
|
||||||
|
// First, let's see if we have a plan currently. If not, let's create a default plan with this goal inside.:)
|
||||||
|
if (!data.plan) {
|
||||||
|
data.plan = insertGoalInPlan({...structuredClone(defaultPlan), id: crypto.randomUUID()} as Plan, sourceNode as GoalNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Else, lets just insert this goal into our current plan.
|
||||||
|
else {
|
||||||
|
data.plan = insertGoalInPlan(structuredClone(data.plan), sourceNode as GoalNode)
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: data.children.push(_sourceNodeId); break;
|
default: data.children.push(_sourceNodeId); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../H
|
|||||||
import useFlowStore from '../VisProgStores';
|
import useFlowStore from '../VisProgStores';
|
||||||
import {PlanReduce, type Plan } from '../components/Plan';
|
import {PlanReduce, type Plan } from '../components/Plan';
|
||||||
import PlanEditorDialog from '../components/PlanEditor';
|
import PlanEditorDialog from '../components/PlanEditor';
|
||||||
import {BeliefGlobalReduce} from "./BeliefGlobals.ts";
|
import { BasicBeliefReduce } from './BasicBeliefNode';
|
||||||
import type { GoalNode } from './GoalNode.tsx';
|
import type { GoalNode } from './GoalNode.tsx';
|
||||||
import { defaultPlan } from '../components/Plan.default.ts';
|
import { defaultPlan } from '../components/Plan.default.ts';
|
||||||
import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
|
import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx';
|
||||||
@@ -72,7 +72,7 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
|||||||
id="TriggerBeliefs"
|
id="TriggerBeliefs"
|
||||||
style={{ left: '40%' }}
|
style={{ left: '40%' }}
|
||||||
rules={[
|
rules={[
|
||||||
allowOnlyConnectionsFromType(['basic_belief', "inferred_belief"]),
|
allowOnlyConnectionsFromType(['basic_belief']),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -102,13 +102,13 @@ export default function TriggerNode(props: NodeProps<TriggerNode>) {
|
|||||||
/**
|
/**
|
||||||
* Reduces each Trigger, including its children down into its core data.
|
* Reduces each Trigger, including its children down into its core data.
|
||||||
* @param node - The Trigger node to reduce.
|
* @param node - The Trigger node to reduce.
|
||||||
* @param nodes - The list of all nodes in the current flow graph.
|
* @param _nodes - The list of all nodes in the current flow graph.
|
||||||
* @returns A simplified object containing the node label and its list of triggers.
|
* @returns A simplified object containing the node label and its list of triggers.
|
||||||
*/
|
*/
|
||||||
export function TriggerReduce(node: Node, nodes: Node[]) {
|
export function TriggerReduce(node: Node, nodes: Node[]) {
|
||||||
const data = node.data as TriggerNodeData;
|
const data = node.data as TriggerNodeData;
|
||||||
const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined
|
const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined
|
||||||
const conditionData = conditionNode ? BeliefGlobalReduce(conditionNode, nodes) : ""
|
const conditionData = conditionNode ? BasicBeliefReduce(conditionNode, nodes) : ""
|
||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
name: node.data.name,
|
name: node.data.name,
|
||||||
@@ -136,7 +136,7 @@ export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string)
|
|||||||
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
|
const otherNode = nodes.find((x) => x.id === _sourceNodeId)
|
||||||
if (!otherNode) return;
|
if (!otherNode) return;
|
||||||
|
|
||||||
if (otherNode.type === 'basic_belief'|| otherNode.type ==='inferred_belief') {
|
if (otherNode.type === 'basic_belief' /* TODO: Add the option for an inferred belief */) {
|
||||||
data.condition = _sourceNodeId;
|
data.condition = _sourceNodeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export type ProgramState = {
|
|||||||
// Utility functions:
|
// Utility functions:
|
||||||
// to avoid having to manually go through the entire state for every instance where data is required
|
// to avoid having to manually go through the entire state for every instance where data is required
|
||||||
getPhaseIds: () => string[];
|
getPhaseIds: () => string[];
|
||||||
getPhaseNames: () => string[];
|
|
||||||
getNormsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
getNormsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||||
getGoalsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
getGoalsInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||||
getTriggersInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
getTriggersInPhase: (currentPhaseId: string) => Record<string, unknown>[];
|
||||||
@@ -44,10 +43,6 @@ const useProgramStore = create<ProgramState>((set, get) => ({
|
|||||||
* gets the ids of all phases in the program
|
* gets the ids of all phases in the program
|
||||||
*/
|
*/
|
||||||
getPhaseIds: () => get().currentProgram.phases.map(entry => entry["id"] as string),
|
getPhaseIds: () => get().currentProgram.phases.map(entry => entry["id"] as string),
|
||||||
/**
|
|
||||||
* gets the names of all phases in the program
|
|
||||||
*/
|
|
||||||
getPhaseNames: () => get().currentProgram.phases.map((entry) => (entry["name"] as string)),
|
|
||||||
/**
|
/**
|
||||||
* gets the norms for the provided phase
|
* gets the norms for the provided phase
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,293 +0,0 @@
|
|||||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
import MonitoringPage from '../../../src/pages/MonitoringPage/MonitoringPage';
|
|
||||||
import useProgramStore from '../../../src/utils/programStore';
|
|
||||||
import * as MonitoringAPI from '../../../src/pages/MonitoringPage/MonitoringPageAPI';
|
|
||||||
import * as VisProg from '../../../src/pages/VisProgPage/VisProgLogic';
|
|
||||||
|
|
||||||
// --- Mocks ---
|
|
||||||
|
|
||||||
// Mock the Zustand store
|
|
||||||
jest.mock('../../../src/utils/programStore', () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the API layer including hooks
|
|
||||||
jest.mock('../../../src/pages/MonitoringPage/MonitoringPageAPI', () => ({
|
|
||||||
nextPhase: jest.fn(),
|
|
||||||
resetPhase: jest.fn(),
|
|
||||||
pauseExperiment: jest.fn(),
|
|
||||||
playExperiment: jest.fn(),
|
|
||||||
// We mock these to capture the callbacks and trigger them manually in tests
|
|
||||||
useExperimentLogger: jest.fn(),
|
|
||||||
useStatusLogger: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock VisProg functionality
|
|
||||||
jest.mock('../../../src/pages/VisProgPage/VisProgLogic', () => ({
|
|
||||||
graphReducer: jest.fn(),
|
|
||||||
runProgramm: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock Child Components to reduce noise (optional, but keeps unit test focused)
|
|
||||||
// For this test, we will allow them to render to test data passing,
|
|
||||||
// but we mock RobotConnected as it has its own side effects
|
|
||||||
jest.mock('../../../src/pages/MonitoringPage/MonitoringPageComponents', () => {
|
|
||||||
const original = jest.requireActual('../../../src/pages/MonitoringPage/MonitoringPageComponents');
|
|
||||||
return {
|
|
||||||
...original,
|
|
||||||
RobotConnected: () => <div data-testid="robot-connected-mock">Robot Status</div>,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('MonitoringPage', () => {
|
|
||||||
// Capture stream callbacks
|
|
||||||
let streamUpdateCallback: (data: any) => void;
|
|
||||||
let statusUpdateCallback: (data: any) => void;
|
|
||||||
|
|
||||||
// Setup default store state
|
|
||||||
const mockGetPhaseIds = jest.fn();
|
|
||||||
const mockGetPhaseNames = jest.fn();
|
|
||||||
const mockGetNorms = jest.fn();
|
|
||||||
const mockGetGoals = jest.fn();
|
|
||||||
const mockGetTriggers = jest.fn();
|
|
||||||
const mockSetProgramState = jest.fn();
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
|
|
||||||
// Default Store Implementation
|
|
||||||
(useProgramStore as unknown as jest.Mock).mockImplementation((selector) => {
|
|
||||||
const state = {
|
|
||||||
getPhaseIds: mockGetPhaseIds,
|
|
||||||
getPhaseNames: mockGetPhaseNames,
|
|
||||||
getNormsInPhase: mockGetNorms,
|
|
||||||
getGoalsInPhase: mockGetGoals,
|
|
||||||
getTriggersInPhase: mockGetTriggers,
|
|
||||||
setProgramState: mockSetProgramState,
|
|
||||||
};
|
|
||||||
return selector(state);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Capture the hook callbacks
|
|
||||||
(MonitoringAPI.useExperimentLogger as jest.Mock).mockImplementation((cb) => {
|
|
||||||
streamUpdateCallback = cb;
|
|
||||||
});
|
|
||||||
(MonitoringAPI.useStatusLogger as jest.Mock).mockImplementation((cb) => {
|
|
||||||
statusUpdateCallback = cb;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Default mock return values
|
|
||||||
mockGetPhaseIds.mockReturnValue(['phase-1', 'phase-2']);
|
|
||||||
mockGetPhaseNames.mockReturnValue(['Intro', 'Main']);
|
|
||||||
mockGetGoals.mockReturnValue([{ id: 'g1', name: 'Goal 1' }, { id: 'g2', name: 'Goal 2' }]);
|
|
||||||
mockGetTriggers.mockReturnValue([{ id: 't1', name: 'Trigger 1' }]);
|
|
||||||
mockGetNorms.mockReturnValue([
|
|
||||||
{ id: 'n1', norm: 'Norm 1', condition: null },
|
|
||||||
{ id: 'cn1', norm: 'Cond Norm 1', condition: 'some-cond' }
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders "No program loaded" when phaseIds are empty', () => {
|
|
||||||
mockGetPhaseIds.mockReturnValue([]);
|
|
||||||
render(<MonitoringPage />);
|
|
||||||
expect(screen.getByText('No program loaded.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders the dashboard with initial state', () => {
|
|
||||||
render(<MonitoringPage />);
|
|
||||||
|
|
||||||
// Check Header
|
|
||||||
expect(screen.getByText('Phase 1:')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Intro')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Check Lists
|
|
||||||
expect(screen.getByText(/Goal 1/)).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(screen.getByText('Trigger 1')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Norm 1')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Cond Norm 1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Control Buttons', () => {
|
|
||||||
test('Pause calls API and updates UI', async () => {
|
|
||||||
render(<MonitoringPage />);
|
|
||||||
const pauseBtn = screen.getByText('❚❚');
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(pauseBtn);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(MonitoringAPI.pauseExperiment).toHaveBeenCalled();
|
|
||||||
// Ensure local state toggled (we check if play button is now inactive style or pause active)
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Play calls API and updates UI', async () => {
|
|
||||||
render(<MonitoringPage />);
|
|
||||||
const playBtn = screen.getByText('▶');
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(playBtn);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(MonitoringAPI.playExperiment).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Next Phase calls API', async () => {
|
|
||||||
render(<MonitoringPage />);
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText('⏭'));
|
|
||||||
});
|
|
||||||
expect(MonitoringAPI.nextPhase).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Reset Experiment calls logic and resets state', async () => {
|
|
||||||
render(<MonitoringPage />);
|
|
||||||
|
|
||||||
// Mock graph reducer return
|
|
||||||
(VisProg.graphReducer as jest.Mock).mockReturnValue([{ id: 'new-phase' }]);
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText('⟲'));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(VisProg.graphReducer).toHaveBeenCalled();
|
|
||||||
expect(mockSetProgramState).toHaveBeenCalledWith({ phases: [{ id: 'new-phase' }] });
|
|
||||||
expect(VisProg.runProgramm).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Reset Experiment handles errors gracefully', async () => {
|
|
||||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
(VisProg.runProgramm as jest.Mock).mockRejectedValue(new Error('Fail'));
|
|
||||||
|
|
||||||
render(<MonitoringPage />);
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(screen.getByText('⟲'));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to reset program:', expect.any(Error));
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Stream Updates (useExperimentLogger)', () => {
|
|
||||||
test('Handles phase_update to next phase', () => {
|
|
||||||
render(<MonitoringPage />);
|
|
||||||
|
|
||||||
expect(screen.getByText('Intro')).toBeInTheDocument(); // Phase 0
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
streamUpdateCallback({ type: 'phase_update', id: 'phase-2' });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('Main')).toBeInTheDocument(); // Phase 1
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Handles phase_update to "end"', () => {
|
|
||||||
render(<MonitoringPage />);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
streamUpdateCallback({ type: 'phase_update', id: 'end' });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('Experiment finished')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('All phases have been successfully completed.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Handles phase_update with unknown ID gracefully', () => {
|
|
||||||
render(<MonitoringPage />);
|
|
||||||
act(() => {
|
|
||||||
streamUpdateCallback({ type: 'phase_update', id: 'unknown-phase' });
|
|
||||||
});
|
|
||||||
// Should remain on current phase
|
|
||||||
expect(screen.getByText('Intro')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Handles goal_update: advances index and marks previous as achieved', () => {
|
|
||||||
render(<MonitoringPage />);
|
|
||||||
|
|
||||||
// Initial: Goal 1 (index 0) is current.
|
|
||||||
// Send update for Goal 2 (index 1).
|
|
||||||
act(() => {
|
|
||||||
streamUpdateCallback({ type: 'goal_update', id: 'g2' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Goal 1 should now be marked achieved (passed via activeIds)
|
|
||||||
// Goal 2 should be current.
|
|
||||||
|
|
||||||
// We can inspect the "StatusList" props implicitly by checking styling or indicators if not mocked,
|
|
||||||
// but since we render the full component, we check the class/text.
|
|
||||||
// Goal 1 should have checkmark (override logic puts checkmark for activeIds)
|
|
||||||
// The implementation details of StatusList show ✔️ for activeIds.
|
|
||||||
|
|
||||||
const items = screen.getAllByRole('listitem');
|
|
||||||
// Helper to find checkmarks within items
|
|
||||||
expect(items[0]).toHaveTextContent('Goal 1');
|
|
||||||
// After update, g1 is active (achieved), g2 is current
|
|
||||||
// logic: loop i < gIndex (1). activeIds['g1'] = true.
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Handles goal_update with unknown ID', () => {
|
|
||||||
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
||||||
|
|
||||||
render(<MonitoringPage />);
|
|
||||||
act(() => {
|
|
||||||
streamUpdateCallback({ type: 'goal_update', id: 'unknown-goal' });
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Goal unknown-goal not found'));
|
|
||||||
warnSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Handles trigger_update', () => {
|
|
||||||
render(<MonitoringPage />);
|
|
||||||
|
|
||||||
// Trigger 1 initially not achieved
|
|
||||||
act(() => {
|
|
||||||
streamUpdateCallback({ type: 'trigger_update', id: 't1', achieved: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// StatusList logic: if activeId is true, show ✔️
|
|
||||||
// We look for visual confirmation or check logic
|
|
||||||
const triggerList = screen.getByText('Triggers').parentElement;
|
|
||||||
expect(triggerList).toHaveTextContent('✔️'); // Assuming 't1' is the only trigger
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Status Updates (useStatusLogger)', () => {
|
|
||||||
test('Handles cond_norms_state_update', () => {
|
|
||||||
render(<MonitoringPage />);
|
|
||||||
|
|
||||||
// Initial state: activeIds empty.
|
|
||||||
act(() => {
|
|
||||||
statusUpdateCallback({
|
|
||||||
type: 'cond_norms_state_update',
|
|
||||||
norms: [{ id: 'cn1', active: true }]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Conditional Norm 1 should now be active
|
|
||||||
const cnList = screen.getByText('Conditional Norms').parentElement;
|
|
||||||
expect(cnList).toHaveTextContent('✔️');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Ignores status update if no changes detected', () => {
|
|
||||||
render(<MonitoringPage />);
|
|
||||||
// First update
|
|
||||||
act(() => {
|
|
||||||
statusUpdateCallback({ type: 'cond_norms_state_update', norms: [{ id: 'cn1', active: true }] });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Second identical update - strictly checking if this causes a rerender is hard in RTL,
|
|
||||||
// but we ensure no errors and state remains consistent.
|
|
||||||
act(() => {
|
|
||||||
statusUpdateCallback({ type: 'cond_norms_state_update', norms: [{ id: 'cn1', active: true }] });
|
|
||||||
});
|
|
||||||
|
|
||||||
const cnList = screen.getByText('Conditional Norms').parentElement;
|
|
||||||
expect(cnList).toHaveTextContent('✔️');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
import { renderHook, act, cleanup } from '@testing-library/react';
|
|
||||||
import {
|
|
||||||
sendAPICall,
|
|
||||||
nextPhase,
|
|
||||||
resetPhase,
|
|
||||||
pauseExperiment,
|
|
||||||
playExperiment,
|
|
||||||
useExperimentLogger,
|
|
||||||
useStatusLogger
|
|
||||||
} from '../../../src/pages/MonitoringPage/MonitoringPageAPI';
|
|
||||||
|
|
||||||
// --- MOCK EVENT SOURCE SETUP ---
|
|
||||||
// This mocks the browser's EventSource so we can manually 'push' messages to our hooks
|
|
||||||
const mockInstances: MockEventSource[] = [];
|
|
||||||
|
|
||||||
class MockEventSource {
|
|
||||||
url: string;
|
|
||||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
|
||||||
onerror: ((event: Event) => void) | null = null; // Added onerror support
|
|
||||||
closed = false;
|
|
||||||
|
|
||||||
constructor(url: string) {
|
|
||||||
this.url = url;
|
|
||||||
mockInstances.push(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
sendMessage(data: string) {
|
|
||||||
if (this.onmessage) {
|
|
||||||
this.onmessage({ data } as MessageEvent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
triggerError(err: any) {
|
|
||||||
if (this.onerror) {
|
|
||||||
this.onerror(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
close() {
|
|
||||||
this.closed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock global EventSource
|
|
||||||
beforeAll(() => {
|
|
||||||
(globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mock global fetch
|
|
||||||
beforeEach(() => {
|
|
||||||
globalThis.fetch = jest.fn(() =>
|
|
||||||
Promise.resolve({
|
|
||||||
ok: true,
|
|
||||||
json: () => Promise.resolve({ reply: 'ok' }),
|
|
||||||
})
|
|
||||||
) as jest.Mock;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup after every test
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
mockInstances.length = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('MonitoringPageAPI', () => {
|
|
||||||
|
|
||||||
describe('sendAPICall', () => {
|
|
||||||
test('sends correct POST request', async () => {
|
|
||||||
await sendAPICall('test_type', 'test_ctx');
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
'http://localhost:8000/button_pressed',
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ type: 'test_type', context: 'test_ctx' }),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('appends endpoint if provided', async () => {
|
|
||||||
await sendAPICall('t', 'c', '/extra');
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('/button_pressed/extra'),
|
|
||||||
expect.any(Object)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('logs error on fetch network failure', async () => {
|
|
||||||
(globalThis.fetch as jest.Mock).mockRejectedValue('Network error');
|
|
||||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
await sendAPICall('t', 'c');
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to send api call:', 'Network error');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('throws error if response is not ok', async () => {
|
|
||||||
(globalThis.fetch as jest.Mock).mockResolvedValue({ ok: false });
|
|
||||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
|
|
||||||
await sendAPICall('t', 'c');
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('Failed to send api call:', expect.any(Error));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Helper Functions', () => {
|
|
||||||
test('nextPhase sends correct params', async () => {
|
|
||||||
await nextPhase();
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.any(String),
|
|
||||||
expect.objectContaining({ body: JSON.stringify({ type: 'next_phase', context: '' }) })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resetPhase sends correct params', async () => {
|
|
||||||
await resetPhase();
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.any(String),
|
|
||||||
expect.objectContaining({ body: JSON.stringify({ type: 'reset_phase', context: '' }) })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('pauseExperiment sends correct params', async () => {
|
|
||||||
await pauseExperiment();
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.any(String),
|
|
||||||
expect.objectContaining({ body: JSON.stringify({ type: 'pause', context: 'true' }) })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('playExperiment sends correct params', async () => {
|
|
||||||
await playExperiment();
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
|
||||||
expect.any(String),
|
|
||||||
expect.objectContaining({ body: JSON.stringify({ type: 'pause', context: 'false' }) })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useExperimentLogger', () => {
|
|
||||||
test('connects to SSE and receives messages', () => {
|
|
||||||
const onUpdate = jest.fn();
|
|
||||||
|
|
||||||
// Hook must be rendered to start the effect
|
|
||||||
renderHook(() => useExperimentLogger(onUpdate));
|
|
||||||
|
|
||||||
// Retrieve the mocked instance created by the hook
|
|
||||||
const eventSource = mockInstances[0];
|
|
||||||
expect(eventSource.url).toContain('/experiment_stream');
|
|
||||||
|
|
||||||
// Simulate incoming message
|
|
||||||
act(() => {
|
|
||||||
eventSource.sendMessage(JSON.stringify({ type: 'phase_update', id: '1' }));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(onUpdate).toHaveBeenCalledWith({ type: 'phase_update', id: '1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles JSON parse errors in stream', () => {
|
|
||||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
||||||
renderHook(() => useExperimentLogger());
|
|
||||||
const eventSource = mockInstances[0];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
eventSource.sendMessage('invalid-json');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('Stream parse error:', expect.any(Error));
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
test('handles SSE connection error', () => {
|
|
||||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
renderHook(() => useExperimentLogger());
|
|
||||||
const eventSource = mockInstances[0];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
eventSource.triggerError('Connection lost');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('SSE Connection Error:', 'Connection lost');
|
|
||||||
expect(eventSource.closed).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('closes EventSource on unmount', () => {
|
|
||||||
const { unmount } = renderHook(() => useExperimentLogger());
|
|
||||||
const eventSource = mockInstances[0];
|
|
||||||
const closeSpy = jest.spyOn(eventSource, 'close');
|
|
||||||
|
|
||||||
unmount();
|
|
||||||
|
|
||||||
expect(closeSpy).toHaveBeenCalled();
|
|
||||||
expect(eventSource.closed).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('useStatusLogger', () => {
|
|
||||||
test('connects to SSE and receives messages', () => {
|
|
||||||
const onUpdate = jest.fn();
|
|
||||||
renderHook(() => useStatusLogger(onUpdate));
|
|
||||||
const eventSource = mockInstances[0];
|
|
||||||
|
|
||||||
expect(eventSource.url).toContain('/status_stream');
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
eventSource.sendMessage(JSON.stringify({ some: 'data' }));
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(onUpdate).toHaveBeenCalledWith({ some: 'data' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles JSON parse errors', () => {
|
|
||||||
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
||||||
renderHook(() => useStatusLogger());
|
|
||||||
const eventSource = mockInstances[0];
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
eventSource.sendMessage('bad-data');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('Status stream error:', expect.any(Error));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
|
|
||||||
// Corrected Imports
|
|
||||||
import {
|
|
||||||
GestureControls,
|
|
||||||
SpeechPresets,
|
|
||||||
DirectSpeechInput,
|
|
||||||
StatusList,
|
|
||||||
RobotConnected
|
|
||||||
} from '../../../src/pages/MonitoringPage/MonitoringPageComponents';
|
|
||||||
|
|
||||||
import * as MonitoringAPI from '../../../src/pages/MonitoringPage/MonitoringPageAPI';
|
|
||||||
|
|
||||||
// Mock the API Call function with the correct path
|
|
||||||
jest.mock('../../../src/pages/MonitoringPage/MonitoringPageAPI', () => ({
|
|
||||||
sendAPICall: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('MonitoringPageComponents', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GestureControls', () => {
|
|
||||||
test('renders and sends gesture command', () => {
|
|
||||||
render(<GestureControls />);
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole('combobox'), {
|
|
||||||
target: { value: 'animations/Stand/Gestures/Hey_1' }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click button
|
|
||||||
fireEvent.click(screen.getByText('Actuate'));
|
|
||||||
|
|
||||||
// Expect the API to be called with that new value
|
|
||||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('gesture', 'animations/Stand/Gestures/Hey_1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SpeechPresets', () => {
|
|
||||||
test('renders buttons and sends speech command', () => {
|
|
||||||
render(<SpeechPresets />);
|
|
||||||
|
|
||||||
const btn = screen.getByText('"Hello, I\'m Pepper"');
|
|
||||||
fireEvent.click(btn);
|
|
||||||
|
|
||||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', "Hello, I'm Pepper");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DirectSpeechInput', () => {
|
|
||||||
test('inputs text and sends on button click', () => {
|
|
||||||
render(<DirectSpeechInput />);
|
|
||||||
const input = screen.getByPlaceholderText('Type message...');
|
|
||||||
|
|
||||||
fireEvent.change(input, { target: { value: 'Custom text' } });
|
|
||||||
fireEvent.click(screen.getByText('Send'));
|
|
||||||
|
|
||||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', 'Custom text');
|
|
||||||
expect(input).toHaveValue(''); // Should clear
|
|
||||||
});
|
|
||||||
|
|
||||||
test('sends on Enter key', () => {
|
|
||||||
render(<DirectSpeechInput />);
|
|
||||||
const input = screen.getByPlaceholderText('Type message...');
|
|
||||||
|
|
||||||
fireEvent.change(input, { target: { value: 'Enter text' } });
|
|
||||||
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter' });
|
|
||||||
|
|
||||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('speech', 'Enter text');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not send empty text', () => {
|
|
||||||
render(<DirectSpeechInput />);
|
|
||||||
fireEvent.click(screen.getByText('Send'));
|
|
||||||
expect(MonitoringAPI.sendAPICall).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('StatusList', () => {
|
|
||||||
const mockSet = jest.fn();
|
|
||||||
const items = [
|
|
||||||
{ id: '1', name: 'Item 1' },
|
|
||||||
{ id: '2', name: 'Item 2' }
|
|
||||||
];
|
|
||||||
|
|
||||||
test('renders list items', () => {
|
|
||||||
render(<StatusList title="Test List" items={items} type="goal" activeIds={{}} />);
|
|
||||||
expect(screen.getByText('Test List')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Item 1')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Goals: click override on inactive item calls API', () => {
|
|
||||||
render(
|
|
||||||
<StatusList
|
|
||||||
title="Goals"
|
|
||||||
items={items}
|
|
||||||
type="goal"
|
|
||||||
activeIds={{}}
|
|
||||||
setActiveIds={mockSet}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Click the X (inactive)
|
|
||||||
const indicator = screen.getAllByText('❌')[0];
|
|
||||||
fireEvent.click(indicator);
|
|
||||||
|
|
||||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('override', '1');
|
|
||||||
expect(mockSet).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Conditional Norms: click override on ACTIVE item unachieves', () => {
|
|
||||||
render(
|
|
||||||
<StatusList
|
|
||||||
title="CN"
|
|
||||||
items={items}
|
|
||||||
type="cond_norm"
|
|
||||||
activeIds={{ '1': true }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const indicator = screen.getByText('✔️'); // It is active
|
|
||||||
fireEvent.click(indicator);
|
|
||||||
|
|
||||||
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('override_unachieve', '1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Current Goal highlighting', () => {
|
|
||||||
render(
|
|
||||||
<StatusList
|
|
||||||
title="Goals"
|
|
||||||
items={items}
|
|
||||||
type="goal"
|
|
||||||
activeIds={{}}
|
|
||||||
currentGoalIndex={0}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
// Using regex to handle the "(Current)" text
|
|
||||||
expect(screen.getByText(/Item 1/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/(Current)/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('RobotConnected', () => {
|
|
||||||
let mockEventSource: any;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
Object.defineProperty(window, 'EventSource', {
|
|
||||||
writable: true,
|
|
||||||
value: jest.fn().mockImplementation(() => ({
|
|
||||||
close: jest.fn(),
|
|
||||||
onmessage: null,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockEventSource = new window.EventSource('url');
|
|
||||||
(window.EventSource as unknown as jest.Mock).mockClear();
|
|
||||||
(window.EventSource as unknown as jest.Mock).mockImplementation(() => mockEventSource);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('displays disconnected initially', () => {
|
|
||||||
render(<RobotConnected />);
|
|
||||||
expect(screen.getByText('● Robot is disconnected')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('updates to connected when SSE receives true', async () => {
|
|
||||||
render(<RobotConnected />);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
if(mockEventSource.onmessage) {
|
|
||||||
mockEventSource.onmessage({ data: 'true' } as MessageEvent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(await screen.findByText('● Robot is connected')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('handles invalid JSON gracefully', async () => {
|
|
||||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
render(<RobotConnected />);
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
if(mockEventSource.onmessage) {
|
|
||||||
mockEventSource.onmessage({ data: 'invalid-json' } as MessageEvent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should catch error and log it, state remains disconnected
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('Ping message not in correct format:', 'invalid-json');
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('logs error if state update fails (inner catch block)', async () => {
|
|
||||||
const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
||||||
|
|
||||||
// 1. Force useState to return a setter that throws an error
|
|
||||||
const mockThrowingSetter = jest.fn(() => { throw new Error('Forced State Error'); });
|
|
||||||
|
|
||||||
// We use mockImplementation to return [currentState, throwingSetter]
|
|
||||||
const useStateSpy = jest.spyOn(React, 'useState')
|
|
||||||
.mockImplementation(() => [null, mockThrowingSetter]);
|
|
||||||
|
|
||||||
render(<RobotConnected />);
|
|
||||||
|
|
||||||
// 2. Trigger the event with VALID JSON ("true")
|
|
||||||
// This passes the first JSON.parse try/catch,
|
|
||||||
// but fails when calling setConnected(true) because of our mock.
|
|
||||||
await act(async () => {
|
|
||||||
if (mockEventSource.onmessage) {
|
|
||||||
mockEventSource.onmessage({ data: 'true' } as MessageEvent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Verify the specific error log from line 205
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith("couldnt extract connected from incoming ping data");
|
|
||||||
|
|
||||||
// Cleanup spies
|
|
||||||
useStateSpy.mockRestore();
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
|
||||||
import {type Connection, getOutgoers, type Node} from '@xyflow/react';
|
|
||||||
import {ruleResult} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
|
|
||||||
import {BasicBeliefReduce} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx";
|
|
||||||
import {
|
|
||||||
BeliefGlobalReduce, noBeliefCycles,
|
|
||||||
noMatchingLeftRightBelief
|
|
||||||
} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts";
|
|
||||||
import { InferredBeliefReduce } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx";
|
|
||||||
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
|
|
||||||
import * as BasicModule from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode';
|
|
||||||
import * as InferredModule from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx';
|
|
||||||
import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
|
|
||||||
|
|
||||||
|
|
||||||
describe('BeliefGlobalReduce', () => {
|
|
||||||
const nodes: Node[] = [];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('delegates to BasicBeliefReduce for basic_belief nodes', () => {
|
|
||||||
const spy = jest
|
|
||||||
.spyOn(BasicModule, 'BasicBeliefReduce')
|
|
||||||
.mockReturnValue('basic-result' as any);
|
|
||||||
|
|
||||||
const node = { id: '1', type: 'basic_belief' } as Node;
|
|
||||||
|
|
||||||
const result = BeliefGlobalReduce(node, nodes);
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledWith(node, nodes);
|
|
||||||
expect(result).toBe('basic-result');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('delegates to InferredBeliefReduce for inferred_belief nodes', () => {
|
|
||||||
const spy = jest
|
|
||||||
.spyOn(InferredModule, 'InferredBeliefReduce')
|
|
||||||
.mockReturnValue('inferred-result' as any);
|
|
||||||
|
|
||||||
const node = { id: '2', type: 'inferred_belief' } as Node;
|
|
||||||
|
|
||||||
const result = BeliefGlobalReduce(node, nodes);
|
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalledWith(node, nodes);
|
|
||||||
expect(result).toBe('inferred-result');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined for unknown node types', () => {
|
|
||||||
const node = { id: '3', type: 'other' } as Node;
|
|
||||||
|
|
||||||
const result = BeliefGlobalReduce(node, nodes);
|
|
||||||
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
expect(BasicBeliefReduce).not.toHaveBeenCalled();
|
|
||||||
expect(InferredBeliefReduce).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('noMatchingLeftRightBelief rule', () => {
|
|
||||||
let getStateSpy: ReturnType<typeof jest.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
getStateSpy = jest.spyOn(FlowStore.default, 'getState');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is satisfied when target node is not an inferred belief', () => {
|
|
||||||
getStateSpy.mockReturnValue({
|
|
||||||
nodes: [{ id: 't1', type: 'basic_belief' }],
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const result = noMatchingLeftRightBelief(
|
|
||||||
{ source: 's1', target: 't1' } as Connection,
|
|
||||||
null as any
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe(ruleResult.satisfied);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is satisfied when inferred belief has no matching left/right', () => {
|
|
||||||
getStateSpy.mockReturnValue({
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 't1',
|
|
||||||
type: 'inferred_belief',
|
|
||||||
data: {
|
|
||||||
inferredBelief: {
|
|
||||||
left: 'a',
|
|
||||||
right: 'b',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const result = noMatchingLeftRightBelief(
|
|
||||||
{ source: 'c', target: 't1' } as Connection,
|
|
||||||
null as any
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toBe(ruleResult.satisfied);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is NOT satisfied when source matches left input', () => {
|
|
||||||
getStateSpy.mockReturnValue({
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 't1',
|
|
||||||
type: 'inferred_belief',
|
|
||||||
data: {
|
|
||||||
inferredBelief: {
|
|
||||||
left: 's1',
|
|
||||||
right: 's2',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const result = noMatchingLeftRightBelief(
|
|
||||||
{ source: 's1', target: 't1' } as Connection,
|
|
||||||
null as any
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.isSatisfied).toBe(false);
|
|
||||||
if (!(result.isSatisfied)) {
|
|
||||||
expect(result.message).toContain(
|
|
||||||
'Connecting one belief to both input handles of an inferred belief node is not allowed'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is NOT satisfied when source matches right input', () => {
|
|
||||||
getStateSpy.mockReturnValue({
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: 't1',
|
|
||||||
type: 'inferred_belief',
|
|
||||||
data: {
|
|
||||||
inferredBelief: {
|
|
||||||
left: 's1',
|
|
||||||
right: 's2',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const result = noMatchingLeftRightBelief(
|
|
||||||
{ source: 's2', target: 't1' } as Connection,
|
|
||||||
null as any
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.isSatisfied).toBe(false);
|
|
||||||
if (!(result.isSatisfied)) {
|
|
||||||
expect(result.message).toContain(
|
|
||||||
'Connecting one belief to both input handles of an inferred belief node is not allowed'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
jest.mock('@xyflow/react', () => ({
|
|
||||||
getOutgoers: jest.fn(),
|
|
||||||
getConnectedEdges: jest.fn(), // include if some tests require it
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('noBeliefCycles rule', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns notSatisfied when source === target', () => {
|
|
||||||
const result = noBeliefCycles({ source: 'n1', target: 'n1' } as any, null as any);
|
|
||||||
expect(result.isSatisfied).toBe(false);
|
|
||||||
if (!(result.isSatisfied)) {
|
|
||||||
expect(result.message).toContain('Cyclical connection exists');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns satisfied when there are no outgoing inferred beliefs', () => {
|
|
||||||
jest.spyOn(useFlowStore, 'getState').mockReturnValue({
|
|
||||||
nodes: [{ id: 'n1', type: 'inferred_belief' }],
|
|
||||||
edges: [],
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
(getOutgoers as jest.Mock).mockReturnValue([]);
|
|
||||||
|
|
||||||
const result = noBeliefCycles({ source: 'n1', target: 'n2' } as any, null as any);
|
|
||||||
expect(result).toBe(ruleResult.satisfied);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns notSatisfied for direct cycle', () => {
|
|
||||||
jest.spyOn(useFlowStore, 'getState').mockReturnValue({
|
|
||||||
nodes: [
|
|
||||||
{ id: 'n1', type: 'inferred_belief' },
|
|
||||||
{ id: 'n2', type: 'inferred_belief' },
|
|
||||||
],
|
|
||||||
edges: [{ source: 'n2', target: 'n1' }],
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// @ts-expect-error is acting up
|
|
||||||
(getOutgoers as jest.Mock).mockImplementation(({ id }) => {
|
|
||||||
if (id === 'n2') return [{ id: 'n1', type: 'inferred_belief' }];
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = noBeliefCycles({ source: 'n1', target: 'n2' } as any, null as any);
|
|
||||||
expect(result.isSatisfied).toBe(false);
|
|
||||||
if (!(result.isSatisfied)) {
|
|
||||||
expect(result.message).toContain('Cyclical connection exists');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns notSatisfied for indirect cycle', () => {
|
|
||||||
jest.spyOn(useFlowStore, 'getState').mockReturnValue({
|
|
||||||
nodes: [
|
|
||||||
{ id: 'A', type: 'inferred_belief' },
|
|
||||||
{ id: 'B', type: 'inferred_belief' },
|
|
||||||
{ id: 'C', type: 'inferred_belief' },
|
|
||||||
],
|
|
||||||
edges: [
|
|
||||||
{ source: 'A', target: 'B' },
|
|
||||||
{ source: 'B', target: 'C' },
|
|
||||||
{ source: 'C', target: 'A' },
|
|
||||||
],
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// @ts-expect-error is acting up
|
|
||||||
(getOutgoers as jest.Mock).mockImplementation(({ id }) => {
|
|
||||||
const mapping: Record<string, any[]> = {
|
|
||||||
A: [{ id: 'B', type: 'inferred_belief' }],
|
|
||||||
B: [{ id: 'C', type: 'inferred_belief' }],
|
|
||||||
C: [{ id: 'A', type: 'inferred_belief' }],
|
|
||||||
};
|
|
||||||
return mapping[id] || [];
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = noBeliefCycles({ source: 'A', target: 'B' } as any, null as any);
|
|
||||||
expect(result.isSatisfied).toBe(false);
|
|
||||||
if (!(result.isSatisfied)) {
|
|
||||||
expect(result.message).toContain('Cyclical connection exists');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns satisfied when no cycle exists in a multi-node graph', () => {
|
|
||||||
jest.spyOn(useFlowStore, 'getState').mockReturnValue({
|
|
||||||
nodes: [
|
|
||||||
{ id: 'A', type: 'inferred_belief' },
|
|
||||||
{ id: 'B', type: 'inferred_belief' },
|
|
||||||
{ id: 'C', type: 'inferred_belief' },
|
|
||||||
],
|
|
||||||
edges: [
|
|
||||||
{ source: 'A', target: 'B' },
|
|
||||||
{ source: 'B', target: 'C' },
|
|
||||||
],
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// @ts-expect-error is acting up
|
|
||||||
(getOutgoers as jest.Mock).mockImplementation(({ id }) => {
|
|
||||||
const mapping: Record<string, any[]> = {
|
|
||||||
A: [{ id: 'B', type: 'inferred_belief' }],
|
|
||||||
B: [{ id: 'C', type: 'inferred_belief' }],
|
|
||||||
C: [],
|
|
||||||
};
|
|
||||||
return mapping[id] || [];
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = noBeliefCycles({ source: 'A', target: 'B' } as any, null as any);
|
|
||||||
expect(result).toBe(ruleResult.satisfied);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,7 +3,7 @@ import { describe, it, beforeEach } from '@jest/globals';
|
|||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { renderWithProviders } from '../.././/./../../test-utils/test-utils';
|
import { renderWithProviders } from '../.././/./../../test-utils/test-utils';
|
||||||
import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx';
|
import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode';
|
||||||
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';
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
|
||||||
import type {Node, Edge} from '@xyflow/react';
|
|
||||||
import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
|
|
||||||
import {
|
|
||||||
type InferredBelief,
|
|
||||||
InferredBeliefConnectionTarget,
|
|
||||||
InferredBeliefDisconnectionTarget,
|
|
||||||
InferredBeliefReduce,
|
|
||||||
} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx';
|
|
||||||
|
|
||||||
// helper functions
|
|
||||||
function inferredNode(overrides = {}): Node {
|
|
||||||
return {
|
|
||||||
id: 'i1',
|
|
||||||
type: 'inferred_belief',
|
|
||||||
position: {x: 0, y: 0},
|
|
||||||
data: {
|
|
||||||
inferredBelief: {
|
|
||||||
left: undefined,
|
|
||||||
operator: true,
|
|
||||||
right: undefined,
|
|
||||||
},
|
|
||||||
...overrides,
|
|
||||||
},
|
|
||||||
} as Node;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('InferredBelief connection logic', () => {
|
|
||||||
let getStateSpy: ReturnType<typeof jest.spyOn>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
getStateSpy = jest.spyOn(FlowStore.default, 'getState');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets left belief when connected on beliefLeft handle', () => {
|
|
||||||
const node = inferredNode();
|
|
||||||
|
|
||||||
getStateSpy.mockReturnValue({
|
|
||||||
nodes: [{ id: 'b1', type: 'basic_belief' }],
|
|
||||||
edges: [
|
|
||||||
{
|
|
||||||
source: 'b1',
|
|
||||||
target: 'i1',
|
|
||||||
targetHandle: 'beliefLeft',
|
|
||||||
} as Edge,
|
|
||||||
],
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
InferredBeliefConnectionTarget(node, 'b1');
|
|
||||||
|
|
||||||
expect((node.data.inferredBelief as InferredBelief).left).toBe('b1');
|
|
||||||
expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sets right belief when connected on beliefRight handle', () => {
|
|
||||||
const node = inferredNode();
|
|
||||||
|
|
||||||
getStateSpy.mockReturnValue({
|
|
||||||
nodes: [{ id: 'b2', type: 'basic_belief' }],
|
|
||||||
edges: [
|
|
||||||
{
|
|
||||||
source: 'b2',
|
|
||||||
target: 'i1',
|
|
||||||
targetHandle: 'beliefRight',
|
|
||||||
} as Edge,
|
|
||||||
],
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
InferredBeliefConnectionTarget(node, 'b2');
|
|
||||||
|
|
||||||
expect((node.data.inferredBelief as InferredBelief).right).toBe('b2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores connections from unsupported node types', () => {
|
|
||||||
const node = inferredNode();
|
|
||||||
|
|
||||||
getStateSpy.mockReturnValue({
|
|
||||||
nodes: [{ id: 'x', type: 'norm' }],
|
|
||||||
edges: [],
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
InferredBeliefConnectionTarget(node, 'x');
|
|
||||||
|
|
||||||
expect((node.data.inferredBelief as InferredBelief).left).toBeUndefined();
|
|
||||||
expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clears left or right belief on disconnection', () => {
|
|
||||||
const node = inferredNode({
|
|
||||||
inferredBelief: { left: 'a', right: 'b', operator: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
InferredBeliefDisconnectionTarget(node, 'a');
|
|
||||||
expect((node.data.inferredBelief as InferredBelief).left).toBeUndefined();
|
|
||||||
|
|
||||||
InferredBeliefDisconnectionTarget(node, 'b');
|
|
||||||
expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('InferredBeliefReduce', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws if left belief is missing', () => {
|
|
||||||
const node = inferredNode({
|
|
||||||
inferredBelief: { left: 'l', right: 'r', operator: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
InferredBeliefReduce(node, [{ id: 'r' } as Node])
|
|
||||||
).toThrow('No Left belief found');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws if right belief is missing', () => {
|
|
||||||
const node = inferredNode({
|
|
||||||
inferredBelief: { left: 'l', right: 'r', operator: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(() =>
|
|
||||||
InferredBeliefReduce(node, [{ id: 'l' } as Node])
|
|
||||||
).toThrow('No Right Belief found');
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/vi
|
|||||||
import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts';
|
import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts';
|
||||||
import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts';
|
import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts';
|
||||||
import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts';
|
import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts';
|
||||||
import { act } from '@testing-library/react';
|
import { act } from 'react-dom/test-utils';
|
||||||
|
|
||||||
describe('TriggerNode', () => {
|
describe('TriggerNode', () => {
|
||||||
|
|
||||||
@@ -137,6 +137,7 @@ describe('TriggerNode', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
describe('TriggerConnects Function', () => {
|
describe('TriggerConnects Function', () => {
|
||||||
it('should correctly remove a goal from the triggers plan after it has been disconnected', () => {
|
it('should correctly remove a goal from the triggers plan after it has been disconnected', () => {
|
||||||
// first, define the goal node and trigger node.
|
// first, define the goal node and trigger node.
|
||||||
@@ -161,6 +162,7 @@ describe('TriggerNode', () => {
|
|||||||
act(() => {
|
act(() => {
|
||||||
useFlowStore.getState().onConnect({ source: 'g-1', target: 'trigger-1', sourceHandle: null, targetHandle: null });
|
useFlowStore.getState().onConnect({ source: 'g-1', target: 'trigger-1', sourceHandle: null, targetHandle: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// expect the goal id to be part of a goal step of the plan.
|
// expect the goal id to be part of a goal step of the plan.
|
||||||
let updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1');
|
let updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1');
|
||||||
expect(updatedTrigger?.data.plan).toBeDefined();
|
expect(updatedTrigger?.data.plan).toBeDefined();
|
||||||
@@ -179,4 +181,4 @@ describe('TriggerNode', () => {
|
|||||||
expect(stillHas).toBeUndefined();
|
expect(stillHas).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -114,30 +114,3 @@ describe('useProgramStore', () => {
|
|||||||
expect(storedProgram.phases[0]['norms']).toHaveLength(1);
|
expect(storedProgram.phases[0]['norms']).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the names of all phases in the program', () => {
|
|
||||||
// Define a program specifically with names for this test
|
|
||||||
const programWithNames: ReducedProgram = {
|
|
||||||
phases: [
|
|
||||||
{
|
|
||||||
id: 'phase-1',
|
|
||||||
name: 'Introduction Phase', // Assuming the property is 'name'
|
|
||||||
norms: [],
|
|
||||||
goals: [],
|
|
||||||
triggers: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'phase-2',
|
|
||||||
name: 'Execution Phase',
|
|
||||||
norms: [],
|
|
||||||
goals: [],
|
|
||||||
triggers: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
useProgramStore.getState().setProgramState(programWithNames);
|
|
||||||
|
|
||||||
const phaseNames = useProgramStore.getState().getPhaseNames();
|
|
||||||
expect(phaseNames).toEqual(['Introduction Phase', 'Execution Phase']);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user