diff --git a/src/stores/executionStore.ts b/src/stores/executionStore.ts index bec879d33..9182d04f3 100644 --- a/src/stores/executionStore.ts +++ b/src/stores/executionStore.ts @@ -24,6 +24,7 @@ import type { } from '@/schemas/comfyWorkflowSchema' import { api } from '@/scripts/api' import { app } from '@/scripts/app' +import type { NodeLocatorId } from '@/types/nodeIdentification' import { useCanvasStore } from './graphStore' import { ComfyWorkflow, useWorkflowStore } from './workflowStore' @@ -309,6 +310,40 @@ export const useExecutionStore = defineStore('execution', () => { ) } + /** + * Convert execution context node IDs to NodeLocatorIds + * @param nodeId The node ID from execution context (could be hierarchical) + * @returns The NodeLocatorId + */ + const executionIdToNodeLocatorId = ( + nodeId: string | number + ): NodeLocatorId => { + const nodeIdStr = String(nodeId) + + // If it's a hierarchical ID, use the workflow store's conversion + if (nodeIdStr.includes(':')) { + const result = workflowStore.hierarchicalIdToNodeLocatorId(nodeIdStr) + // If conversion fails, return the original ID as-is + return result ?? (nodeIdStr as NodeLocatorId) + } + + // For simple node IDs, we need the active subgraph context + return workflowStore.nodeIdToNodeLocatorId(nodeIdStr) + } + + /** + * Convert a NodeLocatorId to an execution context ID (hierarchical ID) + * @param locatorId The NodeLocatorId + * @returns The hierarchical execution ID or null if conversion fails + */ + const nodeLocatorIdToExecutionId = ( + locatorId: NodeLocatorId | string + ): string | null => { + const hierarchicalId = + workflowStore.nodeLocatorIdToHierarchicalId(locatorId) + return hierarchicalId + } + return { isIdle, clientId, @@ -368,6 +403,9 @@ export const useExecutionStore = defineStore('execution', () => { unbindExecutionEvents, storePrompt, // Raw executing progress data for backward compatibility in ComfyApp. - _executingNodeProgress + _executingNodeProgress, + // NodeLocatorId conversion helpers + executionIdToNodeLocatorId, + nodeLocatorIdToExecutionId } }) diff --git a/src/stores/workflowStore.ts b/src/stores/workflowStore.ts index 6b0044e9a..a27f82644 100644 --- a/src/stores/workflowStore.ts +++ b/src/stores/workflowStore.ts @@ -4,10 +4,21 @@ import { defineStore } from 'pinia' import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue' import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema' +import type { NodeId } from '@/schemas/comfyWorkflowSchema' import { api } from '@/scripts/api' import { app as comfyApp } from '@/scripts/app' import { ChangeTracker } from '@/scripts/changeTracker' import { defaultGraphJSON } from '@/scripts/defaultGraph' +import type { + HierarchicalNodeId, + NodeLocatorId +} from '@/types/nodeIdentification' +import { + createHierarchicalNodeId, + createNodeLocatorId, + parseHierarchicalNodeId, + parseNodeLocatorId +} from '@/types/nodeIdentification' import { getPathDetails } from '@/utils/formatUtil' import { syncEntities } from '@/utils/syncUtil' import { isSubgraph } from '@/utils/typeGuardUtil' @@ -163,6 +174,15 @@ export interface WorkflowStore { /** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */ updateActiveGraph: () => void executionIdToCurrentId: (id: string) => any + nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId + hierarchicalIdToNodeLocatorId: ( + hierarchicalId: HierarchicalNodeId | string + ) => NodeLocatorId | null + nodeLocatorIdToNodeId: (locatorId: NodeLocatorId | string) => NodeId | null + nodeLocatorIdToHierarchicalId: ( + locatorId: NodeLocatorId | string, + targetSubgraph?: Subgraph + ) => HierarchicalNodeId | null } export const useWorkflowStore = defineStore('workflow', () => { @@ -488,6 +508,136 @@ export const useWorkflowStore = defineStore('workflow', () => { watch(activeWorkflow, updateActiveGraph) + /** + * Convert a node ID to a NodeLocatorId + * @param nodeId The local node ID + * @param subgraph The subgraph containing the node (defaults to active subgraph) + * @returns The NodeLocatorId (for root graph nodes, returns the node ID as-is) + */ + const nodeIdToNodeLocatorId = ( + nodeId: NodeId, + subgraph?: Subgraph + ): NodeLocatorId => { + const targetSubgraph = subgraph ?? activeSubgraph.value + if (!targetSubgraph) { + // Node is in the root graph, return the node ID as-is + return String(nodeId) as NodeLocatorId + } + + return createNodeLocatorId(targetSubgraph.id, nodeId) + } + + /** + * Convert a hierarchical ID to a NodeLocatorId + * @param hierarchicalId The hierarchical node ID (e.g., "123:456:789") + * @returns The NodeLocatorId or null if conversion fails + */ + const hierarchicalIdToNodeLocatorId = ( + hierarchicalId: HierarchicalNodeId | string + ): NodeLocatorId | null => { + // Handle simple node IDs (root graph - no colons) + if (!hierarchicalId.includes(':')) { + return hierarchicalId as NodeLocatorId + } + + const parts = parseHierarchicalNodeId(hierarchicalId) + if (!parts || parts.length === 0) return null + + const nodeId = parts[parts.length - 1] + const subgraphNodeIds = parts.slice(0, -1) + + if (subgraphNodeIds.length === 0) { + // Node is in root graph, return the node ID as-is + return String(nodeId) as NodeLocatorId + } + + try { + const subgraphs = getSubgraphsFromInstanceIds( + comfyApp.graph, + subgraphNodeIds.map((id) => String(id)) + ) + const immediateSubgraph = subgraphs[subgraphs.length - 1] + return createNodeLocatorId(immediateSubgraph.id, nodeId) + } catch { + return null + } + } + + /** + * Extract the node ID from a NodeLocatorId + * @param locatorId The NodeLocatorId + * @returns The local node ID or null if invalid + */ + const nodeLocatorIdToNodeId = ( + locatorId: NodeLocatorId | string + ): NodeId | null => { + const parsed = parseNodeLocatorId(locatorId) + return parsed?.localNodeId ?? null + } + + /** + * Convert a NodeLocatorId to a hierarchical ID for a specific context + * @param locatorId The NodeLocatorId + * @param targetSubgraph The subgraph context (defaults to active subgraph) + * @returns The hierarchical ID or null if the node is not accessible from the target context + */ + const nodeLocatorIdToHierarchicalId = ( + locatorId: NodeLocatorId | string, + targetSubgraph?: Subgraph + ): HierarchicalNodeId | null => { + const parsed = parseNodeLocatorId(locatorId) + if (!parsed) return null + + const { subgraphUuid, localNodeId } = parsed + + // If no subgraph UUID, this is a root graph node + if (!subgraphUuid) { + return String(localNodeId) as HierarchicalNodeId + } + + // Find the path from root to the subgraph with this UUID + const findSubgraphPath = ( + graph: LGraph | Subgraph, + targetUuid: string, + path: NodeId[] = [] + ): NodeId[] | null => { + if (isSubgraph(graph) && graph.id === targetUuid) { + return path + } + + for (const node of graph._nodes) { + if (node.isSubgraphNode?.() && (node as any).subgraph) { + const result = findSubgraphPath((node as any).subgraph, targetUuid, [ + ...path, + node.id + ]) + if (result) return result + } + } + + return null + } + + const path = findSubgraphPath(comfyApp.graph, subgraphUuid) + if (!path) return null + + // If we have a target subgraph, check if the path goes through it + if ( + targetSubgraph && + !path.some((_, idx) => { + const subgraphs = getSubgraphsFromInstanceIds( + comfyApp.graph, + path.slice(0, idx + 1).map((id) => String(id)) + ) + return subgraphs[subgraphs.length - 1] === targetSubgraph + }) + ) { + return null + } + + return createHierarchicalNodeId([...path, localNodeId]) + } + return { activeWorkflow, isActive, @@ -514,7 +664,11 @@ export const useWorkflowStore = defineStore('workflow', () => { isSubgraphActive, activeSubgraph, updateActiveGraph, - executionIdToCurrentId + executionIdToCurrentId, + nodeIdToNodeLocatorId, + hierarchicalIdToNodeLocatorId, + nodeLocatorIdToNodeId, + nodeLocatorIdToHierarchicalId } }) satisfies () => WorkflowStore diff --git a/src/types/index.ts b/src/types/index.ts index 20d38a345..2371a434f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -31,6 +31,16 @@ export type { ComfyApi } from '@/scripts/api' export type { ComfyApp } from '@/scripts/app' export type { ComfyNodeDef } from '@/schemas/nodeDefSchema' export type { InputSpec } from '@/schemas/nodeDefSchema' +export type { + NodeLocatorId, + HierarchicalNodeId, + isNodeLocatorId, + isHierarchicalNodeId, + parseNodeLocatorId, + createNodeLocatorId, + parseHierarchicalNodeId, + createHierarchicalNodeId +} from './nodeIdentification' export type { EmbeddingsResponse, ExtensionsResponse, diff --git a/src/types/nodeIdentification.ts b/src/types/nodeIdentification.ts new file mode 100644 index 000000000..97b2b47e3 --- /dev/null +++ b/src/types/nodeIdentification.ts @@ -0,0 +1,127 @@ +import type { NodeId } from '@/schemas/comfyWorkflowSchema' + +/** + * A globally unique identifier for nodes that maintains consistency across + * multiple instances of the same subgraph. + * + * Format: + * - For subgraph nodes: `:` + * - For root graph nodes: `` + * + * Examples: + * - "a1b2c3d4-e5f6-7890-abcd-ef1234567890:123" (node in subgraph) + * - "456" (node in root graph) + * + * Unlike hierarchical IDs which change based on the instance path, + * NodeLocatorId remains the same for all instances of a particular node. + */ +export type NodeLocatorId = string & { __brand: 'NodeLocatorId' } + +/** + * A hierarchical identifier representing a node's position in nested subgraphs. + * Also known as ExecutionId in some contexts. + * + * Format: Colon-separated path of node IDs + * Example: "123:456:789" (node 789 in subgraph 456 in subgraph 123) + */ +export type HierarchicalNodeId = string & { __brand: 'HierarchicalNodeId' } + +/** + * Type guard to check if a value is a NodeLocatorId + */ +export function isNodeLocatorId(value: unknown): value is NodeLocatorId { + if (typeof value !== 'string') return false + + // Check if it's a simple node ID (root graph node) + const parts = value.split(':') + if (parts.length === 1) { + // Simple node ID - must be non-empty + return value.length > 0 + } + + // Check for UUID:nodeId format + if (parts.length !== 2) return false + + // Check that node ID part is not empty + if (!parts[1]) return false + + // Basic UUID format check (8-4-4-4-12 hex characters) + const uuidPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + return uuidPattern.test(parts[0]) +} + +/** + * Type guard to check if a value is a HierarchicalNodeId + */ +export function isHierarchicalNodeId( + value: unknown +): value is HierarchicalNodeId { + if (typeof value !== 'string') return false + // Must contain at least one colon to be hierarchical + return value.includes(':') +} + +/** + * Parse a NodeLocatorId into its components + * @param id The NodeLocatorId to parse + * @returns The subgraph UUID and local node ID, or null if invalid + */ +export function parseNodeLocatorId( + id: string +): { subgraphUuid: string | null; localNodeId: NodeId } | null { + if (!isNodeLocatorId(id)) return null + + const parts = id.split(':') + + if (parts.length === 1) { + // Simple node ID (root graph) + return { + subgraphUuid: null, + localNodeId: isNaN(Number(id)) ? id : Number(id) + } + } + + const [subgraphUuid, localNodeId] = parts + return { + subgraphUuid, + localNodeId: isNaN(Number(localNodeId)) ? localNodeId : Number(localNodeId) + } +} + +/** + * Create a NodeLocatorId from components + * @param subgraphUuid The UUID of the immediate containing subgraph + * @param localNodeId The local node ID within that subgraph + * @returns A properly formatted NodeLocatorId + */ +export function createNodeLocatorId( + subgraphUuid: string, + localNodeId: NodeId +): NodeLocatorId { + return `${subgraphUuid}:${localNodeId}` as NodeLocatorId +} + +/** + * Parse a HierarchicalNodeId into its component node IDs + * @param id The HierarchicalNodeId to parse + * @returns Array of node IDs from root to target, or null if not hierarchical + */ +export function parseHierarchicalNodeId(id: string): NodeId[] | null { + if (!isHierarchicalNodeId(id)) return null + + return id + .split(':') + .map((part) => (isNaN(Number(part)) ? part : Number(part))) +} + +/** + * Create a HierarchicalNodeId from an array of node IDs + * @param nodeIds Array of node IDs from root to target + * @returns A properly formatted HierarchicalNodeId + */ +export function createHierarchicalNodeId( + nodeIds: NodeId[] +): HierarchicalNodeId { + return nodeIds.join(':') as HierarchicalNodeId +} diff --git a/tests-ui/tests/store/executionStore.test.ts b/tests-ui/tests/store/executionStore.test.ts index 590ce6955..0dd9451eb 100644 --- a/tests-ui/tests/store/executionStore.test.ts +++ b/tests-ui/tests/store/executionStore.test.ts @@ -2,10 +2,11 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { useExecutionStore } from '@/stores/executionStore' +import { useWorkflowStore } from '@/stores/workflowStore' // Remove any previous global types declare global { - // Empty interface to override any previous declarations + // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface Window {} } @@ -80,3 +81,93 @@ describe('executionStore - display_component handling', () => { expect(mockShowChatHistory).not.toHaveBeenCalled() }) }) + +describe('useExecutionStore - NodeLocatorId conversions', () => { + let store: ReturnType + let workflowStore: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + store = useExecutionStore() + workflowStore = useWorkflowStore() + vi.clearAllMocks() + }) + + describe('executionIdToNodeLocatorId', () => { + it('should convert hierarchical execution ID to NodeLocatorId', () => { + const mockNodeLocatorId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456' + vi.spyOn(workflowStore, 'hierarchicalIdToNodeLocatorId').mockReturnValue( + mockNodeLocatorId as any + ) + + const result = store.executionIdToNodeLocatorId('123:456') + + expect(workflowStore.hierarchicalIdToNodeLocatorId).toHaveBeenCalledWith( + '123:456' + ) + expect(result).toBe(mockNodeLocatorId) + }) + + it('should convert simple node ID to NodeLocatorId', () => { + const mockNodeLocatorId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:123' + vi.spyOn(workflowStore, 'nodeIdToNodeLocatorId').mockReturnValue( + mockNodeLocatorId as any + ) + + const result = store.executionIdToNodeLocatorId('123') + + expect(workflowStore.nodeIdToNodeLocatorId).toHaveBeenCalledWith('123') + expect(result).toBe(mockNodeLocatorId) + }) + + it('should handle numeric node IDs', () => { + const mockNodeLocatorId = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:123' + vi.spyOn(workflowStore, 'nodeIdToNodeLocatorId').mockReturnValue( + mockNodeLocatorId as any + ) + + const result = store.executionIdToNodeLocatorId(123) + + expect(workflowStore.nodeIdToNodeLocatorId).toHaveBeenCalledWith('123') + expect(result).toBe(mockNodeLocatorId) + }) + + it('should return null when conversion fails', () => { + vi.spyOn(workflowStore, 'hierarchicalIdToNodeLocatorId').mockReturnValue( + null + ) + + const result = store.executionIdToNodeLocatorId('123:456') + + expect(result).toBeNull() + }) + }) + + describe('nodeLocatorIdToExecutionId', () => { + it('should convert NodeLocatorId to hierarchical execution ID', () => { + const mockHierarchicalId = '123:456' + vi.spyOn(workflowStore, 'nodeLocatorIdToHierarchicalId').mockReturnValue( + mockHierarchicalId as any + ) + + const result = store.nodeLocatorIdToExecutionId( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456' + ) + + expect(workflowStore.nodeLocatorIdToHierarchicalId).toHaveBeenCalledWith( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456' + ) + expect(result).toBe(mockHierarchicalId) + }) + + it('should return null when conversion fails', () => { + vi.spyOn(workflowStore, 'nodeLocatorIdToHierarchicalId').mockReturnValue( + null + ) + + const result = store.nodeLocatorIdToExecutionId('invalid:format') + + expect(result).toBeNull() + }) + }) +}) diff --git a/tests-ui/tests/store/workflowStore.test.ts b/tests-ui/tests/store/workflowStore.test.ts index 4c5668c8f..ac0be2f18 100644 --- a/tests-ui/tests/store/workflowStore.test.ts +++ b/tests-ui/tests/store/workflowStore.test.ts @@ -1,3 +1,4 @@ +import type { Subgraph } from '@comfyorg/litegraph' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' @@ -11,6 +12,7 @@ import { useWorkflowBookmarkStore, useWorkflowStore } from '@/stores/workflowStore' +import { isSubgraph } from '@/utils/typeGuardUtil' // Add mock for api at the top of the file vi.mock('@/scripts/api', () => ({ @@ -26,10 +28,15 @@ vi.mock('@/scripts/api', () => ({ // Mock comfyApp globally for the store setup vi.mock('@/scripts/app', () => ({ app: { - canvas: null // Start with canvas potentially undefined or null + canvas: {} // Start with empty canvas object } })) +// Mock isSubgraph +vi.mock('@/utils/typeGuardUtil', () => ({ + isSubgraph: vi.fn(() => false) +})) + describe('useWorkflowStore', () => { let store: ReturnType let bookmarkStore: ReturnType @@ -569,4 +576,131 @@ describe('useWorkflowStore', () => { expect(store.activeSubgraph).toBeUndefined() }) }) + + describe('NodeLocatorId conversions', () => { + beforeEach(() => { + // Setup mock graph structure with subgraphs + const mockSubgraph = { + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + _nodes: [] + } + + const mockNode = { + id: 123, + isSubgraphNode: () => true, + subgraph: mockSubgraph + } + + const mockRootGraph = { + _nodes: [mockNode], + subgraphs: new Map([[mockSubgraph.id, mockSubgraph]]), + getNodeById: (id: string | number) => { + if (String(id) === '123') return mockNode + return null + } + } + + vi.mocked(comfyApp).graph = mockRootGraph as any + vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any + store.activeSubgraph = mockSubgraph as any + }) + + describe('nodeIdToNodeLocatorId', () => { + it('should convert node ID to NodeLocatorId for subgraph nodes', () => { + const result = store.nodeIdToNodeLocatorId(456) + expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456') + }) + + it('should return simple node ID for root graph nodes', () => { + store.activeSubgraph = undefined + const result = store.nodeIdToNodeLocatorId(123) + expect(result).toBe('123') + }) + + it('should use provided subgraph instead of active one', () => { + const customSubgraph = { + id: 'custom-uuid-1234-5678-90ab-cdef12345678' + } as any + const result = store.nodeIdToNodeLocatorId(789, customSubgraph) + expect(result).toBe('custom-uuid-1234-5678-90ab-cdef12345678:789') + }) + }) + + describe('hierarchicalIdToNodeLocatorId', () => { + it('should convert hierarchical ID to NodeLocatorId', () => { + const result = store.hierarchicalIdToNodeLocatorId('123:456') + expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456') + }) + + it('should return simple node ID for root level nodes', () => { + const result = store.hierarchicalIdToNodeLocatorId('123') + expect(result).toBe('123') + }) + + it('should return null for invalid hierarchical IDs', () => { + const result = store.hierarchicalIdToNodeLocatorId('999:456') + expect(result).toBeNull() + }) + }) + + describe('nodeLocatorIdToNodeId', () => { + it('should extract node ID from NodeLocatorId', () => { + const result = store.nodeLocatorIdToNodeId( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456' + ) + expect(result).toBe(456) + }) + + it('should handle string node IDs', () => { + const result = store.nodeLocatorIdToNodeId( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:node_1' + ) + expect(result).toBe('node_1') + }) + + it('should handle simple node IDs (root graph)', () => { + const result = store.nodeLocatorIdToNodeId('123') + expect(result).toBe(123) + + const stringResult = store.nodeLocatorIdToNodeId('node_1') + expect(stringResult).toBe('node_1') + }) + + it('should return null for invalid NodeLocatorId', () => { + const result = store.nodeLocatorIdToNodeId('invalid:format') + expect(result).toBeNull() + }) + }) + + describe('nodeLocatorIdToHierarchicalId', () => { + it('should convert NodeLocatorId to hierarchical ID', () => { + // Need to mock isSubgraph to identify our mockSubgraph + vi.mocked(isSubgraph).mockImplementation((obj): obj is Subgraph => { + return obj === store.activeSubgraph + }) + + const result = store.nodeLocatorIdToHierarchicalId( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456' + ) + expect(result).toBe('123:456') + }) + + it('should handle simple node IDs (root graph)', () => { + const result = store.nodeLocatorIdToHierarchicalId('123') + expect(result).toBe('123') + }) + + it('should return null for unknown subgraph UUID', () => { + const result = store.nodeLocatorIdToHierarchicalId( + 'unknown-uuid-1234-5678-90ab-cdef12345678:456' + ) + expect(result).toBeNull() + }) + + it('should return null for invalid NodeLocatorId', () => { + const result = store.nodeLocatorIdToHierarchicalId('invalid:format') + expect(result).toBeNull() + }) + }) + }) }) diff --git a/tests-ui/tests/types/nodeIdentification.test.ts b/tests-ui/tests/types/nodeIdentification.test.ts new file mode 100644 index 000000000..ae9f302ea --- /dev/null +++ b/tests-ui/tests/types/nodeIdentification.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest' + +import type { NodeId } from '@/schemas/comfyWorkflowSchema' +import { + type NodeLocatorId, + createHierarchicalNodeId, + createNodeLocatorId, + isHierarchicalNodeId, + isNodeLocatorId, + parseHierarchicalNodeId, + parseNodeLocatorId +} from '@/types/nodeIdentification' + +describe('nodeIdentification', () => { + describe('NodeLocatorId', () => { + const validUuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + const validNodeId = '123' + const validNodeLocatorId = `${validUuid}:${validNodeId}` as NodeLocatorId + + describe('isNodeLocatorId', () => { + it('should return true for valid NodeLocatorId', () => { + expect(isNodeLocatorId(validNodeLocatorId)).toBe(true) + expect(isNodeLocatorId(`${validUuid}:456`)).toBe(true) + expect(isNodeLocatorId(`${validUuid}:node_1`)).toBe(true) + // Simple node IDs (root graph) + expect(isNodeLocatorId('123')).toBe(true) + expect(isNodeLocatorId('node_1')).toBe(true) + expect(isNodeLocatorId('5')).toBe(true) + }) + + it('should return false for invalid formats', () => { + expect(isNodeLocatorId('123:456')).toBe(false) // No UUID in first part + expect(isNodeLocatorId('not-a-uuid:123')).toBe(false) + expect(isNodeLocatorId('')).toBe(false) // Empty string + expect(isNodeLocatorId(':123')).toBe(false) // Empty UUID + expect(isNodeLocatorId(`${validUuid}:`)).toBe(false) // Empty node ID + expect(isNodeLocatorId(`${validUuid}:123:456`)).toBe(false) // Too many parts + expect(isNodeLocatorId(123)).toBe(false) // Not a string + expect(isNodeLocatorId(null)).toBe(false) + expect(isNodeLocatorId(undefined)).toBe(false) + }) + + it('should validate UUID format correctly', () => { + // Valid UUID formats + expect( + isNodeLocatorId('00000000-0000-0000-0000-000000000000:123') + ).toBe(true) + expect( + isNodeLocatorId('A1B2C3D4-E5F6-7890-ABCD-EF1234567890:123') + ).toBe(true) + + // Invalid UUID formats + expect(isNodeLocatorId('00000000-0000-0000-0000-00000000000:123')).toBe( + false + ) // Too short + expect( + isNodeLocatorId('00000000-0000-0000-0000-0000000000000:123') + ).toBe(false) // Too long + expect( + isNodeLocatorId('00000000_0000_0000_0000_000000000000:123') + ).toBe(false) // Wrong separator + expect( + isNodeLocatorId('g0000000-0000-0000-0000-000000000000:123') + ).toBe(false) // Invalid hex + }) + }) + + describe('parseNodeLocatorId', () => { + it('should parse valid NodeLocatorId', () => { + const result = parseNodeLocatorId(validNodeLocatorId) + expect(result).toEqual({ + subgraphUuid: validUuid, + localNodeId: 123 + }) + }) + + it('should handle string node IDs', () => { + const stringNodeId = `${validUuid}:node_1` + const result = parseNodeLocatorId(stringNodeId) + expect(result).toEqual({ + subgraphUuid: validUuid, + localNodeId: 'node_1' + }) + }) + + it('should handle simple node IDs (root graph)', () => { + const result = parseNodeLocatorId('123') + expect(result).toEqual({ + subgraphUuid: null, + localNodeId: 123 + }) + + const stringResult = parseNodeLocatorId('node_1') + expect(stringResult).toEqual({ + subgraphUuid: null, + localNodeId: 'node_1' + }) + }) + + it('should return null for invalid formats', () => { + expect(parseNodeLocatorId('123:456')).toBeNull() // No UUID in first part + expect(parseNodeLocatorId('')).toBeNull() + }) + }) + + describe('createNodeLocatorId', () => { + it('should create NodeLocatorId from components', () => { + const result = createNodeLocatorId(validUuid, 123) + expect(result).toBe(validNodeLocatorId) + expect(isNodeLocatorId(result)).toBe(true) + }) + + it('should handle string node IDs', () => { + const result = createNodeLocatorId(validUuid, 'node_1') + expect(result).toBe(`${validUuid}:node_1`) + expect(isNodeLocatorId(result)).toBe(true) + }) + }) + }) + + describe('HierarchicalNodeId', () => { + describe('isHierarchicalNodeId', () => { + it('should return true for hierarchical IDs', () => { + expect(isHierarchicalNodeId('123:456')).toBe(true) + expect(isHierarchicalNodeId('123:456:789')).toBe(true) + expect(isHierarchicalNodeId('node_1:node_2')).toBe(true) + }) + + it('should return false for non-hierarchical IDs', () => { + expect(isHierarchicalNodeId('123')).toBe(false) + expect(isHierarchicalNodeId('node_1')).toBe(false) + expect(isHierarchicalNodeId('')).toBe(false) + expect(isHierarchicalNodeId(123)).toBe(false) + expect(isHierarchicalNodeId(null)).toBe(false) + expect(isHierarchicalNodeId(undefined)).toBe(false) + }) + }) + + describe('parseHierarchicalNodeId', () => { + it('should parse hierarchical IDs correctly', () => { + expect(parseHierarchicalNodeId('123:456')).toEqual([123, 456]) + expect(parseHierarchicalNodeId('123:456:789')).toEqual([123, 456, 789]) + expect(parseHierarchicalNodeId('node_1:node_2')).toEqual([ + 'node_1', + 'node_2' + ]) + expect(parseHierarchicalNodeId('123:node_2:456')).toEqual([ + 123, + 'node_2', + 456 + ]) + }) + + it('should return null for non-hierarchical IDs', () => { + expect(parseHierarchicalNodeId('123')).toBeNull() + expect(parseHierarchicalNodeId('')).toBeNull() + }) + }) + + describe('createHierarchicalNodeId', () => { + it('should create hierarchical IDs from node arrays', () => { + expect(createHierarchicalNodeId([123, 456])).toBe('123:456') + expect(createHierarchicalNodeId([123, 456, 789])).toBe('123:456:789') + expect(createHierarchicalNodeId(['node_1', 'node_2'])).toBe( + 'node_1:node_2' + ) + expect(createHierarchicalNodeId([123, 'node_2', 456])).toBe( + '123:node_2:456' + ) + }) + + it('should handle single node ID', () => { + const result = createHierarchicalNodeId([123]) + expect(result).toBe('123') + // Single node IDs are not hierarchical + expect(isHierarchicalNodeId(result)).toBe(false) + }) + + it('should handle empty array', () => { + expect(createHierarchicalNodeId([])).toBe('') + }) + }) + }) + + describe('Integration tests', () => { + it('should round-trip NodeLocatorId correctly', () => { + const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + const nodeId: NodeId = 123 + + const locatorId = createNodeLocatorId(uuid, nodeId) + const parsed = parseNodeLocatorId(locatorId) + + expect(parsed).toBeTruthy() + expect(parsed!.subgraphUuid).toBe(uuid) + expect(parsed!.localNodeId).toBe(nodeId) + }) + + it('should round-trip HierarchicalNodeId correctly', () => { + const nodeIds: NodeId[] = [123, 'node_2', 456] + + const hierarchicalId = createHierarchicalNodeId(nodeIds) + const parsed = parseHierarchicalNodeId(hierarchicalId) + + expect(parsed).toEqual(nodeIds) + }) + }) +})