Add experiment logs to the monitoring page #48
@@ -1,201 +1,343 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useState } 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 './MonitoringPageComponents.tsx';
|
|
||||||
import { nextPhase, useExperimentLogger, useStatusLogger, pauseExperiment, playExperiment, type ExperimentStreamData, type GoalUpdate, type TriggerUpdate, type CondNormsStateUpdate, type PhaseUpdate } from ".//MonitoringPageAPI.ts"
|
|
||||||
import { graphReducer, runProgramm } from '../VisProgPage/VisProg.tsx';
|
|
||||||
|
|
||||||
import type { NormNodeData} from '../VisProgPage/visualProgrammingUI/nodes/NormNode.tsx';
|
// Store & API
|
||||||
import type { GoalNode } from '../VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx';
|
import useProgramStore from "../../utils/programStore";
|
||||||
import type { TriggerNode } from '../VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx';
|
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
|
||||||
|
import type { NormNodeData } from '../VisProgPage/visualProgrammingUI/nodes/NormNode';
|
||||||
|
import type { GoalNode } from '../VisProgPage/visualProgrammingUI/nodes/GoalNode';
|
||||||
|
import type { TriggerNode } from '../VisProgPage/visualProgrammingUI/nodes/TriggerNode';
|
||||||
|
|
||||||
|
// Sub-components
|
||||||
|
import {
|
||||||
|
GestureControls,
|
||||||
|
SpeechPresets,
|
||||||
|
DirectSpeechInput,
|
||||||
|
StatusList,
|
||||||
|
RobotConnected
|
||||||
|
} from './MonitoringPageComponents';
|
||||||
|
|
||||||
const MonitoringPage: React.FC = () => {
|
// ----------------------------------------------------------------------
|
||||||
|
// 1. State management
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the state of the active experiment, including phase progression,
|
||||||
|
* goal tracking, and stream event listeners.
|
||||||
|
*/
|
||||||
|
function useExperimentLogic() {
|
||||||
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
|
const getPhaseIds = useProgramStore((s) => s.getPhaseIds);
|
||||||
const getPhaseNames = useProgramStore((s) => s.getPhaseNames);
|
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 getTriggersInPhase = useProgramStore((s) => s.getTriggersInPhase);
|
|
||||||
const setProgramState = useProgramStore((state) => state.setProgramState);
|
const setProgramState = useProgramStore((state) => state.setProgramState);
|
||||||
|
|
||||||
|
|
||||||
// Can be used to block actions until feedback from CB.
|
const [loading, setLoading] = useState(false);
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [activeIds, setActiveIds] = useState<Record<string, boolean>>({});
|
||||||
const [activeIds, setActiveIds] = React.useState<Record<string, boolean>>({});
|
const [goalIndex, setGoalIndex] = useState(0);
|
||||||
const [goalIndex, setGoalIndex] = React.useState(0);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [isPlaying, setIsPlaying] = React.useState(false);
|
const [phaseIndex, setPhaseIndex] = useState(0);
|
||||||
|
const [isFinished, setIsFinished] = useState(false);
|
||||||
|
|
||||||
const phaseIds = getPhaseIds();
|
const phaseIds = getPhaseIds();
|
||||||
const phaseNames = getPhaseNames();
|
const phaseNames = getPhaseNames();
|
||||||
|
|
||||||
const [phaseIndex, setPhaseIndex] = React.useState(0);
|
|
||||||
|
|
||||||
//see if we reached end node
|
// --- Stream Handlers ---
|
||||||
const [isFinished, setIsFinished] = React.useState(false);
|
|
||||||
|
|
||||||
const handleStreamUpdate = React.useCallback((data: ExperimentStreamData) => {
|
const handleStreamUpdate = 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}`)
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 GoalNode[];
|
||||||
const gIndex = currentPhaseGoals.findIndex((g: GoalNode) => g.id === payload.id);
|
const gIndex = currentPhaseGoals.findIndex((g) => g.id === payload.id);
|
||||||
console.log(`${data.type} received, id : ${data.id}`)
|
|
||||||
if (gIndex == -1)
|
|
||||||
{console.log(`goal to update with id ${payload.id} not found in current phase ${phaseNames[phaseIndex]}`)}
|
|
||||||
else {
|
|
||||||
//set current goal to the goal that is just started
|
|
||||||
setGoalIndex(gIndex);
|
|
||||||
|
|
||||||
// All previous goals are set to "active" which means they are achieved
|
console.log(`${data.type} received, id : ${data.id}`);
|
||||||
setActiveIds((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++) {
|
|
||||||
nextState[currentPhaseGoals[i].id ] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextState;
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Now pursuing goal: ${payload.id}. Previous goals marked achieved.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (gIndex === -1) {
|
||||||
|
console.warn(`Goal ${payload.id} not found in phase ${phaseNames[phaseIndex]}`);
|
||||||
|
} else {
|
||||||
|
setGoalIndex(gIndex);
|
||||||
|
// Mark all previous goals as achieved
|
||||||
|
setActiveIds((prev) => {
|
||||||
|
const nextState = { ...prev };
|
||||||
|
for (let i = 0; i < gIndex; i++) {
|
||||||
|
nextState[currentPhaseGoals[i].id] = true;
|
||||||
|
}
|
||||||
|
return nextState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (data.type === 'trigger_update') {
|
else if (data.type === 'trigger_update') {
|
||||||
const payload = data as TriggerUpdate;
|
const payload = data as TriggerUpdate;
|
||||||
setActiveIds((prev) => ({
|
setActiveIds((prev) => ({ ...prev, [payload.id]: payload.achieved }));
|
||||||
...prev,
|
}
|
||||||
[payload.id]: payload.achieved
|
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex, phaseNames]);
|
||||||
}));
|
|
||||||
}
|
const handleStatusUpdate = useCallback((data: unknown) => {
|
||||||
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex]);
|
|
||||||
|
|
||||||
const handleStatusUpdate = React.useCallback((data: any) => {
|
|
||||||
if (data.type === 'cond_norms_state_update') {
|
|
||||||
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(
|
const hasChanges = payload.norms.some((u) => prev[u.id] !== u.active);
|
||||||
(normUpdate) => prev[normUpdate.id] !== normUpdate.active
|
if (!hasChanges) return prev;
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasChanges) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const nextState = { ...prev };
|
const nextState = { ...prev };
|
||||||
payload.norms.forEach((normUpdate) => {
|
payload.norms.forEach((u) => { nextState[u.id] = u.active; });
|
||||||
nextState[normUpdate.id] = normUpdate.active;
|
|
||||||
});
|
|
||||||
return nextState;
|
return nextState;
|
||||||
});
|
});
|
||||||
}
|
}, []);
|
||||||
}, []);
|
|
||||||
//For incoming phase, goals and trigger updates
|
// Connect listeners
|
||||||
useExperimentLogger(handleStreamUpdate);
|
useExperimentLogger(handleStreamUpdate);
|
||||||
//For pings that update conditional norms
|
|
||||||
useStatusLogger(handleStatusUpdate);
|
useStatusLogger(handleStatusUpdate);
|
||||||
|
|
||||||
const resetExperiment = React.useCallback(async () => {
|
// --- Actions ---
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const phases = graphReducer();
|
const resetExperiment = useCallback(async () => {
|
||||||
setProgramState({ phases });
|
|
||||||
|
|
||||||
//reset monitoring page
|
|
||||||
setActiveIds({}); //remove active items
|
|
||||||
setPhaseIndex(0); //Go to first phase
|
|
||||||
setGoalIndex(0); // Reset goal indicator
|
|
||||||
setIsFinished(false); // Reset experiment done
|
|
||||||
|
|
||||||
//inform backend
|
|
||||||
await runProgramm();
|
|
||||||
|
|
||||||
console.log("Experiment & UI successfully reset to start.");
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to reset program:", err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [graphReducer, setProgramState]);
|
|
||||||
|
|
||||||
|
|
||||||
if (phaseIds.length === 0) {
|
|
||||||
return <p className={styles.empty}>No program loaded.</p>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const phaseId = phaseIds[phaseIndex];
|
|
||||||
|
|
||||||
const goals = (getGoalsInPhase(phaseId) as GoalNode[]).map(g => ({
|
|
||||||
...g,
|
|
||||||
achieved: activeIds[g.id] ?? false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const triggers = (getTriggersInPhase(phaseId) as TriggerNode[]).map(t => ({
|
|
||||||
...t,
|
|
||||||
achieved: 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 (NormNodeData &{id: string})[])
|
|
||||||
.filter(n => !!n.condition) // Only items with a condition
|
|
||||||
.map(n => ({
|
|
||||||
...n,
|
|
||||||
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 (button) {
|
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]);
|
||||||
|
|
||||||
|
const handleControlAction = async (action: "pause" | "play" | "nextPhase" | "resetPhase") => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
switch (action) {
|
||||||
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 "resetExperiment":
|
// Case for resetPhase if implemented in API
|
||||||
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 */}
|
||||||
@@ -206,117 +348,45 @@ const resetExperiment = React.useCallback(async () => {
|
|||||||
{isFinished ? (
|
{isFinished ? (
|
||||||
<strong>Experiment finished</strong>
|
<strong>Experiment finished</strong>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<><strong>Phase {phaseIndex + 1}:</strong> {phaseNames[phaseIndex]}</>
|
||||||
<strong>Phase {phaseIndex + 1}:</strong> {phaseNames[phaseIndex]}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.phaseProgress}>
|
<PhaseProgressBar phaseIds={phaseIds} phaseIndex={phaseIndex} isFinished={isFinished} />
|
||||||
{phaseIds.map((id, index) => {
|
|
||||||
// Determine the status of the phase indicator
|
|
||||||
let phaseStatusClass = "";
|
|
||||||
|
|
||||||
if (isFinished) {
|
|
||||||
// If the whole experiment is done, all squares are green (completed)
|
|
||||||
phaseStatusClass = styles.completed;
|
|
||||||
} else if (index < phaseIndex) {
|
|
||||||
// Past phases
|
|
||||||
phaseStatusClass = styles.completed;
|
|
||||||
} else if (index === phaseIndex) {
|
|
||||||
// The current phase being worked on
|
|
||||||
phaseStatusClass = styles.current;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={id}
|
|
||||||
className={`${styles.phase} ${phaseStatusClass}`}
|
|
||||||
>
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.experimentControls}>
|
<ControlPanel
|
||||||
<h3>Experiment Controls</h3>
|
loading={loading}
|
||||||
<div className={styles.controlsButtons}>
|
isPlaying={isPlaying}
|
||||||
{/*Pause button*/}
|
onAction={handleControlAction}
|
||||||
<button
|
onReset={resetExperiment}
|
||||||
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={() => 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
|
||||||
<StatusList title="Goals" items={goals} type="goal" activeIds={activeIds} setActiveIds = {setActiveIds} currentGoalIndex={goalIndex} />
|
phaseId={phaseIds[phaseIndex]}
|
||||||
<StatusList title="Triggers" items={triggers} type="trigger" activeIds={activeIds} />
|
activeIds={activeIds}
|
||||||
<StatusList title="Norms" items={norms} type="norm" activeIds={activeIds} />
|
setActiveIds={setActiveIds}
|
||||||
<StatusList title="Conditional Norms" items={conditionalNorms} type="cond_norm" activeIds={activeIds} />
|
goalIndex={goalIndex}
|
||||||
</>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* LOGS */}
|
{/* LOGS TODO: add actual logs */}
|
||||||
<aside className={styles.logs}>
|
<aside className={styles.logs}>
|
||||||
<h3>Logs</h3>
|
<h3>Logs</h3>
|
||||||
<div className={styles.logHeader}>
|
<div className={styles.logHeader}>
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function useExperimentLogger(onUpdate?: (data: ExperimentStreamData) => v
|
|||||||
* A hook that listens to the status stream that updates active conditional norms
|
* A hook that listens to the status stream that updates active conditional norms
|
||||||
* via updates sent from the backend
|
* via updates sent from the backend
|
||||||
*/
|
*/
|
||||||
export function useStatusLogger(onUpdate?: (data: any) => void) {
|
export function useStatusLogger(onUpdate?: (data: ExperimentStreamData) => void) {
|
||||||
const callbackRef = React.useRef(onUpdate);
|
const callbackRef = React.useRef(onUpdate);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -9,16 +9,15 @@ 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 { NodeReduces, NodeTypes } from './visualProgrammingUI/NodeRegistry.ts';
|
import {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 |--
|
||||||
|
|
||||||
@@ -145,42 +144,6 @@ function VisualProgrammingUI() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// currently outputs the prepared program to the console
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* houses the entire page, so also UI elements
|
* houses the entire page, so also UI elements
|
||||||
|
|||||||
43
src/pages/VisProgPage/VisProgLogic.ts
Normal file
43
src/pages/VisProgPage/VisProgLogic.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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'||'inferred_belief') {
|
if (otherNode.type === 'basic_belief'|| otherNode.type ==='inferred_belief') {
|
||||||
data.condition = _sourceNodeId;
|
data.condition = _sourceNodeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
293
test/pages/monitoringPage/MonitoringPage.test.tsx
Normal file
293
test/pages/monitoringPage/MonitoringPage.test.tsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
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('✔️');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
229
test/pages/monitoringPage/MonitoringPageAPI.test.ts
Normal file
229
test/pages/monitoringPage/MonitoringPageAPI.test.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
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 />);
|
||||||
|
|
||||||
|
// Change selection
|
||||||
|
fireEvent.change(screen.getByRole('combobox'), { target: { value: 'animations/Stand/Gestures/Thinking_8' } });
|
||||||
|
|
||||||
|
// Click button
|
||||||
|
fireEvent.click(screen.getByText('Actuate'));
|
||||||
|
|
||||||
|
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('gesture', 'animations/Stand/Gestures/Thinking_8');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -137,47 +137,46 @@ describe('TriggerNode', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
//doesnt work anymore, but I have no idea how to fix it
|
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.
|
const goal: Node = {
|
||||||
// const goal: Node = {
|
id: 'g-1',
|
||||||
// id: 'g-1',
|
type: 'goal',
|
||||||
// type: 'goal',
|
position: { x: 0, y: 0 },
|
||||||
// position: { x: 0, y: 0 },
|
data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'Goal 1' },
|
||||||
// data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'Goal 1' },
|
};
|
||||||
// };
|
|
||||||
|
|
||||||
// const trigger: Node<TriggerNodeData> = {
|
const trigger: Node<TriggerNodeData> = {
|
||||||
// id: 'trigger-1',
|
id: 'trigger-1',
|
||||||
// type: 'trigger',
|
type: 'trigger',
|
||||||
// position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
// data: { ...JSON.parse(JSON.stringify(TriggerNodeDefaults)) },
|
data: { ...JSON.parse(JSON.stringify(TriggerNodeDefaults)) },
|
||||||
// };
|
};
|
||||||
|
|
||||||
// // set initial store
|
// set initial store
|
||||||
// useFlowStore.setState({ nodes: [goal, trigger], edges: [] });
|
useFlowStore.setState({ nodes: [goal, trigger], edges: [] });
|
||||||
|
|
||||||
// // then, connect the goal to the trigger.
|
// then, connect the goal to the trigger.
|
||||||
// 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();
|
||||||
// const plan = updatedTrigger?.data.plan as any;
|
const plan = updatedTrigger?.data.plan as any;
|
||||||
// expect(plan.steps.find((s: any) => s.id === 'g-1')).toBeDefined();
|
expect(plan.steps.find((s: any) => s.id === 'g-1')).toBeDefined();
|
||||||
|
|
||||||
// // then, disconnect the goal from the trigger.
|
// then, disconnect the goal from the trigger.
|
||||||
// act(() => {
|
act(() => {
|
||||||
// useFlowStore.getState().onEdgesDelete([{ id: 'g-1-trigger-1', source: 'g-1', target: 'trigger-1' } as any]);
|
useFlowStore.getState().onEdgesDelete([{ id: 'g-1-trigger-1', source: 'g-1', target: 'trigger-1' } as any]);
|
||||||
// });
|
});
|
||||||
|
|
||||||
// // finally, expect the goal id to NOT be part of the goal step of the plan.
|
// finally, expect the goal id to NOT be part of the goal step of the plan.
|
||||||
// updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1');
|
updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1');
|
||||||
// const planAfter = updatedTrigger?.data.plan as any;
|
const planAfter = updatedTrigger?.data.plan as any;
|
||||||
// const stillHas = planAfter?.steps?.find((s: any) => s.id === 'g-1');
|
const stillHas = planAfter?.steps?.find((s: any) => s.id === 'g-1');
|
||||||
// expect(stillHas).toBeUndefined();
|
expect(stillHas).toBeUndefined();
|
||||||
// });
|
});
|
||||||
// });
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -113,4 +113,31 @@ describe('useProgramStore', () => {
|
|||||||
// store should NOT change
|
// store should NOT change
|
||||||
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