Add functions for mapping node locations to an id

These locations differ from either local ids (like `5`) or execution ids
(like `3:4:5`) in that all nodes which are 'linked' such that changes to
one node will apply changes to other nodes will map to the same id. This
is specifically relevant for multiple instances of the same subgraph.

These IDs will actually get used in a followup commit
This commit is contained in:
Jacob Segal
2025-07-06 20:41:58 -07:00
parent a70a81c9ae
commit c8371d6089
7 changed files with 765 additions and 4 deletions

View File

@@ -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
}
})

View File

@@ -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

View File

@@ -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,

View File

@@ -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: `<immediate-contained-subgraph-uuid>:<local-node-id>`
* - For root graph nodes: `<local-node-id>`
*
* 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
}

View File

@@ -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<typeof useExecutionStore>
let workflowStore: ReturnType<typeof useWorkflowStore>
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()
})
})
})

View File

@@ -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<typeof useWorkflowStore>
let bookmarkStore: ReturnType<typeof useWorkflowBookmarkStore>
@@ -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()
})
})
})
})

View File

@@ -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)
})
})
})