轻松聘
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

380 lines
15 KiB

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<string, string>();
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<string, string> }).__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<string, string>;
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<JobManagementJob> {
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<JobManagementJobStatus, JobManagementJobStatus> = {
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');
});
});