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('✔️'); }); }); });