mirror of
https://github.com/langgenius/dify.git
synced 2026-03-11 11:40:02 +08:00
268 lines
8.8 KiB
TypeScript
268 lines
8.8 KiB
TypeScript
import type {
|
|
Klass,
|
|
LexicalEditor,
|
|
LexicalNode,
|
|
RangeSelection,
|
|
TextNode,
|
|
} from 'lexical'
|
|
import type { CustomTextNode } from './plugins/custom-text/node'
|
|
import type { MenuTextMatch } from './types'
|
|
import {
|
|
$splitNodeContainingQuery,
|
|
decoratorTransform,
|
|
getSelectedNode,
|
|
registerLexicalTextEntity,
|
|
textToEditorState,
|
|
} from './utils'
|
|
|
|
const mockState = vi.hoisted(() => ({
|
|
isAtNodeEnd: false,
|
|
selection: null as unknown,
|
|
createTextNode: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('@lexical/selection', () => ({
|
|
$isAtNodeEnd: () => mockState.isAtNodeEnd,
|
|
}))
|
|
|
|
vi.mock('lexical', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('lexical')>()
|
|
return {
|
|
...actual,
|
|
$getSelection: () => mockState.selection,
|
|
$isRangeSelection: (selection: unknown) => !!(selection as { __isRangeSelection?: boolean } | null)?.__isRangeSelection,
|
|
$createTextNode: mockState.createTextNode,
|
|
$isTextNode: (node: unknown) => !!(node as { __isTextNode?: boolean } | null)?.__isTextNode,
|
|
}
|
|
})
|
|
|
|
vi.mock('./plugins/custom-text/node', () => ({
|
|
CustomTextNode: class MockCustomTextNode {},
|
|
}))
|
|
|
|
describe('prompt-editor/utils', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
mockState.isAtNodeEnd = false
|
|
mockState.selection = null
|
|
})
|
|
|
|
// Node selection utility for forward/backward lexical cursor behavior.
|
|
describe('getSelectedNode', () => {
|
|
it('should return anchor node when anchor and focus are the same node', () => {
|
|
const sharedNode = { id: 'same' }
|
|
const selection = {
|
|
anchor: { getNode: () => sharedNode },
|
|
focus: { getNode: () => sharedNode },
|
|
isBackward: () => false,
|
|
} as unknown as RangeSelection
|
|
|
|
expect(getSelectedNode(selection)).toBe(sharedNode)
|
|
})
|
|
|
|
it('should return anchor node for backward selection when focus is at node end', () => {
|
|
const anchorNode = { id: 'anchor' }
|
|
const focusNode = { id: 'focus' }
|
|
const selection = {
|
|
anchor: { getNode: () => anchorNode },
|
|
focus: { getNode: () => focusNode },
|
|
isBackward: () => true,
|
|
} as unknown as RangeSelection
|
|
|
|
mockState.isAtNodeEnd = true
|
|
expect(getSelectedNode(selection)).toBe(anchorNode)
|
|
})
|
|
|
|
it('should return focus node for forward selection when anchor is not at node end', () => {
|
|
const anchorNode = { id: 'anchor' }
|
|
const focusNode = { id: 'focus' }
|
|
const selection = {
|
|
anchor: { getNode: () => anchorNode },
|
|
focus: { getNode: () => focusNode },
|
|
isBackward: () => false,
|
|
} as unknown as RangeSelection
|
|
|
|
mockState.isAtNodeEnd = false
|
|
expect(getSelectedNode(selection)).toBe(focusNode)
|
|
})
|
|
})
|
|
|
|
// Entity registration should register transforms and convert invalid entity nodes.
|
|
describe('registerLexicalTextEntity', () => {
|
|
it('should register transforms and replace invalid target node with plain text', () => {
|
|
class TargetNode {
|
|
__isTextNode = true
|
|
getTextContent = vi.fn(() => 'invalid')
|
|
getFormat = vi.fn(() => 9)
|
|
replace = vi.fn()
|
|
splitText = vi.fn()
|
|
getPreviousSibling = vi.fn(() => null)
|
|
getNextSibling = vi.fn(() => null)
|
|
getLatest = vi.fn(() => ({ __mode: 0 }))
|
|
}
|
|
|
|
const removePlainTextTransform = vi.fn()
|
|
const removeReverseNodeTransform = vi.fn()
|
|
const registerNodeTransform = vi
|
|
.fn()
|
|
.mockReturnValueOnce(removePlainTextTransform)
|
|
.mockReturnValueOnce(removeReverseNodeTransform)
|
|
const editor = {
|
|
registerNodeTransform,
|
|
} as unknown as LexicalEditor
|
|
const createdTextNode = {
|
|
setFormat: vi.fn(),
|
|
}
|
|
mockState.createTextNode.mockReturnValue(createdTextNode)
|
|
const getMatch = vi.fn(() => null)
|
|
type TargetTextNode = InstanceType<typeof TargetNode> & TextNode
|
|
const targetNodeClass = TargetNode as unknown as Klass<TargetTextNode>
|
|
const createNode = vi.fn((textNode: TextNode) => textNode as TargetTextNode)
|
|
|
|
const cleanups = registerLexicalTextEntity(editor, getMatch, targetNodeClass, createNode)
|
|
expect(cleanups).toEqual([removePlainTextTransform, removeReverseNodeTransform])
|
|
|
|
const reverseNodeTransform = registerNodeTransform.mock.calls[1][1] as (node: TargetTextNode) => void
|
|
const targetNode = new TargetNode() as TargetTextNode
|
|
reverseNodeTransform(targetNode)
|
|
|
|
expect(mockState.createTextNode).toHaveBeenCalledWith('invalid')
|
|
expect(createdTextNode.setFormat).toHaveBeenCalledWith(9)
|
|
expect(targetNode.replace).toHaveBeenCalledWith(createdTextNode)
|
|
})
|
|
})
|
|
|
|
// Decorator transform behavior for converting matched text segments.
|
|
describe('decoratorTransform', () => {
|
|
it('should do nothing when node is not simple text', () => {
|
|
const node = {
|
|
isSimpleText: vi.fn(() => false),
|
|
} as unknown as CustomTextNode
|
|
const getMatch = vi.fn()
|
|
const createNode = vi.fn()
|
|
|
|
decoratorTransform(node, getMatch, createNode)
|
|
|
|
expect(getMatch).not.toHaveBeenCalled()
|
|
expect(createNode).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('should replace matched text node segment with created decorator node', () => {
|
|
const replacedNode = { replace: vi.fn() }
|
|
const node = {
|
|
__isTextNode: true,
|
|
isSimpleText: vi.fn(() => true),
|
|
getPreviousSibling: vi.fn(() => null),
|
|
getTextContent: vi.fn(() => 'abc'),
|
|
getNextSibling: vi.fn(() => null),
|
|
splitText: vi.fn(() => [replacedNode, null]),
|
|
} as unknown as CustomTextNode
|
|
const getMatch = vi
|
|
.fn()
|
|
.mockReturnValueOnce({ start: 0, end: 1 })
|
|
.mockReturnValueOnce(null)
|
|
const createdDecoratorNode = { id: 'decorator' }
|
|
const createNode = vi.fn(() => createdDecoratorNode as unknown as LexicalNode)
|
|
|
|
decoratorTransform(node, getMatch, createNode)
|
|
|
|
expect(node.splitText).toHaveBeenCalledWith(1)
|
|
expect(createNode).toHaveBeenCalledWith(replacedNode)
|
|
expect(replacedNode.replace).toHaveBeenCalledWith(createdDecoratorNode)
|
|
})
|
|
})
|
|
|
|
// Split helper for menu query replacement inside collapsed text selection.
|
|
describe('$splitNodeContainingQuery', () => {
|
|
const match: MenuTextMatch = {
|
|
leadOffset: 0,
|
|
matchingString: 'abc',
|
|
replaceableString: '@abc',
|
|
}
|
|
|
|
it('should return null when selection is not a collapsed range selection', () => {
|
|
mockState.selection = { __isRangeSelection: false }
|
|
expect($splitNodeContainingQuery(match)).toBeNull()
|
|
})
|
|
|
|
it('should return null when anchor is not text selection', () => {
|
|
mockState.selection = {
|
|
__isRangeSelection: true,
|
|
isCollapsed: () => true,
|
|
anchor: {
|
|
type: 'element',
|
|
offset: 1,
|
|
getNode: vi.fn(),
|
|
},
|
|
}
|
|
|
|
expect($splitNodeContainingQuery(match)).toBeNull()
|
|
})
|
|
|
|
it('should split using single offset when query starts at beginning of text', () => {
|
|
const newNode = { id: 'new-node' }
|
|
const anchorNode = {
|
|
isSimpleText: () => true,
|
|
getTextContent: () => '@abc',
|
|
splitText: vi.fn(() => [newNode]),
|
|
}
|
|
mockState.selection = {
|
|
__isRangeSelection: true,
|
|
isCollapsed: () => true,
|
|
anchor: {
|
|
type: 'text',
|
|
offset: 4,
|
|
getNode: () => anchorNode,
|
|
},
|
|
}
|
|
|
|
const result = $splitNodeContainingQuery(match)
|
|
|
|
expect(anchorNode.splitText).toHaveBeenCalledWith(4)
|
|
expect(result).toBe(newNode)
|
|
})
|
|
|
|
it('should split using range offsets when query is inside text', () => {
|
|
const newNode = { id: 'new-node' }
|
|
const anchorNode = {
|
|
isSimpleText: () => true,
|
|
getTextContent: () => 'hello @abc',
|
|
splitText: vi.fn(() => [null, newNode]),
|
|
}
|
|
mockState.selection = {
|
|
__isRangeSelection: true,
|
|
isCollapsed: () => true,
|
|
anchor: {
|
|
type: 'text',
|
|
offset: 10,
|
|
getNode: () => anchorNode,
|
|
},
|
|
}
|
|
|
|
const result = $splitNodeContainingQuery(match)
|
|
|
|
expect(anchorNode.splitText).toHaveBeenCalledWith(6, 10)
|
|
expect(result).toBe(newNode)
|
|
})
|
|
})
|
|
|
|
// Serialization utility for prompt text -> lexical editor state JSON.
|
|
describe('textToEditorState', () => {
|
|
it('should serialize multiline text into paragraph nodes', () => {
|
|
const state = JSON.parse(textToEditorState('line-1\nline-2'))
|
|
|
|
expect(state.root.children).toHaveLength(2)
|
|
expect(state.root.children[0].children[0].text).toBe('line-1')
|
|
expect(state.root.children[1].children[0].text).toBe('line-2')
|
|
expect(state.root.type).toBe('root')
|
|
})
|
|
|
|
it('should create one empty paragraph when text is empty', () => {
|
|
const state = JSON.parse(textToEditorState(''))
|
|
|
|
expect(state.root.children).toHaveLength(1)
|
|
expect(state.root.children[0].children[0].text).toBe('')
|
|
})
|
|
})
|
|
})
|