Browse Source
接 4.14 / 4.15 的同一类缺陷排查。那两批治的是「同一个事实两处取值不同」,
本批治它的另一半:**取值当时相同,但同一张映射表 / 同一段业务规则被抄了多份**。
后者平时看不出来,代价有两个:改一处漏一处;以及测试锁的是**没人渲染的那份**。
本批不改任何取值,属纯重构 —— 已逐条核对 git diff 的删除行,无一色值被改。
修法:14 处本地声明 → 6 张单一来源的表
- 评级 → 文字色 **5 份 → 1**:growth / jobs / ability / assessment 四处同名同值
(三处叫 LEVEL_TEXT_COLOR、一处叫 LEVEL_COLOR),hr-evaluation 另有一份中文键版。
- 霍兰德六型 → 强调色 3 份 → 1(介绍 / 答题 / 结果三页各一份 TYPE_COLORS)。
- 语义色名 → 色值 3 份 → 1(企业看板 / 面试评分 / AI 面试各一份 COLOR_TOKENS)。
- 文章缩略图 2 种写法 → 1:成长页是 {violet:{bg,color}} 对象表,详情页则拆成
THUMBNAIL_BG + THUMBNAIL_COLOR 两份平行 Record。
- 社保基数 clamp **3 份 → 1**:权威那份在接口层 computeSalaryResult 里,另两份在
计算器页(一个同名 helper + handleSelectCity 内联)。提取时把 city 提成**显式参数** ——
页面内联那份的成因正是 helper 读的是 inputs.city(旧城市),而切城市要用新城市的上限,
所以「简化成调用 helper」会静默算错城市。
- 时间戳 → 「月-日 时:分」2 份 → 1:utils.formatDateTime 收 ISO 串、页面 formatRecordTime
收时间戳,格式逐字节相同。formatDateTime 改为接受 string | number。
副作用(正向的):时间戳版本原先不判 NaN,坏值会渲染出「NaN-NaN NaN:NaN」,现返回空串。
- 手机号脱敏 2 份 → 1:登录页内联的那段与 api.maskPhone 逐字节相同,而
carrier-login.test.ts 里那条 describe 的名字就是「脱敏在接口层,页面不自己切字符串」,
《运营商一键登录接入方案.md》也写明「页面不做脱敏」—— 页面违反了它自己的约定。
- 企业端发布页自持的学历 / 工作类型选项表接回 src/constants(工作类型的编码与标签
成对定义,标签列表由它派生,避免「有编码没标签」的悬空项)。
放置位置按职责定,不是就近塞:颜色映射进 theme(该文件自己写着「色值是本文件独占的职责」,
且已有 templateAccentColors 先例);clamp 进 api 层(AGENTS.md 规定业务规则在接口层,
权威那份本来就在那里);时间格式化进 utils;选项词表进 constants。
一处是**派生**而不是搬家
hr-evaluation 的键是中文评级('优秀'),不是 JobLevel —— 根因是数据层
EvaluationDimension.level 声明为 string 且直接下发中文评级。这是「同一事实、两个键域」,
把字面量搬个地方等于把「两份同值」换成「两份同值、只是搬了家」。故 levelLabelTextColors
按引用派生自 jobLevelTextColors,将来换色只改一处。断言锁的是关系(同键同值),不是字面量。
刻意**不**合并的两处
- 经验要求:constants 的 EXPERIENCE_OPTIONS 是**筛选**项(含「不限」、不含「1年以内」),
发布页那份是**必填要求**(含「1年以内」、无「不限」),不是同一个事实。已加反向锁
把差异写死,防止后来者按名字想当然地合并。
- DIMENSION_LEVEL(profile.tsx)不在收敛范围:键的是 DimensionScore['level'](只有两档),
且「良好」用 textSecondary 而非另外五处的 primary。两处对同一档取色确实不同,
属需要产品定夺的配色分歧,**未擅自统一**,已记入《项目开发状态.md》4.16。
测试
- 新增 shared-color-maps.test.ts(26 例)。断言分两类:**关系**(中文评级表与
jobLevelTextColors 同键同值、各表内部色值互不相同、计算器养老金额可由 clampSocialBase
反推、formatDateTime 两种入参格式一致)与**接线锁**(读源码断言 14 处旧声明名不再出现、
页面必须引用新表名)—— 值对了但页面不读表,缺陷照旧。
- 灵敏度验证:一次性注入 6 个缺陷(重建旧声明 + input.city 换成 '北京' +
levelLabelTextColors 改回字面量),**6 例如期全红**,逐字还原后 grep 确认 0 残留。
- 一处如实记录:clamp 那条接线锁**第一版是写错的** —— 断言「页面不得出现 socialBaseUpper」,
而页面本来就该读它展示「基数范围 ¥x – ¥y(城市)」。错的是断言不是代码,已改为精确锁
「上下限字段与 Math.min/Math.max 同现」(那才是旧版内联 clamp 的形状)。
另有一次 edit 吃掉了 `const DIM_COLORS =` 后的空格,已复原到与 HEAD 逐字节一致。
如实记录:一份子代理报告的三条说法未通过核对
- 称 DIMENSION_LEVEL 重复声明 —— 不成立,全仓只有 profile.tsx 一处。
- 称 THUMBNAIL_* 重复「×2」—— 不精确,实为一个对象表 + 两份平行 Record(两种写法)。
- 给的 clamp 行号不准,且**没提权威那份在 api.ts**。
与既有一贯口径一致:代理报告是模型输出、不是证据,每条都独立 grep + 逐行读过才动手。
实测(Android 模拟器 emulator-5554,截图核对,非代码走查)
- 本批取值未变,真机的价值在「没崩、没落到兜底」:成长页三个缩略图色系全部解析;
HR 评价「优秀」「良好」都有色、无一落兜底灰;企业看板统计四宫格与快捷入口
**五个语义色各不相同**(若有键写错会静默全变蓝,多色并存即证明未落兜底);
发布页学历 5 项 / 经验 6 项 / 工作类型 4 项且选中态正确;薪资格算器切城市后
提示与区间正确更新。
- 未验到的:clamp 本身未在真机制造出越界值 —— 社保基数输入框**逐键 clamp**,
手工输入停不到目标值(既有交互,非本批改动),故 clamp 语义以单测 + 注入为准;
岗位列表 / 能力 / 测评的评级色、霍兰德三页六型色、面试两页语义色、登录页掩码手机号、
薪资历史记录时间格式 —— 均有类型约束或测试锁盖住,但本轮未逐屏截图。
仍未做(需产品定夺,未自行选择)
- 「良好」一档在个人页与其余五处取色不同(灰 vs 蓝),统一只改一行。
- JobExperienceRequirement 的「1年以内」是死值(全仓仅见于类型声明,mock 数据没用过、
筛选表也没这项),留还是删。
- 数据层 color: string 未收紧成联合类型,故 semanticColorTokens 的键只能保持
Record<string, string>,代价是数据层写错色名会静默落到 ?? colors.primary。已在注释与
测试里把「这是刻意的」写死,根治属另一批。
门槛:tsc 0 / eslint 0 / 26 文件 365 例全绿(较 4.15 的 339 例 +26)/ check-routes 0 /
check:affordances 0 / check:icons 0(194 文件、1357 字形)/ check-backend-stubs 140
master
23 changed files with 678 additions and 189 deletions
@ -0,0 +1,259 @@ |
|||
import { readFileSync } from 'node:fs'; |
|||
import { join } from 'node:path'; |
|||
import { clampSocialBase, computeSalaryResult, maskPhone } from '../api/api'; |
|||
import { SOCIAL_SECURITY_CITY_PROFILES, JOB_LEVEL_LABEL, EDUCATION_OPTIONS, WORK_TYPE_LABELS, WORK_TYPE_OPTIONS } from '../constants'; |
|||
import { |
|||
articleThumbnailColors, |
|||
colors, |
|||
hollandTypeColors, |
|||
jobLevelTextColors, |
|||
levelLabelTextColors, |
|||
semanticColorTokens, |
|||
templateAccentColors, |
|||
} from '../theme'; |
|||
import { formatDateTime } from '../utils'; |
|||
import type { JobLevel } from '../types'; |
|||
|
|||
// api.ts 依赖 storage,Jest 环境无原生 AsyncStorage,需内存版 mock(与 carrier-login.test.ts 同)
|
|||
jest.mock('../lib/storage', () => ({ |
|||
storageGet: jest.fn(async () => null), |
|||
storageSet: jest.fn(async () => undefined), |
|||
storageRemove: jest.fn(async () => undefined), |
|||
})); |
|||
|
|||
/** |
|||
* 本批要收敛的缺陷形态是**同一张映射表在多个页面各写一份**。 |
|||
* |
|||
* 断言刻意写成两类: |
|||
* · 值的关系(「A 必须等于 B」),而不是「A 等于 #175AB0」—— 以后调色不会让测试变红, |
|||
* 只有把派生关系改回并列复写才会红; |
|||
* · 源码级接线锁(读源码断言页面**不再自持一份**)—— 值对了但页面不读常量,缺陷照旧。 |
|||
* 这是本项目既有的做法(见 job-preference-options.test.ts)。 |
|||
* |
|||
* 说明:源码级断言读的是字符串,拦得住「改回本地字面量」,拦不住「读了常量又转换一层」。 |
|||
*/ |
|||
const read = (relative: string) => readFileSync(join(__dirname, '../../', relative), 'utf8'); |
|||
|
|||
const JOB_LEVELS: JobLevel[] = ['excellent', 'good', 'medium']; |
|||
|
|||
describe('评级 → 文字颜色:五处收敛成 theme 里的一张表', () => { |
|||
it('AGENTS.md 第九节:正向评级「优秀」必须用 primaryDark', () => { |
|||
expect(jobLevelTextColors.excellent).toBe(colors.primaryDark); |
|||
}); |
|||
|
|||
it('三档取值互不相同(否则「等级」这个区分在页面上看不出来)', () => { |
|||
const values = JOB_LEVELS.map((level) => jobLevelTextColors[level]); |
|||
expect(new Set(values).size).toBe(JOB_LEVELS.length); |
|||
}); |
|||
|
|||
it('中文评级表由 jobLevelTextColors 派生:每档都与它同键的那档同值', () => { |
|||
// 这张表存在的原因是**键的域不同**(数据层有的字段下发中文评级而不是 JobLevel),
|
|||
// 不是取值不同。两者若分叉,就是「优秀」与 excellent 在同一个页面上显示出两种蓝。
|
|||
for (const level of JOB_LEVELS) { |
|||
expect(levelLabelTextColors[JOB_LEVEL_LABEL[level]]).toBe(jobLevelTextColors[level]); |
|||
} |
|||
}); |
|||
|
|||
it('接线锁:四个「等级 → 文字色」的页面读 theme,不再各自声明本地映射', () => { |
|||
for (const file of [ |
|||
'app/(tabs)/growth.tsx', |
|||
'app/(tabs)/jobs.tsx', |
|||
'app/ability.tsx', |
|||
'app/assessment.tsx', |
|||
]) { |
|||
const src = read(file); |
|||
expect(src).toContain('jobLevelTextColors'); |
|||
// 旧版本地映射的两种名字;重建即回归
|
|||
expect(src).not.toMatch(/^const (LEVEL_TEXT_COLOR|LEVEL_COLOR)\b/m); |
|||
} |
|||
}); |
|||
|
|||
it('接线锁:HR 评价页读中文评级表,不再自持一份', () => { |
|||
const src = read('app/hr-evaluation.tsx'); |
|||
expect(src).toContain('levelLabelTextColors'); |
|||
expect(src).not.toMatch(/^const LEVEL_TEXT_COLOR\b/m); |
|||
// 中文键不该再出现在页面里(那是表的内容,不是页面的内容)
|
|||
expect(src).not.toContain("'优秀'"); |
|||
}); |
|||
}); |
|||
|
|||
describe('霍兰德六型 → 强调色:三处收敛成一张表', () => { |
|||
it('六型取值互不相同(否则雷达图六个顶点会撞色)', () => { |
|||
const values = Object.values(hollandTypeColors); |
|||
expect(values).toHaveLength(6); |
|||
expect(new Set(values).size).toBe(6); |
|||
}); |
|||
|
|||
it('接线锁:介绍 / 答题 / 结果三页读 theme,不再各自声明 TYPE_COLORS', () => { |
|||
for (const file of ['app/holland.tsx', 'app/holland/question.tsx', 'app/holland-result.tsx']) { |
|||
const src = read(file); |
|||
expect(src).toContain('hollandTypeColors'); |
|||
expect(src).not.toMatch(/^const TYPE_COLORS\b/m); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
describe('语义色名 → 色值:三处收敛成一张表', () => { |
|||
/** |
|||
* 这张表的键刻意保持 `string` 而非字面量联合:数据层这些字段声明的是 `color: string`, |
|||
* 收紧会让页面按 `string` 取值编译不过。这条断言把「刻意」写死,防止后来者顺手收紧后又放开。 |
|||
*/ |
|||
it('五个色名都能取到值,且都是合法十六进制', () => { |
|||
for (const key of ['primary', 'success', 'action', 'info', 'danger']) { |
|||
expect(semanticColorTokens[key]).toMatch(/^#[0-9A-Fa-f]{6}$/); |
|||
} |
|||
}); |
|||
|
|||
it('接线锁:企业看板 / 面试评分 / AI 面试三页读 theme,不再各自声明 COLOR_TOKENS', () => { |
|||
for (const file of [ |
|||
'app/(enterprise)/enterprise-overview.tsx', |
|||
'app/interview-score.tsx', |
|||
'app/interview.tsx', |
|||
]) { |
|||
const src = read(file); |
|||
expect(src).toContain('semanticColorTokens'); |
|||
expect(src).not.toMatch(/^const COLOR_TOKENS\b/m); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
describe('文章缩略图:底色与图标色成对定义一处', () => { |
|||
it('三组底色互不相同、三组图标色互不相同', () => { |
|||
const bgs = Object.values(articleThumbnailColors).map((t) => t.bg); |
|||
const fgs = Object.values(articleThumbnailColors).map((t) => t.color); |
|||
expect(new Set(bgs).size).toBe(3); |
|||
expect(new Set(fgs).size).toBe(3); |
|||
}); |
|||
|
|||
it('接线锁:成长页与文章详情页读同一张表', () => { |
|||
for (const file of ['app/(tabs)/growth.tsx', 'app/article/[id].tsx']) { |
|||
const src = read(file); |
|||
expect(src).toContain('articleThumbnailColors'); |
|||
// 旧版:成长页写成一个对象表,详情页拆成两份平行 Record —— 两种写法都不该回来
|
|||
expect(src).not.toMatch(/^const (THUMBNAIL_STYLE|THUMBNAIL_BG|THUMBNAIL_COLOR)\b/m); |
|||
} |
|||
}); |
|||
|
|||
it('模板强调色与语义色表都从 colors 取值,页面不写字面量', () => { |
|||
for (const value of [...Object.values(templateAccentColors), ...Object.values(semanticColorTokens)]) { |
|||
expect(value).toMatch(/^#[0-9A-Fa-f]{6}$/); |
|||
} |
|||
}); |
|||
}); |
|||
|
|||
describe('社保基数 clamp:三份副本收敛成接口层一条规则', () => { |
|||
it('切城市时按**新城市**的上下限 clamp(沿用旧城市的档案就是原来的 bug)', () => { |
|||
const shenzhenUpper = SOCIAL_SECURITY_CITY_PROFILES['深圳'].socialBaseUpper; |
|||
const beijingUpper = SOCIAL_SECURITY_CITY_PROFILES['北京'].socialBaseUpper; |
|||
// 前提校验:两城上限确实不同,否则这一例什么也证明不了
|
|||
expect(shenzhenUpper).toBeGreaterThan(beijingUpper); |
|||
|
|||
// 用户在深圳把基数拉到上限,再切到北京:必须被压到北京上限
|
|||
const inShenzhen = clampSocialBase('深圳', shenzhenUpper); |
|||
expect(inShenzhen).toBe(shenzhenUpper); |
|||
expect(clampSocialBase('北京', inShenzhen)).toBe(beijingUpper); |
|||
}); |
|||
|
|||
it('上下限之外的值被压回区间,区间内的值原样返回', () => { |
|||
const profile = SOCIAL_SECURITY_CITY_PROFILES['北京']; |
|||
expect(clampSocialBase('北京', 0)).toBe(profile.socialBaseLower); |
|||
expect(clampSocialBase('北京', 99_999_999)).toBe(profile.socialBaseUpper); |
|||
const inside = Math.round((profile.socialBaseLower + profile.socialBaseUpper) / 2); |
|||
expect(clampSocialBase('北京', inside)).toBe(inside); |
|||
}); |
|||
|
|||
it('无档案的城市落到默认档案(不静默返回原值)', () => { |
|||
expect(clampSocialBase('不存在的城市', 1)).toBe(SOCIAL_SECURITY_CITY_PROFILES['北京'].socialBaseLower); |
|||
}); |
|||
|
|||
it('计算器与页面走的是同一条 clamp 规则', () => { |
|||
// 养老保险个人缴纳 8%,反推社保基数 —— 若 computeSalaryResult 自带一份 clamp,这里会对不上
|
|||
const result = computeSalaryResult({ |
|||
city: '深圳', |
|||
monthlyGross: 50_000, |
|||
bonusMonths: 3, |
|||
socialBase: 99_999_999, // 故意超上限
|
|||
providentFundRate: 0.07, |
|||
specialDeduction: 0, |
|||
}); |
|||
const pension = result.insuranceItems.find((item) => item.label === '养老保险'); |
|||
expect(pension?.amount).toBe(Math.round(clampSocialBase('深圳', 99_999_999) * 0.08)); |
|||
}); |
|||
|
|||
it('接线锁:计算器页面不再自己 clamp', () => { |
|||
const src = read('app/tools/salary-calculator.tsx'); |
|||
expect(src).toContain('clampSocialBase'); |
|||
/* |
|||
* 页面**会**读 `cityProfile.socialBaseLower/Upper`,那是正当用途 —— |
|||
* 在社保基数输入框下方显示「基数范围 ¥x – ¥y(城市)」。 |
|||
* 故不能简单地禁掉这两个字段(第一版就这么写错了,断言本身是错的)。 |
|||
* 真正的分叉信号是**它们与 Math.min/Math.max 同现** —— 旧版那份内联 clamp 正是这个形状。 |
|||
*/ |
|||
expect(src).not.toMatch(/Math\.(min|max)\([^)]*socialBase(Upper|Lower)/); |
|||
}); |
|||
}); |
|||
|
|||
describe('脱敏与时间格式化:页面不再自持一份', () => { |
|||
it('接线锁:登录页调用接口层的 maskPhone,不再自己切字符串', () => { |
|||
const src = read('components/onboarding/screens/P2PhoneCode.tsx'); |
|||
expect(src).toContain('maskPhone(phone)'); |
|||
// 旧版内联写法;重建即回归
|
|||
expect(src).not.toContain("****${phone.slice"); |
|||
}); |
|||
|
|||
it('maskPhone 对 11 位手机号保留前 3 后 4,非 11 位原样返回', () => { |
|||
expect(maskPhone('15000009367')).toBe('150****9367'); |
|||
expect(maskPhone('1500000')).toBe('1500000'); |
|||
}); |
|||
|
|||
it('formatDateTime 同时接受 ISO 串与时间戳,两种入参格式一致', () => { |
|||
const iso = '2026-09-15T06:20:00.000Z'; |
|||
expect(formatDateTime(new Date(iso).getTime())).toBe(formatDateTime(iso)); |
|||
expect(formatDateTime(iso)).toMatch(/^\d{2}-\d{2} \d{2}:\d{2}$/); |
|||
}); |
|||
|
|||
it('坏值一律返回空串(原先时间戳那份不判 NaN,会渲染出 NaN-NaN NaN:NaN)', () => { |
|||
expect(formatDateTime(Number.NaN)).toBe(''); |
|||
expect(formatDateTime('')).toBe(''); |
|||
expect(formatDateTime('不是时间')).toBe(''); |
|||
}); |
|||
|
|||
it('接线锁:薪资格算器读 utils 的 formatDateTime,不再自带 formatRecordTime', () => { |
|||
const src = read('app/tools/salary-calculator.tsx'); |
|||
expect(src).toContain('formatDateTime'); |
|||
expect(src).not.toMatch(/^function formatRecordTime\b/m); |
|||
}); |
|||
}); |
|||
|
|||
describe('选项词表:企业端发布页与 constants 的关系', () => { |
|||
it('学历选项与 JobEducationRequirement 的成员集合一致(两处是同一个事实)', () => { |
|||
expect([...EDUCATION_OPTIONS].sort()).toEqual(['不限', '大专', '本科', '硕士', '博士'].sort()); |
|||
}); |
|||
|
|||
it('工作类型的编码与标签成对定义,标签列表由它派生', () => { |
|||
expect(WORK_TYPE_OPTIONS).toEqual(WORK_TYPE_LABELS.map((item) => item.label)); |
|||
// 编码域与标签域都要齐全,避免「有编码没标签」的悬空项
|
|||
expect(WORK_TYPE_LABELS.every((item) => item.value !== '' && item.label !== '')).toBe(true); |
|||
}); |
|||
|
|||
it('接线锁:发布页的学历与工作类型都取自 constants', () => { |
|||
const src = read('app/job-publish.tsx'); |
|||
expect(src).toContain('EDUCATION_OPTIONS'); |
|||
expect(src).toContain('WORK_TYPE_LABELS'); |
|||
// 旧版本地字面量;重建即回归
|
|||
expect(src).not.toContain("'不限', '大专'"); |
|||
expect(src).not.toMatch(/label: '全职'/); |
|||
}); |
|||
|
|||
/** |
|||
* 经验要求的两张表**不是**同一个事实,本批刻意不合并(合并会让发布页多出一个 |
|||
* 不该有的「不限」选项)。这例把差异锁住,防止后来者按名字想当然地合并。 |
|||
*/ |
|||
it('发布页的经验要求与筛选项表本就该不同:前者含「1年以内」不含「不限」,后者反之', () => { |
|||
const src = read('app/job-publish.tsx'); |
|||
expect(src).toContain("'1年以内'"); |
|||
// 发布页那张表里不该出现「不限」这个筛选项专用的兜底值
|
|||
const localExperience = src.slice(src.indexOf('const EXPERIENCE_OPTIONS')); |
|||
expect(localExperience.slice(0, 400)).not.toContain("'不限'"); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue