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 = {}; 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 => ({ ...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: '杭州' }); }); });