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