feat: The Big One UI

This commit is contained in:
Gerla, J. (Justin)
2026-01-28 08:27:30 +00:00
committed by Pim Hutting
parent f9e0eb95f8
commit 82785dc8cb
45 changed files with 4147 additions and 143 deletions

View File

@@ -0,0 +1,299 @@
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: () => <div data-testid="robot-connected-mock">Robot Status</div>,
};
});
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 mockGetGoalsWithDepth = 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,
getGoalsWithDepth: mockGetGoalsWithDepth,
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'}]);
mockGetGoalsWithDepth.mockReturnValue([
{ id: 'g1', name: 'Goal 1', level: 0 },
{ id: 'g2', name: 'Goal 2', level: 0 }
]);
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(<MonitoringPage />);
expect(screen.getByText('No program loaded.')).toBeInTheDocument();
});
test('renders the dashboard with initial state', () => {
render(<MonitoringPage />);
// 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(<MonitoringPage />);
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(<MonitoringPage />);
const playBtn = screen.getByText('▶');
await act(async () => {
fireEvent.click(playBtn);
});
expect(MonitoringAPI.playExperiment).toHaveBeenCalled();
});
test('Next Phase calls API', async () => {
render(<MonitoringPage />);
await act(async () => {
fireEvent.click(screen.getByText('⏭'));
});
expect(MonitoringAPI.nextPhase).toHaveBeenCalled();
});
test('Reset Experiment calls logic and resets state', async () => {
render(<MonitoringPage />);
// 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(<MonitoringPage />);
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(<MonitoringPage />);
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(<MonitoringPage />);
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(<MonitoringPage />);
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(<MonitoringPage />);
// 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(<MonitoringPage />);
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(<MonitoringPage />);
// 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(<MonitoringPage />);
// 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(<MonitoringPage />);
// 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('✔️');
});
});
});

View File

@@ -0,0 +1,220 @@
import { renderHook, act, cleanup } from '@testing-library/react';
import {
sendAPICall,
nextPhase,
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('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));
});
});
});

View File

@@ -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(<GestureControls />);
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(<SpeechPresets />);
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(<DirectSpeechInput />);
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(<DirectSpeechInput />);
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(<DirectSpeechInput />);
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(<StatusList title="Test List" items={items} type="goal" activeIds={{}} />);
expect(screen.getByText('Test List')).toBeInTheDocument();
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
test('Goals: click override on inactive item calls API', () => {
render(
<StatusList
title="Goals"
items={items}
type="goal"
activeIds={{}}
setActiveIds={mockSet}
/>
);
// 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(
<StatusList
title="CN"
items={items}
type="cond_norm"
activeIds={{ '1': true }}
/>
);
const indicator = screen.getByText('✔️'); // It is active
fireEvent.click(indicator);
expect(MonitoringAPI.sendAPICall).toHaveBeenCalledWith('override_unachieve', '1');
});
test('Current Goal highlighting', () => {
render(
<StatusList
title="Goals"
items={items}
type="goal"
activeIds={{}}
currentGoalIndex={0}
/>
);
// 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(<RobotConnected />);
expect(screen.getByText('● Robot is disconnected')).toBeInTheDocument();
});
test('updates to connected when SSE receives true', async () => {
render(<RobotConnected />);
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(<RobotConnected />);
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(<RobotConnected />);
// 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();
});
});
});

View File

@@ -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<string, unknown>[]) {
useProgramStore.getState().setProgramState({ phases });
}
describe("SimpleProgram", () => {
beforeEach(() => {
loadProgram([]);
});
test("shows empty state when no program is loaded", () => {
render(<SimpleProgram />);
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(<SimpleProgram />);
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(<SimpleProgram />);
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(<SimpleProgram />);
expect(screen.getByText("◀ Prev")).toBeDisabled();
});
test("next button is disabled on last phase", () => {
loadProgram([
{ id: "phase-1", norms: [], goals: [], triggers: [] },
]);
render(<SimpleProgram />);
expect(screen.getByText("Next ▶")).toBeDisabled();
});
});

View File

