// REQ-USR-003: useUserList 列表查询 hook 状态机单测 // 覆盖 initialLoading/loading/success/empty/error/exporting + BR2/BR7/BR8/BR10/BR11/BR15 + 错误码分流 import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { ReactNode } from 'react'; import { renderHook, act, waitFor } from '@testing-library/react'; import { App as AntdApp, ConfigProvider } from 'antd'; // 桩 message:保留 antd 其余真实导出,仅覆盖 App.useApp 返回的 message const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn() }; vi.mock('antd', async () => { const actual = await vi.importActual('antd'); return { ...actual, App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }), }; }); // 桩 listUsers vi.mock('../../src/api/usrApi', () => ({ listUsers: vi.fn(), })); // 桩 CSV 下载(不真正触发 jsdom 下载) vi.mock('../../src/pages/usr/UserList/exportUtils', async () => { const actual = await vi.importActual< typeof import('../../src/pages/usr/UserList/exportUtils') >('../../src/pages/usr/UserList/exportUtils'); return { ...actual, downloadCsv: vi.fn() }; }); import { listUsers } from '../../src/api/usrApi'; import { downloadCsv } from '../../src/pages/usr/UserList/exportUtils'; import { useUserList } from '../../src/pages/usr/UserList/useUserList'; import { DEFAULT_QUERY } from '../../src/pages/usr/UserList/constants'; import { ApiError } from '../../src/api/request'; import type { UserVO } from '../../src/api/types'; const mockedList = listUsers as unknown as ReturnType; const mockedDownload = downloadCsv as unknown as ReturnType; function makeUser(id: number, name = `u${id}`): UserVO { return { id, sUserName: name, employeeName: null, sUserNo: null, departmentName: null, sUserType: '普通用户', sLanguage: '中文', iIsVoid: 0, tLastLoginDate: null, sCreator: 'admin', tCreateDate: '2024-01-01T00:00:00', }; } function page(records: UserVO[], total: number, pageNum = 1, pageSize = 10) { return { records, total, pageNum, pageSize }; } function wrapper({ children }: { children: ReactNode }) { return ( {children} ); } function lastCallQuery() { const calls = mockedList.mock.calls; return calls[calls.length - 1][0]; } describe('useUserList 状态机', () => { beforeEach(() => { vi.clearAllMocks(); }); it('mounts with default query and loads first page (initialLoading→success)', async () => { mockedList.mockResolvedValue(page([makeUser(1), makeUser(2)], 2, 1, 10)); const { result } = renderHook(() => useUserList(), { wrapper }); // 挂载即以默认 query 调 listUsers await waitFor(() => expect(mockedList).toHaveBeenCalled()); expect(mockedList.mock.calls[0][0]).toMatchObject({ queryField: '用户名', matchType: '包含', pageNum: 1, pageSize: 10, }); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.list).toHaveLength(2); expect(result.current.total).toBe(2); expect(result.current.query.pageNum).toBe(1); }); it('empty records sets empty state without error', async () => { mockedList.mockResolvedValue(page([], 0, 1, 10)); const { result } = renderHook(() => useUserList(), { wrapper }); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.list).toEqual([]); expect(result.current.total).toBe(0); expect(result.current.error).toBeNull(); expect(messageSpy.error).not.toHaveBeenCalled(); }); it('search resets to page 1 and refetches with current filters (BR7)', async () => { mockedList.mockResolvedValue(page([makeUser(1)], 1, 1, 10)); const { result } = renderHook(() => useUserList(), { wrapper }); await waitFor(() => expect(result.current.loading).toBe(false)); act(() => { result.current.setQueryField('员工名'); result.current.setQueryValue('李'); }); act(() => { result.current.search(); }); await waitFor(() => { const q = lastCallQuery(); expect(q.queryField).toBe('员工名'); expect(q.queryValue).toBe('李'); expect(q.pageNum).toBe(1); }); }); it('refresh keeps current query and page (BR8)', async () => { mockedList.mockResolvedValue(page([makeUser(1)], 30, 2, 10)); const { result } = renderHook(() => useUserList(), { wrapper }); await waitFor(() => expect(result.current.loading).toBe(false)); act(() => { result.current.changePage(2, 10); }); await waitFor(() => expect(result.current.query.pageNum).toBe(2)); mockedList.mockClear(); act(() => { result.current.refresh(); }); await waitFor(() => expect(mockedList).toHaveBeenCalled()); expect(lastCallQuery().pageNum).toBe(2); }); it('clear resets to DEFAULT_QUERY then refetches (BR10)', async () => { mockedList.mockResolvedValue(page([makeUser(1)], 1, 1, 10)); const { result } = renderHook(() => useUserList(), { wrapper }); await waitFor(() => expect(result.current.loading).toBe(false)); act(() => { result.current.setQueryField('部门'); result.current.setQueryValue('技术'); }); act(() => { result.current.clear(); }); await waitFor(() => { expect(result.current.query.queryField).toBe(DEFAULT_QUERY.queryField); expect(result.current.query.queryValue).toBe(''); expect(result.current.query.pageNum).toBe(1); }); expect(lastCallQuery().queryField).toBe('用户名'); }); it('changePage refetch; changing pageSize resets to page 1 (BR11)', async () => { mockedList.mockResolvedValue(page([makeUser(1)], 100, 3, 10)); const { result } = renderHook(() => useUserList(), { wrapper }); await waitFor(() => expect(result.current.loading).toBe(false)); act(() => { result.current.changePage(3, 10); }); await waitFor(() => expect(lastCallQuery().pageNum).toBe(3)); // 改 pageSize(10→50)应回第 1 页 act(() => { result.current.changePage(3, 50); }); await waitFor(() => { const q = lastCallQuery(); expect(q.pageSize).toBe(50); expect(q.pageNum).toBe(1); }); }); it('ApiError 40001 keeps filters and shows error, sets error state', async () => { // 首次挂载成功,之后搜索失败 40001 mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); const { result } = renderHook(() => useUserList(), { wrapper }); await waitFor(() => expect(result.current.loading).toBe(false)); act(() => { result.current.setQueryValue('张'); }); mockedList.mockRejectedValueOnce(new ApiError(40001, '查询参数校验失败')); act(() => { result.current.search(); }); await waitFor(() => expect(result.current.error).not.toBeNull()); expect(result.current.error?.code).toBe(40001); expect(result.current.query.queryValue).toBe('张'); // 条件保留 expect(messageSpy.error).toHaveBeenCalled(); // 不自动重查:失败后调用次数 = 挂载1 + 搜索1 = 2 expect(mockedList).toHaveBeenCalledTimes(2); }); it('ApiError 42201 warns and refetches at page 1', async () => { mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); const { result } = renderHook(() => useUserList(), { wrapper }); await waitFor(() => expect(result.current.loading).toBe(false)); // 翻到第 3 页 → 42201 → 应重置 pageNum=1 并重查 mockedList.mockClear(); mockedList.mockRejectedValueOnce(new ApiError(42201, '分页参数非法')); mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); act(() => { result.current.changePage(3, 10); }); await waitFor(() => expect(messageSpy.warning).toHaveBeenCalled()); await waitFor(() => expect(result.current.query.pageNum).toBe(1)); // 重查发生 expect(lastCallQuery().pageNum).toBe(1); }); it('network error (code -1) sets error state', async () => { mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); const { result } = renderHook(() => useUserList(), { wrapper }); await waitFor(() => expect(result.current.loading).toBe(false)); mockedList.mockRejectedValueOnce(new ApiError(-1, '网络异常')); act(() => { result.current.refresh(); }); await waitFor(() => expect(result.current.error).not.toBeNull()); expect(result.current.error?.code).toBe(-1); expect(messageSpy.error).toHaveBeenCalled(); }); it('response pageNum echo syncs pagination (BR15)', async () => { // 请求 pageNum=99 越界,后端回退最后一页 pageNum=5 mockedList.mockResolvedValueOnce(page([makeUser(1)], 50, 1, 10)); const { result } = renderHook(() => useUserList(), { wrapper }); await waitFor(() => expect(result.current.loading).toBe(false)); mockedList.mockResolvedValueOnce(page([makeUser(1)], 50, 5, 10)); act(() => { result.current.changePage(99, 10); }); await waitFor(() => expect(result.current.query.pageNum).toBe(5)); expect(result.current.total).toBe(50); }); it('exportExcel toggles exporting and downloads (BR9)', async () => { mockedList.mockResolvedValue(page([makeUser(1), makeUser(2)], 2, 1, 10)); const { result } = renderHook(() => useUserList(), { wrapper }); await waitFor(() => expect(result.current.loading).toBe(false)); await act(async () => { await result.current.exportExcel(); }); expect(mockedDownload).toHaveBeenCalledTimes(1); expect(result.current.exporting).toBe(false); expect(messageSpy.success).toHaveBeenCalledWith('导出成功'); }); it('exportExcel failure shows 导出失败', async () => { mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); const { result } = renderHook(() => useUserList(), { wrapper }); await waitFor(() => expect(result.current.loading).toBe(false)); mockedList.mockRejectedValueOnce(new ApiError(-1, '网络异常')); await act(async () => { await result.current.exportExcel(); }); expect(messageSpy.error).toHaveBeenCalledWith('导出失败'); expect(result.current.exporting).toBe(false); }); });