mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
chore: improve codecov
This commit is contained in:
parent
295306a30d
commit
577ab01bbb
@ -1,12 +1,17 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import type { WorkflowProps } from '@/app/components/workflow'
|
import type { WorkflowProps } from '@/app/components/workflow'
|
||||||
import { fireEvent, render, screen } from '@testing-library/react'
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
|
import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type'
|
||||||
import WorkflowMain from '../workflow-main'
|
import WorkflowMain from '../workflow-main'
|
||||||
|
|
||||||
const mockSetFeatures = vi.fn()
|
const mockSetFeatures = vi.fn()
|
||||||
const mockSetConversationVariables = vi.fn()
|
const mockSetConversationVariables = vi.fn()
|
||||||
const mockSetEnvironmentVariables = vi.fn()
|
const mockSetEnvironmentVariables = vi.fn()
|
||||||
|
const mockHandleUpdateWorkflowCanvas = vi.hoisted(() => vi.fn())
|
||||||
|
const mockFetchWorkflowDraft = vi.hoisted(() => vi.fn())
|
||||||
|
const mockOnVarsAndFeaturesUpdate = vi.hoisted(() => vi.fn())
|
||||||
|
const mockOnWorkflowUpdate = vi.hoisted(() => vi.fn())
|
||||||
|
const mockOnSyncRequest = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
const hookFns = {
|
const hookFns = {
|
||||||
doSyncWorkflowDraft: vi.fn(),
|
doSyncWorkflowDraft: vi.fn(),
|
||||||
@ -44,9 +49,24 @@ const hookFns = {
|
|||||||
invalidateConversationVarValues: vi.fn(),
|
invalidateConversationVarValues: vi.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const collaborationRuntime = vi.hoisted(() => ({
|
||||||
|
startCursorTracking: vi.fn(),
|
||||||
|
stopCursorTracking: vi.fn(),
|
||||||
|
onlineUsers: [] as Array<{ user_id: string, username: string, avatar: string, sid: string }>,
|
||||||
|
cursors: {} as Record<string, { x: number, y: number, userId: string, timestamp: number }>,
|
||||||
|
isConnected: false,
|
||||||
|
isEnabled: false,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const collaborationListeners = vi.hoisted(() => ({
|
||||||
|
varsAndFeaturesUpdate: null as null | ((update: unknown) => void | Promise<void>),
|
||||||
|
workflowUpdate: null as null | (() => void | Promise<void>),
|
||||||
|
syncRequest: null as null | (() => void),
|
||||||
|
}))
|
||||||
|
|
||||||
let capturedContextProps: Record<string, unknown> | null = null
|
let capturedContextProps: Record<string, unknown> | null = null
|
||||||
|
|
||||||
type MockWorkflowWithInnerContextProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport' | 'onWorkflowDataUpdate'> & {
|
type MockWorkflowWithInnerContextProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport' | 'onWorkflowDataUpdate' | 'cursors' | 'myUserId' | 'onlineUsers'> & {
|
||||||
hooksStore?: Record<string, unknown>
|
hooksStore?: Record<string, unknown>
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
}
|
}
|
||||||
@ -82,21 +102,42 @@ vi.mock('reactflow', () => ({
|
|||||||
|
|
||||||
vi.mock('@/app/components/workflow/collaboration/hooks/use-collaboration', () => ({
|
vi.mock('@/app/components/workflow/collaboration/hooks/use-collaboration', () => ({
|
||||||
useCollaboration: () => ({
|
useCollaboration: () => ({
|
||||||
startCursorTracking: vi.fn(),
|
startCursorTracking: collaborationRuntime.startCursorTracking,
|
||||||
stopCursorTracking: vi.fn(),
|
stopCursorTracking: collaborationRuntime.stopCursorTracking,
|
||||||
onlineUsers: [],
|
onlineUsers: collaborationRuntime.onlineUsers,
|
||||||
cursors: {},
|
cursors: collaborationRuntime.cursors,
|
||||||
isConnected: false,
|
isConnected: collaborationRuntime.isConnected,
|
||||||
isEnabled: false,
|
isEnabled: collaborationRuntime.isEnabled,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/workflow/hooks/use-workflow-interactions', () => ({
|
vi.mock('@/app/components/workflow/hooks/use-workflow-interactions', () => ({
|
||||||
useWorkflowUpdate: () => ({
|
useWorkflowUpdate: () => ({
|
||||||
handleUpdateWorkflowCanvas: vi.fn(),
|
handleUpdateWorkflowCanvas: mockHandleUpdateWorkflowCanvas,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
|
||||||
|
collaborationManager: {
|
||||||
|
onVarsAndFeaturesUpdate: mockOnVarsAndFeaturesUpdate.mockImplementation((handler: (update: unknown) => void | Promise<void>) => {
|
||||||
|
collaborationListeners.varsAndFeaturesUpdate = handler
|
||||||
|
return vi.fn()
|
||||||
|
}),
|
||||||
|
onWorkflowUpdate: mockOnWorkflowUpdate.mockImplementation((handler: () => void | Promise<void>) => {
|
||||||
|
collaborationListeners.workflowUpdate = handler
|
||||||
|
return vi.fn()
|
||||||
|
}),
|
||||||
|
onSyncRequest: mockOnSyncRequest.mockImplementation((handler: () => void) => {
|
||||||
|
collaborationListeners.syncRequest = handler
|
||||||
|
return vi.fn()
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/service/workflow', () => ({
|
||||||
|
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/app/components/workflow', () => ({
|
vi.mock('@/app/components/workflow', () => ({
|
||||||
WorkflowWithInnerContext: ({
|
WorkflowWithInnerContext: ({
|
||||||
nodes,
|
nodes,
|
||||||
@ -104,6 +145,9 @@ vi.mock('@/app/components/workflow', () => ({
|
|||||||
viewport,
|
viewport,
|
||||||
onWorkflowDataUpdate,
|
onWorkflowDataUpdate,
|
||||||
hooksStore,
|
hooksStore,
|
||||||
|
cursors,
|
||||||
|
myUserId,
|
||||||
|
onlineUsers,
|
||||||
children,
|
children,
|
||||||
}: MockWorkflowWithInnerContextProps) => {
|
}: MockWorkflowWithInnerContextProps) => {
|
||||||
capturedContextProps = {
|
capturedContextProps = {
|
||||||
@ -111,6 +155,9 @@ vi.mock('@/app/components/workflow', () => ({
|
|||||||
edges,
|
edges,
|
||||||
viewport,
|
viewport,
|
||||||
hooksStore,
|
hooksStore,
|
||||||
|
cursors,
|
||||||
|
myUserId,
|
||||||
|
onlineUsers,
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div data-testid="workflow-inner-context">
|
<div data-testid="workflow-inner-context">
|
||||||
@ -221,6 +268,16 @@ describe('WorkflowMain', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
capturedContextProps = null
|
capturedContextProps = null
|
||||||
|
collaborationRuntime.startCursorTracking.mockReset()
|
||||||
|
collaborationRuntime.stopCursorTracking.mockReset()
|
||||||
|
collaborationRuntime.onlineUsers = []
|
||||||
|
collaborationRuntime.cursors = {}
|
||||||
|
collaborationRuntime.isConnected = false
|
||||||
|
collaborationRuntime.isEnabled = false
|
||||||
|
collaborationListeners.varsAndFeaturesUpdate = null
|
||||||
|
collaborationListeners.workflowUpdate = null
|
||||||
|
collaborationListeners.syncRequest = null
|
||||||
|
mockFetchWorkflowDraft.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render the inner workflow context with children and forwarded graph props', () => {
|
it('should render the inner workflow context with children and forwarded graph props', () => {
|
||||||
@ -328,4 +385,79 @@ describe('WorkflowMain', () => {
|
|||||||
configsMap: { flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } },
|
configsMap: { flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('passes collaboration props and tracks cursors when collaboration is enabled', () => {
|
||||||
|
collaborationRuntime.isEnabled = true
|
||||||
|
collaborationRuntime.isConnected = true
|
||||||
|
collaborationRuntime.onlineUsers = [{ user_id: 'u-1', username: 'Alice', avatar: '', sid: 'sid-1' }]
|
||||||
|
collaborationRuntime.cursors = {
|
||||||
|
'current-user': { x: 1, y: 2, userId: 'current-user', timestamp: 1 },
|
||||||
|
'user-other': { x: 20, y: 30, userId: 'user-other', timestamp: 2 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<WorkflowMain
|
||||||
|
nodes={[]}
|
||||||
|
edges={[]}
|
||||||
|
viewport={{ x: 0, y: 0, zoom: 1 }}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(collaborationRuntime.startCursorTracking).toHaveBeenCalled()
|
||||||
|
expect(capturedContextProps).toMatchObject({
|
||||||
|
myUserId: 'current-user',
|
||||||
|
onlineUsers: [{ user_id: 'u-1' }],
|
||||||
|
cursors: {
|
||||||
|
'user-other': expect.objectContaining({ userId: 'user-other' }),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
unmount()
|
||||||
|
expect(collaborationRuntime.stopCursorTracking).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('subscribes collaboration listeners and handles sync/workflow update callbacks', async () => {
|
||||||
|
collaborationRuntime.isEnabled = true
|
||||||
|
mockFetchWorkflowDraft.mockResolvedValue({
|
||||||
|
features: {
|
||||||
|
file_upload: { enabled: true },
|
||||||
|
opening_statement: 'hello',
|
||||||
|
},
|
||||||
|
conversation_variables: [],
|
||||||
|
environment_variables: [],
|
||||||
|
graph: {
|
||||||
|
nodes: [{ id: 'n-1' }],
|
||||||
|
edges: [{ id: 'e-1' }],
|
||||||
|
viewport: { x: 3, y: 4, zoom: 1.2 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WorkflowMain
|
||||||
|
nodes={[]}
|
||||||
|
edges={[]}
|
||||||
|
viewport={{ x: 0, y: 0, zoom: 1 }}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockOnVarsAndFeaturesUpdate).toHaveBeenCalled()
|
||||||
|
expect(mockOnWorkflowUpdate).toHaveBeenCalled()
|
||||||
|
expect(mockOnSyncRequest).toHaveBeenCalled()
|
||||||
|
|
||||||
|
collaborationListeners.syncRequest?.()
|
||||||
|
expect(hookFns.doSyncWorkflowDraft).toHaveBeenCalled()
|
||||||
|
|
||||||
|
await collaborationListeners.varsAndFeaturesUpdate?.({})
|
||||||
|
await collaborationListeners.workflowUpdate?.()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/apps/app-1/workflows/draft')
|
||||||
|
expect(mockSetFeatures).toHaveBeenCalled()
|
||||||
|
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||||
|
nodes: [{ id: 'n-1' }],
|
||||||
|
edges: [{ id: 'e-1' }],
|
||||||
|
viewport: { x: 3, y: 4, zoom: 1.2 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { BaseEdge, internalsSymbol, Position, ReactFlowProvider, useStoreApi } f
|
|||||||
import { FlowType } from '@/types/common'
|
import { FlowType } from '@/types/common'
|
||||||
import { WORKFLOW_DATA_UPDATE } from '../constants'
|
import { WORKFLOW_DATA_UPDATE } from '../constants'
|
||||||
import { Workflow } from '../index'
|
import { Workflow } from '../index'
|
||||||
|
import { ControlMode } from '../types'
|
||||||
import { renderWorkflowComponent } from './workflow-test-env'
|
import { renderWorkflowComponent } from './workflow-test-env'
|
||||||
|
|
||||||
type WorkflowUpdateEvent = {
|
type WorkflowUpdateEvent = {
|
||||||
@ -23,6 +24,32 @@ const reactFlowBridge = vi.hoisted(() => ({
|
|||||||
store: null as null | ReturnType<typeof useStoreApi>,
|
store: null as null | ReturnType<typeof useStoreApi>,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const collaborationBridge = vi.hoisted(() => ({
|
||||||
|
graphImportHandler: null as null | ((payload: { nodes: Node[], edges: Edge[] }) => void),
|
||||||
|
historyActionHandler: null as null | ((payload: unknown) => void),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const toastInfoMock = vi.hoisted(() => vi.fn())
|
||||||
|
|
||||||
|
const workflowCommentState = vi.hoisted(() => ({
|
||||||
|
comments: [] as Array<Record<string, unknown>>,
|
||||||
|
pendingComment: null as null | { elementX: number, elementY: number },
|
||||||
|
activeComment: null as null | Record<string, unknown>,
|
||||||
|
activeCommentLoading: false,
|
||||||
|
replySubmitting: false,
|
||||||
|
replyUpdating: false,
|
||||||
|
handleCommentSubmit: vi.fn(),
|
||||||
|
handleCommentCancel: vi.fn(),
|
||||||
|
handleCommentIconClick: vi.fn(),
|
||||||
|
handleActiveCommentClose: vi.fn(),
|
||||||
|
handleCommentResolve: vi.fn(),
|
||||||
|
handleCommentDelete: vi.fn(async () => {}),
|
||||||
|
handleCommentReply: vi.fn(),
|
||||||
|
handleCommentReplyUpdate: vi.fn(),
|
||||||
|
handleCommentReplyDelete: vi.fn(async () => {}),
|
||||||
|
handleCommentPositionUpdate: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
const workflowHookMocks = vi.hoisted(() => ({
|
const workflowHookMocks = vi.hoisted(() => ({
|
||||||
handleNodeDragStart: vi.fn(),
|
handleNodeDragStart: vi.fn(),
|
||||||
handleNodeDrag: vi.fn(),
|
handleNodeDrag: vi.fn(),
|
||||||
@ -137,6 +164,109 @@ vi.mock('@/service/workflow', () => ({
|
|||||||
fetchAllInspectVars: vi.fn().mockResolvedValue([]),
|
fetchAllInspectVars: vi.fn().mockResolvedValue([]),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||||
|
toast: {
|
||||||
|
info: toastInfoMock,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../collaboration/core/collaboration-manager', () => ({
|
||||||
|
collaborationManager: {
|
||||||
|
onGraphImport: (handler: (payload: { nodes: Node[], edges: Edge[] }) => void) => {
|
||||||
|
collaborationBridge.graphImportHandler = handler
|
||||||
|
return vi.fn()
|
||||||
|
},
|
||||||
|
onHistoryAction: (handler: (payload: unknown) => void) => {
|
||||||
|
collaborationBridge.historyActionHandler = handler
|
||||||
|
return vi.fn()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../comment-manager', () => ({
|
||||||
|
default: () => <div data-testid="comment-manager" />,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../comment/cursor', () => ({
|
||||||
|
CommentCursor: () => <div data-testid="comment-cursor" />,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../comment/comment-input', () => ({
|
||||||
|
CommentInput: ({ disabled, onCancel }: { disabled?: boolean, onCancel?: () => void }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={disabled ? 'comment-input-preview' : 'comment-input-active'}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
comment-input
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../comment/comment-icon', () => ({
|
||||||
|
CommentIcon: ({
|
||||||
|
comment,
|
||||||
|
onClick,
|
||||||
|
onPositionUpdate,
|
||||||
|
}: {
|
||||||
|
comment: { id: string }
|
||||||
|
onClick?: () => void
|
||||||
|
onPositionUpdate?: (position: { elementX: number, elementY: number }) => void
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={`comment-icon-${comment.id}`}
|
||||||
|
onClick={() => {
|
||||||
|
onClick?.()
|
||||||
|
onPositionUpdate?.({ elementX: 1, elementY: 2 })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
icon
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../comment/thread', () => ({
|
||||||
|
CommentThread: ({
|
||||||
|
onDelete,
|
||||||
|
onReplyDelete,
|
||||||
|
onNext,
|
||||||
|
}: {
|
||||||
|
onDelete?: () => void
|
||||||
|
onReplyDelete?: (replyId: string) => void
|
||||||
|
onNext?: () => void
|
||||||
|
}) => (
|
||||||
|
<div data-testid="comment-thread">
|
||||||
|
<button type="button" onClick={onDelete}>delete-thread</button>
|
||||||
|
<button type="button" onClick={() => onReplyDelete?.('reply-1')}>delete-reply</button>
|
||||||
|
<button type="button" onClick={onNext}>next-comment</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../hooks/use-workflow-comment', () => ({
|
||||||
|
useWorkflowComment: () => workflowCommentState,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../base/confirm', () => ({
|
||||||
|
default: ({
|
||||||
|
isShow,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
isShow: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) => isShow
|
||||||
|
? (
|
||||||
|
<div data-testid="confirm-dialog">
|
||||||
|
<button type="button" onClick={onConfirm}>confirm</button>
|
||||||
|
<button type="button" onClick={onCancel}>cancel</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('../candidate-node', () => ({
|
vi.mock('../candidate-node', () => ({
|
||||||
default: () => null,
|
default: () => null,
|
||||||
}))
|
}))
|
||||||
@ -336,6 +466,14 @@ describe('Workflow edge event wiring', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
eventEmitterState.subscription = null
|
eventEmitterState.subscription = null
|
||||||
reactFlowBridge.store = null
|
reactFlowBridge.store = null
|
||||||
|
collaborationBridge.graphImportHandler = null
|
||||||
|
collaborationBridge.historyActionHandler = null
|
||||||
|
workflowCommentState.comments = []
|
||||||
|
workflowCommentState.pendingComment = null
|
||||||
|
workflowCommentState.activeComment = null
|
||||||
|
workflowCommentState.activeCommentLoading = false
|
||||||
|
workflowCommentState.replySubmitting = false
|
||||||
|
workflowCommentState.replyUpdating = false
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should forward pane, node and edge-change events to workflow handlers when emitted by the canvas', async () => {
|
it('should forward pane, node and edge-change events to workflow handlers when emitted by the canvas', async () => {
|
||||||
@ -415,4 +553,98 @@ describe('Workflow edge event wiring', () => {
|
|||||||
|
|
||||||
expect(store.getState().edgeMenu).toBeUndefined()
|
expect(store.getState().edgeMenu).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should sync graph import events and show history action toast', async () => {
|
||||||
|
renderSubject()
|
||||||
|
|
||||||
|
const importedNodes = [createInitializedNode('node-3', 480, 'Workflow node node-3')] as unknown as Node[]
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
collaborationBridge.graphImportHandler?.({
|
||||||
|
nodes: importedNodes,
|
||||||
|
edges: [],
|
||||||
|
})
|
||||||
|
collaborationBridge.historyActionHandler?.({ action: 'undo' })
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Workflow node node-3')).toBeInTheDocument()
|
||||||
|
expect(toastInfoMock).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render comment overlays and execute comment actions in comment mode', async () => {
|
||||||
|
workflowCommentState.comments = [
|
||||||
|
{ id: 'comment-1', resolved: false },
|
||||||
|
{ id: 'comment-2', resolved: false },
|
||||||
|
]
|
||||||
|
workflowCommentState.activeComment = { id: 'comment-1', resolved: false }
|
||||||
|
workflowCommentState.pendingComment = { elementX: 20, elementY: 30 }
|
||||||
|
|
||||||
|
const { container, store } = renderSubject({
|
||||||
|
initialStoreState: {
|
||||||
|
controlMode: ControlMode.Comment,
|
||||||
|
showUserComments: true,
|
||||||
|
showResolvedComments: false,
|
||||||
|
isCommentPlacing: true,
|
||||||
|
pendingComment: null,
|
||||||
|
isCommentPreviewHovering: true,
|
||||||
|
mousePosition: {
|
||||||
|
pageX: 100,
|
||||||
|
pageY: 120,
|
||||||
|
elementX: 40,
|
||||||
|
elementY: 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const pane = getPane(container)
|
||||||
|
act(() => {
|
||||||
|
fireEvent.mouseMove(pane, { clientX: 150, clientY: 180 })
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByTestId('comment-cursor')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('comment-input-preview')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('comment-input-active')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('comment-icon-comment-1')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('comment-icon-comment-2')).toBeInTheDocument()
|
||||||
|
expect(screen.getByTestId('comment-thread')).toBeInTheDocument()
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'next-comment' }))
|
||||||
|
})
|
||||||
|
expect(workflowCommentState.handleCommentIconClick).toHaveBeenCalledWith({ id: 'comment-2', resolved: false })
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'delete-thread' }))
|
||||||
|
})
|
||||||
|
expect(store.getState().showConfirm).toBeDefined()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await store.getState().showConfirm?.onConfirm()
|
||||||
|
})
|
||||||
|
expect(workflowCommentState.handleCommentDelete).toHaveBeenCalledWith('comment-1')
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'delete-reply' }))
|
||||||
|
})
|
||||||
|
expect(store.getState().showConfirm).toBeDefined()
|
||||||
|
await act(async () => {
|
||||||
|
await store.getState().showConfirm?.onConfirm()
|
||||||
|
})
|
||||||
|
expect(workflowCommentState.handleCommentReplyDelete).toHaveBeenCalledWith('comment-1', 'reply-1')
|
||||||
|
|
||||||
|
const wheelEvent = new WheelEvent('wheel', {
|
||||||
|
cancelable: true,
|
||||||
|
ctrlKey: true,
|
||||||
|
})
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(wheelEvent)
|
||||||
|
})
|
||||||
|
|
||||||
|
const gestureEvent = new Event('gesturestart', { cancelable: true })
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(gestureEvent)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -0,0 +1,560 @@
|
|||||||
|
import type { LoroMap } from 'loro-crdt'
|
||||||
|
import type { Socket } from 'socket.io-client'
|
||||||
|
import type {
|
||||||
|
CollaborationUpdate,
|
||||||
|
NodePanelPresenceMap,
|
||||||
|
OnlineUser,
|
||||||
|
RestoreCompleteData,
|
||||||
|
RestoreIntentData,
|
||||||
|
} from '../../types/collaboration'
|
||||||
|
import type { Edge, Node } from '@/app/components/workflow/types'
|
||||||
|
import { LoroDoc } from 'loro-crdt'
|
||||||
|
import { BlockEnum } from '@/app/components/workflow/types'
|
||||||
|
import { CollaborationManager } from '../collaboration-manager'
|
||||||
|
import { webSocketClient } from '../websocket-manager'
|
||||||
|
|
||||||
|
type ReactFlowStore = {
|
||||||
|
getState: () => {
|
||||||
|
getNodes: () => Node[]
|
||||||
|
setNodes: (nodes: Node[]) => void
|
||||||
|
getEdges: () => Edge[]
|
||||||
|
setEdges: (edges: Edge[]) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoroSubscribeEvent = {
|
||||||
|
by?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockSocket = {
|
||||||
|
id: string
|
||||||
|
connected: boolean
|
||||||
|
emit: ReturnType<typeof vi.fn>
|
||||||
|
on: ReturnType<typeof vi.fn>
|
||||||
|
trigger: (event: string, ...args: unknown[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type CollaborationManagerInternals = {
|
||||||
|
doc: LoroDoc | null
|
||||||
|
nodesMap: LoroMap | null
|
||||||
|
edgesMap: LoroMap | null
|
||||||
|
currentAppId: string | null
|
||||||
|
reactFlowStore: ReactFlowStore | null
|
||||||
|
isLeader: boolean
|
||||||
|
leaderId: string | null
|
||||||
|
pendingInitialSync: boolean
|
||||||
|
pendingGraphImportEmit: boolean
|
||||||
|
rejoinInProgress: boolean
|
||||||
|
onlineUsers: OnlineUser[]
|
||||||
|
nodePanelPresence: NodePanelPresenceMap
|
||||||
|
cursors: Record<string, { x: number, y: number, userId: string, timestamp: number }>
|
||||||
|
graphSyncDiagnostics: unknown[]
|
||||||
|
setNodesAnomalyLogs: unknown[]
|
||||||
|
handleSessionUnauthorized: () => void
|
||||||
|
forceDisconnect: () => void
|
||||||
|
setupSocketEventListeners: (socket: Socket) => void
|
||||||
|
setupSubscriptions: () => void
|
||||||
|
scheduleGraphImportEmit: () => void
|
||||||
|
emitGraphResyncRequest: () => void
|
||||||
|
broadcastCurrentGraph: () => void
|
||||||
|
recordGraphSyncDiagnostic: (
|
||||||
|
stage: 'nodes_subscribe' | 'edges_subscribe' | 'nodes_import_apply' | 'edges_import_apply' | 'schedule_graph_import_emit' | 'graph_import_emit' | 'start_import_log' | 'finalize_import_log',
|
||||||
|
status: 'triggered' | 'skipped' | 'applied' | 'queued' | 'emitted' | 'snapshot',
|
||||||
|
reason?: string,
|
||||||
|
details?: Record<string, unknown>,
|
||||||
|
) => void
|
||||||
|
captureSetNodesAnomaly: (oldNodes: Node[], newNodes: Node[], source: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals =>
|
||||||
|
manager as unknown as CollaborationManagerInternals
|
||||||
|
|
||||||
|
const createNode = (id: string, title = `Node-${id}`): Node => ({
|
||||||
|
id,
|
||||||
|
type: 'custom',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
type: BlockEnum.Start,
|
||||||
|
title,
|
||||||
|
desc: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createEdge = (id: string, source: string, target: string): Edge => ({
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
type: 'custom',
|
||||||
|
data: {
|
||||||
|
sourceType: BlockEnum.Start,
|
||||||
|
targetType: BlockEnum.End,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const createMockSocket = (id = 'socket-1'): MockSocket => {
|
||||||
|
const handlers = new Map<string, (...args: unknown[]) => void>()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
connected: true,
|
||||||
|
emit: vi.fn(),
|
||||||
|
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||||
|
handlers.set(event, handler)
|
||||||
|
}),
|
||||||
|
trigger: (event: string, ...args: unknown[]) => {
|
||||||
|
const handler = handlers.get(event)
|
||||||
|
if (handler)
|
||||||
|
handler(...args)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setupManagerWithDoc = () => {
|
||||||
|
const manager = new CollaborationManager()
|
||||||
|
const doc = new LoroDoc()
|
||||||
|
const internals = getManagerInternals(manager)
|
||||||
|
internals.doc = doc
|
||||||
|
internals.nodesMap = doc.getMap('nodes')
|
||||||
|
internals.edgesMap = doc.getMap('edges')
|
||||||
|
return { manager, internals }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CollaborationManager socket and subscription behavior', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits cursor/sync/workflow events via collaboration_event when connected', () => {
|
||||||
|
const { manager, internals } = setupManagerWithDoc()
|
||||||
|
const socket = createMockSocket('socket-connected')
|
||||||
|
|
||||||
|
internals.currentAppId = 'app-1'
|
||||||
|
vi.spyOn(webSocketClient, 'isConnected').mockReturnValue(true)
|
||||||
|
vi.spyOn(webSocketClient, 'getSocket').mockReturnValue(socket as unknown as Socket)
|
||||||
|
|
||||||
|
manager.emitCursorMove({ x: 11, y: 22, userId: 'u-1', timestamp: Date.now() })
|
||||||
|
manager.emitSyncRequest()
|
||||||
|
manager.emitWorkflowUpdate('wf-1')
|
||||||
|
|
||||||
|
expect(socket.emit).toHaveBeenCalledTimes(3)
|
||||||
|
const payloads = socket.emit.mock.calls.map(call => call[1] as { type: string, data: Record<string, unknown> })
|
||||||
|
expect(payloads.map(item => item.type)).toEqual(['mouse_move', 'sync_request', 'workflow_update'])
|
||||||
|
expect(payloads[0]?.data).toMatchObject({ x: 11, y: 22 })
|
||||||
|
expect(payloads[2]?.data).toMatchObject({ appId: 'wf-1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('tries to rejoin on unauthorized and forces disconnect on unauthorized ack', () => {
|
||||||
|
const { internals } = setupManagerWithDoc()
|
||||||
|
const socket = createMockSocket('socket-rejoin')
|
||||||
|
const getSocketSpy = vi.spyOn(webSocketClient, 'getSocket').mockReturnValue(socket as unknown as Socket)
|
||||||
|
const forceDisconnectSpy = vi.spyOn(internals, 'forceDisconnect').mockImplementation(() => undefined)
|
||||||
|
|
||||||
|
internals.currentAppId = 'app-rejoin'
|
||||||
|
internals.rejoinInProgress = true
|
||||||
|
internals.handleSessionUnauthorized()
|
||||||
|
expect(socket.emit).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
internals.rejoinInProgress = false
|
||||||
|
internals.handleSessionUnauthorized()
|
||||||
|
expect(socket.emit).toHaveBeenCalledWith(
|
||||||
|
'user_connect',
|
||||||
|
{ workflow_id: 'app-rejoin' },
|
||||||
|
expect.any(Function),
|
||||||
|
)
|
||||||
|
|
||||||
|
const ack = socket.emit.mock.calls[0]?.[2] as ((...ackArgs: unknown[]) => void) | undefined
|
||||||
|
expect(ack).toBeDefined()
|
||||||
|
ack?.({ msg: 'unauthorized' })
|
||||||
|
|
||||||
|
expect(forceDisconnectSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(internals.rejoinInProgress).toBe(false)
|
||||||
|
expect(getSocketSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('routes collaboration_update payloads to corresponding event channels', () => {
|
||||||
|
const { manager, internals } = setupManagerWithDoc()
|
||||||
|
const socket = createMockSocket('socket-events')
|
||||||
|
|
||||||
|
const broadcastSpy = vi.spyOn(internals, 'broadcastCurrentGraph').mockImplementation(() => undefined)
|
||||||
|
internals.isLeader = true
|
||||||
|
internals.setupSocketEventListeners(socket as unknown as Socket)
|
||||||
|
|
||||||
|
const varsFeatureHandler = vi.fn()
|
||||||
|
const appStateHandler = vi.fn()
|
||||||
|
const appMetaHandler = vi.fn()
|
||||||
|
const appPublishHandler = vi.fn()
|
||||||
|
const mcpHandler = vi.fn()
|
||||||
|
const workflowUpdateHandler = vi.fn()
|
||||||
|
const commentsHandler = vi.fn()
|
||||||
|
const restoreRequestHandler = vi.fn()
|
||||||
|
const restoreIntentHandler = vi.fn()
|
||||||
|
const restoreCompleteHandler = vi.fn()
|
||||||
|
const historyHandler = vi.fn()
|
||||||
|
const syncRequestHandler = vi.fn()
|
||||||
|
let latestPresence: NodePanelPresenceMap | null = null
|
||||||
|
let latestCursors: Record<string, unknown> | null = null
|
||||||
|
|
||||||
|
manager.onVarsAndFeaturesUpdate(varsFeatureHandler)
|
||||||
|
manager.onAppStateUpdate(appStateHandler)
|
||||||
|
manager.onAppMetaUpdate(appMetaHandler)
|
||||||
|
manager.onAppPublishUpdate(appPublishHandler)
|
||||||
|
manager.onMcpServerUpdate(mcpHandler)
|
||||||
|
manager.onWorkflowUpdate(workflowUpdateHandler)
|
||||||
|
manager.onCommentsUpdate(commentsHandler)
|
||||||
|
manager.onRestoreRequest(restoreRequestHandler)
|
||||||
|
manager.onRestoreIntent(restoreIntentHandler)
|
||||||
|
manager.onRestoreComplete(restoreCompleteHandler)
|
||||||
|
manager.onHistoryAction(historyHandler)
|
||||||
|
manager.onSyncRequest(syncRequestHandler)
|
||||||
|
manager.onNodePanelPresenceUpdate((presence) => {
|
||||||
|
latestPresence = presence
|
||||||
|
})
|
||||||
|
manager.onCursorUpdate((cursors) => {
|
||||||
|
latestCursors = cursors as Record<string, unknown>
|
||||||
|
})
|
||||||
|
|
||||||
|
const baseUpdate: Pick<CollaborationUpdate, 'userId' | 'timestamp'> = {
|
||||||
|
userId: 'u-1',
|
||||||
|
timestamp: 1000,
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.trigger('collaboration_update', {
|
||||||
|
...baseUpdate,
|
||||||
|
type: 'mouse_move',
|
||||||
|
data: { x: 1, y: 2 },
|
||||||
|
} satisfies CollaborationUpdate)
|
||||||
|
socket.trigger('collaboration_update', {
|
||||||
|
...baseUpdate,
|
||||||
|
type: 'vars_and_features_update',
|
||||||
|
data: { value: 1 },
|
||||||
|
} satisfies CollaborationUpdate)
|
||||||
|
socket.trigger('collaboration_update', {
|
||||||
|
...baseUpdate,
|
||||||
|
type: 'app_state_update',
|
||||||
|
data: { value: 2 },
|
||||||
|
} satisfies CollaborationUpdate)
|
||||||
|
socket.trigger('collaboration_update', {
|
||||||
|
...baseUpdate,
|
||||||
|
type: 'app_meta_update',
|
||||||
|
data: { value: 3 },
|
||||||
|
} satisfies CollaborationUpdate)
|
||||||
|
socket.trigger('collaboration_update', {
|
||||||
|
...baseUpdate,
|
||||||
|
type: 'app_publish_update',
|
||||||
|
data: { value: 4 },
|
||||||
|
} satisfies CollaborationUpdate)
|
||||||
|
socket.trigger('collaboration_update', {
|
||||||
|
...baseUpdate,
|
||||||
|
type: 'mcp_server_update',
|
||||||
|
data: { value: 5 },
|
||||||
|
} satisfies CollaborationUpdate)
|
||||||
|
socket.trigger('collaboration_update', {
|
||||||
|
...baseUpdate,
|
||||||
|
type: 'workflow_update',
|
||||||
|
data: { appId: 'wf', timestamp: 9 },
|
||||||
|
} satisfies CollaborationUpdate)
|
||||||
|
socket.trigger('collaboration_update', {
|
||||||
|
...baseUpdate,
|
||||||
|
type: 'comments_update',
|
||||||
|
data: { appId: 'wf', timestamp: 10 },
|
||||||
|
} satisfies CollaborationUpdate)
|
||||||
|
socket.trigger('collaboration_update', {
|
||||||
|
...baseUpdate,
|
||||||
|
type: 'node_panel_presence',
|
||||||
|
data: {
|
||||||
|
nodeId: 'n-1',
|
||||||
|
action: 'open',
|
||||||
|
user: { userId: 'u-1', username: 'Alice' },
|
||||||
|
clientId: 'socket-events',
|
||||||
|
timestamp: 11,
|
||||||
|
},
|
||||||
|
} satisfies CollaborationUpdate)
|
||||||
|
socket.trigger('collaboration_update', {
|
||||||
|
...baseUpdate,
|
||||||
|
type: 'sync_request',
|
||||||
|
data: {},
|
||||||
|
} satisfies CollaborationUpdate)
|
||||||
|
socket.trigger('collaboration_update', {
|
||||||
|
...baseUpdate,
|
||||||
|
type: 'graph_resync_request',
|
||||||
|
data: {},
|
||||||
|
} satisfies CollaborationUpdate)
|
||||||
|
socket.trigger('collaboration_update', {
|
||||||
|
...baseUpdate,
|
||||||
|
type: 'workflow_restore_request',
|
||||||
|
data: { versionId: 'v1', initiatorUserId: 'u-1', initiatorName: 'Alice', graphData: { nodes: [], edges: [] } } as unknown as Record<string, unknown>,
|
||||||
|
} satisfies CollaborationUpdate)
|
||||||
|
socket.trigger('collaboration_update', {
|
||||||
|
...baseUpdate,
|
||||||
|
type: 'workflow_restore_intent',
|
||||||
|
data: { versionId: 'v1', initiatorUserId: 'u-1', initiatorName: 'Alice' } as unknown as Record<string, unknown>,
|
||||||
|
} satisfies CollaborationUpdate)
|
||||||
|
socket.trigger('collaboration_update', {
|
||||||
|
...baseUpdate,
|
||||||
|
type: 'workflow_restore_complete',
|
||||||
|
data: { versionId: 'v1', success: true } as unknown as Record<string, unknown>,
|
||||||
|
} satisfies CollaborationUpdate)
|
||||||
|
socket.trigger('collaboration_update', {
|
||||||
|
...baseUpdate,
|
||||||
|
type: 'workflow_history_action',
|
||||||
|
data: { action: 'undo' },
|
||||||
|
} satisfies CollaborationUpdate)
|
||||||
|
socket.trigger('collaboration_update', {
|
||||||
|
...baseUpdate,
|
||||||
|
type: 'workflow_history_action',
|
||||||
|
data: {},
|
||||||
|
} satisfies CollaborationUpdate)
|
||||||
|
|
||||||
|
expect(latestCursors).toMatchObject({
|
||||||
|
'u-1': { x: 1, y: 2, userId: 'u-1' },
|
||||||
|
})
|
||||||
|
expect(varsFeatureHandler).toHaveBeenCalledTimes(1)
|
||||||
|
expect(appStateHandler).toHaveBeenCalledTimes(1)
|
||||||
|
expect(appMetaHandler).toHaveBeenCalledTimes(1)
|
||||||
|
expect(appPublishHandler).toHaveBeenCalledTimes(1)
|
||||||
|
expect(mcpHandler).toHaveBeenCalledTimes(1)
|
||||||
|
expect(workflowUpdateHandler).toHaveBeenCalledWith({ appId: 'wf', timestamp: 9 })
|
||||||
|
expect(commentsHandler).toHaveBeenCalledWith({ appId: 'wf', timestamp: 10 })
|
||||||
|
expect(latestPresence).toMatchObject({ 'n-1': { 'socket-events': { userId: 'u-1' } } })
|
||||||
|
expect(syncRequestHandler).toHaveBeenCalledTimes(1)
|
||||||
|
expect(broadcastSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(restoreRequestHandler).toHaveBeenCalledTimes(1)
|
||||||
|
expect(restoreIntentHandler).toHaveBeenCalledWith({ versionId: 'v1', initiatorUserId: 'u-1', initiatorName: 'Alice' } satisfies RestoreIntentData)
|
||||||
|
expect(restoreCompleteHandler).toHaveBeenCalledWith({ versionId: 'v1', success: true } satisfies RestoreCompleteData)
|
||||||
|
expect(historyHandler).toHaveBeenCalledWith({ action: 'undo', userId: 'u-1' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('processes online_users/status/connect/disconnect/error socket events', () => {
|
||||||
|
const { manager, internals } = setupManagerWithDoc()
|
||||||
|
const socket = createMockSocket('socket-state')
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||||
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
const emitGraphResyncRequestSpy = vi.spyOn(internals, 'emitGraphResyncRequest').mockImplementation(() => undefined)
|
||||||
|
|
||||||
|
internals.cursors = {
|
||||||
|
stale: {
|
||||||
|
x: 1,
|
||||||
|
y: 1,
|
||||||
|
userId: 'offline-user',
|
||||||
|
timestamp: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
internals.nodePanelPresence = {
|
||||||
|
'n-1': {
|
||||||
|
'offline-client': {
|
||||||
|
userId: 'offline-user',
|
||||||
|
username: 'Offline',
|
||||||
|
clientId: 'offline-client',
|
||||||
|
timestamp: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
internals.setupSocketEventListeners(socket as unknown as Socket)
|
||||||
|
|
||||||
|
const onlineUsersHandler = vi.fn()
|
||||||
|
const leaderChangeHandler = vi.fn()
|
||||||
|
const stateChanges: Array<{ isConnected: boolean, disconnectReason?: string, error?: string }> = []
|
||||||
|
manager.onOnlineUsersUpdate(onlineUsersHandler)
|
||||||
|
manager.onLeaderChange(leaderChangeHandler)
|
||||||
|
manager.onStateChange((state) => {
|
||||||
|
stateChanges.push(state as { isConnected: boolean, disconnectReason?: string, error?: string })
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.trigger('online_users', { users: 'invalid-structure' })
|
||||||
|
expect(warnSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
socket.trigger('online_users', {
|
||||||
|
users: [{
|
||||||
|
user_id: 'online-user',
|
||||||
|
username: 'Alice',
|
||||||
|
avatar: '',
|
||||||
|
sid: 'socket-state',
|
||||||
|
}],
|
||||||
|
leader: 'leader-1',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onlineUsersHandler).toHaveBeenCalledWith([{
|
||||||
|
user_id: 'online-user',
|
||||||
|
username: 'Alice',
|
||||||
|
avatar: '',
|
||||||
|
sid: 'socket-state',
|
||||||
|
} satisfies OnlineUser])
|
||||||
|
expect(internals.cursors).toEqual({})
|
||||||
|
expect(internals.nodePanelPresence).toEqual({})
|
||||||
|
expect(internals.leaderId).toBe('leader-1')
|
||||||
|
|
||||||
|
socket.trigger('status', { isLeader: 'invalid' })
|
||||||
|
expect(warnSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
internals.pendingInitialSync = true
|
||||||
|
internals.isLeader = false
|
||||||
|
socket.trigger('status', { isLeader: false })
|
||||||
|
expect(emitGraphResyncRequestSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(internals.pendingInitialSync).toBe(false)
|
||||||
|
|
||||||
|
socket.trigger('status', { isLeader: true })
|
||||||
|
expect(leaderChangeHandler).toHaveBeenCalledWith(true)
|
||||||
|
|
||||||
|
socket.trigger('connect')
|
||||||
|
socket.trigger('disconnect', 'transport close')
|
||||||
|
socket.trigger('connect_error', new Error('connect failed'))
|
||||||
|
socket.trigger('error', new Error('generic socket error'))
|
||||||
|
|
||||||
|
expect(stateChanges).toEqual([
|
||||||
|
{ isConnected: true },
|
||||||
|
{ isConnected: false, disconnectReason: 'transport close' },
|
||||||
|
{ isConnected: false, error: 'connect failed' },
|
||||||
|
])
|
||||||
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('setupSubscriptions applies import updates and emits merged graph payload', () => {
|
||||||
|
const { manager, internals } = setupManagerWithDoc()
|
||||||
|
const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
|
||||||
|
callback(0)
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
const initialNode = {
|
||||||
|
...createNode('n-1', 'Initial'),
|
||||||
|
data: {
|
||||||
|
...createNode('n-1', 'Initial').data,
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const remoteNode = {
|
||||||
|
...initialNode,
|
||||||
|
data: {
|
||||||
|
...initialNode.data,
|
||||||
|
title: 'RemoteTitle',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const edge = createEdge('e-1', 'n-1', 'n-2')
|
||||||
|
|
||||||
|
manager.setNodes([], [initialNode])
|
||||||
|
manager.setEdges([], [edge])
|
||||||
|
manager.setNodes([initialNode], [remoteNode])
|
||||||
|
|
||||||
|
let reactFlowNodes: Node[] = [{
|
||||||
|
...initialNode,
|
||||||
|
data: {
|
||||||
|
...initialNode.data,
|
||||||
|
selected: true,
|
||||||
|
_localMeta: 'keep-me',
|
||||||
|
},
|
||||||
|
}]
|
||||||
|
let reactFlowEdges: Edge[] = [edge]
|
||||||
|
const setNodesSpy = vi.fn((nodes: Node[]) => {
|
||||||
|
reactFlowNodes = nodes
|
||||||
|
})
|
||||||
|
const setEdgesSpy = vi.fn((edges: Edge[]) => {
|
||||||
|
reactFlowEdges = edges
|
||||||
|
})
|
||||||
|
internals.reactFlowStore = {
|
||||||
|
getState: () => ({
|
||||||
|
getNodes: () => reactFlowNodes,
|
||||||
|
setNodes: setNodesSpy,
|
||||||
|
getEdges: () => reactFlowEdges,
|
||||||
|
setEdges: setEdgesSpy,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
let nodesSubscribeHandler: ((event: LoroSubscribeEvent) => void) | null = null
|
||||||
|
let edgesSubscribeHandler: ((event: LoroSubscribeEvent) => void) | null = null
|
||||||
|
vi.spyOn(internals.nodesMap as object as { subscribe: (handler: (event: LoroSubscribeEvent) => void) => void }, 'subscribe')
|
||||||
|
.mockImplementation((handler: (event: LoroSubscribeEvent) => void) => {
|
||||||
|
nodesSubscribeHandler = handler
|
||||||
|
})
|
||||||
|
vi.spyOn(internals.edgesMap as object as { subscribe: (handler: (event: LoroSubscribeEvent) => void) => void }, 'subscribe')
|
||||||
|
.mockImplementation((handler: (event: LoroSubscribeEvent) => void) => {
|
||||||
|
edgesSubscribeHandler = handler
|
||||||
|
})
|
||||||
|
|
||||||
|
let importedGraph: { nodes: Node[], edges: Edge[] } | null = null
|
||||||
|
manager.onGraphImport((payload) => {
|
||||||
|
importedGraph = payload
|
||||||
|
})
|
||||||
|
|
||||||
|
internals.setupSubscriptions()
|
||||||
|
nodesSubscribeHandler?.({ by: 'local' })
|
||||||
|
nodesSubscribeHandler?.({ by: 'import' })
|
||||||
|
edgesSubscribeHandler?.({ by: 'import' })
|
||||||
|
|
||||||
|
expect(setNodesSpy).toHaveBeenCalled()
|
||||||
|
expect(setEdgesSpy).toHaveBeenCalled()
|
||||||
|
expect(importedGraph).not.toBeNull()
|
||||||
|
expect(importedGraph?.nodes[0]?.data).toMatchObject({
|
||||||
|
title: 'RemoteTitle',
|
||||||
|
selected: true,
|
||||||
|
_localMeta: 'keep-me',
|
||||||
|
})
|
||||||
|
|
||||||
|
internals.pendingGraphImportEmit = true
|
||||||
|
internals.scheduleGraphImportEmit()
|
||||||
|
expect(internals.pendingGraphImportEmit).toBe(true)
|
||||||
|
|
||||||
|
internals.reactFlowStore = null
|
||||||
|
nodesSubscribeHandler?.({ by: 'import' })
|
||||||
|
|
||||||
|
rafSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects diagnostic and anomaly log limits', () => {
|
||||||
|
const { internals } = setupManagerWithDoc()
|
||||||
|
const oldNode = createNode('old')
|
||||||
|
|
||||||
|
for (let i = 0; i < 401; i += 1) {
|
||||||
|
internals.recordGraphSyncDiagnostic('nodes_subscribe', 'triggered', undefined, { index: i })
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 101; i += 1) {
|
||||||
|
internals.captureSetNodesAnomaly([oldNode], [], `source-${i}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(internals.graphSyncDiagnostics).toHaveLength(400)
|
||||||
|
expect(internals.setNodesAnomalyLogs).toHaveLength(100)
|
||||||
|
|
||||||
|
// no anomaly should be recorded when node count and start node invariants are unchanged
|
||||||
|
const beforeLength = internals.setNodesAnomalyLogs.length
|
||||||
|
internals.captureSetNodesAnomaly([oldNode], [createNode('old')], 'no-op')
|
||||||
|
expect(internals.setNodesAnomalyLogs).toHaveLength(beforeLength)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('guards graph resync emission and graph snapshot broadcast', () => {
|
||||||
|
const { manager, internals } = setupManagerWithDoc()
|
||||||
|
const socket = createMockSocket('socket-resync')
|
||||||
|
const sendGraphEventSpy = vi.spyOn(
|
||||||
|
manager as unknown as { sendGraphEvent: (payload: Uint8Array) => void },
|
||||||
|
'sendGraphEvent',
|
||||||
|
).mockImplementation(() => undefined)
|
||||||
|
|
||||||
|
internals.currentAppId = null
|
||||||
|
vi.spyOn(webSocketClient, 'isConnected').mockReturnValue(false)
|
||||||
|
vi.spyOn(webSocketClient, 'getSocket').mockReturnValue(socket as unknown as Socket)
|
||||||
|
internals.emitGraphResyncRequest()
|
||||||
|
expect(socket.emit).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
internals.currentAppId = 'app-graph'
|
||||||
|
vi.spyOn(webSocketClient, 'isConnected').mockReturnValue(true)
|
||||||
|
internals.emitGraphResyncRequest()
|
||||||
|
expect(socket.emit).toHaveBeenCalledWith(
|
||||||
|
'collaboration_event',
|
||||||
|
expect.objectContaining({ type: 'graph_resync_request' }),
|
||||||
|
expect.any(Function),
|
||||||
|
)
|
||||||
|
|
||||||
|
internals.doc = null
|
||||||
|
internals.broadcastCurrentGraph()
|
||||||
|
expect(sendGraphEventSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
const doc = new LoroDoc()
|
||||||
|
internals.doc = doc
|
||||||
|
internals.nodesMap = doc.getMap('nodes')
|
||||||
|
internals.edgesMap = doc.getMap('edges')
|
||||||
|
internals.broadcastCurrentGraph()
|
||||||
|
expect(sendGraphEventSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
manager.setNodes([], [createNode('n-broadcast')])
|
||||||
|
internals.broadcastCurrentGraph()
|
||||||
|
expect(sendGraphEventSpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -3,13 +3,17 @@ import { act } from '@testing-library/react'
|
|||||||
import { createEdge, createNode } from '../../__tests__/fixtures'
|
import { createEdge, createNode } from '../../__tests__/fixtures'
|
||||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||||
import { BlockEnum } from '../../types'
|
import { collaborationManager } from '../../collaboration/core/collaboration-manager'
|
||||||
|
import { BlockEnum, ControlMode } from '../../types'
|
||||||
import { useNodesInteractions } from '../use-nodes-interactions'
|
import { useNodesInteractions } from '../use-nodes-interactions'
|
||||||
|
|
||||||
const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
|
const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
|
||||||
const mockSaveStateToHistory = vi.hoisted(() => vi.fn())
|
const mockSaveStateToHistory = vi.hoisted(() => vi.fn())
|
||||||
const mockUndo = vi.hoisted(() => vi.fn())
|
const mockUndo = vi.hoisted(() => vi.fn())
|
||||||
const mockRedo = vi.hoisted(() => vi.fn())
|
const mockRedo = vi.hoisted(() => vi.fn())
|
||||||
|
const runtimeNodesMetaDataMap = vi.hoisted(() => ({
|
||||||
|
value: {} as Record<string, unknown>,
|
||||||
|
}))
|
||||||
|
|
||||||
const runtimeState = vi.hoisted(() => ({
|
const runtimeState = vi.hoisted(() => ({
|
||||||
nodesReadOnly: false,
|
nodesReadOnly: false,
|
||||||
@ -45,7 +49,7 @@ vi.mock('../use-helpline', () => ({
|
|||||||
|
|
||||||
vi.mock('../use-nodes-meta-data', () => ({
|
vi.mock('../use-nodes-meta-data', () => ({
|
||||||
useNodesMetaData: () => ({
|
useNodesMetaData: () => ({
|
||||||
nodesMap: {},
|
nodesMap: runtimeNodesMetaDataMap.value,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@ -114,6 +118,7 @@ describe('useNodesInteractions', () => {
|
|||||||
]
|
]
|
||||||
rfState.nodes = currentNodes as unknown as typeof rfState.nodes
|
rfState.nodes = currentNodes as unknown as typeof rfState.nodes
|
||||||
rfState.edges = currentEdges as unknown as typeof rfState.edges
|
rfState.edges = currentEdges as unknown as typeof rfState.edges
|
||||||
|
runtimeNodesMetaDataMap.value = {}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('persists node drags only when the node position actually changes', () => {
|
it('persists node drags only when the node position actually changes', () => {
|
||||||
@ -202,4 +207,469 @@ describe('useNodesInteractions', () => {
|
|||||||
expect(rfState.setNodes).not.toHaveBeenCalled()
|
expect(rfState.setNodes).not.toHaveBeenCalled()
|
||||||
expect(rfState.setEdges).not.toHaveBeenCalled()
|
expect(rfState.setEdges).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('broadcasts history undo/redo actions to collaborators when connected', () => {
|
||||||
|
const historyNodes = [
|
||||||
|
createNode({
|
||||||
|
id: 'history-node-2',
|
||||||
|
data: {
|
||||||
|
type: BlockEnum.End,
|
||||||
|
title: 'History End',
|
||||||
|
desc: '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const historyEdges = [
|
||||||
|
createEdge({
|
||||||
|
id: 'history-edge-2',
|
||||||
|
source: 'history-node-2',
|
||||||
|
target: 'node-1',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const isConnectedSpy = vi.spyOn(collaborationManager, 'isConnected').mockReturnValue(true)
|
||||||
|
const emitHistoryActionSpy = vi.spyOn(collaborationManager, 'emitHistoryAction').mockImplementation(() => undefined)
|
||||||
|
const collabSetNodesSpy = vi.spyOn(collaborationManager, 'setNodes').mockImplementation(() => undefined)
|
||||||
|
const collabSetEdgesSpy = vi.spyOn(collaborationManager, 'setEdges').mockImplementation(() => undefined)
|
||||||
|
|
||||||
|
const { result } = renderWorkflowHook(() => useNodesInteractions(), {
|
||||||
|
historyStore: {
|
||||||
|
nodes: historyNodes,
|
||||||
|
edges: historyEdges,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleHistoryBack()
|
||||||
|
result.current.handleHistoryForward()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(collabSetNodesSpy).toHaveBeenCalledTimes(2)
|
||||||
|
expect(collabSetEdgesSpy).toHaveBeenCalledTimes(2)
|
||||||
|
expect(emitHistoryActionSpy).toHaveBeenNthCalledWith(1, 'undo')
|
||||||
|
expect(emitHistoryActionSpy).toHaveBeenNthCalledWith(2, 'redo')
|
||||||
|
expect(isConnectedSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not broadcast history changes when collaboration is disconnected', () => {
|
||||||
|
const historyNodes = [
|
||||||
|
createNode({
|
||||||
|
id: 'history-node-3',
|
||||||
|
data: {
|
||||||
|
type: BlockEnum.End,
|
||||||
|
title: 'History End',
|
||||||
|
desc: '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
const historyEdges = [
|
||||||
|
createEdge({
|
||||||
|
id: 'history-edge-3',
|
||||||
|
source: 'history-node-3',
|
||||||
|
target: 'node-1',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
vi.spyOn(collaborationManager, 'isConnected').mockReturnValue(false)
|
||||||
|
const emitHistoryActionSpy = vi.spyOn(collaborationManager, 'emitHistoryAction').mockImplementation(() => undefined)
|
||||||
|
const collabSetNodesSpy = vi.spyOn(collaborationManager, 'setNodes').mockImplementation(() => undefined)
|
||||||
|
const collabSetEdgesSpy = vi.spyOn(collaborationManager, 'setEdges').mockImplementation(() => undefined)
|
||||||
|
|
||||||
|
const { result } = renderWorkflowHook(() => useNodesInteractions(), {
|
||||||
|
historyStore: {
|
||||||
|
nodes: historyNodes,
|
||||||
|
edges: historyEdges,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleHistoryBack()
|
||||||
|
result.current.handleHistoryForward()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(collabSetNodesSpy).not.toHaveBeenCalled()
|
||||||
|
expect(collabSetEdgesSpy).not.toHaveBeenCalled()
|
||||||
|
expect(emitHistoryActionSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores node click selection in comment control mode', () => {
|
||||||
|
const { result } = renderWorkflowHook(() => useNodesInteractions(), {
|
||||||
|
initialStoreState: {
|
||||||
|
controlMode: ControlMode.Comment,
|
||||||
|
},
|
||||||
|
historyStore: {
|
||||||
|
nodes: currentNodes,
|
||||||
|
edges: currentEdges,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleNodeClick({} as never, currentNodes[0] as Node)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rfState.setNodes).not.toHaveBeenCalled()
|
||||||
|
expect(rfState.setEdges).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates entering states on node enter and clears them on leave using collaborative workflow state', () => {
|
||||||
|
currentNodes = [
|
||||||
|
createNode({
|
||||||
|
id: 'from-node',
|
||||||
|
data: {
|
||||||
|
type: BlockEnum.Code,
|
||||||
|
title: 'From',
|
||||||
|
desc: '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createNode({
|
||||||
|
id: 'to-node',
|
||||||
|
position: { x: 120, y: 120 },
|
||||||
|
data: {
|
||||||
|
type: BlockEnum.Code,
|
||||||
|
title: 'To',
|
||||||
|
desc: '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
currentEdges = [
|
||||||
|
createEdge({
|
||||||
|
id: 'edge-from-to',
|
||||||
|
source: 'from-node',
|
||||||
|
target: 'to-node',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
rfState.nodes = currentNodes as unknown as typeof rfState.nodes
|
||||||
|
rfState.edges = currentEdges as unknown as typeof rfState.edges
|
||||||
|
|
||||||
|
const { result, store } = renderWorkflowHook(() => useNodesInteractions(), {
|
||||||
|
historyStore: {
|
||||||
|
nodes: currentNodes,
|
||||||
|
edges: currentEdges,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
store.setState({
|
||||||
|
connectingNodePayload: {
|
||||||
|
nodeId: 'from-node',
|
||||||
|
handleType: 'source',
|
||||||
|
handleId: 'source',
|
||||||
|
} as never,
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleNodeEnter({} as never, currentNodes[1] as Node)
|
||||||
|
result.current.handleNodeLeave({} as never, currentNodes[1] as Node)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rfState.setNodes).toHaveBeenCalled()
|
||||||
|
expect(rfState.setEdges).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores connecting payload from collaborative nodes when connect starts', () => {
|
||||||
|
const { result, store } = renderWorkflowHook(() => useNodesInteractions(), {
|
||||||
|
historyStore: {
|
||||||
|
nodes: currentNodes,
|
||||||
|
edges: currentEdges,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleNodeConnectStart(
|
||||||
|
{} as never,
|
||||||
|
{
|
||||||
|
nodeId: 'node-1',
|
||||||
|
handleType: 'source',
|
||||||
|
handleId: 'source',
|
||||||
|
} as never,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.getState().connectingNodePayload).toMatchObject({
|
||||||
|
nodeId: 'node-1',
|
||||||
|
handleType: 'source',
|
||||||
|
handleId: 'source',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns early for node add/change when metadata for node type is missing', () => {
|
||||||
|
const { result } = renderWorkflowHook(() => useNodesInteractions(), {
|
||||||
|
historyStore: {
|
||||||
|
nodes: currentNodes,
|
||||||
|
edges: currentEdges,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleNodeAdd(
|
||||||
|
{
|
||||||
|
nodeType: BlockEnum.Code,
|
||||||
|
},
|
||||||
|
{ prevNodeId: 'node-1' },
|
||||||
|
)
|
||||||
|
result.current.handleNodeChange('node-1', BlockEnum.Answer, 'source')
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rfState.setNodes).not.toHaveBeenCalled()
|
||||||
|
expect(rfState.setEdges).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels selection state with collaborative nodes snapshot', () => {
|
||||||
|
currentNodes = [
|
||||||
|
createNode({
|
||||||
|
id: 'selected-node',
|
||||||
|
data: {
|
||||||
|
type: BlockEnum.Code,
|
||||||
|
title: 'Selected',
|
||||||
|
desc: '',
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
rfState.nodes = currentNodes as unknown as typeof rfState.nodes
|
||||||
|
|
||||||
|
const { result } = renderWorkflowHook(() => useNodesInteractions(), {
|
||||||
|
historyStore: {
|
||||||
|
nodes: currentNodes,
|
||||||
|
edges: currentEdges,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleNodesCancelSelected()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rfState.setNodes).toHaveBeenCalledTimes(1)
|
||||||
|
const nodesArg = rfState.setNodes.mock.calls[0]?.[0] as Node[]
|
||||||
|
expect(nodesArg[0]?.data.selected).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips clipboard copy when bundled/selected nodes have no metadata', () => {
|
||||||
|
currentNodes = [
|
||||||
|
createNode({
|
||||||
|
id: 'bundled-node',
|
||||||
|
data: {
|
||||||
|
type: BlockEnum.Code,
|
||||||
|
title: 'Bundled',
|
||||||
|
desc: '',
|
||||||
|
_isBundled: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createNode({
|
||||||
|
id: 'selected-node',
|
||||||
|
position: { x: 100, y: 0 },
|
||||||
|
data: {
|
||||||
|
type: BlockEnum.Code,
|
||||||
|
title: 'Selected',
|
||||||
|
desc: '',
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
rfState.nodes = currentNodes as unknown as typeof rfState.nodes
|
||||||
|
|
||||||
|
const { result, store } = renderWorkflowHook(() => useNodesInteractions(), {
|
||||||
|
historyStore: {
|
||||||
|
nodes: currentNodes,
|
||||||
|
edges: currentEdges,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleNodesCopy()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(store.getState().clipboardElements).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads nodes/edges from collaborative state for delete, disconnect, dim and undim actions', () => {
|
||||||
|
currentNodes = [
|
||||||
|
createNode({
|
||||||
|
id: 'node-a',
|
||||||
|
data: {
|
||||||
|
type: BlockEnum.Code,
|
||||||
|
title: 'A',
|
||||||
|
desc: '',
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createNode({
|
||||||
|
id: 'node-b',
|
||||||
|
position: { x: 160, y: 0 },
|
||||||
|
data: {
|
||||||
|
type: BlockEnum.Code,
|
||||||
|
title: 'B',
|
||||||
|
desc: '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
currentEdges = [
|
||||||
|
createEdge({
|
||||||
|
id: 'edge-a-b',
|
||||||
|
source: 'node-a',
|
||||||
|
target: 'node-b',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
rfState.nodes = currentNodes as unknown as typeof rfState.nodes
|
||||||
|
rfState.edges = currentEdges as unknown as typeof rfState.edges
|
||||||
|
|
||||||
|
const { result } = renderWorkflowHook(() => useNodesInteractions(), {
|
||||||
|
historyStore: {
|
||||||
|
nodes: currentNodes,
|
||||||
|
edges: currentEdges,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleNodesDelete()
|
||||||
|
result.current.handleNodeDisconnect('node-a')
|
||||||
|
result.current.dimOtherNodes()
|
||||||
|
result.current.undimAllNodes()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rfState.setNodes).toHaveBeenCalled()
|
||||||
|
expect(rfState.setEdges).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reads collaborative nodes when dragging/selecting/connecting nodes', () => {
|
||||||
|
currentNodes = [
|
||||||
|
createNode({
|
||||||
|
id: 'drag-node-1',
|
||||||
|
data: {
|
||||||
|
type: BlockEnum.Code,
|
||||||
|
title: 'Drag1',
|
||||||
|
desc: '',
|
||||||
|
selected: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createNode({
|
||||||
|
id: 'drag-node-2',
|
||||||
|
position: { x: 180, y: 0 },
|
||||||
|
data: {
|
||||||
|
type: BlockEnum.Code,
|
||||||
|
title: 'Drag2',
|
||||||
|
desc: '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
currentEdges = [
|
||||||
|
createEdge({
|
||||||
|
id: 'drag-edge',
|
||||||
|
source: 'drag-node-1',
|
||||||
|
target: 'drag-node-2',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
rfState.nodes = currentNodes as unknown as typeof rfState.nodes
|
||||||
|
rfState.edges = currentEdges as unknown as typeof rfState.edges
|
||||||
|
|
||||||
|
const { result, store } = renderWorkflowHook(() => useNodesInteractions(), {
|
||||||
|
historyStore: {
|
||||||
|
nodes: currentNodes,
|
||||||
|
edges: currentEdges,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
store.setState({
|
||||||
|
connectingNodePayload: {
|
||||||
|
nodeId: 'drag-node-1',
|
||||||
|
handleType: 'source',
|
||||||
|
handleId: 'source',
|
||||||
|
} as never,
|
||||||
|
enteringNodePayload: {
|
||||||
|
nodeId: 'drag-node-2',
|
||||||
|
} as never,
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleNodeDrag(
|
||||||
|
{ stopPropagation: vi.fn() } as never,
|
||||||
|
currentNodes[0] as Node,
|
||||||
|
)
|
||||||
|
result.current.handleNodeSelect('drag-node-2')
|
||||||
|
result.current.handleNodeConnect({
|
||||||
|
source: 'drag-node-1',
|
||||||
|
target: 'drag-node-2',
|
||||||
|
sourceHandle: 'source',
|
||||||
|
targetHandle: 'target',
|
||||||
|
})
|
||||||
|
result.current.handleNodeConnectEnd({ clientX: 0, clientY: 0 } as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rfState.setNodes).toHaveBeenCalled()
|
||||||
|
expect(rfState.setEdges).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses metadata defaults during add/change/paste and reads collaborative nodes on resize', () => {
|
||||||
|
runtimeNodesMetaDataMap.value = {
|
||||||
|
[BlockEnum.Code]: {
|
||||||
|
defaultValue: {
|
||||||
|
type: BlockEnum.Code,
|
||||||
|
title: 'Code',
|
||||||
|
desc: '',
|
||||||
|
},
|
||||||
|
metaData: {
|
||||||
|
isSingleton: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[BlockEnum.Answer]: {
|
||||||
|
defaultValue: {
|
||||||
|
type: BlockEnum.Answer,
|
||||||
|
title: 'Answer',
|
||||||
|
desc: '',
|
||||||
|
},
|
||||||
|
metaData: {
|
||||||
|
isSingleton: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
currentNodes = [
|
||||||
|
createNode({
|
||||||
|
id: 'meta-node-1',
|
||||||
|
data: {
|
||||||
|
type: BlockEnum.Code,
|
||||||
|
title: 'Meta',
|
||||||
|
desc: '',
|
||||||
|
selected: false,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
rfState.nodes = currentNodes as unknown as typeof rfState.nodes
|
||||||
|
rfState.edges = []
|
||||||
|
|
||||||
|
const { result, store } = renderWorkflowHook(() => useNodesInteractions(), {
|
||||||
|
historyStore: {
|
||||||
|
nodes: currentNodes,
|
||||||
|
edges: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
store.setState({
|
||||||
|
clipboardElements: [
|
||||||
|
createNode({
|
||||||
|
id: 'clipboard-node',
|
||||||
|
data: {
|
||||||
|
type: BlockEnum.Code,
|
||||||
|
title: 'Clipboard',
|
||||||
|
desc: '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
] as never,
|
||||||
|
mousePosition: {
|
||||||
|
pageX: 60,
|
||||||
|
pageY: 80,
|
||||||
|
} as never,
|
||||||
|
})
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleNodeAdd(
|
||||||
|
{ nodeType: BlockEnum.Code },
|
||||||
|
{ prevNodeId: 'meta-node-1' },
|
||||||
|
)
|
||||||
|
result.current.handleNodeChange('meta-node-1', BlockEnum.Answer, 'source')
|
||||||
|
result.current.handleNodesPaste()
|
||||||
|
result.current.handleNodeResize('meta-node-1', {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 260,
|
||||||
|
height: 140,
|
||||||
|
} as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rfState.setNodes).toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user