diff --git a/src/App.tsx b/src/App.tsx index 75d423d..e0576a2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import VisProg from "./pages/VisProgPage/VisProg.tsx"; import {useState} from "react"; import Logging from "./components/Logging/Logging.tsx"; + function App(){ const [showLogs, setShowLogs] = useState(false); diff --git a/src/index.css b/src/index.css index f4e6ffe..7f56d84 100644 --- a/src/index.css +++ b/src/index.css @@ -8,6 +8,9 @@ background-color: #242424; --accent-color: #008080; + --panel-shadow: + 0 1px 2px white, + 0 8px 24px rgba(190, 186, 186, 0.253); font-synthesis: none; text-rendering: optimizeLegibility; @@ -15,6 +18,14 @@ -moz-osx-font-smoothing: grayscale; } +@media (prefers-color-scheme: dark) { + :root { + --panel-shadow: + 0 1px 2px rgba(221, 221, 221, 0.178), + 0 8px 24px rgba(27, 27, 27, 0.507); + } +} + html, body, #root { margin: 0; padding: 0; diff --git a/src/pages/MonitoringPage/MonitoringPage.module.css b/src/pages/MonitoringPage/MonitoringPage.module.css new file mode 100644 index 0000000..5f23eea --- /dev/null +++ b/src/pages/MonitoringPage/MonitoringPage.module.css @@ -0,0 +1,281 @@ +.dashboardContainer { + display: grid; + grid-template-columns: 2fr 1fr; /* Left = content, Right = logs */ + grid-template-rows: auto 1fr auto; /* Header, Main, Footer */ + grid-template-areas: + "header logs" + "main logs" + "footer footer"; + gap: 1rem; + padding: 1rem; + background-color: var(--bg-main); + color: var(--text-main); + font-family: Arial, sans-serif; +} + +/* HEADER */ +.experimentOverview { + grid-area: header; + display: flex; + color: color; + justify-content: space-between; + align-items: flex-start; + background: var(--bg-surface); + color: var(--text-main); + box-shadow: var(--shadow); + padding: 1rem; + box-shadow: var(--panel-shadow); + position: static; /* ensures it scrolls away */ +} + +.phaseProgress { + margin-top: 0.5rem; +} + +.phase { + display: inline-block; + width: 25px; + height: 25px; + margin: 0 3px; + text-align: center; + line-height: 25px; + background: gray; +} + +.completed { + background-color: green; + color: white; +} + +.current { + background-color: rgb(255, 123, 0); + color: white; +} + +.connected { + color: green; + font-weight: bold; +} + +.disconnected { + color: red; + font-weight: bold; +} + +.pausePlayInactive{ + background-color: gray; + color: white; +} + +.pausePlayActive{ + background-color: green; + color: white; +} + +.next { + background-color: #6c757d; + color: white; +} + +.restartPhase{ + background-color: rgb(255, 123, 0); + color: white; +} + +.restartExperiment{ + background-color: red; + color: white; +} + +/* MAIN GRID */ +.phaseOverview { + grid-area: main; + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-template-rows: repeat(2, auto); + gap: 1rem; + background: var(--bg-surface); + color: var(--text-main); + padding: 1rem; + box-shadow: var(--panel-shadow); + +} + +.phaseBox { + background: var(--bg-surface); + border: 1px solid var(--border-color); + box-shadow: var(--panel-shadow); + padding: 1rem; + display: flex; + flex-direction: column; + box-shadow: inset 0 0 3px rgba(0, 0, 0, 0.05); + + height: 250px; +} + +.phaseBox ul { + list-style: none; + padding: 0; + margin: 0; + + overflow-y: auto; + flex-grow: 1; + + +} + +.phaseBox ul::-webkit-scrollbar { + width: 6px; +} + +.phaseBox ul::-webkit-scrollbar-thumb { + background-color: #ccc; + border-radius: 10px; +} + +.phaseOverviewText { + grid-column: 1 / -1; /* make the title span across both columns */ + font-size: 1.4rem; + font-weight: 600; + margin: 0; /* remove default section margin */ + padding: 0.25rem 0; /* smaller internal space */ +} + +.phaseOverviewText h3{ + margin: 0; /* removes top/bottom whitespace */ + padding: 0; /* keeps spacing tight */ +} + +.phaseBox h3 { + margin-top: 0; + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.4rem; +} + +.checked::before { + content: '✔️ '; +} + +.statusIndicator { + display: inline-block; + margin-right: 10px; + user-select: none; + transition: transform 0.1s ease; + font-size: 1.1rem; +} + +.statusIndicator.clickable { + cursor: pointer; +} + +.statusIndicator.clickable:hover { + transform: scale(1.2); +} + +.clickable { + cursor: pointer; +} + +.clickable:hover { + transform: scale(1.2); +} + +.active { + opacity: 1; +} + +.statusItem { + display: flex; + align-items: center; + margin-bottom: 0.4rem; +} + +.itemDescription { + line-height: 1.4; +} + +/* LOGS */ +.logs { + grid-area: logs; + background: var(--bg-surface); + color: var(--text-main); + box-shadow: var(--panel-shadow); + padding: 1rem; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); +} + +.logs textarea { + width: 100%; + height: 83%; + margin-top: 0.5rem; + background-color: Canvas; + color: CanvasText; + border: 1px solid var(--border-color); +} + +.logs button { + background: var(--bg-surface); + box-shadow: var(--panel-shadow); + margin-top: 0.5rem; + margin-left: 0.5rem; +} + +/* FOOTER */ +.controlsSection { + grid-area: footer; + display: flex; + justify-content: space-between; + gap: 1rem; + background: var(--bg-surface); + color: var(--text-main); + box-shadow: var(--panel-shadow); + padding: 1rem; + box-shadow: 0 0 5px rgba(0, 0, 0, 0.1); +} + +.controlsSection button { + background: var(--bg-surface); + box-shadow: var(--panel-shadow); + margin-top: 0.5rem; + margin-left: 0.5rem; +} + +.gestures, +.speech, +.directSpeech { + flex: 1; +} + +.speechInput { + display: flex; + margin-top: 0.5rem; +} + +.speechInput input { + flex: 1; + padding: 0.5rem; + background-color: Canvas; + color: CanvasText; + border: 1px solid var(--border-color); +} + +.speechInput button { + color: white; + border: none; + padding: 0.5rem 1rem; + cursor: pointer; + background-color: Canvas; + color: CanvasText; + border: 1px solid var(--border-color); +} + +/* RESPONSIVE */ +@media (max-width: 900px) { + .phaseOverview { + grid-template-columns: 1fr; + } + + .controlsSection { + flex-direction: column; + } +} diff --git a/src/pages/MonitoringPage/MonitoringPage.tsx b/src/pages/MonitoringPage/MonitoringPage.tsx new file mode 100644 index 0000000..cd7f95e --- /dev/null +++ b/src/pages/MonitoringPage/MonitoringPage.tsx @@ -0,0 +1,441 @@ +import React, { useCallback, useRef, useState } from 'react'; +import styles from './MonitoringPage.module.css'; + +// 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'; + +// ---------------------------------------------------------------------- +// 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 getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase); + const setProgramState = useProgramStore((state) => state.setProgramState); + + 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); + + // Ref to suppress stream updates during the "Reset Phase" fast-forward sequence + const suppressUpdates = useRef(false); + + const phaseIds = getPhaseIds(); + const phaseNames = getPhaseNames(); + + // --- Stream Handlers --- + + const handleStreamUpdate = useCallback((data: ExperimentStreamData) => { + if (suppressUpdates.current) return; + if (data.type === 'phase_update' && data.id) { + const payload = data as PhaseUpdate; + console.log(`${data.type} received, id : ${data.id}`); + + if (payload.id === "end") { + setIsFinished(true); + } else { + setIsFinished(false); + const newIndex = getPhaseIds().indexOf(payload.id); + if (newIndex !== -1) { + 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) => g.id === payload.id); + + 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, phaseNames]); + + const handleStatusUpdate = useCallback((data: unknown) => { + if (suppressUpdates.current) return; + const payload = data as CondNormsStateUpdate; + if (payload.type !== 'cond_norms_state_update') return; + + setActiveIds((prev) => { + const hasChanges = payload.norms.some((u) => prev[u.id] !== u.active); + if (!hasChanges) return prev; + + const nextState = { ...prev }; + payload.norms.forEach((u) => { nextState[u.id] = u.active; }); + return nextState; + }); + }, []); + + // Connect listeners + useExperimentLogger(handleStreamUpdate); + useStatusLogger(handleStatusUpdate); + + // --- Actions --- + + const resetExperiment = useCallback(async () => { + try { + setLoading(true); + const phases = graphReducer(); + setProgramState({ phases }); + + setActiveIds({}); + setPhaseIndex(0); + setGoalIndex(0); + setIsFinished(false); + + await runProgramm(); + console.log("Experiment & UI successfully reset."); + } catch (err) { + console.error("Failed to reset program:", err); + } finally { + setLoading(false); + } + }, [setProgramState]); + + 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 "resetPhase": + //make sure you don't see the phases pass to arrive back at current phase + suppressUpdates.current = true; + + const targetIndex = phaseIndex; + console.log(`Resetting phase: Restarting and skipping to index ${targetIndex}`); + const phases = graphReducer(); + setProgramState({ phases }); + + setActiveIds({}); + setPhaseIndex(0); // Visually reset to start + setGoalIndex(0); + setIsFinished(false); + + // Restart backend + await runProgramm(); + for (let i = 0; i < targetIndex; i++) { + console.log(`Skipping phase ${i}...`); + await nextPhase(); + } + suppressUpdates.current = false; + setPhaseIndex(targetIndex); + setIsPlaying(true); //Maybe you pause and then reset + break; + } + } 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 getGoalsWithDepth = useProgramStore((s) => s.getGoalsWithDepth); + const getTriggers = useProgramStore((s) => s.getTriggersInPhase); + const getNorms = useProgramStore((s) => s.getNormsInPhase); + + // Prepare data view models + const goals = getGoalsWithDepth(phaseId).map((g) => ({ + ...g, + id: g.id as string, + name: g.name as string, + achieved: activeIds[g.id as string] ?? false, + level: g.level, // Pass this new property to the UI + })); + + 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 */} +
+
+

Experiment Overview

+

+ {isFinished ? ( + Experiment finished + ) : ( + <>Phase {phaseIndex + 1}: {phaseNames[phaseIndex]} + )} +

+ +
+ + + +
+ +
+
+ + {/* MAIN GRID */} +
+
+

Phase Overview

+
+ + {isFinished ? ( +
+

All phases have been successfully completed.

+
+ ) : ( + + )} +
+ + {/* LOGS TODO: add actual logs */} + + + {/* FOOTER */} +
+ + + +
+
+ ); +} + +export default MonitoringPage; \ No newline at end of file diff --git a/src/pages/MonitoringPage/MonitoringPageAPI.ts b/src/pages/MonitoringPage/MonitoringPageAPI.ts new file mode 100644 index 0000000..efe6946 --- /dev/null +++ b/src/pages/MonitoringPage/MonitoringPageAPI.ts @@ -0,0 +1,131 @@ +import React, { useEffect } from 'react'; + +const API_BASE = "http://localhost:8000"; +const API_BASE_BP = API_BASE + "/button_pressed"; //UserInterruptAgent endpoint + +/** + * HELPER: Unified sender function + */ +export const sendAPICall = async (type: string, context: string, endpoint?: string) => { + try { + const response = await fetch(`${API_BASE_BP}${endpoint ?? ""}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type, context }), + }); + if (!response.ok) throw new Error("Backend response error"); + console.log(`API Call send - Type: ${type}, Context: ${context} ${endpoint ? `, Endpoint: ${endpoint}` : ""}`); + } catch (err) { + console.error(`Failed to send api call:`, err); + } +}; + + +/** + * Sends an API call to the CB for going to the next phase. + * In case we can't go to the next phase, the function will throw an error. + */ +export async function nextPhase(): Promise { + const type = "next_phase" + const context = "" + sendAPICall(type, context) +} + + +/** + * Sends an API call to the CB for going to reset the currect phase + * In case we can't go to the next phase, the function will throw an error. + */ +export async function resetPhase(): Promise { + const type = "reset_phase" + const context = "" + sendAPICall(type, context) +} + +/** + * Sends an API call to the CB for going to pause experiment +*/ +export async function pauseExperiment(): Promise { + const type = "pause" + const context = "true" + sendAPICall(type, context) +} + +/** + * Sends an API call to the CB for going to resume experiment +*/ +export async function playExperiment(): Promise { + const type = "pause" + const context = "false" + sendAPICall(type, context) +} + + +/** + * Types for the experiment stream messages + */ +export type PhaseUpdate = { type: 'phase_update'; id: string }; +export type GoalUpdate = { type: 'goal_update'; id: string }; +export type TriggerUpdate = { type: 'trigger_update'; id: string; achieved: boolean }; +export type CondNormsStateUpdate = { type: 'cond_norms_state_update'; norms: { id: string; active: boolean }[] }; +export type ExperimentStreamData = PhaseUpdate | GoalUpdate | TriggerUpdate | CondNormsStateUpdate | Record; + +/** + * A hook that listens to the experiment stream that updates current state of the program + * via updates sent from the backend + */ +export function useExperimentLogger(onUpdate?: (data: ExperimentStreamData) => void) { + const callbackRef = React.useRef(onUpdate); + // Ref is updated every time with on update + React.useEffect(() => { + callbackRef.current = onUpdate; + }, [onUpdate]); + + useEffect(() => { + console.log("Connecting to Experiment Stream..."); + const eventSource = new EventSource(`${API_BASE}/experiment_stream`); + + eventSource.onmessage = (event) => { + try { + const parsedData = JSON.parse(event.data) as ExperimentStreamData; + //call function using the ref + callbackRef.current?.(parsedData); + } catch (err) { + console.warn("Stream parse error:", err); + } + }; + + eventSource.onerror = (err) => { + console.error("SSE Connection Error:", err); + eventSource.close(); + }; + + return () => { + console.log("Closing Experiment Stream..."); + eventSource.close(); + }; + }, []); +} + +/** + * A hook that listens to the status stream that updates active conditional norms + * via updates sent from the backend + */ +export function useStatusLogger(onUpdate?: (data: ExperimentStreamData) => void) { + const callbackRef = React.useRef(onUpdate); + + React.useEffect(() => { + callbackRef.current = onUpdate; + }, [onUpdate]); + + useEffect(() => { + const eventSource = new EventSource(`${API_BASE}/status_stream`); + eventSource.onmessage = (event) => { + try { + const parsedData = JSON.parse(event.data); + callbackRef.current?.(parsedData); + } catch (err) { console.warn("Status stream error:", err); } + }; + return () => eventSource.close(); + }, []); +} \ No newline at end of file diff --git a/src/pages/MonitoringPage/MonitoringPageComponents.tsx b/src/pages/MonitoringPage/MonitoringPageComponents.tsx new file mode 100644 index 0000000..d1d2854 --- /dev/null +++ b/src/pages/MonitoringPage/MonitoringPageComponents.tsx @@ -0,0 +1,232 @@ +import React, { useEffect, useState } from 'react'; +import styles from './MonitoringPage.module.css'; +import { sendAPICall } from './MonitoringPageAPI'; + +// --- GESTURE COMPONENT --- +export const GestureControls: React.FC = () => { + const [selectedGesture, setSelectedGesture] = useState("animations/Stand/BodyTalk/Speaking/BodyTalk_1"); + + const gestures = [ + { label: "Wave", value: "animations/Stand/Gestures/Hey_1" }, + { label: "Think", value: "animations/Stand/Emotions/Neutral/Puzzled_1" }, + { label: "Explain", value: "animations/Stand/Gestures/Explain_4" }, + { label: "You", value: "animations/Stand/Gestures/You_1" }, + { label: "Happy", value: "animations/Stand/Emotions/Positive/Happy_1" }, + { label: "Laugh", value: "animations/Stand/Emotions/Positive/Laugh_2" }, + { label: "Lonely", value: "animations/Stand/Emotions/Neutral/Lonely_1" }, + { label: "Suprise", value: "animations/Stand/Emotions/Negative/Surprise_1" }, + { label: "Hurt", value: "animations/Stand/Emotions/Negative/Hurt_2" }, + { label: "Angry", value: "animations/Stand/Emotions/Negative/Angry_4" }, + ]; + return ( +
+

