Files
pepperplus-ui/test/pages/monitoringPage/MonitoringPage.test.tsx
2026-01-28 10:34:36 +00:00

303 lines
11 KiB
TypeScript

// This program has been developed by students from the bachelor Computer Science at Utrecht
// University within the Software Project course.
// © Copyright Utrecht University (Department of Information and Computing Sciences)
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(),
runProgram: 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.runProgram).toHaveBeenCalled();
});
test('Reset Experiment handles errors gracefully', async () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
(VisProg.runProgram 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('✔️');
});
});
});