also moved some functions from VisProg outside VisProg.tsx into VisProgLogic.tsx so I can reuse it for the reset experiment function of monitor page Also fixed a small merge error in TriggerNodes.tsx ref: N25B-400
294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
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 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(<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('✔️');
|
|
});
|
|
});
|
|
});
|
|
|