@@ -34,10 +34,17 @@ describe("UndoRedo Middleware", () => {
type: 'default',
position: {x: 0, y: 0},
data: {label: 'A'}
},
}
],
edges: []
edges: [],
warnings: {
warningRegistry: new Map(),
severityIndex: new Map()
}
}],
ruleRegistry: new Map(),
editorWarningRegistry: new Map(),
severityIndex: new Map()
});
act(() => {
@@ -53,7 +60,11 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: []
edges: [],
warnings: {
warningRegistry: {},
severityIndex: {}
}
});
expect(state.future).toEqual([]);
});
@@ -80,7 +91,9 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: []
edges: [],
editorWarningRegistry: new Map(),
severityIndex: new Map()
});
act(() => {
@@ -114,7 +127,11 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'B'}
}],
edges: []
edges: [],
warnings: {
warningRegistry: {},
severityIndex: {}
}
});
});
@@ -140,7 +157,9 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: []
edges: [],
editorWarningRegistry: new Map(),
severityIndex: new Map()
});
act(() => {
@@ -176,7 +195,11 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: []
edges: [],
warnings: {
warningRegistry: {},
severityIndex: {}
}
});
});
@@ -199,7 +222,9 @@ describe("UndoRedo Middleware", () => {
position: {x: 0, y: 0},
data: {label: 'A'}
}],
edges: []
edges: [],
editorWarningRegistry: new Map(),
severityIndex: new Map()
});
act(() => { store.getState().beginBatchAction(); });

View File

@@ -1,5 +1,9 @@
import {act} from '@testing-library/react';
import type {Connection, Edge, Node} from "@xyflow/react";
import {
type Connection,
type Edge,
type Node,
} from "@xyflow/react";
import type {HandleRule, RuleResult} from "../../../../src/pages/VisProgPage/visualProgrammingUI/HandleRuleLogic.ts";
import { NodeDisconnections } from "../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts";
import type {PhaseNodeData} from "../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx";
@@ -398,6 +402,7 @@ describe('FlowStore Functionality', () => {
}]
});
act(()=> {
deleteNode(nodeId);
});

View File

@@ -0,0 +1,152 @@
import { describe, it, expect} from '@jest/globals';
import {
type EditorWarning, warningSummary
} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx";
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
function makeWarning(
overrides?: Partial<EditorWarning>
): EditorWarning {
return {
scope: { id: 'node-1' },
type: 'MISSING_INPUT',
severity: 'ERROR',
description: 'Missing input',
...overrides,
};
}
describe("editorWarnings", () => {
describe('registerWarning', () => {
it('registers a node-level warning', () => {
const warning = makeWarning();
const {registerWarning, getWarnings} = useFlowStore.getState()
registerWarning(warning);
const warnings = getWarnings();
expect(warnings).toHaveLength(1);
expect(warnings[0]).toEqual(warning);
});
it('registers a handle-level warning with scoped key', () => {
const warning = makeWarning({
scope: { id: 'node-1', handleId: 'input-1' },
});
const {registerWarning} = useFlowStore.getState()
registerWarning(warning);
const nodeWarnings = useFlowStore.getState().editorWarningRegistry.get('node-1');
expect(nodeWarnings?.has('MISSING_INPUT:input-1') === true).toBe(true);
});
it('updates severityIndex correctly', () => {
const {registerWarning, severityIndex} = useFlowStore.getState()
registerWarning(makeWarning());
expect(severityIndex.get('ERROR')!.size).toBe(1);
});
});
describe('getWarningsBySeverity', () => {
it('returns only warnings of requested severity', () => {
const {registerWarning, getWarningsBySeverity} = useFlowStore.getState()
registerWarning(
makeWarning({ severity: 'ERROR' })
);
registerWarning(
makeWarning({
severity: 'WARNING',
type: 'MISSING_OUTPUT',
})
);
const errors = getWarningsBySeverity('ERROR');
const warnings = getWarningsBySeverity('WARNING');
expect(errors).toHaveLength(1);
expect(warnings).toHaveLength(1);
});
});
describe('isProgramValid', () => {
it('returns true when no ERROR warnings exist', () => {
expect(useFlowStore.getState().isProgramValid()).toBe(true);
});
it('returns false when ERROR warnings exist', () => {
const {registerWarning, isProgramValid} = useFlowStore.getState()
registerWarning(makeWarning());
expect(isProgramValid()).toBe(false);
});
});
describe('unregisterWarning', () => {
it('removes warning from registry and severityIndex', () => {
const warning = makeWarning();
const {
registerWarning,
getWarnings,
unregisterWarning,
severityIndex
} = useFlowStore.getState()
registerWarning(warning);
unregisterWarning('node-1', 'MISSING_INPUT');
expect(getWarnings()).toHaveLength(0);
expect(severityIndex.get('ERROR')!.size).toBe(0);
});
it('does nothing if warning does not exist', () => {
expect(() =>
useFlowStore.getState().unregisterWarning('node-1', 'DOES_NOT_EXIST')
).not.toThrow();
});
});
describe('unregisterWarningsForId', () => {
it('removes all warnings for a node', () => {
const {registerWarning, unregisterWarningsForId, getWarnings, severityIndex} = useFlowStore.getState()
registerWarning(
makeWarning({
scope: { id: 'node-1', handleId: 'h1' },
})
);
registerWarning(
makeWarning({
scope: { id: 'node-1' },
type: 'MISSING_OUTPUT',
severity: 'WARNING',
})
);
unregisterWarningsForId('node-1');
expect(getWarnings()).toHaveLength(0);
expect(
severityIndex.get('ERROR')!.size
).toBe(0);
expect(
severityIndex.get('WARNING')!.size
).toBe(0);
});
});
describe('warningSummary', () => {
it('returns correct counts and validity', () => {
const {registerWarning} = useFlowStore.getState()
registerWarning(
makeWarning({ severity: 'ERROR' })
);
const summary = warningSummary();
expect(summary.error).toBe(1);
expect(summary.warning).toBe(0);
expect(summary.info).toBe(0);
expect(summary.isValid).toBe(false);
});
});
})

