mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 01:48:04 +08:00
test: improve coverage for some test files (#32916)
Signed-off-by: edvatar <88481784+toroleapinc@users.noreply.github.com> Signed-off-by: -LAN- <laipz8200@outlook.com> Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: majiayu000 <1835304752@qq.com> Co-authored-by: Poojan <poojan@infocusp.com> Co-authored-by: sahil-infocusp <73810410+sahil-infocusp@users.noreply.github.com> Co-authored-by: 非法操作 <hjlarry@163.com> Co-authored-by: Pandaaaa906 <ye.pandaaaa906@gmail.com> Co-authored-by: Asuka Minato <i@asukaminato.eu.org> Co-authored-by: heyszt <270985384@qq.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Ijas <ijas.ahmd.ap@gmail.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: 木之本澪 <kinomotomiovo@gmail.com> Co-authored-by: KinomotoMio <200703522+KinomotoMio@users.noreply.github.com> Co-authored-by: 不做了睡大觉 <64798754+stakeswky@users.noreply.github.com> Co-authored-by: User <user@example.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: edvatar <88481784+toroleapinc@users.noreply.github.com> Co-authored-by: -LAN- <laipz8200@outlook.com> Co-authored-by: Leilei <138381132+Inlei@users.noreply.github.com> Co-authored-by: HaKu <104669497+haku-ink@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: wangxiaolei <fatelei@gmail.com> Co-authored-by: Varun Chawla <34209028+veeceey@users.noreply.github.com> Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Co-authored-by: yyh <yuanyouhuilyz@gmail.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> Co-authored-by: tda <95275462+tda1017@users.noreply.github.com> Co-authored-by: root <root@DESKTOP-KQLO90N> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Co-authored-by: Niels Kaspers <153818647+nielskaspers@users.noreply.github.com> Co-authored-by: hj24 <mambahj24@gmail.com> Co-authored-by: Tyson Cung <45380903+tysoncung@users.noreply.github.com> Co-authored-by: Stephen Zhou <hi@hyoban.cc> Co-authored-by: FFXN <31929997+FFXN@users.noreply.github.com> Co-authored-by: slegarraga <64795732+slegarraga@users.noreply.github.com> Co-authored-by: 99 <wh2099@pm.me> Co-authored-by: Br1an <932039080@qq.com> Co-authored-by: L1nSn0w <l1nsn0w@qq.com> Co-authored-by: Yunlu Wen <yunlu.wen@dify.ai> Co-authored-by: akkoaya <151345394+akkoaya@users.noreply.github.com> Co-authored-by: 盐粒 Yanli <yanli@dify.ai> Co-authored-by: lif <1835304752@qq.com> Co-authored-by: weiguang li <codingpunk@gmail.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com> Co-authored-by: HanWenbo <124024253+hwb96@users.noreply.github.com> Co-authored-by: Coding On Star <447357187@qq.com> Co-authored-by: CodingOnStar <hanxujiang@dify.com> Co-authored-by: Stable Genius <stablegenius043@gmail.com> Co-authored-by: Stable Genius <259448942+stablegenius49@users.noreply.github.com> Co-authored-by: ふるい <46769295+Echo0ff@users.noreply.github.com> Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
This commit is contained in:
@ -0,0 +1,209 @@
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
ParagraphNode,
|
||||
TextNode,
|
||||
} from 'lexical'
|
||||
import {
|
||||
createLexicalTestEditor,
|
||||
expectInlineWrapperDom,
|
||||
getNodeCount,
|
||||
getNodesByType,
|
||||
readEditorStateValue,
|
||||
readRootTextContent,
|
||||
renderLexicalEditor,
|
||||
selectRootEnd,
|
||||
setEditorRootText,
|
||||
waitForEditorReady,
|
||||
} from '../test-helpers'
|
||||
|
||||
describe('test-helpers', () => {
|
||||
describe('renderLexicalEditor & waitForEditorReady', () => {
|
||||
it('should render the editor and wait for it', async () => {
|
||||
const { getEditor } = renderLexicalEditor({
|
||||
namespace: 'TestNamespace',
|
||||
nodes: [ParagraphNode, TextNode],
|
||||
children: null,
|
||||
})
|
||||
|
||||
const editor = await waitForEditorReady(getEditor)
|
||||
expect(editor).toBeDefined()
|
||||
expect(editor).toBe(getEditor())
|
||||
})
|
||||
|
||||
it('should throw if wait times out without editor', async () => {
|
||||
await expect(waitForEditorReady(() => null)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('should throw if editor is null after waitFor completes', async () => {
|
||||
let callCount = 0
|
||||
await expect(
|
||||
waitForEditorReady(() => {
|
||||
callCount++
|
||||
// Return non-null on the last check of `waitFor` so it passes,
|
||||
// then null when actually retrieving the editor
|
||||
return callCount === 1 ? ({} as LexicalEditor) : null
|
||||
}),
|
||||
).rejects.toThrow('Editor is not available')
|
||||
})
|
||||
|
||||
it('should surface errors through configured onError callback', async () => {
|
||||
const { getEditor } = renderLexicalEditor({
|
||||
namespace: 'TestNamespace',
|
||||
nodes: [ParagraphNode, TextNode],
|
||||
children: null,
|
||||
})
|
||||
const editor = await waitForEditorReady(getEditor)
|
||||
|
||||
expect(() => {
|
||||
editor.update(() => {
|
||||
throw new Error('test error')
|
||||
}, { discrete: true })
|
||||
}).toThrow('test error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectRootEnd', () => {
|
||||
it('should select the end of the root', async () => {
|
||||
const { getEditor } = renderLexicalEditor({ namespace: 'test', nodes: [ParagraphNode, TextNode], children: null })
|
||||
const editor = await waitForEditorReady(getEditor)
|
||||
|
||||
selectRootEnd(editor)
|
||||
|
||||
await waitFor(() => {
|
||||
let isRangeSelection = false
|
||||
editor.getEditorState().read(() => {
|
||||
const selection = $getSelection()
|
||||
isRangeSelection = $isRangeSelection(selection)
|
||||
})
|
||||
expect(isRangeSelection).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Content Reading/Writing Helpers', () => {
|
||||
it('should read root text content', async () => {
|
||||
const { getEditor } = renderLexicalEditor({ namespace: 'test', nodes: [ParagraphNode, TextNode], children: null })
|
||||
const editor = await waitForEditorReady(getEditor)
|
||||
|
||||
act(() => {
|
||||
editor.update(() => {
|
||||
const root = $getRoot()
|
||||
root.clear()
|
||||
const paragraph = $createParagraphNode()
|
||||
paragraph.append($createTextNode('Hello World'))
|
||||
root.append(paragraph)
|
||||
}, { discrete: true })
|
||||
})
|
||||
|
||||
let content = ''
|
||||
act(() => {
|
||||
content = readRootTextContent(editor)
|
||||
})
|
||||
expect(content).toBe('Hello World')
|
||||
})
|
||||
|
||||
it('should set editor root text and select end', async () => {
|
||||
const { getEditor } = renderLexicalEditor({ namespace: 'test', nodes: [ParagraphNode, TextNode], children: null })
|
||||
const editor = await waitForEditorReady(getEditor)
|
||||
|
||||
setEditorRootText(editor, 'New Text', $createTextNode)
|
||||
|
||||
await waitFor(() => {
|
||||
let content = ''
|
||||
editor.getEditorState().read(() => {
|
||||
content = $getRoot().getTextContent()
|
||||
})
|
||||
expect(content).toBe('New Text')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Selection Helpers', () => {
|
||||
it('should get node count', async () => {
|
||||
const { getEditor } = renderLexicalEditor({ namespace: 'test', nodes: [ParagraphNode, TextNode], children: null })
|
||||
const editor = await waitForEditorReady(getEditor)
|
||||
|
||||
act(() => {
|
||||
editor.update(() => {
|
||||
const root = $getRoot()
|
||||
root.clear()
|
||||
root.append($createParagraphNode())
|
||||
root.append($createParagraphNode())
|
||||
}, { discrete: true })
|
||||
})
|
||||
|
||||
let count = 0
|
||||
act(() => {
|
||||
count = getNodeCount(editor, ParagraphNode)
|
||||
})
|
||||
expect(count).toBe(2)
|
||||
})
|
||||
|
||||
it('should get nodes by type', async () => {
|
||||
const { getEditor } = renderLexicalEditor({ namespace: 'test', nodes: [ParagraphNode, TextNode], children: null })
|
||||
const editor = await waitForEditorReady(getEditor)
|
||||
|
||||
act(() => {
|
||||
editor.update(() => {
|
||||
const root = $getRoot()
|
||||
root.clear()
|
||||
root.append($createParagraphNode())
|
||||
}, { discrete: true })
|
||||
})
|
||||
|
||||
let nodes: ParagraphNode[] = []
|
||||
act(() => {
|
||||
nodes = getNodesByType(editor, ParagraphNode)
|
||||
})
|
||||
expect(nodes).toHaveLength(1)
|
||||
expect(nodes[0]).not.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('readEditorStateValue', () => {
|
||||
it('should read primitive values from editor state', () => {
|
||||
const editor = createLexicalTestEditor('test', [ParagraphNode, TextNode])
|
||||
|
||||
const val = readEditorStateValue(editor, () => {
|
||||
return $getRoot().isEmpty()
|
||||
})
|
||||
expect(val).toBe(true)
|
||||
})
|
||||
|
||||
it('should throw if value is undefined', () => {
|
||||
const editor = createLexicalTestEditor('test', [ParagraphNode, TextNode])
|
||||
|
||||
expect(() => {
|
||||
readEditorStateValue(editor, () => undefined)
|
||||
}).toThrow('Failed to read editor state value')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createLexicalTestEditor', () => {
|
||||
it('should expose createLexicalTestEditor with onError throw', () => {
|
||||
const editor = createLexicalTestEditor('custom-namespace', [ParagraphNode, TextNode])
|
||||
expect(editor).toBeDefined()
|
||||
|
||||
expect(() => {
|
||||
editor.update(() => {
|
||||
throw new Error('test error')
|
||||
}, { discrete: true })
|
||||
}).toThrow('test error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('expectInlineWrapperDom', () => {
|
||||
it('should assert wrapper properties on a valid DOM element', () => {
|
||||
const div = document.createElement('div')
|
||||
div.classList.add('inline-flex', 'items-center', 'align-middle', 'extra1', 'extra2')
|
||||
|
||||
expectInlineWrapperDom(div, ['extra1', 'extra2']) // Does not throw
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,300 @@
|
||||
import type { RootNode } from 'lexical'
|
||||
import { $createParagraphNode, $createTextNode, $getRoot, ParagraphNode, TextNode } from 'lexical'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createTestEditor, withEditorUpdate } from './utils'
|
||||
|
||||
describe('Prompt Editor Test Utils', () => {
|
||||
describe('createTestEditor', () => {
|
||||
it('should create an editor without crashing', () => {
|
||||
const editor = createTestEditor()
|
||||
expect(editor).toBeDefined()
|
||||
})
|
||||
|
||||
it('should create an editor with no nodes by default', () => {
|
||||
const editor = createTestEditor()
|
||||
expect(editor).toBeDefined()
|
||||
})
|
||||
|
||||
it('should create an editor with provided nodes', () => {
|
||||
const nodes = [ParagraphNode, TextNode]
|
||||
const editor = createTestEditor(nodes)
|
||||
expect(editor).toBeDefined()
|
||||
})
|
||||
|
||||
it('should set up root element for the editor', () => {
|
||||
const editor = createTestEditor()
|
||||
// The editor should be properly initialized with a root element
|
||||
expect(editor).toBeDefined()
|
||||
})
|
||||
|
||||
it('should throw errors when they occur', () => {
|
||||
const nodes = [ParagraphNode, TextNode]
|
||||
const editor = createTestEditor(nodes)
|
||||
|
||||
expect(() => {
|
||||
editor.update(() => {
|
||||
throw new Error('Test error')
|
||||
}, { discrete: true })
|
||||
}).toThrow('Test error')
|
||||
})
|
||||
|
||||
it('should allow multiple editors to be created independently', () => {
|
||||
const editor1 = createTestEditor()
|
||||
const editor2 = createTestEditor()
|
||||
|
||||
expect(editor1).not.toBe(editor2)
|
||||
})
|
||||
|
||||
it('should initialize with basic node types', () => {
|
||||
const nodes = [ParagraphNode, TextNode]
|
||||
const editor = createTestEditor(nodes)
|
||||
|
||||
let content: string = ''
|
||||
editor.update(() => {
|
||||
const root = $getRoot()
|
||||
const paragraph = $createParagraphNode()
|
||||
const text = $createTextNode('Hello World')
|
||||
paragraph.append(text)
|
||||
root.append(paragraph)
|
||||
|
||||
content = root.getTextContent()
|
||||
}, { discrete: true })
|
||||
|
||||
expect(content).toBe('Hello World')
|
||||
})
|
||||
})
|
||||
|
||||
describe('withEditorUpdate', () => {
|
||||
it('should execute update function without crashing', () => {
|
||||
const editor = createTestEditor([ParagraphNode, TextNode])
|
||||
const updateFn = vi.fn()
|
||||
|
||||
withEditorUpdate(editor, updateFn)
|
||||
|
||||
expect(updateFn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should pass discrete: true option to editor.update', () => {
|
||||
const editor = createTestEditor([ParagraphNode, TextNode])
|
||||
const updateSpy = vi.spyOn(editor, 'update')
|
||||
|
||||
withEditorUpdate(editor, () => {
|
||||
$getRoot()
|
||||
})
|
||||
|
||||
expect(updateSpy).toHaveBeenCalledWith(expect.any(Function), { discrete: true })
|
||||
})
|
||||
|
||||
it('should allow updating editor state', () => {
|
||||
const editor = createTestEditor([ParagraphNode, TextNode])
|
||||
let textContent: string = ''
|
||||
|
||||
withEditorUpdate(editor, () => {
|
||||
const root = $getRoot()
|
||||
const paragraph = $createParagraphNode()
|
||||
const text = $createTextNode('Test Content')
|
||||
paragraph.append(text)
|
||||
root.append(paragraph)
|
||||
})
|
||||
|
||||
withEditorUpdate(editor, () => {
|
||||
textContent = $getRoot().getTextContent()
|
||||
})
|
||||
|
||||
expect(textContent).toBe('Test Content')
|
||||
})
|
||||
|
||||
it('should handle multiple consecutive updates', () => {
|
||||
const editor = createTestEditor([ParagraphNode, TextNode])
|
||||
|
||||
withEditorUpdate(editor, () => {
|
||||
const root = $getRoot()
|
||||
const p1 = $createParagraphNode()
|
||||
p1.append($createTextNode('First'))
|
||||
root.append(p1)
|
||||
})
|
||||
|
||||
withEditorUpdate(editor, () => {
|
||||
const root = $getRoot()
|
||||
const p2 = $createParagraphNode()
|
||||
p2.append($createTextNode('Second'))
|
||||
root.append(p2)
|
||||
})
|
||||
|
||||
let content: string = ''
|
||||
withEditorUpdate(editor, () => {
|
||||
content = $getRoot().getTextContent()
|
||||
})
|
||||
|
||||
expect(content).toContain('First')
|
||||
expect(content).toContain('Second')
|
||||
})
|
||||
|
||||
it('should provide access to editor state within update', () => {
|
||||
const editor = createTestEditor([ParagraphNode, TextNode])
|
||||
let capturedState: RootNode | null = null
|
||||
|
||||
withEditorUpdate(editor, () => {
|
||||
const root = $getRoot()
|
||||
capturedState = root
|
||||
})
|
||||
|
||||
expect(capturedState).toBeDefined()
|
||||
})
|
||||
|
||||
it('should execute update function immediately', () => {
|
||||
const editor = createTestEditor([ParagraphNode, TextNode])
|
||||
let executed = false
|
||||
|
||||
withEditorUpdate(editor, () => {
|
||||
executed = true
|
||||
})
|
||||
|
||||
// Update should be executed synchronously in discrete mode
|
||||
expect(executed).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle complex editor operations within update', () => {
|
||||
const editor = createTestEditor([ParagraphNode, TextNode])
|
||||
let nodeCount: number = 0
|
||||
|
||||
withEditorUpdate(editor, () => {
|
||||
const root = $getRoot()
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const paragraph = $createParagraphNode()
|
||||
paragraph.append($createTextNode(`Paragraph ${i}`))
|
||||
root.append(paragraph)
|
||||
}
|
||||
|
||||
// Count child nodes
|
||||
nodeCount = root.getChildrenSize()
|
||||
})
|
||||
|
||||
expect(nodeCount).toBe(3)
|
||||
})
|
||||
|
||||
it('should allow reading editor state after update', () => {
|
||||
const editor = createTestEditor([ParagraphNode, TextNode])
|
||||
|
||||
withEditorUpdate(editor, () => {
|
||||
const root = $getRoot()
|
||||
const paragraph = $createParagraphNode()
|
||||
paragraph.append($createTextNode('Read Test'))
|
||||
root.append(paragraph)
|
||||
})
|
||||
|
||||
let readContent: string = ''
|
||||
withEditorUpdate(editor, () => {
|
||||
readContent = $getRoot().getTextContent()
|
||||
})
|
||||
|
||||
expect(readContent).toBe('Read Test')
|
||||
})
|
||||
|
||||
it('should handle error thrown within update function', () => {
|
||||
const editor = createTestEditor([ParagraphNode, TextNode])
|
||||
|
||||
expect(() => {
|
||||
withEditorUpdate(editor, () => {
|
||||
throw new Error('Update error')
|
||||
})
|
||||
}).toThrow('Update error')
|
||||
})
|
||||
|
||||
it('should preserve editor state across multiple updates', () => {
|
||||
const editor = createTestEditor([ParagraphNode, TextNode])
|
||||
|
||||
withEditorUpdate(editor, () => {
|
||||
const root = $getRoot()
|
||||
const p = $createParagraphNode()
|
||||
p.append($createTextNode('Persistent'))
|
||||
root.append(p)
|
||||
})
|
||||
|
||||
let persistedContent: string = ''
|
||||
withEditorUpdate(editor, () => {
|
||||
persistedContent = $getRoot().getTextContent()
|
||||
})
|
||||
|
||||
expect(persistedContent).toBe('Persistent')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should work together to create and update editor', () => {
|
||||
const editor = createTestEditor([ParagraphNode, TextNode])
|
||||
|
||||
withEditorUpdate(editor, () => {
|
||||
const root = $getRoot()
|
||||
const p = $createParagraphNode()
|
||||
p.append($createTextNode('Integration Test'))
|
||||
root.append(p)
|
||||
})
|
||||
|
||||
let result: string = ''
|
||||
withEditorUpdate(editor, () => {
|
||||
result = $getRoot().getTextContent()
|
||||
})
|
||||
|
||||
expect(result).toBe('Integration Test')
|
||||
})
|
||||
|
||||
it('should support multiple editors with isolated state', () => {
|
||||
const editor1 = createTestEditor([ParagraphNode, TextNode])
|
||||
const editor2 = createTestEditor([ParagraphNode, TextNode])
|
||||
|
||||
withEditorUpdate(editor1, () => {
|
||||
const root = $getRoot()
|
||||
const p = $createParagraphNode()
|
||||
p.append($createTextNode('Editor 1'))
|
||||
root.append(p)
|
||||
})
|
||||
|
||||
withEditorUpdate(editor2, () => {
|
||||
const root = $getRoot()
|
||||
const p = $createParagraphNode()
|
||||
p.append($createTextNode('Editor 2'))
|
||||
root.append(p)
|
||||
})
|
||||
|
||||
let content1: string = ''
|
||||
let content2: string = ''
|
||||
|
||||
withEditorUpdate(editor1, () => {
|
||||
content1 = $getRoot().getTextContent()
|
||||
})
|
||||
|
||||
withEditorUpdate(editor2, () => {
|
||||
content2 = $getRoot().getTextContent()
|
||||
})
|
||||
|
||||
expect(content1).toBe('Editor 1')
|
||||
expect(content2).toBe('Editor 2')
|
||||
})
|
||||
|
||||
it('should handle nested paragraph and text nodes', () => {
|
||||
const editor = createTestEditor([ParagraphNode, TextNode])
|
||||
|
||||
withEditorUpdate(editor, () => {
|
||||
const root = $getRoot()
|
||||
const p1 = $createParagraphNode()
|
||||
const p2 = $createParagraphNode()
|
||||
|
||||
p1.append($createTextNode('First Para'))
|
||||
p2.append($createTextNode('Second Para'))
|
||||
|
||||
root.append(p1)
|
||||
root.append(p2)
|
||||
})
|
||||
|
||||
let content: string = ''
|
||||
withEditorUpdate(editor, () => {
|
||||
content = $getRoot().getTextContent()
|
||||
})
|
||||
|
||||
expect(content).toContain('First Para')
|
||||
expect(content).toContain('Second Para')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,112 +1,251 @@
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import type { JSX, RefObject } from 'react'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import DraggableBlockPlugin from '..'
|
||||
|
||||
const CONTENT_EDITABLE_TEST_ID = 'draggable-content-editable'
|
||||
let namespaceCounter = 0
|
||||
|
||||
function renderWithEditor(anchorElem?: HTMLElement) {
|
||||
render(
|
||||
<LexicalComposer
|
||||
initialConfig={{
|
||||
namespace: `draggable-plugin-test-${namespaceCounter++}`,
|
||||
onError: (error: Error) => { throw error },
|
||||
}}
|
||||
>
|
||||
<RichTextPlugin
|
||||
contentEditable={<ContentEditable data-testid={CONTENT_EDITABLE_TEST_ID} />}
|
||||
placeholder={null}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<DraggableBlockPlugin anchorElem={anchorElem} />
|
||||
</LexicalComposer>,
|
||||
)
|
||||
|
||||
return screen.getByTestId(CONTENT_EDITABLE_TEST_ID)
|
||||
type DraggableExperimentalProps = {
|
||||
anchorElem: HTMLElement
|
||||
menuRef: RefObject<HTMLDivElement>
|
||||
targetLineRef: RefObject<HTMLDivElement>
|
||||
menuComponent: JSX.Element | null
|
||||
targetLineComponent: JSX.Element
|
||||
isOnMenu: (element: HTMLElement) => boolean
|
||||
onElementChanged: (element: HTMLElement | null) => void
|
||||
}
|
||||
|
||||
function appendChildToRoot(rootElement: HTMLElement, className = '') {
|
||||
const element = document.createElement('div')
|
||||
element.className = className
|
||||
rootElement.appendChild(element)
|
||||
return element
|
||||
type MouseMoveHandler = (event: MouseEvent) => void
|
||||
|
||||
const { draggableMockState } = vi.hoisted(() => ({
|
||||
draggableMockState: {
|
||||
latestProps: null as DraggableExperimentalProps | null,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/react/LexicalComposerContext')
|
||||
vi.mock('@lexical/react/LexicalDraggableBlockPlugin', () => ({
|
||||
DraggableBlockPlugin_EXPERIMENTAL: (props: DraggableExperimentalProps) => {
|
||||
draggableMockState.latestProps = props
|
||||
return (
|
||||
<div data-testid="draggable-plugin-experimental-mock">
|
||||
{props.menuComponent}
|
||||
{props.targetLineComponent}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
function createRootElementMock() {
|
||||
let mouseMoveHandler: MouseMoveHandler | null = null
|
||||
const addEventListener = vi.fn((eventName: string, handler: EventListenerOrEventListenerObject) => {
|
||||
if (eventName === 'mousemove' && typeof handler === 'function')
|
||||
mouseMoveHandler = handler as MouseMoveHandler
|
||||
})
|
||||
const removeEventListener = vi.fn()
|
||||
|
||||
return {
|
||||
rootElement: {
|
||||
addEventListener,
|
||||
removeEventListener,
|
||||
} as unknown as HTMLElement,
|
||||
addEventListener,
|
||||
removeEventListener,
|
||||
getMouseMoveHandler: () => mouseMoveHandler,
|
||||
}
|
||||
}
|
||||
|
||||
function getRegisteredMouseMoveHandler(
|
||||
rootMock: ReturnType<typeof createRootElementMock>,
|
||||
): MouseMoveHandler {
|
||||
const handler = rootMock.getMouseMoveHandler()
|
||||
if (!handler)
|
||||
throw new Error('Expected mousemove handler to be registered')
|
||||
return handler
|
||||
}
|
||||
|
||||
function setupEditorRoot(rootElement: HTMLElement | null) {
|
||||
const editor = {
|
||||
getRootElement: vi.fn(() => rootElement),
|
||||
} as unknown as LexicalEditor
|
||||
|
||||
vi.mocked(useLexicalComposerContext).mockReturnValue([
|
||||
editor,
|
||||
{},
|
||||
] as unknown as ReturnType<typeof useLexicalComposerContext>)
|
||||
|
||||
return editor
|
||||
}
|
||||
|
||||
describe('DraggableBlockPlugin', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
draggableMockState.latestProps = null
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should use body as default anchor and render target line', () => {
|
||||
renderWithEditor()
|
||||
const rootMock = createRootElementMock()
|
||||
setupEditorRoot(rootMock.rootElement)
|
||||
|
||||
const targetLine = screen.getByTestId('draggable-target-line')
|
||||
expect(targetLine).toBeInTheDocument()
|
||||
expect(document.body.contains(targetLine)).toBe(true)
|
||||
render(<DraggableBlockPlugin />)
|
||||
|
||||
expect(draggableMockState.latestProps?.anchorElem).toBe(document.body)
|
||||
expect(screen.getByTestId('draggable-target-line')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('draggable-menu')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render inside custom anchor element when provided', () => {
|
||||
const customAnchor = document.createElement('div')
|
||||
document.body.appendChild(customAnchor)
|
||||
it('should render with custom anchor when provided', () => {
|
||||
const rootMock = createRootElementMock()
|
||||
setupEditorRoot(rootMock.rootElement)
|
||||
const anchorElem = document.createElement('div')
|
||||
|
||||
renderWithEditor(customAnchor)
|
||||
render(<DraggableBlockPlugin anchorElem={anchorElem} />)
|
||||
|
||||
const targetLine = screen.getByTestId('draggable-target-line')
|
||||
expect(customAnchor.contains(targetLine)).toBe(true)
|
||||
expect(draggableMockState.latestProps?.anchorElem).toBe(anchorElem)
|
||||
expect(screen.getByTestId('draggable-target-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
customAnchor.remove()
|
||||
it('should return early when editor root element is null', () => {
|
||||
const editor = setupEditorRoot(null)
|
||||
|
||||
render(<DraggableBlockPlugin />)
|
||||
|
||||
expect(editor.getRootElement).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByTestId('draggable-target-line')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('draggable-menu')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Drag Support Detection', () => {
|
||||
it('should render drag menu when mouse moves over a support-drag element', async () => {
|
||||
const rootElement = renderWithEditor()
|
||||
const supportDragTarget = appendChildToRoot(rootElement, 'support-drag')
|
||||
describe('Drag support detection', () => {
|
||||
it('should show menu when target has support-drag class', () => {
|
||||
const rootMock = createRootElementMock()
|
||||
setupEditorRoot(rootMock.rootElement)
|
||||
render(<DraggableBlockPlugin />)
|
||||
|
||||
const onMove = getRegisteredMouseMoveHandler(rootMock)
|
||||
const target = document.createElement('div')
|
||||
target.className = 'support-drag'
|
||||
|
||||
act(() => {
|
||||
onMove({ target } as unknown as MouseEvent)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('draggable-menu')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show menu when target contains a support-drag descendant', () => {
|
||||
const rootMock = createRootElementMock()
|
||||
setupEditorRoot(rootMock.rootElement)
|
||||
render(<DraggableBlockPlugin />)
|
||||
|
||||
const onMove = getRegisteredMouseMoveHandler(rootMock)
|
||||
const target = document.createElement('div')
|
||||
target.appendChild(Object.assign(document.createElement('span'), { className: 'support-drag' }))
|
||||
|
||||
act(() => {
|
||||
onMove({ target } as unknown as MouseEvent)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('draggable-menu')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show menu when target is inside a support-drag ancestor', () => {
|
||||
const rootMock = createRootElementMock()
|
||||
setupEditorRoot(rootMock.rootElement)
|
||||
render(<DraggableBlockPlugin />)
|
||||
|
||||
const onMove = getRegisteredMouseMoveHandler(rootMock)
|
||||
const ancestor = document.createElement('div')
|
||||
ancestor.className = 'support-drag'
|
||||
const child = document.createElement('span')
|
||||
ancestor.appendChild(child)
|
||||
|
||||
act(() => {
|
||||
onMove({ target: child } as unknown as MouseEvent)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('draggable-menu')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide menu when target does not support drag', () => {
|
||||
const rootMock = createRootElementMock()
|
||||
setupEditorRoot(rootMock.rootElement)
|
||||
render(<DraggableBlockPlugin />)
|
||||
|
||||
const onMove = getRegisteredMouseMoveHandler(rootMock)
|
||||
const supportDragTarget = document.createElement('div')
|
||||
supportDragTarget.className = 'support-drag'
|
||||
|
||||
act(() => {
|
||||
onMove({ target: supportDragTarget } as unknown as MouseEvent)
|
||||
})
|
||||
expect(screen.getByTestId('draggable-menu')).toBeInTheDocument()
|
||||
|
||||
const plainTarget = document.createElement('div')
|
||||
act(() => {
|
||||
onMove({ target: plainTarget } as unknown as MouseEvent)
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('draggable-menu')).not.toBeInTheDocument()
|
||||
fireEvent.mouseMove(supportDragTarget)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('draggable-menu')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should hide drag menu when support-drag target is removed and mouse moves again', async () => {
|
||||
const rootElement = renderWithEditor()
|
||||
const supportDragTarget = appendChildToRoot(rootElement, 'support-drag')
|
||||
it('should keep menu hidden when event target becomes null', () => {
|
||||
const rootMock = createRootElementMock()
|
||||
setupEditorRoot(rootMock.rootElement)
|
||||
render(<DraggableBlockPlugin />)
|
||||
|
||||
fireEvent.mouseMove(supportDragTarget)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('draggable-menu')).toBeInTheDocument()
|
||||
const onMove = getRegisteredMouseMoveHandler(rootMock)
|
||||
const supportDragTarget = document.createElement('div')
|
||||
supportDragTarget.className = 'support-drag'
|
||||
act(() => {
|
||||
onMove({ target: supportDragTarget } as unknown as MouseEvent)
|
||||
})
|
||||
expect(screen.getByTestId('draggable-menu')).toBeInTheDocument()
|
||||
act(() => {
|
||||
onMove({ target: null } as unknown as MouseEvent)
|
||||
})
|
||||
|
||||
supportDragTarget.remove()
|
||||
fireEvent.mouseMove(rootElement)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('draggable-menu')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByTestId('draggable-menu')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Menu Detection Contract', () => {
|
||||
it('should render menu with draggable-block-menu class and keep non-menu elements outside it', async () => {
|
||||
const rootElement = renderWithEditor()
|
||||
const supportDragTarget = appendChildToRoot(rootElement, 'support-drag')
|
||||
describe('Forwarded callbacks', () => {
|
||||
it('should forward isOnMenu and detect menu membership correctly', () => {
|
||||
const rootMock = createRootElementMock()
|
||||
setupEditorRoot(rootMock.rootElement)
|
||||
render(<DraggableBlockPlugin />)
|
||||
|
||||
fireEvent.mouseMove(supportDragTarget)
|
||||
const onMove = getRegisteredMouseMoveHandler(rootMock)
|
||||
const supportDragTarget = document.createElement('div')
|
||||
supportDragTarget.className = 'support-drag'
|
||||
act(() => {
|
||||
onMove({ target: supportDragTarget } as unknown as MouseEvent)
|
||||
})
|
||||
|
||||
const menuIcon = await screen.findByTestId('draggable-menu-icon')
|
||||
expect(menuIcon.closest('.draggable-block-menu')).not.toBeNull()
|
||||
const renderedMenu = screen.getByTestId('draggable-menu')
|
||||
const isOnMenu = draggableMockState.latestProps?.isOnMenu
|
||||
if (!isOnMenu)
|
||||
throw new Error('Expected isOnMenu callback')
|
||||
|
||||
const normalElement = document.createElement('div')
|
||||
document.body.appendChild(normalElement)
|
||||
expect(normalElement.closest('.draggable-block-menu')).toBeNull()
|
||||
normalElement.remove()
|
||||
const menuIcon = screen.getByTestId('draggable-menu-icon')
|
||||
const outsideElement = document.createElement('div')
|
||||
|
||||
expect(isOnMenu(menuIcon)).toBe(true)
|
||||
expect(isOnMenu(renderedMenu)).toBe(true)
|
||||
expect(isOnMenu(outsideElement)).toBe(false)
|
||||
})
|
||||
|
||||
it('should register and cleanup mousemove listener on mount and unmount', () => {
|
||||
const rootMock = createRootElementMock()
|
||||
setupEditorRoot(rootMock.rootElement)
|
||||
const { unmount } = render(<DraggableBlockPlugin />)
|
||||
|
||||
const onMove = getRegisteredMouseMoveHandler(rootMock)
|
||||
expect(rootMock.addEventListener).toHaveBeenCalledWith('mousemove', expect.any(Function))
|
||||
|
||||
unmount()
|
||||
|
||||
expect(rootMock.removeEventListener).toHaveBeenCalledWith('mousemove', onMove)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import type { LexicalCommand } from 'lexical'
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createCommand } from 'lexical'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import ShortcutsPopupPlugin, { SHORTCUTS_EMPTY_CONTENT } from '../index'
|
||||
@ -21,6 +23,9 @@ const mockDOMRect = {
|
||||
toJSON: () => ({}),
|
||||
}
|
||||
|
||||
const originalRangeGetClientRects = Range.prototype.getClientRects
|
||||
const originalRangeGetBoundingClientRect = Range.prototype.getBoundingClientRect
|
||||
|
||||
beforeAll(() => {
|
||||
// Mock getClientRects on Range prototype
|
||||
Range.prototype.getClientRects = vi.fn(() => {
|
||||
@ -34,12 +39,31 @@ beforeAll(() => {
|
||||
Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
Range.prototype.getClientRects = originalRangeGetClientRects
|
||||
Range.prototype.getBoundingClientRect = originalRangeGetBoundingClientRect
|
||||
})
|
||||
|
||||
const CONTAINER_ID = 'host'
|
||||
const CONTENT_EDITABLE_ID = 'ce'
|
||||
|
||||
const MinimalEditor: React.FC<{
|
||||
type MinimalEditorProps = {
|
||||
withContainer?: boolean
|
||||
}> = ({ withContainer = true }) => {
|
||||
hotkey?: string | string[] | string[][] | ((e: KeyboardEvent) => boolean)
|
||||
children?: React.ReactNode | ((close: () => void, onInsert: (command: LexicalCommand<unknown>, params: unknown[]) => void) => React.ReactNode)
|
||||
className?: string
|
||||
onOpen?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const MinimalEditor: React.FC<MinimalEditorProps> = ({
|
||||
withContainer = true,
|
||||
hotkey,
|
||||
children,
|
||||
className,
|
||||
onOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const initialConfig = {
|
||||
namespace: 'shortcuts-popup-plugin-test',
|
||||
onError: (e: Error) => {
|
||||
@ -58,25 +82,35 @@ const MinimalEditor: React.FC<{
|
||||
/>
|
||||
<ShortcutsPopupPlugin
|
||||
container={withContainer ? containerEl : undefined}
|
||||
/>
|
||||
hotkey={hotkey}
|
||||
className={className}
|
||||
onOpen={onOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
{children}
|
||||
</ShortcutsPopupPlugin>
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
)
|
||||
}
|
||||
|
||||
/** Helper: focus the content editable and trigger a hotkey. */
|
||||
function focusAndTriggerHotkey(key: string, modifiers: Partial<Record<'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey', boolean>> = { ctrlKey: true }) {
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
fireEvent.keyDown(document, { key, ...modifiers })
|
||||
}
|
||||
|
||||
describe('ShortcutsPopupPlugin', () => {
|
||||
// ─── Basic open / close ───
|
||||
it('opens on hotkey when editor is focused', async () => {
|
||||
render(<MinimalEditor />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true }) // 模拟 Ctrl+/
|
||||
focusAndTriggerHotkey('/')
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not open when editor is not focused', async () => {
|
||||
render(<MinimalEditor />)
|
||||
// 未聚焦
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
@ -85,10 +119,7 @@ describe('ShortcutsPopupPlugin', () => {
|
||||
|
||||
it('closes on Escape', async () => {
|
||||
render(<MinimalEditor />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
focusAndTriggerHotkey('/')
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
@ -111,24 +142,370 @@ describe('ShortcutsPopupPlugin', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Container / portal ───
|
||||
it('portals into provided container when container is set', async () => {
|
||||
render(<MinimalEditor withContainer />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
const host = screen.getByTestId(CONTAINER_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
focusAndTriggerHotkey('/')
|
||||
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
expect(host).toContainElement(portalContent)
|
||||
})
|
||||
|
||||
it('falls back to document.body when container is not provided', async () => {
|
||||
render(<MinimalEditor withContainer={false} />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
focusAndTriggerHotkey('/')
|
||||
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
expect(document.body).toContainElement(portalContent)
|
||||
})
|
||||
|
||||
// ─── matchHotkey: string hotkey ───
|
||||
it('matches a string hotkey like "mod+/"', async () => {
|
||||
render(<MinimalEditor hotkey="mod+/" />)
|
||||
focusAndTriggerHotkey('/', { metaKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('matches ctrl+/ when hotkey is "mod+/" (mod matches ctrl or meta)', async () => {
|
||||
render(<MinimalEditor hotkey="mod+/" />)
|
||||
focusAndTriggerHotkey('/', { ctrlKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ─── matchHotkey: string[] hotkey ───
|
||||
it('matches when hotkey is a string array like ["mod", "/"]', async () => {
|
||||
render(<MinimalEditor hotkey={['mod', '/']} />)
|
||||
focusAndTriggerHotkey('/', { ctrlKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ─── matchHotkey: string[][] (nested) hotkey ───
|
||||
it('matches when hotkey is a nested array (any combo matches)', async () => {
|
||||
render(<MinimalEditor hotkey={[['ctrl', 'k'], ['meta', 'j']]} />)
|
||||
focusAndTriggerHotkey('k', { ctrlKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('matches the second combo in a nested array', async () => {
|
||||
render(<MinimalEditor hotkey={[['ctrl', 'k'], ['meta', 'j']]} />)
|
||||
focusAndTriggerHotkey('j', { metaKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not match nested array when no combo matches', async () => {
|
||||
render(<MinimalEditor hotkey={[['ctrl', 'k'], ['meta', 'j']]} />)
|
||||
focusAndTriggerHotkey('x', { ctrlKey: true })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── matchHotkey: function hotkey ───
|
||||
it('matches when hotkey is a custom function returning true', async () => {
|
||||
const customMatcher = (e: KeyboardEvent) => e.key === 'F1'
|
||||
render(<MinimalEditor hotkey={customMatcher} />)
|
||||
focusAndTriggerHotkey('F1', {})
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not match when custom function returns false', async () => {
|
||||
const customMatcher = (e: KeyboardEvent) => e.key === 'F1'
|
||||
render(<MinimalEditor hotkey={customMatcher} />)
|
||||
focusAndTriggerHotkey('F2', {})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── matchHotkey: modifier aliases ───
|
||||
it('matches meta/cmd/command aliases', async () => {
|
||||
render(<MinimalEditor hotkey="cmd+k" />)
|
||||
focusAndTriggerHotkey('k', { metaKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('matches "command" alias for meta', async () => {
|
||||
render(<MinimalEditor hotkey="command+k" />)
|
||||
focusAndTriggerHotkey('k', { metaKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not match meta alias when meta is not pressed', async () => {
|
||||
render(<MinimalEditor hotkey="cmd+k" />)
|
||||
focusAndTriggerHotkey('k', {})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('matches alt/option alias', async () => {
|
||||
render(<MinimalEditor hotkey="alt+a" />)
|
||||
focusAndTriggerHotkey('a', { altKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not match alt alias when alt is not pressed', async () => {
|
||||
render(<MinimalEditor hotkey="alt+a" />)
|
||||
focusAndTriggerHotkey('a', {})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('matches shift alias', async () => {
|
||||
render(<MinimalEditor hotkey="shift+s" />)
|
||||
focusAndTriggerHotkey('s', { shiftKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not match shift alias when shift is not pressed', async () => {
|
||||
render(<MinimalEditor hotkey="shift+s" />)
|
||||
focusAndTriggerHotkey('s', {})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('matches ctrl alias', async () => {
|
||||
render(<MinimalEditor hotkey="ctrl+b" />)
|
||||
focusAndTriggerHotkey('b', { ctrlKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not match ctrl alias when ctrl is not pressed', async () => {
|
||||
render(<MinimalEditor hotkey="ctrl+b" />)
|
||||
focusAndTriggerHotkey('b', {})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── matchHotkey: space key normalization ───
|
||||
it('normalizes space key to "space" for matching', async () => {
|
||||
render(<MinimalEditor hotkey="ctrl+space" />)
|
||||
focusAndTriggerHotkey(' ', { ctrlKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ─── matchHotkey: key mismatch ───
|
||||
it('does not match when expected key does not match pressed key', async () => {
|
||||
render(<MinimalEditor hotkey="ctrl+z" />)
|
||||
focusAndTriggerHotkey('x', { ctrlKey: true })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Children rendering ───
|
||||
it('renders children as ReactNode when provided', async () => {
|
||||
render(
|
||||
<MinimalEditor>
|
||||
<div data-testid="custom-content">My Content</div>
|
||||
</MinimalEditor>,
|
||||
)
|
||||
focusAndTriggerHotkey('/')
|
||||
expect(await screen.findByTestId('custom-content')).toBeInTheDocument()
|
||||
expect(screen.getByText('My Content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders children as render function and provides close/onInsert', async () => {
|
||||
const TEST_COMMAND = createCommand<unknown>('TEST_COMMAND')
|
||||
const childrenFn = vi.fn((close: () => void, onInsert: (cmd: LexicalCommand<unknown>, params: unknown[]) => void) => (
|
||||
<div>
|
||||
<button type="button" data-testid="close-btn" onClick={close}>Close</button>
|
||||
<button type="button" data-testid="insert-btn" onClick={() => onInsert(TEST_COMMAND, ['param1'])}>Insert</button>
|
||||
</div>
|
||||
))
|
||||
|
||||
render(
|
||||
<MinimalEditor>
|
||||
{childrenFn}
|
||||
</MinimalEditor>,
|
||||
)
|
||||
focusAndTriggerHotkey('/')
|
||||
|
||||
// Children render function should have been called
|
||||
expect(await screen.findByTestId('close-btn')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('insert-btn')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders SHORTCUTS_EMPTY_CONTENT when children is undefined', async () => {
|
||||
render(<MinimalEditor />)
|
||||
focusAndTriggerHotkey('/')
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ─── handleInsert callback ───
|
||||
it('calls close after insert via children render function', async () => {
|
||||
const TEST_COMMAND = createCommand<unknown>('TEST_INSERT_COMMAND')
|
||||
render(
|
||||
<MinimalEditor>
|
||||
{(close: () => void, onInsert: (cmd: LexicalCommand<unknown>, params: unknown[]) => void) => (
|
||||
<div>
|
||||
<button type="button" data-testid="insert-btn" onClick={() => onInsert(TEST_COMMAND, ['value'])}>Insert</button>
|
||||
</div>
|
||||
)}
|
||||
</MinimalEditor>,
|
||||
)
|
||||
focusAndTriggerHotkey('/')
|
||||
|
||||
const insertBtn = await screen.findByTestId('insert-btn')
|
||||
fireEvent.click(insertBtn)
|
||||
|
||||
// After insert, the popup should close
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('insert-btn')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('calls close via children render function close callback', async () => {
|
||||
render(
|
||||
<MinimalEditor>
|
||||
{(close: () => void) => (
|
||||
<button type="button" data-testid="close-via-fn" onClick={close}>Close</button>
|
||||
)}
|
||||
</MinimalEditor>,
|
||||
)
|
||||
focusAndTriggerHotkey('/')
|
||||
|
||||
const closeBtn = await screen.findByTestId('close-via-fn')
|
||||
fireEvent.click(closeBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('close-via-fn')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── onOpen / onClose callbacks ───
|
||||
it('calls onOpen when popup opens', async () => {
|
||||
const onOpen = vi.fn()
|
||||
render(<MinimalEditor onOpen={onOpen} />)
|
||||
focusAndTriggerHotkey('/')
|
||||
await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
expect(onOpen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onClose when popup closes', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(<MinimalEditor onClose={onClose} />)
|
||||
focusAndTriggerHotkey('/')
|
||||
await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── className prop ───
|
||||
it('applies custom className to floating popup', async () => {
|
||||
render(<MinimalEditor className="custom-popup-class" />)
|
||||
focusAndTriggerHotkey('/')
|
||||
const content = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
const floatingDiv = content.closest('div')
|
||||
expect(floatingDiv).toHaveClass('custom-popup-class')
|
||||
})
|
||||
|
||||
// ─── mousedown inside portal should not close ───
|
||||
it('does not close on mousedown inside the portal', async () => {
|
||||
render(
|
||||
<MinimalEditor>
|
||||
<div data-testid="portal-inner">Inner content</div>
|
||||
</MinimalEditor>,
|
||||
)
|
||||
focusAndTriggerHotkey('/')
|
||||
|
||||
const inner = await screen.findByTestId('portal-inner')
|
||||
fireEvent.mouseDown(inner)
|
||||
|
||||
// Should still be open
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('portal-inner')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('prevents default and stops propagation on Escape when open', async () => {
|
||||
render(<MinimalEditor />)
|
||||
focusAndTriggerHotkey('/')
|
||||
await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
|
||||
const preventDefaultSpy = vi.fn()
|
||||
const stopPropagationSpy = vi.fn()
|
||||
|
||||
// Use a custom event to capture preventDefault/stopPropagation calls
|
||||
const escEvent = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
||||
Object.defineProperty(escEvent, 'preventDefault', { value: preventDefaultSpy })
|
||||
Object.defineProperty(escEvent, 'stopPropagation', { value: stopPropagationSpy })
|
||||
document.dispatchEvent(escEvent)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
expect(preventDefaultSpy).toHaveBeenCalledTimes(1)
|
||||
expect(stopPropagationSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// ─── Zero-rect fallback in openPortal ───
|
||||
it('handles zero-size range rects by falling back to node bounding rect', async () => {
|
||||
// Temporarily override getClientRects to return zero-size rect
|
||||
const zeroRect = { x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0, toJSON: () => ({}) }
|
||||
const originalGetClientRects = Range.prototype.getClientRects
|
||||
const originalGetBoundingClientRect = Range.prototype.getBoundingClientRect
|
||||
|
||||
Range.prototype.getClientRects = vi.fn(() => {
|
||||
const rectList = [zeroRect] as unknown as DOMRectList
|
||||
Object.defineProperty(rectList, 'length', { value: 1 })
|
||||
Object.defineProperty(rectList, 'item', { value: () => zeroRect })
|
||||
return rectList
|
||||
})
|
||||
Range.prototype.getBoundingClientRect = vi.fn(() => zeroRect as DOMRect)
|
||||
|
||||
render(<MinimalEditor />)
|
||||
focusAndTriggerHotkey('/')
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
|
||||
// Restore
|
||||
Range.prototype.getClientRects = originalGetClientRects
|
||||
Range.prototype.getBoundingClientRect = originalGetBoundingClientRect
|
||||
})
|
||||
|
||||
it('handles empty getClientRects by using getBoundingClientRect fallback', async () => {
|
||||
const originalGetClientRects = Range.prototype.getClientRects
|
||||
const originalGetBoundingClientRect = Range.prototype.getBoundingClientRect
|
||||
|
||||
Range.prototype.getClientRects = vi.fn(() => {
|
||||
const rectList = [] as unknown as DOMRectList
|
||||
Object.defineProperty(rectList, 'length', { value: 0 })
|
||||
Object.defineProperty(rectList, 'item', { value: () => null })
|
||||
return rectList
|
||||
})
|
||||
Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect)
|
||||
|
||||
render(<MinimalEditor />)
|
||||
focusAndTriggerHotkey('/')
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
|
||||
Range.prototype.getClientRects = originalGetClientRects
|
||||
Range.prototype.getBoundingClientRect = originalGetBoundingClientRect
|
||||
})
|
||||
|
||||
// ─── Combined modifier hotkeys ───
|
||||
it('matches hotkey with multiple modifiers: ctrl+shift+k', async () => {
|
||||
render(<MinimalEditor hotkey="ctrl+shift+k" />)
|
||||
focusAndTriggerHotkey('k', { ctrlKey: true, shiftKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('matches "option" alias for alt', async () => {
|
||||
render(<MinimalEditor hotkey="option+o" />)
|
||||
focusAndTriggerHotkey('o', { altKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not match mod hotkey when neither ctrl nor meta is pressed', async () => {
|
||||
render(<MinimalEditor hotkey="mod+k" />)
|
||||
focusAndTriggerHotkey('k', {})
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user