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
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');
|
|
});
|
|
});
|
|
|