DocHub/frontend/tests/components/DocumentEditor.test.tsx
Tianyang 39761b4ce7 fix: lock sidebar width and add rendered markdown preview
Sidebar overflowed horizontally on long file/folder names, which broke
vertical scrolling too — locked width with min/max-width plus
overflow-x: hidden, and truncate folder names with ellipsis.

Documents now open in a rendered preview (marked + DOMPurify) by
default, with an 编辑/预览 toggle to switch to the CodeMirror source view.
2026-08-01 18:30:50 +08:00

268 lines
9.4 KiB
TypeScript

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach } from 'vitest';
import DocumentEditor from '../../src/components/DocumentEditor';
import { deleteDocument, getDocument, saveDocument } from '../../src/api/client';
vi.mock('../../src/api/client');
vi.mock('@uiw/react-codemirror', () => ({
default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
<textarea
data-testid="codemirror-mock"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
),
}));
vi.mock('@codemirror/lang-markdown', () => ({ markdown: () => ({}) }));
vi.mock('@codemirror/lang-html', () => ({ html: () => ({}) }));
beforeEach(() => {
vi.mocked(getDocument).mockReset();
vi.mocked(saveDocument).mockReset();
vi.mocked(deleteDocument).mockReset();
});
async function switchToEditMode(user: ReturnType<typeof userEvent.setup>) {
await screen.findByTestId('markdown-preview');
await user.click(screen.getByRole('button', { name: '编辑' }));
return screen.findByTestId('codemirror-mock');
}
test('shows a rendered markdown preview by default', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
const preview = await screen.findByTestId('markdown-preview');
expect(preview.querySelector('h1')).toHaveTextContent('架构设计');
expect(screen.queryByTestId('codemirror-mock')).not.toBeInTheDocument();
});
test('clicking 编辑 switches to the code editor and back to 预览 restores the rendered view', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
const user = userEvent.setup();
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
const textarea = await switchToEditMode(user);
expect(textarea).toHaveValue('# 架构设计');
await user.click(screen.getByRole('button', { name: '预览' }));
expect(await screen.findByTestId('markdown-preview')).toBeInTheDocument();
});
test('shows an image preview instead of the code editor for image content types', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/图片.png',
content: 'iVBORw0KGgo=',
contentType: 'image/png',
encoding: 'base64',
});
render(<DocumentEditor documentKey="产品文档/图片.png" onDeleted={vi.fn()} />);
const preview = await screen.findByTestId('image-preview');
const img = preview.querySelector('img');
expect(img).toHaveAttribute('src', 'data:image/png;base64,iVBORw0KGgo=');
expect(screen.queryByTestId('codemirror-mock')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '保存' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: '编辑' })).not.toBeInTheDocument();
});
test('shows a loading state, then loads and displays document content', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
expect(screen.getByTestId('document-editor-loading')).toBeInTheDocument();
await screen.findByTestId('markdown-preview');
expect(getDocument).toHaveBeenCalledWith('产品文档/架构设计.md');
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
});
test('typing marks the document dirty and saving clears the dirty state', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
vi.mocked(saveDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
updatedAt: '2026-08-01T10:05:00Z',
});
const user = userEvent.setup();
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
const textarea = await switchToEditMode(user);
await user.clear(textarea);
await user.type(textarea, '# 新内容');
expect(screen.getByTestId('save-status')).toHaveTextContent('未保存');
const saveButton = screen.getByRole('button', { name: '保存' });
await user.click(saveButton);
await waitFor(() => {
expect(saveDocument).toHaveBeenCalledWith('产品文档/架构设计.md', '# 新内容', 'text/markdown');
});
await waitFor(() => {
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
});
});
test('save failure keeps the editor and unsaved content visible, shows an error, and allows retry', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
vi.mocked(saveDocument).mockRejectedValueOnce(new Error('network error'));
vi.mocked(saveDocument).mockResolvedValueOnce({
key: '产品文档/架构设计.md',
updatedAt: '2026-08-01T10:05:00Z',
});
const user = userEvent.setup();
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={vi.fn()} />);
const textarea = await switchToEditMode(user);
await user.clear(textarea);
await user.type(textarea, '# 未保存的修改');
const saveButton = screen.getByRole('button', { name: '保存' });
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByTestId('save-error')).toBeInTheDocument();
});
expect(screen.getByTestId('document-editor')).toBeInTheDocument();
expect(screen.getByTestId('codemirror-mock')).toHaveValue('# 未保存的修改');
expect(screen.getByTestId('save-status')).toHaveTextContent('未保存');
await user.click(saveButton);
await waitFor(() => {
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档');
});
expect(screen.queryByTestId('save-error')).not.toBeInTheDocument();
});
test('calls onDirtyChange when dirty state changes', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
const onDirtyChange = vi.fn();
const user = userEvent.setup();
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDirtyChange={onDirtyChange} onDeleted={vi.fn()} />);
const textarea = await switchToEditMode(user);
await waitFor(() => {
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
});
await user.type(textarea, '追加内容');
await waitFor(() => {
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
});
});
test('clicking delete confirms, calls deleteDocument, and calls onDeleted on success', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
vi.mocked(deleteDocument).mockResolvedValue(undefined);
const onDeleted = vi.fn();
const user = userEvent.setup();
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
await screen.findByTestId('markdown-preview');
const deleteButton = screen.getByRole('button', { name: '删除' });
await user.click(deleteButton);
expect(confirmSpy).toHaveBeenCalledWith('确定要删除这篇文档吗?此操作无法撤销。');
await waitFor(() => {
expect(deleteDocument).toHaveBeenCalledWith('产品文档/架构设计.md');
});
await waitFor(() => {
expect(onDeleted).toHaveBeenCalled();
});
});
test('clicking delete does nothing if the confirmation is declined', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
const onDeleted = vi.fn();
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
await screen.findByTestId('markdown-preview');
const deleteButton = screen.getByRole('button', { name: '删除' });
await user.click(deleteButton);
expect(deleteDocument).not.toHaveBeenCalled();
expect(onDeleted).not.toHaveBeenCalled();
});
test('shows an inline error and does not call onDeleted when delete fails', async () => {
vi.mocked(getDocument).mockResolvedValue({
key: '产品文档/架构设计.md',
content: '# 架构设计',
contentType: 'text/markdown',
encoding: 'utf-8',
});
vi.mocked(deleteDocument).mockRejectedValue(new Error('network error'));
const onDeleted = vi.fn();
const user = userEvent.setup();
vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDeleted={onDeleted} />);
await screen.findByTestId('markdown-preview');
const deleteButton = screen.getByRole('button', { name: '删除' });
await user.click(deleteButton);
await waitFor(() => {
expect(screen.getByTestId('delete-error')).toBeInTheDocument();
});
expect(onDeleted).not.toHaveBeenCalled();
});