chore: added tests for monitoringpage

also moved some functions from VisProg outside VisProg.tsx into
VisProgLogic.tsx so I can reuse it for the reset experiment function
of monitor page
Also fixed a small merge error in TriggerNodes.tsx

ref: N25B-400
This commit is contained in:
Pim Hutting
2026-01-18 12:38:23 +01:00
parent ab383d77c4
commit cada85e253
10 changed files with 1159 additions and 311 deletions

View File

@@ -1,201 +1,343 @@
import React from 'react';
import React, { useCallback, useState } from 'react';
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';
import type { GoalNode } from '../VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx';
import type { TriggerNode } from '../VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx';
// Store & API
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
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 getPhaseNames = useProgramStore((s) => s.getPhaseNames);
const getNormsInPhase = useProgramStore((s) => s.getNormsInPhase);
const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase);
const getTriggersInPhase = useProgramStore((s) => s.getTriggersInPhase);
const setProgramState = useProgramStore((state) => state.setProgramState);
// Can be used to block actions until feedback from CB.
const [loading, setLoading] = React.useState(false);
const [activeIds, setActiveIds] = React.useState<Record<string, boolean>>({});
const [goalIndex, setGoalIndex] = React.useState(0);
const [isPlaying, setIsPlaying] = React.useState(false);
const [loading, setLoading] = useState(false);
const [activeIds, setActiveIds] = useState<Record<string, boolean>>({});
const [goalIndex, setGoalIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [phaseIndex, setPhaseIndex] = useState(0);
const [isFinished, setIsFinished] = useState(false);
const phaseIds = getPhaseIds();
const phaseNames = getPhaseNames();
const [phaseIndex, setPhaseIndex] = React.useState(0);
//see if we reached end node
const [isFinished, setIsFinished] = React.useState(false);
// --- Stream Handlers ---
const handleStreamUpdate = React.useCallback((data: ExperimentStreamData) => {
// Check for phase updates
const handleStreamUpdate = useCallback((data: ExperimentStreamData) => {
if (data.type === 'phase_update' && data.id) {
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") {
setIsFinished(true);
} else {
setIsFinished(false);
const allIds = getPhaseIds();
const newIndex = allIds.indexOf(payload.id);
const newIndex = getPhaseIds().indexOf(payload.id);
if (newIndex !== -1) {
setPhaseIndex(newIndex);
setGoalIndex(0);
setPhaseIndex(newIndex);
setGoalIndex(0);
}
}
}
else if (data.type === 'goal_update') {
const payload = data as GoalUpdate;
const currentPhaseGoals = getGoalsInPhase(phaseIds[phaseIndex]) as GoalNode[];
const gIndex = currentPhaseGoals.findIndex((g: GoalNode) => 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);
const payload = data as GoalUpdate;
const currentPhaseGoals = getGoalsInPhase(phaseIds[phaseIndex]) as GoalNode[];
const gIndex = currentPhaseGoals.findIndex((g) => g.id === payload.id);
// All previous goals are set to "active" which means they are achieved
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.`);
}
}
console.log(`${data.type} received, id : ${data.id}`);
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') {
const payload = data as TriggerUpdate;
setActiveIds((prev) => ({
...prev,
[payload.id]: payload.achieved
}));
}
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex]);
const payload = data as TriggerUpdate;
setActiveIds((prev) => ({ ...prev, [payload.id]: payload.achieved }));
}
}, [getPhaseIds, getGoalsInPhase, phaseIds, phaseIndex, phaseNames]);
const handleStatusUpdate = useCallback((data: unknown) => {
const handleStatusUpdate = React.useCallback((data: any) => {
if (data.type === 'cond_norms_state_update') {
const payload = data as CondNormsStateUpdate;
if (payload.type !== 'cond_norms_state_update') return;
setActiveIds((prev) => {
const hasChanges = payload.norms.some(
(normUpdate) => prev[normUpdate.id] !== normUpdate.active
);
if (!hasChanges) {
return prev;
}
const hasChanges = payload.norms.some((u) => prev[u.id] !== u.active);
if (!hasChanges) return prev;
const nextState = { ...prev };
payload.norms.forEach((normUpdate) => {
nextState[normUpdate.id] = normUpdate.active;
});
payload.norms.forEach((u) => { nextState[u.id] = u.active; });
return nextState;
});
}
}, []);
//For incoming phase, goals and trigger updates
}, []);
// Connect listeners
useExperimentLogger(handleStreamUpdate);
//For pings that update conditional norms
useStatusLogger(handleStatusUpdate);
const resetExperiment = React.useCallback(async () => {
try {
setLoading(true);
// --- Actions ---
const phases = graphReducer();
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) => {
const resetExperiment = useCallback(async () => {
try {
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":
setIsPlaying(false);
await pauseExperiment();
break;
case "play":
setIsPlaying(true);
await playExperiment();
break;
case "nextPhase":
await nextPhase();
break;
case "resetExperiment":
await resetExperiment();
break;
default:
// Case for resetPhase if implemented in API
}
} catch (err) {
console.error(err);
} finally {
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 (
<div className={styles.dashboardContainer}>
{/* HEADER */}
@@ -206,117 +348,45 @@ const resetExperiment = React.useCallback(async () => {
{isFinished ? (
<strong>Experiment finished</strong>
) : (
<>
<strong>Phase {phaseIndex + 1}:</strong> {phaseNames[phaseIndex]}
</>
<><strong>Phase {phaseIndex + 1}:</strong> {phaseNames[phaseIndex]}</>
)}
</p>
<div className={styles.phaseProgress}>
{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>
<PhaseProgressBar phaseIds={phaseIds} phaseIndex={phaseIndex} isFinished={isFinished} />
</div>
<div className={styles.experimentControls}>
<h3>Experiment Controls</h3>
<div className={styles.controlsButtons}>
{/*Pause button*/}
<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={() => resetExperiment()}
disabled={loading}
>
</button>
</div>
</div>
<ControlPanel
loading={loading}
isPlaying={isPlaying}
onAction={handleControlAction}
onReset={resetExperiment}
/>
<div className={styles.connectionStatus}>
{RobotConnected()}
<RobotConnected />
</div>
</header>
{/* MAIN GRID */}
<main className={styles.phaseOverview}>
<section className={styles.phaseOverviewText}>
<h3>Phase Overview</h3>
</section>
{isFinished ? (
{isFinished ? (
<div className={styles.finishedMessage}>
<p> All phases have been successfully completed.</p>
<p>All phases have been successfully completed.</p>
</div>
) : (
<>
<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} />
</>
<PhaseDashboard
phaseId={phaseIds[phaseIndex]}
activeIds={activeIds}
setActiveIds={setActiveIds}
goalIndex={goalIndex}
/>
)}
</main>
{/* LOGS */}
{/* LOGS TODO: add actual logs */}
<aside className={styles.logs}>
<h3>Logs</h3>
<div className={styles.logHeader}>

View File

@@ -111,7 +111,7 @@ export function useExperimentLogger(onUpdate?: (data: ExperimentStreamData) => v
* A hook that listens to the status stream that updates active conditional norms
* 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);
React.useEffect(() => {