Browse Source
读:GET /profile/me/review-weights + GET /review-weight-templates 并行拉取
写:PUT /profile/me/review-weights,实测为两种互斥模式(实网 2026-09-30 验证)
· 套用模板:只传 { templateCode }
· 自定义: 只传 { weights }(八个维度全给、总和 100)
· 同时传两者 / 未知 templateCode / 缺维度 / 总和≠100 → 一律 400 code=1001
接口文档的 body 示例是错的,见《联调问题清单-致后端》§1.12
- 维度集合改为以后端下发为准:原前端自造的「九大中文维度」与后端八个
英文 token 不一致(后端无「地理位置/工作强度」,多出 JD_CLARITY、
SALARY_TRANSPARENCY 等),照旧实现会默认显示错误的维度集合
- 后端不下发维度中文名 → constants 新增 WEIGHT_DIMENSION_LABEL /
WEIGHT_DIMENSION_ORDER / WEIGHT_TOTAL 作为唯一真相源;未登记 token
追加末尾并原样显示,不静默吞掉后端新增维度
- 新增 matchWeightTemplate():保存时据此在两种互斥模式间二选一
- saveWeightSettings 增加可选 templateCode 参数;保存后用服务端回传值
覆盖本地编辑态(不调 refresh,避免整页闪回 loading)
- 新增 weight-settings.test.ts(15 例):锁住映射顺序/中文名/未知 token
兜底、模板 id 映射、模式判定(含维度数不同不得误判)、双模式 body 互斥
- mock 形态与真实同构(同八维 + 同四模板),避免两套口径分叉
master
9 changed files with 554 additions and 83 deletions
@ -0,0 +1,250 @@ |
|||||
|
/** |
||||
|
* 评分权重设置 ↔ 后端契约测试 |
||||
|
* |
||||
|
* 锁住三件容易回归的事: |
||||
|
* 1. 后端 configuredWeights(英文 token 对象)→ 前端维度数组的映射:顺序、中文名、未登记 token 兜底; |
||||
|
* 2. 保存时「套用模板」与「自定义权重」两种**互斥**写入模式的 body 选择 |
||||
|
* (实网实测:同时提交 templateCode 与 weights → 400 code=1001); |
||||
|
* 3. 模板权重的键是 token,不是中文展示名(改回中文名会让套用模板静默失效)。 |
||||
|
*/ |
||||
|
jest.mock('expo-constants', () => ({ expoConfig: { extra: {} } })); |
||||
|
|
||||
|
jest.mock('../lib/token-store', () => { |
||||
|
let mem: { accessToken: string; refreshToken: string; expiresAt?: number } | null = null; |
||||
|
return { |
||||
|
getAccessToken: jest.fn(() => mem?.accessToken ?? null), |
||||
|
getTokens: jest.fn(() => mem), |
||||
|
setTokens: jest.fn((a: string, r: string, e?: number) => { |
||||
|
mem = { accessToken: a, refreshToken: r, expiresAt: e }; |
||||
|
}), |
||||
|
clearTokens: jest.fn(() => { |
||||
|
mem = null; |
||||
|
}), |
||||
|
hydrateTokens: jest.fn(async () => undefined), |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock('../lib/storage', () => { |
||||
|
const m = new Map<string, string>(); |
||||
|
return { |
||||
|
storageGet: jest.fn(async (k: string) => m.get(k) ?? null), |
||||
|
storageSet: jest.fn(async (k: string, v: string) => { |
||||
|
m.set(k, v); |
||||
|
}), |
||||
|
storageRemove: jest.fn(async (k: string) => { |
||||
|
m.delete(k); |
||||
|
}), |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
import { setMockMode } from '../api/http'; |
||||
|
import { fetchWeightSettings, matchWeightTemplate, saveWeightSettings } from '../api/api'; |
||||
|
import type { WeightDimension, WeightTemplate } from '../types'; |
||||
|
|
||||
|
function jsonResponse(data: unknown): unknown { |
||||
|
const envelope = { code: 200, message: 'ok', data, requestId: 'r', timestamp: '' }; |
||||
|
return { |
||||
|
ok: true, |
||||
|
status: 200, |
||||
|
headers: { get: () => 'x' }, |
||||
|
text: async () => JSON.stringify(envelope), |
||||
|
json: async () => envelope, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/** 后端实际下发的八个维度(顺序刻意打乱,验证前端不依赖对象键序) */ |
||||
|
const BACKEND_WEIGHTS = { |
||||
|
JD_CLARITY: 12.5, |
||||
|
BENEFITS: 12.5, |
||||
|
SALARY_TRANSPARENCY: 12.5, |
||||
|
AUTHENTICITY: 12.5, |
||||
|
GROWTH: 12.5, |
||||
|
PROMOTION: 12.5, |
||||
|
TEAM_ATMOSPHERE: 12.5, |
||||
|
WORK_LIFE_BALANCE: 12.5, |
||||
|
}; |
||||
|
|
||||
|
const BACKEND_TEMPLATES = [ |
||||
|
{ |
||||
|
code: 'BALANCED', |
||||
|
name: '均衡权重', |
||||
|
description: '八个维度平均分配', |
||||
|
weights: BACKEND_WEIGHTS, |
||||
|
}, |
||||
|
{ |
||||
|
code: 'SALARY_FIRST', |
||||
|
name: '薪资优先', |
||||
|
description: '重点关注薪资透明度与基础保障', |
||||
|
weights: { |
||||
|
BENEFITS: 20, WORK_LIFE_BALANCE: 5, GROWTH: 10, TEAM_ATMOSPHERE: 5, |
||||
|
PROMOTION: 5, AUTHENTICITY: 15, JD_CLARITY: 10, SALARY_TRANSPARENCY: 30, |
||||
|
}, |
||||
|
}, |
||||
|
]; |
||||
|
|
||||
|
function mockFetchByPath(map: Record<string, unknown>): jest.Mock { |
||||
|
const fn = jest.fn(async (url: string) => { |
||||
|
const hit = Object.keys(map).find((p) => String(url).includes(p)); |
||||
|
if (!hit) throw new Error(`未预期的请求: ${url}`); |
||||
|
return jsonResponse(map[hit]); |
||||
|
}); |
||||
|
(global as any).fetch = fn; |
||||
|
return fn; |
||||
|
} |
||||
|
|
||||
|
afterEach(() => { |
||||
|
setMockMode(true); |
||||
|
}); |
||||
|
|
||||
|
describe('fetchWeightSettings:后端 token 对象 → 维度数组', () => { |
||||
|
beforeEach(() => { |
||||
|
setMockMode(false); |
||||
|
mockFetchByPath({ |
||||
|
'/profile/me/review-weights': { |
||||
|
source: 'SYSTEM_DEFAULT', |
||||
|
configuredWeights: BACKEND_WEIGHTS, |
||||
|
}, |
||||
|
'/review-weight-templates': BACKEND_TEMPLATES, |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
test('按 constants 的展示顺序输出,且映射出中文名', async () => { |
||||
|
const settings = await fetchWeightSettings(); |
||||
|
expect(settings.dimensions.map((d) => d.id)).toEqual([ |
||||
|
'SALARY_TRANSPARENCY', |
||||
|
'GROWTH', |
||||
|
'BENEFITS', |
||||
|
'WORK_LIFE_BALANCE', |
||||
|
'PROMOTION', |
||||
|
'TEAM_ATMOSPHERE', |
||||
|
'AUTHENTICITY', |
||||
|
'JD_CLARITY', |
||||
|
]); |
||||
|
expect(settings.dimensions[0].name).toBe('薪资透明度'); |
||||
|
expect(settings.dimensions.map((d) => d.weight)).toEqual(Array(8).fill(12.5)); |
||||
|
expect(settings.source).toBe('SYSTEM_DEFAULT'); |
||||
|
}); |
||||
|
|
||||
|
test('模板 code → id,权重键保持 token', async () => { |
||||
|
const settings = await fetchWeightSettings(); |
||||
|
expect(settings.templates.map((t) => t.id)).toEqual(['BALANCED', 'SALARY_FIRST']); |
||||
|
expect(settings.templates[1].weights.SALARY_TRANSPARENCY).toBe(30); |
||||
|
expect(settings.templates[1].weights['薪资透明度']).toBeUndefined(); |
||||
|
}); |
||||
|
|
||||
|
test('未登记的新维度追加在末尾并原样显示 token(不吞数据)', async () => { |
||||
|
mockFetchByPath({ |
||||
|
'/profile/me/review-weights': { |
||||
|
source: 'CUSTOM', |
||||
|
configuredWeights: { ...BACKEND_WEIGHTS, NEW_DIMENSION_X: 3 }, |
||||
|
}, |
||||
|
'/review-weight-templates': [], |
||||
|
}); |
||||
|
const settings = await fetchWeightSettings(); |
||||
|
const last = settings.dimensions[settings.dimensions.length - 1]; |
||||
|
expect(last.id).toBe('NEW_DIMENSION_X'); |
||||
|
expect(last.name).toBe('NEW_DIMENSION_X'); |
||||
|
expect(last.weight).toBe(3); |
||||
|
}); |
||||
|
|
||||
|
test('source=TEMPLATE 时回传 templateCode 供展示', async () => { |
||||
|
mockFetchByPath({ |
||||
|
'/profile/me/review-weights': { |
||||
|
source: 'TEMPLATE', |
||||
|
templateCode: 'SALARY_FIRST', |
||||
|
configuredWeights: BACKEND_TEMPLATES[1].weights, |
||||
|
}, |
||||
|
'/review-weight-templates': BACKEND_TEMPLATES, |
||||
|
}); |
||||
|
const settings = await fetchWeightSettings(); |
||||
|
expect(settings.source).toBe('TEMPLATE'); |
||||
|
expect(settings.templateCode).toBe('SALARY_FIRST'); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('matchWeightTemplate:决定写入模式', () => { |
||||
|
const templates: WeightTemplate[] = [ |
||||
|
{ id: 'BALANCED', name: '均衡权重', description: '', weights: BACKEND_WEIGHTS }, |
||||
|
{ |
||||
|
id: 'SALARY_FIRST', |
||||
|
name: '薪资优先', |
||||
|
description: '', |
||||
|
weights: BACKEND_TEMPLATES[1].weights, |
||||
|
}, |
||||
|
]; |
||||
|
const toDims = (weights: Record<string, number>): WeightDimension[] => |
||||
|
Object.entries(weights).map(([id, weight]) => ({ id, name: id, weight })); |
||||
|
|
||||
|
test('与模板完全一致 → 返回该模板 code', () => { |
||||
|
expect(matchWeightTemplate(toDims(BACKEND_WEIGHTS), templates)).toBe('BALANCED'); |
||||
|
}); |
||||
|
|
||||
|
test('有一项不同 → null(走自定义模式)', () => { |
||||
|
const tweaked = { ...BACKEND_WEIGHTS, GROWTH: 13.5, BENEFITS: 11.5 }; |
||||
|
expect(matchWeightTemplate(toDims(tweaked), templates)).toBeNull(); |
||||
|
}); |
||||
|
|
||||
|
test('维度数量不同 → null(避免把残缺配置误判成模板)', () => { |
||||
|
const partial = { ...BACKEND_WEIGHTS } as Record<string, number>; |
||||
|
delete partial.GROWTH; |
||||
|
expect(matchWeightTemplate(toDims(partial), templates)).toBeNull(); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('saveWeightSettings:两种互斥写入模式', () => { |
||||
|
const dims: WeightDimension[] = Object.entries(BACKEND_WEIGHTS).map(([id, weight]) => ({ |
||||
|
id, |
||||
|
name: id, |
||||
|
weight, |
||||
|
})); |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
setMockMode(false); |
||||
|
}); |
||||
|
|
||||
|
test('传入 templateCode → body 只有 templateCode,不带 weights', async () => { |
||||
|
const fn = mockFetchByPath({ |
||||
|
'/profile/me/review-weights': { |
||||
|
source: 'TEMPLATE', |
||||
|
templateCode: 'BALANCED', |
||||
|
configuredWeights: BACKEND_WEIGHTS, |
||||
|
}, |
||||
|
}); |
||||
|
const saved = await saveWeightSettings(dims, 'BALANCED'); |
||||
|
const [url, opts] = fn.mock.calls[0]; |
||||
|
expect(String(url)).toContain('/profile/me/review-weights'); |
||||
|
expect(opts.method).toBe('PUT'); |
||||
|
// 同时传 templateCode 与 weights 会被后端拒(400 code=1001)
|
||||
|
expect(JSON.parse(opts.body)).toEqual({ templateCode: 'BALANCED' }); |
||||
|
expect(saved).toHaveLength(8); |
||||
|
}); |
||||
|
|
||||
|
test('不传 templateCode → body 只有 weights(八个 token 全给)', async () => { |
||||
|
const fn = mockFetchByPath({ |
||||
|
'/profile/me/review-weights': { source: 'CUSTOM', configuredWeights: BACKEND_WEIGHTS }, |
||||
|
}); |
||||
|
await saveWeightSettings(dims); |
||||
|
const opts = fn.mock.calls[0][1]; |
||||
|
const body = JSON.parse(opts.body); |
||||
|
expect(Object.keys(body)).toEqual(['weights']); |
||||
|
expect(Object.keys(body.weights)).toHaveLength(8); |
||||
|
expect(body.weights.SALARY_TRANSPARENCY).toBe(12.5); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('mock 态不触网', () => { |
||||
|
beforeEach(() => setMockMode(true)); |
||||
|
|
||||
|
test('返回与真实同构的八维 + 四个模板,且不与常量同引用', async () => { |
||||
|
const fn = jest.fn(); |
||||
|
(global as any).fetch = fn; |
||||
|
const settings = await fetchWeightSettings(); |
||||
|
expect(fn).not.toHaveBeenCalled(); |
||||
|
expect(settings.dimensions).toHaveLength(8); |
||||
|
expect(settings.templates).toHaveLength(4); |
||||
|
// 改动返回值不应污染后续调用
|
||||
|
settings.dimensions[0].weight = 999; |
||||
|
const again = await fetchWeightSettings(); |
||||
|
expect(again.dimensions[0].weight).toBe(12.5); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue