Browse Source
后端测评是「答卷」模型(一次抽 30 题 → 批量保存 → 提交出结果),
前端 UI 是单题推进,桥接全部收敛在 api 层,页面与 hook 无需改动:
- 新增 fetchAssessmentTypes / fetchAssessmentQuestions / fetchMyAssessments /
createAssessmentAttempt / fetchAssessmentAttempt / saveAssessmentAnswers /
submitAssessmentAttempt / fetchAssessmentResult 八个真实端点
- fetchHollandAssessment / submitHollandAnswer / fetchHollandResult 补真实分支
- 关键契约(实网实测,文档未标注):
创建用 body {typeCode}(不是 type);保存答案必须回传 version 乐观锁;
未答完提交报 9706;未完成取结果报 409/9703
- 结果映射:primaryCode→code、dimensionScores→六维、explorationDirections→行动建议;
后端不下发的类型解析/推荐岗位留空数组,不本地编造
- 答题过程中后端不下发实时六维(维度属隐藏字段),分布置空,不本地估算
新增 src/__tests__/assessment-attempt.test.ts(7 用例)
tsc 0 错误;jest 525 全过;eslint 0 error
master
4 changed files with 496 additions and 17 deletions
@ -0,0 +1,206 @@ |
|||||
|
/** |
||||
|
* 测评答卷(/assessments)真实分支测试 |
||||
|
* |
||||
|
* 后端是「一次抽 30 题 + 批量保存 + 提交出结果」的答卷模型,前端 UI 是单题推进, |
||||
|
* 桥接逻辑(ensureActiveAttempt / mapAttemptToAssessment)全在 api 层,此处验证: |
||||
|
* - 有活跃答卷就复用,不重复创建(后端同类型重复创建会 409/9708) |
||||
|
* - 保存答案必须回传乐观锁 version |
||||
|
* - 结果只在 COMPLETED 后可读,未完成取结果要给出明确错误 |
||||
|
*/ |
||||
|
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 () => undefined), |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
import { setMockMode, API_BASE_URL } from '../api/http'; |
||||
|
import { |
||||
|
fetchHollandAssessment, |
||||
|
submitHollandAnswer, |
||||
|
fetchHollandResult, |
||||
|
fetchAssessmentTypes, |
||||
|
fetchAssessmentQuestions, |
||||
|
} from '../api/api'; |
||||
|
|
||||
|
type Responder = (url: string) => { status: number; body: unknown }; |
||||
|
let responder: Responder | null = null; |
||||
|
let calls: Array<{ url: string; init: RequestInit }> = []; |
||||
|
|
||||
|
function okData(data: unknown) { |
||||
|
return { status: 200, body: { code: 200, message: 'ok', data, requestId: 'r', timestamp: 't' } }; |
||||
|
} |
||||
|
|
||||
|
function attempt(overrides: Partial<Record<string, unknown>> = {}) { |
||||
|
return { |
||||
|
attemptId: 1, |
||||
|
status: 'DRAFT', |
||||
|
totalQuestions: 30, |
||||
|
answeredCount: 0, |
||||
|
completionRate: 0, |
||||
|
questionSetVersion: 'riasec-self-v0.2', |
||||
|
version: 0, |
||||
|
questions: [ |
||||
|
{ id: 67, position: 1, stem: '我喜欢制定时间表或任务清单。', scaleType: 'LIKERT_5' }, |
||||
|
{ id: 10, position: 2, stem: '我对了解建筑结构的实际施工过程感兴趣。', scaleType: 'LIKERT_5' }, |
||||
|
{ id: 45, position: 3, stem: '我对帮助他人改善生活状态感兴趣。', scaleType: 'LIKERT_5' }, |
||||
|
], |
||||
|
...overrides, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
calls = []; |
||||
|
responder = null; |
||||
|
(global as unknown as { fetch: jest.Mock }).fetch = jest.fn(async (url: string, init: RequestInit) => { |
||||
|
calls.push({ url, init }); |
||||
|
const spec = responder ? responder(url) : okData(null); |
||||
|
return { |
||||
|
status: spec.status, |
||||
|
ok: spec.status >= 200 && spec.status < 300, |
||||
|
headers: { get: () => 'r' }, |
||||
|
text: async () => JSON.stringify(spec.body), |
||||
|
} as unknown as Response; |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('测评答卷真实态', () => { |
||||
|
beforeEach(() => setMockMode(false)); |
||||
|
|
||||
|
it('无活跃答卷时先创建再取详情,映射成单题推进状态', async () => { |
||||
|
responder = (url) => { |
||||
|
if (url.includes('/assessments/me')) return okData({ items: [], total: 0 }); |
||||
|
if (url.includes('/assessments/attempts')) return okData(attempt()); |
||||
|
return okData(null); |
||||
|
}; |
||||
|
const data = await fetchHollandAssessment(); |
||||
|
expect(calls[0].url).toBe(`${API_BASE_URL}/assessments/me?page=0&size=20`); |
||||
|
expect(calls[1].url).toBe(`${API_BASE_URL}/assessments/attempts`); |
||||
|
expect(JSON.parse(String(calls[1].init.body))).toEqual({ typeCode: 'HOLLAND_RIASEC' }); |
||||
|
expect(data.totalQuestions).toBe(30); |
||||
|
expect(data.currentQuestionIndex).toBe(0); |
||||
|
expect(data.question.id).toBe('67'); |
||||
|
expect(data.question.text).toBe('我喜欢制定时间表或任务清单。'); |
||||
|
expect(data.options.map((o) => o.value)).toEqual([1, 2, 3, 4, 5]); |
||||
|
}); |
||||
|
|
||||
|
it('已有活跃答卷时复用,不重复创建', async () => { |
||||
|
responder = (url) => { |
||||
|
if (url.includes('/assessments/me')) |
||||
|
return okData({ |
||||
|
items: [{ attemptId: 9, typeCode: 'HOLLAND_RIASEC', status: 'IN_PROGRESS' }], |
||||
|
total: 1, |
||||
|
}); |
||||
|
if (url.includes('/assessments/attempts/')) return okData(attempt({ attemptId: 9, answeredCount: 2 })); |
||||
|
return okData(null); |
||||
|
}; |
||||
|
const data = await fetchHollandAssessment(); |
||||
|
expect(calls.some((c) => c.url.endsWith('/assessments/attempts') && c.init.method === 'POST')).toBe(false); |
||||
|
expect(calls[1].url).toBe(`${API_BASE_URL}/assessments/attempts/9`); |
||||
|
// 已答 2 题 → 下一题下标 2(下标被题目数钳住,避免答完最后一题后越界)
|
||||
|
expect(data.currentQuestionIndex).toBe(2); |
||||
|
}); |
||||
|
|
||||
|
it('提交答案带乐观锁 version,且以服务端返回的进度为准', async () => { |
||||
|
let saved = false; |
||||
|
responder = (url) => { |
||||
|
if (url.includes('/assessments/me')) |
||||
|
return okData({ |
||||
|
items: [{ attemptId: 3, typeCode: 'HOLLAND_RIASEC', status: 'DRAFT' }], |
||||
|
total: 1, |
||||
|
}); |
||||
|
if (url.includes('/answers')) { |
||||
|
saved = true; |
||||
|
return okData(attempt({ attemptId: 3, status: 'IN_PROGRESS', answeredCount: 1, version: 1 })); |
||||
|
} |
||||
|
if (url.includes('/assessments/attempts/')) |
||||
|
return okData(attempt({ attemptId: 3, answeredCount: saved ? 1 : 0, version: saved ? 1 : 0 })); |
||||
|
return okData(null); |
||||
|
}; |
||||
|
const data = await submitHollandAnswer('67', 5); |
||||
|
const patch = calls.find((c) => c.init.method === 'PATCH'); |
||||
|
expect(patch?.url).toBe(`${API_BASE_URL}/assessments/attempts/3/answers`); |
||||
|
expect(JSON.parse(String(patch?.init.body))).toEqual({ |
||||
|
version: 0, |
||||
|
answers: [{ questionId: 67, value: 5 }], |
||||
|
}); |
||||
|
expect(data.currentQuestionIndex).toBe(1); |
||||
|
}); |
||||
|
|
||||
|
it('未完成时取结果给出明确错误,不静默返回空', async () => { |
||||
|
responder = (url) => { |
||||
|
if (url.includes('/assessments/me')) |
||||
|
return okData({ items: [{ attemptId: 1, typeCode: 'HOLLAND_RIASEC', status: 'DRAFT' }], total: 1 }); |
||||
|
return okData(null); |
||||
|
}; |
||||
|
await expect(fetchHollandResult()).rejects.toThrow('暂无测评结果'); |
||||
|
}); |
||||
|
|
||||
|
it('完成后映射六维得分与探索方向', async () => { |
||||
|
responder = (url) => { |
||||
|
if (url.includes('/assessments/me')) |
||||
|
return okData({ items: [{ attemptId: 1, typeCode: 'HOLLAND_RIASEC', status: 'COMPLETED' }], total: 1 }); |
||||
|
if (url.includes('/result')) |
||||
|
return okData({ |
||||
|
attemptId: 1, |
||||
|
typeCode: 'HOLLAND_RIASEC', |
||||
|
status: 'COMPLETED', |
||||
|
primaryCode: 'ACE', |
||||
|
primaryType: 'A', |
||||
|
dimensionScores: [ |
||||
|
{ dimension: 'R', score: 60 }, |
||||
|
{ dimension: 'A', score: 80 }, |
||||
|
], |
||||
|
typeRanking: ['A', 'C', 'E'], |
||||
|
explorationDirections: ['创意设计/内容创作'], |
||||
|
}); |
||||
|
return okData(null); |
||||
|
}; |
||||
|
const result = await fetchHollandResult(); |
||||
|
expect(result.code).toBe('ACE'); |
||||
|
expect(result.dimensions[0]).toEqual({ type: 'R', name: '现实型', score: 60, percent: 60 }); |
||||
|
expect(result.actions[0].title).toBe('创意设计/内容创作'); |
||||
|
// 后端不下发类型解析与推荐岗位:留空数组由页面按空态渲染
|
||||
|
expect(result.analyses).toEqual([]); |
||||
|
expect(result.jobs).toEqual([]); |
||||
|
}); |
||||
|
|
||||
|
it('类型列表与题目列表打后端真实路径', async () => { |
||||
|
responder = () => okData([{ typeCode: 'HOLLAND_RIASEC', displayName: 'RIASEC 职业兴趣测评' }]); |
||||
|
await fetchAssessmentTypes(); |
||||
|
expect(calls[0].url).toBe(`${API_BASE_URL}/assessments/types`); |
||||
|
await fetchAssessmentQuestions('HOLLAND_RIASEC'); |
||||
|
expect(calls[1].url).toBe(`${API_BASE_URL}/assessments/HOLLAND_RIASEC/questions`); |
||||
|
}); |
||||
|
}); |
||||
|
|
||||
|
describe('测评答卷 mock 态', () => { |
||||
|
beforeEach(() => setMockMode(true)); |
||||
|
|
||||
|
it('mock 模式不发任何请求', async () => { |
||||
|
const data = await fetchHollandAssessment(); |
||||
|
expect(calls.length).toBe(0); |
||||
|
expect(data.totalQuestions).toBeGreaterThan(0); |
||||
|
}); |
||||
|
}); |
||||
Loading…
Reference in new issue