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:
Tianyang 2026-08-01 08:59:49 +08:00
parent 13789cc0b4
commit 7e218b5738
11 changed files with 351 additions and 44 deletions

View File

@ -10,34 +10,67 @@ export default function App() {
const [selectedKey, setSelectedKey] = useState<string | null>(null); const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<SearchResult[]>([]); 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) { function handleJumpToResult(key: string) {
if (!confirmDiscardIfDirty()) {
return;
}
setSelectedKey(key); setSelectedKey(key);
setSearchQuery(''); setSearchQuery('');
setSearchResults([]); setSearchResults([]);
setIsSearching(false);
}
function handleQueryChange(query: string) {
setSearchQuery(query);
setIsSearching(query.trim() !== '');
}
function handleResults(results: SearchResult[]) {
setSearchResults(results);
setIsSearching(false);
} }
function handleUploaded(result: UploadDocumentResponse) { function handleUploaded(result: UploadDocumentResponse) {
setSelectedKey(result.key); setSelectedKey(result.key);
setRefreshKey((prev) => prev + 1);
} }
return ( return (
<div className="app"> <div className="app">
<div className="topbar"> <div className="topbar">
<h1>DocHub</h1> <h1>DocHub</h1>
<SearchBar onQueryChange={setSearchQuery} onResults={setSearchResults} /> <SearchBar value={searchQuery} onQueryChange={handleQueryChange} onResults={handleResults} />
<UploadButton path="" onUploaded={handleUploaded} /> <UploadButton path={uploadPath} onUploaded={handleUploaded} />
</div> </div>
<div className="body"> <div className="body">
<FileExplorer onSelectFile={setSelectedKey} selectedKey={selectedKey} /> <FileExplorer onSelectFile={handleSelectFile} selectedKey={selectedKey} refreshKey={refreshKey} />
<div className="main" data-testid="main-area"> <div className="main" data-testid="main-area">
{selectedKey ? ( {selectedKey ? (
<DocumentEditor documentKey={selectedKey} /> <DocumentEditor documentKey={selectedKey} onDirtyChange={setIsDirty} />
) : ( ) : (
<div data-testid="empty-state"></div> <div data-testid="empty-state"></div>
)} )}
</div> </div>
{searchQuery !== '' && ( {searchQuery !== '' && !isSearching && (
<SearchResults query={searchQuery} results={searchResults} onJumpToResult={handleJumpToResult} /> <SearchResults query={searchQuery} results={searchResults} onJumpToResult={handleJumpToResult} />
)} )}
</div> </div>

View File

@ -4,19 +4,22 @@ import { getDocument, saveDocument } from '../api/client';
interface DocumentEditorProps { interface DocumentEditorProps {
documentKey: string; documentKey: string;
onDirtyChange?: (isDirty: boolean) => void;
} }
export default function DocumentEditor({ documentKey }: DocumentEditorProps) { export default function DocumentEditor({ documentKey, onDirtyChange }: DocumentEditorProps) {
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [originalContent, setOriginalContent] = useState(''); const [originalContent, setOriginalContent] = useState('');
const [contentType, setContentType] = useState('text/markdown'); const [contentType, setContentType] = useState('text/markdown');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); 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(() => { useEffect(() => {
setLoading(true); setLoading(true);
setError(null); setLoadError(null);
setSaveError(null);
getDocument(documentKey) getDocument(documentKey)
.then((doc) => { .then((doc) => {
setContent(doc.content); setContent(doc.content);
@ -25,26 +28,31 @@ export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
setLoading(false); setLoading(false);
}) })
.catch(() => { .catch(() => {
setError('无法加载文档'); setLoadError('无法加载文档');
setLoading(false); setLoading(false);
}); });
}, [documentKey]); }, [documentKey]);
const isDirty = content !== originalContent; const isDirty = content !== originalContent;
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
function handleChange(value: string | undefined) { function handleChange(value: string | undefined) {
setContent(value ?? ''); setContent(value ?? '');
} }
function handleSave() { function handleSave() {
setSaving(true); setSaving(true);
setSaveError(null);
saveDocument(documentKey, content, contentType) saveDocument(documentKey, content, contentType)
.then(() => { .then(() => {
setOriginalContent(content); setOriginalContent(content);
setSaving(false); setSaving(false);
}) })
.catch(() => { .catch(() => {
setError('保存失败'); setSaveError('保存失败,请重试');
setSaving(false); setSaving(false);
}); });
} }
@ -53,8 +61,8 @@ export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
return <div data-testid="document-editor-loading"></div>; return <div data-testid="document-editor-loading"></div>;
} }
if (error) { if (loadError) {
return <div data-testid="document-editor-error">{error}</div>; return <div data-testid="document-editor-error">{loadError}</div>;
} }
return ( return (
@ -64,6 +72,14 @@ export default function DocumentEditor({ documentKey }: DocumentEditorProps) {
<button onClick={handleSave} disabled={!isDirty || saving}> <button onClick={handleSave} disabled={!isDirty || saving}>
{saving ? '保存中…' : '保存'} {saving ? '保存中…' : '保存'}
</button> </button>
{saveError && (
<span data-testid="save-error">
{saveError}
<button onClick={() => setSaveError(null)} aria-label="关闭错误提示">
×
</button>
</span>
)}
</div> </div>
<Editor value={content} onChange={handleChange} language={contentType === 'text/markdown' ? 'markdown' : 'html'} /> <Editor value={content} onChange={handleChange} language={contentType === 'text/markdown' ? 'markdown' : 'html'} />
</div> </div>

View File

@ -4,9 +4,10 @@ import { listDocuments, type DocumentFile } from '../api/client';
interface FileExplorerProps { interface FileExplorerProps {
onSelectFile: (key: string) => void; onSelectFile: (key: string) => void;
selectedKey?: string | null; 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 [folders, setFolders] = useState<string[]>([]);
const [files, setFiles] = useState<DocumentFile[]>([]); const [files, setFiles] = useState<DocumentFile[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -18,7 +19,7 @@ export default function FileExplorer({ onSelectFile, selectedKey }: FileExplorer
setFiles(data.files); setFiles(data.files);
}) })
.catch(() => setError('无法加载文档目录')); .catch(() => setError('无法加载文档目录'));
}, []); }, [refreshKey]);
if (error) { if (error) {
return <div data-testid="file-explorer-error">{error}</div>; return <div data-testid="file-explorer-error">{error}</div>;

View File

@ -1,14 +1,14 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef } from 'react';
import { search, type SearchResult } from '../api/client'; import { search, type SearchResult } from '../api/client';
interface SearchBarProps { interface SearchBarProps {
value: string;
onQueryChange: (query: string) => void; onQueryChange: (query: string) => void;
onResults: (results: SearchResult[]) => void; onResults: (results: SearchResult[]) => void;
debounceMs?: number; debounceMs?: number;
} }
export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) { export default function SearchBar({ value, onQueryChange, onResults, debounceMs = 300 }: SearchBarProps) {
const [value, setValue] = useState('');
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { useEffect(() => {
@ -21,7 +21,6 @@ export default function SearchBar({ onQueryChange, onResults, debounceMs = 300 }
function handleChange(event: React.ChangeEvent<HTMLInputElement>) { function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const nextValue = event.target.value; const nextValue = event.target.value;
setValue(nextValue);
onQueryChange(nextValue); onQueryChange(nextValue);
if (timerRef.current) { if (timerRef.current) {

View File

@ -9,6 +9,7 @@ interface UploadButtonProps {
export default function UploadButton({ path, onUploaded }: UploadButtonProps) { export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
function openModal() { function openModal() {
@ -18,12 +19,18 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
function closeModal() { function closeModal() {
setIsOpen(false); setIsOpen(false);
setIsDragOver(false); setIsDragOver(false);
setUploadError(null);
} }
function uploadFiles(files: FileList | File[]) { function uploadFiles(files: FileList | File[]) {
Array.from(files).forEach((file) => { Array.from(files).forEach((file) => {
uploadDocument(file, path).then((result) => { setUploadError(null);
uploadDocument(file, path)
.then((result) => {
onUploaded(result); onUploaded(result);
})
.catch((err) => {
setUploadError(err instanceof Error ? err.message : '上传失败');
}); });
}); });
} }
@ -68,6 +75,7 @@ export default function UploadButton({ path, onUploaded }: UploadButtonProps) {
> >
</div> </div>
{uploadError && <div data-testid="upload-error">{uploadError}</div>}
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"

View File

@ -1,9 +1,9 @@
import { render, screen } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import { test, expect, beforeEach, vi } from 'vitest'; import { test, expect, beforeEach, afterEach, vi } from 'vitest';
import App from '../src/App'; import App from '../src/App';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { waitFor } from '@testing-library/react'; 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'); vi.mock('../src/api/client');
@ -21,6 +21,7 @@ beforeEach(() => {
vi.mocked(listDocuments).mockReset(); vi.mocked(listDocuments).mockReset();
vi.mocked(getDocument).mockReset(); vi.mocked(getDocument).mockReset();
vi.mocked(search).mockReset(); vi.mocked(search).mockReset();
vi.mocked(uploadDocument).mockReset();
vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] }); vi.mocked(listDocuments).mockResolvedValue({ folders: [], files: [] });
vi.mocked(getDocument).mockResolvedValue({ vi.mocked(getDocument).mockResolvedValue({
key: '', key: '',
@ -30,6 +31,10 @@ beforeEach(() => {
vi.mocked(search).mockResolvedValue({ results: [] }); vi.mocked(search).mockResolvedValue({ results: [] });
}); });
afterEach(() => {
vi.restoreAllMocks();
});
test('renders the DocHub heading', () => { test('renders the DocHub heading', () => {
render(<App />); render(<App />);
expect(screen.getByRole('heading', { name: 'DocHub' })).toBeInTheDocument(); 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'); const textarea = await screen.findByTestId('monaco-editor-mock');
expect(textarea).toHaveValue('# 架构设计'); 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);
});
});

View File

@ -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'; import { listDocuments, getDocument, saveDocument, deleteDocument, uploadDocument, search } from '../../src/api/client';
beforeEach(() => { beforeEach(() => {
@ -10,33 +10,33 @@ test('listDocuments calls GET /api/documents with the path query param and retur
folders: ['产品文档'], folders: ['产品文档'],
files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 512, modifiedAt: '2026-08-01T10:00:00Z' }], 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, ok: true,
json: () => Promise.resolve(mockResponse), json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch; }) as unknown as typeof fetch;
const result = await listDocuments('产品文档'); const result = await listDocuments('产品文档');
expect(global.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`); expect(globalThis.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`);
expect(result).toEqual(mockResponse); expect(result).toEqual(mockResponse);
}); });
test('listDocuments throws when the response is not ok', async () => { 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'); await expect(listDocuments('')).rejects.toThrow('Failed to list documents: 500');
}); });
test('getDocument calls GET /api/documents/{key} preserving slashes in the key', async () => { test('getDocument calls GET /api/documents/{key} preserving slashes in the key', async () => {
const mockResponse = { key: '产品文档/架构设计.md', content: '# 架构设计', contentType: 'text/markdown' }; const mockResponse = { key: '产品文档/架构设计.md', content: '# 架构设计', contentType: 'text/markdown' };
global.fetch = vi.fn().mockResolvedValue({ globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve(mockResponse), json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch; }) as unknown as typeof fetch;
const result = await getDocument('产品文档/架构设计.md'); const result = await getDocument('产品文档/架构设计.md');
expect(global.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}` `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`
); );
expect(result).toEqual(mockResponse); 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 () => { test('saveDocument calls PUT /api/documents/{key} with content and contentType', async () => {
const mockResponse = { key: '产品文档/架构设计.md', updatedAt: '2026-08-01T10:05:00Z' }; const mockResponse = { key: '产品文档/架构设计.md', updatedAt: '2026-08-01T10:05:00Z' };
global.fetch = vi.fn().mockResolvedValue({ globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve(mockResponse), json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch; }) as unknown as typeof fetch;
const result = await saveDocument('产品文档/架构设计.md', '# 新内容', 'text/markdown'); const result = await saveDocument('产品文档/架构设计.md', '# 新内容', 'text/markdown');
expect(global.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`, `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
{ {
method: 'PUT', 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 () => { 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(); await expect(deleteDocument('产品文档/架构设计.md')).resolves.toBeUndefined();
expect(global.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`, `/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
{ method: 'DELETE' } { 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 () => { test('uploadDocument posts a multipart form with file and path fields', async () => {
const mockResponse = { key: '产品文档/新文档.md', name: '新文档.md', size: 42 }; const mockResponse = { key: '产品文档/新文档.md', name: '新文档.md', size: 42 };
global.fetch = vi.fn().mockResolvedValue({ globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve(mockResponse), json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch; }) 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, '产品文档'); const result = await uploadDocument(file, '产品文档');
expect(global.fetch).toHaveBeenCalledTimes(1); expect(globalThis.fetch).toHaveBeenCalledTimes(1);
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0]; const [url, init] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('/api/upload'); expect(url).toBe('/api/upload');
expect(init.method).toBe('POST'); expect(init.method).toBe('POST');
const body = init.body as FormData; 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>...' }, { key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' },
], ],
}; };
global.fetch = vi.fn().mockResolvedValue({ globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve(mockResponse), json: () => Promise.resolve(mockResponse),
}) as unknown as typeof fetch; }) as unknown as typeof fetch;
const result = await search('检索'); const result = await search('检索');
expect(global.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`); expect(globalThis.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`);
expect(result).toEqual(mockResponse); expect(result).toEqual(mockResponse);
}); });

View File

@ -70,3 +70,64 @@ test('typing marks the document dirty and saving clears the dirty state', async
expect(screen.getByTestId('save-status')).toHaveTextContent('已归档'); 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);
});
});

View File

@ -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 { vi, test, expect, beforeEach } from 'vitest';
import FileExplorer from '../../src/components/FileExplorer'; import FileExplorer from '../../src/components/FileExplorer';
import { listDocuments } from '../../src/api/client'; 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(); 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);
});
});

View File

@ -1,7 +1,19 @@
import { useState } from 'react';
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import { vi, test, expect, beforeEach, afterEach } from 'vitest'; import { vi, test, expect, beforeEach, afterEach } from 'vitest';
import SearchBar from '../../src/components/SearchBar'; 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'); vi.mock('../../src/api/client');
@ -19,7 +31,7 @@ test('debounces input for 300ms before calling search', async () => {
const onQueryChange = vi.fn(); const onQueryChange = vi.fn();
const onResults = 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; const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: '检索' } }); 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 () => { test('clearing the input immediately reports empty results without calling search', async () => {
vi.mocked(search).mockResolvedValue({ results: [] }); vi.mocked(search).mockResolvedValue({ results: [] });
const onQueryChange = vi.fn();
const onResults = vi.fn(); const onResults = vi.fn();
render(<SearchBar onQueryChange={onQueryChange} onResults={onResults} />); render(<ControlledSearchBar onResults={onResults} />);
const input = screen.getByRole('textbox') as HTMLInputElement; const input = screen.getByRole('textbox') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'x' } }); fireEvent.change(input, { target: { value: 'x' } });
@ -46,3 +57,17 @@ test('clearing the input immediately reports empty results without calling searc
expect(onResults).toHaveBeenCalledWith([]); 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('');
});

View File

@ -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 () => { test('dropping a file onto the dropzone uploads it', async () => {
vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 }); vi.mocked(uploadDocument).mockResolvedValue({ key: '产品文档/拖拽文档.md', name: '拖拽文档.md', size: 10 });
const onUploaded = vi.fn(); const onUploaded = vi.fn();