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..2ff91bb --- /dev/null +++ b/src/pages/MonitoringPage/MonitoringPage.tsx @@ -0,0 +1,411 @@ +import React, { useCallback, 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); + + const phaseIds = getPhaseIds(); + const phaseNames = getPhaseNames(); + + // --- Stream Handlers --- + + 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}`); + + 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) => { + + 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 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 */} +
+
+

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..6efeab5 --- /dev/null +++ b/src/pages/MonitoringPage/MonitoringPageComponents.tsx @@ -0,0 +1,228 @@ +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 --- +type StatusItem = { + id?: string | number; + achieved?: boolean; + description?: string; + label?: string; + norm?: string; + name?: string; +}; + +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 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 00268eb..8a0003c 100644 --- a/src/pages/VisProgPage/VisProg.module.css +++ b/src/pages/VisProgPage/VisProg.module.css @@ -84,7 +84,10 @@ filter: drop-shadow(0 0 0.25rem plum); } - +.node-inferred_belief { + outline: mediumpurple solid 2pt; + filter: drop-shadow(0 0 0.25rem mediumpurple); +} .draggable-node { padding: 3px 10px; @@ -158,6 +161,14 @@ filter: drop-shadow(0 0 0.25rem plum); } +.draggable-node-inferred_belief { + padding: 3px 10px; + background-color: canvas; + border-radius: 5pt; + outline: mediumpurple solid 2pt; + filter: drop-shadow(0 0 0.25rem mediumpurple); +} + .planNoIterate { opacity: 0.5; font-style: italic; @@ -172,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 a083bbf..ee9df14 100644 --- a/src/pages/VisProgPage/VisProg.tsx +++ b/src/pages/VisProgPage/VisProg.tsx @@ -9,15 +9,15 @@ 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 {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 |-- @@ -143,42 +143,7 @@ function VisualProgrammingUI() { ); } - -// currently outputs the prepared program to the console -function runProgram() { - const phases = graphReducer(); - const program = {phases} - console.log(JSON.stringify(program, null, 2)); - fetch( - "http://localhost:8000/program", - { - method: "POST", - headers: {"Content-Type": "application/json"}, - body: JSON.stringify(program), - } - ).then((res) => { - if (!res.ok) throw new Error("Failed communicating with the backend.") - console.log("Successfully sent the program to the backend."); - - // store reduced program in global program store for further use in the UI - // when the program was sent to the backend successfully: - useProgramStore.getState().setProgramState(structuredClone(program)); - }).catch(() => console.log("Failed to send program to the backend.")); -} - -/** - * Reduces the graph into its phases' information and recursively calls their reducing function - */ -function graphReducer() { - const { nodes } = useFlowStore.getState(); - return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode []) - .map((n) => { - const reducer = NodeReduces['phase']; - return reducer(n, nodes) - }); -} - - + /** * houses the entire page, so also UI elements @@ -186,6 +151,27 @@ function graphReducer() { * @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 ( +
+ + +
+ ); + } + return ( <> 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/HandleRules.ts b/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts index a04282c..aca415e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/HandleRules.ts @@ -43,3 +43,4 @@ export const noSelfConnections : HandleRule = } + diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 54f0241..a4285ec 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -52,15 +52,24 @@ import TriggerNode, { TriggerTooltip } from "./nodes/TriggerNode"; import { TriggerNodeDefaults } from "./nodes/TriggerNode.default"; +import InferredBeliefNode, { + InferredBeliefConnectionTarget, + InferredBeliefConnectionSource, + InferredBeliefDisconnectionTarget, + InferredBeliefDisconnectionSource, + InferredBeliefReduce, InferredBeliefTooltip +} from "./nodes/InferredBeliefNode"; +import { InferredBeliefNodeDefaults } from "./nodes/InferredBeliefNode.default"; import BasicBeliefNode, { BasicBeliefConnectionSource, BasicBeliefConnectionTarget, BasicBeliefDisconnectionSource, BasicBeliefDisconnectionTarget, - BasicBeliefReduce, + BasicBeliefReduce +, BasicBeliefTooltip -} from "./nodes/BasicBeliefNode"; -import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default"; +} from "./nodes/BasicBeliefNode.tsx"; +import { BasicBeliefNodeDefaults } from "./nodes/BasicBeliefNode.default.ts"; /** * Registered node types in the visual programming system. @@ -76,6 +85,7 @@ export const NodeTypes = { goal: GoalNode, trigger: TriggerNode, basic_belief: BasicBeliefNode, + inferred_belief: InferredBeliefNode, }; /** @@ -91,6 +101,7 @@ export const NodeDefaults = { goal: GoalNodeDefaults, trigger: TriggerNodeDefaults, basic_belief: BasicBeliefNodeDefaults, + inferred_belief: InferredBeliefNodeDefaults, }; @@ -108,6 +119,7 @@ export const NodeReduces = { goal: GoalReduce, trigger: TriggerReduce, basic_belief: BasicBeliefReduce, + inferred_belief: InferredBeliefReduce, } @@ -126,6 +138,7 @@ export const NodeConnections = { goal: GoalConnectionTarget, trigger: TriggerConnectionTarget, basic_belief: BasicBeliefConnectionTarget, + inferred_belief: InferredBeliefConnectionTarget, }, Sources: { start: StartConnectionSource, @@ -134,7 +147,8 @@ export const NodeConnections = { norm: NormConnectionSource, goal: GoalConnectionSource, trigger: TriggerConnectionSource, - basic_belief: BasicBeliefConnectionSource + basic_belief: BasicBeliefConnectionSource, + inferred_belief: InferredBeliefConnectionSource, } } @@ -153,6 +167,7 @@ export const NodeDisconnections = { goal: GoalDisconnectionTarget, trigger: TriggerDisconnectionTarget, basic_belief: BasicBeliefDisconnectionTarget, + inferred_belief: InferredBeliefDisconnectionTarget, }, Sources: { start: StartDisconnectionSource, @@ -162,6 +177,7 @@ export const NodeDisconnections = { goal: GoalDisconnectionSource, trigger: TriggerDisconnectionSource, basic_belief: BasicBeliefDisconnectionSource, + inferred_belief: InferredBeliefDisconnectionSource, }, } @@ -186,6 +202,7 @@ export const NodesInPhase = { end: () => false, phase: () => false, basic_belief: () => false, + inferred_belief: () => false, } /** @@ -199,4 +216,5 @@ export const NodeTooltips = { goal: GoalTooltip, trigger: TriggerTooltip, basic_belief: BasicBeliefTooltip, + inferred_belief: InferredBeliefTooltip, } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts index 655aaaa..01f1cfa 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.default.ts @@ -1,4 +1,4 @@ -import type { BasicBeliefNodeData } from "./BasicBeliefNode"; +import type { BasicBeliefNodeData } from "./BasicBeliefNode.tsx"; /** diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx index bed642f..467187d 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx @@ -3,13 +3,13 @@ import { Position, type Node, } from '@xyflow/react'; -import { Toolbar } from '../components/NodeComponents'; +import { Toolbar } from '../components/NodeComponents.tsx'; import styles from '../../VisProg.module.css'; import {MultiConnectionHandle} from "../components/RuleBasedHandle.tsx"; import {allowOnlyConnectionsFromHandle} from "../HandleRules.ts"; -import useFlowStore from '../VisProgStores'; -import { TextField } from '../../../../components/TextField'; -import { MultilineTextField } from '../../../../components/MultilineTextField'; +import useFlowStore from '../VisProgStores.tsx'; +import { TextField } from '../../../../components/TextField.tsx'; +import { MultilineTextField } from '../../../../components/MultilineTextField.tsx'; /** * The default data structure for a BasicBelief node @@ -31,7 +31,7 @@ export type BasicBeliefNodeData = { }; // These are all the types a basic belief could be. -type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion +export type BasicBeliefType = Keyword | Semantic | DetectedObject | Emotion type Keyword = { type: "keyword", id: string, value: string, label: "Keyword said:"}; type Semantic = { type: "semantic", id: string, value: string, description: string, label: "Detected with LLM:"}; type DetectedObject = { type: "object", id: string, value: string, label: "Object found:"}; @@ -189,7 +189,7 @@ export default function BasicBeliefNode(props: NodeProps) { )} diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts new file mode 100644 index 0000000..b92c5b2 --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts @@ -0,0 +1,63 @@ +import {getOutgoers, type Node} from '@xyflow/react'; +import {type HandleRule, type RuleResult, ruleResult} from "../HandleRuleLogic.ts"; +import useFlowStore from "../VisProgStores.tsx"; +import {BasicBeliefReduce} from "./BasicBeliefNode.tsx"; +import {type InferredBeliefNodeData, InferredBeliefReduce} from "./InferredBeliefNode.tsx"; + +export function BeliefGlobalReduce(beliefNode: Node, nodes: Node[]) { + switch (beliefNode.type) { + case 'basic_belief': + return BasicBeliefReduce(beliefNode, nodes); + case 'inferred_belief': + return InferredBeliefReduce(beliefNode, nodes); + } +} + +export const noMatchingLeftRightBelief : HandleRule = (connection, _)=> { + const { nodes } = useFlowStore.getState(); + const thisNode = nodes.find(node => node.id === connection.target && node.type === 'inferred_belief'); + if (!thisNode) return ruleResult.satisfied; + + const iBelief = (thisNode.data as InferredBeliefNodeData).inferredBelief; + return (iBelief.left === connection.source || iBelief.right === connection.source) + ? ruleResult.notSatisfied("Connecting one belief to both input handles of an inferred belief node is not allowed") + : ruleResult.satisfied; +} +/** + * makes it impossible to connect Inferred belief nodes + * if the connection would create a cyclical connection between inferred beliefs + */ +export const noBeliefCycles: HandleRule = (connection, _): RuleResult => { + const {nodes, edges} = useFlowStore.getState(); + const defaultErrorMessage = "Cyclical connection exists between inferred beliefs"; + + /** + * recursively checks for cyclical connections between InferredBelief nodes + * + * to check for a cycle provide the source of an attempted connection as the targetNode for the cycle check, + * the currentNodeId should be initialised with the id of the targetNode of the attempted connection. + * + * @param {string} targetNodeId - the id of the node we are looking for as the endpoint of a cyclical connection + * @param {string} currentNodeId - the id of the node we are checking for outgoing connections to the provided target node + * @returns {RuleResult} + */ + function checkForCycle(targetNodeId: string, currentNodeId: string): RuleResult { + const outgoingBeliefs = getOutgoers({id: currentNodeId}, nodes, edges) + .filter(node => node.type === 'inferred_belief'); + + if (outgoingBeliefs.length === 0) return ruleResult.satisfied; + if (outgoingBeliefs.some(node => node.id === targetNodeId)) return ruleResult + .notSatisfied(defaultErrorMessage); + + const next = outgoingBeliefs.map(node => checkForCycle(targetNodeId, node.id)) + .find(result => !result.isSatisfied); + + return next + ? next + : ruleResult.satisfied; + } + + return connection.source === connection.target + ? ruleResult.notSatisfied(defaultErrorMessage) + : checkForCycle(connection.source, connection.target); +}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts new file mode 100644 index 0000000..71f9f6b --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.default.ts @@ -0,0 +1,16 @@ +import type { InferredBeliefNodeData } from "./InferredBeliefNode.tsx"; + + +/** + * Default data for this node + */ +export const InferredBeliefNodeDefaults: InferredBeliefNodeData = { + label: "Inferred Belief", + droppable: true, + inferredBelief: { + left: undefined, + operator: true, + right: undefined + }, + hasReduce: true, +}; \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css new file mode 100644 index 0000000..2f9b7ae --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.module.css @@ -0,0 +1,80 @@ +.operator-switch { + display: inline-flex; + align-items: center; + gap: 0.5em; + cursor: pointer; + font-family: sans-serif; + /* Change this font-size to scale the whole component */ + font-size: 12px; +} + +/* hide the default checkbox */ +.operator-switch input { + display: none; +} + +/* The Track */ +.switch-visual { + position: relative; + /* height is now 3x the font size */ + height: 3em; + aspect-ratio: 1 / 2; + background-color: ButtonFace; + border-radius: 2em; + transition: 0.2s; +} + +/* The Knob */ +.switch-visual::after { + content: ""; + position: absolute; + top: 0.1em; + left: 0.1em; + width: 1em; + height: 1em; + background: Canvas; + border: 0.175em solid mediumpurple; + border-radius: 50%; + transition: transform 0.2s ease-in-out, border-color 0.2s; +} + +/* Labels */ +.switch-labels { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 3em; /* Matches the track height */ + font-weight: 800; + color: Canvas; + line-height: 1.4; + padding: 0.2em 0; +} + +.operator-switch input:checked + .switch-visual::after { + /* Moves the slider down */ + transform: translateY(1.4em); +} + +/*change the colours to highlight the selected operator*/ +.operator-switch input:checked ~ .switch-labels{ + :first-child { + transition: ease-in-out color 0.2s; + color: ButtonFace; + } + :last-child { + transition: ease-in-out color 0.2s; + color: mediumpurple; + } +} + +.operator-switch input:not(:checked) ~ .switch-labels{ + :first-child { + transition: ease-in-out color 0.2s; + color: mediumpurple; + } + :last-child { + transition: ease-in-out color 0.2s; + color: ButtonFace; + } + +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx new file mode 100644 index 0000000..be5d4ec --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx @@ -0,0 +1,176 @@ +import {getConnectedEdges, type Node, type NodeProps, Position} from '@xyflow/react'; +import {useState} from "react"; +import styles from '../../VisProg.module.css'; +import {Toolbar} from '../components/NodeComponents.tsx'; +import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; +import {allowOnlyConnectionsFromType} from "../HandleRules.ts"; +import useFlowStore from "../VisProgStores.tsx"; +import {BeliefGlobalReduce, noBeliefCycles, noMatchingLeftRightBelief} from "./BeliefGlobals.ts"; +import switchStyles from './InferredBeliefNode.module.css'; + + +/** + * The default data structure for an InferredBelief node + */ +export type InferredBeliefNodeData = { + label: string; + droppable: boolean; + inferredBelief: InferredBelief; + hasReduce: boolean; +}; + +/** + * stores a boolean to represent the operator + * and a left and right BeliefNode (can be both an inferred and a basic belief) + * in the form of their corresponding id's + */ +export type InferredBelief = { + left: string | undefined, + operator: boolean, + right: string | undefined, +} + +export type InferredBeliefNode = Node; + +/** + * This function is called whenever a connection is made with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the received connection + */ +export function InferredBeliefConnectionTarget(_thisNode: Node, _sourceNodeId: string) { + const data = _thisNode.data as InferredBeliefNodeData; + + if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId + && ['basic_belief', 'inferred_belief'].includes(node.type!))) + ) { + const connectedEdges = getConnectedEdges([_thisNode], useFlowStore.getState().edges); + switch(connectedEdges.find(edge => edge.source === _sourceNodeId)?.targetHandle){ + case 'beliefLeft': data.inferredBelief.left = _sourceNodeId; break; + case 'beliefRight': data.inferredBelief.right = _sourceNodeId; break; + } + } +} + +/** + * This function is called whenever a connection is made with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the created connection + */ +export function InferredBeliefConnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +/** + * This function is called whenever a connection is disconnected with this node type as the target + * @param _thisNode the node of this node type which function is called + * @param _sourceNodeId the source of the disconnected connection + */ +export function InferredBeliefDisconnectionTarget(_thisNode: Node, _sourceNodeId: string) { + const data = _thisNode.data as InferredBeliefNodeData; + + if (_sourceNodeId === data.inferredBelief.left) data.inferredBelief.left = undefined; + if (_sourceNodeId === data.inferredBelief.right) data.inferredBelief.right = undefined; +} + +/** + * This function is called whenever a connection is disconnected with this node type as the source + * @param _thisNode the node of this node type which function is called + * @param _targetNodeId the target of the diconnected connection + */ +export function InferredBeliefDisconnectionSource(_thisNode: Node, _targetNodeId: string) { + // no additional connection logic exists yet +} + +export const InferredBeliefTooltip = ` + Combines two beliefs into a single belief using logical inference, + the node can be toggled between using "AND" and "OR" mode for inference`; +/** + * Defines how an InferredBelief node should be rendered + * @param {NodeProps} props - Node properties provided by React Flow, including `id` and `data`. + * @returns The rendered InferredBeliefNode React element. (React.JSX.Element) + */ +export default function InferredBeliefNode(props: NodeProps) { + const data = props.data; + const { updateNodeData } = useFlowStore(); + // start of as an AND operator, true: "AND", false: "OR" + const [enforceAllBeliefs, setEnforceAllBeliefs] = useState(true); + + // used to toggle operator + function onToggle() { + const newOperator = !enforceAllBeliefs; // compute the new value + setEnforceAllBeliefs(newOperator); + + updateNodeData(props.id, { + ...data, + inferredBelief: { + ...data.inferredBelief, + operator: enforceAllBeliefs, + } + }); + } + + return ( + <> + +
+ {/* The checkbox used to toggle the operator between 'AND' and 'OR' */} + + + + {/* outgoing connections */} + + + {/* incoming connections */} + + +
+ + ); +}; + +/** + * Reduces each BasicBelief, including its children down into its core data. + * @param {Node} node - The BasicBelief node to reduce. + * @param {Node[]} nodes - The list of all nodes in the current flow graph. + * @returns A simplified object containing the node label and its list of BasicBeliefs. + */ +export function InferredBeliefReduce(node: Node, nodes: Node[]) { + const data = node.data as InferredBeliefNodeData; + const leftBelief = nodes.find((node) => node.id === data.inferredBelief.left); + const rightBelief = nodes.find((node) => node.id === data.inferredBelief.right); + + if (!leftBelief) { throw new Error("No Left belief found")} + if (!rightBelief) { throw new Error("No Right Belief found")} + + const result: Record = { + id: node.id, + left: BeliefGlobalReduce(leftBelief, nodes), + operator: data.inferredBelief.operator ? "AND" : "OR", + right: BeliefGlobalReduce(rightBelief, nodes), + }; + + return result +} \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 6cde46a..8ee5462 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -9,7 +9,7 @@ import { TextField } from '../../../../components/TextField'; import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleBasedHandle.tsx"; import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts"; import useFlowStore from '../VisProgStores'; -import { BasicBeliefReduce } from './BasicBeliefNode'; +import {BeliefGlobalReduce} from "./BeliefGlobals.ts"; /** * The default data dot a phase node @@ -81,7 +81,7 @@ export default function NormNode(props: NodeProps) { allowOnlyConnectionsFromHandle([{nodeType:"phase",handleId:"data"}]) ]}/> ; @@ -105,11 +105,10 @@ export function NormReduce(node: Node, nodes: Node[]) { }; if (data.condition) { - const reducer = BasicBeliefReduce; // TODO: also add inferred. const conditionNode = nodes.find((node) => node.id === data.condition); // In case something went wrong, and our condition doesn't actually exist; if (conditionNode == undefined) return result; - result["condition"] = reducer(conditionNode, nodes) + result["condition"] = BeliefGlobalReduce(conditionNode, nodes) } return result } @@ -126,7 +125,7 @@ export const NormTooltip = ` export function NormConnectionTarget(_thisNode: Node, _sourceNodeId: string) { const data = _thisNode.data as NormNodeData; // If we got a belief connected, this is the condition for the norm. - if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && node.type === 'basic_belief' /* TODO: Add the option for an inferred belief */))) { + if ((useFlowStore.getState().nodes.find((node) => node.id === _sourceNodeId && ['basic_belief', 'inferred_belief'].includes(node.type!)))) { data.condition = _sourceNodeId; } } diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index 8b3378a..ea8d350 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -9,8 +9,8 @@ import {MultiConnectionHandle, SingleConnectionHandle} from "../components/RuleB import {allowOnlyConnectionsFromHandle, allowOnlyConnectionsFromType} from "../HandleRules.ts"; import useFlowStore from '../VisProgStores'; import {PlanReduce, type Plan } from '../components/Plan'; -import PlanEditorDialog from '../components/PlanEditor'; -import { BasicBeliefReduce } from './BasicBeliefNode'; +import PlanEditorDialog from '../components/PlanEditor'; +import {BeliefGlobalReduce} from "./BeliefGlobals.ts"; import type { GoalNode } from './GoalNode.tsx'; import { defaultPlan } from '../components/Plan.default.ts'; import { deleteGoalInPlanByID, insertGoalInPlan } from '../components/PlanEditingFunctions.tsx'; @@ -72,7 +72,7 @@ export default function TriggerNode(props: NodeProps) { id="TriggerBeliefs" style={{ left: '40%' }} rules={[ - allowOnlyConnectionsFromType(['basic_belief']), + allowOnlyConnectionsFromType(['basic_belief', "inferred_belief"]), ]} /> @@ -102,13 +102,13 @@ export default function TriggerNode(props: NodeProps) { /** * Reduces each Trigger, including its children down into its core data. * @param node - The Trigger node to reduce. - * @param _nodes - The list of all nodes in the current flow graph. + * @param nodes - The list of all nodes in the current flow graph. * @returns A simplified object containing the node label and its list of triggers. */ export function TriggerReduce(node: Node, nodes: Node[]) { const data = node.data as TriggerNodeData; const conditionNode = data.condition ? nodes.find((n)=>n.id===data.condition) : undefined - const conditionData = conditionNode ? BasicBeliefReduce(conditionNode, nodes) : "" + const conditionData = conditionNode ? BeliefGlobalReduce(conditionNode, nodes) : "" return { id: node.id, name: node.data.name, @@ -136,7 +136,7 @@ export function TriggerConnectionTarget(_thisNode: Node, _sourceNodeId: string) const otherNode = nodes.find((x) => x.id === _sourceNodeId) if (!otherNode) return; - if (otherNode.type === 'basic_belief' /* TODO: Add the option for an inferred belief */) { + if (otherNode.type === 'basic_belief'|| otherNode.type ==='inferred_belief') { data.condition = _sourceNodeId; } diff --git a/src/utils/programStore.ts b/src/utils/programStore.ts index e6bcc3a..ef46424 100644 --- a/src/utils/programStore.ts +++ b/src/utils/programStore.ts @@ -15,6 +15,7 @@ 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[]; getTriggersInPhase: (currentPhaseId: string) => Record[]; @@ -43,6 +44,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 */ 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/BeliefGlobals.test.ts b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefGlobals.test.ts new file mode 100644 index 0000000..54cfa7f --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefGlobals.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import {type Connection, getOutgoers, type Node} from '@xyflow/react'; +import {ruleResult} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts"; +import {BasicBeliefReduce} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx"; +import { + BeliefGlobalReduce, noBeliefCycles, + noMatchingLeftRightBelief +} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BeliefGlobals.ts"; +import { InferredBeliefReduce } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx"; +import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx"; +import * as BasicModule from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode'; +import * as InferredModule from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx'; +import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; + + +describe('BeliefGlobalReduce', () => { + const nodes: Node[] = []; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('delegates to BasicBeliefReduce for basic_belief nodes', () => { + const spy = jest + .spyOn(BasicModule, 'BasicBeliefReduce') + .mockReturnValue('basic-result' as any); + + const node = { id: '1', type: 'basic_belief' } as Node; + + const result = BeliefGlobalReduce(node, nodes); + + expect(spy).toHaveBeenCalledWith(node, nodes); + expect(result).toBe('basic-result'); + }); + + it('delegates to InferredBeliefReduce for inferred_belief nodes', () => { + const spy = jest + .spyOn(InferredModule, 'InferredBeliefReduce') + .mockReturnValue('inferred-result' as any); + + const node = { id: '2', type: 'inferred_belief' } as Node; + + const result = BeliefGlobalReduce(node, nodes); + + expect(spy).toHaveBeenCalledWith(node, nodes); + expect(result).toBe('inferred-result'); + }); + + it('returns undefined for unknown node types', () => { + const node = { id: '3', type: 'other' } as Node; + + const result = BeliefGlobalReduce(node, nodes); + + expect(result).toBeUndefined(); + expect(BasicBeliefReduce).not.toHaveBeenCalled(); + expect(InferredBeliefReduce).not.toHaveBeenCalled(); + }); +}); + +describe('noMatchingLeftRightBelief rule', () => { + let getStateSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + getStateSpy = jest.spyOn(FlowStore.default, 'getState'); + }); + + it('is satisfied when target node is not an inferred belief', () => { + getStateSpy.mockReturnValue({ + nodes: [{ id: 't1', type: 'basic_belief' }], + } as any); + + const result = noMatchingLeftRightBelief( + { source: 's1', target: 't1' } as Connection, + null as any + ); + + expect(result).toBe(ruleResult.satisfied); + }); + + it('is satisfied when inferred belief has no matching left/right', () => { + getStateSpy.mockReturnValue({ + nodes: [ + { + id: 't1', + type: 'inferred_belief', + data: { + inferredBelief: { + left: 'a', + right: 'b', + }, + }, + }, + ], + } as any); + + const result = noMatchingLeftRightBelief( + { source: 'c', target: 't1' } as Connection, + null as any + ); + + expect(result).toBe(ruleResult.satisfied); + }); + + it('is NOT satisfied when source matches left input', () => { + getStateSpy.mockReturnValue({ + nodes: [ + { + id: 't1', + type: 'inferred_belief', + data: { + inferredBelief: { + left: 's1', + right: 's2', + }, + }, + }, + ], + } as any); + + const result = noMatchingLeftRightBelief( + { source: 's1', target: 't1' } as Connection, + null as any + ); + + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain( + 'Connecting one belief to both input handles of an inferred belief node is not allowed' + ); + } + }); + + it('is NOT satisfied when source matches right input', () => { + getStateSpy.mockReturnValue({ + nodes: [ + { + id: 't1', + type: 'inferred_belief', + data: { + inferredBelief: { + left: 's1', + right: 's2', + }, + }, + }, + ], + } as any); + + const result = noMatchingLeftRightBelief( + { source: 's2', target: 't1' } as Connection, + null as any + ); + + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain( + 'Connecting one belief to both input handles of an inferred belief node is not allowed' + ); + } + }); +}); + + +jest.mock('@xyflow/react', () => ({ + getOutgoers: jest.fn(), + getConnectedEdges: jest.fn(), // include if some tests require it +})); + +describe('noBeliefCycles rule', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns notSatisfied when source === target', () => { + const result = noBeliefCycles({ source: 'n1', target: 'n1' } as any, null as any); + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain('Cyclical connection exists'); + } + }); + + it('returns satisfied when there are no outgoing inferred beliefs', () => { + jest.spyOn(useFlowStore, 'getState').mockReturnValue({ + nodes: [{ id: 'n1', type: 'inferred_belief' }], + edges: [], + } as any); + + (getOutgoers as jest.Mock).mockReturnValue([]); + + const result = noBeliefCycles({ source: 'n1', target: 'n2' } as any, null as any); + expect(result).toBe(ruleResult.satisfied); + }); + + it('returns notSatisfied for direct cycle', () => { + jest.spyOn(useFlowStore, 'getState').mockReturnValue({ + nodes: [ + { id: 'n1', type: 'inferred_belief' }, + { id: 'n2', type: 'inferred_belief' }, + ], + edges: [{ source: 'n2', target: 'n1' }], + } as any); + + // @ts-expect-error is acting up + (getOutgoers as jest.Mock).mockImplementation(({ id }) => { + if (id === 'n2') return [{ id: 'n1', type: 'inferred_belief' }]; + return []; + }); + + const result = noBeliefCycles({ source: 'n1', target: 'n2' } as any, null as any); + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain('Cyclical connection exists'); + } + }); + + it('returns notSatisfied for indirect cycle', () => { + jest.spyOn(useFlowStore, 'getState').mockReturnValue({ + nodes: [ + { id: 'A', type: 'inferred_belief' }, + { id: 'B', type: 'inferred_belief' }, + { id: 'C', type: 'inferred_belief' }, + ], + edges: [ + { source: 'A', target: 'B' }, + { source: 'B', target: 'C' }, + { source: 'C', target: 'A' }, + ], + } as any); + + // @ts-expect-error is acting up + (getOutgoers as jest.Mock).mockImplementation(({ id }) => { + const mapping: Record = { + A: [{ id: 'B', type: 'inferred_belief' }], + B: [{ id: 'C', type: 'inferred_belief' }], + C: [{ id: 'A', type: 'inferred_belief' }], + }; + return mapping[id] || []; + }); + + const result = noBeliefCycles({ source: 'A', target: 'B' } as any, null as any); + expect(result.isSatisfied).toBe(false); + if (!(result.isSatisfied)) { + expect(result.message).toContain('Cyclical connection exists'); + } + }); + + it('returns satisfied when no cycle exists in a multi-node graph', () => { + jest.spyOn(useFlowStore, 'getState').mockReturnValue({ + nodes: [ + { id: 'A', type: 'inferred_belief' }, + { id: 'B', type: 'inferred_belief' }, + { id: 'C', type: 'inferred_belief' }, + ], + edges: [ + { source: 'A', target: 'B' }, + { source: 'B', target: 'C' }, + ], + } as any); + + // @ts-expect-error is acting up + (getOutgoers as jest.Mock).mockImplementation(({ id }) => { + const mapping: Record = { + A: [{ id: 'B', type: 'inferred_belief' }], + B: [{ id: 'C', type: 'inferred_belief' }], + C: [], + }; + return mapping[id] || []; + }); + + const result = noBeliefCycles({ source: 'A', target: 'B' } as any, null as any); + expect(result).toBe(ruleResult.satisfied); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx index 34872c9..a023769 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/BeliefNode.test.tsx @@ -3,7 +3,7 @@ import { describe, it, beforeEach } from '@jest/globals'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { renderWithProviders } from '../.././/./../../test-utils/test-utils'; -import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode'; +import BasicBeliefNode, { type BasicBeliefNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/BasicBeliefNode.tsx'; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import type { Node } from '@xyflow/react'; import '@testing-library/jest-dom'; diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx new file mode 100644 index 0000000..d683b23 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/InferredBeliefNode.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; +import type {Node, Edge} from '@xyflow/react'; +import * as FlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx'; +import { + type InferredBelief, + InferredBeliefConnectionTarget, + InferredBeliefDisconnectionTarget, + InferredBeliefReduce, +} from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/InferredBeliefNode.tsx'; + +// helper functions +function inferredNode(overrides = {}): Node { + return { + id: 'i1', + type: 'inferred_belief', + position: {x: 0, y: 0}, + data: { + inferredBelief: { + left: undefined, + operator: true, + right: undefined, + }, + ...overrides, + }, + } as Node; +} + +describe('InferredBelief connection logic', () => { + let getStateSpy: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + getStateSpy = jest.spyOn(FlowStore.default, 'getState'); + }); + + it('sets left belief when connected on beliefLeft handle', () => { + const node = inferredNode(); + + getStateSpy.mockReturnValue({ + nodes: [{ id: 'b1', type: 'basic_belief' }], + edges: [ + { + source: 'b1', + target: 'i1', + targetHandle: 'beliefLeft', + } as Edge, + ], + } as any); + + InferredBeliefConnectionTarget(node, 'b1'); + + expect((node.data.inferredBelief as InferredBelief).left).toBe('b1'); + expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined(); + }); + + it('sets right belief when connected on beliefRight handle', () => { + const node = inferredNode(); + + getStateSpy.mockReturnValue({ + nodes: [{ id: 'b2', type: 'basic_belief' }], + edges: [ + { + source: 'b2', + target: 'i1', + targetHandle: 'beliefRight', + } as Edge, + ], + } as any); + + InferredBeliefConnectionTarget(node, 'b2'); + + expect((node.data.inferredBelief as InferredBelief).right).toBe('b2'); + }); + + it('ignores connections from unsupported node types', () => { + const node = inferredNode(); + + getStateSpy.mockReturnValue({ + nodes: [{ id: 'x', type: 'norm' }], + edges: [], + } as any); + + InferredBeliefConnectionTarget(node, 'x'); + + expect((node.data.inferredBelief as InferredBelief).left).toBeUndefined(); + expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined(); + }); + + it('clears left or right belief on disconnection', () => { + const node = inferredNode({ + inferredBelief: { left: 'a', right: 'b', operator: true }, + }); + + InferredBeliefDisconnectionTarget(node, 'a'); + expect((node.data.inferredBelief as InferredBelief).left).toBeUndefined(); + + InferredBeliefDisconnectionTarget(node, 'b'); + expect((node.data.inferredBelief as InferredBelief).right).toBeUndefined(); + }); +}); + +describe('InferredBeliefReduce', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws if left belief is missing', () => { + const node = inferredNode({ + inferredBelief: { left: 'l', right: 'r', operator: true }, + }); + + expect(() => + InferredBeliefReduce(node, [{ id: 'r' } as Node]) + ).toThrow('No Left belief found'); + }); + + it('throws if right belief is missing', () => { + const node = inferredNode({ + inferredBelief: { left: 'l', right: 'r', operator: true }, + }); + + expect(() => + InferredBeliefReduce(node, [{ id: 'l' } as Node]) + ).toThrow('No Right Belief found'); + }); + +}); + + 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