View File

@@ -105,6 +105,8 @@ describe("SaveLoadPanel - combined tests", () => {
});
test("onLoad with invalid JSON does not update store", async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const file = new File(["not json"], "bad.json", { type: "application/json" });
file.text = jest.fn(() => Promise.resolve(`{"bad json`));
@@ -112,20 +114,19 @@ describe("SaveLoadPanel - combined tests", () => {
render(<SaveLoadPanel />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
expect(input).toBeTruthy();
// Give some input
act(() => {
fireEvent.change(input, { target: { files: [file] } });
});
await waitFor(() => {
expect(window.alert).toHaveBeenCalledTimes(1);
const nodesAfter = useFlowStore.getState().nodes;
expect(nodesAfter).toHaveLength(0);
expect(input.value).toBe("");
});
// Clean up the spy
consoleSpy.mockRestore();
});
test("onLoad resolves to null when no file is chosen (user cancels) and does not update store", async () => {

View File

@@ -0,0 +1,138 @@
import {fireEvent, render, screen} from '@testing-library/react';
import '@testing-library/jest-dom';
import {useReactFlow, useStoreApi} from "@xyflow/react";
import {
type EditorWarning,
globalWarning
} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx";
import {WarningsSidebar} from "../../../../../src/pages/VisProgPage/visualProgrammingUI/components/WarningSidebar.tsx";
import useFlowStore from "../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx";
jest.mock('@xyflow/react', () => ({
useReactFlow: jest.fn(),
useStoreApi: jest.fn(),
}));
jest.mock('../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx');
function makeWarning(
overrides?: Partial<EditorWarning>
): EditorWarning {
return {
scope: { id: 'node-1' },
type: 'MISSING_INPUT',
severity: 'ERROR',
description: 'Missing input',
...overrides,
};
}
describe('WarningsSidebar', () => {
let getStateSpy: jest.SpyInstance;
const setCenter = jest.fn(() => Promise.resolve());
const getNode = jest.fn();
const addSelectedNodes = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// React Flow hooks
(useReactFlow as jest.Mock).mockReturnValue({
getNode,
setCenter,
});
(useStoreApi as jest.Mock).mockReturnValue({
getState: () => ({ addSelectedNodes }),
});
// Use spyOn to override store
const mockWarnings = [
makeWarning({ description: 'Node warning', scope: { id: 'node-1' } }),
makeWarning({
description: 'Global warning',
scope: { id: globalWarning },
type: 'INCOMPLETE_PROGRAM',
severity: 'WARNING',
}),
makeWarning({
description: 'Info warning',
scope: { id: 'node-2' },
severity: 'INFO',
}),
];
getStateSpy = jest
.spyOn(useFlowStore, 'getState')
.mockReturnValue({
getWarnings: () => mockWarnings,
} as any);
});
afterEach(() => {
getStateSpy.mockRestore();
});
it('renders warnings header', () => {
render(<WarningsSidebar />);
expect(screen.getByText('Warnings')).toBeInTheDocument();
});
it('renders all warning descriptions', () => {
render(<WarningsSidebar />);
expect(screen.getByText('Node warning')).toBeInTheDocument();
expect(screen.getByText('Global warning')).toBeInTheDocument();
expect(screen.getByText('Info warning')).toBeInTheDocument();
});
it('splits global and other warnings correctly', () => {
render(<WarningsSidebar />);
expect(screen.getByText('global:')).toBeInTheDocument();
expect(screen.getByText('other:')).toBeInTheDocument();
});
it('shows empty state when no warnings exist', () => {
getStateSpy.mockReturnValueOnce({
getWarnings: () => [],
} as any);
render(<WarningsSidebar />);
expect(screen.getByText('No warnings!')).toBeInTheDocument();
});
it('filters by severity', () => {
render(<WarningsSidebar />);
fireEvent.click(screen.getByText('ERROR'));
expect(screen.getByText('Node warning')).toBeInTheDocument();
expect(screen.queryByText('Global warning')).not.toBeInTheDocument();
expect(screen.queryByText('Info warning')).not.toBeInTheDocument();
});
it('filters INFO severity correctly', () => {
render(<WarningsSidebar />);
fireEvent.click(screen.getByText('INFO'));
expect(screen.getByText('Info warning')).toBeInTheDocument();
expect(screen.queryByText('Node warning')).not.toBeInTheDocument();
expect(screen.queryByText('Global warning')).not.toBeInTheDocument();
});
it('clicking global warning does NOT jump', () => {
render(<WarningsSidebar />);
fireEvent.click(screen.getByText('Global warning'));
expect(setCenter).not.toHaveBeenCalled();
expect(addSelectedNodes).not.toHaveBeenCalled();
});
it('does nothing if node does not exist', () => {
getNode.mockReturnValue(undefined);
render(<WarningsSidebar />);
fireEvent.click(screen.getByText('Node warning'));
expect(setCenter).not.toHaveBeenCalled();
});
});

View File

@@ -2,7 +2,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 { renderWithProviders } from '../../../../test-utils/test-utils.tsx';
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';
@@ -150,7 +150,7 @@ describe('BasicBeliefNode', () => {
expect(screen.getByDisplayValue('Emotion recognised:')).toBeInTheDocument();
// For emotion type, we should check that the select has the correct value selected
const selectElement = screen.getByDisplayValue('Happy');
const selectElement = screen.getByDisplayValue('happy');
expect(selectElement).toBeInTheDocument();
expect((selectElement as HTMLSelectElement).value).toBe('happy');
});
@@ -185,14 +185,14 @@ describe('BasicBeliefNode', () => {
/>
);
const selectElement = screen.getByDisplayValue('Happy');
const selectElement = screen.getByDisplayValue('happy');
expect(selectElement).toBeInTheDocument();
// Check that all emotion options are present
expect(screen.getByText('Happy')).toBeInTheDocument();
expect(screen.getByText('Angry')).toBeInTheDocument();
expect(screen.getByText('Sad')).toBeInTheDocument();
expect(screen.getByText('Cheerful')).toBeInTheDocument();
expect(screen.getByText('happy')).toBeInTheDocument();
expect(screen.getByText('angry')).toBeInTheDocument();
expect(screen.getByText('sad')).toBeInTheDocument();
expect(screen.getByText('surprise')).toBeInTheDocument();
});
it('should render without wrapping quotes for object type', () => {
@@ -382,7 +382,7 @@ describe('BasicBeliefNode', () => {
data: {
label: 'Belief',
droppable: true,
belief: { type: 'emotion', id: 'em1', value: 'happy', label: 'Emotion recognised:' },
belief: { type: 'emotion', id: 'em1', value: 'sad', label: 'Emotion recognised:' },
hasReduce: true,
},
};
@@ -409,13 +409,13 @@ describe('BasicBeliefNode', () => {
/>
);
const select = screen.getByDisplayValue('Happy');
await user.selectOptions(select, 'sad');
const select = screen.getByDisplayValue('sad');
await user.selectOptions(select, 'happy');
await waitFor(() => {
const state = useFlowStore.getState();
const updatedNode = state.nodes.find(n => n.id === 'belief-1') as Node<BasicBeliefNodeData>;
expect(updatedNode?.data.belief.value).toBe('sad');
expect(updatedNode?.data.belief.value).toBe('happy');
});
});
@@ -511,13 +511,11 @@ describe('BasicBeliefNode', () => {
expect(updatedNode?.data.belief.type).toBe('emotion');
// The component doesn't reset the value when changing types
// So it keeps the old value even though it doesn't make sense for emotion type
expect(updatedNode?.data.belief.value).toBe('Happy');
expect(updatedNode?.data.belief.value).toBe('sad');
});
});
});
// ... rest of the tests remain the same, just fixing the Integration with Store section ...
describe('Integration with Store', () => {
it('should properly update the store when changing belief value', async () => {
const mockNode: Node<BasicBeliefNodeData> = {

View File

@@ -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();
});
});
});
});

