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);
+ });
+ });
+});