轻松聘
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.
 
 
 

255 lines
9.6 KiB

import {
clearOnboardingProfile,
fetchJobList,
fetchJobPreference,
saveJobPreference,
saveOnboardingProfile,
} from '../api/api';
import { storageRemove, storageSet } from '../lib/storage';
import type { Job, JobPreference, OnboardingProfile } from '../types';
// 内存版 storage mock(与 salary.test.ts / onboarding-profile.test.ts 同)。
// 键是 api 层传入的原始键(无 qingsongpin: 前缀,前缀由真实 storage 封装补上)。
jest.mock('../lib/storage', () => {
const store: Record<string, string> = {};
return {
storageGet: jest.fn(async (k: string) => (k in store ? store[k] : null)),
storageSet: jest.fn(async (k: string, v: string) => {
store[k] = v;
}),
storageRemove: jest.fn(async (k: string) => {
delete store[k];
}),
};
});
/** 全部字段留空 / 为 null 的兜底资料,只覆盖测试关心的字段 */
const BASE_PROFILE: OnboardingProfile = {
identity: 'candidate',
isStudent: null,
city: '',
expectedPositions: [],
expectedSalaryMin: '',
expectedSalaryMax: '',
resumeName: '',
gender: null,
birthYear: '',
birthMonth: '',
jobStatus: null,
disabledJobseeker: false,
firstWorkYear: '',
firstWorkMonth: '',
currentPosition: null,
company: '',
joinYear: '',
joinMonth: '',
skills: [],
workContent: '',
education: null,
eduType: null,
school: '',
avatar: null,
recruiter: null,
};
const makeProfile = (patch: Partial<OnboardingProfile>): OnboardingProfile => ({
...BASE_PROFILE,
...patch,
});
/**
* mock 岗位(`src/api/api.ts` 的 MOCK_JOBS)固定为 9 条:
* | id | 职位 | 城市 | 薪资 | 分数 |
* | 1 | 高级前端工程师 | 北京 | 25-40 | 92 |
* | 2 | 产品经理 | 深圳 | 20-35 | 78 |
* | 3 | UI设计师 | 杭州 | 15-25 | 85 |
* | 4 | Java开发工程师 | 北京 | 18-30 | 65 |
* | 5 | 前端实习生 | 上海 | 6-9 | 60 |
* | 6 | 金融分析师 | 深圳 | 30-50 | 88 |
* | 7 | 课程顾问 | 北京 | 12-20 | 72 |
* | 8 | 医学代表 | 广州 | 15-25 | 75 |
* | 9 | 电池工程师 | 宁德 | 28-45 | 90 |
* 下面所有「决定性输入 → 决定性顺序」的断言都基于这张表。
*/
const DEFAULT_PARAMS = { sortBy: 'comprehensive' as const };
const idsOf = (jobs: Job[]): string[] => jobs.map((job) => job.id);
describe('fetchJobList 岗位推荐匹配(默认推荐视图叠加偏好加分)', () => {
beforeEach(async () => {
await clearOnboardingProfile();
await storageRemove('user:job-preference');
});
it('无任何采集数据时,按岗位分数降序(不凭空加分)', async () => {
const { jobs, total } = await fetchJobList(DEFAULT_PARAMS);
expect(total).toBe(9);
expect(idsOf(jobs)).toEqual(['1', '9', '6', '3', '2', '8', '7', '4', '5']);
});
it('命中期望城市 +15:杭州岗位从第 4 位升到第 1 位', async () => {
await saveOnboardingProfile(makeProfile({ city: '杭州' }));
const { jobs } = await fetchJobList(DEFAULT_PARAMS);
// UI设计师(杭州,85)→ 85+15=100,越过原来的第 1 名(92)
expect(idsOf(jobs)).toEqual(['3', '1', '9', '6', '2', '8', '7', '4', '5']);
});
it('命中期望职位 +20:职位名归一化后再比对', async () => {
await saveOnboardingProfile(makeProfile({ expectedPositions: ['前端开发工程师'] }));
const { jobs } = await fetchJobList(DEFAULT_PARAMS);
// 「前端开发工程师」⇒ 去掉「开发」「工程师」⇒「前端」
// 岗位 1「高级前端工程师」⇒「前端」命中(+20 → 112),原本就在第 1 位
// 岗位 5「前端实习生」⇒「前端」同样命中(+20 → 80),从末位升到第 5 位
expect(idsOf(jobs)).toEqual(['1', '9', '6', '3', '5', '2', '8', '7', '4']);
});
it('期望薪资与岗位薪资区间有重叠 +10(含边界值)', async () => {
await saveOnboardingProfile(
makeProfile({ expectedSalaryMin: '35K', expectedSalaryMax: '45K' }),
);
const { jobs } = await fetchJobList(DEFAULT_PARAMS);
// 命中(+10):1(25-40) 2(20-35 边界 35>=35) 6(30-50) 9(28-45)
// 未命中:3(15-25) 4(18-30) 5(6-9) 7(12-20) 8(15-25)
// 结果:产品经理 78+10=88 越过 UI设计师 85
expect(idsOf(jobs)).toEqual(['1', '9', '6', '2', '3', '8', '7', '4', '5']);
});
it('三项加分可叠加:命中越多排越前', async () => {
await saveOnboardingProfile(
makeProfile({
city: '杭州',
expectedPositions: ['UI设计师'],
expectedSalaryMin: '20K',
expectedSalaryMax: '35K',
}),
);
const { jobs } = await fetchJobList(DEFAULT_PARAMS);
// UI设计师:85 + 城市15 + 职位20 + 薪资10(15-25 与 20-35 有交集)= 130
expect(idsOf(jobs)[0]).toBe('3');
expect(idsOf(jobs)[1]).toBe('1'); // 92 + 薪资10(25-40 与 20-35 有交集)
});
it('无法解析的期望薪资不加分(不凭空命中)', async () => {
await saveOnboardingProfile(
makeProfile({ expectedSalaryMin: '面议', expectedSalaryMax: '面议' }),
);
const { jobs } = await fetchJobList(DEFAULT_PARAMS);
expect(idsOf(jobs)).toEqual(['1', '9', '6', '3', '2', '8', '7', '4', '5']);
});
});
describe('fetchJobList 显式筛选优先(用户主动筛选时不叠加偏好加分)', () => {
/**
* 该偏好若被叠加,会把「课程顾问」(72+20+10=102)顶到北京岗位的第 1 位
* —— 未叠加时北京岗位按原始分数序为 [1(92), 7(72), 4(65)]。
* 城市留空:城市加分对同城岗位一视同仁,加与不加都改不了相对顺序,留着反而干扰断言。
*/
const PREFERENCE: JobPreference = {
position: '课程顾问',
city: '',
salary: '12K-20K',
workType: '全职',
status: '在职-月内到岗',
};
beforeEach(async () => {
await clearOnboardingProfile();
await storageRemove('user:job-preference');
await saveJobPreference(PREFERENCE);
});
it('显式选了城市:只做筛选,不叠加加分', async () => {
// 北京岗位原始顺序:1(92) / 7(72) / 4(65)
const filtered = await fetchJobList({ ...DEFAULT_PARAMS, city: '北京' });
expect(idsOf(filtered.jobs)).toEqual(['1', '7', '4']);
// 同一份偏好在默认视图下确实生效(否则上面的断言可能是「偏好没读到」而假通过)
const unfiltered = await fetchJobList(DEFAULT_PARAMS);
expect(idsOf(unfiltered.jobs)[0]).toBe('7'); // 72 + 职位20 + 薪资10 = 102
});
it('显式输入了关键词:只做搜索,不叠加加分', async () => {
const matched = await fetchJobList({ ...DEFAULT_PARAMS, keyword: '工程师' });
expect(idsOf(matched.jobs)).toEqual(['1', '9', '4']);
// 上面这份偏好只对「课程顾问」加权,换成「电池工程师」再验一次默认视图
await saveJobPreference({ ...PREFERENCE, position: '电池工程师', salary: '' });
const filtered = await fetchJobList({ ...DEFAULT_PARAMS, keyword: '工程师' });
expect(idsOf(filtered.jobs)).toEqual(['1', '9', '4']);
const unfiltered = await fetchJobList(DEFAULT_PARAMS);
expect(idsOf(unfiltered.jobs)[0]).toBe('9'); // 90 + 职位20 = 110
});
it('城市传「全部城市」哨兵值等同未筛选,偏好生效', async () => {
const { jobs } = await fetchJobList({ ...DEFAULT_PARAMS, city: '全部城市' });
expect(idsOf(jobs)[0]).toBe('7');
});
});
describe('fetchJobPreference 取值优先级(显式编辑 > 引导资料推导 > 空值)', () => {
beforeEach(async () => {
await clearOnboardingProfile();
await storageRemove('user:job-preference');
});
it('无任何数据时返回空值,工作类型补默认「全职」', async () => {
await expect(fetchJobPreference()).resolves.toEqual({
position: '',
city: '',
salary: '',
workType: '全职',
status: '',
});
});
it('由引导资料推导:期望职位取第 1 个,薪资拼成区间', async () => {
await saveOnboardingProfile(
makeProfile({
city: '杭州',
expectedPositions: ['鸿蒙开发工程师', '前端开发工程师'],
expectedSalaryMin: '20K',
expectedSalaryMax: '35K',
jobStatus: '离职-随时到岗',
}),
);
await expect(fetchJobPreference()).resolves.toEqual({
position: '鸿蒙开发工程师',
city: '杭州',
salary: '20K-35K',
workType: '全职',
status: '离职-随时到岗',
});
});
it('期望薪资只填了一头时不拼区间(不留「20K-」这种半截值)', async () => {
await saveOnboardingProfile(
makeProfile({ expectedSalaryMin: '20K', expectedSalaryMax: '' }),
);
await expect(fetchJobPreference()).resolves.toMatchObject({ salary: '' });
});
it('用户在求职意向页显式保存过时,以显式值为准', async () => {
await saveOnboardingProfile(makeProfile({ city: '杭州', expectedPositions: ['前端开发工程师'] }));
await saveJobPreference({
position: '电池工程师',
city: '宁德',
salary: '',
workType: '全职',
status: '',
});
await expect(fetchJobPreference()).resolves.toMatchObject({
position: '电池工程师',
city: '宁德',
});
// 岗位排序同样按显式偏好:宁德岗位 90+15=105 越过杭州岗位 85+15=100
const { jobs } = await fetchJobList(DEFAULT_PARAMS);
expect(idsOf(jobs)[0]).toBe('9');
});
it('显式偏好存储损坏时回退到引导资料推导', async () => {
await saveOnboardingProfile(makeProfile({ city: '杭州' }));
await storageSet('user:job-preference', '{ 这不是合法 JSON');
await expect(fetchJobPreference()).resolves.toMatchObject({ city: '杭州' });
});
});