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:
Poojan
2026-02-25 15:06:58 +05:30
committed by GitHub
parent 3c69bac2b1
commit 0ac09127c7
30 changed files with 3811 additions and 30 deletions

View File

@ -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',
)
})
})
})

View File

@ -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()
})
})

View File

@ -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)
})
})

View File

@ -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)
})
})
})