FileSelector.test.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. import { describe, it, expect, vi, beforeEach } from 'vitest';
  2. import { render, screen, fireEvent, waitFor } from '@testing-library/react';
  3. import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
  4. import React from 'react';
  5. import FileSelector from '../../src/components/FileSelector';
  6. // 完整的mock响应对象
  7. const createMockResponse = (status: number, data?: any) => ({
  8. status,
  9. ok: status >= 200 && status < 300,
  10. body: null,
  11. bodyUsed: false,
  12. statusText: status === 200 ? 'OK' : status === 201 ? 'Created' : status === 204 ? 'No Content' : 'Error',
  13. headers: new Headers(),
  14. url: '',
  15. redirected: false,
  16. type: 'basic' as ResponseType,
  17. json: async () => data || {},
  18. text: async () => '',
  19. blob: async () => new Blob(),
  20. arrayBuffer: async () => new ArrayBuffer(0),
  21. formData: async () => new FormData(),
  22. clone: function() { return this; }
  23. });
  24. // Mock API客户端
  25. vi.mock('../../src/api/fileClient', () => {
  26. const mockFileClient = {
  27. index: {
  28. $get: vi.fn(() => Promise.resolve(createMockResponse(200, {
  29. data: [],
  30. pagination: { current: 1, pageSize: 50, total: 0 }
  31. }))),
  32. },
  33. ':id': {
  34. $get: vi.fn(() => Promise.resolve(createMockResponse(200, {}))),
  35. },
  36. };
  37. const mockFileClientManager = {
  38. get: vi.fn(() => mockFileClient),
  39. };
  40. return {
  41. fileClientManager: mockFileClientManager,
  42. fileClient: mockFileClient,
  43. };
  44. });
  45. // Mock 文件上传组件
  46. vi.mock('../../src/components/MinioUploader', () => ({
  47. default: ({ onUploadSuccess, testId }: { onUploadSuccess?: (key: string, url: string, file: File) => void; testId?: string }) => {
  48. // 提供一个测试辅助方法来触发上传成功
  49. const triggerUpload = () => {
  50. if (onUploadSuccess) {
  51. const testFile = new File(['test'], 'test-upload.jpg', { type: 'image/jpeg' });
  52. onUploadSuccess('test-key', 'http://example.com/test.jpg', testFile);
  53. }
  54. };
  55. // 将触发方法挂载到 DOM 元素上供测试调用
  56. React.useEffect(() => {
  57. if (testId) {
  58. const element = document.querySelector(`[data-testid="${testId}"]`);
  59. if (element) {
  60. (element as any).__triggerUpload = triggerUpload;
  61. }
  62. }
  63. }, [testId, onUploadSuccess]);
  64. return React.createElement('div', { 'data-testid': testId || 'minio-uploader' });
  65. },
  66. }));
  67. describe('FileSelector', () => {
  68. let queryClient: QueryClient;
  69. beforeEach(() => {
  70. queryClient = new QueryClient({
  71. defaultOptions: {
  72. queries: { retry: false },
  73. mutations: { retry: false },
  74. },
  75. });
  76. vi.clearAllMocks();
  77. });
  78. const renderWithQueryClient = (component: React.ReactElement) => {
  79. return render(
  80. <QueryClientProvider client={queryClient}>
  81. {component}
  82. </QueryClientProvider>
  83. );
  84. };
  85. const mockFiles = [
  86. {
  87. id: 1,
  88. name: 'test-image.jpg',
  89. type: 'image/jpeg',
  90. size: 1024,
  91. fullUrl: 'http://example.com/test-image.jpg',
  92. uploadTime: '2024-01-01T00:00:00Z',
  93. },
  94. {
  95. id: 2,
  96. name: 'test-document.pdf',
  97. type: 'application/pdf',
  98. size: 2048,
  99. fullUrl: 'http://example.com/test-document.pdf',
  100. uploadTime: '2024-01-01T00:00:00Z',
  101. },
  102. ];
  103. it('应该渲染文件选择器', () => {
  104. renderWithQueryClient(
  105. <FileSelector value={null} onChange={() => {}} />
  106. );
  107. expect(screen.getByTestId('file-selector-button')).toBeInTheDocument();
  108. });
  109. it('应该打开选择对话框', async () => {
  110. const { fileClient } = await import('../../src/api/fileClient');
  111. (fileClient.index.$get as any).mockResolvedValue(createMockResponse(200, {
  112. data: mockFiles,
  113. pagination: { current: 1, pageSize: 50, total: 2 }
  114. }));
  115. renderWithQueryClient(
  116. <FileSelector value={null} onChange={() => {}} />
  117. );
  118. const selectButton = screen.getByTestId('file-selector-button');
  119. fireEvent.click(selectButton);
  120. await waitFor(() => {
  121. expect(screen.getByTestId('file-selector-dialog')).toBeInTheDocument();
  122. expect(screen.getByRole('heading', { name: '选择文件' })).toBeInTheDocument();
  123. expect(screen.getByText('上传新文件或从已有文件中选择')).toBeInTheDocument();
  124. });
  125. });
  126. it('应该显示已选文件预览', async () => {
  127. const { fileClient } = await import('../../src/api/fileClient');
  128. (fileClient[':id'].$get as any).mockResolvedValue(createMockResponse(200, mockFiles[0]));
  129. renderWithQueryClient(
  130. <FileSelector value={1} onChange={() => {}} showPreview={true} />
  131. );
  132. await waitFor(() => {
  133. expect(screen.getByText('更换文件')).toBeInTheDocument();
  134. });
  135. });
  136. it('应该支持多选模式', async () => {
  137. const { fileClient } = await import('../../src/api/fileClient');
  138. (fileClient.index.$get as any).mockResolvedValue(createMockResponse(200, {
  139. data: mockFiles,
  140. pagination: { current: 1, pageSize: 50, total: 2 }
  141. }));
  142. const onChange = vi.fn();
  143. renderWithQueryClient(
  144. <FileSelector
  145. value={[1, 2]}
  146. onChange={onChange}
  147. allowMultiple={true}
  148. showPreview={true}
  149. />
  150. );
  151. await waitFor(() => {
  152. expect(screen.getByText('已选择 2 个文件')).toBeInTheDocument();
  153. });
  154. });
  155. it('应该过滤文件类型', async () => {
  156. const { fileClient } = await import('../../src/api/fileClient');
  157. (fileClient.index.$get as any).mockResolvedValue(createMockResponse(200, {
  158. data: mockFiles,
  159. pagination: { current: 1, pageSize: 50, total: 2 }
  160. }));
  161. renderWithQueryClient(
  162. <FileSelector
  163. value={null}
  164. onChange={() => {}}
  165. filterType="image"
  166. />
  167. );
  168. const selectButton = screen.getByTestId('file-selector-button');
  169. fireEvent.click(selectButton);
  170. await waitFor(() => {
  171. expect(fileClient.index.$get).toHaveBeenCalledWith({
  172. query: {
  173. page: 1,
  174. pageSize: 50,
  175. keyword: 'image'
  176. }
  177. });
  178. });
  179. });
  180. it('应该处理文件选择确认', async () => {
  181. const { fileClient } = await import('../../src/api/fileClient');
  182. (fileClient.index.$get as any).mockResolvedValue(createMockResponse(200, {
  183. data: mockFiles,
  184. pagination: { current: 1, pageSize: 50, total: 2 }
  185. }));
  186. const onChange = vi.fn();
  187. renderWithQueryClient(
  188. <FileSelector value={null} onChange={onChange} />
  189. );
  190. const selectButton = screen.getByTestId('file-selector-button');
  191. fireEvent.click(selectButton);
  192. await waitFor(() => {
  193. const fileItems = screen.getAllByText('test-image.jpg');
  194. fireEvent.click(fileItems[0]);
  195. });
  196. const confirmButton = screen.getByText('确认选择');
  197. fireEvent.click(confirmButton);
  198. expect(onChange).toHaveBeenCalledWith(1);
  199. });
  200. describe('uploadOnly 模式', () => {
  201. it('uploadOnly=true 时不应该调用文件列表查询 API', async () => {
  202. const { fileClient } = await import('../../src/api/fileClient');
  203. (fileClient.index.$get as any).mockResolvedValue(createMockResponse(200, {
  204. data: mockFiles,
  205. pagination: { current: 1, pageSize: 50, total: 2 }
  206. }));
  207. renderWithQueryClient(
  208. <FileSelector
  209. value={null}
  210. onChange={() => {}}
  211. uploadOnly={true}
  212. />
  213. );
  214. const selectButton = screen.getByTestId('file-selector-button');
  215. fireEvent.click(selectButton);
  216. await waitFor(() => {
  217. expect(screen.getByTestId('file-selector-dialog')).toBeInTheDocument();
  218. // 验证描述文本显示为上传模式
  219. expect(screen.getByText('请上传文件')).toBeInTheDocument();
  220. });
  221. // 验证没有调用文件列表 API(因为 uploadOnly=true 禁用了查询)
  222. expect(fileClient.index.$get).not.toHaveBeenCalled();
  223. });
  224. it('uploadOnly 模式下对话框应该只显示上传区域', async () => {
  225. renderWithQueryClient(
  226. <FileSelector
  227. value={null}
  228. onChange={() => {}}
  229. uploadOnly={true}
  230. />
  231. );
  232. const selectButton = screen.getByTestId('file-selector-button');
  233. fireEvent.click(selectButton);
  234. await waitFor(() => {
  235. expect(screen.getByTestId('file-selector-dialog')).toBeInTheDocument();
  236. // 验证上传组件存在
  237. expect(screen.getByTestId('minio-uploader')).toBeInTheDocument();
  238. });
  239. // 验证不显示确认/取消按钮
  240. expect(screen.queryByText('取消')).not.toBeInTheDocument();
  241. expect(screen.queryByText('确认选择')).not.toBeInTheDocument();
  242. });
  243. it('uploadOnly 模式与 allowMultiple 多选模式兼容', async () => {
  244. const { fileClient } = await import('../../src/api/fileClient');
  245. (fileClient.index.$get as any).mockResolvedValue(createMockResponse(200, {
  246. data: mockFiles,
  247. pagination: { current: 1, pageSize: 50, total: 2 }
  248. }));
  249. const onChange = vi.fn();
  250. renderWithQueryClient(
  251. <FileSelector
  252. value={null}
  253. onChange={onChange}
  254. uploadOnly={true}
  255. allowMultiple={true}
  256. />
  257. );
  258. const selectButton = screen.getByTestId('file-selector-button');
  259. fireEvent.click(selectButton);
  260. await waitFor(() => {
  261. expect(screen.getByTestId('file-selector-dialog')).toBeInTheDocument();
  262. expect(screen.getByTestId('minio-uploader')).toBeInTheDocument();
  263. });
  264. // 验证没有调用文件列表 API
  265. expect(fileClient.index.$get).not.toHaveBeenCalled();
  266. });
  267. it('uploadOnly=false 或未设置时,行为与原组件一致', async () => {
  268. const { fileClient } = await import('../../src/api/fileClient');
  269. (fileClient.index.$get as any).mockResolvedValue(createMockResponse(200, {
  270. data: mockFiles,
  271. pagination: { current: 1, pageSize: 50, total: 2 }
  272. }));
  273. renderWithQueryClient(
  274. <FileSelector
  275. value={null}
  276. onChange={() => {}}
  277. uploadOnly={false}
  278. />
  279. );
  280. const selectButton = screen.getByTestId('file-selector-button');
  281. fireEvent.click(selectButton);
  282. await waitFor(() => {
  283. expect(screen.getByTestId('file-selector-dialog')).toBeInTheDocument();
  284. // 验证显示默认描述文本
  285. expect(screen.getByText('上传新文件或从已有文件中选择')).toBeInTheDocument();
  286. });
  287. // 验证调用了文件列表 API
  288. expect(fileClient.index.$get).toHaveBeenCalledWith({
  289. query: {
  290. page: 1,
  291. pageSize: 50,
  292. }
  293. });
  294. });
  295. it('uploadOnly 模式下不显示文件列表', async () => {
  296. renderWithQueryClient(
  297. <FileSelector
  298. value={null}
  299. onChange={() => {}}
  300. uploadOnly={true}
  301. />
  302. );
  303. const selectButton = screen.getByTestId('file-selector-button');
  304. fireEvent.click(selectButton);
  305. await waitFor(() => {
  306. expect(screen.getByTestId('file-selector-dialog')).toBeInTheDocument();
  307. });
  308. // 验证不显示现有文件
  309. expect(screen.queryByText('test-image.jpg')).not.toBeInTheDocument();
  310. expect(screen.queryByText('test-document.pdf')).not.toBeInTheDocument();
  311. });
  312. it('uploadOnly 模式下上传成功后自动选择文件并关闭对话框', async () => {
  313. const { fileClient } = await import('../../src/api/fileClient');
  314. // Mock 文件列表 API,返回刚上传的文件
  315. const uploadedFile = {
  316. id: 999,
  317. name: 'test-upload.jpg',
  318. type: 'image/jpeg',
  319. size: 4, // File(['test']) 的 size 是 4
  320. fullUrl: 'http://example.com/test-upload.jpg',
  321. uploadTime: new Date().toISOString(),
  322. };
  323. (fileClient.index.$get as any).mockResolvedValue(createMockResponse(200, {
  324. data: [uploadedFile],
  325. pagination: { current: 1, pageSize: 50, total: 1 }
  326. }));
  327. const onChange = vi.fn();
  328. renderWithQueryClient(
  329. <FileSelector
  330. value={null}
  331. onChange={onChange}
  332. uploadOnly={true}
  333. testId="photo-upload-0"
  334. />
  335. );
  336. // 打开对话框
  337. const selectButton = screen.getByTestId('file-selector-button');
  338. fireEvent.click(selectButton);
  339. await waitFor(() => {
  340. expect(screen.getByTestId('file-selector-dialog')).toBeInTheDocument();
  341. });
  342. // 触发上传成功(通过挂载的测试辅助方法)
  343. const uploaderElement = document.querySelector('[data-testid="photo-upload-0"]');
  344. expect(uploaderElement).toBeInTheDocument();
  345. (uploaderElement as any).__triggerUpload();
  346. // 等待 API 被调用
  347. await waitFor(() => {
  348. expect(fileClient.index.$get).toHaveBeenCalled();
  349. });
  350. // 验证 onChange 被调用,返回正确的 fileId
  351. await waitFor(() => {
  352. expect(onChange).toHaveBeenCalledWith(999);
  353. });
  354. // 验证对话框已关闭
  355. await waitFor(() => {
  356. expect(screen.queryByTestId('file-selector-dialog')).not.toBeInTheDocument();
  357. });
  358. });
  359. });
  360. });