Gestures

+
+ + +
+
+ ); +}; + +// --- PRESET SPEECH COMPONENT --- +export const SpeechPresets: React.FC = () => { + const phrases = [ + { label: "Hello, I'm Pepper", text: "Hello, I'm Pepper" }, + { label: "Repeat please", text: "Could you repeat that please" }, + { label: "About yourself", text: "Tell me something about yourself" }, + ]; + + return ( +
+

Speech Presets

+
    + {phrases.map((phrase, i) => ( +
  • + +
  • + ))} +
+
+ ); +}; + +// --- DIRECT SPEECH (INPUT) COMPONENT --- +export const DirectSpeechInput: React.FC = () => { + const [text, setText] = useState(""); + + const handleSend = () => { + if (!text.trim()) return; + sendAPICall("speech", text); + setText(""); // Clear after sending + }; + + return ( +
+

Direct Pepper Speech

+
+ setText(e.target.value)} + placeholder="Type message..." + onKeyDown={(e) => e.key === 'Enter' && handleSend()} + /> + +
+
+ ); +}; + +// --- interface for goals/triggers/norms/conditional norms --- +export type StatusItem = { + id?: string | number; + achieved?: boolean; + description?: string; + label?: string; + norm?: string; + name?: string; + level?: number; +}; + +interface StatusListProps { + title: string; + items: StatusItem[]; + type: 'goal' | 'trigger' | 'norm'| 'cond_norm'; + activeIds: Record; + setActiveIds?: React.Dispatch>>; + currentGoalIndex?: number; +} + +// --- STATUS LIST COMPONENT --- +export const StatusList: React.FC = ({ + title, + items, + type, + activeIds, + setActiveIds, + currentGoalIndex // Destructure this prop +}) => { + return ( +
+

