- 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
74 lines
2.3 KiB
TypeScript
74 lines
2.3 KiB
TypeScript
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, 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');
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(search).mockReset();
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test('debounces input for 300ms before calling search', async () => {
|
|
vi.mocked(search).mockResolvedValue({ results: [] });
|
|
const onQueryChange = vi.fn();
|
|
const onResults = vi.fn();
|
|
|
|
render(<SearchBar value="" onQueryChange={onQueryChange} onResults={onResults} />);
|
|
|
|
const input = screen.getByRole('textbox') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: '检索' } });
|
|
|
|
expect(onQueryChange).toHaveBeenLastCalledWith('检索');
|
|
expect(search).not.toHaveBeenCalled();
|
|
|
|
vi.advanceTimersByTime(300);
|
|
|
|
expect(search).toHaveBeenCalledWith('检索');
|
|
});
|
|
|
|
test('clearing the input immediately reports empty results without calling search', async () => {
|
|
vi.mocked(search).mockResolvedValue({ results: [] });
|
|
const onResults = vi.fn();
|
|
|
|
render(<ControlledSearchBar onResults={onResults} />);
|
|
|
|
const input = screen.getByRole('textbox') as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: 'x' } });
|
|
vi.advanceTimersByTime(300);
|
|
fireEvent.change(input, { target: { value: '' } });
|
|
|
|
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('');
|
|
});
|