mirror of
https://github.com/langgenius/dify.git
synced 2026-04-26 02:06:35 +08:00
297 lines
7.9 KiB
TypeScript
297 lines
7.9 KiB
TypeScript
import type { LexicalEditor } from 'lexical'
|
|
import type { ReactNode } from 'react'
|
|
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
|
import { render } from '@testing-library/react'
|
|
import { $createParagraphNode, $getRoot, $nodesOfType } from 'lexical'
|
|
import * as React from 'react'
|
|
import { ContextBlockNode } from '../context-block/node'
|
|
import { $createCustomTextNode, CustomTextNode } from '../custom-text/node'
|
|
import ContextBlockReplacementBlock from './context-block-replacement-block'
|
|
|
|
// Mock the component rendered by ContextBlockNode.decorate()
|
|
vi.mock('./component', () => ({
|
|
default: () => null,
|
|
}))
|
|
|
|
function createEditorConfig() {
|
|
return {
|
|
namespace: 'test',
|
|
nodes: [CustomTextNode, ContextBlockNode],
|
|
onError: (error: Error) => { throw error },
|
|
}
|
|
}
|
|
|
|
function TestWrapper({ children }: { children: ReactNode }) {
|
|
return (
|
|
<LexicalComposer initialConfig={createEditorConfig()}>
|
|
{children}
|
|
</LexicalComposer>
|
|
)
|
|
}
|
|
|
|
function renderWithEditor(ui: ReactNode) {
|
|
return render(ui, { wrapper: TestWrapper })
|
|
}
|
|
|
|
// Captures the editor instance so we can do updates after the initial render
|
|
let capturedEditor: LexicalEditor | null = null
|
|
|
|
const defaultOnCapture = (editor: LexicalEditor) => {
|
|
capturedEditor = editor
|
|
}
|
|
|
|
function EditorCapture({ onCapture = defaultOnCapture }: { onCapture?: (e: LexicalEditor) => void }) {
|
|
const [editor] = useLexicalComposerContext()
|
|
React.useEffect(() => {
|
|
onCapture(editor)
|
|
}, [editor, onCapture])
|
|
return null
|
|
}
|
|
|
|
type ReadResult = {
|
|
count: number
|
|
datasets: Array<{ id: string, name: string, type: string }>
|
|
canNotAddContext: boolean
|
|
}
|
|
|
|
function insertTextAndRead(text: string): ReadResult {
|
|
if (!capturedEditor)
|
|
throw new Error('Editor not captured')
|
|
|
|
// Insert CustomTextNode with the given text
|
|
capturedEditor.update(() => {
|
|
const root = $getRoot()
|
|
root.clear()
|
|
const paragraph = $createParagraphNode()
|
|
const textNode = $createCustomTextNode(text)
|
|
paragraph.append(textNode)
|
|
root.append(paragraph)
|
|
}, { discrete: true })
|
|
|
|
// Read the resulting state — extract all properties inside .read()
|
|
const result: ReadResult = { count: 0, datasets: [], canNotAddContext: false }
|
|
capturedEditor.getEditorState().read(() => {
|
|
const nodes = $nodesOfType(ContextBlockNode)
|
|
result.count = nodes.length
|
|
if (nodes.length > 0) {
|
|
result.datasets = nodes[0].getDatasets()
|
|
result.canNotAddContext = nodes[0].getCanNotAddContext()
|
|
}
|
|
})
|
|
return result
|
|
}
|
|
|
|
describe('ContextBlockReplacementBlock', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
capturedEditor = null
|
|
})
|
|
|
|
describe('Rendering', () => {
|
|
it('should render without crashing', () => {
|
|
renderWithEditor(
|
|
<>
|
|
<ContextBlockReplacementBlock />
|
|
<EditorCapture />
|
|
</>,
|
|
)
|
|
expect(capturedEditor).not.toBeNull()
|
|
})
|
|
|
|
it('should return null (no visible output from the plugin itself)', () => {
|
|
const { container } = renderWithEditor(
|
|
<>
|
|
<ContextBlockReplacementBlock />
|
|
<EditorCapture />
|
|
</>,
|
|
)
|
|
expect(container.querySelector('[data-testid]')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('Editor Node Registration Check', () => {
|
|
it('should not throw when ContextBlockNode is registered', () => {
|
|
expect(() => {
|
|
renderWithEditor(
|
|
<>
|
|
<ContextBlockReplacementBlock />
|
|
<EditorCapture />
|
|
</>,
|
|
)
|
|
}).not.toThrow()
|
|
})
|
|
|
|
it('should throw when ContextBlockNode is not registered', () => {
|
|
const configWithoutNode = {
|
|
namespace: 'test',
|
|
nodes: [CustomTextNode],
|
|
onError: (error: Error) => { throw error },
|
|
}
|
|
|
|
expect(() => {
|
|
render(
|
|
<LexicalComposer initialConfig={configWithoutNode}>
|
|
<ContextBlockReplacementBlock />
|
|
</LexicalComposer>,
|
|
)
|
|
}).toThrow('ContextBlockNodePlugin: ContextBlockNode not registered on editor')
|
|
})
|
|
})
|
|
|
|
describe('Text Replacement Transform', () => {
|
|
it('should replace context placeholder text with a ContextBlockNode', () => {
|
|
renderWithEditor(
|
|
<>
|
|
<ContextBlockReplacementBlock />
|
|
<EditorCapture />
|
|
</>,
|
|
)
|
|
|
|
const result = insertTextAndRead('{{#context#}}')
|
|
expect(result.count).toBe(1)
|
|
})
|
|
|
|
it('should not replace text that is not the placeholder', () => {
|
|
renderWithEditor(
|
|
<>
|
|
<ContextBlockReplacementBlock />
|
|
<EditorCapture />
|
|
</>,
|
|
)
|
|
|
|
const result = insertTextAndRead('just some normal text')
|
|
expect(result.count).toBe(0)
|
|
})
|
|
|
|
it('should not replace partial placeholder text', () => {
|
|
renderWithEditor(
|
|
<>
|
|
<ContextBlockReplacementBlock />
|
|
<EditorCapture />
|
|
</>,
|
|
)
|
|
|
|
const result = insertTextAndRead('{{#contex')
|
|
expect(result.count).toBe(0)
|
|
})
|
|
|
|
it('should pass datasets to the created ContextBlockNode', () => {
|
|
const datasets = [{ id: '1', name: 'Test', type: 'text' }]
|
|
renderWithEditor(
|
|
<>
|
|
<ContextBlockReplacementBlock datasets={datasets} onAddContext={vi.fn()} />
|
|
<EditorCapture />
|
|
</>,
|
|
)
|
|
|
|
const result = insertTextAndRead('{{#context#}}')
|
|
expect(result.count).toBe(1)
|
|
expect(result.datasets).toEqual(datasets)
|
|
})
|
|
|
|
it('should pass canNotAddContext to the created ContextBlockNode', () => {
|
|
renderWithEditor(
|
|
<>
|
|
<ContextBlockReplacementBlock canNotAddContext={true} />
|
|
<EditorCapture />
|
|
</>,
|
|
)
|
|
|
|
const result = insertTextAndRead('{{#context#}}')
|
|
expect(result.count).toBe(1)
|
|
expect(result.canNotAddContext).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('onInsert callback', () => {
|
|
it('should call onInsert when a placeholder is replaced', () => {
|
|
const onInsert = vi.fn()
|
|
renderWithEditor(
|
|
<>
|
|
<ContextBlockReplacementBlock onInsert={onInsert} />
|
|
<EditorCapture />
|
|
</>,
|
|
)
|
|
|
|
insertTextAndRead('{{#context#}}')
|
|
expect(onInsert).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('should not call onInsert when no placeholder is found', () => {
|
|
const onInsert = vi.fn()
|
|
renderWithEditor(
|
|
<>
|
|
<ContextBlockReplacementBlock onInsert={onInsert} />
|
|
<EditorCapture />
|
|
</>,
|
|
)
|
|
|
|
insertTextAndRead('no placeholder here')
|
|
expect(onInsert).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('Props Defaults', () => {
|
|
it('should default datasets to empty array', () => {
|
|
renderWithEditor(
|
|
<>
|
|
<ContextBlockReplacementBlock />
|
|
<EditorCapture />
|
|
</>,
|
|
)
|
|
|
|
const result = insertTextAndRead('{{#context#}}')
|
|
expect(result.datasets).toEqual([])
|
|
})
|
|
|
|
it('should default canNotAddContext to false', () => {
|
|
renderWithEditor(
|
|
<>
|
|
<ContextBlockReplacementBlock />
|
|
<EditorCapture />
|
|
</>,
|
|
)
|
|
|
|
const result = insertTextAndRead('{{#context#}}')
|
|
expect(result.canNotAddContext).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('Edge Cases', () => {
|
|
it('should handle undefined datasets prop', () => {
|
|
expect(() => {
|
|
renderWithEditor(
|
|
<>
|
|
<ContextBlockReplacementBlock datasets={undefined} />
|
|
<EditorCapture />
|
|
</>,
|
|
)
|
|
}).not.toThrow()
|
|
})
|
|
|
|
it('should handle empty datasets array', () => {
|
|
expect(() => {
|
|
renderWithEditor(
|
|
<>
|
|
<ContextBlockReplacementBlock datasets={[]} />
|
|
<EditorCapture />
|
|
</>,
|
|
)
|
|
}).not.toThrow()
|
|
})
|
|
|
|
it('should handle empty string text', () => {
|
|
renderWithEditor(
|
|
<>
|
|
<ContextBlockReplacementBlock />
|
|
<EditorCapture />
|
|
</>,
|
|
)
|
|
|
|
const result = insertTextAndRead('')
|
|
expect(result.count).toBe(0)
|
|
})
|
|
})
|
|
})
|