test(hooks): expand focus-trap tests with Tab/Shift+Tab coverage and fix TypeScript errors
- Add 33 unit tests for useFocusTrap hook covering all key behaviors - Add Tab/Shift+Tab focus cycling tests with activeElement mocking attempt - Add Escape key behavior, state change, and dependency tracking tests - Fix TypeScript errors in RichTextEditor.test.tsx (unknown types, unused vars) - Fix ESLint error in use-focus-trap.test.tsx (self-closing component) - Fix unused variable warnings in use-focus-trap.test.tsx - All 33 tests passing, 0 TypeScript errors, 0 ESLint errors
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { RichTextEditor } from './RichTextEditor';
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Mock @tiptap/react
|
||||
const mockEditor = {
|
||||
getHTML: jest.fn(() => '<p>test content</p>'),
|
||||
commands: {
|
||||
setContent: jest.fn(),
|
||||
},
|
||||
chain: jest.fn(() => ({
|
||||
focus: jest.fn(() => ({
|
||||
toggleBold: jest.fn(() => ({ run: jest.fn() })),
|
||||
toggleItalic: jest.fn(() => ({ run: jest.fn() })),
|
||||
toggleHeading: jest.fn(() => ({ run: jest.fn() })),
|
||||
toggleBulletList: jest.fn(() => ({ run: jest.fn() })),
|
||||
toggleOrderedList: jest.fn(() => ({ run: jest.fn() })),
|
||||
toggleBlockquote: jest.fn(() => ({ run: jest.fn() })),
|
||||
undo: jest.fn(() => ({ run: jest.fn() })),
|
||||
redo: jest.fn(() => ({ run: jest.fn() })),
|
||||
extendMarkRange: jest.fn(() => ({
|
||||
unsetLink: jest.fn(() => ({ run: jest.fn() })),
|
||||
setLink: jest.fn(() => ({ run: jest.fn() })),
|
||||
})),
|
||||
setLink: jest.fn(() => ({ run: jest.fn() })),
|
||||
run: jest.fn(),
|
||||
})),
|
||||
})),
|
||||
isActive: jest.fn(() => false),
|
||||
can: jest.fn(() => ({
|
||||
undo: jest.fn(() => true),
|
||||
redo: jest.fn(() => true),
|
||||
})),
|
||||
getAttributes: jest.fn(() => ({ href: '' })),
|
||||
};
|
||||
|
||||
jest.mock('@tiptap/react', () => ({
|
||||
useEditor: jest.fn(() => mockEditor),
|
||||
EditorContent: (_props: {
|
||||
editor: typeof mockEditor;
|
||||
}) => <div data-testid="editor-content" />,
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
jest.mock('lucide-react', () => {
|
||||
const mockIcon = (name: string) => {
|
||||
const Icon = (_props: Record<string, unknown>) => (
|
||||
<svg data-testid={`icon-${name.toLowerCase()}`} />
|
||||
);
|
||||
Icon.displayName = name;
|
||||
return Icon;
|
||||
};
|
||||
return {
|
||||
Bold: mockIcon('bold'),
|
||||
Italic: mockIcon('italic'),
|
||||
Heading2: mockIcon('heading2'),
|
||||
Heading3: mockIcon('heading3'),
|
||||
List: mockIcon('list'),
|
||||
ListOrdered: mockIcon('list-ordered'),
|
||||
Link: mockIcon('link'),
|
||||
Quote: mockIcon('quote'),
|
||||
Undo2: mockIcon('undo2'),
|
||||
Redo2: mockIcon('redo2'),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock window.prompt
|
||||
const mockPrompt = jest.spyOn(window, 'prompt') as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockPrompt.mockReturnValue(null);
|
||||
});
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('RichTextEditor', () => {
|
||||
describe('Initial State', () => {
|
||||
it('should render editor content when editor is ready', () => {
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
expect(screen.getByTestId('editor-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render loading state when editor is null', () => {
|
||||
// Temporarily make useEditor return null
|
||||
const useEditorMock = (jest.requireMock('@tiptap/react') as { useEditor: jest.Mock }).useEditor;
|
||||
useEditorMock.mockReturnValueOnce(null);
|
||||
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
expect(screen.getByText('加载编辑器...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render toolbar with buttons', () => {
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
expect(screen.getByTitle('加粗')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('斜体')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('标题 2')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('标题 3')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('无序列表')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('有序列表')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('引用')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('链接')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('撤销')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('重做')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor Options', () => {
|
||||
it('should call useEditor with correct options', () => {
|
||||
const useEditorMock = (jest.requireMock('@tiptap/react') as { useEditor: jest.Mock }).useEditor;
|
||||
render(<RichTextEditor value="initial content" onChange={jest.fn()} placeholder="custom placeholder" />);
|
||||
|
||||
expect(useEditorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: 'initial content',
|
||||
extensions: expect.any(Array),
|
||||
immediatelyRender: false,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default placeholder when not provided', () => {
|
||||
const useEditorMock = (jest.requireMock('@tiptap/react') as { useEditor: jest.Mock }).useEditor;
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
|
||||
expect(useEditorMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extensions: expect.any(Array),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Toolbar Interactions', () => {
|
||||
it('should toggle bold on click', () => {
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
const boldButton = screen.getByTitle('加粗');
|
||||
fireEvent.click(boldButton);
|
||||
expect(mockEditor.chain).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should toggle italic on click', () => {
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTitle('斜体'));
|
||||
expect(mockEditor.chain).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should toggle heading 2 on click', () => {
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTitle('标题 2'));
|
||||
expect(mockEditor.chain).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should toggle heading 3 on click', () => {
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTitle('标题 3'));
|
||||
expect(mockEditor.chain).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should toggle bullet list on click', () => {
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTitle('无序列表'));
|
||||
expect(mockEditor.chain).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should toggle ordered list on click', () => {
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTitle('有序列表'));
|
||||
expect(mockEditor.chain).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should toggle blockquote on click', () => {
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTitle('引用'));
|
||||
expect(mockEditor.chain).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should undo on click', () => {
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTitle('撤销'));
|
||||
expect(mockEditor.chain).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should redo on click', () => {
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTitle('重做'));
|
||||
expect(mockEditor.chain).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Link Button', () => {
|
||||
it('should prompt for URL when link button is clicked', () => {
|
||||
mockPrompt.mockReturnValue('https://example.com');
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTitle('链接'));
|
||||
expect(mockPrompt).toHaveBeenCalledWith('链接 URL', '');
|
||||
});
|
||||
|
||||
it('should unset link when empty URL is provided', () => {
|
||||
mockPrompt.mockReturnValue('');
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTitle('链接'));
|
||||
expect(mockPrompt).toHaveBeenCalledWith('链接 URL', '');
|
||||
});
|
||||
|
||||
it('should not modify link when prompt is cancelled', () => {
|
||||
mockPrompt.mockReturnValue(null);
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTitle('链接'));
|
||||
expect(mockPrompt).toHaveBeenCalledWith('链接 URL', '');
|
||||
});
|
||||
|
||||
it('should show existing URL when editing a link', () => {
|
||||
mockEditor.getAttributes.mockReturnValue({ href: 'https://existing.com' });
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTitle('链接'));
|
||||
expect(mockPrompt).toHaveBeenCalledWith('链接 URL', 'https://existing.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content Synchronization', () => {
|
||||
it('should sync content when value changes externally', () => {
|
||||
const { rerender } = render(
|
||||
<RichTextEditor value="initial" onChange={jest.fn()} />
|
||||
);
|
||||
rerender(<RichTextEditor value="updated" onChange={jest.fn()} />);
|
||||
expect(mockEditor.commands.setContent).toHaveBeenCalledWith('updated');
|
||||
});
|
||||
|
||||
it('should not sync content when HTML matches the value', () => {
|
||||
// The component compares value === editor.getHTML()
|
||||
// So we need to pass the same HTML string as the mock returns
|
||||
mockEditor.getHTML.mockReturnValue('<p>same content</p>');
|
||||
const { rerender } = render(
|
||||
<RichTextEditor value="<p>same content</p>" onChange={jest.fn()} />
|
||||
);
|
||||
rerender(<RichTextEditor value="<p>same content</p>" onChange={jest.fn()} />);
|
||||
// setContent should not be called when HTML matches the value
|
||||
expect(mockEditor.commands.setContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call onUpdate when editor content changes', () => {
|
||||
// Simulate the onUpdate callback
|
||||
const useEditorMock = (jest.requireMock('@tiptap/react') as { useEditor: jest.Mock }).useEditor;
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(<RichTextEditor value="" onChange={onChange} />);
|
||||
|
||||
// Get the onUpdate callback from useEditor options
|
||||
const editorOptions = useEditorMock.mock.calls[0]?.[0] as { onUpdate: (args: { editor: typeof mockEditor }) => void };
|
||||
const onUpdate = editorOptions.onUpdate;
|
||||
|
||||
// Call onUpdate with mock editor
|
||||
onUpdate({ editor: mockEditor });
|
||||
|
||||
expect(mockEditor.getHTML).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Editor State Management', () => {
|
||||
it('should apply active state to toolbar buttons', () => {
|
||||
mockEditor.isActive.mockReturnValue(true);
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
// When active, the button should have brand styling
|
||||
const boldButton = screen.getByTitle('加粗');
|
||||
expect(boldButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should disable undo button when cannot undo', () => {
|
||||
mockEditor.can.mockReturnValue({
|
||||
undo: jest.fn(() => false),
|
||||
redo: jest.fn(() => true),
|
||||
});
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
const undoButton = screen.getByTitle('撤销');
|
||||
expect(undoButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should disable redo button when cannot redo', () => {
|
||||
mockEditor.can.mockReturnValue({
|
||||
undo: jest.fn(() => true),
|
||||
redo: jest.fn(() => false),
|
||||
});
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
const redoButton = screen.getByTitle('重做');
|
||||
expect(redoButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle null editor in useEffect', () => {
|
||||
// Force editor to be null after first render
|
||||
const useEditorMock = (jest.requireMock('@tiptap/react') as { useEditor: jest.Mock }).useEditor;
|
||||
useEditorMock.mockReturnValueOnce(null);
|
||||
|
||||
const { rerender } = render(
|
||||
<RichTextEditor value="test" onChange={jest.fn()} />
|
||||
);
|
||||
// Rerender with null editor - should not crash
|
||||
expect(() => {
|
||||
rerender(<RichTextEditor value="updated" onChange={jest.fn()} />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should handle empty value', () => {
|
||||
const useEditorMock = (jest.requireMock('@tiptap/react') as { useEditor: jest.Mock }).useEditor;
|
||||
render(<RichTextEditor value="" onChange={jest.fn()} />);
|
||||
const editorOptions = useEditorMock.mock.calls[0]?.[0] as { content: string };
|
||||
expect(editorOptions.content).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user