View File

@@ -1,5 +1,9 @@
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
import {
type CompositeWarningKey,
type SeverityIndex,
} from "../src/pages/VisProgPage/visualProgrammingUI/components/EditorWarnings.tsx";
import useFlowStore from '../src/pages/VisProgPage/visualProgrammingUI/VisProgStores.tsx';
if (!globalThis.structuredClone) {
@@ -69,8 +73,6 @@ export const mockReactFlow = () => {
};
beforeAll(() => {
useFlowStore.setState({
nodes: [],
@@ -79,7 +81,13 @@ beforeAll(() => {
future: [],
isBatchAction: false,
edgeReconnectSuccessful: true,
ruleRegistry: new Map()
ruleRegistry: new Map(),
editorWarningRegistry: new Map(),
severityIndex: new Map([
['INFO', new Set<CompositeWarningKey>()],
['WARNING', new Set<CompositeWarningKey>()],
['ERROR', new Set<CompositeWarningKey>()],
]) as SeverityIndex,
});
});
@@ -92,7 +100,13 @@ afterEach(() => {
future: [],
isBatchAction: false,
edgeReconnectSuccessful: true,
ruleRegistry: new Map()
ruleRegistry: new Map(),
editorWarningRegistry: new Map(),
severityIndex: new Map([
['INFO', new Set<CompositeWarningKey>()],
['WARNING', new Set<CompositeWarningKey>()],
['ERROR', new Set<CompositeWarningKey>()],
]) as SeverityIndex,
});
});

View File

@@ -2,6 +2,9 @@
import { render, type RenderOptions } from '@testing-library/react';
import { type ReactElement, type ReactNode } from 'react';
import { ReactFlowProvider } from '@xyflow/react';
import {mockReactFlow} from "../setupFlowTests.ts";
mockReactFlow();
/**
* Custom render function that wraps components with necessary providers

View File

@@ -113,4 +113,114 @@ describe('useProgramStore', () => {
// store should NOT change
expect(storedProgram.phases[0]['norms']).toHaveLength(1);
});
});
});
describe('getGoalsWithDepth', () => {
const complexProgram: ReducedProgram = {
phases: [
{
id: 'phase-nested',
goals: [
// Level 0: Root Goal 1
{
id: 'root-1',
name: 'Root Goal 1',
plan: {
steps: [
// This is an ACTION (no plan), should be ignored
{ id: 'action-1', type: 'speech' },
// Level 1: Child Goal
{
id: 'child-1',
name: 'Child Goal',
plan: {
steps: [
// Level 2: Grandchild Goal
{
id: 'grandchild-1',
name: 'Grandchild',
plan: { steps: [] } // Empty plan is still a plan
}
]
}
}
]
}
},
// Level 0: Root Goal 2 (Sibling)
{
id: 'root-2',
name: 'Root Goal 2',
plan: { steps: [] }
}
]
}
]
};
it('should flatten nested goals and assign correct depth levels', () => {
useProgramStore.getState().setProgramState(complexProgram);
const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested');
// logic: Root 1 -> Child 1 -> Grandchild 1 -> Root 2
expect(goals).toHaveLength(4);
// Check Root 1
expect(goals[0]).toEqual(expect.objectContaining({ id: 'root-1', level: 0 }));
// Check Child 1
expect(goals[1]).toEqual(expect.objectContaining({ id: 'child-1', level: 1 }));
// Check Grandchild 1
expect(goals[2]).toEqual(expect.objectContaining({ id: 'grandchild-1', level: 2 }));
// Check Root 2
expect(goals[3]).toEqual(expect.objectContaining({ id: 'root-2', level: 0 }));
});
it('should ignore steps that are not goals (missing "plan" property)', () => {
useProgramStore.getState().setProgramState(complexProgram);
const goals = useProgramStore.getState().getGoalsWithDepth('phase-nested');
// The 'action-1' object should NOT be in the list
const action = goals.find(g => g.id === 'action-1');
expect(action).toBeUndefined();
});
it('throws if phase does not exist', () => {
useProgramStore.getState().setProgramState(complexProgram);
expect(() =>
useProgramStore.getState().getGoalsWithDepth('missing-phase')
).toThrow('phase with id:"missing-phase" not found');
});
});
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']);
});