diff --git a/test/pages/robot/Robot.test.tsx b/test/pages/robot/Robot.test.tsx new file mode 100644 index 0000000..bcebac8 --- /dev/null +++ b/test/pages/robot/Robot.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + const eventSource = mockInstances[0]; + const closeSpy = jest.spyOn(eventSource, 'close'); + + unmount(); + expect(closeSpy).toHaveBeenCalled(); + expect(eventSource.closed).toBe(true); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx new file mode 100644 index 0000000..3754202 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx @@ -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( + + ); + + + 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(); + }); + }); +}); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx new file mode 100644 index 0000000..a7c5437 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -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; + + beforeEach(() => { + resetFlowStore(); + user = userEvent.setup(); + }); + + describe('Rendering', () => { + it('should render TriggerNode with keywords type', () => { + const mockNode: Node = { + id: 'trigger-1', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Keyword Trigger', + droppable: true, + triggerType: 'keywords', + triggers: [], + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + 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 = { + id: 'trigger-2', + type: 'trigger', + position: { x: 0, y: 0 }, + data: { + label: 'Emotion Trigger', + droppable: true, + triggerType: 'emotion', + triggers: [], + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByText(/Emotion\?/i)).toBeInTheDocument(); + }); + }); + + describe('User Interactions', () => { + it('should add a new keyword', async () => { + const mockNode: Node = { + 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( + + ); + + 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 | 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 = { + 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( + + ); + + 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 | 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); + }); + }); +});