import { buildJobManagementTabs, buildJobStatusAction, fetchJobManagement, filterJobManagementJobs, getJobStatusLabel, refreshJob, updateJobStatus, } from '../api/api'; import type { JobManagement, JobManagementJob, JobManagementJobStatus } from '../types'; // api.ts 依赖 storage,Jest 环境无原生 AsyncStorage。 // 用带状态的 Map 版 mock(不是恒返回 null 的空实现), // 否则「下架后重进页面还是下架状态」这类落库行为根本没被验证到。 jest.mock('../lib/storage', () => { const store = new Map(); return { __store: store, storageGet: jest.fn(async (key: string) => store.get(key) ?? null), storageSet: jest.fn(async (key: string, value: string) => { store.set(key, value); }), storageRemove: jest.fn(async (key: string) => { store.delete(key); }), }; }); const store = (jest.requireMock('../lib/storage') as { __store: Map }).__store; /** 存储键(取自 api.ts,用于构造「存储内容损坏」的降级用例) */ const STATUS_KEY = 'enterprise:jobs-status'; const REFRESH_KEY = 'enterprise:jobs-refreshed'; /** mock 岗位总数(12 条:8 在招 / 3 已下线 / 1 草稿) */ const TOTAL_JOBS = 12; const ACTIVE_JOBS = 8; const OFFLINE_JOBS = 3; function findJob(management: JobManagement, jobId: string) { const found = management.jobs.find((job) => job.id === jobId); if (!found) throw new Error(`岗位列表里找不到 ${jobId}`); return found; } function tabCount(management: JobManagement, tabKey: string): number { return management.tabs.find((tab) => tab.key === tabKey)?.count ?? -1; } function activeStat(management: JobManagement): number { return management.stats.find((stat) => stat.id === 'active')?.value ?? -1; } beforeEach(() => { store.clear(); }); describe('fetchJobManagement(岗位管理取数)', () => { it('Tab 计数由列表推导,与 jobs 实际条数一致', async () => { const management = await fetchJobManagement(); expect(management.jobs).toHaveLength(TOTAL_JOBS); expect(tabCount(management, 'all')).toBe(TOTAL_JOBS); expect(tabCount(management, 'active')).toBe(ACTIVE_JOBS); // 计数与列表同源:每个 Tab 的计数必须等于按该状态过滤出来的条数 for (const tab of management.tabs) { const actual = management.jobs.filter( (job) => tab.key === 'all' || job.status === tab.key, ).length; expect(tab.count).toBe(actual); } }); it('「在招岗位」统计值取自列表,不是写死的数字', async () => { const management = await fetchJobManagement(); expect(activeStat(management)).toBe(ACTIVE_JOBS); }); it('存储内容损坏时退回 mock 原值,不把异常抛到页面', async () => { store.set(STATUS_KEY, '{不是合法 JSON'); store.set(REFRESH_KEY, '[1,2,3]'); const management = await fetchJobManagement(); expect(management.jobs).toHaveLength(TOTAL_JOBS); expect(findJob(management, 'job-1').status).toBe('active'); }); it('存储里放的是数组(而非对象)时按空表处理', async () => { store.set(STATUS_KEY, '["active"]'); const management = await fetchJobManagement(); expect(findJob(management, 'job-1').status).toBe('active'); }); }); describe('updateJobStatus(下架 / 重新上架)', () => { it('下架后状态变为 offline,且重新取数仍然是下架的', async () => { await updateJobStatus('job-1', 'offline'); // 换一次全新的读取,模拟退出页面再进来 const management = await fetchJobManagement(); expect(findJob(management, 'job-1').status).toBe('offline'); }); it('下架后 Tab 计数与「在招岗位」统计同步减一', async () => { const updated = await updateJobStatus('job-1', 'offline'); expect(tabCount(updated, 'active')).toBe(ACTIVE_JOBS - 1); expect(tabCount(updated, 'offline')).toBe(OFFLINE_JOBS + 1); expect(tabCount(updated, 'all')).toBe(TOTAL_JOBS); expect(activeStat(updated)).toBe(ACTIVE_JOBS - 1); }); it('重新上架后计数回到原值(不会只减不增)', async () => { await updateJobStatus('job-1', 'offline'); const restored = await updateJobStatus('job-1', 'active'); expect(findJob(restored, 'job-1').status).toBe('active'); expect(tabCount(restored, 'active')).toBe(ACTIVE_JOBS); expect(activeStat(restored)).toBe(ACTIVE_JOBS); }); it('只影响被改的那一个岗位,其它岗位状态不受牵连', async () => { const updated = await updateJobStatus('job-1', 'offline'); expect(findJob(updated, 'job-2').status).toBe('active'); expect(findJob(updated, 'job-3').status).toBe('active'); expect(findJob(updated, 'job-9').status).toBe('offline'); }); it('返回的是完整聚合对象(页面可直接用它替换本地镜像)', async () => { const updated = await updateJobStatus('job-1', 'offline'); expect(updated.companyName).toBe('智联科技'); expect(updated.hasData).toBe(true); expect(updated.tabs).toHaveLength(4); expect(updated.stats).toHaveLength(4); expect(updated.benefits.length).toBeGreaterThan(0); }); it('写入失败不静默:存储抛错时 reject,页面据此给出「操作失败」提示', async () => { const storage = jest.requireMock('../lib/storage') as { storageSet: jest.Mock }; storage.storageSet.mockRejectedValueOnce(new Error('磁盘写满')); await expect(updateJobStatus('job-1', 'offline')).rejects.toThrow('磁盘写满'); }); }); describe('refreshJob(刷新岗位)', () => { it('写入 refreshedAt,且重新取数仍然在', async () => { const before = await fetchJobManagement(); expect(findJob(before, 'job-1').refreshedAt).toBeUndefined(); const updated = await refreshJob('job-1'); expect(findJob(updated, 'job-1').refreshedAt).toBeDefined(); expect(Number.isNaN(Date.parse(findJob(updated, 'job-1').refreshedAt as string))).toBe(false); const reread = await fetchJobManagement(); expect(findJob(reread, 'job-1').refreshedAt).toBe(findJob(updated, 'job-1').refreshedAt); }); it('只给被刷新那一个岗位打时间,其它岗位仍是「未刷新过」', async () => { const updated = await refreshJob('job-1'); expect(findJob(updated, 'job-1').refreshedAt).toBeDefined(); expect(findJob(updated, 'job-2').refreshedAt).toBeUndefined(); }); it('刷新不改动岗位状态与计数', async () => { const updated = await refreshJob('job-1'); expect(findJob(updated, 'job-1').status).toBe('active'); expect(tabCount(updated, 'active')).toBe(ACTIVE_JOBS); expect(activeStat(updated)).toBe(ACTIVE_JOBS); }); it('重复刷新是幂等的:时间被覆盖而不是叠加', async () => { await refreshJob('job-1'); const second = await refreshJob('job-1'); const refreshMap = JSON.parse(store.get(REFRESH_KEY) ?? '{}') as Record; expect(Object.keys(refreshMap)).toEqual(['job-1']); expect(findJob(second, 'job-1').refreshedAt).toBeDefined(); }); }); describe('buildJobManagementTabs(计数推导,纯函数)', () => { it('空列表时各 Tab 计数为 0', () => { const tabs = buildJobManagementTabs([]); expect(tabs.map((tab) => tab.count)).toEqual([0, 0, 0, 0]); }); }); describe('filterJobManagementJobs(筛选 + 排序,纯函数)', () => { it('Tab 过滤只留下对应状态的岗位', async () => { const { jobs } = await fetchJobManagement(); const offline = filterJobManagementJobs(jobs, { tabKey: 'offline' }); expect(offline).toHaveLength(OFFLINE_JOBS); expect(offline.every((job) => job.status === 'offline')).toBe(true); }); it('关键词同时匹配岗位名与部门名,忽略大小写与首尾空格', async () => { const { jobs } = await fetchJobManagement(); expect(filterJobManagementJobs(jobs, { tabKey: 'all', keyword: ' 前端 ' })).toHaveLength(1); // 「技术部」是部门名,不是岗位名 —— 也应命中 const techDept = filterJobManagementJobs(jobs, { tabKey: 'all', keyword: '技术部' }); expect(techDept.length).toBeGreaterThan(0); expect(techDept.every((job) => job.department === '技术部')).toBe(true); }); it('城市与部门是「与」关系:两者都满足才留下', async () => { const { jobs } = await fetchJobManagement(); const both = filterJobManagementJobs(jobs, { tabKey: 'all', cities: ['北京'], departments: ['技术部'], }); expect(both.length).toBeGreaterThan(0); expect(both.every((job) => job.city === '北京' && job.department === '技术部')).toBe(true); // 北京没有设计部 —— 与关系下应为空 expect( filterJobManagementJobs(jobs, { tabKey: 'all', cities: ['北京'], departments: ['设计部'] }), ).toHaveLength(0); }); it('「最新发布」按发布时间倒序,第一条是最近发布的', async () => { const { jobs } = await fetchJobManagement(); const sorted = filterJobManagementJobs(jobs, { tabKey: 'all', sortKey: 'latest' }); for (let i = 1; i < sorted.length; i += 1) { expect(sorted[i - 1].publishedAt >= sorted[i].publishedAt).toBe(true); } }); it('投递 / 查看 / 评分排序都是降序,且不丢条目', async () => { const { jobs } = await fetchJobManagement(); const byApplicants = filterJobManagementJobs(jobs, { tabKey: 'all', sortKey: 'applicants' }); expect(byApplicants).toHaveLength(TOTAL_JOBS); for (let i = 1; i < byApplicants.length; i += 1) { expect(byApplicants[i - 1].applicants).toBeGreaterThanOrEqual(byApplicants[i].applicants); } const byScore = filterJobManagementJobs(jobs, { tabKey: 'all', sortKey: 'score' }); expect(byScore[0].overallScore).toBeGreaterThanOrEqual(byScore[1].overallScore); }); it('不改动入参数组(排序在副本上做)', async () => { const { jobs } = await fetchJobManagement(); const originalOrder = jobs.map((job) => job.id); filterJobManagementJobs(jobs, { tabKey: 'all', sortKey: 'score' }); expect(jobs.map((job) => job.id)).toEqual(originalOrder); }); it('筛不出结果时返回空数组,而不是全量兜底', async () => { const { jobs } = await fetchJobManagement(); expect(filterJobManagementJobs(jobs, { tabKey: 'all', keyword: '不存在的岗位名' })).toEqual([]); }); }); /** 取一个指定状态的岗位(mock 数据里三种状态都有,取不到说明数据被改坏了) */ async function jobWithStatus(status: JobManagementJobStatus): Promise { const { jobs } = await fetchJobManagement(); const found = jobs.find((job) => job.status === status); if (!found) throw new Error(`mock 岗位数据里没有 status=${status} 的样本`); return found; } describe('buildJobStatusAction(状态 → 按钮/确认文案,纯函数)', () => { it('在招岗位:文案是「下架」,标为破坏性操作,目标状态 offline', async () => { const active = await jobWithStatus('active'); const action = buildJobStatusAction(active); expect(action.label).toBe('下架'); expect(action.nextStatus).toBe('offline'); expect(action.destructive).toBe(true); // 破坏性操作必须说清后果,且要指名是哪个岗位 expect(action.confirmMessage).toContain(active.title); expect(action.confirmMessage).not.toBe(''); expect(action.confirmTitle).not.toBe(''); expect(action.confirmButton).not.toBe(''); expect(action.icon).not.toBe(''); }); it('草稿:不得说「重新上架」「再次看到」—— 它从未上架过', async () => { const draft = await jobWithStatus('draft'); const action = buildJobStatusAction(draft); // 页面原先写死 `isActive ? '下架' : '重新上架'`,草稿因此落到「重新上架」分支, // 确认框还说「候选人可以再次看到该岗位」。草稿从未上架,两句都不成立。 expect(action.label).not.toBe('重新上架'); expect(action.label).not.toContain('重新'); expect(action.confirmTitle).not.toContain('重新'); expect(action.confirmMessage).not.toContain('再次'); expect(action.label).toBe('上架'); expect(action.nextStatus).toBe('active'); expect(action.destructive).toBe(false); }); it('已下线:才用「重新上架」,且不是破坏性操作', async () => { const offline = await jobWithStatus('offline'); const action = buildJobStatusAction(offline); expect(action.label).toBe('重新上架'); expect(action.nextStatus).toBe('active'); expect(action.destructive).toBe(false); }); it('草稿与已下线的确认文案不同 —— 两种处境不能共用一句话', async () => { const [draftAction, offlineAction] = [ buildJobStatusAction(await jobWithStatus('draft')), buildJobStatusAction(await jobWithStatus('offline')), ]; expect(draftAction.confirmMessage).not.toBe(offlineAction.confirmMessage); expect(draftAction.confirmTitle).not.toBe(offlineAction.confirmTitle); expect(draftAction.label).not.toBe(offlineAction.label); }); it('遍历全部岗位:目标状态与当前状态不同,且不是原地打转', async () => { const { jobs } = await fetchJobManagement(); for (const job of jobs) { const action = buildJobStatusAction(job); expect(action.nextStatus).not.toBe(job.status); // 三种状态的合法目标是确定的,写死在这里当契约 const expected: Record = { active: 'offline', offline: 'active', draft: 'active', }; expect(action.nextStatus).toBe(expected[job.status]); } }); it('确认框正文都带上了岗位名,不会出现「下架该岗位」这种指代不清的说法', async () => { const { jobs } = await fetchJobManagement(); for (const job of jobs) { expect(buildJobStatusAction(job).confirmMessage).toContain(job.title); } }); }); describe('getJobStatusLabel(状态角标文案,与 Tab 同源)', () => { it('与 fetchJobManagement 下发的 Tab 文案逐字一致 —— 不另立一份', async () => { const { jobs, tabs } = await fetchJobManagement(); for (const job of jobs) { const tab = tabs.find((item) => item.key === job.status); expect(tab).toBeDefined(); // 角标与 Tab 用同一份文案(都来自 JOB_MANAGEMENT_TABS), // 页面原先自己维护了一份 STATUS_LABEL,两处文案会各改各的 expect(getJobStatusLabel(job.status)).toBe(tab?.label); } }); it('三种状态各有互不相同的文案', async () => { const { jobs } = await fetchJobManagement(); const labels = jobs.map((job) => getJobStatusLabel(job.status)); expect(new Set(labels).size).toBe(3); }); it('未知状态原样返回,不抛错也不编文案', () => { expect(getJobStatusLabel('archived' as JobManagementJobStatus)).toBe('archived'); }); });