mirror of
https://github.com/langgenius/dify.git
synced 2026-05-06 02:18:08 +08:00
test: add unit tests for base components-part-4 (#32452)
Co-authored-by: sahil-infocusp <73810410+sahil-infocusp@users.noreply.github.com>
This commit is contained in:
@ -0,0 +1,95 @@
|
||||
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import type { ReactElement } from 'react'
|
||||
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useSelectOrDelete } from '../../hooks'
|
||||
import ErrorMessageBlockComponent from './component'
|
||||
import { DELETE_ERROR_MESSAGE_COMMAND, ErrorMessageBlockNode } from './index'
|
||||
|
||||
vi.mock('../../hooks')
|
||||
|
||||
const mockHasNodes = vi.fn()
|
||||
|
||||
const mockEditor = {
|
||||
hasNodes: mockHasNodes,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
const lexicalContextValue: LexicalComposerContextWithEditor = [
|
||||
mockEditor,
|
||||
{ getTheme: () => undefined },
|
||||
]
|
||||
|
||||
const renderWithLexicalContext = (ui: ReactElement) => {
|
||||
return render(
|
||||
<LexicalComposerContext.Provider value={lexicalContextValue}>
|
||||
{ui}
|
||||
</LexicalComposerContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ErrorMessageBlockComponent', () => {
|
||||
const mockRef = { current: null as HTMLDivElement | null }
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHasNodes.mockReturnValue(true)
|
||||
vi.mocked(useSelectOrDelete).mockReturnValue([mockRef, false])
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render error_message text and base styles when unselected', () => {
|
||||
const { container } = renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-1" />)
|
||||
|
||||
expect(screen.getByText('error_message')).toBeInTheDocument()
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
expect(container.firstChild).toHaveClass('border-components-panel-border-subtle')
|
||||
})
|
||||
|
||||
it('should render selected styles when node is selected', () => {
|
||||
vi.mocked(useSelectOrDelete).mockReturnValue([mockRef, true])
|
||||
|
||||
const { container } = renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-1" />)
|
||||
|
||||
expect(container.firstChild).toHaveClass('border-state-accent-solid')
|
||||
expect(container.firstChild).toHaveClass('bg-state-accent-hover')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactions', () => {
|
||||
it('should stop propagation when wrapper is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onParentClick = vi.fn()
|
||||
|
||||
render(
|
||||
<LexicalComposerContext.Provider value={lexicalContextValue}>
|
||||
<div onClick={onParentClick}>
|
||||
<ErrorMessageBlockComponent nodeKey="node-1" />
|
||||
</div>
|
||||
</LexicalComposerContext.Provider>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByText('error_message'))
|
||||
|
||||
expect(onParentClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hooks', () => {
|
||||
it('should use selection hook and check node registration on mount', () => {
|
||||
renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-xyz" />)
|
||||
|
||||
expect(useSelectOrDelete).toHaveBeenCalledWith('node-xyz', DELETE_ERROR_MESSAGE_COMMAND)
|
||||
expect(mockHasNodes).toHaveBeenCalledWith([ErrorMessageBlockNode])
|
||||
})
|
||||
|
||||
it('should throw when ErrorMessageBlockNode is not registered', () => {
|
||||
mockHasNodes.mockReturnValue(false)
|
||||
|
||||
expect(() => renderWithLexicalContext(<ErrorMessageBlockComponent nodeKey="node-1" />)).toThrow(
|
||||
'WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor',
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,125 @@
|
||||
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
|
||||
import type { EntityMatch } from '@lexical/text'
|
||||
import type { LexicalEditor, LexicalNode } from 'lexical'
|
||||
import type { ReactElement } from 'react'
|
||||
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { render } from '@testing-library/react'
|
||||
import { $applyNodeReplacement } from 'lexical'
|
||||
import { ERROR_MESSAGE_PLACEHOLDER_TEXT } from '../../constants'
|
||||
import { decoratorTransform } from '../../utils'
|
||||
import { CustomTextNode } from '../custom-text/node'
|
||||
import ErrorMessageBlockReplacementBlock from './error-message-block-replacement-block'
|
||||
import { $createErrorMessageBlockNode, ErrorMessageBlockNode } from './node'
|
||||
|
||||
vi.mock('@lexical/utils')
|
||||
vi.mock('lexical')
|
||||
vi.mock('../../utils')
|
||||
vi.mock('./node')
|
||||
|
||||
const mockHasNodes = vi.fn()
|
||||
const mockRegisterNodeTransform = vi.fn()
|
||||
|
||||
const mockEditor = {
|
||||
hasNodes: mockHasNodes,
|
||||
registerNodeTransform: mockRegisterNodeTransform,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
const lexicalContextValue: LexicalComposerContextWithEditor = [
|
||||
mockEditor,
|
||||
{ getTheme: () => undefined },
|
||||
]
|
||||
|
||||
const renderWithLexicalContext = (ui: ReactElement) => {
|
||||
return render(
|
||||
<LexicalComposerContext.Provider value={lexicalContextValue}>
|
||||
{ui}
|
||||
</LexicalComposerContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ErrorMessageBlockReplacementBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHasNodes.mockReturnValue(true)
|
||||
mockRegisterNodeTransform.mockReturnValue(vi.fn())
|
||||
vi.mocked(mergeRegister).mockImplementation((...cleanups) => {
|
||||
return () => cleanups.forEach(cleanup => cleanup())
|
||||
})
|
||||
vi.mocked($createErrorMessageBlockNode).mockReturnValue({ type: 'node' } as unknown as ErrorMessageBlockNode)
|
||||
vi.mocked($applyNodeReplacement).mockImplementation((node: LexicalNode) => node)
|
||||
})
|
||||
|
||||
it('should register transform and cleanup on unmount', () => {
|
||||
const transformCleanup = vi.fn()
|
||||
mockRegisterNodeTransform.mockReturnValue(transformCleanup)
|
||||
|
||||
const { unmount, container } = renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(mockHasNodes).toHaveBeenCalledWith([ErrorMessageBlockNode])
|
||||
expect(mockRegisterNodeTransform).toHaveBeenCalledWith(CustomTextNode, expect.any(Function))
|
||||
|
||||
unmount()
|
||||
expect(transformCleanup).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw when ErrorMessageBlockNode is not registered', () => {
|
||||
mockHasNodes.mockReturnValue(false)
|
||||
|
||||
expect(() => renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />)).toThrow(
|
||||
'ErrorMessageBlockNodePlugin: ErrorMessageBlockNode not registered on editor',
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass matcher and creator to decoratorTransform and match placeholder text', () => {
|
||||
renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />)
|
||||
|
||||
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
|
||||
const textNode = { id: 't-1' } as unknown as LexicalNode
|
||||
transformCallback(textNode)
|
||||
|
||||
expect(decoratorTransform).toHaveBeenCalledWith(
|
||||
textNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
)
|
||||
|
||||
const getMatch = vi.mocked(decoratorTransform).mock.calls[0][1] as (text: string) => EntityMatch | null
|
||||
const match = getMatch(`hello ${ERROR_MESSAGE_PLACEHOLDER_TEXT} world`)
|
||||
|
||||
expect(match).toEqual({
|
||||
start: 6,
|
||||
end: 6 + ERROR_MESSAGE_PLACEHOLDER_TEXT.length,
|
||||
})
|
||||
expect(getMatch('hello world')).toBeNull()
|
||||
})
|
||||
|
||||
it('should create replacement node and call onInsert when creator runs', () => {
|
||||
const onInsert = vi.fn()
|
||||
renderWithLexicalContext(<ErrorMessageBlockReplacementBlock onInsert={onInsert} />)
|
||||
|
||||
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
|
||||
transformCallback({ id: 't-1' } as unknown as LexicalNode)
|
||||
|
||||
const createNode = vi.mocked(decoratorTransform).mock.calls[0][2] as () => ErrorMessageBlockNode
|
||||
const created = createNode()
|
||||
|
||||
expect(onInsert).toHaveBeenCalledTimes(1)
|
||||
expect($createErrorMessageBlockNode).toHaveBeenCalledTimes(1)
|
||||
expect($applyNodeReplacement).toHaveBeenCalledWith({ type: 'node' })
|
||||
expect(created).toEqual({ type: 'node' })
|
||||
})
|
||||
|
||||
it('should create replacement node without onInsert callback', () => {
|
||||
renderWithLexicalContext(<ErrorMessageBlockReplacementBlock />)
|
||||
|
||||
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
|
||||
transformCallback({ id: 't-1' } as unknown as LexicalNode)
|
||||
|
||||
const createNode = vi.mocked(decoratorTransform).mock.calls[0][2] as () => ErrorMessageBlockNode
|
||||
|
||||
expect(() => createNode()).not.toThrow()
|
||||
expect($createErrorMessageBlockNode).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,143 @@
|
||||
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import type { ReactElement } from 'react'
|
||||
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { render } from '@testing-library/react'
|
||||
import { $insertNodes, COMMAND_PRIORITY_EDITOR } from 'lexical'
|
||||
import {
|
||||
DELETE_ERROR_MESSAGE_COMMAND,
|
||||
ErrorMessageBlock,
|
||||
ErrorMessageBlockNode,
|
||||
INSERT_ERROR_MESSAGE_BLOCK_COMMAND,
|
||||
} from './index'
|
||||
import { $createErrorMessageBlockNode } from './node'
|
||||
|
||||
vi.mock('@lexical/utils')
|
||||
vi.mock('lexical', async () => {
|
||||
const actual = await vi.importActual('lexical')
|
||||
return {
|
||||
...actual,
|
||||
$insertNodes: vi.fn(),
|
||||
createCommand: vi.fn(name => name),
|
||||
COMMAND_PRIORITY_EDITOR: 1,
|
||||
}
|
||||
})
|
||||
vi.mock('./node')
|
||||
|
||||
const mockHasNodes = vi.fn()
|
||||
const mockRegisterCommand = vi.fn()
|
||||
|
||||
const mockEditor = {
|
||||
hasNodes: mockHasNodes,
|
||||
registerCommand: mockRegisterCommand,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
const lexicalContextValue: LexicalComposerContextWithEditor = [
|
||||
mockEditor,
|
||||
{ getTheme: () => undefined },
|
||||
]
|
||||
|
||||
const renderWithLexicalContext = (ui: ReactElement) => {
|
||||
return render(
|
||||
<LexicalComposerContext.Provider value={lexicalContextValue}>
|
||||
{ui}
|
||||
</LexicalComposerContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ErrorMessageBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHasNodes.mockReturnValue(true)
|
||||
mockRegisterCommand.mockReturnValue(vi.fn())
|
||||
vi.mocked(mergeRegister).mockImplementation((...cleanups) => {
|
||||
return () => cleanups.forEach(cleanup => cleanup())
|
||||
})
|
||||
vi.mocked($createErrorMessageBlockNode).mockReturnValue({ id: 'node' } as unknown as ErrorMessageBlockNode)
|
||||
})
|
||||
|
||||
it('should render null and register insert and delete commands', () => {
|
||||
const { container } = renderWithLexicalContext(<ErrorMessageBlock />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(mockHasNodes).toHaveBeenCalledWith([ErrorMessageBlockNode])
|
||||
expect(mockRegisterCommand).toHaveBeenCalledTimes(2)
|
||||
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
INSERT_ERROR_MESSAGE_BLOCK_COMMAND,
|
||||
expect.any(Function),
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
DELETE_ERROR_MESSAGE_COMMAND,
|
||||
expect.any(Function),
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
expect(ErrorMessageBlock.displayName).toBe('ErrorMessageBlock')
|
||||
})
|
||||
|
||||
it('should throw when ErrorMessageBlockNode is not registered', () => {
|
||||
mockHasNodes.mockReturnValue(false)
|
||||
|
||||
expect(() => renderWithLexicalContext(<ErrorMessageBlock />)).toThrow(
|
||||
'ERROR_MESSAGEBlockPlugin: ERROR_MESSAGEBlock not registered on editor',
|
||||
)
|
||||
})
|
||||
|
||||
it('should insert created node and call onInsert when insert command handler runs', () => {
|
||||
const onInsert = vi.fn()
|
||||
renderWithLexicalContext(<ErrorMessageBlock onInsert={onInsert} />)
|
||||
|
||||
const insertHandler = mockRegisterCommand.mock.calls[0][1] as () => boolean
|
||||
const result = insertHandler()
|
||||
|
||||
expect($createErrorMessageBlockNode).toHaveBeenCalledTimes(1)
|
||||
expect($insertNodes).toHaveBeenCalledWith([{ id: 'node' }])
|
||||
expect(onInsert).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true on insert command without onInsert callback', () => {
|
||||
renderWithLexicalContext(<ErrorMessageBlock />)
|
||||
|
||||
const insertHandler = mockRegisterCommand.mock.calls[0][1] as () => boolean
|
||||
|
||||
expect(insertHandler()).toBe(true)
|
||||
expect($insertNodes).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call onDelete and return true when delete command handler runs', () => {
|
||||
const onDelete = vi.fn()
|
||||
renderWithLexicalContext(<ErrorMessageBlock onDelete={onDelete} />)
|
||||
|
||||
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
|
||||
const result = deleteHandler()
|
||||
|
||||
expect(onDelete).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true on delete command without onDelete callback', () => {
|
||||
renderWithLexicalContext(<ErrorMessageBlock />)
|
||||
|
||||
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
|
||||
|
||||
expect(deleteHandler()).toBe(true)
|
||||
})
|
||||
|
||||
it('should run merged cleanup on unmount', () => {
|
||||
const insertCleanup = vi.fn()
|
||||
const deleteCleanup = vi.fn()
|
||||
mockRegisterCommand
|
||||
.mockReturnValueOnce(insertCleanup)
|
||||
.mockReturnValueOnce(deleteCleanup)
|
||||
|
||||
const { unmount } = renderWithLexicalContext(<ErrorMessageBlock />)
|
||||
unmount()
|
||||
|
||||
expect(insertCleanup).toHaveBeenCalledTimes(1)
|
||||
expect(deleteCleanup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,86 @@
|
||||
import type { Klass, LexicalEditor, LexicalNode } from 'lexical'
|
||||
import { createEditor } from 'lexical'
|
||||
import { $createErrorMessageBlockNode, $isErrorMessageBlockNode, ErrorMessageBlockNode } from './node'
|
||||
|
||||
describe('ErrorMessageBlockNode', () => {
|
||||
let editor: LexicalEditor
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
editor = createEditor({
|
||||
nodes: [ErrorMessageBlockNode as unknown as Klass<LexicalNode>],
|
||||
})
|
||||
})
|
||||
|
||||
const runInEditor = (callback: () => void) => {
|
||||
editor.update(callback, { discrete: true })
|
||||
}
|
||||
|
||||
it('should expose correct static type and clone behavior', () => {
|
||||
runInEditor(() => {
|
||||
const original = new ErrorMessageBlockNode('node-key')
|
||||
const cloned = ErrorMessageBlockNode.clone(original)
|
||||
|
||||
expect(ErrorMessageBlockNode.getType()).toBe('error-message-block')
|
||||
expect(cloned).toBeInstanceOf(ErrorMessageBlockNode)
|
||||
expect(cloned).not.toBe(original)
|
||||
expect(cloned.getKey()).toBe(original.getKey())
|
||||
})
|
||||
})
|
||||
|
||||
it('should be inline and provide expected text and json payload', () => {
|
||||
runInEditor(() => {
|
||||
const node = new ErrorMessageBlockNode()
|
||||
|
||||
expect(node.isInline()).toBe(true)
|
||||
expect(node.getTextContent()).toBe('{{#error_message#}}')
|
||||
expect(node.exportJSON()).toEqual({
|
||||
type: 'error-message-block',
|
||||
version: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should create dom with expected classes and never update dom', () => {
|
||||
runInEditor(() => {
|
||||
const node = new ErrorMessageBlockNode()
|
||||
const dom = node.createDOM()
|
||||
|
||||
expect(dom.tagName).toBe('DIV')
|
||||
expect(dom).toHaveClass('inline-flex')
|
||||
expect(dom).toHaveClass('items-center')
|
||||
expect(dom).toHaveClass('align-middle')
|
||||
expect(node.updateDOM()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should decorate using ErrorMessageBlockComponent with node key', () => {
|
||||
runInEditor(() => {
|
||||
const node = new ErrorMessageBlockNode('decorator-key')
|
||||
const decorated = node.decorate()
|
||||
|
||||
expect(decorated.props.nodeKey).toBe('decorator-key')
|
||||
})
|
||||
})
|
||||
|
||||
it('should create and import node instances via helper APIs', () => {
|
||||
runInEditor(() => {
|
||||
const created = $createErrorMessageBlockNode()
|
||||
const imported = ErrorMessageBlockNode.importJSON()
|
||||
|
||||
expect(created).toBeInstanceOf(ErrorMessageBlockNode)
|
||||
expect(imported).toBeInstanceOf(ErrorMessageBlockNode)
|
||||
})
|
||||
})
|
||||
|
||||
it('should return correct type guard values for lexical and non lexical inputs', () => {
|
||||
runInEditor(() => {
|
||||
const node = new ErrorMessageBlockNode()
|
||||
|
||||
expect($isErrorMessageBlockNode(node)).toBe(true)
|
||||
expect($isErrorMessageBlockNode(null)).toBe(false)
|
||||
expect($isErrorMessageBlockNode(undefined)).toBe(false)
|
||||
expect($isErrorMessageBlockNode({} as ErrorMessageBlockNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,87 @@
|
||||
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
|
||||
import type { EntityMatch } from '@lexical/text'
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import type { ReactElement } from 'react'
|
||||
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { render } from '@testing-library/react'
|
||||
import { useLexicalTextEntity } from '../../hooks'
|
||||
import VariableValueBlock from './index'
|
||||
import { $createVariableValueBlockNode, VariableValueBlockNode } from './node'
|
||||
|
||||
vi.mock('../../hooks')
|
||||
vi.mock('./node')
|
||||
|
||||
const mockHasNodes = vi.fn()
|
||||
|
||||
const mockEditor = {
|
||||
hasNodes: mockHasNodes,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
const lexicalContextValue: LexicalComposerContextWithEditor = [
|
||||
mockEditor,
|
||||
{ getTheme: () => undefined },
|
||||
]
|
||||
|
||||
const renderWithLexicalContext = (ui: ReactElement) => {
|
||||
return render(
|
||||
<LexicalComposerContext.Provider value={lexicalContextValue}>
|
||||
{ui}
|
||||
</LexicalComposerContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('VariableValueBlock', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHasNodes.mockReturnValue(true)
|
||||
vi.mocked($createVariableValueBlockNode).mockImplementation(
|
||||
text => ({ createdText: text } as unknown as VariableValueBlockNode),
|
||||
)
|
||||
})
|
||||
|
||||
it('should render null and register lexical text entity when node is registered', () => {
|
||||
const { container } = renderWithLexicalContext(<VariableValueBlock />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(mockHasNodes).toHaveBeenCalledWith([VariableValueBlockNode])
|
||||
expect(useLexicalTextEntity).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
VariableValueBlockNode,
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw when VariableValueBlockNode is not registered', () => {
|
||||
mockHasNodes.mockReturnValue(false)
|
||||
|
||||
expect(() => renderWithLexicalContext(<VariableValueBlock />)).toThrow(
|
||||
'VariableValueBlockPlugin: VariableValueNode not registered on editor',
|
||||
)
|
||||
})
|
||||
|
||||
it('should return match offsets when placeholder exists and null when not present', () => {
|
||||
renderWithLexicalContext(<VariableValueBlock />)
|
||||
|
||||
const getMatch = vi.mocked(useLexicalTextEntity).mock.calls[0][0] as (text: string) => EntityMatch | null
|
||||
|
||||
const match = getMatch('prefix {{foo_1}} suffix')
|
||||
expect(match).toEqual({ start: 7, end: 16 })
|
||||
|
||||
expect(getMatch('prefix without variable')).toBeNull()
|
||||
})
|
||||
|
||||
it('should create variable node from text node content in create callback', () => {
|
||||
renderWithLexicalContext(<VariableValueBlock />)
|
||||
|
||||
const createNode = vi.mocked(useLexicalTextEntity).mock.calls[0][2] as (
|
||||
textNode: { getTextContent: () => string },
|
||||
) => VariableValueBlockNode
|
||||
|
||||
const created = createNode({
|
||||
getTextContent: () => '{{account_id}}',
|
||||
})
|
||||
|
||||
expect($createVariableValueBlockNode).toHaveBeenCalledWith('{{account_id}}')
|
||||
expect(created).toEqual({ createdText: '{{account_id}}' })
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,92 @@
|
||||
import type { EditorConfig, Klass, LexicalEditor, LexicalNode, SerializedTextNode } from 'lexical'
|
||||
import { createEditor } from 'lexical'
|
||||
import {
|
||||
$createVariableValueBlockNode,
|
||||
$isVariableValueNodeBlock,
|
||||
VariableValueBlockNode,
|
||||
} from './node'
|
||||
|
||||
describe('VariableValueBlockNode', () => {
|
||||
let editor: LexicalEditor
|
||||
let config: EditorConfig
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
editor = createEditor({
|
||||
nodes: [VariableValueBlockNode as unknown as Klass<LexicalNode>],
|
||||
})
|
||||
config = editor._config
|
||||
})
|
||||
|
||||
const runInEditor = (callback: () => void) => {
|
||||
editor.update(callback, { discrete: true })
|
||||
}
|
||||
|
||||
it('should expose static type and clone with same text/key', () => {
|
||||
runInEditor(() => {
|
||||
const original = new VariableValueBlockNode('value-text', 'node-key')
|
||||
const cloned = VariableValueBlockNode.clone(original)
|
||||
|
||||
expect(VariableValueBlockNode.getType()).toBe('variable-value-block')
|
||||
expect(cloned).toBeInstanceOf(VariableValueBlockNode)
|
||||
expect(cloned).not.toBe(original)
|
||||
expect(cloned.getKey()).toBe('node-key')
|
||||
})
|
||||
})
|
||||
|
||||
it('should add block classes in createDOM and disallow text insertion before', () => {
|
||||
runInEditor(() => {
|
||||
const node = new VariableValueBlockNode('hello')
|
||||
const dom = node.createDOM(config)
|
||||
|
||||
expect(dom).toHaveClass('inline-flex')
|
||||
expect(dom).toHaveClass('items-center')
|
||||
expect(dom).toHaveClass('px-0.5')
|
||||
expect(dom).toHaveClass('h-[22px]')
|
||||
expect(dom).toHaveClass('text-text-accent')
|
||||
expect(dom).toHaveClass('rounded-[5px]')
|
||||
expect(dom).toHaveClass('align-middle')
|
||||
expect(node.canInsertTextBefore()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should import serialized node and preserve text metadata in export', () => {
|
||||
runInEditor(() => {
|
||||
const serialized = {
|
||||
detail: 2,
|
||||
format: 1,
|
||||
mode: 'token',
|
||||
style: 'color:red;',
|
||||
text: '{{profile_name}}',
|
||||
type: 'text',
|
||||
version: 1,
|
||||
} as SerializedTextNode
|
||||
|
||||
const imported = VariableValueBlockNode.importJSON(serialized)
|
||||
const exported = imported.exportJSON()
|
||||
|
||||
expect(exported).toEqual({
|
||||
detail: 2,
|
||||
format: 1,
|
||||
mode: 'token',
|
||||
style: 'color:red;',
|
||||
text: '{{profile_name}}',
|
||||
type: 'variable-value-block',
|
||||
version: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should create node with helper and support type guard checks', () => {
|
||||
runInEditor(() => {
|
||||
const node = $createVariableValueBlockNode('{{org_id}}')
|
||||
|
||||
expect(node).toBeInstanceOf(VariableValueBlockNode)
|
||||
expect(node.getTextContent()).toBe('{{org_id}}')
|
||||
expect($isVariableValueNodeBlock(node)).toBe(true)
|
||||
expect($isVariableValueNodeBlock(null)).toBe(false)
|
||||
expect($isVariableValueNodeBlock(undefined)).toBe(false)
|
||||
expect($isVariableValueNodeBlock({} as LexicalNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,507 @@
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useReactFlow, useStoreApi } from 'reactflow'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { useSelectOrDelete } from '../../hooks'
|
||||
import WorkflowVariableBlockComponent from './component'
|
||||
import { UPDATE_WORKFLOW_NODES_MAP } from './index'
|
||||
import { WorkflowVariableBlockNode } from './node'
|
||||
|
||||
const { mockVarLabel, mockIsExceptionVariable, mockForcedVariableKind } = vi.hoisted(() => ({
|
||||
mockVarLabel: vi.fn(),
|
||||
mockIsExceptionVariable: vi.fn<(variable: string, nodeType?: BlockEnum) => boolean>(() => false),
|
||||
mockForcedVariableKind: { value: '' as '' | 'env' | 'conversation' | 'rag' },
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/react/LexicalComposerContext')
|
||||
vi.mock('@lexical/utils')
|
||||
vi.mock('reactflow')
|
||||
vi.mock('../../hooks')
|
||||
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
|
||||
return {
|
||||
...actual,
|
||||
isExceptionVariable: mockIsExceptionVariable,
|
||||
}
|
||||
})
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/nodes/_base/components/variable/utils')>()
|
||||
return {
|
||||
...actual,
|
||||
isENV: (valueSelector: ValueSelector) => {
|
||||
if (mockForcedVariableKind.value === 'env')
|
||||
return true
|
||||
return actual.isENV(valueSelector)
|
||||
},
|
||||
isConversationVar: (valueSelector: ValueSelector) => {
|
||||
if (mockForcedVariableKind.value === 'conversation')
|
||||
return true
|
||||
return actual.isConversationVar(valueSelector)
|
||||
},
|
||||
isRagVariableVar: (valueSelector: ValueSelector) => {
|
||||
if (mockForcedVariableKind.value === 'rag')
|
||||
return true
|
||||
return actual.isRagVariableVar(valueSelector)
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/variable-label', () => ({
|
||||
VariableLabelInEditor: (props: {
|
||||
onClick: (e: React.MouseEvent) => void
|
||||
errorMsg?: string
|
||||
nodeTitle?: string
|
||||
nodeType?: BlockEnum
|
||||
notShowFullPath?: boolean
|
||||
}) => {
|
||||
mockVarLabel(props)
|
||||
return (
|
||||
<button type="button" onClick={props.onClick}>
|
||||
label
|
||||
</button>
|
||||
)
|
||||
},
|
||||
}))
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel', () => ({
|
||||
default: (props: {
|
||||
nodeName: string
|
||||
path: string[]
|
||||
varType: Type
|
||||
nodeType?: BlockEnum
|
||||
}) => <div data-testid="var-full-path-panel">{props.nodeName}</div>,
|
||||
}))
|
||||
|
||||
const mockRegisterCommand = vi.fn()
|
||||
const mockHasNodes = vi.fn()
|
||||
const mockSetViewport = vi.fn()
|
||||
const mockGetState = vi.fn()
|
||||
|
||||
const mockEditor = {
|
||||
registerCommand: mockRegisterCommand,
|
||||
hasNodes: mockHasNodes,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
describe('WorkflowVariableBlockComponent', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockForcedVariableKind.value = ''
|
||||
mockHasNodes.mockReturnValue(true)
|
||||
mockRegisterCommand.mockReturnValue(vi.fn())
|
||||
mockGetState.mockReturnValue({ transform: [0, 0, 2] })
|
||||
|
||||
vi.mocked(useLexicalComposerContext).mockReturnValue([
|
||||
mockEditor,
|
||||
{},
|
||||
] as unknown as ReturnType<typeof useLexicalComposerContext>)
|
||||
vi.mocked(mergeRegister).mockImplementation((...cleanups) => () => cleanups.forEach(cleanup => cleanup()))
|
||||
vi.mocked(useSelectOrDelete).mockReturnValue([{ current: null }, false])
|
||||
vi.mocked(useReactFlow).mockReturnValue({
|
||||
setViewport: mockSetViewport,
|
||||
} as unknown as ReturnType<typeof useReactFlow>)
|
||||
vi.mocked(useStoreApi).mockReturnValue({
|
||||
getState: mockGetState,
|
||||
} as unknown as ReturnType<typeof useStoreApi>)
|
||||
})
|
||||
|
||||
it('should throw when WorkflowVariableBlockNode is not registered', () => {
|
||||
mockHasNodes.mockReturnValue(false)
|
||||
|
||||
expect(() => render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['node-1', 'output']}
|
||||
workflowNodesMap={{}}
|
||||
/>,
|
||||
)).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor')
|
||||
})
|
||||
|
||||
it('should render variable label and register update command', () => {
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['node-1', 'output']}
|
||||
workflowNodesMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'label' })).toBeInTheDocument()
|
||||
expect(mockHasNodes).toHaveBeenCalledWith([WorkflowVariableBlockNode])
|
||||
expect(mockRegisterCommand).toHaveBeenCalledWith(
|
||||
UPDATE_WORKFLOW_NODES_MAP,
|
||||
expect.any(Function),
|
||||
expect.any(Number),
|
||||
)
|
||||
})
|
||||
|
||||
it('should call setViewport when label is clicked and node exists', async () => {
|
||||
const user = userEvent.setup()
|
||||
const workflowContainer = document.createElement('div')
|
||||
workflowContainer.id = 'workflow-container'
|
||||
Object.defineProperty(workflowContainer, 'clientWidth', { value: 1000, configurable: true })
|
||||
Object.defineProperty(workflowContainer, 'clientHeight', { value: 800, configurable: true })
|
||||
document.body.appendChild(workflowContainer)
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['node-1', 'group', 'field']}
|
||||
workflowNodesMap={{
|
||||
'node-1': {
|
||||
title: 'Node A',
|
||||
type: BlockEnum.LLM,
|
||||
width: 200,
|
||||
height: 100,
|
||||
position: { x: 50, y: 80 },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'label' }))
|
||||
|
||||
expect(mockSetViewport).toHaveBeenCalledWith({
|
||||
x: (1000 - 400 - 200 * 2) / 2 - 50 * 2,
|
||||
y: (800 - 100 * 2) / 2 - 80 * 2,
|
||||
zoom: 2,
|
||||
})
|
||||
})
|
||||
|
||||
it('should render safely when node exists and getVarType is not provided', () => {
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['node-1', 'group', 'field']}
|
||||
workflowNodesMap={{
|
||||
'node-1': {
|
||||
title: 'Node A',
|
||||
type: BlockEnum.LLM,
|
||||
width: 200,
|
||||
height: 100,
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'label' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass computed varType when getVarType is provided', () => {
|
||||
const getVarType = vi.fn(() => Type.number)
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['node-1', 'group', 'field']}
|
||||
workflowNodesMap={{
|
||||
'node-1': {
|
||||
title: 'Node A',
|
||||
type: BlockEnum.LLM,
|
||||
width: 200,
|
||||
height: 100,
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
}}
|
||||
getVarType={getVarType}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(getVarType).toHaveBeenCalledWith({
|
||||
nodeId: 'node-1',
|
||||
valueSelector: ['node-1', 'group', 'field'] as ValueSelector,
|
||||
})
|
||||
})
|
||||
|
||||
it('should mark env variable invalid when not found in environmentVariables', () => {
|
||||
const environmentVariables: Var[] = [{ variable: 'env.valid_key', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['env', 'missing_key']}
|
||||
workflowNodesMap={{}}
|
||||
environmentVariables={environmentVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: expect.any(String),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should keep env variable valid when environmentVariables is omitted', () => {
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['env', 'missing_key']}
|
||||
workflowNodesMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should treat env variable as valid when it exists in environmentVariables', () => {
|
||||
const environmentVariables: Var[] = [{ variable: 'env.valid_key', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['env', 'valid_key']}
|
||||
workflowNodesMap={{}}
|
||||
environmentVariables={environmentVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle env selector with missing segment when environmentVariables are provided', () => {
|
||||
const environmentVariables: Var[] = [{ variable: 'env.', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['env']}
|
||||
workflowNodesMap={{}}
|
||||
environmentVariables={environmentVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should evaluate env fallback selector tokens when classifier is forced', () => {
|
||||
mockForcedVariableKind.value = 'env'
|
||||
const environmentVariables: Var[] = [{ variable: '.', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={[]}
|
||||
workflowNodesMap={{}}
|
||||
environmentVariables={environmentVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should treat conversation variable as valid when found in conversationVariables', () => {
|
||||
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['conversation', 'topic']}
|
||||
workflowNodesMap={{}}
|
||||
conversationVariables={conversationVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should keep conversation variable valid when conversationVariables is omitted', () => {
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['conversation', 'topic']}
|
||||
workflowNodesMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should mark conversation variable invalid when not found in conversationVariables', () => {
|
||||
const conversationVariables: Var[] = [{ variable: 'conversation.other', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['conversation', 'topic']}
|
||||
workflowNodesMap={{}}
|
||||
conversationVariables={conversationVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: expect.any(String),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle conversation selector with missing segment when conversationVariables are provided', () => {
|
||||
const conversationVariables: Var[] = [{ variable: 'conversation.', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['conversation']}
|
||||
workflowNodesMap={{}}
|
||||
conversationVariables={conversationVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should evaluate conversation fallback selector tokens when classifier is forced', () => {
|
||||
mockForcedVariableKind.value = 'conversation'
|
||||
const conversationVariables: Var[] = [{ variable: '.', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={[]}
|
||||
workflowNodesMap={{}}
|
||||
conversationVariables={conversationVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should treat global variable as valid without node', () => {
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['sys', 'user_id']}
|
||||
workflowNodesMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should use rag variable validation path', () => {
|
||||
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['rag', 'shared', 'answer']}
|
||||
workflowNodesMap={{ rag: { title: 'Rag', type: BlockEnum.Tool } as never }}
|
||||
ragVariables={ragVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should keep rag variable valid when ragVariables is omitted', () => {
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['rag', 'shared', 'answer']}
|
||||
workflowNodesMap={{ rag: { title: 'Rag', type: BlockEnum.Tool } as never }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should mark rag variable invalid when not found in ragVariables', () => {
|
||||
const ragVariables: Var[] = [{ variable: 'rag.shared.other', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['rag', 'shared', 'answer']}
|
||||
workflowNodesMap={{ rag: { title: 'Rag', type: BlockEnum.Tool } as never }}
|
||||
ragVariables={ragVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: expect.any(String),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should handle rag selector with missing segment when ragVariables are provided', () => {
|
||||
const ragVariables: Var[] = [{ variable: 'rag.shared.', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['rag', 'shared']}
|
||||
workflowNodesMap={{ shared: { title: 'Rag', type: BlockEnum.Tool } as never }}
|
||||
ragVariables={ragVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should evaluate rag fallback selector tokens when classifier is forced', () => {
|
||||
mockForcedVariableKind.value = 'rag'
|
||||
const ragVariables: Var[] = [{ variable: '..', type: VarType.string }]
|
||||
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={[]}
|
||||
workflowNodesMap={{}}
|
||||
ragVariables={ragVariables}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockVarLabel).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorMsg: undefined,
|
||||
}))
|
||||
})
|
||||
|
||||
it('should apply workflow node map updates through command handler', () => {
|
||||
render(
|
||||
<WorkflowVariableBlockComponent
|
||||
nodeKey="k"
|
||||
variables={['node-1', 'field']}
|
||||
workflowNodesMap={{}}
|
||||
/>,
|
||||
)
|
||||
|
||||
const updateHandler = mockRegisterCommand.mock.calls[0][1] as (map: Record<string, unknown>) => boolean
|
||||
let result = false
|
||||
act(() => {
|
||||
result = updateHandler({
|
||||
'node-1': {
|
||||
title: 'Updated',
|
||||
type: BlockEnum.LLM,
|
||||
width: 100,
|
||||
height: 50,
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,204 @@
|
||||
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { WorkflowNodesMap } from './node'
|
||||
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { render } from '@testing-library/react'
|
||||
import { $insertNodes, COMMAND_PRIORITY_EDITOR } from 'lexical'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import {
|
||||
CLEAR_HIDE_MENU_TIMEOUT,
|
||||
DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND,
|
||||
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
|
||||
UPDATE_WORKFLOW_NODES_MAP,
|
||||
WorkflowVariableBlock,
|
||||
WorkflowVariableBlockNode,
|
||||
} from './index'
|
||||
import { $createWorkflowVariableBlockNode } from './node'
|
||||
|
||||
vi.mock('@lexical/utils')
|
||||
vi.mock('lexical', async () => {
|
||||
const actual = await vi.importActual('lexical')
|
||||
return {
|
||||
...actual,
|
||||
$insertNodes: vi.fn(),
|
||||
createCommand: vi.fn(name => name),
|
||||
COMMAND_PRIORITY_EDITOR: 1,
|
||||
}
|
||||
})
|
||||
vi.mock('./node')
|
||||
|
||||
const mockHasNodes = vi.fn()
|
||||
const mockRegisterCommand = vi.fn()
|
||||
const mockDispatchCommand = vi.fn()
|
||||
const mockUpdate = vi.fn((callback: () => void) => callback())
|
||||
|
||||
const mockEditor = {
|
||||
hasNodes: mockHasNodes,
|
||||
registerCommand: mockRegisterCommand,
|
||||
dispatchCommand: mockDispatchCommand,
|
||||
update: mockUpdate,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
const lexicalContextValue: LexicalComposerContextWithEditor = [
|
||||
mockEditor,
|
||||
{ getTheme: () => undefined },
|
||||
]
|
||||
|
||||
const renderWithLexicalContext = (ui: ReactElement) => {
|
||||
return render(
|
||||
<LexicalComposerContext.Provider value={lexicalContextValue}>
|
||||
{ui}
|
||||
</LexicalComposerContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkflowVariableBlock', () => {
|
||||
const workflowNodesMap: WorkflowNodesMap = {
|
||||
'node-1': {
|
||||
title: 'Node A',
|
||||
type: BlockEnum.LLM,
|
||||
width: 200,
|
||||
height: 100,
|
||||
position: { x: 10, y: 20 },
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHasNodes.mockReturnValue(true)
|
||||
mockRegisterCommand.mockReturnValue(vi.fn())
|
||||
vi.mocked(mergeRegister).mockImplementation((...cleanups) => () => cleanups.forEach(cleanup => cleanup()))
|
||||
vi.mocked($createWorkflowVariableBlockNode).mockReturnValue({ id: 'workflow-node' } as unknown as WorkflowVariableBlockNode)
|
||||
})
|
||||
|
||||
it('should render null and register insert/delete commands', () => {
|
||||
const { container } = renderWithLexicalContext(
|
||||
<WorkflowVariableBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(mockHasNodes).toHaveBeenCalledWith([WorkflowVariableBlockNode])
|
||||
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND,
|
||||
expect.any(Function),
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
expect(mockRegisterCommand).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
DELETE_WORKFLOW_VARIABLE_BLOCK_COMMAND,
|
||||
expect.any(Function),
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
)
|
||||
expect(WorkflowVariableBlock.displayName).toBe('WorkflowVariableBlock')
|
||||
})
|
||||
|
||||
it('should dispatch workflow node map update on mount', () => {
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(mockUpdate).toHaveBeenCalled()
|
||||
expect(mockDispatchCommand).toHaveBeenCalledWith(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap)
|
||||
})
|
||||
|
||||
it('should throw when WorkflowVariableBlockNode is not registered', () => {
|
||||
mockHasNodes.mockReturnValue(false)
|
||||
|
||||
expect(() => renderWithLexicalContext(
|
||||
<WorkflowVariableBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)).toThrow('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor')
|
||||
})
|
||||
|
||||
it('should insert workflow variable block node and call onInsert', () => {
|
||||
const onInsert = vi.fn()
|
||||
const getVarType = vi.fn(() => Type.string)
|
||||
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
onInsert={onInsert}
|
||||
getVarType={getVarType}
|
||||
/>,
|
||||
)
|
||||
|
||||
const insertHandler = mockRegisterCommand.mock.calls[0][1] as (variables: string[]) => boolean
|
||||
const result = insertHandler(['node-1', 'answer'])
|
||||
|
||||
expect(mockDispatchCommand).toHaveBeenCalledWith(CLEAR_HIDE_MENU_TIMEOUT, undefined)
|
||||
expect($createWorkflowVariableBlockNode).toHaveBeenCalledWith(
|
||||
['node-1', 'answer'],
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
)
|
||||
expect($insertNodes).toHaveBeenCalledWith([{ id: 'workflow-node' }])
|
||||
expect(onInsert).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true on insert when onInsert is omitted', () => {
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const insertHandler = mockRegisterCommand.mock.calls[0][1] as (variables: string[]) => boolean
|
||||
expect(insertHandler(['node-1', 'answer'])).toBe(true)
|
||||
})
|
||||
|
||||
it('should call onDelete and return true when delete handler runs', () => {
|
||||
const onDelete = vi.fn()
|
||||
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
onDelete={onDelete}
|
||||
/>,
|
||||
)
|
||||
|
||||
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
|
||||
const result = deleteHandler()
|
||||
|
||||
expect(onDelete).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true on delete when onDelete is omitted', () => {
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const deleteHandler = mockRegisterCommand.mock.calls[1][1] as () => boolean
|
||||
expect(deleteHandler()).toBe(true)
|
||||
})
|
||||
|
||||
it('should run merged cleanup on unmount', () => {
|
||||
const insertCleanup = vi.fn()
|
||||
const deleteCleanup = vi.fn()
|
||||
mockRegisterCommand
|
||||
.mockReturnValueOnce(insertCleanup)
|
||||
.mockReturnValueOnce(deleteCleanup)
|
||||
|
||||
const { unmount } = renderWithLexicalContext(
|
||||
<WorkflowVariableBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
unmount()
|
||||
|
||||
expect(insertCleanup).toHaveBeenCalledTimes(1)
|
||||
expect(deleteCleanup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,166 @@
|
||||
import type { Klass, LexicalEditor, LexicalNode } from 'lexical'
|
||||
import type { Var } from '@/app/components/workflow/types'
|
||||
import { createEditor } from 'lexical'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import {
|
||||
$createWorkflowVariableBlockNode,
|
||||
$isWorkflowVariableBlockNode,
|
||||
WorkflowVariableBlockNode,
|
||||
} from './node'
|
||||
|
||||
describe('WorkflowVariableBlockNode', () => {
|
||||
let editor: LexicalEditor
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
editor = createEditor({
|
||||
nodes: [WorkflowVariableBlockNode as unknown as Klass<LexicalNode>],
|
||||
})
|
||||
})
|
||||
|
||||
const runInEditor = (callback: () => void) => {
|
||||
editor.update(callback, { discrete: true })
|
||||
}
|
||||
|
||||
it('should expose type and clone with same payload', () => {
|
||||
runInEditor(() => {
|
||||
const getVarType = vi.fn(() => Type.string)
|
||||
const original = new WorkflowVariableBlockNode(
|
||||
['node-1', 'answer'],
|
||||
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
|
||||
getVarType,
|
||||
'node-key',
|
||||
)
|
||||
const cloned = WorkflowVariableBlockNode.clone(original)
|
||||
|
||||
expect(WorkflowVariableBlockNode.getType()).toBe('workflow-variable-block')
|
||||
expect(cloned).toBeInstanceOf(WorkflowVariableBlockNode)
|
||||
expect(cloned.getKey()).toBe(original.getKey())
|
||||
})
|
||||
})
|
||||
|
||||
it('should be inline and create expected dom classes', () => {
|
||||
runInEditor(() => {
|
||||
const node = new WorkflowVariableBlockNode(['node-1', 'answer'], {}, undefined)
|
||||
const dom = node.createDOM()
|
||||
|
||||
expect(node.isInline()).toBe(true)
|
||||
expect(dom.tagName).toBe('DIV')
|
||||
expect(dom).toHaveClass('inline-flex')
|
||||
expect(dom).toHaveClass('items-center')
|
||||
expect(dom).toHaveClass('align-middle')
|
||||
expect(node.updateDOM()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('should decorate with component props from node state', () => {
|
||||
runInEditor(() => {
|
||||
const getVarType = vi.fn(() => Type.number)
|
||||
const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }]
|
||||
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
|
||||
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
|
||||
|
||||
const node = new WorkflowVariableBlockNode(
|
||||
['node-1', 'answer'],
|
||||
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
|
||||
getVarType,
|
||||
'decorator-key',
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
)
|
||||
|
||||
const decorated = node.decorate()
|
||||
expect(decorated.props.nodeKey).toBe('decorator-key')
|
||||
expect(decorated.props.variables).toEqual(['node-1', 'answer'])
|
||||
expect(decorated.props.workflowNodesMap).toEqual({ 'node-1': { title: 'A', type: BlockEnum.LLM } })
|
||||
expect(decorated.props.environmentVariables).toEqual(environmentVariables)
|
||||
expect(decorated.props.conversationVariables).toEqual(conversationVariables)
|
||||
expect(decorated.props.ragVariables).toEqual(ragVariables)
|
||||
})
|
||||
})
|
||||
|
||||
it('should export and import json with full payload', () => {
|
||||
runInEditor(() => {
|
||||
const getVarType = vi.fn(() => Type.string)
|
||||
const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }]
|
||||
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
|
||||
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
|
||||
|
||||
const node = new WorkflowVariableBlockNode(
|
||||
['node-1', 'answer'],
|
||||
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
|
||||
getVarType,
|
||||
undefined,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
)
|
||||
|
||||
expect(node.exportJSON()).toEqual({
|
||||
type: 'workflow-variable-block',
|
||||
version: 1,
|
||||
variables: ['node-1', 'answer'],
|
||||
workflowNodesMap: { 'node-1': { title: 'A', type: BlockEnum.LLM } },
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
})
|
||||
|
||||
const imported = WorkflowVariableBlockNode.importJSON({
|
||||
type: 'workflow-variable-block',
|
||||
version: 1,
|
||||
variables: ['node-2', 'result'],
|
||||
workflowNodesMap: { 'node-2': { title: 'B', type: BlockEnum.Tool } },
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
})
|
||||
|
||||
expect(imported).toBeInstanceOf(WorkflowVariableBlockNode)
|
||||
expect(imported.getVariables()).toEqual(['node-2', 'result'])
|
||||
expect(imported.getWorkflowNodesMap()).toEqual({ 'node-2': { title: 'B', type: BlockEnum.Tool } })
|
||||
})
|
||||
})
|
||||
|
||||
it('should return getters and text content in expected format', () => {
|
||||
runInEditor(() => {
|
||||
const getVarType = vi.fn(() => Type.string)
|
||||
const environmentVariables: Var[] = [{ variable: 'env.key', type: VarType.string }]
|
||||
const conversationVariables: Var[] = [{ variable: 'conversation.topic', type: VarType.string }]
|
||||
const ragVariables: Var[] = [{ variable: 'rag.shared.answer', type: VarType.string }]
|
||||
const node = new WorkflowVariableBlockNode(
|
||||
['node-1', 'answer'],
|
||||
{ 'node-1': { title: 'A', type: BlockEnum.LLM } },
|
||||
getVarType,
|
||||
undefined,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
)
|
||||
|
||||
expect(node.getVariables()).toEqual(['node-1', 'answer'])
|
||||
expect(node.getWorkflowNodesMap()).toEqual({ 'node-1': { title: 'A', type: BlockEnum.LLM } })
|
||||
expect(node.getVarType()).toBe(getVarType)
|
||||
expect(node.getEnvironmentVariables()).toEqual(environmentVariables)
|
||||
expect(node.getConversationVariables()).toEqual(conversationVariables)
|
||||
expect(node.getRagVariables()).toEqual(ragVariables)
|
||||
expect(node.getTextContent()).toBe('{{#node-1.answer#}}')
|
||||
})
|
||||
})
|
||||
|
||||
it('should create node helper and type guard checks', () => {
|
||||
runInEditor(() => {
|
||||
const node = $createWorkflowVariableBlockNode(['node-1', 'answer'], {}, undefined)
|
||||
|
||||
expect(node).toBeInstanceOf(WorkflowVariableBlockNode)
|
||||
expect($isWorkflowVariableBlockNode(node)).toBe(true)
|
||||
expect($isWorkflowVariableBlockNode(null)).toBe(false)
|
||||
expect($isWorkflowVariableBlockNode(undefined)).toBe(false)
|
||||
expect($isWorkflowVariableBlockNode({} as LexicalNode)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,221 @@
|
||||
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
|
||||
import type { EntityMatch } from '@lexical/text'
|
||||
import type { LexicalEditor, LexicalNode } from 'lexical'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { WorkflowNodesMap } from './node'
|
||||
import type { NodeOutPutVar } from '@/app/components/workflow/types'
|
||||
import { LexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { render } from '@testing-library/react'
|
||||
import { $applyNodeReplacement } from 'lexical'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { BlockEnum, VarType } from '@/app/components/workflow/types'
|
||||
import { decoratorTransform } from '../../utils'
|
||||
import { CustomTextNode } from '../custom-text/node'
|
||||
import { WorkflowVariableBlockNode } from './index'
|
||||
import { $createWorkflowVariableBlockNode } from './node'
|
||||
import WorkflowVariableBlockReplacementBlock from './workflow-variable-block-replacement-block'
|
||||
|
||||
vi.mock('@lexical/utils')
|
||||
vi.mock('lexical')
|
||||
vi.mock('../../utils')
|
||||
vi.mock('./node')
|
||||
|
||||
const mockHasNodes = vi.fn()
|
||||
const mockRegisterNodeTransform = vi.fn()
|
||||
|
||||
const mockEditor = {
|
||||
hasNodes: mockHasNodes,
|
||||
registerNodeTransform: mockRegisterNodeTransform,
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
const lexicalContextValue: LexicalComposerContextWithEditor = [
|
||||
mockEditor,
|
||||
{ getTheme: () => undefined },
|
||||
]
|
||||
|
||||
const renderWithLexicalContext = (ui: ReactElement) => {
|
||||
return render(
|
||||
<LexicalComposerContext.Provider value={lexicalContextValue}>
|
||||
{ui}
|
||||
</LexicalComposerContext.Provider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('WorkflowVariableBlockReplacementBlock', () => {
|
||||
const variables: NodeOutPutVar[] = [
|
||||
{
|
||||
nodeId: 'env',
|
||||
title: 'ENV',
|
||||
vars: [{ variable: 'env.key', type: VarType.string }],
|
||||
},
|
||||
{
|
||||
nodeId: 'conversation',
|
||||
title: 'Conversation',
|
||||
vars: [{ variable: 'conversation.topic', type: VarType.string }],
|
||||
},
|
||||
{
|
||||
nodeId: 'node-1',
|
||||
title: 'Node A',
|
||||
vars: [
|
||||
{ variable: 'output', type: VarType.string },
|
||||
{ variable: 'ragVarA', type: VarType.string, isRagVariable: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
nodeId: 'rag',
|
||||
title: 'RAG',
|
||||
vars: [{ variable: 'rag.shared.answer', type: VarType.string, isRagVariable: true }],
|
||||
},
|
||||
]
|
||||
|
||||
const workflowNodesMap: WorkflowNodesMap = {
|
||||
'node-1': {
|
||||
title: 'Node A',
|
||||
type: BlockEnum.LLM,
|
||||
width: 200,
|
||||
height: 100,
|
||||
position: { x: 20, y: 40 },
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockHasNodes.mockReturnValue(true)
|
||||
mockRegisterNodeTransform.mockReturnValue(vi.fn())
|
||||
vi.mocked(mergeRegister).mockImplementation((...cleanups) => () => cleanups.forEach(cleanup => cleanup()))
|
||||
vi.mocked($createWorkflowVariableBlockNode).mockReturnValue({ type: 'workflow-node' } as unknown as WorkflowVariableBlockNode)
|
||||
vi.mocked($applyNodeReplacement).mockImplementation((node: LexicalNode) => node)
|
||||
})
|
||||
|
||||
it('should register transform and cleanup on unmount', () => {
|
||||
const transformCleanup = vi.fn()
|
||||
mockRegisterNodeTransform.mockReturnValue(transformCleanup)
|
||||
|
||||
const { unmount, container } = renderWithLexicalContext(
|
||||
<WorkflowVariableBlockReplacementBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
expect(mockHasNodes).toHaveBeenCalledWith([WorkflowVariableBlockNode])
|
||||
expect(mockRegisterNodeTransform).toHaveBeenCalledWith(CustomTextNode, expect.any(Function))
|
||||
|
||||
unmount()
|
||||
expect(transformCleanup).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should throw when WorkflowVariableBlockNode is not registered', () => {
|
||||
mockHasNodes.mockReturnValue(false)
|
||||
|
||||
expect(() => renderWithLexicalContext(
|
||||
<WorkflowVariableBlockReplacementBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)).toThrow('WorkflowVariableBlockNodePlugin: WorkflowVariableBlockNode not registered on editor')
|
||||
})
|
||||
|
||||
it('should pass matcher and creator to decoratorTransform', () => {
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlockReplacementBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
|
||||
const textNode = { id: 'text-node' } as unknown as LexicalNode
|
||||
transformCallback(textNode)
|
||||
|
||||
expect(decoratorTransform).toHaveBeenCalledWith(
|
||||
textNode,
|
||||
expect.any(Function),
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
|
||||
it('should match variable placeholders and return null for non-placeholder text', () => {
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlockReplacementBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
|
||||
transformCallback({ id: 'text-node' } as unknown as LexicalNode)
|
||||
|
||||
const getMatch = vi.mocked(decoratorTransform).mock.calls[0][1] as (text: string) => EntityMatch | null
|
||||
const match = getMatch('prefix {{#node-1.output#}} suffix')
|
||||
|
||||
expect(match).toEqual({
|
||||
start: 7,
|
||||
end: 26,
|
||||
})
|
||||
expect(getMatch('plain text only')).toBeNull()
|
||||
})
|
||||
|
||||
it('should create replacement node with mapped env/conversation/rag vars and call onInsert', () => {
|
||||
const onInsert = vi.fn()
|
||||
const getVarType = vi.fn(() => Type.string)
|
||||
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlockReplacementBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
onInsert={onInsert}
|
||||
getVarType={getVarType}
|
||||
variables={variables}
|
||||
/>,
|
||||
)
|
||||
|
||||
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
|
||||
transformCallback({ id: 'text-node' } as unknown as LexicalNode)
|
||||
|
||||
const createNode = vi.mocked(decoratorTransform).mock.calls[0][2] as (
|
||||
textNode: { getTextContent: () => string },
|
||||
) => WorkflowVariableBlockNode
|
||||
|
||||
const created = createNode({
|
||||
getTextContent: () => '{{#node-1.output#}}',
|
||||
})
|
||||
|
||||
expect(onInsert).toHaveBeenCalledTimes(1)
|
||||
expect($createWorkflowVariableBlockNode).toHaveBeenCalledWith(
|
||||
['node-1', 'output'],
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
variables[0].vars,
|
||||
variables[1].vars,
|
||||
[
|
||||
{ variable: 'ragVarA', type: VarType.string, isRagVariable: true },
|
||||
{ variable: 'rag.shared.answer', type: VarType.string, isRagVariable: true },
|
||||
],
|
||||
)
|
||||
expect($applyNodeReplacement).toHaveBeenCalledWith({ type: 'workflow-node' })
|
||||
expect(created).toEqual({ type: 'workflow-node' })
|
||||
})
|
||||
|
||||
it('should create replacement node without optional callbacks and variable groups', () => {
|
||||
renderWithLexicalContext(
|
||||
<WorkflowVariableBlockReplacementBlock
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
/>,
|
||||
)
|
||||
|
||||
const transformCallback = mockRegisterNodeTransform.mock.calls[0][1] as (node: LexicalNode) => void
|
||||
transformCallback({ id: 'text-node' } as unknown as LexicalNode)
|
||||
|
||||
const createNode = vi.mocked(decoratorTransform).mock.calls[0][2] as (
|
||||
textNode: { getTextContent: () => string },
|
||||
) => WorkflowVariableBlockNode
|
||||
|
||||
expect(() => createNode({ getTextContent: () => '{{#node-1.output#}}' })).not.toThrow()
|
||||
expect($createWorkflowVariableBlockNode).toHaveBeenCalledWith(
|
||||
['node-1', 'output'],
|
||||
workflowNodesMap,
|
||||
undefined,
|
||||
[],
|
||||
[],
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user