{title}

+
    + {items.map((item, idx) => { + if (item.id === undefined) return null; + const isActive = !!activeIds[item.id]; + const showIndicator = type !== 'norm'; + const isCurrentGoal = type === 'goal' && idx === currentGoalIndex; + const canOverride = (showIndicator && !isActive) || (type === 'cond_norm' && isActive); + + const indentation = (item.level || 0) * 20; + + const handleOverrideClick = () => { + if (!canOverride) return; + if (type === 'cond_norm' && isActive){ + {/* Unachieve conditional norm */} + sendAPICall("override_unachieve", String(item.id)); + } + else { + if(type === 'goal') + if(setActiveIds) + {setActiveIds(prev => ({ ...prev, [String(item.id)]: true }));} + + sendAPICall("override", String(item.id)); + } + }; + + return ( +
  • + {showIndicator && ( + + {isActive ? "✔️" : "❌"} + + )} + + {item.name || item.norm} + {isCurrentGoal && " (Current)"} + +
  • + ); + })} +
+
+ ); +}; + + +// --- Robot Connected --- +export const RobotConnected = () => { + + /** + * The current connection state: + * - `true`: Robot is connected. + * - `false`: Robot is not connected. + * - `null`: Connection status is unknown (initial check in progress). + */ + const [connected, setConnected] = useState(null); + + useEffect(() => { + // Open a Server-Sent Events (SSE) connection to receive live ping updates. + // We're expecting a stream of data like that looks like this: `data = False` or `data = True` + const eventSource = new EventSource("http://localhost:8000/robot/ping_stream"); + eventSource.onmessage = (event) => { + + // Expecting messages in JSON format: `true` or `false` + //commented out this log as it clutters console logs, but might be useful to debug + //console.log("received message:", event.data); + try { + const data = JSON.parse(event.data); + + try { + setConnected(data) + } + catch { + console.log("couldnt extract connected from incoming ping data") + } + + } catch { + console.log("Ping message not in correct format:", event.data); + } + }; + + // Clean up the SSE connection when the component unmounts. + return () => eventSource.close(); + }, []); + + return ( +
+

