Commit 7241d98bdaf5e90214bc8873250996e53a928018
1 parent
a6460994
feat(usr): 用户列表查询 hook useUserList 状态机 REQ-USR-003
Showing
2 changed files
with
444 additions
and
0 deletions
frontend/src/pages/usr/UserList/useUserList.ts
0 → 100644
| 1 | +// REQ-USR-003: 用户列表查询 hook(状态机 initialLoading/loading/success/empty/error/exporting) | ||
| 2 | +import { useCallback, useEffect, useRef, useState } from 'react'; | ||
| 3 | +import { App as AntdApp } from 'antd'; | ||
| 4 | +import { listUsers } from '../../../api/usrApi'; | ||
| 5 | +import { ApiError } from '../../../api/request'; | ||
| 6 | +import type { UserVO, UserListQuery } from '../../../api/types'; | ||
| 7 | +import { buildUserCsv, downloadCsv } from './exportUtils'; | ||
| 8 | +import { | ||
| 9 | + DEFAULT_QUERY, | ||
| 10 | + ERR_PAGE_INVALID, | ||
| 11 | + ERR_QUERY_INVALID, | ||
| 12 | + EXPORT_FILENAME, | ||
| 13 | + TEXT_EXPORT_FAIL, | ||
| 14 | + TEXT_EXPORT_SUCCESS, | ||
| 15 | + TEXT_MSG_NETWORK, | ||
| 16 | + TEXT_MSG_PAGE_INVALID, | ||
| 17 | + TEXT_MSG_QUERY_INVALID, | ||
| 18 | +} from './constants'; | ||
| 19 | + | ||
| 20 | +export interface UseUserListReturn { | ||
| 21 | + list: UserVO[]; | ||
| 22 | + total: number; | ||
| 23 | + loading: boolean; | ||
| 24 | + error: ApiError | null; | ||
| 25 | + query: UserListQuery; | ||
| 26 | + exporting: boolean; | ||
| 27 | + search(): void; | ||
| 28 | + refresh(): void; | ||
| 29 | + clear(): void; | ||
| 30 | + setQueryField(v: string): void; | ||
| 31 | + setMatchType(v: string): void; | ||
| 32 | + setQueryValue(v: string): void; | ||
| 33 | + changePage(pageNum: number, pageSize: number): void; | ||
| 34 | + exportExcel(): Promise<void>; | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +export function useUserList(): UseUserListReturn { | ||
| 38 | + const { message } = AntdApp.useApp(); | ||
| 39 | + | ||
| 40 | + const [query, setQuery] = useState<UserListQuery>({ ...DEFAULT_QUERY }); | ||
| 41 | + const [list, setList] = useState<UserVO[]>([]); | ||
| 42 | + const [total, setTotal] = useState(0); | ||
| 43 | + const [loading, setLoading] = useState(true); // initialLoading | ||
| 44 | + const [error, setError] = useState<ApiError | null>(null); | ||
| 45 | + const [exporting, setExporting] = useState(false); | ||
| 46 | + | ||
| 47 | + // 最新 query 镜像,避免 refresh / 闭包读到旧值 | ||
| 48 | + const queryRef = useRef<UserListQuery>(query); | ||
| 49 | + queryRef.current = query; | ||
| 50 | + | ||
| 51 | + const messageRef = useRef(message); | ||
| 52 | + messageRef.current = message; | ||
| 53 | + | ||
| 54 | + /** 以给定 query 取数;同步 total/pageNum/pageSize 回显(BR15);错误码分流(spec § 4) */ | ||
| 55 | + const runFetch = useCallback(async (q: UserListQuery) => { | ||
| 56 | + setQuery(q); | ||
| 57 | + queryRef.current = q; | ||
| 58 | + setLoading(true); | ||
| 59 | + setError(null); | ||
| 60 | + try { | ||
| 61 | + const pageData = await listUsers(q); | ||
| 62 | + setList(pageData.records); | ||
| 63 | + setTotal(pageData.total); | ||
| 64 | + // 信任后端回显(越界回退最后一页等),同步分页当前页/页大小(BR15) | ||
| 65 | + setQuery((prev) => { | ||
| 66 | + const next = { ...prev, pageNum: pageData.pageNum, pageSize: pageData.pageSize }; | ||
| 67 | + queryRef.current = next; | ||
| 68 | + return next; | ||
| 69 | + }); | ||
| 70 | + setLoading(false); | ||
| 71 | + } catch (err) { | ||
| 72 | + const apiErr = err instanceof ApiError ? err : new ApiError(-1, TEXT_MSG_NETWORK); | ||
| 73 | + setLoading(false); | ||
| 74 | + if (apiErr.code === ERR_PAGE_INVALID) { | ||
| 75 | + // 42201:兜底重置分页为合法值后重查(pageNum=1,pageSize 收敛 ≤100) | ||
| 76 | + messageRef.current.warning(TEXT_MSG_PAGE_INVALID); | ||
| 77 | + const safePageSize = Math.min(q.pageSize, 100); | ||
| 78 | + void runFetch({ ...q, pageNum: 1, pageSize: safePageSize }); | ||
| 79 | + return; | ||
| 80 | + } | ||
| 81 | + if (apiErr.code === ERR_QUERY_INVALID) { | ||
| 82 | + // 40001:保留条件不自动重查 | ||
| 83 | + messageRef.current.error(TEXT_MSG_QUERY_INVALID); | ||
| 84 | + setError(apiErr); | ||
| 85 | + return; | ||
| 86 | + } | ||
| 87 | + // 网络 / 超时 / 5xx 兜底 | ||
| 88 | + messageRef.current.error(TEXT_MSG_NETWORK); | ||
| 89 | + setError(apiErr); | ||
| 90 | + } | ||
| 91 | + }, []); | ||
| 92 | + | ||
| 93 | + // 挂载即以默认条件取数(initialLoading,BR2) | ||
| 94 | + useEffect(() => { | ||
| 95 | + void runFetch({ ...DEFAULT_QUERY }); | ||
| 96 | + // eslint-disable-next-line react-hooks/exhaustive-deps | ||
| 97 | + }, []); | ||
| 98 | + | ||
| 99 | + const setQueryField = useCallback((v: string) => { | ||
| 100 | + setQuery((prev) => ({ ...prev, queryField: v })); | ||
| 101 | + }, []); | ||
| 102 | + const setMatchType = useCallback((v: string) => { | ||
| 103 | + setQuery((prev) => ({ ...prev, matchType: v })); | ||
| 104 | + }, []); | ||
| 105 | + const setQueryValue = useCallback((v: string) => { | ||
| 106 | + setQuery((prev) => ({ ...prev, queryValue: v })); | ||
| 107 | + }, []); | ||
| 108 | + | ||
| 109 | + /** 搜索:以当前条件回第 1 页取数(BR7) */ | ||
| 110 | + const search = useCallback(() => { | ||
| 111 | + void runFetch({ ...queryRef.current, pageNum: 1 }); | ||
| 112 | + }, [runFetch]); | ||
| 113 | + | ||
| 114 | + /** 刷新:保持当前 query(含 pageNum)重取(BR8) */ | ||
| 115 | + const refresh = useCallback(() => { | ||
| 116 | + void runFetch({ ...queryRef.current }); | ||
| 117 | + }, [runFetch]); | ||
| 118 | + | ||
| 119 | + /** 清空:重置为默认条件并全量查询(BR10) */ | ||
| 120 | + const clear = useCallback(() => { | ||
| 121 | + void runFetch({ ...DEFAULT_QUERY }); | ||
| 122 | + }, [runFetch]); | ||
| 123 | + | ||
| 124 | + /** 切页 / 改页大小:改 pageSize 回第 1 页(BR11),仅切页保留页码 */ | ||
| 125 | + const changePage = useCallback( | ||
| 126 | + (pageNum: number, pageSize: number) => { | ||
| 127 | + const cur = queryRef.current; | ||
| 128 | + const sizeChanged = pageSize !== cur.pageSize; | ||
| 129 | + void runFetch({ ...cur, pageNum: sizeChanged ? 1 : pageNum, pageSize }); | ||
| 130 | + }, | ||
| 131 | + [runFetch], | ||
| 132 | + ); | ||
| 133 | + | ||
| 134 | + /** 导出:拉当前条件命中结果(pageSize 收敛至上限 100 一次取)→ CSV 下载(BR9 / D-PLAN-1) */ | ||
| 135 | + const exportExcel = useCallback(async () => { | ||
| 136 | + setExporting(true); | ||
| 137 | + try { | ||
| 138 | + const cur = queryRef.current; | ||
| 139 | + const fetchSize = Math.min(Math.max(total, cur.pageSize, 1), 100); | ||
| 140 | + const pageData = await listUsers({ ...cur, pageNum: 1, pageSize: fetchSize }); | ||
| 141 | + const csv = buildUserCsv(pageData.records); | ||
| 142 | + downloadCsv(EXPORT_FILENAME, csv); | ||
| 143 | + messageRef.current.success(TEXT_EXPORT_SUCCESS); | ||
| 144 | + } catch { | ||
| 145 | + messageRef.current.error(TEXT_EXPORT_FAIL); | ||
| 146 | + } finally { | ||
| 147 | + setExporting(false); | ||
| 148 | + } | ||
| 149 | + }, [total]); | ||
| 150 | + | ||
| 151 | + return { | ||
| 152 | + list, | ||
| 153 | + total, | ||
| 154 | + loading, | ||
| 155 | + error, | ||
| 156 | + query, | ||
| 157 | + exporting, | ||
| 158 | + search, | ||
| 159 | + refresh, | ||
| 160 | + clear, | ||
| 161 | + setQueryField, | ||
| 162 | + setMatchType, | ||
| 163 | + setQueryValue, | ||
| 164 | + changePage, | ||
| 165 | + exportExcel, | ||
| 166 | + }; | ||
| 167 | +} |
frontend/tests/unit/useUserList.test.tsx
0 → 100644
| 1 | +// REQ-USR-003: useUserList 列表查询 hook 状态机单测 | ||
| 2 | +// 覆盖 initialLoading/loading/success/empty/error/exporting + BR2/BR7/BR8/BR10/BR11/BR15 + 错误码分流 | ||
| 3 | +import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| 4 | +import type { ReactNode } from 'react'; | ||
| 5 | +import { renderHook, act, waitFor } from '@testing-library/react'; | ||
| 6 | +import { App as AntdApp, ConfigProvider } from 'antd'; | ||
| 7 | + | ||
| 8 | +// 桩 message:保留 antd 其余真实导出,仅覆盖 App.useApp 返回的 message | ||
| 9 | +const messageSpy = { success: vi.fn(), error: vi.fn(), warning: vi.fn() }; | ||
| 10 | +vi.mock('antd', async () => { | ||
| 11 | + const actual = await vi.importActual<typeof import('antd')>('antd'); | ||
| 12 | + return { | ||
| 13 | + ...actual, | ||
| 14 | + App: Object.assign(actual.App, { useApp: () => ({ message: messageSpy }) }), | ||
| 15 | + }; | ||
| 16 | +}); | ||
| 17 | + | ||
| 18 | +// 桩 listUsers | ||
| 19 | +vi.mock('../../src/api/usrApi', () => ({ | ||
| 20 | + listUsers: vi.fn(), | ||
| 21 | +})); | ||
| 22 | + | ||
| 23 | +// 桩 CSV 下载(不真正触发 jsdom 下载) | ||
| 24 | +vi.mock('../../src/pages/usr/UserList/exportUtils', async () => { | ||
| 25 | + const actual = await vi.importActual< | ||
| 26 | + typeof import('../../src/pages/usr/UserList/exportUtils') | ||
| 27 | + >('../../src/pages/usr/UserList/exportUtils'); | ||
| 28 | + return { ...actual, downloadCsv: vi.fn() }; | ||
| 29 | +}); | ||
| 30 | + | ||
| 31 | +import { listUsers } from '../../src/api/usrApi'; | ||
| 32 | +import { downloadCsv } from '../../src/pages/usr/UserList/exportUtils'; | ||
| 33 | +import { useUserList } from '../../src/pages/usr/UserList/useUserList'; | ||
| 34 | +import { DEFAULT_QUERY } from '../../src/pages/usr/UserList/constants'; | ||
| 35 | +import { ApiError } from '../../src/api/request'; | ||
| 36 | +import type { UserVO } from '../../src/api/types'; | ||
| 37 | + | ||
| 38 | +const mockedList = listUsers as unknown as ReturnType<typeof vi.fn>; | ||
| 39 | +const mockedDownload = downloadCsv as unknown as ReturnType<typeof vi.fn>; | ||
| 40 | + | ||
| 41 | +function makeUser(id: number, name = `u${id}`): UserVO { | ||
| 42 | + return { | ||
| 43 | + id, | ||
| 44 | + sUserName: name, | ||
| 45 | + employeeName: null, | ||
| 46 | + sUserNo: null, | ||
| 47 | + departmentName: null, | ||
| 48 | + sUserType: '普通用户', | ||
| 49 | + sLanguage: '中文', | ||
| 50 | + iIsVoid: 0, | ||
| 51 | + tLastLoginDate: null, | ||
| 52 | + sCreator: 'admin', | ||
| 53 | + tCreateDate: '2024-01-01T00:00:00', | ||
| 54 | + }; | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +function page(records: UserVO[], total: number, pageNum = 1, pageSize = 10) { | ||
| 58 | + return { records, total, pageNum, pageSize }; | ||
| 59 | +} | ||
| 60 | + | ||
| 61 | +function wrapper({ children }: { children: ReactNode }) { | ||
| 62 | + return ( | ||
| 63 | + <ConfigProvider> | ||
| 64 | + <AntdApp>{children}</AntdApp> | ||
| 65 | + </ConfigProvider> | ||
| 66 | + ); | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +function lastCallQuery() { | ||
| 70 | + const calls = mockedList.mock.calls; | ||
| 71 | + return calls[calls.length - 1][0]; | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +describe('useUserList 状态机', () => { | ||
| 75 | + beforeEach(() => { | ||
| 76 | + vi.clearAllMocks(); | ||
| 77 | + }); | ||
| 78 | + | ||
| 79 | + it('mounts with default query and loads first page (initialLoading→success)', async () => { | ||
| 80 | + mockedList.mockResolvedValue(page([makeUser(1), makeUser(2)], 2, 1, 10)); | ||
| 81 | + const { result } = renderHook(() => useUserList(), { wrapper }); | ||
| 82 | + // 挂载即以默认 query 调 listUsers | ||
| 83 | + await waitFor(() => expect(mockedList).toHaveBeenCalled()); | ||
| 84 | + expect(mockedList.mock.calls[0][0]).toMatchObject({ | ||
| 85 | + queryField: '用户名', | ||
| 86 | + matchType: '包含', | ||
| 87 | + pageNum: 1, | ||
| 88 | + pageSize: 10, | ||
| 89 | + }); | ||
| 90 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 91 | + expect(result.current.list).toHaveLength(2); | ||
| 92 | + expect(result.current.total).toBe(2); | ||
| 93 | + expect(result.current.query.pageNum).toBe(1); | ||
| 94 | + }); | ||
| 95 | + | ||
| 96 | + it('empty records sets empty state without error', async () => { | ||
| 97 | + mockedList.mockResolvedValue(page([], 0, 1, 10)); | ||
| 98 | + const { result } = renderHook(() => useUserList(), { wrapper }); | ||
| 99 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 100 | + expect(result.current.list).toEqual([]); | ||
| 101 | + expect(result.current.total).toBe(0); | ||
| 102 | + expect(result.current.error).toBeNull(); | ||
| 103 | + expect(messageSpy.error).not.toHaveBeenCalled(); | ||
| 104 | + }); | ||
| 105 | + | ||
| 106 | + it('search resets to page 1 and refetches with current filters (BR7)', async () => { | ||
| 107 | + mockedList.mockResolvedValue(page([makeUser(1)], 1, 1, 10)); | ||
| 108 | + const { result } = renderHook(() => useUserList(), { wrapper }); | ||
| 109 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 110 | + | ||
| 111 | + act(() => { | ||
| 112 | + result.current.setQueryField('员工名'); | ||
| 113 | + result.current.setQueryValue('李'); | ||
| 114 | + }); | ||
| 115 | + act(() => { | ||
| 116 | + result.current.search(); | ||
| 117 | + }); | ||
| 118 | + await waitFor(() => { | ||
| 119 | + const q = lastCallQuery(); | ||
| 120 | + expect(q.queryField).toBe('员工名'); | ||
| 121 | + expect(q.queryValue).toBe('李'); | ||
| 122 | + expect(q.pageNum).toBe(1); | ||
| 123 | + }); | ||
| 124 | + }); | ||
| 125 | + | ||
| 126 | + it('refresh keeps current query and page (BR8)', async () => { | ||
| 127 | + mockedList.mockResolvedValue(page([makeUser(1)], 30, 2, 10)); | ||
| 128 | + const { result } = renderHook(() => useUserList(), { wrapper }); | ||
| 129 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 130 | + | ||
| 131 | + act(() => { | ||
| 132 | + result.current.changePage(2, 10); | ||
| 133 | + }); | ||
| 134 | + await waitFor(() => expect(result.current.query.pageNum).toBe(2)); | ||
| 135 | + mockedList.mockClear(); | ||
| 136 | + | ||
| 137 | + act(() => { | ||
| 138 | + result.current.refresh(); | ||
| 139 | + }); | ||
| 140 | + await waitFor(() => expect(mockedList).toHaveBeenCalled()); | ||
| 141 | + expect(lastCallQuery().pageNum).toBe(2); | ||
| 142 | + }); | ||
| 143 | + | ||
| 144 | + it('clear resets to DEFAULT_QUERY then refetches (BR10)', async () => { | ||
| 145 | + mockedList.mockResolvedValue(page([makeUser(1)], 1, 1, 10)); | ||
| 146 | + const { result } = renderHook(() => useUserList(), { wrapper }); | ||
| 147 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 148 | + | ||
| 149 | + act(() => { | ||
| 150 | + result.current.setQueryField('部门'); | ||
| 151 | + result.current.setQueryValue('技术'); | ||
| 152 | + }); | ||
| 153 | + act(() => { | ||
| 154 | + result.current.clear(); | ||
| 155 | + }); | ||
| 156 | + await waitFor(() => { | ||
| 157 | + expect(result.current.query.queryField).toBe(DEFAULT_QUERY.queryField); | ||
| 158 | + expect(result.current.query.queryValue).toBe(''); | ||
| 159 | + expect(result.current.query.pageNum).toBe(1); | ||
| 160 | + }); | ||
| 161 | + expect(lastCallQuery().queryField).toBe('用户名'); | ||
| 162 | + }); | ||
| 163 | + | ||
| 164 | + it('changePage refetch; changing pageSize resets to page 1 (BR11)', async () => { | ||
| 165 | + mockedList.mockResolvedValue(page([makeUser(1)], 100, 3, 10)); | ||
| 166 | + const { result } = renderHook(() => useUserList(), { wrapper }); | ||
| 167 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 168 | + | ||
| 169 | + act(() => { | ||
| 170 | + result.current.changePage(3, 10); | ||
| 171 | + }); | ||
| 172 | + await waitFor(() => expect(lastCallQuery().pageNum).toBe(3)); | ||
| 173 | + | ||
| 174 | + // 改 pageSize(10→50)应回第 1 页 | ||
| 175 | + act(() => { | ||
| 176 | + result.current.changePage(3, 50); | ||
| 177 | + }); | ||
| 178 | + await waitFor(() => { | ||
| 179 | + const q = lastCallQuery(); | ||
| 180 | + expect(q.pageSize).toBe(50); | ||
| 181 | + expect(q.pageNum).toBe(1); | ||
| 182 | + }); | ||
| 183 | + }); | ||
| 184 | + | ||
| 185 | + it('ApiError 40001 keeps filters and shows error, sets error state', async () => { | ||
| 186 | + // 首次挂载成功,之后搜索失败 40001 | ||
| 187 | + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); | ||
| 188 | + const { result } = renderHook(() => useUserList(), { wrapper }); | ||
| 189 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 190 | + | ||
| 191 | + act(() => { | ||
| 192 | + result.current.setQueryValue('张'); | ||
| 193 | + }); | ||
| 194 | + mockedList.mockRejectedValueOnce(new ApiError(40001, '查询参数校验失败')); | ||
| 195 | + act(() => { | ||
| 196 | + result.current.search(); | ||
| 197 | + }); | ||
| 198 | + await waitFor(() => expect(result.current.error).not.toBeNull()); | ||
| 199 | + expect(result.current.error?.code).toBe(40001); | ||
| 200 | + expect(result.current.query.queryValue).toBe('张'); // 条件保留 | ||
| 201 | + expect(messageSpy.error).toHaveBeenCalled(); | ||
| 202 | + // 不自动重查:失败后调用次数 = 挂载1 + 搜索1 = 2 | ||
| 203 | + expect(mockedList).toHaveBeenCalledTimes(2); | ||
| 204 | + }); | ||
| 205 | + | ||
| 206 | + it('ApiError 42201 warns and refetches at page 1', async () => { | ||
| 207 | + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); | ||
| 208 | + const { result } = renderHook(() => useUserList(), { wrapper }); | ||
| 209 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 210 | + | ||
| 211 | + // 翻到第 3 页 → 42201 → 应重置 pageNum=1 并重查 | ||
| 212 | + mockedList.mockClear(); | ||
| 213 | + mockedList.mockRejectedValueOnce(new ApiError(42201, '分页参数非法')); | ||
| 214 | + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); | ||
| 215 | + act(() => { | ||
| 216 | + result.current.changePage(3, 10); | ||
| 217 | + }); | ||
| 218 | + await waitFor(() => expect(messageSpy.warning).toHaveBeenCalled()); | ||
| 219 | + await waitFor(() => expect(result.current.query.pageNum).toBe(1)); | ||
| 220 | + // 重查发生 | ||
| 221 | + expect(lastCallQuery().pageNum).toBe(1); | ||
| 222 | + }); | ||
| 223 | + | ||
| 224 | + it('network error (code -1) sets error state', async () => { | ||
| 225 | + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); | ||
| 226 | + const { result } = renderHook(() => useUserList(), { wrapper }); | ||
| 227 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 228 | + | ||
| 229 | + mockedList.mockRejectedValueOnce(new ApiError(-1, '网络异常')); | ||
| 230 | + act(() => { | ||
| 231 | + result.current.refresh(); | ||
| 232 | + }); | ||
| 233 | + await waitFor(() => expect(result.current.error).not.toBeNull()); | ||
| 234 | + expect(result.current.error?.code).toBe(-1); | ||
| 235 | + expect(messageSpy.error).toHaveBeenCalled(); | ||
| 236 | + }); | ||
| 237 | + | ||
| 238 | + it('response pageNum echo syncs pagination (BR15)', async () => { | ||
| 239 | + // 请求 pageNum=99 越界,后端回退最后一页 pageNum=5 | ||
| 240 | + mockedList.mockResolvedValueOnce(page([makeUser(1)], 50, 1, 10)); | ||
| 241 | + const { result } = renderHook(() => useUserList(), { wrapper }); | ||
| 242 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 243 | + | ||
| 244 | + mockedList.mockResolvedValueOnce(page([makeUser(1)], 50, 5, 10)); | ||
| 245 | + act(() => { | ||
| 246 | + result.current.changePage(99, 10); | ||
| 247 | + }); | ||
| 248 | + await waitFor(() => expect(result.current.query.pageNum).toBe(5)); | ||
| 249 | + expect(result.current.total).toBe(50); | ||
| 250 | + }); | ||
| 251 | + | ||
| 252 | + it('exportExcel toggles exporting and downloads (BR9)', async () => { | ||
| 253 | + mockedList.mockResolvedValue(page([makeUser(1), makeUser(2)], 2, 1, 10)); | ||
| 254 | + const { result } = renderHook(() => useUserList(), { wrapper }); | ||
| 255 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 256 | + | ||
| 257 | + await act(async () => { | ||
| 258 | + await result.current.exportExcel(); | ||
| 259 | + }); | ||
| 260 | + expect(mockedDownload).toHaveBeenCalledTimes(1); | ||
| 261 | + expect(result.current.exporting).toBe(false); | ||
| 262 | + expect(messageSpy.success).toHaveBeenCalledWith('导出成功'); | ||
| 263 | + }); | ||
| 264 | + | ||
| 265 | + it('exportExcel failure shows 导出失败', async () => { | ||
| 266 | + mockedList.mockResolvedValueOnce(page([makeUser(1)], 1, 1, 10)); | ||
| 267 | + const { result } = renderHook(() => useUserList(), { wrapper }); | ||
| 268 | + await waitFor(() => expect(result.current.loading).toBe(false)); | ||
| 269 | + | ||
| 270 | + mockedList.mockRejectedValueOnce(new ApiError(-1, '网络异常')); | ||
| 271 | + await act(async () => { | ||
| 272 | + await result.current.exportExcel(); | ||
| 273 | + }); | ||
| 274 | + expect(messageSpy.error).toHaveBeenCalledWith('导出失败'); | ||
| 275 | + expect(result.current.exporting).toBe(false); | ||
| 276 | + }); | ||
| 277 | +}); |