Browse Source
合并 2026-09-20 当天的 4.14、4.15 两个批次。范围仅前端:后端(Java + SpringBoot)
由其他成员实现,本次未写后端代码,接口仍按约定在 api.ts 中预留注释版 fetch。
主线索是一类反复出现的缺陷:**同一个业务事实被写了两份,两处取值不同**。
修法一律是让重复的那一份在接口层从权威源**派生**,而不是把两个字面量手工对齐 ——
手工对齐只解决这一次,下次改数据照样分叉。
4.14 求职意向词表统一 + 四处数据自相矛盾
- 引导采集的期望薪资 / 求职状态 / 期望城市三组选项,引导页与「设置求职意向」页
各有一份词表,三组全部匹配不上:走完引导进来的用户看到三组 chip 一个都没选中,
像资料丢了。更严重的是在那里选「暂不考虑」保存后,简历仍把他记成积极求职中
(判定常量与页面词表不是同一个字符串)。三组选项下沉到 src/constants 单一来源,
新增 withCurrentOption 处理引导阶梯能拼出、快捷档覆盖不到的薪资组合。
- 顺带收敛四处数据矛盾:当前用户 Offer 数、求职进度每月 Offer 之和、企业端在招岗位、
企业端评价数、企业名(全称 / 简称两个值)。
- 撤回 4.10 第 7 条的错误结论:该条断言「常量无消费者、缺页脚组件」,实际
components/onboarding/ui/SupportFooter.tsx 早一天就已存在并挂在 P1Login / P2QuickLogin
两页上,页脚一直渲染。该结论朝「把已完成的当成未完成」的方向错,已改为如实。
4.15 数据自相矛盾批量收敛 + 半透明色 token 化 + lint 补盲区
- 又查出并修掉 10 组同源不同值,多为跨页面 / 跨层级,翻页时肉眼不易对照:
· 求职进度 stats 五项之和 30 ≠ 总数 28(ended 改为由其余四项相减得出,求和成为结构性的)
· 月度图表投递合计 52 / 面试合计 16,而卡片写 28 / 6(六个月三列重排为 28 / 6 / 2)
· 我的页收藏徽标 12 与收藏列表 3
· 勋章「投递满100次」显示已获得,而实际只有 28 次投递(obtained / progress 改由
buildUserStats() 派生)
· 匹配成功横幅写 3 家,而会话列表里 matched 为 true 的只有 2 条
· 岗位详情综合分恒为 92 分·优秀(fetchJobOverallScore 里是 `void id;`),9 个岗位里
只有第 1 个对得上;雷达图八维改按该岗位实际分等比缩放,否则 65 分的岗位会配一张
最低 78 分的图
· 对比页把产品经理与 UI 设计师的综合评分正好写反
· 候选人 4–8 号点进去全叫「张明远」(身份改取自人才推荐列表)
· 岗位 1 的公司所在地在列表卡写海淀区、详情页写朝阳区
· 待回复卡片写 3 人,而它跳转的「未回复」Tab 只有 2 条 —— 两者不是同一集合
(有未读消息 vs 未回复)。判据应是「与去向一致」,不是「某个内部集合的长度」
- 顺带修掉一处同屏矛盾:求职进度页标题「求职中 {stats.interviewing}」压在按 Tab 过滤的
companies 上(4 条),标题写 2、下面列 4 行,切到「面试中」Tab 只剩 1 行。该事实当时有
三份副本(统计卡 / 标题 / Tab 列表),已去掉标题这一份。
**未解决并如实记录**:companies 是 28 条投递的抽样,故统计卡的分阶段数字在 Tab 列表里
永远对不上行数 —— 属全量 vs 抽样的结构问题,两条修法都会改变用户看到的页面,需产品定夺。
- 5 处 rgba(255,255,255,x) 逐处看下来不是同一个 token 被写重 5 次,而是 4 个不同角色
(卡片底 / 弱底色 / 分割线 / 进度条轨道),故按角色拆成 4 个 token,值等于现状、一像素未改;
resume.tsx 的 rgba(0,0,0,0.4) 改用已有 colors.scrim。
- 补 lint 盲区:no-hardcoded-hex-color 只匹配纯 hex,拦不住 rgba()/hsl()。已扩到函数式颜色,
并加 TemplateElement 访问器,否则模板字符串里的颜色照样漏网。
- formatCompact(企业端看板内联的 .toFixed(1)k)与 formatThousands 下沉到 src/utils。
formatCompact 刻意保留尾随 .0(2000 → 2.0k),与 formatViews 去掉 .0 的做法不一致且是有意的,
改口径会动到看板现状显示。
测试
- 新增 data-consistency.test.ts(22 例),断言刻意写成「A 必须等于 B」的关系而非「A 等于 28」:
以后调整 mock 数据不会让测试变红,只有把派生关系改回写字面量才会红。
- 新增 job-preference-options.test.ts(15 例),含源码级接线锁。
- 修正 enterprise-filters.test.ts 一条把矛盾当契约的断言:原先锁的是「有未读消息的会话数」,
与卡片跳转过去的「未回复」Tab 不是同一集合,等于把缺陷固化成了期望。现锁「与去向一致」。
- 灵敏度验证:注入缺陷确认对应断言确实变红(含一次一次性注入 3 个缺陷、5 例如期失败),
随后逐字还原并 grep 确认无残留。
- 两处如实记录:一次用 python 做注入时脚本静默失败(本机 python 是 Windows 商店占位存根,
非交互调用不执行也不报错),文件其实没被改动 —— 若没先 grep 确认注入物存在,就会拿一次
「未注入」的运行去证明「规则有效」;以及 data-consistency.test.ts 初版把「累计面试邀请数」
与「当前面试中数」当成同一个事实而断言相等,红的是测试不是数据,已改为反向断言并写明注释。
实测(Android 模拟器 emulator-5554,截图核对,非代码走查)
- 求职进度统计卡 28 / 6 / 2 / 2 / 18,6+2+2+18 = 28 成立;月度图表三列合计 28 / 6 / 2;
段落标题随 Tab 变化,不再出现与列表矛盾的数字。
- 我的页 投递 28 / 收藏 3 / 沟通 8 / Offer 2(收藏 12 → 3);勋章 28/100、6/10、95/100,
其中诚信之星 95 与同屏「八大维度 → 诚信记录 95」同源,肉眼可自查。
- 未验到的:候选人详情 4–8 号姓名、岗位详情评分、对比页评分互换、收藏列表条数 ——
均有测试锁盖住,但本轮未逐屏截图,按「未做真机验证」记录,不当成已验证。
门槛:tsc 0 / eslint 0 / 25 文件 339 例全绿 / check:routes 0 / check:affordances 0 /
check:icons 0(194 文件、1357 字形)/ check:backend-stubs 140
master
22 changed files with 1310 additions and 130 deletions
@ -0,0 +1,295 @@ |
|||||
|
/** |
||||
|
* 数据自洽性回归锁(2026-09-20 批次) |
||||
|
* |
||||
|
* 本批修的都是同一类缺陷:**同一个业务事实写了两份,两处取值不同**。 |
||||
|
* 修法一律是让重复的那一份从权威源**派生**,而不是把两个数字手工对齐 —— |
||||
|
* 手工对齐只解决这一次,下次改数据照样分叉。 |
||||
|
* |
||||
|
* 因此下面的断言尽量写成「A 必须等于 B」的**关系**而非「A 等于 28」: |
||||
|
* 这样以后调整 mock 数据(把 28 改成 30)不会让测试变红, |
||||
|
* 只有把派生关系改回写字面量才会红 —— 这正是要锁的东西。 |
||||
|
* |
||||
|
* 每条断言都对应一个**真实发生过的**矛盾,注释里写了当时两处各写了什么。 |
||||
|
*/ |
||||
|
import { |
||||
|
fetchBadges, |
||||
|
fetchCandidateDetail, |
||||
|
fetchCompareData, |
||||
|
fetchConversations, |
||||
|
fetchFavorites, |
||||
|
fetchJobDetail, |
||||
|
fetchJobList, |
||||
|
fetchJobOverallScore, |
||||
|
fetchJobProgress, |
||||
|
fetchMatchResult, |
||||
|
fetchTalentRecommendation, |
||||
|
fetchUserStats, |
||||
|
} from '../api/api'; |
||||
|
import { JOB_LEVEL_LABEL } from '../constants'; |
||||
|
import { formatCompact } from '../utils'; |
||||
|
|
||||
|
// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock
|
||||
|
jest.mock('../lib/storage', () => ({ |
||||
|
storageGet: jest.fn(async () => null), |
||||
|
storageSet: jest.fn(async () => undefined), |
||||
|
storageRemove: jest.fn(async () => undefined), |
||||
|
})); |
||||
|
|
||||
|
/** 取值并把「取不到」变成带名字的失败,避免 undefined 参与比较后产生假绿 */ |
||||
|
function must<T>(value: T | undefined, what: string): T { |
||||
|
if (value === undefined) throw new Error(`测试期望存在:${what}`); |
||||
|
return value; |
||||
|
} |
||||
|
|
||||
|
describe('求职进度:统计卡 / 月度图表 / 时间轴必须是同一个事实', () => { |
||||
|
it('统计卡五项之和等于总数(用户会心算这一行)', async () => { |
||||
|
const { stats } = await fetchJobProgress(); |
||||
|
|
||||
|
expect(stats.pending + stats.interviewing + stats.offer + stats.ended).toBe(stats.total); |
||||
|
}); |
||||
|
|
||||
|
it('monthlyStats 每一列的合计都等于顶部的对应计数', async () => { |
||||
|
const progress = await fetchJobProgress(); |
||||
|
const { monthlyStats } = progress; |
||||
|
const sum = (pick: (m: (typeof monthlyStats)[number]) => number) => |
||||
|
monthlyStats.reduce((acc, m) => acc + pick(m), 0); |
||||
|
|
||||
|
// 原先只对齐了 offers 一列:投递合计 52、面试合计 16,而卡片上写 28 / 6。
|
||||
|
// 注意对齐的对象是 `*Count`(累计),不是 `stats.interviewing`(当前面试中)——
|
||||
|
// 见下一条测试的说明,这两个不是同一个事实。
|
||||
|
expect(sum((m) => m.deliveries)).toBe(must(progress.deliveryCount, 'deliveryCount')); |
||||
|
expect(sum((m) => m.interviews)).toBe(must(progress.interviewCount, 'interviewCount')); |
||||
|
expect(sum((m) => m.offers)).toBe(must(progress.offerCount, 'offerCount')); |
||||
|
}); |
||||
|
|
||||
|
it('「累计面试邀请数」与「当前面试中数」是两个事实,不能要求相等', async () => { |
||||
|
const progress = await fetchJobProgress(); |
||||
|
const interviewCount = must(progress.interviewCount, 'interviewCount'); |
||||
|
const { interviewing } = progress.stats; |
||||
|
|
||||
|
// 写这条是为了钉住一个**踩过的坑**:本文件初版把这两个数当成同一份被写重的值,
|
||||
|
// 断言 interviewCount === stats.interviewing 并因此变红 —— 红的是测试,不是数据。
|
||||
|
// 语义:interviewCount 是「累计收到多少场面试邀请」(与 monthlyStats.interviews 之和、
|
||||
|
// 时间轴「收到 6 场面试邀请」同源);stats.interviewing 是「此刻有几家停在面试中」。
|
||||
|
// 前者必然 ≥ 后者(面试过的不可能比收到的多),但两者绝不相等。
|
||||
|
expect(interviewCount).toBeGreaterThanOrEqual(interviewing); |
||||
|
expect(interviewCount).not.toBe(interviewing); |
||||
|
}); |
||||
|
|
||||
|
it('投递总数与 Offer 数这两处确实是同一个事实,必须同源', async () => { |
||||
|
const progress = await fetchJobProgress(); |
||||
|
const { stats } = progress; |
||||
|
|
||||
|
// 与上一条相反:这两个在语义上就是「同一个数」,
|
||||
|
// 原先 `deliveryCount`、`stats.total`、时间轴文案三份各写各的,才出现 30 ≠ 28。
|
||||
|
expect(progress.deliveryCount).toBe(stats.total); |
||||
|
expect(progress.offerCount).toBe(stats.offer); |
||||
|
}); |
||||
|
|
||||
|
it('时间轴文案里的数字与计数同源(不能文案写 28、卡片写 30)', async () => { |
||||
|
const progress = await fetchJobProgress(); |
||||
|
const { steps } = progress; |
||||
|
const descOf = (id: string) => must(steps?.find((s) => s.id === id), `步骤 ${id}`).desc; |
||||
|
|
||||
|
expect(descOf('p1')).toContain(String(must(progress.deliveryCount, 'deliveryCount'))); |
||||
|
expect(descOf('p3')).toContain(String(must(progress.interviewCount, 'interviewCount'))); |
||||
|
expect(descOf('p5')).toContain(String(must(progress.offerCount, 'offerCount'))); |
||||
|
}); |
||||
|
|
||||
|
it('「即将到期的 Offer」条数不能超过 Offer 总数(提醒页比统计卡还多就是吹牛)', async () => { |
||||
|
const { offers, stats } = await fetchJobProgress(); |
||||
|
|
||||
|
expect(offers.length).toBeGreaterThan(0); |
||||
|
expect(offers.length).toBeLessThanOrEqual(stats.offer); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('我的:统计数字必须与它click进去看到的列表同源', () => { |
||||
|
it('收藏数与收藏列表条数一致(原先徽标 12、列表 3)', async () => { |
||||
|
const stats = await fetchUserStats(); |
||||
|
const favorites = await fetchFavorites(); |
||||
|
|
||||
|
expect(stats.favorites).toBe(favorites.length); |
||||
|
}); |
||||
|
|
||||
|
it('投递数与求职进度总数一致(同一个「我投了多少家」)', async () => { |
||||
|
const stats = await fetchUserStats(); |
||||
|
const { stats: progressStats } = await fetchJobProgress(); |
||||
|
|
||||
|
expect(stats.deliveries).toBe(progressStats.total); |
||||
|
}); |
||||
|
|
||||
|
it('Offer 数与求职进度 Offer 数一致', async () => { |
||||
|
const stats = await fetchUserStats(); |
||||
|
const { stats: progressStats } = await fetchJobProgress(); |
||||
|
|
||||
|
expect(stats.offers).toBe(progressStats.offer); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('勋章:已获得与否必须由真实统计推导', () => { |
||||
|
it('投递类勋章的进度就是投递数(不能 28 次投递配「投递满100次 已获得」)', async () => { |
||||
|
const stats = await fetchUserStats(); |
||||
|
const badges = await fetchBadges(); |
||||
|
const badge = (id: string) => must(badges.find((b) => b.id === id), `勋章 ${id}`); |
||||
|
|
||||
|
const target100 = must(badge('badge-1').target, 'badge-1.target'); |
||||
|
expect(badge('badge-1').progress).toBe(Math.min(stats.deliveries, target100)); |
||||
|
expect(badge('badge-1').obtained).toBe(stats.deliveries >= target100); |
||||
|
|
||||
|
const target200 = must(badge('badge-5').target, 'badge-5.target'); |
||||
|
expect(badge('badge-5').progress).toBe(Math.min(stats.deliveries, target200)); |
||||
|
expect(badge('badge-5').obtained).toBe(stats.deliveries >= target200); |
||||
|
}); |
||||
|
|
||||
|
it('Offer 类勋章同理', async () => { |
||||
|
const stats = await fetchUserStats(); |
||||
|
const badges = await fetchBadges(); |
||||
|
const badge = must(badges.find((b) => b.id === 'badge-4'), '勋章 badge-4'); |
||||
|
const target = must(badge.target, 'badge-4.target'); |
||||
|
|
||||
|
expect(badge.progress).toBe(Math.min(stats.offers, target)); |
||||
|
expect(badge.obtained).toBe(stats.offers >= target); |
||||
|
}); |
||||
|
|
||||
|
it('任何勋章都不能出现「已获得却没达标」或「未获得却已超额」', async () => { |
||||
|
const stats = await fetchUserStats(); |
||||
|
const badges = await fetchBadges(); |
||||
|
|
||||
|
expect(badges.length).toBeGreaterThan(0); |
||||
|
badges.forEach((badge) => { |
||||
|
const target = must(badge.target, `${badge.id}.target`); |
||||
|
const progress = must(badge.progress, `${badge.id}.progress`); |
||||
|
|
||||
|
expect(progress).toBeLessThanOrEqual(target); |
||||
|
expect(badge.obtained).toBe(progress >= target); |
||||
|
// 文案里若写了门槛,必须与 target 一致(如「投递满100次」↔ target 100)。
|
||||
|
// 「诚信记录满分」这类描述本就不含数字,故只在有数字时才比对,否则这条会变成假要求。
|
||||
|
const numbersInDescription = badge.description.match(/\d+/g) ?? []; |
||||
|
if (numbersInDescription.length > 0) { |
||||
|
expect(numbersInDescription).toContain(String(target)); |
||||
|
} |
||||
|
// 统计卡上写着投递数,勋章进度不能凭空超过它
|
||||
|
if (badge.id === 'badge-1' || badge.id === 'badge-5') { |
||||
|
expect(progress).toBeLessThanOrEqual(stats.deliveries); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('匹配成功横幅:数字与逐条会话标记同源', () => { |
||||
|
it('total 等于「已匹配」会话数,companies 就是这些公司', async () => { |
||||
|
const matchResult = await fetchMatchResult(); |
||||
|
const conversations = await fetchConversations(); |
||||
|
|
||||
|
const matched = conversations.filter((chat) => chat.matched); |
||||
|
|
||||
|
expect(matchResult.total).toBe(matched.length); |
||||
|
expect(matchResult.companies).toEqual(matched.map((chat) => chat.companyName)); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('岗位评分:列表卡 / 详情雷达图 / 对比页三处必须同分', () => { |
||||
|
it('每个岗位的详情总分等于它在列表里的分数(原先恒返回 92 分)', async () => { |
||||
|
const { jobs } = await fetchJobList({ sortBy: 'comprehensive', pageSize: 50 }); |
||||
|
expect(jobs.length).toBeGreaterThan(1); |
||||
|
|
||||
|
for (const job of jobs) { |
||||
|
const score = await fetchJobOverallScore(job.id); |
||||
|
expect(score.totalScore).toBe(job.score); |
||||
|
// 中文等级也必须与列表卡上的 level 同源
|
||||
|
expect(score.level).toBe(JOB_LEVEL_LABEL[job.level]); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('详情雷达图的维度值经缩放后不越界(65 分的岗位不能配一张最低 78 分的雷达图)', async () => { |
||||
|
const { jobs } = await fetchJobList({ sortBy: 'comprehensive', pageSize: 50 }); |
||||
|
|
||||
|
for (const job of jobs) { |
||||
|
const { dimensions, totalScore } = await fetchJobOverallScore(job.id); |
||||
|
expect(dimensions.length).toBeGreaterThan(0); |
||||
|
dimensions.forEach((dim) => { |
||||
|
expect(dim.value).toBeGreaterThanOrEqual(0); |
||||
|
expect(dim.value).toBeLessThanOrEqual(100); |
||||
|
}); |
||||
|
// 维度全部为 0 只可能是缩放因子算错(base 为 0)
|
||||
|
expect(dimensions.some((dim) => dim.value > 0)).toBe(true); |
||||
|
expect(totalScore).toBe(job.score); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('对比页的「综合评分」就是各岗位自己的分(原先两个岗位的分正好写反)', async () => { |
||||
|
const { jobs } = await fetchJobList({ sortBy: 'comprehensive', pageSize: 50 }); |
||||
|
const scoreById = new Map(jobs.map((job) => [job.id, job.score])); |
||||
|
|
||||
|
const compare = await fetchCompareData([...scoreById.keys()]); |
||||
|
const dimension = must( |
||||
|
compare.dimensionScores.find((dim) => dim.dimension === '综合评分'), |
||||
|
'对比页的「综合评分」维度', |
||||
|
); |
||||
|
|
||||
|
expect(dimension.scores).toHaveLength(compare.jobs.length); |
||||
|
compare.jobs.forEach((job, index) => { |
||||
|
const expected = scoreById.get(job.id); |
||||
|
expect(expected).toBeDefined(); |
||||
|
expect(dimension.scores[index]).toBe(expected); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
it('详情页的公司所在地与列表卡一致(原先同一个岗位写了两个城区)', async () => { |
||||
|
const { jobs } = await fetchJobList({ sortBy: 'comprehensive', pageSize: 50 }); |
||||
|
|
||||
|
for (const job of jobs) { |
||||
|
const detail = await fetchJobDetail(job.id); |
||||
|
expect(detail.companyLocation).toBe(job.companyLocation); |
||||
|
expect(detail.positionName).toBe(job.positionName); |
||||
|
expect(detail.companyName).toBe(job.companyName); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('候选人详情:身份必须取自人才推荐列表', () => { |
||||
|
it('每位候选人的姓名 / 年龄 / 城市 / 三项评分都与列表同源(原先 4-8 号全叫张明远)', async () => { |
||||
|
const { candidates } = await fetchTalentRecommendation(); |
||||
|
expect(candidates.length).toBeGreaterThan(4); |
||||
|
|
||||
|
for (const candidate of candidates) { |
||||
|
const detail = await fetchCandidateDetail(candidate.id); |
||||
|
|
||||
|
expect(detail.id).toBe(candidate.id); |
||||
|
expect(detail.basicInfo.name).toBe(candidate.name); |
||||
|
expect(detail.basicInfo.age).toBe(candidate.age); |
||||
|
expect(detail.basicInfo.city).toBe(candidate.city); |
||||
|
expect(detail.matchPercent).toBe(candidate.matchPercent); |
||||
|
expect(detail.overallScore).toBe(candidate.overallScore); |
||||
|
expect(detail.percentile).toBe(candidate.percentile); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('姓名不是所有人共用一个(这条断言专门盯住「都渲染成张明远」)', async () => { |
||||
|
const { candidates } = await fetchTalentRecommendation(); |
||||
|
const names = await Promise.all( |
||||
|
candidates.map(async (candidate) => (await fetchCandidateDetail(candidate.id)).basicInfo.name), |
||||
|
); |
||||
|
|
||||
|
expect(new Set(names).size).toBeGreaterThan(1); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('formatCompact:看板紧凑计数', () => { |
||||
|
it('四位数及以上转 k,保留一位小数', () => { |
||||
|
expect(formatCompact(2300)).toBe('2.3k'); |
||||
|
expect(formatCompact(1286)).toBe('1.3k'); |
||||
|
}); |
||||
|
|
||||
|
it('不足一千原样输出', () => { |
||||
|
expect(formatCompact(0)).toBe('0'); |
||||
|
expect(formatCompact(999)).toBe('999'); |
||||
|
}); |
||||
|
|
||||
|
it('小数位口径与改造前的内联写法一致(刻意不去掉 ".0")', () => { |
||||
|
// 改这个口径会动到企业端看板现在的显示,属于另一批事,这里锁住现状
|
||||
|
expect(formatCompact(1000)).toBe('1.0k'); |
||||
|
expect(formatCompact(2000)).toBe('2.0k'); |
||||
|
}); |
||||
|
}); |
||||
@ -0,0 +1,229 @@ |
|||||
|
import { fetchJobPreference, fetchResume, saveJobPreference, saveOnboardingProfile } from '../api/api'; |
||||
|
import { readFileSync } from 'node:fs'; |
||||
|
import { join } from 'node:path'; |
||||
|
import { |
||||
|
EXPECTED_CITY_OPTIONS, |
||||
|
EXPECTED_SALARY_OPTIONS, |
||||
|
JOB_STATUS_INACTIVE, |
||||
|
JOB_STATUS_OPTIONS, |
||||
|
PREFERENCE_DEFAULT_WORK_TYPE, |
||||
|
SALARY_MAX_OPTIONS, |
||||
|
SALARY_MIN_OPTIONS, |
||||
|
WORK_TYPE_OPTIONS, |
||||
|
} from '../constants'; |
||||
|
import { storageRemove } from '../lib/storage'; |
||||
|
import { withCurrentOption } from '../utils'; |
||||
|
import type { JobPreference, OnboardingProfile } from '../types'; |
||||
|
|
||||
|
// 内存版 storage mock(与 job-match.test.ts 同)
|
||||
|
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]; |
||||
|
}), |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
/** 全部字段留空的兜底资料,只覆盖测试关心的字段 */ |
||||
|
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, |
||||
|
}); |
||||
|
|
||||
|
/** |
||||
|
* chip 组的选中判定(与 `app/job-preference.tsx` 的 `opt === value` 同一口径)。 |
||||
|
* 薪资那一组选项要过 `withCurrentOption`,其余三组直接用常量列表。 |
||||
|
*/ |
||||
|
const isSelected = (options: readonly string[], current: string): boolean => |
||||
|
current !== '' && options.includes(current); |
||||
|
|
||||
|
beforeEach(async () => { |
||||
|
await storageRemove('user:job-preference'); |
||||
|
await saveOnboardingProfile(BASE_PROFILE); |
||||
|
}); |
||||
|
|
||||
|
describe('引导采集的求职意向,在「设置求职意向」页必须有 chip 能选中', () => { |
||||
|
/** |
||||
|
* 这一组是回归锁。此前引导页与求职意向页各有一份选项表: |
||||
|
* 期望薪资——引导拼出 `10K-20K`,该页写的是 `10-20K`(最低值不带 K) |
||||
|
* 求职状态——引导用 `离职-随时到岗` 等,该页用 `积极求职中` 等,两个列表零重合 |
||||
|
* 期望城市——引导 16 城,该页 10 城,天津 / 重庆 / 青岛等 6 城落空 |
||||
|
* 用户刚在引导里填完,进来看到「期望职位」有值、其余三项全空,像资料丢了。 |
||||
|
*/ |
||||
|
|
||||
|
it('期望薪资:P7 两个阶梯拼出的值,与该页快捷档同一种写法', () => { |
||||
|
// 取阶梯里任意两组,拼出的写法必须与快捷档一致(都是「最低K-最高K」)
|
||||
|
for (const min of SALARY_MIN_OPTIONS) { |
||||
|
for (const max of SALARY_MAX_OPTIONS) { |
||||
|
const derived = `${min}-${max}`; |
||||
|
// 快捷档覆盖不到的组合(如 8K-12K)由 withCurrentOption 补进去,同样能选中
|
||||
|
expect(isSelected(withCurrentOption(EXPECTED_SALARY_OPTIONS, derived), derived)).toBe(true); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('期望薪资:常见组合直接命中快捷档,无需补值', () => { |
||||
|
for (const derived of ['5K-10K', '10K-20K', '20K-30K', '30K-50K']) { |
||||
|
expect(withCurrentOption(EXPECTED_SALARY_OPTIONS, derived)).toEqual(EXPECTED_SALARY_OPTIONS); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('求职状态:引导的每一个状态都能在该页选中', () => { |
||||
|
for (const status of JOB_STATUS_OPTIONS) { |
||||
|
expect(isSelected(JOB_STATUS_OPTIONS, status)).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('期望城市:引导选得出来的城市,该页一个都不能少', () => { |
||||
|
for (const city of EXPECTED_CITY_OPTIONS) { |
||||
|
expect(isSelected(EXPECTED_CITY_OPTIONS, city)).toBe(true); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
it('工作类型:默认值必须落在选项表内(否则新用户一进来就选不中)', () => { |
||||
|
expect(WORK_TYPE_OPTIONS).toContain(PREFERENCE_DEFAULT_WORK_TYPE); |
||||
|
}); |
||||
|
|
||||
|
it('端到端:引导采集三件套 → fetchJobPreference 的产物逐项可选中', async () => { |
||||
|
await saveOnboardingProfile( |
||||
|
makeProfile({ |
||||
|
city: '天津', // 旧版该页 10 城里没有这个
|
||||
|
expectedSalaryMin: '10K', |
||||
|
expectedSalaryMax: '20K', |
||||
|
jobStatus: '在职-月内到岗', // 旧版该页词表里没有这一项
|
||||
|
}), |
||||
|
); |
||||
|
|
||||
|
const preference = await fetchJobPreference(); |
||||
|
expect(isSelected(EXPECTED_CITY_OPTIONS, preference.city)).toBe(true); |
||||
|
expect(isSelected(withCurrentOption(EXPECTED_SALARY_OPTIONS, preference.salary), preference.salary)).toBe(true); |
||||
|
expect(isSelected(JOB_STATUS_OPTIONS, preference.status)).toBe(true); |
||||
|
expect(isSelected(WORK_TYPE_OPTIONS, preference.workType)).toBe(true); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('求职状态词表统一后,简历求职意向的积极 / 不积极判定才成立', () => { |
||||
|
/** |
||||
|
* `fetchResume` 用 `preference.status === JOB_STATUS_INACTIVE` 判「是否积极求职」。 |
||||
|
* 该常量属于引导词表;此前该页的自有词表里写的是 `在职暂不考虑`(没那两个短横), |
||||
|
* 于是用户在该页选了「暂不考虑」,保存后简历仍然把他记成积极求职中 —— 存进去的值 |
||||
|
* 与判定用的常量不是同一个字符串,比较恒为 false。 |
||||
|
*/ |
||||
|
it('在该页选「暂不考虑」并保存,简历必须记为 inactive', async () => { |
||||
|
await saveJobPreference({ |
||||
|
position: '前端开发工程师', |
||||
|
city: '北京', |
||||
|
salary: '20K-30K', |
||||
|
workType: '全职', |
||||
|
status: JOB_STATUS_INACTIVE, |
||||
|
} as JobPreference); |
||||
|
|
||||
|
const resume = await fetchResume(); |
||||
|
expect(resume.jobIntention.status).toBe('inactive'); |
||||
|
expect(resume.jobIntention.statusLabel).toBe(JOB_STATUS_INACTIVE); |
||||
|
}); |
||||
|
|
||||
|
it('选其余任一状态都记为 active', async () => { |
||||
|
for (const status of JOB_STATUS_OPTIONS.filter((s) => s !== JOB_STATUS_INACTIVE)) { |
||||
|
await saveJobPreference({ |
||||
|
position: '前端开发工程师', |
||||
|
city: '北京', |
||||
|
salary: '20K-30K', |
||||
|
workType: '全职', |
||||
|
status, |
||||
|
} as JobPreference); |
||||
|
|
||||
|
const resume = await fetchResume(); |
||||
|
expect(resume.jobIntention.status).toBe('active'); |
||||
|
} |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('接线锁:选项表必须来自 constants,页面不得自持一份', () => { |
||||
|
/** |
||||
|
* 上面几例锁的是「常量之间自洽」,但原缺陷的形态是**页面自己又写了一份**—— |
||||
|
* 常量没毛病,页面不读它,照样对不上。故这里直接读源码,锁住接线。 |
||||
|
* |
||||
|
* 说明:这是源码级断言,不是渲染断言(本项目 24 个测试文件均为纯逻辑测试, |
||||
|
* 不引入组件测试)。它能拦住「改回本地字面量」,但拦不住「读常量后又做了一层转换」。 |
||||
|
*/ |
||||
|
const read = (relative: string) => |
||||
|
readFileSync(join(__dirname, '../../', relative), 'utf8'); |
||||
|
|
||||
|
it('求职意向页从 constants 取四组选项,且不再声明本地选项数组', () => { |
||||
|
const src = read('app/job-preference.tsx'); |
||||
|
for (const name of [ |
||||
|
'EXPECTED_CITY_OPTIONS', |
||||
|
'EXPECTED_SALARY_OPTIONS', |
||||
|
'JOB_STATUS_OPTIONS', |
||||
|
'WORK_TYPE_OPTIONS', |
||||
|
]) { |
||||
|
expect(src).toContain(name); |
||||
|
} |
||||
|
// 旧版的本地数组名;重建即回归
|
||||
|
expect(src).not.toMatch(/^const (CITIES|SALARIES|WORK_TYPES|STATUSES)\b/m); |
||||
|
}); |
||||
|
|
||||
|
it('引导选城市页与求职意向页读的是同一份城市表', () => { |
||||
|
const onboardingSrc = read('components/onboarding/screens/P5City.tsx'); |
||||
|
expect(onboardingSrc).toContain('EXPECTED_CITY_OPTIONS'); |
||||
|
expect(onboardingSrc).not.toMatch(/^const ALL = \[/m); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('withCurrentOption(把用户的真实值补进 chip 组)', () => { |
||||
|
it('已在列表中时原样返回', () => { |
||||
|
expect(withCurrentOption(['不限', '20K-30K'], '20K-30K')).toEqual(['不限', '20K-30K']); |
||||
|
}); |
||||
|
|
||||
|
it('不在列表中时插在首位之后(首位通常是「不限」这类兜底项)', () => { |
||||
|
expect(withCurrentOption(['不限', '20K-30K'], '8K-12K')).toEqual(['不限', '8K-12K', '20K-30K']); |
||||
|
}); |
||||
|
|
||||
|
it('空串(未设置)不补,保持列表原样 —— 「未设置」不该变成一个可选项', () => { |
||||
|
expect(withCurrentOption(['不限', '20K-30K'], '')).toEqual(['不限', '20K-30K']); |
||||
|
}); |
||||
|
|
||||
|
it('空列表时返回只含当前值的列表', () => { |
||||
|
expect(withCurrentOption([], '8K-12K')).toEqual(['8K-12K']); |
||||
|
}); |
||||
|
|
||||
|
it('不修改传入的数组(纯函数)', () => { |
||||
|
const options = ['不限', '20K-30K']; |
||||
|
withCurrentOption(options, '8K-12K'); |
||||
|
expect(options).toEqual(['不限', '20K-30K']); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue