From c5d9b8342d0163ca558c57132c468df8156536b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 27 Nov 2025 17:14:19 +0100 Subject: [PATCH 1/9] chore: create new tests for the UI, namely normnode, and one for all nodes --- .../components/DragDropSidebar.tsx | 7 +- test/components/Logging/Logging.test.tsx | 12 +- .../components/DragDropSidebar.test.tsx | 115 ++- .../nodes/NormNode.test.tsx | 745 ++++++++++++++++++ .../nodes/UniversalNodes.test.tsx | 55 ++ test/test-utils/mocks.ts | 41 + test/test-utils/test-utils.tsx | 35 + 7 files changed, 999 insertions(+), 11 deletions(-) create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx create mode 100644 test/test-utils/mocks.ts create mode 100644 test/test-utils/test-utils.tsx diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 97b563b..fb4857e 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -37,7 +37,11 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP }); return ( -
+
{children}
); @@ -120,6 +124,7 @@ export function DndToolbar() { {/* Maps over all the nodes that are droppable, and puts them in the panel */} {droppableNodes.map(({type, data}) => ( { render(); - expect(screen.getByText("Logs")).toBeInTheDocument(); - expect(screen.getByText("WARNING")).toBeInTheDocument(); - expect(screen.getByText("logging")).toBeInTheDocument(); - expect(screen.getByText("Ping")).toBeInTheDocument(); + expect(screen.getByText("Logs")).toBeDefined(); + expect(screen.getByText("WARNING")).toBeDefined(); + expect(screen.getByText("logging")).toBeDefined(); + expect(screen.getByText("Ping")).toBeDefined(); let timestamp = screen.queryByText("ABS TIME"); if (!timestamp) { @@ -141,7 +141,7 @@ describe("Logging component", () => { } await user.click(timestamp); - expect(screen.getByText("00:00:12.345")).toBeInTheDocument(); + expect(screen.getByText("00:00:12.345")).toBeDefined(); }); it("shows the scroll-to-bottom button after a manual scroll and scrolls when clicked", async () => { @@ -188,7 +188,7 @@ describe("Logging component", () => { logCell.set({...current, message: "Updated"}); }); - expect(screen.getByText("Updated")).toBeInTheDocument(); + expect(screen.getByText("Updated")).toBeDefined(); await waitFor(() => { expect(scrollSpy).toHaveBeenCalledTimes(1); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx index 70087ee..a17fde8 100644 --- a/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/components/DragDropSidebar.test.tsx @@ -1,5 +1,112 @@ -describe('Not implemented', () => { - test('nothing yet', () => { - expect(true) - }); +import { getByTestId, render } from '@testing-library/react'; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; +import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg'; +import userEvent from '@testing-library/user-event'; + + + +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +window.ResizeObserver = ResizeObserver; + +jest.mock('@neodrag/react', () => ({ + useDraggable: (ref: React.RefObject, options: any) => { + // We access the real useEffect from React to attach a listener + // This bridges the gap between the test's userEvent and the component's logic + const { useEffect } = jest.requireActual('react'); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + // When the test fires a "pointerup" (end of click/drag), + // we manually trigger the library's onDragEnd callback. + const handlePointerUp = (e: PointerEvent) => { + if (options.onDragEnd) { + options.onDragEnd({ event: e }); + } + }; + + element.addEventListener('pointerup', handlePointerUp as EventListener); + return () => { + element.removeEventListener('pointerup', handlePointerUp as EventListener); + }; + }, [ref, options]); + }, +})); + +// We will mock @xyflow/react so we control screenToFlowPosition +jest.mock('@xyflow/react', () => { + const actual = jest.requireActual('@xyflow/react'); + return { + ...actual, + useReactFlow: () => ({ + screenToFlowPosition: ({ x, y }: { x: number; y: number }) => ({ + x: x - 100, + y: y - 100, + }), + }), + }; }); + +// Reset Zustand state helper +function resetStore() { + useFlowStore.setState({ nodes: [], edges: [] }); +} + +describe("Drag & drop node creation", () => { + beforeEach(() => resetStore()); + + test("drops a phase node inside the canvas and adds it with transformed position", async () => { + const user = userEvent.setup(); + + const { container } = render(); + + // --- Mock ReactFlow bounding box --- + // Your DndToolbar checks these values: + const flowEl = container.querySelector('.react-flow'); + jest.spyOn(flowEl!, 'getBoundingClientRect').mockReturnValue({ + left: 0, + right: 800, + top: 0, + bottom: 600, + width: 800, + height: 600, + x: 0, + y: 0, + toJSON: () => {}, + }); + + + const phaseLabel = getByTestId(container, 'draggable-phase') + + await user.pointer([ + // touch the screen at element1 + {keys: '[TouchA>]', target: phaseLabel}, + // move the touch pointer to element2 + {pointerName: 'TouchA', coords: {x: 300, y: 250}}, + // release the touch pointer at the last position (element2) + {keys: '[/TouchA]'}, + ]); + + // Read the Zustand store + const { nodes } = useFlowStore.getState(); + + // --- Assertions --- + expect(nodes.length).toBe(1); + + const node = nodes[0]; + + expect(node.type).toBe("phase"); + expect(node.id).toBe("phase-1"); + + // screenToFlowPosition was mocked to subtract 100 + expect(node.position).toEqual({ + x: 200, + y: 150, + }); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx new file mode 100644 index 0000000..9e3d049 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -0,0 +1,745 @@ +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 NormNode, { NormReduce, NormConnects, type NormNodeData } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode' +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; +import type { Node } from '@xyflow/react'; +import '@testing-library/jest-dom' + + + +describe('NormNode', () => { + let user: ReturnType; + + beforeEach(() => { + resetFlowStore(); + user = userEvent.setup(); + }); + + describe('Rendering', () => { + it('should render the norm node with default data', () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + expect(screen.getByPlaceholderText('Pepper should ...')).toBeInTheDocument(); + }); + + it('should render with pre-populated norm text', () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Be respectful to humans', + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('Be respectful to humans'); + expect(input).toBeInTheDocument(); + }); + + it('should render with selected state', () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + let norm = screen.getByText("Norm :") + expect(norm).toBeInTheDocument; + }); + + it('should render with dragging state', () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Dragged norm', + hasReduce: true, + }, + }; + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('Dragged norm'); + expect(input).toBeInTheDocument(); + }); + }); + + describe('User Interactions', () => { + it('should update norm text when user types in the input field', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + await user.type(input, 'Be polite to guests{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'norm-1'); + expect(updatedNode?.data.norm).toBe('Be polite to guests'); + }); + }); + + it('should handle clearing the norm text', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Initial norm text', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('Initial norm text') as HTMLInputElement; + + // clearing the norm text is the same as just deleting all characters one by one + // TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/ + for (let a = 0; a < 'Initial norm text'.length; a++){ + await user.type(input, '{backspace}') + } + await user.type(input,'{enter}') + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNode = state.nodes.find(n => n.id === 'norm-1'); + expect(updatedNode?.data.norm).toBe(''); + }); + }); + + it('should update norm text multiple times', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + await user.type(input, 'First norm{enter}'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('First norm'); + }); + + + // TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/ + for (let a = 0; a < 'First norm'.length; a++){ + await user.type(input, '{backspace}') + } + + await user.type(input, 'Second norm{enter}'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('Second norm'); + }); + }); + + it('should handle special characters in norm text', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + await user.type(input, "Don't harm & be nice!{enter}" ); + + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe("Don't harm & be nice!"); + }); + }); + + it('should handle long norm text', async () => { + const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + await user.type(input, longText); + await user.type(input, "{enter}") + + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe(longText); + }); + }); + }); + + describe('NormReduce Function', () => { + it('should reduce a norm node to its essential data', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Safety Norm', + droppable: true, + norm: 'Never harm humans', + hasReduce: true, + }, + }; + + const allNodes: Node[] = [normNode]; + const result = NormReduce(normNode, allNodes); + + expect(result).toEqual({ + id: 'norm-1', + label: 'Safety Norm', + norm: 'Never harm humans', + }); + }); + + it('should reduce multiple norm nodes independently', () => { + const norm1: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Norm 1', + droppable: true, + norm: 'Be helpful', + hasReduce: true, + }, + }; + + const norm2: Node = { + id: 'norm-2', + type: 'norm', + position: { x: 100, y: 0 }, + data: { + label: 'Norm 2', + droppable: true, + norm: 'Be honest', + hasReduce: true, + }, + }; + + const allNodes: Node[] = [norm1, norm2]; + + const result1 = NormReduce(norm1, allNodes); + const result2 = NormReduce(norm2, allNodes); + + expect(result1.id).toBe('norm-1'); + expect(result1.norm).toBe('Be helpful'); + expect(result2.id).toBe('norm-2'); + expect(result2.norm).toBe('Be honest'); + }); + + it('should handle empty norm text', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Empty Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + const result = NormReduce(normNode, [normNode]); + + expect(result.norm).toBe(''); + expect(result.id).toBe('norm-1'); + }); + + it('should preserve node label in reduction', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Custom Label', + droppable: false, + norm: 'Test norm', + hasReduce: false, + }, + }; + + const result = NormReduce(normNode, [normNode]); + + expect(result.label).toBe('Custom Label'); + }); + }); + + describe('NormConnects Function', () => { + it('should handle connection without errors', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }; + + const phaseNode: Node = { + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: [], + hasReduce: true, + }, + }; + + expect(() => { + NormConnects(normNode, phaseNode, true); + }).not.toThrow(); + }); + + it('should handle connection when norm is target', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }; + + const phaseNode: Node = { + id: 'phase-1', + type: 'phase', + position: { x: 100, y: 0 }, + data: { + label: 'Phase 1', + droppable: true, + children: [], + hasReduce: true, + }, + }; + + expect(() => { + NormConnects(normNode, phaseNode, false); + }).not.toThrow(); + }); + + it('should handle self-connection', () => { + const normNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'Test', + hasReduce: true, + }, + }; + + expect(() => { + NormConnects(normNode, normNode, true); + }).not.toThrow(); + }); + }); + + describe('Integration with Store', () => { + it('should properly update the store when editing norm text', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: '', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + + // TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/ + for (let a = 0; a < 20; a++){ + await user.type(input, '{backspace}') + } + await user.type(input, 'New norm value{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + expect(state.nodes).toHaveLength(1); + expect(state.nodes[0].id).toBe('norm-1'); + expect(state.nodes[0].data.norm).toBe('New norm value'); + }); + }); + + it('should not affect other nodes when updating one norm node', async () => { + const norm1: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Norm 1', + droppable: true, + norm: 'Original norm 1', + hasReduce: true, + }, + }; + + const norm2: Node = { + id: 'norm-2', + type: 'norm', + position: { x: 100, y: 0 }, + data: { + label: 'Norm 2', + droppable: true, + norm: 'Original norm 2', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [norm1, norm2], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByDisplayValue('Original norm 1') as HTMLInputElement; + + + // TODO: FIGURE OUT A MORE EFFICIENT WAY, I"M SORRY, user.clear() just doesn't work:/ + for (let a = 0; a < 20; a++){ + await user.type(input, '{backspace}') + } + await user.type(input, 'Updated norm 1{enter}'); + + await waitFor(() => { + const state = useFlowStore.getState(); + const updatedNorm1 = state.nodes.find(n => n.id === 'norm-1'); + const unchangedNorm2 = state.nodes.find(n => n.id === 'norm-2'); + + expect(updatedNorm1?.data.norm).toBe('Updated norm 1'); + expect(unchangedNorm2?.data.norm).toBe('Original norm 2'); + }); + }); + + it('should maintain data consistency with multiple rapid updates', async () => { + const mockNode: Node = { + id: 'norm-1', + type: 'norm', + position: { x: 0, y: 0 }, + data: { + label: 'Test Norm', + droppable: true, + norm: 'haa haa fuyaaah - link', + hasReduce: true, + }, + }; + + useFlowStore.setState({ + nodes: [mockNode], + edges: [], + }); + + renderWithProviders( + + ); + + const input = screen.getByPlaceholderText('Pepper should ...'); + + await user.type(input, 'a'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + }); + + await user.type(input, 'b'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + }); + + await user.type(input, 'c'); + await waitFor(() => { + expect(useFlowStore.getState().nodes[0].data.norm).toBe('haa haa fuyaaah - link'); + }, { timeout: 3000 }); + }); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx new file mode 100644 index 0000000..7e1e9ca --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -0,0 +1,55 @@ +import { describe, beforeEach } from '@jest/globals'; +import { screen } from '@testing-library/react'; +import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils'; +import type { XYPosition } from '@xyflow/react'; +import { NodeTypes, NodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry'; +import '@testing-library/jest-dom' + + +describe('NormNode', () => { + // let user: ReturnType; + + // Copied from VisStores. + function createNode(id: string, type: string, position: XYPosition, data: Record, deletable? : boolean) { + const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] + const newData = { + id: id, + type: type, + position: position, + data: data, + deletable: deletable, + } + return {...defaultData, ...newData} + } + + + beforeEach(() => { + resetFlowStore(); + // user = userEvent.setup(); + }); + + describe('Rendering', () => { + test.each([Object.entries(NodeTypes)].map(([t])=>t))('it should render each node with the default data', (nodeType) => { + let newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {}) + let uiElement = Object.entries(NodeTypes).find(([t])=>t==nodeType)?.[1]!; + let props = { + id:newNode.id, + type:newNode.type as string, + data:newNode.data as any, + selected:false, + isConnectable:true, + zIndex:0, + dragging:false, + selectable:true, + deletable:true, + draggable:true, + positionAbsoluteX:0, + positionAbsoluteY:0,} + renderWithProviders(uiElement(props)); + const elements = screen.queryAllByText((content, ) => + content.toLowerCase().includes(nodeType.toLowerCase()) + ); + expect(elements.length).toBeGreaterThan(0); + }); + }); +}); \ No newline at end of file diff --git a/test/test-utils/mocks.ts b/test/test-utils/mocks.ts new file mode 100644 index 0000000..21971c1 --- /dev/null +++ b/test/test-utils/mocks.ts @@ -0,0 +1,41 @@ +import { jest } from '@jest/globals'; +import React from 'react'; +import '@testing-library/jest-dom'; + +/** + * Mock for @xyflow/react + * Provides simplified versions of React Flow hooks and components + */ +jest.mock('@xyflow/react', () => ({ + useReactFlow: jest.fn(() => ({ + screenToFlowPosition: jest.fn((pos: any) => pos), + getNode: jest.fn(), + getNodes: jest.fn(() => []), + getEdges: jest.fn(() => []), + setNodes: jest.fn(), + setEdges: jest.fn(), + })), + ReactFlowProvider: ({ children }: { children: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'react-flow-provider' }, children), + ReactFlow: ({ children, ...props }: any) => + React.createElement('div', { 'data-testid': 'react-flow', ...props }, children), + Handle: ({ type, position, id }: any) => + React.createElement('div', { 'data-testid': `handle-${type}-${id}`, 'data-position': position }), + Panel: ({ children, position }: any) => + React.createElement('div', { 'data-testid': 'panel', 'data-position': position }, children), + Controls: () => React.createElement('div', { 'data-testid': 'controls' }), + Background: () => React.createElement('div', { 'data-testid': 'background' }), +})); + +/** + * Mock for @neodrag/react + * Simplifies drag behavior for testing + */ +jest.mock('@neodrag/react', () => ({ + useDraggable: jest.fn((ref: any, options?: any) => { + // Store the options so we can trigger them in tests + if (ref && ref.current) { + (ref.current as any)._dragOptions = options; + } + }), +})); \ No newline at end of file diff --git a/test/test-utils/test-utils.tsx b/test/test-utils/test-utils.tsx new file mode 100644 index 0000000..76878b9 --- /dev/null +++ b/test/test-utils/test-utils.tsx @@ -0,0 +1,35 @@ +// __tests__/utils/test-utils.tsx +import { render, type RenderOptions } from '@testing-library/react'; +import { type ReactElement, type ReactNode } from 'react'; +import { ReactFlowProvider } from '@xyflow/react'; +import useFlowStore from '../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; + +/** + * Custom render function that wraps components with necessary providers + * This ensures all components have access to ReactFlow context + */ +export function renderWithProviders( + ui: ReactElement, + options?: Omit +) { + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + } + + return render(ui, { wrapper: Wrapper, ...options }); +} + +/** + * Helper to reset the Zustand store between tests + * This ensures test isolation + */ +export function resetFlowStore() { + useFlowStore.setState({ + nodes: [], + edges: [], + edgeReconnectSuccessful: true, + }); +} + +// Re-export everything from testing library +export * from '@testing-library/react'; \ No newline at end of file From 2261da99156ba9a60802b628ae5c7aa9571c54b3 Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Thu, 27 Nov 2025 18:45:11 +0100 Subject: [PATCH 2/9] test: robot, and 2 nodes tests added. ref: N25B-292 --- test/pages/robot/Robot.test.tsx | 167 ++++++++++++ .../nodes/StartNode.test.tsx | 100 +++++++ .../nodes/TriggerNode.test.tsx | 244 ++++++++++++++++++ 3 files changed, 511 insertions(+) create mode 100644 test/pages/robot/Robot.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/StartNode.test.tsx create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx 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); + }); + }); +}); From d4393e76354ced93763d986cb8cd05781d291904 Mon Sep 17 00:00:00 2001 From: JobvAlewijk Date: Tue, 2 Dec 2025 11:36:10 +0100 Subject: [PATCH 3/9] test: scroll ref: N25B-292 --- .../components/ScrollIntoView.test.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx diff --git a/test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx b/test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx new file mode 100644 index 0000000..2a91e85 --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/components/ScrollIntoView.test.tsx @@ -0,0 +1,14 @@ +import { render } from '@testing-library/react'; +import { act } from '@testing-library/react'; +import ScrollIntoView from '../../../../../src/components/ScrollIntoView'; + +test('scrolls the element into view on render', () => { + const scrollMock = jest.fn(); + HTMLElement.prototype.scrollIntoView = scrollMock; + + act(() => { + render(); + }); + + expect(scrollMock).toHaveBeenCalledWith({ behavior: 'smooth' }); +}); From a95fbd15e6287651f7af945e4aa196ef16709f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 2 Dec 2025 12:01:23 +0100 Subject: [PATCH 4/9] test: create universal tests and rewrite nodes to have optional parameters for more code coverage ref: N25B-362 --- .../visualProgrammingUI/NodeRegistry.ts | 4 +- .../visualProgrammingUI/nodes/EndNode.tsx | 19 +-- .../visualProgrammingUI/nodes/GoalNode.tsx | 13 +- .../visualProgrammingUI/nodes/NormNode.tsx | 14 +- .../visualProgrammingUI/nodes/PhaseNode.tsx | 6 +- .../visualProgrammingUI/nodes/StartNode.tsx | 19 +-- .../visualProgrammingUI/nodes/TriggerNode.tsx | 21 +-- .../nodes/UniversalNodes.test.tsx | 151 ++++++++++++++---- 8 files changed, 153 insertions(+), 94 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index ca8ef73..3d18467 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -59,7 +59,7 @@ export const NodeConnects = { phase: PhaseConnects, norm: NormConnects, goal: GoalConnects, - trigger: TriggerConnects, + trigger: TriggerConnects, } /** @@ -69,6 +69,7 @@ export const NodeConnects = { export const NodeDeletes = { start: () => false, end: () => false, + test: () => false, // Used for coverage of universal/ undefined nodes } /** @@ -79,4 +80,5 @@ export const NodesInPhase = { start: () => false, end: () => false, phase: () => false, + test: () => false, // Used for coverage of universal/ undefined nodes } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx index 580499e..9a496f2 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/EndNode.tsx @@ -40,14 +40,11 @@ export default function EndNode(props: NodeProps) { /** * Functionality for reducing this node into its more compact json program * @param node the node to reduce - * @param nodes all nodes present + * @param _nodes all nodes present * @returns Dictionary, {id: node.id} */ -export function EndReduce(node: Node, nodes: Node[]) { +export function EndReduce(node: Node, _nodes: Node[]) { // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in EndReduce") - } return { id: node.id } @@ -55,13 +52,9 @@ export function EndReduce(node: Node, nodes: Node[]) { /** * Any connection functionality that should get called when a connection is made to this node type (end) - * @param thisNode the node of which the functionality gets called - * @param otherNode the other node which has connected - * @param isThisSource whether this node is the one that is the source of the connection + * @param _thisNode the node of which the functionality gets called + * @param _otherNode the other node which has connected + * @param _isThisSource whether this node is the one that is the source of the connection */ -export function EndConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } +export function EndConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx index 8cfa122..1149496 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/GoalNode.tsx @@ -77,13 +77,9 @@ export default function GoalNode(props: NodeProps) { /** * Reduces each Goal, including its children down into its relevant data. * @param node: The Node Properties of this node. - * @param nodes: all the nodes in the graph + * @param _nodes: all the nodes in the graph */ -export function GoalReduce(node: Node, nodes: Node[]) { - // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in GoalReduce") - } +export function GoalReduce(node: Node, _nodes: Node[]) { const data = node.data as GoalNodeData; return { id: node.id, @@ -93,9 +89,6 @@ export function GoalReduce(node: Node, nodes: Node[]) { } } -export function GoalConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { +export function GoalConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx index 5789cac..1cbf257 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/NormNode.tsx @@ -61,13 +61,9 @@ export default function NormNode(props: NodeProps) { /** * Reduces each Norm, including its children down into its relevant data. * @param node: The Node Properties of this node. - * @param nodes: all the nodes in the graph + * @param _nodes: all the nodes in the graph */ -export function NormReduce(node: Node, nodes: Node[]) { - // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in NormReduce") - } +export function NormReduce(node: Node, _nodes: Node[]) { const data = node.data as NormNodeData; return { id: node.id, @@ -76,9 +72,5 @@ export function NormReduce(node: Node, nodes: Node[]) { } } -export function NormConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } +export function NormConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 7234e34..06cb1e5 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -78,8 +78,10 @@ export function PhaseReduce(node: Node, nodes: Node[]) { .filter(([t]) => !nodesNotInPhase.includes(t)) .map(([t]) => t); - // children nodes - const childrenNodes = nodes.filter((node) => data.children.includes(node.id)); + // children nodes - make sure to check for empty arrays + let childrenNodes: any[] = []; + if (data.children) + childrenNodes = nodes.filter((node) => data.children.includes(node.id)); // Build the result object const result: Record = { diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx index 6d74c08..f994090 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/StartNode.tsx @@ -40,14 +40,11 @@ export default function StartNode(props: NodeProps) { /** * The reduce function for this node type. * @param node this node - * @param nodes all the nodes in the graph + * @param _nodes all the nodes in the graph * @returns a reduced structure of this node */ -export function StartReduce(node: Node, nodes: Node[]) { +export function StartReduce(node: Node, _nodes: Node[]) { // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in StartReduce") - } return { id: node.id } @@ -55,13 +52,9 @@ export function StartReduce(node: Node, nodes: Node[]) { /** * This function is called whenever a connection is made with this node type (start) - * @param thisNode the node of this node type which function is called - * @param otherNode the other node which was part of the connection - * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _thisNode the node of this node type which function is called + * @param _otherNode the other node which was part of the connection + * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. */ -export function StartConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } +export function StartConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx index a6f114e..ed79c99 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/TriggerNode.tsx @@ -68,13 +68,9 @@ export default function TriggerNode(props: NodeProps) { /** * Reduces each Trigger, including its children down into its relevant data. * @param node: The Node Properties of this node. - * @param nodes: all the nodes in the graph. + * @param _nodes: all the nodes in the graph. */ -export function TriggerReduce(node: Node, nodes: Node[]) { - // Replace this for nodes functionality - if (nodes.length <= -1) { - console.warn("Impossible nodes length in TriggerReduce") - } +export function TriggerReduce(node: Node, _nodes: Node[]) { const data = node.data as TriggerNodeData; return { label: data.label, @@ -84,15 +80,12 @@ export function TriggerReduce(node: Node, nodes: Node[]) { /** * This function is called whenever a connection is made with this node type (trigger) - * @param thisNode the node of this node type which function is called - * @param otherNode the other node which was part of the connection - * @param isThisSource whether this instance of the node was the source in the connection, true = yes. + * @param _thisNode the node of this node type which function is called + * @param _otherNode the other node which was part of the connection + * @param _isThisSource whether this instance of the node was the source in the connection, true = yes. */ -export function TriggerConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - // Replace this for connection logic - if (thisNode == undefined && otherNode == undefined && isThisSource == false) { - console.warn("Impossible node connection called in EndConnects") - } +export function TriggerConnects(_thisNode: Node, _otherNode: Node, _isThisSource: boolean) { + } // Definitions for the possible triggers, being keywords and emotions diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 7e1e9ca..1119860 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -2,15 +2,19 @@ import { describe, beforeEach } from '@jest/globals'; import { screen } from '@testing-library/react'; import { renderWithProviders, resetFlowStore } from '../.././/./../../test-utils/test-utils'; import type { XYPosition } from '@xyflow/react'; -import { NodeTypes, NodeDefaults } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry'; +import { NodeTypes, NodeDefaults, NodeConnects, NodeReduces, NodesInPhase } from '../../../../../src/pages/VisProgPage/visualProgrammingUI/NodeRegistry'; import '@testing-library/jest-dom' +import { createElement } from 'react'; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; describe('NormNode', () => { - // let user: ReturnType; + beforeEach(() => { + resetFlowStore(); + jest.clearAllMocks(); + }); - // Copied from VisStores. - function createNode(id: string, type: string, position: XYPosition, data: Record, deletable? : boolean) { + function createNode(id: string, type: string, position: XYPosition, data: Record, deletable?: boolean) { const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] const newData = { id: id, @@ -21,35 +25,122 @@ describe('NormNode', () => { } return {...defaultData, ...newData} } - - beforeEach(() => { - resetFlowStore(); - // user = userEvent.setup(); + + /** + * Reduces the graph into its phases' information and recursively calls their reducing function + */ + function graphReducer() { + const { nodes } = useFlowStore.getState(); + return nodes + .filter((n) => n.type == 'phase') + .map((n) => { + const reducer = NodeReduces['phase']; + return reducer(n, nodes) + }); + } + + function getAllTypes() { + return Object.entries(NodeTypes).map(([t])=>t) + } + + describe('Rendering', () => { + test.each(getAllTypes())('it should render %s node with the default data', (nodeType) => { + const lengthBefore = screen.getAllByText(/.*/).length; + + let newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {}) + let uiElement = Object.entries(NodeTypes).find(([t])=>t==nodeType)?.[1]!; + let props = { + id:newNode.id, + type:newNode.type as string, + data:newNode.data as any, + selected:false, + isConnectable:true, + zIndex:0, + dragging:false, + selectable:true, + deletable:true, + draggable:true, + positionAbsoluteX:0, + positionAbsoluteY:0,} + + renderWithProviders(createElement(uiElement as React.ComponentType, props)); + const lengthAfter = screen.getAllByText(/.*/).length; + + expect(lengthBefore + 1 == lengthAfter) + }); }); - describe('Rendering', () => { - test.each([Object.entries(NodeTypes)].map(([t])=>t))('it should render each node with the default data', (nodeType) => { - let newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {}) - let uiElement = Object.entries(NodeTypes).find(([t])=>t==nodeType)?.[1]!; - let props = { - id:newNode.id, - type:newNode.type as string, - data:newNode.data as any, - selected:false, - isConnectable:true, - zIndex:0, - dragging:false, - selectable:true, - deletable:true, - draggable:true, - positionAbsoluteX:0, - positionAbsoluteY:0,} - renderWithProviders(uiElement(props)); - const elements = screen.queryAllByText((content, ) => - content.toLowerCase().includes(nodeType.toLowerCase()) - ); - expect(elements.length).toBeGreaterThan(0); + + describe('Connecting', () => { + test.each(getAllTypes())('it should call the connect function when %s node is connected', (nodeType) => { + // Create two nodes - one of the current type and one to connect to + const sourceNode = createNode('source-1', nodeType, {x: 100, y: 100}, {}); + const targetNode = createNode('target-1', 'end', {x: 300, y: 100}, {}); + + // Add nodes to store + useFlowStore.setState({ nodes: [sourceNode, targetNode] }); + + // Spy on the connect functions + const sourceConnectSpy = jest.spyOn(NodeConnects, nodeType as keyof typeof NodeConnects); + const targetConnectSpy = jest.spyOn(NodeConnects, 'end'); + + // Simulate connection + useFlowStore.getState().onConnect({ + source: 'source-1', + target: 'target-1', + sourceHandle: null, + targetHandle: null, + }); + + // Verify the connect functions were called + expect(sourceConnectSpy).toHaveBeenCalledWith(sourceNode, targetNode, true); + expect(targetConnectSpy).toHaveBeenCalledWith(targetNode, sourceNode, false); + + sourceConnectSpy.mockRestore(); + targetConnectSpy.mockRestore(); + }); + }); + + describe('Reducing', () => { + test.each(getAllTypes())('it should correctly call/ not call the reduce function when %s node is in a phase', (nodeType) => { + // Create a phase node and a node of the current type + const phaseNode = createNode('phase-1', 'phase', {x: 200, y: 100}, { label: 'Test Phase', children: [] }); + const testNode = createNode('node-1', nodeType, {x: 100, y: 100}, {}); + + // Add the test node as a child of the phase + (phaseNode.data as any).children.push(testNode.id); + + // Add nodes to store + useFlowStore.setState({ nodes: [phaseNode, testNode] }); + + // Spy on the reduce functions + const phaseReduceSpy = jest.spyOn(NodeReduces, 'phase'); + const nodeReduceSpy = jest.spyOn(NodeReduces, nodeType as keyof typeof NodeReduces); + + // Simulate reducing - using the graphReducer + const result = graphReducer(); + + // Verify the reduce functions were called + expect(phaseReduceSpy).toHaveBeenCalledWith(phaseNode, [phaseNode, testNode]); + // Check if this node type is in NodesInPhase and returns false + const nodesInPhaseFunc = NodesInPhase[nodeType as keyof typeof NodesInPhase]; + if (nodesInPhaseFunc && nodesInPhaseFunc() === false && nodeType !== 'phase') { + // Node is NOT in phase, so it should NOT be called + expect(nodeReduceSpy).not.toHaveBeenCalled(); + } else { + // Node IS in phase, so it SHOULD be called + expect(nodeReduceSpy).toHaveBeenCalled(); + } + + // Verify the correct structure is present using NodesInPhase + expect(result).toHaveLength(nodeType !== 'phase' ? 1 : 2); + expect(result[0]).toHaveProperty('id', 'phase-1'); + expect(result[0]).toHaveProperty('label', 'Test Phase'); + + // Restore mocks + phaseReduceSpy.mockRestore(); + nodeReduceSpy.mockRestore(); }); }); }); \ No newline at end of file From 7640c32830af06ee3a7976eb03b4f4fa7075a00c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 2 Dec 2025 12:47:38 +0100 Subject: [PATCH 5/9] fix: fix the creation of new phases so that the data is deepcloned instead of referenced --- .../visualProgrammingUI/components/DragDropSidebar.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index fb4857e..e02f81a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -56,7 +56,7 @@ function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { // Find out if there's any default data about our ndoe const defaultData = NodeDefaults[nodeType] ?? {} - + // Currently, we find out what the Id is by checking the last node and adding one const sameTypeNodes = nodes.filter((node) => node.type === nodeType); const nextNumber = @@ -75,7 +75,9 @@ function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { id: id, type: nodeType, position, - data: {...defaultData} + // Deep copy using JSON because thats how things work: + // Ref: https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy + data: structuredClone(defaultData) } setNodes([...nodes, newNode]); } From fe13017f2de094cca6b22fc8387ebcde06623e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 2 Dec 2025 14:12:35 +0100 Subject: [PATCH 6/9] test: test for the actual better clone- and make sure we use the JSON stringify and parse for this since tests are weird ref: N25B-371 --- .../components/DragDropSidebar.tsx | 37 +----------------- .../visualProgrammingUI/utils/AddNode.ts | 39 +++++++++++++++++++ .../nodes/PhaseNode.test.tsx | 22 +++++++++++ .../nodes/UniversalNodes.test.tsx | 2 +- 4 files changed, 63 insertions(+), 37 deletions(-) create mode 100644 src/pages/VisProgPage/visualProgrammingUI/utils/AddNode.ts create mode 100644 test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index e02f81a..cf20e0a 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -1,9 +1,9 @@ import { useDraggable } from '@neodrag/react'; import { useReactFlow, type XYPosition } from '@xyflow/react'; import { type ReactNode, useCallback, useRef, useState } from 'react'; -import useFlowStore from '../VisProgStores'; import styles from '../../VisProg.module.css'; import { NodeDefaults, type NodeTypes } from '../NodeRegistry' +import addNode from '../utils/AddNode'; /** * DraggableNodeProps dictates the type properties of a DraggableNode @@ -47,41 +47,6 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP ); } -/** - * addNode — adds a new node to the flow using the unified class-based system. - * Keeps numbering logic for phase/norm nodes. - */ -function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { - const { nodes, setNodes } = useFlowStore.getState(); - - // Find out if there's any default data about our ndoe - const defaultData = NodeDefaults[nodeType] ?? {} - - // Currently, we find out what the Id is by checking the last node and adding one - const sameTypeNodes = nodes.filter((node) => node.type === nodeType); - const nextNumber = - sameTypeNodes.length > 0 - ? (() => { - const lastNode = sameTypeNodes[sameTypeNodes.length - 1]; - const parts = lastNode.id.split('-'); - const lastNum = Number(parts[1]); - return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1; - })() - : 1; - const id = `${nodeType}-${nextNumber}`; - - // Create new node - const newNode = { - id: id, - type: nodeType, - position, - // Deep copy using JSON because thats how things work: - // Ref: https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy - data: structuredClone(defaultData) - } - setNodes([...nodes, newNode]); -} - /** * DndToolbar defines how the drag and drop toolbar component works * and includes the default onDrop behavior. diff --git a/src/pages/VisProgPage/visualProgrammingUI/utils/AddNode.ts b/src/pages/VisProgPage/visualProgrammingUI/utils/AddNode.ts new file mode 100644 index 0000000..b73d46b --- /dev/null +++ b/src/pages/VisProgPage/visualProgrammingUI/utils/AddNode.ts @@ -0,0 +1,39 @@ +import type { XYPosition } from "@xyflow/react"; +import { NodeDefaults, type NodeTypes } from "../NodeRegistry"; +import useFlowStore from "../VisProgStores"; + + +/** + * addNode — adds a new node to the flow using the unified class-based system. + * Keeps numbering logic for phase/norm nodes. + */ +export default function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { + const { nodes, setNodes } = useFlowStore.getState(); + + // Find out if there's any default data about our ndoe + const defaultData = NodeDefaults[nodeType] ?? {} + + // Currently, we find out what the Id is by checking the last node and adding one + const sameTypeNodes = nodes.filter((node) => node.type === nodeType); + const nextNumber = + sameTypeNodes.length > 0 + ? (() => { + const lastNode = sameTypeNodes[sameTypeNodes.length - 1]; + const parts = lastNode.id.split('-'); + const lastNum = Number(parts[1]); + return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1; + })() + : 1; + const id = `${nodeType}-${nextNumber}`; + + // Create new node + const newNode = { + id: id, + type: nodeType, + position, + // Deep copy using JSON because thats how things work: + // Ref: https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy + data: JSON.parse(JSON.stringify(defaultData)) + } + setNodes([...nodes, newNode]); +} \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx new file mode 100644 index 0000000..b37c23a --- /dev/null +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx @@ -0,0 +1,22 @@ +import { resetFlowStore } from "../../../../test-utils/test-utils"; +import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; +import addNode from "../../../../../src/pages/VisProgPage/visualProgrammingUI/utils/AddNode"; + + +describe('PhaseNode', () => { + beforeEach(() => resetFlowStore()); + + it('each created phase gets its own children array (store-level)', () => { + addNode("phase", {x:10,y:10}) + addNode("phase", {x:20,y:20}) + + const nodes = useFlowStore.getState().nodes; + const p1 = nodes.find((x) => x.id === 'phase-1')!; + const p2 = nodes.find((x) => x.id === 'phase-2')!; + + // not the same reference + expect(p1.data.children).not.toBe(p2.data.children); + // but same initial value + expect(p1.data.children).toEqual(p2.data.children); + }); +}); \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 1119860..182ff53 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -15,7 +15,7 @@ describe('NormNode', () => { }); function createNode(id: string, type: string, position: XYPosition, data: Record, deletable?: boolean) { - const defaultData = NodeDefaults[type as keyof typeof NodeDefaults] + const defaultData = JSON.parse(JSON.stringify(NodeDefaults[type as keyof typeof NodeDefaults])) const newData = { id: id, type: type, From d9faeafe320800278beedfc2fe4ac288100fc290 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 3 Dec 2025 11:28:15 +0100 Subject: [PATCH 7/9] test: create test for phase node to account for the previous bug. ref: N25B-371 --- .../nodes/PhaseNode.test.tsx | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx index b37c23a..43763d3 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx @@ -1,22 +1,39 @@ -import { resetFlowStore } from "../../../../test-utils/test-utils"; import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; import addNode from "../../../../../src/pages/VisProgPage/visualProgrammingUI/utils/AddNode"; +import type { PhaseNodeData } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode"; describe('PhaseNode', () => { - beforeEach(() => resetFlowStore()); - - it('each created phase gets its own children array (store-level)', () => { + it('each created phase gets its own children array, not the same reference ', () => { + // Create nodes addNode("phase", {x:10,y:10}) addNode("phase", {x:20,y:20}) + addNode("norm", {x:30,y:30}) + addNode("norm", {x:40,y:40}) + addNode("goal", {x:50,y:50}) + // Find nodes const nodes = useFlowStore.getState().nodes; const p1 = nodes.find((x) => x.id === 'phase-1')!; const p2 = nodes.find((x) => x.id === 'phase-2')!; - // not the same reference + // expect same value, not same reference expect(p1.data.children).not.toBe(p2.data.children); - // but same initial value expect(p1.data.children).toEqual(p2.data.children); + + // Add nodes to children + let p1_data = p1.data as PhaseNodeData; + let p2_data = p2.data as PhaseNodeData; + p1_data.children.push("norm-1"); + p2_data.children.push("norm-2"); + p2_data.children.push("goal-1"); + + // check that after adding, its not the same reference, and its not the same children + expect(p1.data.children).not.toBe(p2.data.children); + expect(p1.data.children).not.toEqual(p2.data.children); + + // expect them to have the correct length. + expect(p1_data.children.length == 1); + expect(p2_data.children.length == 2); }); }); \ No newline at end of file From c167144b4d0ae8dcb962b9eb353bc6a254624c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 3 Dec 2025 11:41:14 +0100 Subject: [PATCH 8/9] fix: fix eslint issues, adjust norm test for dev merge ref: N25B-371 --- .../visualProgrammingUI/nodes/PhaseNode.tsx | 2 +- .../visualProgrammingUI/nodes/NormNode.test.tsx | 4 ++-- .../visualProgrammingUI/nodes/PhaseNode.test.tsx | 4 ++-- .../visualProgrammingUI/nodes/TriggerNode.test.tsx | 10 ++++++++-- .../visualProgrammingUI/nodes/UniversalNodes.test.tsx | 7 ++++--- test/test-utils/test-utils.tsx | 2 -- 6 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 06cb1e5..56c762c 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -79,7 +79,7 @@ export function PhaseReduce(node: Node, nodes: Node[]) { .map(([t]) => t); // children nodes - make sure to check for empty arrays - let childrenNodes: any[] = []; + let childrenNodes: Node[] = []; if (data.children) childrenNodes = nodes.filter((node) => data.children.includes(node.id)); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx index 9e3d049..598d687 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/NormNode.test.tsx @@ -115,8 +115,8 @@ describe('NormNode', () => { /> ); - let norm = screen.getByText("Norm :") - expect(norm).toBeInTheDocument; + const norm = screen.getByText("Norm :") + expect(norm).toBeInTheDocument(); }); it('should render with dragging state', () => { diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx index 43763d3..006380c 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx @@ -22,8 +22,8 @@ describe('PhaseNode', () => { expect(p1.data.children).toEqual(p2.data.children); // Add nodes to children - let p1_data = p1.data as PhaseNodeData; - let p2_data = p2.data as PhaseNodeData; + const p1_data = p1.data as PhaseNodeData; + const p2_data = p2.data as PhaseNodeData; p1_data.children.push("norm-1"); p2_data.children.push("norm-2"); p2_data.children.push("goal-1"); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx index a7c5437..9b1ff49 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/TriggerNode.test.tsx @@ -197,8 +197,14 @@ describe('TriggerNode', () => { const result = TriggerReduce(triggerNode, allNodes); expect(result).toEqual({ - label: 'Keyword Trigger', - list: [{ id: 'kw1', keyword: 'hello' }], + id: "trigger-1", + type: "keywords", + label: 'Keyword Trigger', + keywords: [ + { + "id": "kw1", + "keyword": "hello", + },], }); }); }); diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx index 182ff53..80e52b4 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/UniversalNodes.test.tsx @@ -48,9 +48,10 @@ describe('NormNode', () => { test.each(getAllTypes())('it should render %s node with the default data', (nodeType) => { const lengthBefore = screen.getAllByText(/.*/).length; - let newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {}) - let uiElement = Object.entries(NodeTypes).find(([t])=>t==nodeType)?.[1]!; - let props = { + const newNode = createNode(nodeType + "1", nodeType, {x: 200, y:200}, {}) + const uiElement = Object.entries(NodeTypes).find(([t])=>t==nodeType)?.[1]; + expect(uiElement).toBeDefined(); + const props = { id:newNode.id, type:newNode.type as string, data:newNode.data as any, diff --git a/test/test-utils/test-utils.tsx b/test/test-utils/test-utils.tsx index 76878b9..1ad371a 100644 --- a/test/test-utils/test-utils.tsx +++ b/test/test-utils/test-utils.tsx @@ -31,5 +31,3 @@ export function resetFlowStore() { }); } -// Re-export everything from testing library -export * from '@testing-library/react'; \ No newline at end of file From 95397ceccce111af897c0bef6d9ba075fc20d839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 4 Dec 2025 12:33:27 +0100 Subject: [PATCH 9/9] fix: fix the tests by simulating user actions rather than the function, and avoid the cyclic dependancy which was present ref: N25B-371 --- .../visualProgrammingUI/NodeRegistry.ts | 2 - .../components/DragDropSidebar.tsx | 42 +++++++++- .../visualProgrammingUI/nodes/PhaseNode.tsx | 1 - .../visualProgrammingUI/utils/AddNode.ts | 39 ---------- .../nodes/PhaseNode.test.tsx | 78 +++++++++++++++++-- 5 files changed, 111 insertions(+), 51 deletions(-) delete mode 100644 src/pages/VisProgPage/visualProgrammingUI/utils/AddNode.ts diff --git a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts index 8812434..84b0ec5 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts +++ b/src/pages/VisProgPage/visualProgrammingUI/NodeRegistry.ts @@ -79,7 +79,6 @@ export const NodeConnects = { export const NodeDeletes = { start: () => false, end: () => false, - test: () => false, // Used for coverage of universal/ undefined nodes } /** @@ -92,5 +91,4 @@ export const NodesInPhase = { start: () => false, end: () => false, phase: () => false, - test: () => false, // Used for coverage of universal/ undefined nodes } \ No newline at end of file diff --git a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx index 1d4d931..8440552 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/components/DragDropSidebar.tsx @@ -1,9 +1,9 @@ import { useDraggable } from '@neodrag/react'; import { useReactFlow, type XYPosition } from '@xyflow/react'; import { type ReactNode, useCallback, useRef, useState } from 'react'; +import useFlowStore from '../VisProgStores'; import styles from '../../VisProg.module.css'; import { NodeDefaults, type NodeTypes } from '../NodeRegistry' -import addNode from '../utils/AddNode'; /** * Props for a draggable node within the drag-and-drop toolbar. @@ -57,6 +57,46 @@ function DraggableNode({ className, children, nodeType, onDrop }: DraggableNodeP ); } +/** + * Adds a new node to the flow graph. + * + * Handles: + * - Automatic node ID generation based on existing nodes of the same type. + * - Loading of default data from the `NodeDefaults` registry. + * - Integration with the flow store to update global node state. + * + * @param nodeType - The type of node to create (from `NodeTypes`). + * @param position - The XY position in the flow canvas where the node will appear. + */ +function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { + const { nodes, setNodes } = useFlowStore.getState(); + + // Load any predefined data for this node type. + const defaultData = NodeDefaults[nodeType] ?? {} + + // Currently, we find out what the Id is by checking the last node and adding one. + const sameTypeNodes = nodes.filter((node) => node.type === nodeType); + const nextNumber = + sameTypeNodes.length > 0 + ? (() => { + const lastNode = sameTypeNodes[sameTypeNodes.length - 1]; + const parts = lastNode.id.split('-'); + const lastNum = Number(parts[1]); + return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1; + })() + : 1; + const id = `${nodeType}-${nextNumber}`; + + // Create new node + const newNode = { + id: id, + type: nodeType, + position, + data: JSON.parse(JSON.stringify(defaultData)) + } + setNodes([...nodes, newNode]); +} + /** * The drag-and-drop toolbar component for the visual programming interface. * diff --git a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx index 56c762c..c8ea2c0 100644 --- a/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx +++ b/src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode.tsx @@ -110,7 +110,6 @@ export function PhaseReduce(node: Node, nodes: Node[]) { * @param isThisSource whether this instance of the node was the source in the connection, true = yes. */ export function PhaseConnects(thisNode: Node, otherNode: Node, isThisSource: boolean) { - console.log("Connect functionality called.") const node = thisNode as PhaseNode const data = node.data as PhaseNodeData if (!isThisSource) diff --git a/src/pages/VisProgPage/visualProgrammingUI/utils/AddNode.ts b/src/pages/VisProgPage/visualProgrammingUI/utils/AddNode.ts deleted file mode 100644 index b73d46b..0000000 --- a/src/pages/VisProgPage/visualProgrammingUI/utils/AddNode.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { XYPosition } from "@xyflow/react"; -import { NodeDefaults, type NodeTypes } from "../NodeRegistry"; -import useFlowStore from "../VisProgStores"; - - -/** - * addNode — adds a new node to the flow using the unified class-based system. - * Keeps numbering logic for phase/norm nodes. - */ -export default function addNode(nodeType: keyof typeof NodeTypes, position: XYPosition) { - const { nodes, setNodes } = useFlowStore.getState(); - - // Find out if there's any default data about our ndoe - const defaultData = NodeDefaults[nodeType] ?? {} - - // Currently, we find out what the Id is by checking the last node and adding one - const sameTypeNodes = nodes.filter((node) => node.type === nodeType); - const nextNumber = - sameTypeNodes.length > 0 - ? (() => { - const lastNode = sameTypeNodes[sameTypeNodes.length - 1]; - const parts = lastNode.id.split('-'); - const lastNum = Number(parts[1]); - return Number.isNaN(lastNum) ? sameTypeNodes.length + 1 : lastNum + 1; - })() - : 1; - const id = `${nodeType}-${nextNumber}`; - - // Create new node - const newNode = { - id: id, - type: nodeType, - position, - // Deep copy using JSON because thats how things work: - // Ref: https://developer.mozilla.org/en-US/docs/Glossary/Deep_copy - data: JSON.parse(JSON.stringify(defaultData)) - } - setNodes([...nodes, newNode]); -} \ No newline at end of file diff --git a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx index 006380c..01de131 100644 --- a/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx +++ b/test/pages/visProgPage/visualProgrammingUI/nodes/PhaseNode.test.tsx @@ -1,16 +1,78 @@ import useFlowStore from '../../../../../src/pages/VisProgPage/visualProgrammingUI/VisProgStores'; -import addNode from "../../../../../src/pages/VisProgPage/visualProgrammingUI/utils/AddNode"; import type { PhaseNodeData } from "../../../../../src/pages/VisProgPage/visualProgrammingUI/nodes/PhaseNode"; +import { getByTestId, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import VisProgPage from '../../../../../src/pages/VisProgPage/VisProg'; +class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +window.ResizeObserver = ResizeObserver; + +jest.mock('@neodrag/react', () => ({ + useDraggable: (ref: React.RefObject, options: any) => { + // We access the real useEffect from React to attach a listener + // This bridges the gap between the test's userEvent and the component's logic + const { useEffect } = jest.requireActual('react'); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + // When the test fires a "pointerup" (end of click/drag), + // we manually trigger the library's onDragEnd callback. + const handlePointerUp = (e: PointerEvent) => { + if (options.onDragEnd) { + options.onDragEnd({ event: e }); + } + }; + + element.addEventListener('pointerup', handlePointerUp as EventListener); + return () => { + element.removeEventListener('pointerup', handlePointerUp as EventListener); + }; + }, [ref, options]); + }, +})); + describe('PhaseNode', () => { - it('each created phase gets its own children array, not the same reference ', () => { - // Create nodes - addNode("phase", {x:10,y:10}) - addNode("phase", {x:20,y:20}) - addNode("norm", {x:30,y:30}) - addNode("norm", {x:40,y:40}) - addNode("goal", {x:50,y:50}) + it('each created phase gets its own children array, not the same reference ', async () => { + const user = userEvent.setup(); + + const { container } = render(); + + // --- Mock ReactFlow bounding box --- + // Your DndToolbar checks these values: + const flowEl = container.querySelector('.react-flow'); + jest.spyOn(flowEl!, 'getBoundingClientRect').mockReturnValue({ + left: 0, + right: 800, + top: 0, + bottom: 600, + width: 800, + height: 600, + x: 0, + y: 0, + toJSON: () => {}, + }); + + // Find the draggable norm node in the toolbar + const phaseButton = getByTestId(container, 'draggable-phase') + + // Simulate dropping phase down in graph (twice) + for (let i = 0; i < 2; i++) { + await user.pointer([ + // touch the screen at element1 + {keys: '[TouchA>]', target: phaseButton}, + // move the touch pointer to element2 + {pointerName: 'TouchA', coords: {x: 300, y: 250}}, + // release the touch pointer at the last position (element2) + {keys: '[/TouchA]'}, + ]); + } // Find nodes const nodes = useFlowStore.getState().nodes;