diff --git a/app/src/lib/graph-interface/graph-manager.svelte.test.ts b/app/src/lib/graph-interface/graph-manager.svelte.test.ts new file mode 100644 index 0000000..de4d88c --- /dev/null +++ b/app/src/lib/graph-interface/graph-manager.svelte.test.ts @@ -0,0 +1,265 @@ +import { describe, expect, it } from 'vitest'; +import { GraphManager } from './graph-manager.svelte'; +import { + createMockNodeRegistry, + mockFloatInputNode, + mockFloatOutputNode, + mockGeometryOutputNode, + mockPathInputNode, + mockVec3OutputNode +} from './test-utils'; + +describe('GraphManager', () => { + describe('getPossibleSockets', () => { + describe('when dragging an output socket', () => { + it('should return compatible input sockets based on type', () => { + const registry = createMockNodeRegistry([ + mockFloatOutputNode, + mockFloatInputNode, + mockGeometryOutputNode, + mockPathInputNode + ]); + + const manager = new GraphManager(registry); + + const floatInputNode = manager.createNode({ + type: 'test/node/input', + position: [100, 100], + props: {} + }); + + const floatOutputNode = manager.createNode({ + type: 'test/node/output', + position: [0, 0], + props: {} + }); + + expect(floatInputNode).toBeDefined(); + expect(floatOutputNode).toBeDefined(); + + const possibleSockets = manager.getPossibleSockets({ + node: floatOutputNode!, + index: 0, + position: [0, 0] + }); + + expect(possibleSockets.length).toBe(1); + const socketNodeIds = possibleSockets.map(([node]) => node.id); + expect(socketNodeIds).toContain(floatInputNode!.id); + }); + + it('should exclude self node from possible sockets', () => { + const registry = createMockNodeRegistry([ + mockFloatOutputNode, + mockFloatInputNode + ]); + + const manager = new GraphManager(registry); + + const floatInputNode = manager.createNode({ + type: 'test/node/input', + position: [100, 100], + props: {} + }); + + expect(floatInputNode).toBeDefined(); + + const possibleSockets = manager.getPossibleSockets({ + node: floatInputNode!, + index: 'value', + position: [0, 0] + }); + + const socketNodeIds = possibleSockets.map(([node]) => node.id); + expect(socketNodeIds).not.toContain(floatInputNode!.id); + }); + + it('should exclude parent nodes from possible sockets when dragging output', () => { + const registry = createMockNodeRegistry([ + mockFloatOutputNode, + mockFloatInputNode + ]); + + const manager = new GraphManager(registry); + + const parentNode = manager.createNode({ + type: 'test/node/output', + position: [0, 0], + props: {} + }); + + const childNode = manager.createNode({ + type: 'test/node/input', + position: [100, 100], + props: {} + }); + + expect(parentNode).toBeDefined(); + expect(childNode).toBeDefined(); + + if (parentNode && childNode) { + manager.createEdge(parentNode, 0, childNode, 'value'); + } + + const possibleSockets = manager.getPossibleSockets({ + node: parentNode!, + index: 0, + position: [0, 0] + }); + + const socketNodeIds = possibleSockets.map(([node]) => node.id); + expect(socketNodeIds).not.toContain(childNode!.id); + }); + + it('should return sockets compatible with accepts property', () => { + const registry = createMockNodeRegistry([ + mockGeometryOutputNode, + mockPathInputNode + ]); + + const manager = new GraphManager(registry); + + const geometryOutputNode = manager.createNode({ + type: 'test/node/geometry', + position: [0, 0], + props: {} + }); + + const pathInputNode = manager.createNode({ + type: 'test/node/path', + position: [100, 100], + props: {} + }); + + expect(geometryOutputNode).toBeDefined(); + expect(pathInputNode).toBeDefined(); + + const possibleSockets = manager.getPossibleSockets({ + node: geometryOutputNode!, + index: 0, + position: [0, 0] + }); + + const socketNodeIds = possibleSockets.map(([node]) => node.id); + expect(socketNodeIds).toContain(pathInputNode!.id); + }); + + it('should return empty array when no compatible sockets exist', () => { + const registry = createMockNodeRegistry([ + mockVec3OutputNode, + mockFloatInputNode + ]); + + const manager = new GraphManager(registry); + + const vec3OutputNode = manager.createNode({ + type: 'test/node/vec3', + position: [0, 0], + props: {} + }); + + const floatInputNode = manager.createNode({ + type: 'test/node/input', + position: [100, 100], + props: {} + }); + + expect(vec3OutputNode).toBeDefined(); + expect(floatInputNode).toBeDefined(); + + const possibleSockets = manager.getPossibleSockets({ + node: vec3OutputNode!, + index: 0, + position: [0, 0] + }); + + const socketNodeIds = possibleSockets.map(([node]) => node.id); + expect(socketNodeIds).not.toContain(floatInputNode!.id); + expect(possibleSockets.length).toBe(0); + }); + + it('should return socket info with correct socket key for inputs', () => { + const registry = createMockNodeRegistry([ + mockFloatOutputNode, + mockFloatInputNode + ]); + + const manager = new GraphManager(registry); + + const floatOutputNode = manager.createNode({ + type: 'test/node/output', + position: [0, 0], + props: {} + }); + + const floatInputNode = manager.createNode({ + type: 'test/node/input', + position: [100, 100], + props: {} + }); + + expect(floatOutputNode).toBeDefined(); + expect(floatInputNode).toBeDefined(); + + const possibleSockets = manager.getPossibleSockets({ + node: floatOutputNode!, + index: 0, + position: [0, 0] + }); + + const matchingSocket = possibleSockets.find(([node]) => node.id === floatInputNode!.id); + expect(matchingSocket).toBeDefined(); + expect(matchingSocket![1]).toBe('value'); + }); + + it('should return multiple compatible sockets', () => { + const registry = createMockNodeRegistry([ + mockFloatOutputNode, + mockFloatInputNode, + mockGeometryOutputNode, + mockPathInputNode + ]); + + const manager = new GraphManager(registry); + + const floatOutputNode = manager.createNode({ + type: 'test/node/output', + position: [0, 0], + props: {} + }); + + const geometryOutputNode = manager.createNode({ + type: 'test/node/geometry', + position: [200, 0], + props: {} + }); + + const floatInputNode = manager.createNode({ + type: 'test/node/input', + position: [100, 100], + props: {} + }); + + const pathInputNode = manager.createNode({ + type: 'test/node/path', + position: [300, 100], + props: {} + }); + + expect(floatOutputNode).toBeDefined(); + expect(geometryOutputNode).toBeDefined(); + expect(floatInputNode).toBeDefined(); + expect(pathInputNode).toBeDefined(); + + const possibleSocketsForFloat = manager.getPossibleSockets({ + node: floatOutputNode!, + index: 0, + position: [0, 0] + }); + + expect(possibleSocketsForFloat.length).toBe(1); + expect(possibleSocketsForFloat.map(([n]) => n.id)).toContain(floatInputNode!.id); + }); + }); + }); +}); diff --git a/app/src/lib/graph-interface/graph-manager.svelte.ts b/app/src/lib/graph-interface/graph-manager.svelte.ts index 8f9c938..6d33fd2 100644 --- a/app/src/lib/graph-interface/graph-manager.svelte.ts +++ b/app/src/lib/graph-interface/graph-manager.svelte.ts @@ -757,12 +757,16 @@ export class GraphManager extends EventEmitter<{ (n) => n.id !== node.id && !parents.has(n.id) ); - // get edges from this socket - const edges = new SvelteMap( - this.getEdgesFromNode(node) - .filter((e) => e[1] === index) - .map((e) => [e[2].id, e[3]]) - ); + const edges = new SvelteMap(); + this.getEdgesFromNode(node) + .filter((e) => e[1] === index) + .forEach((e) => { + if (edges.has(e[2].id)) { + edges.get(e[2].id)?.push(e[3]); + } else { + edges.set(e[2].id, [e[3]]); + } + }); const ownType = nodeType.outputs?.[index]; @@ -775,7 +779,7 @@ export class GraphManager extends EventEmitter<{ if ( areSocketsCompatible(ownType, otherType) - && edges.get(node.id) !== key + && !edges.get(node.id)?.includes(key) ) { sockets.push([node, key]); } diff --git a/app/src/lib/graph-interface/test-utils.ts b/app/src/lib/graph-interface/test-utils.ts new file mode 100644 index 0000000..26a0a4d --- /dev/null +++ b/app/src/lib/graph-interface/test-utils.ts @@ -0,0 +1,86 @@ +import type { NodeDefinition, NodeId, NodeRegistry } from '@nodarium/types'; + +export function createMockNodeRegistry(nodes: NodeDefinition[]): NodeRegistry { + const nodesMap = new Map(nodes.map(n => [n.id, n])); + return { + status: 'ready' as const, + load: async (nodeIds: NodeId[]) => { + const loaded: NodeDefinition[] = []; + for (const id of nodeIds) { + if (nodesMap.has(id)) { + loaded.push(nodesMap.get(id)!); + } + } + return loaded; + }, + getNode: (id: string) => nodesMap.get(id as NodeId), + getAllNodes: () => Array.from(nodesMap.values()), + register: async () => { + throw new Error('Not implemented in mock'); + } + }; +} + +export const mockFloatOutputNode: NodeDefinition = { + id: 'test/node/output', + inputs: {}, + outputs: ['float'], + meta: { title: 'Float Output' }, + execute: () => new Int32Array() +}; + +export const mockFloatInputNode: NodeDefinition = { + id: 'test/node/input', + inputs: { value: { type: 'float' } }, + outputs: [], + meta: { title: 'Float Input' }, + execute: () => new Int32Array() +}; + +export const mockGeometryOutputNode: NodeDefinition = { + id: 'test/node/geometry', + inputs: {}, + outputs: ['geometry'], + meta: { title: 'Geometry Output' }, + execute: () => new Int32Array() +}; + +export const mockPathInputNode: NodeDefinition = { + id: 'test/node/path', + inputs: { input: { type: 'path', accepts: ['geometry'] } }, + outputs: [], + meta: { title: 'Path Input' }, + execute: () => new Int32Array() +}; + +export const mockVec3OutputNode: NodeDefinition = { + id: 'test/node/vec3', + inputs: {}, + outputs: ['vec3'], + meta: { title: 'Vec3 Output' }, + execute: () => new Int32Array() +}; + +export const mockIntegerInputNode: NodeDefinition = { + id: 'test/node/integer', + inputs: { value: { type: 'integer' } }, + outputs: [], + meta: { title: 'Integer Input' }, + execute: () => new Int32Array() +}; + +export const mockBooleanOutputNode: NodeDefinition = { + id: 'test/node/boolean', + inputs: {}, + outputs: ['boolean'], + meta: { title: 'Boolean Output' }, + execute: () => new Int32Array() +}; + +export const mockBooleanInputNode: NodeDefinition = { + id: 'test/node/boolean-input', + inputs: { value: { type: 'boolean' } }, + outputs: [], + meta: { title: 'Boolean Input' }, + execute: () => new Int32Array() +};