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