mirror of
https://github.com/langgenius/dify.git
synced 2026-04-15 18:06:36 +08:00
chore: improve codecov
This commit is contained in:
parent
026af49ec2
commit
aea20a576f
@ -0,0 +1,348 @@
|
||||
import type { LoroMap } from 'loro-crdt'
|
||||
import type { OnlineUser, RestoreRequestData } 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 UndoManagerLike = {
|
||||
canUndo: () => boolean
|
||||
canRedo: () => boolean
|
||||
undo: () => boolean
|
||||
redo: () => boolean
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
type CollaborationManagerInternals = {
|
||||
doc: LoroDoc | null
|
||||
nodesMap: LoroMap | null
|
||||
edgesMap: LoroMap | null
|
||||
undoManager: UndoManagerLike | null
|
||||
currentAppId: string | null
|
||||
reactFlowStore: ReactFlowStore | null
|
||||
leaderId: string | null
|
||||
isLeader: boolean
|
||||
graphViewActive: boolean | null
|
||||
pendingInitialSync: boolean
|
||||
onlineUsers: OnlineUser[]
|
||||
graphImportLogs: unknown[]
|
||||
setNodesAnomalyLogs: unknown[]
|
||||
graphSyncDiagnostics: unknown[]
|
||||
pendingImportLog: unknown | null
|
||||
}
|
||||
|
||||
const getManagerInternals = (manager: CollaborationManager): CollaborationManagerInternals =>
|
||||
manager as unknown as CollaborationManagerInternals
|
||||
|
||||
const createNode = (id: string): Node => ({
|
||||
id,
|
||||
type: 'custom',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
type: BlockEnum.Start,
|
||||
title: `Node-${id}`,
|
||||
desc: '',
|
||||
},
|
||||
})
|
||||
|
||||
const createEdge = (id: string, source: string, target: string): Edge => ({
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
type: 'default',
|
||||
data: {
|
||||
sourceType: BlockEnum.Start,
|
||||
targetType: BlockEnum.End,
|
||||
},
|
||||
})
|
||||
|
||||
const createRestoreRequestData = (): RestoreRequestData => ({
|
||||
versionId: 'version-1',
|
||||
versionName: 'Version One',
|
||||
initiatorUserId: 'user-1',
|
||||
initiatorName: 'Alice',
|
||||
graphData: {
|
||||
nodes: [createNode('n-restore')],
|
||||
edges: [],
|
||||
viewport: { x: 1, y: 2, zoom: 0.5 },
|
||||
},
|
||||
})
|
||||
|
||||
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 logs and event helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('refreshGraphSynchronously emits merged graph with local selected node state', () => {
|
||||
const { manager, internals } = setupManagerWithDoc()
|
||||
const node = createNode('n-1')
|
||||
const edge = createEdge('e-1', 'n-1', 'n-2')
|
||||
|
||||
manager.setNodes([], [node])
|
||||
manager.setEdges([], [edge])
|
||||
|
||||
internals.reactFlowStore = {
|
||||
getState: () => ({
|
||||
getNodes: () => [{
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
selected: true,
|
||||
},
|
||||
}],
|
||||
setNodes: vi.fn(),
|
||||
getEdges: () => [edge],
|
||||
setEdges: vi.fn(),
|
||||
}),
|
||||
}
|
||||
|
||||
let payload: { nodes: Node[], edges: Edge[] } | null = null
|
||||
manager.onGraphImport((graph) => {
|
||||
payload = graph
|
||||
})
|
||||
|
||||
manager.refreshGraphSynchronously()
|
||||
|
||||
expect(payload).not.toBeNull()
|
||||
expect(payload?.nodes).toHaveLength(1)
|
||||
expect(payload?.edges).toHaveLength(1)
|
||||
expect(payload?.nodes[0]?.data.selected).toBe(true)
|
||||
})
|
||||
|
||||
it('clearGraphImportLog clears logs and pending import snapshot', () => {
|
||||
const { manager, internals } = setupManagerWithDoc()
|
||||
internals.graphImportLogs = [{ id: 1 }]
|
||||
internals.setNodesAnomalyLogs = [{ id: 2 }]
|
||||
internals.graphSyncDiagnostics = [{ id: 3 }]
|
||||
internals.pendingImportLog = { id: 4 }
|
||||
|
||||
manager.clearGraphImportLog()
|
||||
|
||||
expect(manager.getGraphImportLog()).toEqual([])
|
||||
expect(internals.setNodesAnomalyLogs).toEqual([])
|
||||
expect(internals.graphSyncDiagnostics).toEqual([])
|
||||
expect(internals.pendingImportLog).toBeNull()
|
||||
})
|
||||
|
||||
it('downloadGraphImportLog exports a JSON snapshot and triggers browser download', async () => {
|
||||
const { manager, internals } = setupManagerWithDoc()
|
||||
const node = createNode('n-export')
|
||||
const edge = createEdge('e-export', 'n-export', 'n-target')
|
||||
|
||||
manager.setNodes([], [node])
|
||||
manager.setEdges([], [edge])
|
||||
|
||||
internals.currentAppId = 'app-export'
|
||||
internals.leaderId = 'leader-1'
|
||||
internals.isLeader = true
|
||||
internals.graphViewActive = true
|
||||
internals.pendingInitialSync = false
|
||||
internals.onlineUsers = [{ user_id: 'u-1', username: 'Alice', avatar: '', sid: 'sid-1' }]
|
||||
internals.graphImportLogs = [{ timestamp: 1 }]
|
||||
internals.setNodesAnomalyLogs = [{ timestamp: 2 }]
|
||||
internals.graphSyncDiagnostics = [{ timestamp: 3 }]
|
||||
internals.reactFlowStore = {
|
||||
getState: () => ({
|
||||
getNodes: () => [createNode('rf-1'), createNode('rf-2')],
|
||||
setNodes: vi.fn(),
|
||||
getEdges: () => [createEdge('rf-e', 'rf-1', 'rf-2')],
|
||||
setEdges: vi.fn(),
|
||||
}),
|
||||
}
|
||||
|
||||
const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:workflow-log')
|
||||
const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
const anchor = document.createElement('a')
|
||||
const clickSpy = vi.spyOn(anchor, 'click').mockImplementation(() => {})
|
||||
const originalCreateElement = document.createElement.bind(document)
|
||||
const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tagName: string): HTMLElement => {
|
||||
if (tagName === 'a')
|
||||
return anchor
|
||||
return originalCreateElement(tagName)
|
||||
})
|
||||
|
||||
manager.downloadGraphImportLog()
|
||||
|
||||
expect(createObjectURLSpy).toHaveBeenCalledTimes(1)
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
expect(anchor.download).toContain('workflow-graph-import-log-app-export-')
|
||||
expect(anchor.download).toMatch(/\.json$/)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:workflow-log')
|
||||
|
||||
const blobArg = createObjectURLSpy.mock.calls[0]?.[0]
|
||||
expect(blobArg).toBeInstanceOf(Blob)
|
||||
const payload = JSON.parse(await (blobArg as Blob).text()) as {
|
||||
appId: string | null
|
||||
summary: {
|
||||
logCount: number
|
||||
setNodesAnomalyCount: number
|
||||
syncDiagnosticCount: number
|
||||
onlineUsersCount: number
|
||||
crdtCounts: { nodes: number, edges: number }
|
||||
reactFlowCounts: { nodes: number, edges: number }
|
||||
}
|
||||
}
|
||||
|
||||
expect(payload.appId).toBe('app-export')
|
||||
expect(payload.summary.logCount).toBe(1)
|
||||
expect(payload.summary.setNodesAnomalyCount).toBe(1)
|
||||
expect(payload.summary.syncDiagnosticCount).toBe(1)
|
||||
expect(payload.summary.onlineUsersCount).toBe(1)
|
||||
expect(payload.summary.crdtCounts).toEqual({ nodes: 1, edges: 1 })
|
||||
expect(payload.summary.reactFlowCounts).toEqual({ nodes: 2, edges: 1 })
|
||||
|
||||
createElementSpy.mockRestore()
|
||||
clickSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('emits collaboration events only when current app is connected', () => {
|
||||
const { manager, internals } = setupManagerWithDoc()
|
||||
const sendSpy = vi.spyOn(
|
||||
manager as unknown as { sendCollaborationEvent: (payload: unknown) => void },
|
||||
'sendCollaborationEvent',
|
||||
).mockImplementation(() => {})
|
||||
const isConnectedSpy = vi.spyOn(webSocketClient, 'isConnected').mockReturnValue(false)
|
||||
|
||||
manager.emitCommentsUpdate('app-1')
|
||||
manager.emitHistoryAction('undo')
|
||||
manager.emitRestoreRequest(createRestoreRequestData())
|
||||
manager.emitRestoreIntent({
|
||||
versionId: 'version-1',
|
||||
versionName: 'Version One',
|
||||
initiatorUserId: 'u-1',
|
||||
initiatorName: 'Alice',
|
||||
})
|
||||
manager.emitRestoreComplete({ versionId: 'version-1', success: true })
|
||||
expect(sendSpy).not.toHaveBeenCalled()
|
||||
|
||||
internals.currentAppId = 'app-1'
|
||||
|
||||
manager.emitCommentsUpdate('app-1')
|
||||
manager.emitHistoryAction('undo')
|
||||
manager.emitRestoreRequest(createRestoreRequestData())
|
||||
expect(sendSpy).not.toHaveBeenCalled()
|
||||
|
||||
isConnectedSpy.mockReturnValue(true)
|
||||
manager.emitCommentsUpdate('app-1')
|
||||
manager.emitHistoryAction('redo')
|
||||
manager.emitRestoreRequest(createRestoreRequestData())
|
||||
manager.emitRestoreIntent({
|
||||
versionId: 'version-2',
|
||||
initiatorUserId: 'u-2',
|
||||
initiatorName: 'Bob',
|
||||
})
|
||||
manager.emitRestoreComplete({ versionId: 'version-2', success: false, error: 'failed' })
|
||||
|
||||
const eventTypes = sendSpy.mock.calls.map(call => (
|
||||
(call[0] as { type: string }).type
|
||||
))
|
||||
expect(eventTypes).toEqual([
|
||||
'comments_update',
|
||||
'workflow_history_action',
|
||||
'workflow_restore_request',
|
||||
'workflow_restore_intent',
|
||||
'workflow_restore_complete',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns leader state through public getters', () => {
|
||||
const { manager, internals } = setupManagerWithDoc()
|
||||
internals.leaderId = 'leader-123'
|
||||
internals.isLeader = true
|
||||
|
||||
expect(manager.getLeaderId()).toBe('leader-123')
|
||||
expect(manager.getIsLeader()).toBe(true)
|
||||
})
|
||||
|
||||
it('undo and redo apply CRDT graph to ReactFlow store and emit undo/redo state', () => {
|
||||
const { manager, internals } = setupManagerWithDoc()
|
||||
const updatedNode = createNode('n-after-undo-redo')
|
||||
const updatedEdge = createEdge('e-after-undo-redo', 'n-after-undo-redo', 'n-target')
|
||||
internals.nodesMap?.set(updatedNode.id, updatedNode as unknown as Record<string, unknown>)
|
||||
internals.edgesMap?.set(updatedEdge.id, updatedEdge as unknown as Record<string, unknown>)
|
||||
|
||||
const setNodesSpy = vi.fn()
|
||||
const setEdgesSpy = vi.fn()
|
||||
internals.reactFlowStore = {
|
||||
getState: () => ({
|
||||
getNodes: () => [createNode('old-node')],
|
||||
setNodes: setNodesSpy,
|
||||
getEdges: () => [createEdge('old-edge', 'old-node', 'old-target')],
|
||||
setEdges: setEdgesSpy,
|
||||
}),
|
||||
}
|
||||
|
||||
const undoManager: UndoManagerLike = {
|
||||
canUndo: vi.fn(() => true),
|
||||
canRedo: vi.fn(() => true),
|
||||
undo: vi.fn(() => true),
|
||||
redo: vi.fn(() => true),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
internals.undoManager = undoManager
|
||||
|
||||
const rafSpy = vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation((callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 1
|
||||
})
|
||||
|
||||
const historyStates: Array<{ canUndo: boolean, canRedo: boolean }> = []
|
||||
manager.onUndoRedoStateChange((state) => {
|
||||
historyStates.push(state)
|
||||
})
|
||||
|
||||
expect(manager.undo()).toBe(true)
|
||||
expect(manager.redo()).toBe(true)
|
||||
expect(setNodesSpy).toHaveBeenCalledTimes(2)
|
||||
expect(setEdgesSpy).toHaveBeenCalledTimes(2)
|
||||
expect(historyStates).toEqual([
|
||||
{ canUndo: true, canRedo: true },
|
||||
{ canUndo: true, canRedo: true },
|
||||
])
|
||||
|
||||
rafSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('exposes undo stack state helpers and supports clearing the stack', () => {
|
||||
const { manager, internals } = setupManagerWithDoc()
|
||||
|
||||
expect(manager.canUndo()).toBe(false)
|
||||
expect(manager.canRedo()).toBe(false)
|
||||
expect(manager.undo()).toBe(false)
|
||||
expect(manager.redo()).toBe(false)
|
||||
|
||||
const undoManager: UndoManagerLike = {
|
||||
canUndo: vi.fn(() => false),
|
||||
canRedo: vi.fn(() => true),
|
||||
undo: vi.fn(() => false),
|
||||
redo: vi.fn(() => false),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
internals.undoManager = undoManager
|
||||
|
||||
expect(manager.canUndo()).toBe(false)
|
||||
expect(manager.canRedo()).toBe(true)
|
||||
manager.clearUndoStack()
|
||||
expect(undoManager.clear).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,151 @@
|
||||
import type { CursorPosition, NodePanelPresenceMap, OnlineUser } from '../../types/collaboration'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { useCollaboration } from '../use-collaboration'
|
||||
|
||||
type HookReactFlowStore = NonNullable<Parameters<typeof useCollaboration>[1]>
|
||||
type HookReactFlowInstance = Parameters<ReturnType<typeof useCollaboration>['startCursorTracking']>[1]
|
||||
|
||||
const mockConnect = vi.hoisted(() => vi.fn())
|
||||
const mockDisconnect = vi.hoisted(() => vi.fn())
|
||||
const mockIsConnected = vi.hoisted(() => vi.fn(() => true))
|
||||
const mockEmitCursorMove = vi.hoisted(() => vi.fn())
|
||||
const mockGetLeaderId = vi.hoisted(() => vi.fn(() => 'leader-1'))
|
||||
|
||||
let onStateChangeCallback: ((state: { isConnected?: boolean, disconnectReason?: string, error?: string }) => void) | null = null
|
||||
let onCursorCallback: ((cursors: Record<string, CursorPosition>) => void) | null = null
|
||||
let onUsersCallback: ((users: OnlineUser[]) => void) | null = null
|
||||
let onPresenceCallback: ((presence: NodePanelPresenceMap) => void) | null = null
|
||||
let onLeaderCallback: ((isLeader: boolean) => void) | null = null
|
||||
|
||||
const unsubscribeState = vi.hoisted(() => vi.fn())
|
||||
const unsubscribeCursor = vi.hoisted(() => vi.fn())
|
||||
const unsubscribeUsers = vi.hoisted(() => vi.fn())
|
||||
const unsubscribePresence = vi.hoisted(() => vi.fn())
|
||||
const unsubscribeLeader = vi.hoisted(() => vi.fn())
|
||||
|
||||
let isCollaborationEnabled = true
|
||||
|
||||
const mockStartTracking = vi.hoisted(() => vi.fn())
|
||||
const mockStopTracking = vi.hoisted(() => vi.fn())
|
||||
const cursorServiceInstances: Array<{ startTracking: typeof mockStartTracking, stopTracking: typeof mockStopTracking }> = []
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_collaboration_mode: boolean } }) => boolean) =>
|
||||
selector({ systemFeatures: { enable_collaboration_mode: isCollaborationEnabled } }),
|
||||
}))
|
||||
|
||||
vi.mock('../../core/collaboration-manager', () => ({
|
||||
collaborationManager: {
|
||||
connect: (...args: unknown[]) => mockConnect(...args),
|
||||
disconnect: (...args: unknown[]) => mockDisconnect(...args),
|
||||
isConnected: (...args: unknown[]) => mockIsConnected(...args),
|
||||
emitCursorMove: (...args: unknown[]) => mockEmitCursorMove(...args),
|
||||
getLeaderId: (...args: unknown[]) => mockGetLeaderId(...args),
|
||||
onStateChange: (callback: (state: { isConnected?: boolean, disconnectReason?: string, error?: string }) => void) => {
|
||||
onStateChangeCallback = callback
|
||||
return unsubscribeState
|
||||
},
|
||||
onCursorUpdate: (callback: (cursors: Record<string, CursorPosition>) => void) => {
|
||||
onCursorCallback = callback
|
||||
return unsubscribeCursor
|
||||
},
|
||||
onOnlineUsersUpdate: (callback: (users: OnlineUser[]) => void) => {
|
||||
onUsersCallback = callback
|
||||
return unsubscribeUsers
|
||||
},
|
||||
onNodePanelPresenceUpdate: (callback: (presence: NodePanelPresenceMap) => void) => {
|
||||
onPresenceCallback = callback
|
||||
return unsubscribePresence
|
||||
},
|
||||
onLeaderChange: (callback: (isLeader: boolean) => void) => {
|
||||
onLeaderCallback = callback
|
||||
return unsubscribeLeader
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../services/cursor-service', () => ({
|
||||
CursorService: class {
|
||||
startTracking = mockStartTracking
|
||||
stopTracking = mockStopTracking
|
||||
constructor() {
|
||||
cursorServiceInstances.push({ startTracking: this.startTracking, stopTracking: this.stopTracking })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useCollaboration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
onStateChangeCallback = null
|
||||
onCursorCallback = null
|
||||
onUsersCallback = null
|
||||
onPresenceCallback = null
|
||||
onLeaderCallback = null
|
||||
isCollaborationEnabled = true
|
||||
cursorServiceInstances.length = 0
|
||||
mockConnect.mockResolvedValue('conn-1')
|
||||
mockIsConnected.mockReturnValue(true)
|
||||
})
|
||||
|
||||
it('connects, reacts to manager updates, and disconnects on unmount', async () => {
|
||||
const reactFlowStore: HookReactFlowStore = {
|
||||
getState: vi.fn(),
|
||||
}
|
||||
const { result, unmount } = renderHook(() => useCollaboration('app-1', reactFlowStore))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConnect).toHaveBeenCalledWith('app-1', reactFlowStore)
|
||||
})
|
||||
|
||||
onStateChangeCallback?.({ isConnected: true })
|
||||
onUsersCallback?.([{ user_id: 'u1', user_name: 'U1', avatar_url: '', sid: 'sid-1' } as OnlineUser])
|
||||
onCursorCallback?.({ u1: { x: 10, y: 20, userId: 'u1', timestamp: 1 } })
|
||||
onPresenceCallback?.({ nodeA: { sid1: { userId: 'u1', username: 'U1', clientId: 'sid1', timestamp: 1 } } })
|
||||
onLeaderCallback?.(true)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true)
|
||||
expect(result.current.onlineUsers).toHaveLength(1)
|
||||
expect(result.current.cursors.u1?.x).toBe(10)
|
||||
expect(result.current.nodePanelPresence.nodeA).toBeDefined()
|
||||
expect(result.current.isLeader).toBe(true)
|
||||
expect(result.current.leaderId).toBe('leader-1')
|
||||
})
|
||||
|
||||
const ref = { current: document.createElement('div') }
|
||||
const reactFlowInstance: HookReactFlowInstance = {
|
||||
getZoom: () => 1,
|
||||
getViewport: () => ({ x: 0, y: 0, zoom: 1 }),
|
||||
} as HookReactFlowInstance
|
||||
result.current.startCursorTracking(ref, reactFlowInstance)
|
||||
expect(mockStartTracking).toHaveBeenCalledTimes(1)
|
||||
const emitPosition = mockStartTracking.mock.calls[0]?.[1] as ((position: CursorPosition) => void)
|
||||
emitPosition({ x: 1, y: 2, userId: 'u1', timestamp: 2 })
|
||||
expect(mockEmitCursorMove).toHaveBeenCalledWith({ x: 1, y: 2, userId: 'u1', timestamp: 2 })
|
||||
|
||||
result.current.stopCursorTracking()
|
||||
expect(mockStopTracking).toHaveBeenCalled()
|
||||
|
||||
unmount()
|
||||
expect(unsubscribeState).toHaveBeenCalled()
|
||||
expect(unsubscribeCursor).toHaveBeenCalled()
|
||||
expect(unsubscribeUsers).toHaveBeenCalled()
|
||||
expect(unsubscribePresence).toHaveBeenCalled()
|
||||
expect(unsubscribeLeader).toHaveBeenCalled()
|
||||
expect(mockDisconnect).toHaveBeenCalledWith('conn-1')
|
||||
})
|
||||
|
||||
it('does not connect or start cursor tracking when collaboration is disabled', async () => {
|
||||
isCollaborationEnabled = false
|
||||
const { result } = renderHook(() => useCollaboration('app-1'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockConnect).not.toHaveBeenCalled()
|
||||
expect(result.current.isEnabled).toBe(false)
|
||||
})
|
||||
|
||||
result.current.startCursorTracking({ current: document.createElement('div') })
|
||||
expect(mockStartTracking).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,86 @@
|
||||
import type { ReactFlowInstance } from 'reactflow'
|
||||
import { CursorService } from '../cursor-service'
|
||||
|
||||
describe('CursorService', () => {
|
||||
let service: CursorService
|
||||
let container: HTMLDivElement
|
||||
let now = 0
|
||||
|
||||
beforeEach(() => {
|
||||
service = new CursorService()
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
vi.spyOn(container, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 10,
|
||||
y: 20,
|
||||
top: 20,
|
||||
left: 10,
|
||||
right: 410,
|
||||
bottom: 220,
|
||||
width: 400,
|
||||
height: 200,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect)
|
||||
now = 1000
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
container.remove()
|
||||
})
|
||||
|
||||
it('emits transformed positions with throttle and distance guard', () => {
|
||||
const onEmit = vi.fn()
|
||||
const reactFlow = {
|
||||
getViewport: () => ({ x: 5, y: 10, zoom: 2 }),
|
||||
getZoom: () => 2,
|
||||
} as unknown as ReactFlowInstance
|
||||
|
||||
service.startTracking({ current: container }, onEmit, reactFlow)
|
||||
|
||||
container.dispatchEvent(new MouseEvent('mousemove', { clientX: 30, clientY: 50 }))
|
||||
expect(onEmit).toHaveBeenCalledTimes(1)
|
||||
expect(onEmit).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
x: 7.5,
|
||||
y: 10,
|
||||
timestamp: 1000,
|
||||
}))
|
||||
|
||||
now = 1100
|
||||
container.dispatchEvent(new MouseEvent('mousemove', { clientX: 40, clientY: 60 }))
|
||||
expect(onEmit).toHaveBeenCalledTimes(1)
|
||||
|
||||
now = 1401
|
||||
container.dispatchEvent(new MouseEvent('mousemove', { clientX: 33, clientY: 53 }))
|
||||
expect(onEmit).toHaveBeenCalledTimes(1)
|
||||
|
||||
now = 1800
|
||||
container.dispatchEvent(new MouseEvent('mousemove', { clientX: 60, clientY: 90 }))
|
||||
expect(onEmit).toHaveBeenCalledTimes(2)
|
||||
expect(onEmit).toHaveBeenLastCalledWith(expect.objectContaining({
|
||||
x: 22.5,
|
||||
y: 30,
|
||||
timestamp: 1800,
|
||||
}))
|
||||
})
|
||||
|
||||
it('stops tracking and forwards cursor updates to registered handler', () => {
|
||||
const onEmit = vi.fn()
|
||||
const onCursorUpdate = vi.fn()
|
||||
service.startTracking({ current: container }, onEmit)
|
||||
service.setCursorUpdateHandler(onCursorUpdate)
|
||||
|
||||
service.updateCursors({
|
||||
u1: { x: 1, y: 2, userId: 'u1', timestamp: 1 },
|
||||
})
|
||||
expect(onCursorUpdate).toHaveBeenCalledWith({
|
||||
u1: { x: 1, y: 2, userId: 'u1', timestamp: 1 },
|
||||
})
|
||||
|
||||
service.stopTracking()
|
||||
now = 2000
|
||||
container.dispatchEvent(new MouseEvent('mousemove', { clientX: 40, clientY: 60 }))
|
||||
expect(onEmit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
151
web/app/components/workflow/comment/mention-input.spec.tsx
Normal file
151
web/app/components/workflow/comment/mention-input.spec.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import type { UserProfile } from '@/service/workflow-comment'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { MentionInput } from './mention-input'
|
||||
|
||||
const mockFetchMentionableUsers = vi.hoisted(() => vi.fn())
|
||||
const mockSetMentionableUsersLoading = vi.hoisted(() => vi.fn())
|
||||
const mockSetMentionableUsersCache = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mentionStoreState = vi.hoisted(() => ({
|
||||
mentionableUsersCache: {} as Record<string, UserProfile[]>,
|
||||
mentionableUsersLoading: {} as Record<string, boolean>,
|
||||
setMentionableUsersLoading: (appId: string, loading: boolean) => {
|
||||
mockSetMentionableUsersLoading(appId, loading)
|
||||
mentionStoreState.mentionableUsersLoading[appId] = loading
|
||||
},
|
||||
setMentionableUsersCache: (appId: string, users: UserProfile[]) => {
|
||||
mockSetMentionableUsersCache(appId, users)
|
||||
mentionStoreState.mentionableUsersCache[appId] = users
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useParams: () => ({ appId: 'app-1' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow-comment', () => ({
|
||||
fetchMentionableUsers: (...args: unknown[]) => mockFetchMentionableUsers(...args),
|
||||
}))
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: typeof mentionStoreState) => unknown) => selector(mentionStoreState),
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => mentionStoreState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/avatar', () => ({
|
||||
Avatar: ({ name }: { name: string }) => <div data-testid="mention-avatar">{name}</div>,
|
||||
}))
|
||||
|
||||
const mentionUsers: UserProfile[] = [
|
||||
{
|
||||
id: 'user-2',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
avatar_url: 'alice.png',
|
||||
},
|
||||
{
|
||||
id: 'user-3',
|
||||
name: 'Bob',
|
||||
email: 'bob@example.com',
|
||||
avatar_url: 'bob.png',
|
||||
},
|
||||
]
|
||||
|
||||
function ControlledMentionInput({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: (content: string, mentionedUserIds: string[]) => void
|
||||
}) {
|
||||
const [value, setValue] = useState('')
|
||||
return (
|
||||
<MentionInput
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
describe('MentionInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mentionStoreState.mentionableUsersCache = {}
|
||||
mentionStoreState.mentionableUsersLoading = {}
|
||||
mockFetchMentionableUsers.mockResolvedValue(mentionUsers)
|
||||
})
|
||||
|
||||
it('loads mentionable users when cache is empty', async () => {
|
||||
render(
|
||||
<MentionInput
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchMentionableUsers).toHaveBeenCalledWith('app-1')
|
||||
})
|
||||
|
||||
expect(mockSetMentionableUsersLoading).toHaveBeenCalledWith('app-1', true)
|
||||
expect(mockSetMentionableUsersCache).toHaveBeenCalledWith('app-1', mentionUsers)
|
||||
expect(mockSetMentionableUsersLoading).toHaveBeenCalledWith('app-1', false)
|
||||
})
|
||||
|
||||
it('selects a mention and submits with mentioned user ids', async () => {
|
||||
mentionStoreState.mentionableUsersCache['app-1'] = mentionUsers
|
||||
const onSubmit = vi.fn()
|
||||
|
||||
render(<ControlledMentionInput onSubmit={onSubmit} />)
|
||||
|
||||
const textarea = screen.getByPlaceholderText('workflow.comments.placeholder.add') as HTMLTextAreaElement
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(4, 4)
|
||||
fireEvent.change(textarea, { target: { value: '@Ali' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('alice@example.com'))
|
||||
fireEvent.change(textarea, { target: { value: '@Alice hi' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith('@Alice hi', ['user-2'])
|
||||
})
|
||||
})
|
||||
|
||||
it('supports editing mode cancel and save actions', async () => {
|
||||
mentionStoreState.mentionableUsersCache['app-1'] = mentionUsers
|
||||
const onSubmit = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
render(
|
||||
<MentionInput
|
||||
value=" updated reply "
|
||||
onChange={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
isEditing
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.cancel'))
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByText('common.operation.save'))
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith('updated reply', [])
|
||||
})
|
||||
})
|
||||
})
|
||||
244
web/app/components/workflow/comment/thread.spec.tsx
Normal file
244
web/app/components/workflow/comment/thread.spec.tsx
Normal file
@ -0,0 +1,244 @@
|
||||
import type { WorkflowCommentDetail } from '@/service/workflow-comment'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { CommentThread } from './thread'
|
||||
|
||||
const mockSetCommentPreviewHovering = vi.hoisted(() => vi.fn())
|
||||
const mockFlowToScreenPosition = vi.hoisted(() => vi.fn(({ x, y }: { x: number, y: number }) => ({ x, y })))
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
mentionableUsersCache: {
|
||||
'app-1': [
|
||||
{ id: 'user-1', name: 'Alice', email: 'alice@example.com', avatar_url: 'alice.png' },
|
||||
{ id: 'user-2', name: 'Bob', email: 'bob@example.com', avatar_url: 'bob.png' },
|
||||
],
|
||||
} as Record<string, Array<{ id: string, name: string, email: string, avatar_url: string }>>,
|
||||
setCommentPreviewHovering: (...args: unknown[]) => mockSetCommentPreviewHovering(...args),
|
||||
}))
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useParams: () => ({ appId: 'app-1' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: () => 'just now',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
userProfile: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
avatar_url: 'alice.png',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useReactFlow: () => ({
|
||||
flowToScreenPosition: mockFlowToScreenPosition,
|
||||
}),
|
||||
useViewport: () => ({ x: 0, y: 0, zoom: 1 }),
|
||||
}))
|
||||
|
||||
vi.mock('../store', () => ({
|
||||
useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/utils/user-color', () => ({
|
||||
getUserColor: () => '#22c55e',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/divider', () => ({
|
||||
default: () => <div data-testid="divider" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/inline-delete-confirm', () => ({
|
||||
default: ({ onConfirm }: { onConfirm: () => void }) => (
|
||||
<button type="button" data-testid="confirm-delete-reply" onClick={onConfirm}>
|
||||
confirm delete
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/avatar', () => ({
|
||||
Avatar: ({ name }: { name: string }) => <div data-testid="avatar">{name}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/dropdown-menu', () => ({
|
||||
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuTrigger: ({ children, ...props }: React.ComponentProps<'button'>) => (
|
||||
<button type="button" {...props}>{children}</button>
|
||||
),
|
||||
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}))
|
||||
|
||||
vi.mock('./mention-input', () => ({
|
||||
MentionInput: ({
|
||||
placeholder,
|
||||
value,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
placeholder?: string
|
||||
value: string
|
||||
onSubmit: (content: string, mentionedUserIds: string[]) => void
|
||||
onCancel?: () => void
|
||||
}) => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSubmit(value || `content:${placeholder ?? 'default'}`, ['user-2'])}
|
||||
>
|
||||
{`submit-${placeholder ?? 'default'}`}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button type="button" onClick={onCancel}>
|
||||
{`cancel-${placeholder ?? 'default'}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
const createComment = (): WorkflowCommentDetail => ({
|
||||
id: 'comment-1',
|
||||
position_x: 120,
|
||||
position_y: 80,
|
||||
content: '@Alice original comment',
|
||||
created_by: 'user-1',
|
||||
created_by_account: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
avatar_url: 'alice.png',
|
||||
},
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
resolved: false,
|
||||
mentions: [],
|
||||
replies: [{
|
||||
id: 'reply-1',
|
||||
content: 'first reply',
|
||||
created_by: 'user-1',
|
||||
created_by_account: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
avatar_url: 'alice.png',
|
||||
},
|
||||
created_at: 2,
|
||||
updated_at: 2,
|
||||
mentions: [],
|
||||
}],
|
||||
})
|
||||
|
||||
describe('CommentThread', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = ''
|
||||
const workflowContainer = document.createElement('div')
|
||||
workflowContainer.id = 'workflow-container'
|
||||
document.body.appendChild(workflowContainer)
|
||||
})
|
||||
|
||||
it('triggers header actions and closes on Escape', () => {
|
||||
const onClose = vi.fn()
|
||||
const onDelete = vi.fn()
|
||||
const onResolve = vi.fn()
|
||||
const onPrev = vi.fn()
|
||||
const onNext = vi.fn()
|
||||
|
||||
render(
|
||||
<CommentThread
|
||||
comment={createComment()}
|
||||
onClose={onClose}
|
||||
onDelete={onDelete}
|
||||
onResolve={onResolve}
|
||||
onPrev={onPrev}
|
||||
onNext={onNext}
|
||||
canGoPrev
|
||||
canGoNext
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('workflow.comments.aria.deleteComment'))
|
||||
fireEvent.click(screen.getByLabelText('workflow.comments.aria.resolveComment'))
|
||||
fireEvent.click(screen.getByLabelText('workflow.comments.aria.previousComment'))
|
||||
fireEvent.click(screen.getByLabelText('workflow.comments.aria.nextComment'))
|
||||
fireEvent.click(screen.getByLabelText('workflow.comments.aria.closeComment'))
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
expect(onDelete).toHaveBeenCalledTimes(1)
|
||||
expect(onResolve).toHaveBeenCalledTimes(1)
|
||||
expect(onPrev).toHaveBeenCalledTimes(1)
|
||||
expect(onNext).toHaveBeenCalledTimes(1)
|
||||
expect(onClose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('submits reply and updates preview hovering state on mouse enter/leave', async () => {
|
||||
const onReply = vi.fn()
|
||||
const { container } = render(
|
||||
<CommentThread
|
||||
comment={createComment()}
|
||||
onClose={vi.fn()}
|
||||
onReply={onReply}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.mouseEnter(container.firstElementChild as Element)
|
||||
fireEvent.mouseLeave(container.firstElementChild as Element)
|
||||
|
||||
expect(mockSetCommentPreviewHovering).toHaveBeenNthCalledWith(1, true)
|
||||
expect(mockSetCommentPreviewHovering).toHaveBeenNthCalledWith(2, false)
|
||||
|
||||
fireEvent.click(screen.getByText('submit-workflow.comments.placeholder.reply'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onReply).toHaveBeenCalledWith('content:workflow.comments.placeholder.reply', ['user-2'])
|
||||
})
|
||||
})
|
||||
|
||||
it('supports editing and direct deleting an existing reply', async () => {
|
||||
const onReplyEdit = vi.fn()
|
||||
const onReplyDeleteDirect = vi.fn()
|
||||
|
||||
render(
|
||||
<CommentThread
|
||||
comment={createComment()}
|
||||
onClose={vi.fn()}
|
||||
onReplyEdit={onReplyEdit}
|
||||
onReplyDeleteDirect={onReplyDeleteDirect}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.comments.actions.editReply'))
|
||||
fireEvent.click(screen.getByText('submit-workflow.comments.placeholder.editReply'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onReplyEdit).toHaveBeenCalledWith('reply-1', 'first reply', ['user-2'])
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('workflow.comments.actions.deleteReply')).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByText('workflow.comments.actions.deleteReply'))
|
||||
fireEvent.click(screen.getByTestId('confirm-delete-reply'))
|
||||
|
||||
expect(onReplyDeleteDirect).toHaveBeenCalledWith('reply-1')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,259 @@
|
||||
import type { RestoreIntentData, RestoreRequestData } from '../../collaboration/types/collaboration'
|
||||
import type { SyncDraftCallback } from '../../hooks-store/store'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useLeaderRestore, useLeaderRestoreListener } from '../use-leader-restore'
|
||||
|
||||
const mockSetViewport = vi.hoisted(() => vi.fn())
|
||||
const mockSetFeatures = vi.hoisted(() => vi.fn())
|
||||
const mockSetEnvironmentVariables = vi.hoisted(() => vi.fn())
|
||||
const mockSetConversationVariables = vi.hoisted(() => vi.fn())
|
||||
const mockDoSyncWorkflowDraft = vi.hoisted(() => vi.fn())
|
||||
const mockToastInfo = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockEmitRestoreIntent = vi.hoisted(() => vi.fn())
|
||||
const mockEmitRestoreComplete = vi.hoisted(() => vi.fn())
|
||||
const mockEmitWorkflowUpdate = vi.hoisted(() => vi.fn())
|
||||
const mockEmitRestoreRequest = vi.hoisted(() => vi.fn())
|
||||
const mockIsConnected = vi.hoisted(() => vi.fn())
|
||||
const mockGetIsLeader = vi.hoisted(() => vi.fn())
|
||||
const mockSetNodes = vi.hoisted(() => vi.fn())
|
||||
const mockSetEdges = vi.hoisted(() => vi.fn())
|
||||
const mockRefreshGraphSynchronously = vi.hoisted(() => vi.fn())
|
||||
const mockGetNodes = vi.hoisted(() => vi.fn(() => [{ id: 'old-node' }]))
|
||||
const mockGetEdges = vi.hoisted(() => vi.fn(() => [{ id: 'old-edge' }]))
|
||||
|
||||
let restoreCompleteCallback: ((data: { versionId: string, success: boolean }) => void) | null = null
|
||||
let restoreRequestCallback: ((data: RestoreRequestData) => void) | null = null
|
||||
let restoreIntentCallback: ((data: RestoreIntentData) => void) | null = null
|
||||
|
||||
const unsubscribeRestoreComplete = vi.hoisted(() => vi.fn())
|
||||
const unsubscribeRestoreRequest = vi.hoisted(() => vi.fn())
|
||||
const unsubscribeRestoreIntent = vi.hoisted(() => vi.fn())
|
||||
|
||||
let isCollaborationEnabled = true
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string, userName?: string, versionName?: string }) => {
|
||||
const ns = options?.ns ? `${options.ns}.` : ''
|
||||
const extra = options?.userName ? `:${options.userName}:${options.versionName}` : ''
|
||||
return `${ns}${key}${extra}`
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useReactFlow: () => ({
|
||||
setViewport: mockSetViewport,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: {
|
||||
getState: () => ({
|
||||
appDetail: { id: 'app-1' },
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeaturesStore: () => ({
|
||||
getState: () => ({
|
||||
setFeatures: mockSetFeatures,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
info: (...args: unknown[]) => mockToastInfo(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_collaboration_mode: boolean } }) => boolean) =>
|
||||
selector({ systemFeatures: { enable_collaboration_mode: isCollaborationEnabled } }),
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
setEnvironmentVariables: mockSetEnvironmentVariables,
|
||||
setConversationVariables: mockSetConversationVariables,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: (...args: unknown[]) => mockDoSyncWorkflowDraft(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../../collaboration/core/collaboration-manager', () => ({
|
||||
collaborationManager: {
|
||||
emitRestoreIntent: (...args: unknown[]) => mockEmitRestoreIntent(...args),
|
||||
emitRestoreComplete: (...args: unknown[]) => mockEmitRestoreComplete(...args),
|
||||
emitWorkflowUpdate: (...args: unknown[]) => mockEmitWorkflowUpdate(...args),
|
||||
emitRestoreRequest: (...args: unknown[]) => mockEmitRestoreRequest(...args),
|
||||
isConnected: (...args: unknown[]) => mockIsConnected(...args),
|
||||
getIsLeader: (...args: unknown[]) => mockGetIsLeader(...args),
|
||||
setNodes: (...args: unknown[]) => mockSetNodes(...args),
|
||||
setEdges: (...args: unknown[]) => mockSetEdges(...args),
|
||||
refreshGraphSynchronously: (...args: unknown[]) => mockRefreshGraphSynchronously(...args),
|
||||
getNodes: (...args: unknown[]) => mockGetNodes(...args),
|
||||
getEdges: (...args: unknown[]) => mockGetEdges(...args),
|
||||
onRestoreComplete: (callback: (data: { versionId: string, success: boolean }) => void) => {
|
||||
restoreCompleteCallback = callback
|
||||
return unsubscribeRestoreComplete
|
||||
},
|
||||
onRestoreRequest: (callback: (data: RestoreRequestData) => void) => {
|
||||
restoreRequestCallback = callback
|
||||
return unsubscribeRestoreRequest
|
||||
},
|
||||
onRestoreIntent: (callback: (data: RestoreIntentData) => void) => {
|
||||
restoreIntentCallback = callback
|
||||
return unsubscribeRestoreIntent
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
describe('useLeaderRestore', () => {
|
||||
const restoreData: RestoreRequestData = {
|
||||
versionId: 'v-1',
|
||||
versionName: 'Version One',
|
||||
initiatorUserId: 'u-1',
|
||||
initiatorName: 'Alice',
|
||||
features: { a: true },
|
||||
environmentVariables: [{ id: 'env-1', name: 'A', value: '1', value_type: 'string', description: '' }],
|
||||
conversationVariables: [{ id: 'conv-1', name: 'B', value: '2', value_type: 'string', description: '' }],
|
||||
graphData: {
|
||||
nodes: [{ id: 'new-node' }],
|
||||
edges: [{ id: 'new-edge' }],
|
||||
viewport: { x: 1, y: 2, zoom: 0.5 },
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
restoreCompleteCallback = null
|
||||
restoreRequestCallback = null
|
||||
restoreIntentCallback = null
|
||||
isCollaborationEnabled = true
|
||||
mockIsConnected.mockReturnValue(true)
|
||||
mockGetIsLeader.mockReturnValue(false)
|
||||
mockDoSyncWorkflowDraft.mockImplementation((_sync: boolean, callbacks?: SyncDraftCallback) => {
|
||||
callbacks?.onSuccess?.()
|
||||
callbacks?.onSettled?.()
|
||||
})
|
||||
})
|
||||
|
||||
it('performs restore locally when collaboration is disabled', async () => {
|
||||
isCollaborationEnabled = false
|
||||
const onSuccess = vi.fn()
|
||||
const onSettled = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useLeaderRestore())
|
||||
|
||||
await act(async () => {
|
||||
result.current.requestRestore(restoreData, { onSuccess, onSettled })
|
||||
})
|
||||
|
||||
expect(mockEmitRestoreIntent).toHaveBeenCalledWith(expect.objectContaining({
|
||||
versionId: 'v-1',
|
||||
initiatorName: 'Alice',
|
||||
}))
|
||||
expect(mockSetFeatures).toHaveBeenCalledWith({ a: true })
|
||||
expect(mockSetEnvironmentVariables).toHaveBeenCalled()
|
||||
expect(mockSetConversationVariables).toHaveBeenCalled()
|
||||
expect(mockSetNodes).toHaveBeenCalledWith([{ id: 'old-node' }], [{ id: 'new-node' }], 'leader-restore:apply-graph')
|
||||
expect(mockSetEdges).toHaveBeenCalledWith([{ id: 'old-edge' }], [{ id: 'new-edge' }])
|
||||
expect(mockRefreshGraphSynchronously).toHaveBeenCalled()
|
||||
expect(mockSetViewport).toHaveBeenCalledWith({ x: 1, y: 2, zoom: 0.5 })
|
||||
expect(mockEmitRestoreComplete).toHaveBeenCalledWith({ versionId: 'v-1', success: true })
|
||||
expect(mockEmitWorkflowUpdate).toHaveBeenCalledWith('app-1')
|
||||
expect(onSuccess).toHaveBeenCalled()
|
||||
expect(onSettled).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits restore request and resolves callbacks from restore-complete events', async () => {
|
||||
isCollaborationEnabled = true
|
||||
mockIsConnected.mockReturnValue(true)
|
||||
mockGetIsLeader.mockReturnValue(false)
|
||||
const onSuccess = vi.fn()
|
||||
const onError = vi.fn()
|
||||
const onSettled = vi.fn()
|
||||
|
||||
const { result } = renderHook(() => useLeaderRestore())
|
||||
|
||||
act(() => {
|
||||
result.current.requestRestore(restoreData, { onSuccess, onError, onSettled })
|
||||
})
|
||||
|
||||
expect(mockEmitRestoreRequest).toHaveBeenCalledWith(restoreData)
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
restoreCompleteCallback?.({ versionId: 'v-1', success: true })
|
||||
})
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(onSettled).toHaveBeenCalledTimes(1)
|
||||
|
||||
act(() => {
|
||||
result.current.requestRestore(restoreData, { onSuccess, onError, onSettled })
|
||||
restoreCompleteCallback?.({ versionId: 'v-1', success: false })
|
||||
})
|
||||
expect(onError).toHaveBeenCalledTimes(1)
|
||||
expect(onSettled).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useLeaderRestoreListener', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
restoreRequestCallback = null
|
||||
restoreIntentCallback = null
|
||||
mockDoSyncWorkflowDraft.mockImplementation((_sync: boolean, callbacks?: SyncDraftCallback) => {
|
||||
callbacks?.onSuccess?.()
|
||||
callbacks?.onSettled?.()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows restore notifications for request and intent events', () => {
|
||||
const { unmount } = renderHook(() => useLeaderRestoreListener())
|
||||
|
||||
act(() => {
|
||||
restoreRequestCallback?.({
|
||||
...{
|
||||
versionId: 'v-2',
|
||||
versionName: 'Version Two',
|
||||
initiatorUserId: 'u-2',
|
||||
initiatorName: 'Bob',
|
||||
graphData: { nodes: [], edges: [] },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(mockToastInfo).toHaveBeenCalledWith(
|
||||
'workflow.versionHistory.action.restoreInProgress:Bob:Version Two',
|
||||
{ timeout: 3000 },
|
||||
)
|
||||
expect(mockEmitRestoreIntent).toHaveBeenCalled()
|
||||
|
||||
act(() => {
|
||||
restoreIntentCallback?.({
|
||||
versionId: 'v-3',
|
||||
versionName: 'Version Three',
|
||||
initiatorName: 'Carol',
|
||||
})
|
||||
})
|
||||
expect(mockToastInfo).toHaveBeenCalledWith(
|
||||
'workflow.versionHistory.action.restoreInProgress:Carol:Version Three',
|
||||
{ timeout: 3000 },
|
||||
)
|
||||
|
||||
unmount()
|
||||
expect(unsubscribeRestoreRequest).toHaveBeenCalled()
|
||||
expect(unsubscribeRestoreIntent).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,404 @@
|
||||
import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
|
||||
import { ControlMode } from '../../types'
|
||||
import { useWorkflowComment } from '../use-workflow-comment'
|
||||
|
||||
const mockScreenToFlowPosition = vi.hoisted(() => vi.fn(({ x, y }: { x: number, y: number }) => ({ x: x - 90, y: y - 180 })))
|
||||
const mockSetCenter = vi.hoisted(() => vi.fn())
|
||||
const mockGetNodes = vi.hoisted(() => vi.fn(() => []))
|
||||
|
||||
const mockCreateWorkflowComment = vi.hoisted(() => vi.fn())
|
||||
const mockCreateWorkflowCommentReply = vi.hoisted(() => vi.fn())
|
||||
const mockDeleteWorkflowComment = vi.hoisted(() => vi.fn())
|
||||
const mockDeleteWorkflowCommentReply = vi.hoisted(() => vi.fn())
|
||||
const mockFetchWorkflowComment = vi.hoisted(() => vi.fn())
|
||||
const mockFetchWorkflowComments = vi.hoisted(() => vi.fn())
|
||||
const mockResolveWorkflowComment = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateWorkflowComment = vi.hoisted(() => vi.fn())
|
||||
const mockUpdateWorkflowCommentReply = vi.hoisted(() => vi.fn())
|
||||
|
||||
const mockEmitCommentsUpdate = vi.hoisted(() => vi.fn())
|
||||
const mockUnsubscribeCommentsUpdate = vi.hoisted(() => vi.fn())
|
||||
const commentsUpdateState = vi.hoisted(() => ({
|
||||
handler: undefined as undefined | (() => void),
|
||||
}))
|
||||
|
||||
const globalFeatureState = vi.hoisted(() => ({
|
||||
enableCollaboration: true,
|
||||
}))
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useReactFlow: () => ({
|
||||
screenToFlowPosition: mockScreenToFlowPosition,
|
||||
setCenter: mockSetCenter,
|
||||
getNodes: mockGetNodes,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useParams: () => ({ appId: 'app-1' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
userProfile: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
avatar_url: 'alice.png',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/global-public-context', () => ({
|
||||
useGlobalPublicStore: (selector: (state: { systemFeatures: { enable_collaboration_mode: boolean } }) => unknown) => selector({
|
||||
systemFeatures: {
|
||||
enable_collaboration_mode: globalFeatureState.enableCollaboration,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow-comment', () => ({
|
||||
createWorkflowComment: (...args: unknown[]) => mockCreateWorkflowComment(...args),
|
||||
createWorkflowCommentReply: (...args: unknown[]) => mockCreateWorkflowCommentReply(...args),
|
||||
deleteWorkflowComment: (...args: unknown[]) => mockDeleteWorkflowComment(...args),
|
||||
deleteWorkflowCommentReply: (...args: unknown[]) => mockDeleteWorkflowCommentReply(...args),
|
||||
fetchWorkflowComment: (...args: unknown[]) => mockFetchWorkflowComment(...args),
|
||||
fetchWorkflowComments: (...args: unknown[]) => mockFetchWorkflowComments(...args),
|
||||
resolveWorkflowComment: (...args: unknown[]) => mockResolveWorkflowComment(...args),
|
||||
updateWorkflowComment: (...args: unknown[]) => mockUpdateWorkflowComment(...args),
|
||||
updateWorkflowCommentReply: (...args: unknown[]) => mockUpdateWorkflowCommentReply(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
|
||||
collaborationManager: {
|
||||
emitCommentsUpdate: (...args: unknown[]) => mockEmitCommentsUpdate(...args),
|
||||
onCommentsUpdate: (handler: () => void) => {
|
||||
commentsUpdateState.handler = handler
|
||||
return mockUnsubscribeCommentsUpdate
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const baseComment = (): WorkflowCommentList => ({
|
||||
id: 'comment-1',
|
||||
position_x: 10,
|
||||
position_y: 20,
|
||||
content: 'hello',
|
||||
created_by: 'user-1',
|
||||
created_by_account: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
avatar_url: 'alice.png',
|
||||
},
|
||||
created_at: 100,
|
||||
updated_at: 100,
|
||||
resolved: false,
|
||||
mention_count: 0,
|
||||
reply_count: 0,
|
||||
participants: [],
|
||||
})
|
||||
|
||||
const baseCommentDetail = (): WorkflowCommentDetail => ({
|
||||
id: 'comment-1',
|
||||
position_x: 10,
|
||||
position_y: 20,
|
||||
content: 'hello',
|
||||
created_by: 'user-1',
|
||||
created_by_account: {
|
||||
id: 'user-1',
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
avatar_url: 'alice.png',
|
||||
},
|
||||
created_at: 100,
|
||||
updated_at: 100,
|
||||
resolved: false,
|
||||
mentions: [],
|
||||
replies: [],
|
||||
})
|
||||
|
||||
describe('useWorkflowComment', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
commentsUpdateState.handler = undefined
|
||||
globalFeatureState.enableCollaboration = true
|
||||
|
||||
mockFetchWorkflowComments.mockResolvedValue([])
|
||||
mockFetchWorkflowComment.mockResolvedValue(baseCommentDetail())
|
||||
mockCreateWorkflowComment.mockResolvedValue({
|
||||
id: 'comment-2',
|
||||
created_at: '1700000000',
|
||||
})
|
||||
mockUpdateWorkflowComment.mockResolvedValue({})
|
||||
})
|
||||
|
||||
it('loads comment list on mount when collaboration is enabled', async () => {
|
||||
const comment = baseComment()
|
||||
mockFetchWorkflowComments.mockResolvedValue([comment])
|
||||
|
||||
const { store } = renderWorkflowHook(() => useWorkflowComment())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowComments).toHaveBeenCalledWith('app-1')
|
||||
})
|
||||
|
||||
expect(store.getState().comments).toEqual([comment])
|
||||
expect(store.getState().commentsLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('does not load comment list when collaboration is disabled', async () => {
|
||||
globalFeatureState.enableCollaboration = false
|
||||
|
||||
renderWorkflowHook(() => useWorkflowComment())
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mockFetchWorkflowComments).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a comment, updates local cache, and emits collaboration sync', async () => {
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
|
||||
initialStoreState: {
|
||||
comments: [],
|
||||
pendingComment: { pageX: 100, pageY: 200, elementX: 10, elementY: 20 },
|
||||
isCommentQuickAdd: true,
|
||||
mentionableUsersCache: {
|
||||
'app-1': [{
|
||||
id: 'user-2',
|
||||
name: 'Bob',
|
||||
email: 'bob@example.com',
|
||||
avatar_url: 'bob.png',
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCommentSubmit('new message', ['user-2'])
|
||||
})
|
||||
|
||||
expect(mockCreateWorkflowComment).toHaveBeenCalledWith('app-1', {
|
||||
position_x: 10,
|
||||
position_y: 20,
|
||||
content: 'new message',
|
||||
mentioned_user_ids: ['user-2'],
|
||||
})
|
||||
expect(mockEmitCommentsUpdate).toHaveBeenCalledWith('app-1')
|
||||
|
||||
const comments = store.getState().comments
|
||||
expect(comments).toHaveLength(1)
|
||||
expect(comments[0]).toMatchObject({
|
||||
id: 'comment-2',
|
||||
content: 'new message',
|
||||
position_x: 10,
|
||||
position_y: 20,
|
||||
mention_count: 1,
|
||||
reply_count: 0,
|
||||
})
|
||||
expect(comments[0]?.participants.map(p => p.id)).toEqual(['user-1', 'user-2'])
|
||||
expect(store.getState().commentDetailCache['comment-2']).toMatchObject({
|
||||
content: 'new message',
|
||||
position_x: 10,
|
||||
position_y: 20,
|
||||
})
|
||||
expect(store.getState().pendingComment).toBeNull()
|
||||
expect(store.getState().isCommentQuickAdd).toBe(false)
|
||||
})
|
||||
|
||||
it('rolls back optimistic position update when API update fails', async () => {
|
||||
const comment = baseComment()
|
||||
const commentDetail = baseCommentDetail()
|
||||
mockUpdateWorkflowComment.mockRejectedValue(new Error('update failed'))
|
||||
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
|
||||
initialStoreState: {
|
||||
comments: [comment],
|
||||
activeCommentId: comment.id,
|
||||
activeCommentDetail: commentDetail,
|
||||
commentDetailCache: {
|
||||
[comment.id]: commentDetail,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCommentPositionUpdate(comment.id, { x: 300, y: 400 })
|
||||
})
|
||||
|
||||
expect(mockUpdateWorkflowComment).toHaveBeenCalledWith('app-1', comment.id, {
|
||||
content: 'hello',
|
||||
position_x: 300,
|
||||
position_y: 400,
|
||||
})
|
||||
expect(mockEmitCommentsUpdate).not.toHaveBeenCalled()
|
||||
expect(store.getState().comments[0]).toMatchObject({
|
||||
position_x: 10,
|
||||
position_y: 20,
|
||||
})
|
||||
expect(store.getState().commentDetailCache[comment.id]).toMatchObject({
|
||||
position_x: 10,
|
||||
position_y: 20,
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes comments and active detail when collaboration update event arrives', async () => {
|
||||
const comment = baseComment()
|
||||
const detail = {
|
||||
...baseCommentDetail(),
|
||||
content: 'updated by another user',
|
||||
}
|
||||
mockFetchWorkflowComments.mockResolvedValue([comment])
|
||||
mockFetchWorkflowComment.mockResolvedValue({ data: detail })
|
||||
|
||||
const { unmount } = renderWorkflowHook(() => useWorkflowComment(), {
|
||||
initialStoreState: {
|
||||
activeCommentId: comment.id,
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(commentsUpdateState.handler).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
commentsUpdateState.handler?.()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchWorkflowComment).toHaveBeenCalledWith('app-1', comment.id)
|
||||
})
|
||||
expect(mockFetchWorkflowComments).toHaveBeenCalledTimes(2)
|
||||
|
||||
unmount()
|
||||
expect(mockUnsubscribeCommentsUpdate).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('focuses comment thread, loads detail, and updates navigation/create/close states', async () => {
|
||||
const commentA = baseComment()
|
||||
const commentB: WorkflowCommentList = {
|
||||
...baseComment(),
|
||||
id: 'comment-2',
|
||||
content: 'second',
|
||||
position_x: 50,
|
||||
position_y: 80,
|
||||
}
|
||||
mockGetNodes.mockReturnValue([{ id: 'node-1', data: { selected: true } }])
|
||||
mockFetchWorkflowComment.mockResolvedValue({
|
||||
...baseCommentDetail(),
|
||||
id: commentB.id,
|
||||
content: 'second detail',
|
||||
position_x: commentB.position_x,
|
||||
position_y: commentB.position_y,
|
||||
})
|
||||
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
|
||||
initialStoreState: {
|
||||
comments: [commentA, commentB],
|
||||
commentDetailCache: {
|
||||
[commentA.id]: baseCommentDetail(),
|
||||
},
|
||||
rightPanelWidth: 800,
|
||||
nodePanelWidth: 300,
|
||||
controlMode: ControlMode.Comment,
|
||||
activeCommentId: commentA.id,
|
||||
pendingComment: { pageX: 1, pageY: 2, elementX: 3, elementY: 4 },
|
||||
},
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCommentNavigate('next')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(store.getState().activeCommentId).toBe(commentB.id)
|
||||
})
|
||||
expect(mockSetCenter).toHaveBeenCalledWith(
|
||||
502,
|
||||
80,
|
||||
{ zoom: 1, duration: 600 },
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleCreateComment({
|
||||
pageX: 300,
|
||||
pageY: 400,
|
||||
elementX: 30,
|
||||
elementY: 40,
|
||||
})
|
||||
})
|
||||
expect(store.getState().pendingComment).toEqual({
|
||||
pageX: 300,
|
||||
pageY: 400,
|
||||
elementX: 30,
|
||||
elementY: 40,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleActiveCommentClose()
|
||||
})
|
||||
expect(store.getState().activeCommentId).toBeNull()
|
||||
expect(store.getState().activeCommentDetail).toBeNull()
|
||||
expect(store.getState().activeCommentDetailLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('runs resolve, delete, and reply lifecycle handlers with collaboration sync', async () => {
|
||||
const commentA = baseComment()
|
||||
const commentB: WorkflowCommentList = {
|
||||
...baseComment(),
|
||||
id: 'comment-2',
|
||||
content: 'fallback',
|
||||
position_x: 33,
|
||||
position_y: 55,
|
||||
}
|
||||
mockFetchWorkflowComments.mockResolvedValue([commentB])
|
||||
mockFetchWorkflowComment.mockResolvedValue({
|
||||
...baseCommentDetail(),
|
||||
id: commentB.id,
|
||||
content: 'fallback detail',
|
||||
position_x: commentB.position_x,
|
||||
position_y: commentB.position_y,
|
||||
})
|
||||
|
||||
const { result, store } = renderWorkflowHook(() => useWorkflowComment(), {
|
||||
initialStoreState: {
|
||||
comments: [commentA, commentB],
|
||||
activeCommentId: commentA.id,
|
||||
},
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCommentResolve(commentA.id)
|
||||
})
|
||||
|
||||
expect(mockResolveWorkflowComment).toHaveBeenCalledWith('app-1', commentA.id)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCommentReply(commentA.id, ' new reply ', ['user-2'])
|
||||
await result.current.handleCommentReplyUpdate(commentA.id, 'reply-1', ' edited reply ', ['user-2'])
|
||||
await result.current.handleCommentReplyDelete(commentA.id, 'reply-1')
|
||||
})
|
||||
|
||||
expect(mockCreateWorkflowCommentReply).toHaveBeenCalledWith('app-1', commentA.id, {
|
||||
content: 'new reply',
|
||||
mentioned_user_ids: ['user-2'],
|
||||
})
|
||||
expect(mockUpdateWorkflowCommentReply).toHaveBeenCalledWith('app-1', commentA.id, 'reply-1', {
|
||||
content: 'edited reply',
|
||||
mentioned_user_ids: ['user-2'],
|
||||
})
|
||||
expect(mockDeleteWorkflowCommentReply).toHaveBeenCalledWith('app-1', commentA.id, 'reply-1')
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCommentDelete(commentA.id)
|
||||
})
|
||||
|
||||
expect(mockDeleteWorkflowComment).toHaveBeenCalledWith('app-1', commentA.id)
|
||||
await waitFor(() => {
|
||||
expect(store.getState().activeCommentId).toBe(commentB.id)
|
||||
})
|
||||
expect(mockEmitCommentsUpdate).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,161 @@
|
||||
import type { WorkflowCommentList } from '@/service/workflow-comment'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import CommentsPanel from '../index'
|
||||
|
||||
const mockHandleCommentIconClick = vi.hoisted(() => vi.fn())
|
||||
const mockLoadComments = vi.hoisted(() => vi.fn())
|
||||
const mockSetActiveCommentId = vi.hoisted(() => vi.fn())
|
||||
const mockSetControlMode = vi.hoisted(() => vi.fn())
|
||||
const mockSetShowResolvedComments = vi.hoisted(() => vi.fn())
|
||||
const mockResolveWorkflowComment = vi.hoisted(() => vi.fn())
|
||||
const mockEmitCommentsUpdate = vi.hoisted(() => vi.fn())
|
||||
|
||||
const commentFixtures: WorkflowCommentList[] = [
|
||||
{
|
||||
id: 'c-1',
|
||||
created_by: 'user-1',
|
||||
created_by_account: { id: 'user-1', name: 'Alice', avatar_url: '' },
|
||||
content: 'my open thread',
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
resolved: false,
|
||||
reply_count: 2,
|
||||
participants: [],
|
||||
},
|
||||
{
|
||||
id: 'c-2',
|
||||
created_by: 'user-2',
|
||||
created_by_account: { id: 'user-2', name: 'Bob', avatar_url: '' },
|
||||
content: 'others resolved thread',
|
||||
created_at: 3,
|
||||
updated_at: 4,
|
||||
resolved: true,
|
||||
reply_count: 0,
|
||||
participants: [],
|
||||
},
|
||||
]
|
||||
|
||||
type WorkflowStoreSelectionState = {
|
||||
activeCommentId: string | null
|
||||
setActiveCommentId: (value: string | null) => void
|
||||
setControlMode: (value: unknown) => void
|
||||
showResolvedComments: boolean
|
||||
setShowResolvedComments: (value: boolean) => void
|
||||
}
|
||||
|
||||
const storeState = {
|
||||
activeCommentId: null as string | null,
|
||||
showResolvedComments: true,
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { ns?: string }) => options?.ns ? `${options.ns}.${key}` : key,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useParams: () => ({ appId: 'app-1' }),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-format-time-from-now', () => ({
|
||||
useFormatTimeFromNow: () => ({
|
||||
formatTimeFromNow: () => 'just now',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => ({
|
||||
userProfile: { id: 'user-1' },
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: WorkflowStoreSelectionState) => unknown) => selector({
|
||||
activeCommentId: storeState.activeCommentId,
|
||||
setActiveCommentId: (...args: unknown[]) => mockSetActiveCommentId(...args),
|
||||
setControlMode: (...args: unknown[]) => mockSetControlMode(...args),
|
||||
showResolvedComments: storeState.showResolvedComments,
|
||||
setShowResolvedComments: (...args: unknown[]) => mockSetShowResolvedComments(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-workflow-comment', () => ({
|
||||
useWorkflowComment: () => ({
|
||||
comments: commentFixtures,
|
||||
loading: false,
|
||||
loadComments: (...args: unknown[]) => mockLoadComments(...args),
|
||||
handleCommentIconClick: (...args: unknown[]) => mockHandleCommentIconClick(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow-comment', () => ({
|
||||
resolveWorkflowComment: (...args: unknown[]) => mockResolveWorkflowComment(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/collaboration/core/collaboration-manager', () => ({
|
||||
collaborationManager: {
|
||||
emitCommentsUpdate: (...args: unknown[]) => mockEmitCommentsUpdate(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/user-avatar-list', () => ({
|
||||
UserAvatarList: () => <div data-testid="user-avatar-list" />,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/switch', () => ({
|
||||
default: ({ value, onChange }: { value: boolean, onChange: (value: boolean) => void }) => (
|
||||
<button type="button" data-testid="show-resolved-switch" onClick={() => onChange(!value)}>
|
||||
toggle
|
||||
</button>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('CommentsPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
storeState.activeCommentId = null
|
||||
storeState.showResolvedComments = true
|
||||
mockResolveWorkflowComment.mockResolvedValue({})
|
||||
mockLoadComments.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('filters comments and selects a thread', () => {
|
||||
render(<CommentsPanel />)
|
||||
|
||||
expect(screen.getByText('my open thread')).toBeInTheDocument()
|
||||
expect(screen.getByText('others resolved thread')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Filter comments'))
|
||||
fireEvent.click(screen.getByText('Only your threads'))
|
||||
expect(screen.queryByText('others resolved thread')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('my open thread')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('my open thread'))
|
||||
expect(mockHandleCommentIconClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'c-1' }))
|
||||
})
|
||||
|
||||
it('resolves a comment and syncs list refresh', async () => {
|
||||
const { container } = render(<CommentsPanel />)
|
||||
const resolveIcons = container.querySelectorAll('.h-4.w-4.cursor-pointer.text-text-tertiary')
|
||||
expect(resolveIcons.length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.click(resolveIcons[0]!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockResolveWorkflowComment).toHaveBeenCalledWith('app-1', 'c-1')
|
||||
expect(mockEmitCommentsUpdate).toHaveBeenCalledWith('app-1')
|
||||
expect(mockLoadComments).toHaveBeenCalled()
|
||||
expect(mockSetActiveCommentId).toHaveBeenCalledWith('c-1')
|
||||
})
|
||||
})
|
||||
|
||||
it('toggles show-resolved state from filter panel switch', () => {
|
||||
render(<CommentsPanel />)
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Filter comments'))
|
||||
fireEvent.click(screen.getByTestId('show-resolved-switch'))
|
||||
|
||||
expect(mockSetShowResolvedComments).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user