diff --git a/src/pages/MonitoringPage/MonitoringPageAPI.ts b/src/pages/MonitoringPage/MonitoringPageAPI.ts
index 69c024d..efe6946 100644
--- a/src/pages/MonitoringPage/MonitoringPageAPI.ts
+++ b/src/pages/MonitoringPage/MonitoringPageAPI.ts
@@ -111,7 +111,7 @@ export function useExperimentLogger(onUpdate?: (data: ExperimentStreamData) => v
* A hook that listens to the status stream that updates active conditional norms
* via updates sent from the backend
*/
-export function useStatusLogger(onUpdate?: (data: any) => void) {
+export function useStatusLogger(onUpdate?: (data: ExperimentStreamData) => void) {
const callbackRef = React.useRef(onUpdate);
React.useEffect(() => {
diff --git a/src/pages/VisProgPage/VisProg.tsx b/src/pages/VisProgPage/VisProg.tsx
index 329053c..ee9df14 100644
--- a/src/pages/VisProgPage/VisProg.tsx
+++ b/src/pages/VisProgPage/VisProg.tsx
@@ -9,16 +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 |--
@@ -145,42 +144,6 @@ function VisualProgrammingUI() {
);
}
-// currently outputs the prepared program to the console
-export function runProgramm() {
- const phases = graphReducer();
- const program = {phases}
- console.log(JSON.stringify(program, null, 2));
- fetch(
- "http://localhost:8000/program",
- {
- method: "POST",
- headers: {"Content-Type": "application/json"},
- body: JSON.stringify(program),
- }
- ).then((res) => {
- if (!res.ok) throw new Error("Failed communicating with the backend.")
- console.log("Successfully sent the program to the backend.");
-
- // store reduced program in global program store for further use in the UI
- // when the program was sent to the backend successfully:
- useProgramStore.getState().setProgramState(structuredClone(program));
- }).catch(() => console.log("Failed to send program to the backend."));
- console.log(program);
-}
-
-/**
- * Reduces the graph into its phases' information and recursively calls their reducing function
- */
-export function graphReducer() {
- const { nodes } = useFlowStore.getState();
- return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode [])
- .map((n) => {
- const reducer = NodeReduces['phase'];
- return reducer(n, nodes)
- });
-}
-
-
/**
* houses the entire page, so also UI elements
diff --git a/src/pages/VisProgPage/VisProgLogic.ts b/src/pages/VisProgPage/VisProgLogic.ts
new file mode 100644
index 0000000..69c7f77
--- /dev/null
+++ b/src/pages/VisProgPage/VisProgLogic.ts
@@ -0,0 +1,43 @@
+import useProgramStore from "../../utils/programStore";
+import orderPhaseNodeArray from "../../utils/orderPhaseNodes";
+import useFlowStore from './visualProgrammingUI/VisProgStores';
+import { NodeReduces } from './visualProgrammingUI/NodeRegistry';
+import type { PhaseNode } from "./visualProgrammingUI/nodes/PhaseNode";
+
+/**
+ * Reduces the graph into its phases' information and recursively calls their reducing function
+ */
+export function graphReducer() {
+ const { nodes } = useFlowStore.getState();
+ return orderPhaseNodeArray(nodes.filter((n) => n.type == 'phase') as PhaseNode [])
+ .map((n) => {
+ const reducer = NodeReduces['phase'];
+ return reducer(n, nodes)
+ });
+}
+
+
+/**
+ * Outputs the prepared program to the console and sends it to the backend
+ */
+export function runProgramm() {
+ const phases = graphReducer();
+ const program = {phases}
+ console.log(JSON.stringify(program, null, 2));
+ fetch(
+ "http://localhost:8000/program",
+ {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify(program),
+ }
+ ).then((res) => {
+ if (!res.ok) throw new Error("Failed communicating with the backend.")
+ console.log("Successfully sent the program to the backend.");
+
+ // store reduced program in global program store for further use in the UI
+ // when the program was sent to the backend successfully:
+ useProgramStore.getState().setProgramState(structuredClone(program));
+ }).catch(() => console.log("Failed to send program to the backend."));
+ console.log(program);
+}
\ No newline at end of file
diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx
index ca908c2..ea8d350 100644
--- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx
+++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx
@@ -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'||'inferred_belief') {
+ if (otherNode.type === 'basic_belief'|| otherNode.type ==='inferred_belief') {
data.condition = _sourceNodeId;
}
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
index e69de29..a3a92b8 100644
--- a/test/pages/monitoringPage/MonitoringPageComponents.test.tsx
+++ b/test/pages/monitoringPage/MonitoringPageComponents.test.tsx
@@ -0,0 +1,224 @@
+import React from 'react';
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import '@testing-library/jest-dom';
+
+// Corrected Imports
+import {
+ GestureControls,
+ SpeechPresets,
+ DirectSpeechInput,
+ StatusList,
+ RobotConnected
+} from '../../../src/pages/MonitoringPage/MonitoringPageComponents';
+
+import * as MonitoringAPI from '../../../src/pages/MonitoringPage/MonitoringPageAPI';
+
+// Mock the API Call function with the correct path
+jest.mock('../../../src/pages/MonitoringPage/MonitoringPageAPI', () => ({
+ sendAPICall: jest.fn(),
+}));
+
+describe('MonitoringPageComponents', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('GestureControls', () => {
+ test('renders and sends gesture command', () => {
+ render(
);
+
+ // Change selection
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'animations/Stand/Gestures/Thinking_8' } });
+
+ // Click button
+ fireEvent.click(screen.getByText('Actuate'));
+
+ expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('gesture', 'animations/Stand/Gestures/Thinking_8');
+ });
+ });
+
+ describe('SpeechPresets', () => {
+ test('renders buttons and sends speech command', () => {
+ render(
);
+
+ 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/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx
index 515e7e4..43530a2 100644
--- a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx
+++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx
@@ -137,47 +137,46 @@ describe('TriggerNode', () => {
});
});
-//doesnt work anymore, but I have no idea how to fix it
-// 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.
-// const goal: Node = {
-// id: 'g-1',
-// type: 'goal',
-// position: { x: 0, y: 0 },
-// data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'Goal 1' },
-// };
+ 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.
+ const goal: Node = {
+ id: 'g-1',
+ type: 'goal',
+ position: { x: 0, y: 0 },
+ data: { ...JSON.parse(JSON.stringify(GoalNodeDefaults)), name: 'Goal 1' },
+ };
-// const trigger: Node
= {
-// id: 'trigger-1',
-// type: 'trigger',
-// position: { x: 0, y: 0 },
-// data: { ...JSON.parse(JSON.stringify(TriggerNodeDefaults)) },
-// };
+ const trigger: Node = {
+ id: 'trigger-1',
+ type: 'trigger',
+ position: { x: 0, y: 0 },
+ data: { ...JSON.parse(JSON.stringify(TriggerNodeDefaults)) },
+ };
-// // set initial store
-// useFlowStore.setState({ nodes: [goal, trigger], edges: [] });
+ // set initial store
+ useFlowStore.setState({ nodes: [goal, trigger], edges: [] });
-// // then, connect the goal to the trigger.
-// 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();
-// const plan = updatedTrigger?.data.plan as any;
-// expect(plan.steps.find((s: any) => s.id === 'g-1')).toBeDefined();
+ // then, connect the goal to the trigger.
+ 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();
+ const plan = updatedTrigger?.data.plan as any;
+ expect(plan.steps.find((s: any) => s.id === 'g-1')).toBeDefined();
-// // then, disconnect the goal from the trigger.
-// act(() => {
-// useFlowStore.getState().onEdgesDelete([{ id: 'g-1-trigger-1', source: 'g-1', target: 'trigger-1' } as any]);
-// });
+ // then, disconnect the goal from the trigger.
+ act(() => {
+ useFlowStore.getState().onEdgesDelete([{ id: 'g-1-trigger-1', source: 'g-1', target: 'trigger-1' } as any]);
+ });
-// // finally, expect the goal id to NOT be part of the goal step of the plan.
-// updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1');
-// const planAfter = updatedTrigger?.data.plan as any;
-// const stillHas = planAfter?.steps?.find((s: any) => s.id === 'g-1');
-// expect(stillHas).toBeUndefined();
-// });
-// });
+ // finally, expect the goal id to NOT be part of the goal step of the plan.
+ updatedTrigger = useFlowStore.getState().nodes.find((n) => n.id === 'trigger-1');
+ const planAfter = updatedTrigger?.data.plan as any;
+ const stillHas = planAfter?.steps?.find((s: any) => s.id === 'g-1');
+ 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