Merging dev into main #49
167
test/pages/robot/Robot.test.tsx
Normal file
167
test/pages/robot/Robot.test.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { render, screen, act, cleanup, fireEvent } from '@testing-library/react';
|
||||||
|
import Robot from '../../../src/pages/Robot/Robot';
|
||||||
|
|
||||||
|
// Mock EventSource
|
||||||
|
const mockInstances: MockEventSource[] = [];
|
||||||
|
class MockEventSource {
|
||||||
|
url: string;
|
||||||
|
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||||
|
closed = false;
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.url = url;
|
||||||
|
mockInstances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessage(data: string) {
|
||||||
|
this.onmessage?.({ data } as MessageEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.closed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock global EventSource
|
||||||
|
beforeAll(() => {
|
||||||
|
(globalThis as any).EventSource = jest.fn((url: string) => new MockEventSource(url));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
beforeEach(() => {
|
||||||
|
globalThis.fetch = jest.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
json: () => Promise.resolve({ reply: 'ok' }),
|
||||||
|
})
|
||||||
|
) as jest.Mock;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
mockInstances.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Robot', () => {
|
||||||
|
test('renders initial state', () => {
|
||||||
|
render(<Robot />);
|
||||||
|
expect(screen.getByText('Robot interaction')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Force robot speech')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Listening 🔴')).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('Enter a message')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sends message via button', async () => {
|
||||||
|
render(<Robot />);
|
||||||
|
const input = screen.getByPlaceholderText('Enter a message');
|
||||||
|
const button = screen.getByText('Speak');
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: 'Hello' } });
|
||||||
|
await act(async () => fireEvent.click(button));
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:8000/message',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message: 'Hello' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sends message via Enter key', async () => {
|
||||||
|
render(<Robot />);
|
||||||
|
const input = screen.getByPlaceholderText('Enter a message');
|
||||||
|
fireEvent.change(input, { target: { value: 'Hi Enter' } });
|
||||||
|
|
||||||
|
await act(async () =>
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||||
|
'http://localhost:8000/message',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message: 'Hi Enter' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect((input as HTMLInputElement).value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles fetch errors', async () => {
|
||||||
|
globalThis.fetch = jest.fn(() => Promise.reject('Network error')) as jest.Mock;
|
||||||
|
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
render(<Robot />);
|
||||||
|
const input = screen.getByPlaceholderText('Enter a message');
|
||||||
|
const button = screen.getByText('Speak');
|
||||||
|
fireEvent.change(input, { target: { value: 'Error test' } });
|
||||||
|
|
||||||
|
await act(async () => fireEvent.click(button));
|
||||||
|
|
||||||
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
|
'Error sending message: ',
|
||||||
|
'Network error'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates conversation on SSE', async () => {
|
||||||
|
render(<Robot />);
|
||||||
|
const eventSource = mockInstances[0];
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
eventSource.sendMessage(JSON.stringify({ voice_active: true }));
|
||||||
|
eventSource.sendMessage(JSON.stringify({ speech: 'User says hi' }));
|
||||||
|
eventSource.sendMessage(JSON.stringify({ llm_response: 'Assistant replies' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText('Listening 🟢')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('User says hi')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Assistant replies')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles invalid SSE JSON', async () => {
|
||||||
|
const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
render(<Robot />);
|
||||||
|
const eventSource = mockInstances[0];
|
||||||
|
|
||||||
|
await act(async () => eventSource.sendMessage('bad-json'));
|
||||||
|
|
||||||
|
expect(logSpy).toHaveBeenCalledWith('Unparsable SSE message:', 'bad-json');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resets conversation with Reset button', async () => {
|
||||||
|
render(<Robot />);
|
||||||
|
const eventSource = mockInstances[0];
|
||||||
|
|
||||||
|
await act(async () =>
|
||||||
|
eventSource.sendMessage(JSON.stringify({ speech: 'Hello' }))
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Hello')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Reset'));
|
||||||
|
expect(screen.queryByText('Hello')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toggles conversationIndex with Stop/Start button', () => {
|
||||||
|
render(<Robot />);
|
||||||
|
const stopButton = screen.getByText('Stop');
|
||||||
|
fireEvent.click(stopButton);
|
||||||
|
expect(screen.getByText('Start')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('Start'));
|
||||||
|
expect(screen.getByText('Stop')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closes EventSource on unmount', () => {
|
||||||
|
const { unmount } = render(<Robot />);
|
||||||
|
const eventSource = mockInstances[0];
|
||||||
|
const closeSpy = jest.spyOn(eventSource, 'close');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
expect(closeSpy).toHaveBeenCalled();
|
||||||
|
expect(eventSource.closed).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, it, beforeEach } from '@jest/globals';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils';
|
||||||
|
import StartNode, { StartReduce, StartConnects } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode';
|
||||||
|
import type { Node } from '@xyflow/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
describe('StartNode', () => {
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetFlowStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('renders the StartNode correctly', () => {
|
||||||
|
const mockNode: Node = {
|
||||||
|
id: 'start-1',
|
||||||
|
type: 'start', // TypeScript now knows this is a string
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Start Node',
|
||||||
|
droppable: false,
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<StartNode
|
||||||
|
id={mockNode.id}
|
||||||
|
type={mockNode.type!} // <--- fix here
|
||||||
|
data={mockNode.data as any}
|
||||||
|
selected={false}
|
||||||
|
isConnectable={true}
|
||||||
|
zIndex={0}
|
||||||
|
dragging={false}
|
||||||
|
selectable={true}
|
||||||
|
deletable={false}
|
||||||
|
draggable={true}
|
||||||
|
positionAbsoluteX={0}
|
||||||
|
positionAbsoluteY={0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
expect(screen.getByText('Start')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// The handle should exist in the DOM
|
||||||
|
expect(document.querySelector('[data-handleid="source"]')).toBeInTheDocument();
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StartReduce Function', () => {
|
||||||
|
it('reduces the StartNode to its minimal structure', () => {
|
||||||
|
const mockNode: Node = {
|
||||||
|
id: 'start-1',
|
||||||
|
type: 'start',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Start Node',
|
||||||
|
droppable: false,
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = StartReduce(mockNode, [mockNode]);
|
||||||
|
expect(result).toEqual({ id: 'start-1' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('StartConnects Function', () => {
|
||||||
|
it('handles connections without throwing', () => {
|
||||||
|
const startNode: Node = {
|
||||||
|
id: 'start-1',
|
||||||
|
type: 'start',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Start Node',
|
||||||
|
droppable: false,
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const otherNode: Node = {
|
||||||
|
id: 'norm-1',
|
||||||
|
type: 'norm',
|
||||||
|
position: { x: 100, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Norm Node',
|
||||||
|
droppable: true,
|
||||||
|
norm: 'test',
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => StartConnects(startNode, otherNode, true)).not.toThrow();
|
||||||
|
expect(() => StartConnects(startNode, otherNode, false)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { describe, it, beforeEach } from '@jest/globals';
|
||||||
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils';
|
||||||
|
import TriggerNode, { TriggerReduce, TriggerConnects, TriggerNodeCanConnect, type TriggerNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode';
|
||||||
|
import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores';
|
||||||
|
import type { Node } from '@xyflow/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
describe('TriggerNode', () => {
|
||||||
|
let user: ReturnType<typeof userEvent.setup>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetFlowStore();
|
||||||
|
user = userEvent.setup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Rendering', () => {
|
||||||
|
it('should render TriggerNode with keywords type', () => {
|
||||||
|
const mockNode: Node<TriggerNodeData> = {
|
||||||
|
id: 'trigger-1',
|
||||||
|
type: 'trigger',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Keyword Trigger',
|
||||||
|
droppable: true,
|
||||||
|
triggerType: 'keywords',
|
||||||
|
triggers: [],
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<TriggerNode
|
||||||
|
id={mockNode.id}
|
||||||
|
type={mockNode.type as string}
|
||||||
|
data={mockNode.data as any}
|
||||||
|
selected={false}
|
||||||
|
isConnectable={true}
|
||||||
|
zIndex={0}
|
||||||
|
dragging={false}
|
||||||
|
selectable={true}
|
||||||
|
deletable={true}
|
||||||
|
draggable={true}
|
||||||
|
positionAbsoluteX={0}
|
||||||
|
positionAbsoluteY={0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Triggers when the keyword is spoken/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByPlaceholderText('...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render TriggerNode with emotion type', () => {
|
||||||
|
const mockNode: Node<TriggerNodeData> = {
|
||||||
|
id: 'trigger-2',
|
||||||
|
type: 'trigger',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Emotion Trigger',
|
||||||
|
droppable: true,
|
||||||
|
triggerType: 'emotion',
|
||||||
|
triggers: [],
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<TriggerNode
|
||||||
|
id={mockNode.id}
|
||||||
|
type={mockNode.type as string}
|
||||||
|
data={mockNode.data as any}
|
||||||
|
selected={false}
|
||||||
|
isConnectable={true}
|
||||||
|
zIndex={0}
|
||||||
|
dragging={false}
|
||||||
|
selectable={true}
|
||||||
|
deletable={true}
|
||||||
|
draggable={true}
|
||||||
|
positionAbsoluteX={0}
|
||||||
|
positionAbsoluteY={0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/Emotion\?/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('User Interactions', () => {
|
||||||
|
it('should add a new keyword', async () => {
|
||||||
|
const mockNode: Node<TriggerNodeData> = {
|
||||||
|
id: 'trigger-1',
|
||||||
|
type: 'trigger',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Keyword Trigger',
|
||||||
|
droppable: true,
|
||||||
|
triggerType: 'keywords',
|
||||||
|
triggers: [],
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useFlowStore.setState({ nodes: [mockNode], edges: [] });
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<TriggerNode
|
||||||
|
id={mockNode.id}
|
||||||
|
type={mockNode.type as string}
|
||||||
|
data={mockNode.data as any}
|
||||||
|
selected={false}
|
||||||
|
isConnectable={true}
|
||||||
|
zIndex={0}
|
||||||
|
dragging={false}
|
||||||
|
selectable={true}
|
||||||
|
deletable={true}
|
||||||
|
draggable={true}
|
||||||
|
positionAbsoluteX={0}
|
||||||
|
positionAbsoluteY={0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('...');
|
||||||
|
await user.type(input, 'hello{enter}');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node<TriggerNodeData> | undefined;
|
||||||
|
expect(node?.data.triggers.length).toBe(1);
|
||||||
|
expect(node?.data.triggers[0].keyword).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove a keyword when cleared', async () => {
|
||||||
|
const mockNode: Node<TriggerNodeData> = {
|
||||||
|
id: 'trigger-1',
|
||||||
|
type: 'trigger',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Keyword Trigger',
|
||||||
|
droppable: true,
|
||||||
|
triggerType: 'keywords',
|
||||||
|
triggers: [{ id: 'kw1', keyword: 'hello' }],
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useFlowStore.setState({ nodes: [mockNode], edges: [] });
|
||||||
|
|
||||||
|
renderWithProviders(
|
||||||
|
<TriggerNode
|
||||||
|
id={mockNode.id}
|
||||||
|
type={mockNode.type as string}
|
||||||
|
data={mockNode.data as any}
|
||||||
|
selected={false}
|
||||||
|
isConnectable={true}
|
||||||
|
zIndex={0}
|
||||||
|
dragging={false}
|
||||||
|
selectable={true}
|
||||||
|
deletable={true}
|
||||||
|
draggable={true}
|
||||||
|
positionAbsoluteX={0}
|
||||||
|
positionAbsoluteY={0}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByDisplayValue('hello');
|
||||||
|
for (let i = 0; i < 'hello'.length; i++) {
|
||||||
|
await user.type(input, '{backspace}');
|
||||||
|
}
|
||||||
|
await user.type(input, '{enter}');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const node = useFlowStore.getState().nodes.find(n => n.id === 'trigger-1') as Node<TriggerNodeData> | undefined;
|
||||||
|
expect(node?.data.triggers.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TriggerReduce Function', () => {
|
||||||
|
it('should reduce a trigger node to its essential data', () => {
|
||||||
|
const triggerNode: Node = {
|
||||||
|
id: 'trigger-1',
|
||||||
|
type: 'trigger',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Keyword Trigger',
|
||||||
|
droppable: true,
|
||||||
|
triggerType: 'keywords',
|
||||||
|
triggers: [{ id: 'kw1', keyword: 'hello' }],
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const allNodes: Node[] = [triggerNode];
|
||||||
|
const result = TriggerReduce(triggerNode, allNodes);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
label: 'Keyword Trigger',
|
||||||
|
list: [{ id: 'kw1', keyword: 'hello' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TriggerConnects Function', () => {
|
||||||
|
it('should handle connection without errors', () => {
|
||||||
|
const node1: Node = {
|
||||||
|
id: 'trigger-1',
|
||||||
|
type: 'trigger',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Trigger 1',
|
||||||
|
droppable: true,
|
||||||
|
triggerType: 'keywords',
|
||||||
|
triggers: [],
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const node2: Node = {
|
||||||
|
id: 'norm-1',
|
||||||
|
type: 'norm',
|
||||||
|
position: { x: 100, y: 0 },
|
||||||
|
data: {
|
||||||
|
label: 'Norm 1',
|
||||||
|
droppable: true,
|
||||||
|
norm: 'test',
|
||||||
|
hasReduce: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
TriggerConnects(node1, node2, true);
|
||||||
|
TriggerConnects(node1, node2, false);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for TriggerNodeCanConnect if connection exists', () => {
|
||||||
|
const connection = { source: 'trigger-1', target: 'norm-1' };
|
||||||
|
expect(TriggerNodeCanConnect(connection as any)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user