Connection:

+

{connected ? "● Robot is connected" : "● Robot is disconnected"}

+
+ ) +} \ No newline at end of file diff --git a/src/pages/SimpleProgram/SimpleProgram.module.css b/src/pages/SimpleProgram/SimpleProgram.module.css new file mode 100644 index 0000000..69cc65c --- /dev/null +++ b/src/pages/SimpleProgram/SimpleProgram.module.css @@ -0,0 +1,167 @@ +/* ---------- Layout ---------- */ + +.container { + height: 100%; + display: flex; + flex-direction: column; + background: #1e1e1e; + color: #f5f5f5; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: clamp(0.75rem, 2vw, 1.25rem); + background: #2a2a2a; + border-bottom: 1px solid #3a3a3a; +} + +.header h2 { + font-size: clamp(1rem, 2.2vw, 1.4rem); + font-weight: 600; +} + +.controls button { + margin-left: 0.5rem; + padding: 0.4rem 0.9rem; + border-radius: 6px; + border: none; + background: #111; + color: white; + cursor: pointer; +} + +.controls button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +/* ---------- Content ---------- */ + +.content { + flex: 1; + padding: 2%; +} + +/* ---------- Grid ---------- */ + +.phaseGrid { + height: 100%; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); + gap: 2%; +} + +/* ---------- Box ---------- */ + +.box { + display: flex; + flex-direction: column; + background: #ffffff; + color: #1e1e1e; + border-radius: 10px; + overflow: hidden; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); +} + +.boxHeader { + padding: 0.6rem 0.9rem; + background: linear-gradient(135deg, #dcdcdc, #e9e9e9); + font-style: italic; + font-weight: 500; + font-size: clamp(0.9rem, 1.5vw, 1.05rem); + border-bottom: 1px solid #cfcfcf; +} + +.boxContent { + flex: 1; + padding: 0.8rem 1rem; + overflow-y: auto; +} + +/* ---------- Lists ---------- */ + +.iconList { + list-style: none; + padding: 0; + margin: 0; +} + +.iconList li { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.5rem; + font-size: clamp(0.85rem, 1.3vw, 1rem); +} + +.bulletList { + margin: 0; + padding-left: 1.2rem; +} + +.bulletList li { + margin-bottom: 0.4rem; +} + +/* ---------- Icons ---------- */ + +.successIcon, +.failIcon { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.5rem; + height: 1.5rem; + border-radius: 4px; + font-weight: bold; + color: white; + flex-shrink: 0; +} + +.successIcon { + background: #3cb371; +} + +.failIcon { + background: #e5533d; +} + +/* ---------- Empty ---------- */ + +.empty { + opacity: 0.55; + font-style: italic; + font-size: 0.9rem; +} + +/* ---------- Responsive ---------- */ + +@media (max-width: 900px) { + .phaseGrid { + grid-template-columns: 1fr; + grid-template-rows: repeat(4, minmax(0, 1fr)); + gap: 1rem; + } +} + +.leftControls { + display: flex; + align-items: center; + gap: 1rem; +} + +.backButton { + background: transparent; + border: 1px solid #555; + color: #ddd; + padding: 0.35rem 0.75rem; + border-radius: 6px; + cursor: pointer; +} + +.backButton:hover { + background: #333; +} diff --git a/src/pages/SimpleProgram/SimpleProgram.tsx b/src/pages/SimpleProgram/SimpleProgram.tsx new file mode 100644 index 0000000..0f63653 --- /dev/null +++ b/src/pages/SimpleProgram/SimpleProgram.tsx @@ -0,0 +1,192 @@ +import React from "react"; +import styles from "./SimpleProgram.module.css"; +import useProgramStore from "../../utils/programStore.ts"; + +/** + * Generic container box with a header and content area. + */ +type BoxProps = { + title: string; + children: React.ReactNode; +}; + +const Box: React.FC = ({ title, children }) => ( +
+
{title}
+
{children}
+
+); + +/** + * Renders a list of goals for a phase. + * Expects goal-like objects from the program store. + */ +const GoalList: React.FC<{ goals: unknown[] }> = ({ goals }) => { + if (!goals.length) { + return

No goals defined.

; + } + + return ( +
    + {goals.map((g, idx) => { + const goal = g as { + id?: string; + description?: string; + achieved?: boolean; + }; + + return ( +
  • + + {goal.achieved ? "✔" : "✖"} + + {goal.description ?? "Unnamed goal"} +
  • + ); + })} +
+ ); +}; + +/** + * Renders a list of triggers for a phase. + */ +const TriggerList: React.FC<{ triggers: unknown[] }> = ({ triggers }) => { + if (!triggers.length) { + return

No triggers defined.

; + } + + return ( +
    + {triggers.map((t, idx) => { + const trigger = t as { + id?: string; + label?: string; + }; + + return ( +
  • + + {trigger.label ?? "Unnamed trigger"} +
  • + ); + })} +
+ ); +}; + +/** + * Renders a list of norms for a phase. + */ +const NormList: React.FC<{ norms: unknown[] }> = ({ norms }) => { + if (!norms.length) { + return

No norms defined.

; + } + + return ( +
    + {norms.map((n, idx) => { + const norm = n as { + id?: string; + norm?: string; + }; + + return
  • {norm.norm ?? "Unnamed norm"}
  • ; + })} +
+ ); +}; + +/** + * Displays all phase-related information in a grid layout. + */ +type PhaseGridProps = { + norms: unknown[]; + goals: unknown[]; + triggers: unknown[]; +}; + +const PhaseGrid: React.FC = ({ + norms, + goals, + triggers, +}) => ( +
+ + + + + + + + + + + + + +

No conditional norms defined.

+
+
+); + +/** + * Main program viewer. + * Reads all data from the program store and allows + * navigating between phases. + */ +const SimpleProgram: React.FC = () => { + const getPhaseIds = useProgramStore((s) => s.getPhaseIds); + const getNormsInPhase = useProgramStore((s) => s.getNormsInPhase); + const getGoalsInPhase = useProgramStore((s) => s.getGoalsInPhase); + const getTriggersInPhase = useProgramStore((s) => s.getTriggersInPhase); + + const phaseIds = getPhaseIds(); + const [phaseIndex, setPhaseIndex] = React.useState(0); + + if (phaseIds.length === 0) { + return

No program loaded.

; + } + + const phaseId = phaseIds[phaseIndex]; + + return ( +
+
+

+ Phase {phaseIndex + 1} / {phaseIds.length} +

+ +
+ + + +
+
+ +
+ +
+
+ ); +}; + +export default SimpleProgram; diff --git a/src/pages/VisProgPage/VisProg.module.css b/src/pages/VisProgPage/VisProg.module.css index 3e099d8..8a0003c 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -183,6 +183,18 @@ left: 60% !important; } +.planNoIterate { + opacity: 0.5; + font-style: italic; + text-decoration: line-through; +} +.backButton { + background: var(--bg-surface); + box-shadow: var(--panel-shadow); + margin-top: 0.5rem; + margin-left: 0.5rem; +} + .node-toolbar-tooltip { background-color: darkgray; color: white; diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx index 3df70fb..933c4ee 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -9,7 +9,6 @@ import { import '@xyflow/react/dist/style.css'; import {type CSSProperties, useEffect, useState} from "react"; import {useShallow} from 'zustand/react/shallow'; -import orderPhaseNodeArray from "../../utils/orderPhaseNodes.ts"; import useProgramStore from "../../utils/programStore.ts"; import {DndToolbar} from './visualProgrammingUI/components/DragDropSidebar.tsx'; import {type EditorWarning, globalWarning} from "./visualProgrammingUI/components/EditorWarnings.tsx"; @@ -18,8 +17,10 @@ import type {PhaseNode} from "./visualProgrammingUI/nodes/PhaseNode.tsx"; import useFlowStore from './visualProgrammingUI/VisProgStores.tsx'; import type {FlowState} from './visualProgrammingUI/VisProgTypes.tsx'; 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 MonitoringPage from '../MonitoringPage/MonitoringPage.tsx'; +import { graphReducer, runProgramm } from './VisProgLogic.ts'; // --| config starting params for flow |-- @@ -232,6 +233,27 @@ const checkPhaseChain = (): boolean => { * @constructor */ function VisProgPage() { + const [showSimpleProgram, setShowSimpleProgram] = useState(false); + const setProgramState = useProgramStore((state) => state.setProgramState); + + const runProgram = () => { + const phases = graphReducer(); // reduce graph + setProgramState({ phases }); // <-- save to store + setShowSimpleProgram(true); // show SimpleProgram + runProgramm(); // send to backend if needed + }; + + if (showSimpleProgram) { + return ( +
+ + +
+ ); + } + const [programValidity, setProgramValidity] = useState(true); const {isProgramValid, severityIndex} = useFlowStore(); diff --git a/src/pages/VisProgPage/VisProgLogic.ts b/src/pages/VisProgPage/VisProgLogic.ts new file mode 100644 index 0000000..69c7f77 --- /dev/null +++ b/src/pages/VisProgPage/VisProgLogic.ts @@ -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); +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index 5348b06..4d1417b 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -113,8 +113,8 @@ export default function BasicBeliefNode(props: NodeProps) { updateNodeData(props.id, {...data, belief: {...data.belief, description: value}}); } - // Use this - const emotionOptions = ["Happy", "Angry", "Sad", "Cheerful"] + // These are the labels outputted by our emotion detection model + const emotionOptions = ["sad", "angry", "surprise", "fear", "happy", "disgust", "neutral"]; let placeholder = "" diff --git a/src/utils/programStore.ts b/src/utils/programStore.ts index e6bcc3a..ac34ef4 100644 --- a/src/utils/programStore.ts +++ b/src/utils/programStore.ts @@ -3,6 +3,8 @@ import {create} from "zustand"; // the type of a reduced program export type ReducedProgram = { phases: Record[] }; +export type GoalWithDepth = Record & { level: number }; + /** * the type definition of the programStore */ @@ -15,8 +17,10 @@ export type ProgramState = { // Utility functions: // to avoid having to manually go through the entire state for every instance where data is required getPhaseIds: () => string[]; + getPhaseNames: () => string[]; getNormsInPhase: (currentPhaseId: string) => Record[]; getGoalsInPhase: (currentPhaseId: string) => Record[]; + getGoalsWithDepth: (currentPhaseId: string) => GoalWithDepth[]; getTriggersInPhase: (currentPhaseId: string) => Record[]; // if more specific utility functions are needed they can be added here: } @@ -43,6 +47,10 @@ const useProgramStore = create((set, get) => ({ * gets the ids of all phases in the program */ getPhaseIds: () => get().currentProgram.phases.map(entry => entry["id"] as string), + /** + * gets the names of all phases in the program + */ + getPhaseNames: () => get().currentProgram.phases.map((entry) => (entry["name"] as string)), /** * gets the norms for the provided phase */ @@ -65,6 +73,51 @@ const useProgramStore = create((set, get) => ({ } throw new Error(`phase with id:"${currentPhaseId}" not found`) }, + + getGoalsWithDepth: (currentPhaseId: string) => { + const program = get().currentProgram; + const phase = program.phases.find(val => val["id"] === currentPhaseId); + + if (!phase) { + throw new Error(`phase with id:"${currentPhaseId}" not found`); + } + + const rootGoals = phase["goals"] as Record[]; + const flatList: GoalWithDepth[] = []; + + // Helper: Define this ONCE, outside the loop + const isGoal = (item: Record) => { + return item["plan"] !== undefined && item["plan"] !== null; + }; + + // Recursive helper function + const traverse = (goals: Record[], depth: number) => { + goals.forEach((goal) => { + // 1. Add the current goal to the list + flatList.push({ ...goal, level: depth }); + + // 2. Check for children + const plan = goal["plan"] as Record | undefined; + + if (plan && Array.isArray(plan["steps"])) { + const steps = plan["steps"] as Record[]; + + // 3. FILTER: Only recurse on steps that are actually goals + // If we just passed 'steps', we might accidentally add Actions/Speeches to the goal list + const childGoals = steps.filter(isGoal); + + if (childGoals.length > 0) { + traverse(childGoals, depth + 1); + } + } + }); + }; + + // Start traversal + traverse(rootGoals, 0); + + return flatList; + }, /** * gets the triggers for the provided phase */ diff --git a/test/pages/monitoringPage/MonitoringPage.test.tsx b/test/pages/monitoringPage/MonitoringPage.test.tsx new file mode 100644 index 0000000..566d668 --- /dev/null +++ b/test/pages/monitoringPage/MonitoringPage.test.tsx @@ -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: () =>
Robot Status
, + }; +}); + +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(); + expect(screen.getByText('No program loaded.')).toBeInTheDocument(); + }); + + test('renders the dashboard with initial state', () => { + render(); + + // 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(); + 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(); + const playBtn = screen.getByText('▶'); + + await act(async () => { + fireEvent.click(playBtn); + }); + + expect(MonitoringAPI.playExperiment).toHaveBeenCalled(); + }); + + test('Next Phase calls API', async () => { + render(); + await act(async () => { + fireEvent.click(screen.getByText('⏭')); + }); + expect(MonitoringAPI.nextPhase).toHaveBeenCalled(); + }); + + test('Reset Experiment calls logic and resets state', async () => { + render(); + + // 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(); + 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(); + + 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(); + + 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(); + 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(); + + // 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(); + 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(); + + // 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(); + + // 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(); + // 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('✔️'); + }); + }); +}); + diff --git a/test/pages/monitoringPage/MonitoringPageAPI.test.ts b/test/pages/monitoringPage/MonitoringPageAPI.test.ts new file mode 100644 index 0000000..b48681a --- /dev/null +++ b/test/pages/monitoringPage/MonitoringPageAPI.test.ts @@ -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)); + }); + }); +}); \ No newline at end of file diff --git a/test/pages/monitoringPage/MonitoringPageComponents.test.tsx b/test/pages/monitoringPage/MonitoringPageComponents.test.tsx new file mode 100644 index 0000000..f454fe1 --- /dev/null +++ b/test/pages/monitoringPage/MonitoringPageComponents.test.tsx @@ -0,0 +1,226 @@ +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(); + + fireEvent.change(screen.getByRole('combobox'), { + target: { value: 'animations/Stand/Gestures/Hey_1' } + }); + + // Click button + fireEvent.click(screen.getByText('Actuate')); + + // Expect the API to be called with that new value + expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('gesture', 'animations/Stand/Gestures/Hey_1'); + }); + }); + + describe('SpeechPresets', () => { + test('renders buttons and sends speech command', () => { + render(); + + 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(); + 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(); + 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(); + 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(); + expect(screen.getByText('Test List')).toBeInTheDocument(); + expect(screen.getByText('Item 1')).toBeInTheDocument(); + }); + + test('Goals: click override on inactive item calls API', () => { + render( + + ); + + // 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( + + ); + + const indicator = screen.getByText('✔️'); // It is active + fireEvent.click(indicator); + + expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('override_unachieve', '1'); + }); + + test('Current Goal highlighting', () => { + render( + + ); + // 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(); + expect(screen.getByText('● Robot is disconnected')).toBeInTheDocument(); + }); + + test('updates to connected when SSE receives true', async () => { + render(); + + 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(); + + 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(); + + // 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(); + }); + }); +}); diff --git a/test/pages/simpleProgram/SimpleProgram.tsx b/test/pages/simpleProgram/SimpleProgram.tsx new file mode 100644 index 0000000..22fcbbf --- /dev/null +++ b/test/pages/simpleProgram/SimpleProgram.tsx @@ -0,0 +1,83 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import SimpleProgram from "../../../src/pages/SimpleProgram/SimpleProgram"; +import useProgramStore from "../../../src/utils/programStore"; + +/** + * Helper to preload the program store before rendering. + */ +function loadProgram(phases: Record[]) { + useProgramStore.getState().setProgramState({ phases }); +} + +describe("SimpleProgram", () => { + beforeEach(() => { + loadProgram([]); + }); + + test("shows empty state when no program is loaded", () => { + render(); + expect(screen.getByText("No program loaded.")).toBeInTheDocument(); + }); + + test("renders first phase content", () => { + loadProgram([ + { + id: "phase-1", + norms: [{ id: "n1", norm: "Be polite" }], + goals: [{ id: "g1", description: "Finish task", achieved: true }], + triggers: [{ id: "t1", label: "Keyword trigger" }], + }, + ]); + + render(); + + expect(screen.getByText("Phase 1 / 1")).toBeInTheDocument(); + expect(screen.getByText("Be polite")).toBeInTheDocument(); + expect(screen.getByText("Finish task")).toBeInTheDocument(); + expect(screen.getByText("Keyword trigger")).toBeInTheDocument(); + }); + + test("allows navigating between phases", () => { + loadProgram([ + { + id: "phase-1", + norms: [], + goals: [], + triggers: [], + }, + { + id: "phase-2", + norms: [{ id: "n2", norm: "Be careful" }], + goals: [], + triggers: [], + }, + ]); + + render(); + + expect(screen.getByText("Phase 1 / 2")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Next ▶")); + + expect(screen.getByText("Phase 2 / 2")).toBeInTheDocument(); + expect(screen.getByText("Be careful")).toBeInTheDocument(); + }); + + test("prev button is disabled on first phase", () => { + loadProgram([ + { id: "phase-1", norms: [], goals: [], triggers: [] }, + ]); + + render(); + expect(screen.getByText("◀ Prev")).toBeDisabled(); + }); + + test("next button is disabled on last phase", () => { + loadProgram([ + { id: "phase-1", norms: [], goals: [], triggers: [] }, + ]); + + render(); + expect(screen.getByText("Next ▶")).toBeDisabled(); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx index 83bcf34..43530a2 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -14,7 +14,7 @@ import { BasicBeliefNodeDefaults } from '../../../../../src/pages/VisProgPage/vi import { defaultPlan } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/components/Plan.default.ts'; import { NormNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.default.ts'; import { GoalNodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.default.ts'; -import { act } from 'react-dom/test-utils'; +import { act } from '@testing-library/react'; describe('TriggerNode', () => { @@ -137,7 +137,6 @@ describe('TriggerNode', () => { }); }); - describe('TriggerConnects Function', () => { it('should correctly remove a goal from the triggers plan after it has been disconnected', () => { // first, define the goal node and trigger node. @@ -162,7 +161,6 @@ describe('TriggerNode', () => { act(() => { 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. let updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1'); expect(updatedTrigger?.data.plan).toBeDefined(); @@ -181,4 +179,4 @@ describe('TriggerNode', () => { expect(stillHas).toBeUndefined(); }); }); -}); + }); diff --git a/test/utils/programStore.test.ts b/test/utils/programStore.test.ts index ba78b88..422061b 100644 --- a/test/utils/programStore.test.ts +++ b/test/utils/programStore.test.ts @@ -113,4 +113,31 @@ describe('useProgramStore', () => { // store should NOT change expect(storedProgram.phases[0]['norms']).toHaveLength(1); }); -}); \ No newline at end of file +}); + +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']); + }); \ No newline at end of file