fix: repair build, preserve unsaved edits on save failure, fix search/upload UX gaps
- client.test.ts: use globalThis.fetch instead of global.fetch and drop the unused describe import, fixing the broken `npm run build` - DocumentEditor: separate load-error (replaces view) from save-error (shown inline, editor and unsaved content stay mounted so a failed save no longer destroys in-progress edits); expose dirty state via onDirtyChange - App: prompt via window.confirm before switching documents while dirty; increment a refreshKey on successful upload so FileExplorer re-fetches the tree; track isSearching so the "no results" overlay doesn't flash during the search debounce window - FileExplorer: accept a refreshKey prop to force a root-listing re-fetch - SearchBar: convert to a controlled component (value prop) so App can clear the input after jumping to a search result, fixing input/state desync - UploadButton: surface upload failures inline instead of swallowing the rejected promise, and keep the modal open for retry
This commit is contained in:
parent
13789cc0b4
commit
7e218b5738
@ -10,34 +10,67 @@ export default function App() {
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [uploadPath] = useState('');
|
||||
|
||||
function confirmDiscardIfDirty(): boolean {
|
||||
if (!isDirty) {
|
||||
return true;
|
||||
}
|
||||
return window.confirm('有未保存的修改,确定要离开吗?');
|
||||
}
|
||||
|
||||
function handleSelectFile(key: string) {
|
||||
if (!confirmDiscardIfDirty()) {
|
||||
return;
|
||||
}
|
||||
setSelectedKey(key);
|
||||
}
|
||||
|
||||
function handleJumpToResult(key: string) {
|
||||
if (!confirmDiscardIfDirty()) {
|
||||
return;
|
||||
}
|
||||
setSelectedKey(key);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
}
|
||||
|
||||
function handleQueryChange(query: string) {
|
||||
setSearchQuery(query);
|
||||
setIsSearching(query.trim() !== '');
|
||||
}
|
||||
|
||||
function handleResults(results: SearchResult[]) {
|
||||
setSearchResults(results);
|
||||
setIsSearching(false);
|
||||
}
|
||||
|
||||
function handleUploaded(result: UploadDocumentResponse) {
|
||||
setSelectedKey(result.key);
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="topbar">
|
||||
<h1>DocHub</h1>
|
||||
<SearchBar onQueryChange={setSearchQuery} onResults={setSearchResults} />
|
||||
<UploadButton path="" onUploaded={handleUploaded} />
|
||||
<SearchBar value={searchQuery} onQueryChange={handleQueryChange} onResults={handleResults} />
|
||||
<UploadButton path={uploadPath} onUploaded={handleUploaded} />
|
||||
</div>
|
||||
<div className="body">
|
||||
<FileExplorer onSelectFile={setSelectedKey} selectedKey={selectedKey} />
|
||||
<FileExplorer onSelectFile={handleSelectFile} selectedKey={selectedKey} refreshKey={refreshKey} />
|
||||
<div className="main" data-testid="main-area">
|
||||
{selectedKey ? (
|
||||
<DocumentEditor documentKey={selectedKey} />
|
||||
<DocumentEditor documentKey={selectedKey} onDirtyChange={setIsDirty} />
|
||||
) : (
|
||||
<div data-testid="empty-state">未选择文档</div>
|
||||
)}
|
||||
</div>
|
||||
{searchQuery !== '' && (
|
||||
{searchQuery !== '' && !isSearching && (
|
||||
<SearchResults query={searchQuery} results={searchResults} onJumpToResult={handleJumpToResult} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -4,19 +4,22 @@ import { getDocument, saveDocument } from '../api/client';
|
||||
|
||||
interface DocumentEditorProps {
|
||||
documentKey: string;
|
||||
onDirtyChange?: (isDirty: boolean) => void;
|
||||
}
|
||||
|
||||
export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
|
||||
export default function DocumentEditor({ documentKey, onDirtyChange }: DocumentEditorProps) {
|
||||
const [content, setContent] = useState('');
|
||||
const [originalContent, setOriginalContent] = useState('');
|
||||
const [contentType, setContentType] = useState('text/markdown');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setLoadError(null);
|
||||
setSaveError(null);
|
||||
getDocument(documentKey)
|
||||
.then((doc) => {
|
||||
setContent(doc.content);
|
||||
@ -25,26 +28,31 @@ export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setError('无法加载文档');
|
||||
setLoadError('无法加载文档');
|
||||
setLoading(false);
|
||||
});
|
||||
}, [documentKey]);
|
||||
|
||||
const isDirty = content !== originalContent;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
function handleChange(value: string | undefined) {
|
||||
setContent(value ?? '');
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
saveDocument(documentKey, content, contentType)
|
||||
.then(() => {
|
||||
setOriginalContent(content);
|
||||
setSaving(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setError('保存失败');
|
||||
setSaveError('保存失败,请重试');
|
||||
setSaving(false);
|
||||
});
|
||||
}
|
||||
@ -53,8 +61,8 @@ export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
|
||||
return <div data-testid="document-editor-loading">加载中…</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div data-testid="document-editor-error">{error}</div>;
|
||||
if (loadError) {
|
||||
return <div data-testid="document-editor-error">{loadError}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
@ -64,6 +72,14 @@ export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
|
||||
<button onClick={handleSave} disabled={!isDirty || saving}>
|
||||
{saving ? '保存中…' : '保存'}
|
||||
</button>
|
||||
{saveError && (
|
||||
<span data-testid="save-error">
|
||||
{saveError}
|
||||
<button onClick={() => setSaveError(null)} aria-label="关闭错误提示">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Editor value={content} onChange={handleChange} language={contentType === 'text/markdown' ? 'markdown' : 'html'} />
|
||||
</div>
|
||||
|
||||
@ -4,9 +4,10 @@ import { listDocuments, type DocumentFile } from '../api/client';
|
||||
interface FileExplorerProps {
|
||||
onSelectFile: (key: string) => void;
|
||||
selectedKey?: string | null;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorerProps) {
|
||||
export default function FileExplorer({ onSelectFile, selectedKey, refreshKey }: FileExplorerProps) {
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [files, setFiles] = useState<DocumentFile[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -18,7 +19,7 @@ export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorer
|
||||
setFiles(data.files);
|
||||
})
|
||||
.catch(() => setError('无法加载文档目录'));
|
||||
}, []);
|
||||
}, [refreshKey]);
|
||||
|
||||
if (error) {
|
||||
return <div data-testid="file-explorer-error">{error}</div>;
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { search, type SearchResult } from '../api/client';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
onResults: (results: SearchResult[]) => void;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) {
|
||||
const [value, setValue] = useState('');
|
||||
export default function SearchBar({ value, onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@ -21,7 +21,6 @@ export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }
|
||||
|
||||
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const nextValue = event.target.value;
|
||||
setValue(nextValue);
|
||||
onQueryChange(nextValue);
|
||||
|
||||
if (timerRef.current) {
|
||||
|
||||
@ -9,6 +9,7 @@ interface UploadButtonProps {
|
||||
export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function openModal() {
|
||||
@ -18,13 +19,19 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
||||
function closeModal() {
|
||||
setIsOpen(false);
|
||||
setIsDragOver(false);
|
||||
setUploadError(null);
|
||||
}
|
||||
|
||||
function uploadFiles(files: FileList | File[]) {
|
||||
Array.from(files).forEach((file) => {
|
||||
uploadDocument(file, path).then((result) => {
|
||||
onUploaded(result);
|
||||
});
|
||||
setUploadError(null);
|
||||
uploadDocument(file, path)
|
||||
.then((result) => {
|
||||
onUploaded(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
setUploadError(err instanceof Error ? err.message : '上传失败');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -68,6 +75,7 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
|
||||
>
|
||||
拖拽文件到此处,或点击选择
|
||||
</div>
|
||||
{uploadError && <div data-testid="upload-error">{uploadError}</div>}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { test, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import App from '../src/App';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { listDocuments, getDocument, search } from '../src/api/client';
|
||||
import { listDocuments, getDocument, search, uploadDocument } from '../src/api/client';
|
||||
|
||||
vi.mock('../src/api/client');
|
||||
|
||||
@ -21,6 +21,7 @@ beforeEach(() => {
|
||||
vi.mocked(listDocuments).mockReset();
|
||||
vi.mocked(getDocument).mockReset();
|
||||
vi.mocked(search).mockReset();
|
||||
vi.mocked(uploadDocument).mockReset();
|
||||
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||||
vi.mocked(getDocument).mockResolvedValue({
|
||||
key: '',
|
||||
@ -30,6 +31,10 @@ beforeEach(() => {
|
||||
vi.mocked(search).mockResolvedValue({ results: [] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('renders the DocHub heading', () => {
|
||||
render(<App />);
|
||||
expect(screen.getByRole('heading', { name: 'DocHub' })).toBeInTheDocument();
|
||||
@ -100,3 +105,127 @@ test('searching shows the results overlay, and clicking a result opens the docum
|
||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||
expect(textarea).toHaveValue('# 架构设计');
|
||||
});
|
||||
|
||||
test('jumping to a search result clears the search input so it does not show stale text', async () => {
|
||||
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||||
vi.mocked(search).mockResolvedValue({
|
||||
results: [{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' }],
|
||||
});
|
||||
vi.mocked(getDocument).mockResolvedValue({
|
||||
key: '产品文档/架构设计.md',
|
||||
content: '# 架构设计',
|
||||
contentType: 'text/markdown',
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<App />);
|
||||
|
||||
const searchInput = screen.getByRole('textbox', { name: '搜索文档' }) as HTMLInputElement;
|
||||
await user.type(searchInput, '检索');
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByTestId('search-result-产品文档/架构设计.md')).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('search-result-产品文档/架构设计.md'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(searchInput).toHaveValue('');
|
||||
});
|
||||
});
|
||||
|
||||
test('typing a query does not show a false "no results" message before the debounced search resolves', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||||
vi.mocked(search).mockResolvedValue({ results: [] });
|
||||
|
||||
render(<App />);
|
||||
|
||||
const searchInput = screen.getByRole('textbox', { name: '搜索文档' });
|
||||
fireEvent.change(searchInput, { target: { value: '检索' } });
|
||||
|
||||
expect(screen.queryByTestId('search-no-results')).not.toBeInTheDocument();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
expect(screen.getByTestId('search-no-results')).toBeInTheDocument();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('switching files while the current document is dirty prompts for confirmation', async () => {
|
||||
vi.mocked(listDocuments).mockResolvedValue({
|
||||
folders: [],
|
||||
files: [
|
||||
{ key: '文档A.md', name: '文档A.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
|
||||
{ key: '文档B.md', name: '文档B.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
|
||||
],
|
||||
});
|
||||
vi.mocked(getDocument).mockImplementation((key: string) =>
|
||||
Promise.resolve({ key, content: `内容-${key}`, contentType: 'text/markdown' })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await user.click(await screen.findByText('文档A.md'));
|
||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||
await user.type(textarea, '修改内容');
|
||||
|
||||
await user.click(screen.getByText('文档B.md'));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
expect(await screen.findByTestId('monaco-editor-mock')).toHaveValue('内容-文档A.md修改内容');
|
||||
});
|
||||
|
||||
test('confirming the discard prompt proceeds to switch files', async () => {
|
||||
vi.mocked(listDocuments).mockResolvedValue({
|
||||
folders: [],
|
||||
files: [
|
||||
{ key: '文档A.md', name: '文档A.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
|
||||
{ key: '文档B.md', name: '文档B.md', size: 10, modifiedAt: '2026-08-01T10:00:00Z' },
|
||||
],
|
||||
});
|
||||
vi.mocked(getDocument).mockImplementation((key: string) =>
|
||||
Promise.resolve({ key, content: `内容-${key}`, contentType: 'text/markdown' })
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await user.click(await screen.findByText('文档A.md'));
|
||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||
await user.type(textarea, '修改内容');
|
||||
|
||||
await user.click(screen.getByText('文档B.md'));
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByTestId('monaco-editor-mock')).toHaveValue('内容-文档B.md');
|
||||
});
|
||||
});
|
||||
|
||||
test('a successful upload refreshes the file tree', async () => {
|
||||
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||||
vi.mocked(uploadDocument).mockResolvedValue({ key: '新文档.md', name: '新文档.md', size: 5 });
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listDocuments).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '上传文档' }));
|
||||
const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' });
|
||||
const input = screen.getByTestId('upload-file-input');
|
||||
await user.upload(input, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listDocuments).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||
import { test, expect, vi, beforeEach } from 'vitest';
|
||||
import { listDocuments, getDocument, saveDocument, deleteDocument, uploadDocument, search } from '../../src/api/client';
|
||||
|
||||
beforeEach(() => {
|
||||
@ -10,33 +10,33 @@ test('listDocuments calls GET /api/documents with the path query param and retur
|
||||
folders: ['产品文档'],
|
||||
files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 512, modifiedAt: '2026-08-01T10:00:00Z' }],
|
||||
};
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await listDocuments('产品文档');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
test('listDocuments throws when the response is not ok', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }) as unknown as typeof fetch;
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }) as unknown as typeof fetch;
|
||||
|
||||
await expect(listDocuments('')).rejects.toThrow('Failed to list documents: 500');
|
||||
});
|
||||
|
||||
test('getDocument calls GET /api/documents/{key} preserving slashes in the key', async () => {
|
||||
const mockResponse = { key: '产品文档/架构设计.md', content: '# 架构设计', contentType: 'text/markdown' };
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await getDocument('产品文档/架构设计.md');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
@ -44,14 +44,14 @@ test('getDocument calls GET /api/documents/{key} preserving slashes in the key',
|
||||
|
||||
test('saveDocument calls PUT /api/documents/{key} with content and contentType', async () => {
|
||||
const mockResponse = { key: '产品文档/架构设计.md', updatedAt: '2026-08-01T10:05:00Z' };
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await saveDocument('产品文档/架构设计.md', '# 新内容', 'text/markdown');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
@ -63,11 +63,11 @@ test('saveDocument calls PUT /api/documents/{key} with content and contentType',
|
||||
});
|
||||
|
||||
test('deleteDocument calls DELETE /api/documents/{key} and resolves with no value', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 }) as unknown as typeof fetch;
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 }) as unknown as typeof fetch;
|
||||
|
||||
await expect(deleteDocument('产品文档/架构设计.md')).resolves.toBeUndefined();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
@ -75,7 +75,7 @@ test('deleteDocument calls DELETE /api/documents/{key} and resolves with no valu
|
||||
|
||||
test('uploadDocument posts a multipart form with file and path fields', async () => {
|
||||
const mockResponse = { key: '产品文档/新文档.md', name: '新文档.md', size: 42 };
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
}) as unknown as typeof fetch;
|
||||
@ -83,8 +83,8 @@ test('uploadDocument posts a multipart form with file and path fields', async ()
|
||||
|
||||
const result = await uploadDocument(file, '产品文档');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe('/api/upload');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = init.body as FormData;
|
||||
@ -99,13 +99,13 @@ test('search calls GET /api/search with the q query param and returns parsed JSO
|
||||
{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' },
|
||||
],
|
||||
};
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await search('检索');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
@ -70,3 +70,64 @@ test('typing marks the document dirty and saving clears the dirty state', async
|
||||
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',
|
||||
});
|
||||
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" />);
|
||||
|
||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||
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('monaco-editor-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',
|
||||
});
|
||||
const onDirtyChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<DocumentEditor documentKey="产品文档/架构设计.md" onDirtyChange={onDirtyChange} />);
|
||||
|
||||
const textarea = await screen.findByTestId('monaco-editor-mock');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
await user.type(textarea, '追加内容');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDirtyChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
import FileExplorer from '../../src/components/FileExplorer';
|
||||
import { listDocuments } from '../../src/api/client';
|
||||
@ -67,3 +67,19 @@ test('shows an error message when listDocuments rejects', async () => {
|
||||
|
||||
expect(await screen.findByTestId('file-explorer-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('changing refreshKey re-fetches the root listing', async () => {
|
||||
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
|
||||
|
||||
const { rerender } = render(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} refreshKey={0} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listDocuments).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
rerender(<FileExplorer onSelectFile={vi.fn()} selectedKey={null} refreshKey={1} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listDocuments).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,7 +1,19 @@
|
||||
import { useState } from 'react';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
|
||||
import SearchBar from '../../src/components/SearchBar';
|
||||
import { search } from '../../src/api/client';
|
||||
import { search, type SearchResult } from '../../src/api/client';
|
||||
|
||||
function ControlledSearchBar({
|
||||
onResults,
|
||||
debounceMs,
|
||||
}: {
|
||||
onResults: (results: SearchResult[]) => void;
|
||||
debounceMs?: number;
|
||||
}) {
|
||||
const [value, setValue] = useState('');
|
||||
return <SearchBar value={value} onQueryChange={setValue} onResults={onResults} debounceMs={debounceMs} />;
|
||||
}
|
||||
|
||||
vi.mock('../../src/api/client');
|
||||
|
||||
@ -19,7 +31,7 @@ test('debounces input for 300ms before calling search', async () => {
|
||||
const onQueryChange = vi.fn();
|
||||
const onResults = vi.fn();
|
||||
|
||||
render(<SearchBar onQueryChange={onQueryChange} onResults={onResults} />);
|
||||
render(<SearchBar value="" onQueryChange={onQueryChange} onResults={onResults} />);
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: '检索' } });
|
||||
@ -34,10 +46,9 @@ test('debounces input for 300ms before calling search', async () => {
|
||||
|
||||
test('clearing the input immediately reports empty results without calling search', async () => {
|
||||
vi.mocked(search).mockResolvedValue({ results: [] });
|
||||
const onQueryChange = vi.fn();
|
||||
const onResults = vi.fn();
|
||||
|
||||
render(<SearchBar onQueryChange={onQueryChange} onResults={onResults} />);
|
||||
render(<ControlledSearchBar onResults={onResults} />);
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: 'x' } });
|
||||
@ -46,3 +57,17 @@ test('clearing the input immediately reports empty results without calling searc
|
||||
|
||||
expect(onResults).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
test('reflects the value prop, allowing a parent to control/reset the input', () => {
|
||||
const onQueryChange = vi.fn();
|
||||
const onResults = vi.fn();
|
||||
|
||||
const { rerender } = render(<SearchBar value="检索" onQueryChange={onQueryChange} onResults={onResults} />);
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
expect(input).toHaveValue('检索');
|
||||
|
||||
rerender(<SearchBar value="" onQueryChange={onQueryChange} onResults={onResults} />);
|
||||
|
||||
expect(input).toHaveValue('');
|
||||
});
|
||||
|
||||
@ -39,6 +39,25 @@ test('selecting a file through the hidden input uploads it', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('a failed upload shows an error message and keeps the modal open for retry', async () => {
|
||||
vi.mocked(uploadDocument).mockRejectedValue(new Error('Failed to upload document: 400'));
|
||||
const onUploaded = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<UploadButton path="产品文档" onUploaded={onUploaded} />);
|
||||
await user.click(screen.getByRole('button', { name: '上传文档' }));
|
||||
|
||||
const file = new File(['data'], '恶意程序.exe', { type: 'application/octet-stream' });
|
||||
const input = screen.getByTestId('upload-file-input');
|
||||
await user.upload(input, file);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('upload-error')).toHaveTextContent('Failed to upload document: 400');
|
||||
});
|
||||
expect(screen.getByTestId('upload-modal')).toBeInTheDocument();
|
||||
expect(onUploaded).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('dropping a file onto the dropzone uploads it', async () => {
|
||||
vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 });
|
||||
const onUploaded = vi.fn();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user