Compare commits
7 Commits
5d2b8996e1
...
19bbaca18a
| Author | SHA1 | Date |
|---|---|---|
|
|
19bbaca18a | 4 weeks ago |
|
|
9911277e10 | 4 weeks ago |
|
|
6349740c0a | 4 weeks ago |
|
|
6d8487a11f | 4 weeks ago |
|
|
61b0d150d6 | 4 weeks ago |
|
|
b22ed57151 | 4 weeks ago |
|
|
20a741d54d | 4 weeks ago |
16 changed files with 949 additions and 198 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