From cada85e253bf31061e384fe6d19933e0a24e09b9 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Sun, 18 Jan 2026 12:38:23 +0100 Subject: [PATCH] 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 --- src/pages/MonitoringPage/MonitoringPage.tsx | 532 ++++++++++-------- src/pages/MonitoringPage/MonitoringPageAPI.ts | 2 +- src/pages/VisProgPage/VisProg.tsx | 41 +- src/pages/VisProgPage/VisProgLogic.ts | 43 ++ .../visualProgrammingUI/nodes/TriggerNode.tsx | 2 +- .../monitoringPage/MonitoringPage.test.tsx | 293 ++++++++++ .../monitoringPage/MonitoringPageAPI.test.ts | 229 ++++++++ .../MonitoringPageComponents.test.tsx | 224 ++++++++ .../nodes/TriggerNode.test.tsx | 75 ++- test/utils/programStore.test.ts | 29 +- 10 files changed, 1159 insertions(+), 311 deletions(-) create mode 100644 src/pages/VisProgPage/VisProgLogic.ts create mode 100644 test/pages/monitoringPage/MonitoringPage.test.tsx create mode 100644 test/pages/monitoringPage/MonitoringPageAPI.test.ts diff --git a/src/pages/MonitoringPage/MonitoringPage.tsx b/src/pages/MonitoringPage/MonitoringPage.tsx index 640f7df..2ff91bb 100644 --- a/src/pages/MonitoringPage/MonitoringPage.tsx +++ b/src/pages/MonitoringPage/MonitoringPage.tsx @@ -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>({}); - const [goalIndex, setGoalIndex] = React.useState(0); - const [isPlaying, setIsPlaying] = React.useState(false); + const [loading, setLoading] = useState(false); + const [activeIds, setActiveIds] = useState>({}); + 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

No program loaded.

; - } - - 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 ( +
+ {phaseIds.map((id, index) => { + let statusClass = ""; + if (isFinished || index < phaseIndex) statusClass = styles.completed; + else if (index === phaseIndex) statusClass = styles.current; + + return ( + + {index + 1} + + ); + })} +
+ ); +} + +/** + * 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 ( +
+

Experiment Controls

+
+ + + + + + + + + +
+
+ ); +} + +/** + * Displays lists of Goals, Triggers, and Norms for the current phase. + */ +function PhaseDashboard({ + phaseId, + activeIds, + setActiveIds, + goalIndex +}: { + phaseId: string, + activeIds: Record, + setActiveIds: React.Dispatch>>, + 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 ( + <> + + + + + + ); +} + +// ---------------------------------------------------------------------- +// 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

No program loaded.

; } + return (
{/* HEADER */} @@ -206,117 +348,45 @@ const resetExperiment = React.useCallback(async () => { {isFinished ? ( Experiment finished ) : ( - <> - Phase {phaseIndex + 1}: {phaseNames[phaseIndex]} - + <>Phase {phaseIndex + 1}: {phaseNames[phaseIndex]} )}

-
- {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 ( - - {index + 1} - - ); - })} -
+
-
-

Experiment Controls

-
- {/*Pause button*/} - - - {/*Play button*/} - - - {/*Next button*/} - - - {/*Restart Phase button*/} - - - {/*Restart Experiment button*/} - -
-
+
- {RobotConnected()} +
{/* MAIN GRID */} -

Phase Overview

- {isFinished ? ( + + {isFinished ? (
-

All phases have been successfully completed.

+

All phases have been successfully completed.

) : ( - <> - - - - - + )}
- {/* LOGS */} + {/* LOGS TODO: add actual logs */}