diff --git a/packages/x/components/sender/__tests__/index.test.tsx b/packages/x/components/sender/__tests__/index.test.tsx index dbc8ca60..1ded29c9 100644 --- a/packages/x/components/sender/__tests__/index.test.tsx +++ b/packages/x/components/sender/__tests__/index.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { fireEvent, render } from '@testing-library/react'; import mountTest from '../../../tests/shared/mountTest'; import rtlTest from '../../../tests/shared/rtlTest'; +import { act } from '../../../tests/utils'; import Sender from '../index'; describe('Sender Component', () => { @@ -10,14 +11,6 @@ describe('Sender Component', () => { rtlTest(() => ); - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - it('loading state', () => { const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); @@ -98,13 +91,11 @@ describe('Sender Component', () => { describe('submitType', () => { it('default', () => { const onSubmit = jest.fn(); - const onPressKey = jest.fn(); - const { container } = render( - , - ); - fireEvent.keyDown(container.querySelector('textarea')!, { key: 'Enter', shiftKey: false }); + const { container } = render(); + act(() => { + fireEvent.keyUp(container.querySelector('textarea')!, { key: 'Enter', shiftKey: false }); + }); expect(onSubmit).toHaveBeenCalledWith('bamboo'); - expect(onPressKey).toHaveBeenCalled(); }); it('shiftEnter', () => { @@ -112,7 +103,9 @@ describe('Sender Component', () => { const { container } = render( , ); - fireEvent.keyDown(container.querySelector('textarea')!, { key: 'Enter', shiftKey: true }); + act(() => { + fireEvent.keyUp(container.querySelector('textarea')!, { key: 'Enter', shiftKey: true }); + }); expect(onSubmit).toHaveBeenCalledWith('bamboo'); }); }); @@ -260,4 +253,540 @@ describe('Sender Component', () => { expect(onPasteFile).toHaveBeenCalledWith(file1, fileList); }); }); -}); +describe('Props validation and edge cases', () => { + it('should handle undefined value prop', () => { + const { container } = render(); + const textarea = container.querySelector('textarea')!; + expect(textarea.value).toBe(''); + }); + + it('should handle null value prop', () => { + const { container } = render(); + const textarea = container.querySelector('textarea')!; + expect(textarea.value).toBe(''); + }); + + it('should handle empty string value', () => { + const { container } = render(); + const textarea = container.querySelector('textarea')!; + expect(textarea.value).toBe(''); + }); + + it('should handle very long text input', () => { + const longText = 'a'.repeat(10000); + const { container } = render(); + const textarea = container.querySelector('textarea')!; + expect(textarea.value).toBe(longText); + }); + + it('should handle special characters and unicode', () => { + const specialText = '你好世界 🌍 ñáéíóú <>&"\''; + const { container } = render(); + const textarea = container.querySelector('textarea')!; + expect(textarea.value).toBe(specialText); + }); + + it('should handle multiline text with line breaks', () => { + const multilineText = 'Line 1\nLine 2\r\nLine 3\n\nLine 5'; + const { container } = render(); + const textarea = container.querySelector('textarea')!; + expect(textarea.value).toBe(multilineText); + }); + + it('should handle placeholder prop', () => { + const placeholderText = 'Enter your message here...'; + const { container } = render(); + const textarea = container.querySelector('textarea')!; + expect(textarea).toHaveAttribute('placeholder', placeholderText); + }); + + it('should handle maxLength prop if supported', () => { + const { container } = render(); + const textarea = container.querySelector('textarea')!; + if (textarea.hasAttribute('maxlength')) { + expect(textarea).toHaveAttribute('maxlength', '100'); + } + }); + }); + + describe('Accessibility and ARIA attributes', () => { + it('should have proper focus management', () => { + const { container } = render(); + const textarea = container.querySelector('textarea')!; + + textarea.focus(); + expect(document.activeElement).toBe(textarea); + }); + + it('should handle disabled state with proper attributes', () => { + const { container } = render(); + const textarea = container.querySelector('textarea')!; + const button = container.querySelector('button')!; + expect(textarea).toBeDisabled(); + expect(button).toBeDisabled(); + }); + + it('should support keyboard navigation between header and content', () => { + const { container } = render( + + + + } + /> + ); + + const headerInput = container.querySelector('[data-testid="header-input"]') as HTMLElement; + const textarea = container.querySelector('textarea') as HTMLElement; + + headerInput.focus(); + expect(document.activeElement).toBe(headerInput); + + // Click on content should focus textarea + fireEvent.mouseDown(container.querySelector('.ant-sender-content')!); + expect(document.activeElement).toBe(textarea); + }); + + it('should maintain focus during loading state changes', () => { + const { container, rerender } = render(); + const textarea = container.querySelector('textarea')!; + + textarea.focus(); + expect(document.activeElement).toBe(textarea); + + rerender(); + // Focus should be maintained on the textarea even during loading state + expect(container.querySelector('textarea')).toBeInTheDocument(); + }); + }); + + describe('Performance and optimization', () => { + it('should not re-render unnecessarily with same props', () => { + const renderSpy = jest.fn(); + const TestWrapper = ({ value }: { value: string }) => { + renderSpy(); + return ; + }; + + const { rerender } = render(); + expect(renderSpy).toHaveBeenCalledTimes(1); + + rerender(); + expect(renderSpy).toHaveBeenCalledTimes(2); // React will re-render but component should handle it efficiently + + rerender(); + expect(renderSpy).toHaveBeenCalledTimes(3); + }); + + it('should handle rapid state changes efficiently', () => { + const onChange = jest.fn(); + const { container } = render(); + const textarea = container.querySelector('textarea')!; + + // Simulate rapid typing + act(() => { + for (let i = 0; i < 10; i++) { + fireEvent.change(textarea, { target: { value: `text${i}` } }); + } + }); + + expect(onChange).toHaveBeenCalledTimes(10); + expect(onChange).toHaveBeenLastCalledWith('text9', {}); + }); + }); + + describe('Error handling and edge cases', () => { + it('should handle onSubmit callback errors gracefully', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + const onSubmit = jest.fn(() => { + throw new Error('Submit failed'); + }); + + const { container } = render(); + + // Component should not crash when callback throws + expect(() => { + fireEvent.click(container.querySelector('button')!); + }).not.toThrow(); + + consoleError.mockRestore(); + }); + + it('should handle onChange callback errors gracefully', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + const onChange = jest.fn(() => { + throw new Error('Change failed'); + }); + + const { container } = render(); + + expect(() => { + fireEvent.change(container.querySelector('textarea')!, { target: { value: 'test' } }); + }).not.toThrow(); + + consoleError.mockRestore(); + }); + + it('should handle malformed clipboard data', () => { + const onPaste = jest.fn(); + const onPasteFile = jest.fn(); + const { container } = render(); + + const textarea = container.querySelector('textarea')!; + + // Test with malformed clipboard data + expect(() => { + fireEvent.paste(textarea, { + clipboardData: { + files: null, + getData: () => { throw new Error('getData failed'); } + }, + }); + }).not.toThrow(); + + // Component should still be functional + expect(container.querySelector('textarea')).toBeInTheDocument(); + }); + + it('should handle component unmounting gracefully', () => { + const { container, unmount } = render(); + + expect(container.querySelector('textarea')).toBeInTheDocument(); + + expect(() => { + unmount(); + }).not.toThrow(); + }); + }); + + describe('Component composition and flexibility', () => { + it('should render custom header and footer together', () => { + const { container } = render( +
Header
} + footer={
Footer
} + /> + ); + + expect(container.querySelector('[data-testid="custom-header"]')).toBeInTheDocument(); + expect(container.querySelector('[data-testid="custom-footer"]')).toBeInTheDocument(); + }); + + it('should handle complex nested actions', () => { + const onAction1 = jest.fn(); + const onAction2 = jest.fn(); + + const { getByText } = render( + ( +
+ Action 1 +
+ Action 2 +
+
+ )} + /> + ); + + fireEvent.click(getByText('Action 1')); + fireEvent.click(getByText('Action 2')); + + expect(onAction1).toHaveBeenCalled(); + expect(onAction2).toHaveBeenCalled(); + }); + + it('should maintain textarea focus during dynamic content changes', () => { + const { container, rerender } = render(); + const textarea = container.querySelector('textarea')!; + + textarea.focus(); + expect(document.activeElement).toBe(textarea); + + rerender(); + // Focus should be maintained after props update + expect(container.querySelector('textarea')).toBeInTheDocument(); + }); + + it('should handle actions as both function and ReactNode', () => { + const { container, rerender } = render( + Static Actions} /> + ); + + expect(container.querySelector('[data-testid="react-node-action"]')).toBeInTheDocument(); + + rerender( + ( +
+ Dynamic Send +
+ )} + /> + ); + + expect(container.querySelector('[data-testid="function-action"]')).toBeInTheDocument(); + }); + }); + + describe('State management and lifecycle', () => { + it('should handle controlled vs uncontrolled state properly', () => { + const onChange = jest.fn(); + const { container, rerender } = render(); + const textarea = container.querySelector('textarea')!; + + // Uncontrolled initially + fireEvent.change(textarea, { target: { value: 'uncontrolled' } }); + expect(onChange).toHaveBeenCalledWith('uncontrolled', {}); + + // Switch to controlled + rerender(); + expect(textarea.value).toBe('controlled'); + }); + + it('should handle defaultValue with subsequent changes', () => { + const onChange = jest.fn(); + const { container } = render(); + const textarea = container.querySelector('textarea')!; + + expect(textarea.value).toBe('initial'); + + fireEvent.change(textarea, { target: { value: 'changed' } }); + expect(onChange).toHaveBeenCalledWith('changed', {}); + }); + + it('should reset state when clear is called multiple times', () => { + const onChange = jest.fn(); + const { container } = render( + } + /> + ); + + const clearButton = container.querySelector('button')!; + + fireEvent.click(clearButton); + expect(onChange).toHaveBeenCalledWith('', undefined); + + // Clear again should still work + fireEvent.click(clearButton); + expect(onChange).toHaveBeenCalledTimes(2); + }); + + it('should handle rapid prop changes without errors', () => { + const { rerender } = render(); + + expect(() => { + for (let i = 0; i < 50; i++) { + rerender(); + } + }).not.toThrow(); + }); + }); + + describe('Keyboard shortcuts and interactions', () => { + it('should handle Enter key with different submitType configurations', () => { + const onSubmit = jest.fn(); + + // Test default behavior (Enter submits) + const { container, rerender } = render( + + ); + const textarea = container.querySelector('textarea')!; + + act(() => { + fireEvent.keyUp(textarea, { key: 'Enter', shiftKey: false }); + }); + expect(onSubmit).toHaveBeenCalledWith('test'); + + // Test shiftEnter configuration + onSubmit.mockClear(); + rerender(); + + act(() => { + fireEvent.keyUp(textarea, { key: 'Enter', shiftKey: false }); + }); + expect(onSubmit).not.toHaveBeenCalled(); + + act(() => { + fireEvent.keyUp(textarea, { key: 'Enter', shiftKey: true }); + }); + expect(onSubmit).toHaveBeenCalledWith('test'); + }); + + it('should handle Escape key behavior', () => { + const { container } = render(); + const textarea = container.querySelector('textarea')!; + + // Component should not crash when Escape is pressed + expect(() => { + fireEvent.keyDown(textarea, { key: 'Escape' }); + }).not.toThrow(); + + expect(container.querySelector('textarea')).toBeInTheDocument(); + }); + + it('should handle Tab key navigation', () => { + const { container } = render( + } + footer={({ components }) => ( +
+ Send +
+ )} + /> + ); + + const headerInput = container.querySelector('[data-testid="header-input"]') as HTMLElement; + const textarea = container.querySelector('textarea') as HTMLElement; + + // Tab navigation should work properly + headerInput.focus(); + expect(document.activeElement).toBe(headerInput); + + fireEvent.keyDown(headerInput, { key: 'Tab' }); + // Component should handle tab navigation without crashing + expect(container.querySelector('textarea')).toBeInTheDocument(); + }); + }); + + describe('Integration scenarios', () => { + it('should handle complete user workflow: type, edit, submit', () => { + const onSubmit = jest.fn(); + const onChange = jest.fn(); + + const { container } = render(); + const textarea = container.querySelector('textarea')!; + const submitButton = container.querySelector('button')!; + + // Type initial message + fireEvent.change(textarea, { target: { value: 'Hello' } }); + expect(onChange).toHaveBeenCalledWith('Hello', {}); + + // Edit message + fireEvent.change(textarea, { target: { value: 'Hello World' } }); + expect(onChange).toHaveBeenCalledWith('Hello World', {}); + + // Submit + fireEvent.click(submitButton); + expect(onSubmit).toHaveBeenCalledWith('Hello World'); + }); + + it('should handle loading state transitions properly', () => { + const onSubmit = jest.fn(); + const onCancel = jest.fn(); + + const { container, rerender } = render( + + ); + + // Initial state - should have submit button + expect(container.querySelector('button')).toBeInTheDocument(); + + // Switch to loading state + rerender(); + + // Should now have cancel functionality + const button = container.querySelector('button')!; + fireEvent.click(button); + expect(onCancel).toHaveBeenCalled(); + }); + + it('should handle complex paste scenarios with mixed content', () => { + const onPaste = jest.fn(); + const onPasteFile = jest.fn(); + + const { container } = render( + + ); + + const textarea = container.querySelector('textarea')!; + const file = new File(['content'], 'test.txt', { type: 'text/plain' }); + + fireEvent.paste(textarea, { + clipboardData: { + files: { 0: file, length: 1, item: () => file }, + getData: () => 'pasted text', + }, + }); + + expect(onPaste).toHaveBeenCalled(); + expect(onPasteFile).toHaveBeenCalledWith(file, expect.any(Object)); + }); + + it('should maintain consistent state across prop changes', () => { + const onChange = jest.fn(); + const { container, rerender } = render( + + ); + + const textarea = container.querySelector('textarea')!; + expect(textarea.value).toBe('initial'); + expect(textarea).not.toBeDisabled(); + + // Change props + rerender(); + + expect(textarea.value).toBe('updated'); + expect(textarea).toBeDisabled(); + }); + }); + + describe('Snapshot tests for component states', () => { + it('should match snapshot with minimal props', () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + it('should match snapshot in disabled state', () => { + const { asFragment } = render( + ( +
+ Send + Clear +
+ )} + /> + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it('should match snapshot with complex header and footer', () => { + const { asFragment } = render( +
Complex Header
} + footer={({ components }) => ( +
+ Send Now + Reset +
+ )} + /> + ); + expect(asFragment()).toMatchSnapshot(); + }); + + it('should match snapshot with all boolean props enabled', () => { + const { asFragment } = render( + + ); + expect(asFragment()).toMatchSnapshot(); + }); + }); + +}); \ No newline at end of file diff --git a/packages/x/components/sender/__tests__/slot.test.tsx b/packages/x/components/sender/__tests__/slot.test.tsx new file mode 100644 index 00000000..14e3cfa7 --- /dev/null +++ b/packages/x/components/sender/__tests__/slot.test.tsx @@ -0,0 +1,830 @@ +import { fireEvent, render } from '@testing-library/react'; +import React from 'react'; +import { act } from '../../../tests/utils'; +import SlotTextArea from '../SlotTextArea'; +import Sender, { SlotConfigType } from '../index'; + +describe('Sender.SlotTextArea', () => { + const slotConfig: SlotConfigType[] = [ + { type: 'text', text: '前缀文本' }, + { type: 'input', key: 'input1', props: { placeholder: '请输入内容', defaultValue: '默认值' } }, + { type: 'select', key: 'select1', props: { options: ['A', 'B'], placeholder: '请选择' } }, + { type: 'tag', key: 'tag1', props: { label: '标签' } }, + { + type: 'custom', + key: 'custom1', + customRender: (value: any, onChange: (value: any) => void) => ( + + ), + formatResult: (v: any) => `[${v}]`, + }, + ]; + + it('渲染 slotConfig', () => { + const { container, getByPlaceholderText, getByText, getByTestId } = render( + , + ); + expect(container.textContent).toContain('前缀文本'); + expect(getByPlaceholderText('请输入内容')).toBeInTheDocument(); + expect(getByText('请选择')).toBeInTheDocument(); + expect(getByText('标签')).toBeInTheDocument(); + expect(getByTestId('custom-btn')).toBeInTheDocument(); + }); + + it('input slot 输入', () => { + const onChange = jest.fn(); + const { getByPlaceholderText } = render(); + const input = getByPlaceholderText('请输入内容') as HTMLInputElement; + fireEvent.change(input, { target: { value: '新内容' } }); + const calls = onChange.mock.calls; + expect(calls[calls.length - 1][0]).toContain('新内容'); + }); + + it('select slot 选择', () => { + const onChange = jest.fn(); + const { container, getByText } = render(); + // 触发下拉 + fireEvent.click(container.querySelector('.ant-sender-slot-select-selector-value')!); + fireEvent.click(getByText('A')); + expect(onChange).toHaveBeenCalledWith( + expect.stringContaining('A'), + undefined, + expect.any(Array), + ); + }); + + it('custom slot 交互', () => { + const onChange = jest.fn(); + const { getByTestId } = render(); + fireEvent.click(getByTestId('custom-btn')); + expect(onChange).toHaveBeenCalledWith( + expect.stringContaining('custom-value'), + undefined, + expect.any(Array), + ); + }); + + it('onSubmit 触发', () => { + const onSubmit = jest.fn(); + const { container } = render(); + // 回车提交 + act(() => { + fireEvent.keyDown(container.querySelector('[role="textbox"]')!, { key: 'Enter' }); + }); + expect(onSubmit).toHaveBeenCalledWith(expect.any(String), expect.any(Array)); + }); + + describe('submitType SlotTextArea', () => { + it('default', () => { + const onSubmit = jest.fn(); + const { container } = render( + , + ); + act(() => { + fireEvent.keyDown(container.querySelector('[role="textbox"]')!, { + key: 'Enter', + shiftKey: false, + }); + }); + expect(onSubmit).toHaveBeenCalledWith(expect.any(String), expect.any(Array)); + }); + + it('shiftEnter', () => { + const onSubmit = jest.fn(); + const { container } = render( + , + ); + act(() => { + fireEvent.keyDown(container.querySelector('[role="textbox"]')!, { + key: 'Enter', + shiftKey: true, + }); + }); + expect(onSubmit).toHaveBeenCalledWith(expect.any(String), expect.any(Array)); + }); + }); + + it('ref 方法 getValue/insert/clear', () => { + const ref = React.createRef(); + render(); + // insert + ref.current.insert('插入文本'); + expect(ref.current.getValue().value).toContain('插入文本'); + // clear + ref.current.clear(); + expect(ref.current.getValue().value).toBe(''); + }); + + it('onPaste 粘贴文本', () => { + const onChange = jest.fn(); + const { container } = render(); + const editable = container.querySelector('.ant-sender-input')!; + fireEvent.paste(editable, { + clipboardData: { + getData: () => '粘贴内容', + files: { length: 0 }, + }, + }); + // 粘贴文本后内容应变化 + expect(onChange).toHaveBeenCalled(); + }); + + it('onPaste 粘贴文件', () => { + const onPasteFile = jest.fn(); + const { container } = render(); + const editable = container.querySelector('.ant-sender-input')!; + const file = new File(['test'], 'test.txt', { type: 'text/plain' }); + fireEvent.paste(editable, { + clipboardData: { + files: { 0: file, length: 1, item: () => file }, + getData: () => '', + }, + }); + expect(onPasteFile).toHaveBeenCalledWith(file, expect.anything()); + }); + + it('onPaste clipboardData 为空', () => { + const onPaste = jest.fn(); + const { container } = render(); + const editable = container.querySelector('.ant-sender-input')!; + fireEvent.paste(editable, {}); + expect(onPaste).toHaveBeenCalled(); + }); + + it('onFocus/onBlur 事件', () => { + const onFocus = jest.fn(); + const onBlur = jest.fn(); + const { container } = render( + , + ); + const editable = container.querySelector('.ant-sender-input')!; + fireEvent.focus(editable); + fireEvent.blur(editable); + expect(onFocus).toHaveBeenCalled(); + expect(onBlur).toHaveBeenCalled(); + }); + + it('组合输入事件不触发提交', () => { + const onSubmit = jest.fn(); + const { container } = render(); + const editable = container.querySelector('.ant-sender-input')!; + fireEvent.compositionStart(editable); + fireEvent.keyUp(editable, { key: 'Enter' }); + fireEvent.compositionEnd(editable); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('ref focus/blur 方法', () => { + const ref = React.createRef(); + render(); + expect(typeof ref.current.focus).toBe('function'); + expect(typeof ref.current.blur).toBe('function'); + // focus({cursor}) + ref.current.focus({ cursor: 'start' }); + ref.current.focus({ cursor: 'end' }); + ref.current.focus({ cursor: 'all' }); + ref.current.blur(); + }); + + it('slotConfig 为空时为纯文本编辑', () => { + const onChange = jest.fn(); + const { container } = render(); + const editable = container.querySelector('.ant-sender-input')!; + fireEvent.input(editable, { target: { textContent: '纯文本' } }); + expect(onChange).toHaveBeenCalled(); + }); + + it('slotConfig 变化时内容重置', () => { + const { rerender, container } = render(); + expect(container.textContent).toContain('A'); + rerender(); + expect(container.textContent).toContain('B'); + }); + + it('slotConfig 为 undefined 时初始化', () => { + // 直接渲染 SlotTextArea + expect(() => { + render(); + }).not.toThrow(); + }); + + it('slotConfig 有 type 但无 key 时 renderSlot 返回 null', () => { + const { container } = render( + , + ); + // 不显示任何内容 + expect(container.textContent).toBe(''); + }); + + it('ref focus() 不传参数时 editor?.focus() 分支', () => { + const ref = React.createRef(); + render(); + // 只调用不传参数 + ref.current.focus(); + // 能正常调用即可 + expect(typeof ref.current.focus).toBe('function'); + }); + + it('测试 input slot', () => { + const ref = React.createRef(); + const slotConfig: SlotConfigType[] = [ + { + type: 'input', + key: 'input1', + props: { placeholder: '请输入内容', defaultValue: '默认值' }, + }, + ]; + const { getByPlaceholderText } = render(); + // 初始值 + expect(ref.current.getValue().value).toContain('默认值'); + // 输入新内容 + fireEvent.change(getByPlaceholderText('请输入内容'), { target: { value: '新内容' } }); + expect(ref.current.getValue().value).toContain('新内容'); + }); + + it('测试 select slot', () => { + const ref = React.createRef(); + const slotConfig: SlotConfigType[] = [ + { type: 'select', key: 'select1', props: { options: ['A', 'B'], placeholder: '请选择' } }, + ]; + const { container, getByText } = render(); + // 选择 A + fireEvent.click(container.querySelector('.ant-sender-slot-select-selector-value')!); + fireEvent.click(getByText('A')); + expect(ref.current.getValue().value).toContain('A'); + }); + + it('测试 tag slot', () => { + const ref = React.createRef(); + const slotConfig: SlotConfigType[] = [ + { type: 'tag', key: 'tag1', props: { label: '标签', value: '值' } }, + ]; + render(); + // tag slot 只显示 label + expect(ref.current.getValue().value).toContain('值'); + }); + + it('测试 custom slot', () => { + const ref = React.createRef(); + const slotConfig: SlotConfigType[] = [ + { + type: 'custom', + key: 'custom1', + customRender: (value: any, onChange: (value: any) => void) => ( + + ), + formatResult: (v: any) => `[${v}]`, + }, + ]; + const { getByTestId } = render(); + // 初始值 + expect(ref.current.getValue().value).toBe('[]'); + // 交互 + fireEvent.click(getByTestId('custom-btn')); + expect(ref.current.getValue().value).toBe('[custom-value]'); + }); + + it('语音输入 slotConfig 存在时 insert 被调用', () => { + const ref = React.createRef(); + const slotConfig = [ + { type: 'input' as const, key: 'input1', props: { placeholder: '请输入内容' } }, + ]; + render(); + ref.current.insert('语音内容'); + expect(ref.current.getValue().value).toContain('语音内容'); + }); + + it('语音输入 slotConfig 不存在时 triggerValueChange 被调用', () => { + const onChange = jest.fn(); + const { container } = render(); + const textarea = container.querySelector('textarea')!; + fireEvent.change(textarea, { target: { value: '语音内容' } }); + const call = onChange.mock.calls[0]; + expect(call[0]).toBe('语音内容'); + }); + + it('loading=true 时点击发送按钮不会触发 onSubmit', () => { + const onSubmit = jest.fn(); + const { container } = render(); + fireEvent.click(container.querySelector('button')!); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('value 为空时点击发送按钮不会触发 onSubmit', () => { + const onSubmit = jest.fn(); + const { container } = render(); + fireEvent.click(container.querySelector('button')!); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('slotConfig 存在时点击清除按钮会调用 clear', () => { + const ref = React.createRef(); + const slotConfig = [ + { type: 'input' as const, key: 'input1', props: { placeholder: '请输入内容' } }, + ]; + render(); + ref.current.insert('内容'); + ref.current.clear(); + expect(ref.current.getValue().value).toBe(''); + }); + + it('slotConfig 不存在时点击清除按钮只清空 value', () => { + const ref = React.createRef(); + render(); + ref.current.insert('内容'); + ref.current.clear(); + expect(ref.current.getValue().value).toBe(''); + }); + + it('allowSpeech 渲染语音按钮', () => { + const { container } = render(); + expect(container.querySelector('.anticon-audio-muted,.anticon-audio')).toBeTruthy(); + }); + + it('loading 渲染 LoadingButton', () => { + const { container } = render(); + // 断言存在 loading icon + expect(container.querySelector('[class*="loading-icon"]')).toBeTruthy(); + }); + + it('loading=false 渲染 SendButton', () => { + const { container } = render(); + expect(container.querySelector('button')).toBeTruthy(); + }); + + it('actions 为函数时自定义渲染', () => { + const { getByText } = render(
自定义按钮
} />); + expect(getByText('自定义按钮')).toBeInTheDocument(); + }); + + it('actions 为 ReactNode 时渲染', () => { + const { getByText } = render(节点按钮} />); + expect(getByText('节点按钮')).toBeInTheDocument(); + }); + + it('actions 为 false 时不渲染', () => { + const { container } = render(); + expect(container.querySelector('.ant-sender-actions-list')).toBeNull(); + }); + + it('slotConfig 不存在时点击内容区非输入框阻止默认并 focus', () => { + const { container } = render(); + const content = container.querySelector('.ant-sender-content')!; + const event = new window.MouseEvent('mousedown', { bubbles: true, cancelable: true }); + content.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + }); + + it('slotConfig 存在时点击内容区不阻止默认', () => { + const slotConfig = [ + { type: 'input' as const, key: 'input1', props: { placeholder: '请输入内容' } }, + ]; + const { container } = render(); + const content = container.querySelector('.ant-sender-content')!; + const preventDefault = jest.fn(); + fireEvent.mouseDown(content, { target: content, preventDefault }); + // slotConfig 存在时不阻止默认 + expect(preventDefault).not.toHaveBeenCalled(); + }); +describe('Error Handling and Edge Cases', () => { + it('handles malformed slotConfig gracefully', () => { + const malformedConfig = [ + { type: 'invalid' as any, key: 'invalid1' }, + { type: 'input', key: '', props: {} }, // empty key + { type: 'select', key: 'select1' }, // missing props + null as any, + undefined as any, + ]; + expect(() => { + render(); + }).not.toThrow(); + }); + + it('handles custom render function throwing errors', () => { + const errorConfig: SlotConfigType[] = [ + { + type: 'custom', + key: 'error1', + customRender: () => { + throw new Error('Custom render error'); + }, + }, + ]; + expect(() => { + render(); + }).not.toThrow(); + }); + + it('handles formatResult function throwing errors', () => { + const errorConfig: SlotConfigType[] = [ + { + type: 'custom', + key: 'format-error', + customRender: (value: any, onChange: (value: any) => void) => ( + + ), + formatResult: () => { + throw new Error('Format error'); + }, + }, + ]; + const { container } = render(); + fireEvent.click(container.querySelector('button')!); + }); + + it('handles extremely large slotConfig arrays', () => { + const largeConfig = Array.from({ length: 100 }, (_, i) => ({ + type: 'text' as const, + text: `Item ${i}`, + })); + expect(() => { + render(); + }).not.toThrow(); + }); + + it('handles circular reference in custom slot values', () => { + const circularObj: any = { name: 'test' }; + circularObj.self = circularObj; + + const config: SlotConfigType[] = [ + { + type: 'custom', + key: 'circular', + customRender: (value: any, onChange: (value: any) => void) => ( + + ), + }, + ]; + const { container } = render(); + expect(() => { + fireEvent.click(container.querySelector('button')!); + }).not.toThrow(); + }); + }); + + describe('Accessibility Tests', () => { + it('has proper ARIA attributes', () => { + const { container } = render(); + const textbox = container.querySelector('[role="textbox"]'); + expect(textbox).toBeInTheDocument(); + }); + + it('supports keyboard navigation', () => { + const onSubmit = jest.fn(); + const { container } = render(); + const textbox = container.querySelector('[role="textbox"]')!; + + // Test tab navigation + fireEvent.keyDown(textbox, { key: 'Tab' }); + fireEvent.keyDown(textbox, { key: 'Tab', shiftKey: true }); + + // Test escape key + fireEvent.keyDown(textbox, { key: 'Escape' }); + }); + + it('has proper focus management', () => { + const ref = React.createRef(); + const { container } = render(); + + ref.current.focus(); + expect(document.activeElement).toBe(container.querySelector('.ant-sender-input')); + + ref.current.blur(); + expect(document.activeElement).not.toBe(container.querySelector('.ant-sender-input')); + }); + + it('supports aria-label and aria-describedby', () => { + const { container } = render( + + ); + const textbox = container.querySelector('[role="textbox"]'); + expect(textbox).toHaveAttribute('aria-label', 'Message input'); + expect(textbox).toHaveAttribute('aria-describedby', 'helper-text'); + }); + }); + + describe('Performance and Memory Tests', () => { + it('handles rapid value changes without memory leaks', () => { + const onChange = jest.fn(); + const { container } = render(); + const textbox = container.querySelector('[role="textbox"]')!; + + // Simulate rapid typing + for (let i = 0; i < 50; i++) { + fireEvent.input(textbox, { target: { textContent: `text${i}` } }); + } + + expect(onChange).toHaveBeenCalledTimes(50); + }); + + it('handles frequent ref method calls', () => { + const ref = React.createRef(); + render(); + + // Rapid insert/clear cycles + for (let i = 0; i < 20; i++) { + ref.current.insert(`text${i}`); + ref.current.clear(); + } + + expect(ref.current.getValue().value).toBe(''); + }); + + it('handles multiple simultaneous paste events', () => { + const onChange = jest.fn(); + const { container } = render(); + const editable = container.querySelector('.ant-sender-input')!; + + // Multiple paste events in quick succession + for (let i = 0; i < 5; i++) { + fireEvent.paste(editable, { + clipboardData: { + getData: () => `paste${i}`, + files: { length: 0 }, + }, + }); + } + + expect(onChange).toHaveBeenCalled(); + }); + }); + + describe('Advanced Slot Configuration Tests', () => { + it('handles nested slot configurations', () => { + const nestedConfig: SlotConfigType[] = [ + { type: 'text', text: 'Start: ' }, + { type: 'input', key: 'nested1', props: { placeholder: 'Input 1' } }, + { type: 'text', text: ' - ' }, + { type: 'input', key: 'nested2', props: { placeholder: 'Input 2' } }, + { type: 'text', text: ' :End' }, + ]; + + const { getByPlaceholderText } = render(); + expect(getByPlaceholderText('Input 1')).toBeInTheDocument(); + expect(getByPlaceholderText('Input 2')).toBeInTheDocument(); + }); + + it('handles dynamic slotConfig updates with state preservation', () => { + const config1: SlotConfigType[] = [ + { type: 'text', text: 'Config 1' }, + { type: 'input', key: 'persist', props: { placeholder: 'Persistent input' } }, + ]; + const config2: SlotConfigType[] = [ + { type: 'text', text: 'Config 2' }, + { type: 'input', key: 'persist', props: { placeholder: 'Persistent input' } }, + { type: 'input', key: 'new', props: { placeholder: 'New input' } }, + ]; + + const { container, rerender, getByPlaceholderText } = render(); + + // Add some content to the persistent input + const persistentInput = getByPlaceholderText('Persistent input'); + fireEvent.change(persistentInput, { target: { value: 'preserved content' } }); + + rerender(); + expect(container.textContent).toContain('Config 2'); + }); + + it('handles select slot with complex options and groups', () => { + const complexConfig: SlotConfigType[] = [ + { + type: 'select', + key: 'complex-select', + props: { + options: [ + { label: 'Option 1', value: 'opt1' }, + { label: 'Option 2', value: 'opt2', disabled: true }, + { label: 'Option 3', value: 'opt3' }, + ], + placeholder: 'Select complex option', + }, + }, + ]; + + const { container, getByText } = render(); + fireEvent.click(container.querySelector('.ant-sender-slot-select-selector-value')!); + expect(getByText('Option 1')).toBeInTheDocument(); + }); + + it('handles tag slot with various configurations', () => { + const tagConfigs: SlotConfigType[] = [ + { type: 'tag', key: 'tag1', props: { label: 'Simple Tag' } }, + { type: 'tag', key: 'tag2', props: { label: 'Tag with Value', value: 'tag-value' } }, + { type: 'tag', key: 'tag3', props: { label: '', value: 'empty-label' } }, + ]; + + const ref = React.createRef(); + render(); + + const result = ref.current.getValue(); + expect(result.value).toContain('tag-value'); + expect(result.value).toContain('empty-label'); + }); + }); + + describe('Integration Tests', () => { + it('handles complete user workflow with all slot types', async () => { + const onSubmit = jest.fn(); + const onChange = jest.fn(); + const ref = React.createRef(); + + const { container, getByPlaceholderText, getByTestId, getByText } = render( + + ); + + // Step 1: Fill input + const input = getByPlaceholderText('请输入内容'); + fireEvent.change(input, { target: { value: '用户输入' } }); + + // Step 2: Select from dropdown + fireEvent.click(container.querySelector('.ant-sender-slot-select-selector-value')!); + fireEvent.click(getByText('A')); + + // Step 3: Interact with custom slot + fireEvent.click(getByTestId('custom-btn')); + + // Step 4: Submit + act(() => { + fireEvent.keyDown(container.querySelector('[role="textbox"]')!, { key: 'Enter' }); + }); + + expect(onChange).toHaveBeenCalled(); + expect(onSubmit).toHaveBeenCalled(); + }); + + it('handles simultaneous multi-slot interactions', () => { + const onChange = jest.fn(); + const multiSlotConfig: SlotConfigType[] = [ + { type: 'input', key: 'input1', props: { placeholder: 'Input 1' } }, + { type: 'input', key: 'input2', props: { placeholder: 'Input 2' } }, + { type: 'select', key: 'select1', props: { options: ['A', 'B'], placeholder: 'Select' } }, + ]; + + const { container, getByPlaceholderText, getByText } = render( + + ); + + // Interact with multiple slots rapidly + fireEvent.change(getByPlaceholderText('Input 1'), { target: { value: 'Value 1' } }); + fireEvent.change(getByPlaceholderText('Input 2'), { target: { value: 'Value 2' } }); + fireEvent.click(container.querySelector('.ant-sender-slot-select-selector-value')!); + fireEvent.click(getByText('A')); + + expect(onChange).toHaveBeenCalled(); + }); + + it('handles mixed text and slot content submission', () => { + const onSubmit = jest.fn(); + const mixedConfig: SlotConfigType[] = [ + { type: 'text', text: 'Hello ' }, + { type: 'input', key: 'name', props: { placeholder: 'Enter name', defaultValue: 'World' } }, + { type: 'text', text: '!' }, + ]; + + const { container } = render(); + act(() => { + fireEvent.keyDown(container.querySelector('[role="textbox"]')!, { key: 'Enter' }); + }); + + expect(onSubmit).toHaveBeenCalledWith( + expect.stringContaining('Hello'), + expect.any(Array) + ); + expect(onSubmit).toHaveBeenCalledWith( + expect.stringContaining('World'), + expect.any(Array) + ); + }); + }); + + describe('Props Validation and Edge Cases', () => { + it('handles undefined and null props gracefully', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('handles invalid prop types', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('handles empty string values and arrays', () => { + const { container } = render(); + const textbox = container.querySelector('[role="textbox"]'); + expect(textbox).toHaveTextContent(''); + }); + + it('handles boolean values in unexpected places', () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + it('handles numeric values in text fields', () => { + const numericConfig: SlotConfigType[] = [ + { type: 'text', text: 123 as any }, + { type: 'input', key: 'num', props: { defaultValue: 456 as any } }, + ]; + + expect(() => { + render(); + }).not.toThrow(); + }); + }); + + describe('Event Handling Edge Cases', () => { + it('handles events with missing event properties', () => { + const onChange = jest.fn(); + const { container } = render(); + const textbox = container.querySelector('[role="textbox"]')!; + + // Event without target + fireEvent.input(textbox, {} as any); + + // Event with null target + fireEvent.input(textbox, { target: null } as any); + }); + + it('handles keyboard events with unusual key combinations', () => { + const onSubmit = jest.fn(); + const { container } = render(); + const textbox = container.querySelector('[role="textbox"]')!; + + // Unusual key combinations + fireEvent.keyDown(textbox, { key: 'Enter', ctrlKey: true, altKey: true }); + fireEvent.keyDown(textbox, { key: 'Enter', metaKey: true }); + fireEvent.keyDown(textbox, { key: 'NumpadEnter' }); + }); + + it('handles paste events with various clipboard data formats', () => { + const onPaste = jest.fn(); + const { container } = render(); + const editable = container.querySelector('.ant-sender-input')!; + + // HTML content + fireEvent.paste(editable, { + clipboardData: { + getData: (format: string) => + format === 'text/html' ? 'Bold text' : 'Plain text', + files: { length: 0 }, + }, + }); + + // Image data + fireEvent.paste(editable, { + clipboardData: { + getData: () => '', + files: { + 0: new File([''], 'image.png', { type: 'image/png' }), + length: 1, + item: (index: number) => + new File([''], 'image.png', { type: 'image/png' }), + }, + }, + }); + + expect(onPaste).toHaveBeenCalled(); + }); + }); + +}); \ No newline at end of file