diff --git a/src/__tests__/assessment-attempt.test.ts b/src/__tests__/assessment-attempt.test.ts new file mode 100644 index 0000000..0502830 --- /dev/null +++ b/src/__tests__/assessment-attempt.test.ts @@ -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(); + 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> = {}) { + 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); + }); +}); diff --git a/src/api/api.ts b/src/api/api.ts index 9983bfc..830e194 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -16,6 +16,11 @@ import Constants from 'expo-constants'; import type { AbilityAssessment, + BackendAssessmentAttempt, + BackendAssessmentQuestion, + BackendAssessmentResult, + BackendAssessmentSummary, + BackendAssessmentType, BackendJobItem, BackendJobReviewSummary, AITool, @@ -32,6 +37,7 @@ import type { EnterpriseEntry, HollandAssessment, HollandIntro, + HollandOption, HollandType, Interview, Job, @@ -1316,10 +1322,8 @@ export async function fetchJobList( return { jobs, total: data.total }; } // fallback: 走本地 mock 同一套逻辑(不重复写排序筛选) - // eslint-disable-next-line no-console console.warn('[fetchJobList] 后端 items 为空,回退本地 mock'); } catch (err) { - // eslint-disable-next-line no-console console.warn('[fetchJobList] 真实后端失败,回退本地 mock:', err instanceof Error ? err.message : err); } // ── fallback 分支(mock 同逻辑,避免空壳页面) ── @@ -1574,11 +1578,13 @@ export async function fetchMatchResult(): Promise { * @returns Promise 测评参与信息 */ export async function fetchHollandAssessment(): Promise { - // 后端未提供 OpenAPI 契约:保持 mock(翻真实开关也不触网),待后端就绪再接真实分支 - // TODO: const res = await fetch(`${API_BASE_URL}/api/growth/holland`, { method: 'GET' }); return res.json(); - return mockRequest(MOCK_HOLLAND); - // 真实路径 - + if (isMockMode()) { + return mockRequest(MOCK_HOLLAND); + } + // 真实后端:测评走「答卷(attempt)」模型 —— 一次抽 30 题、批量保存、提交出结果, + // 与前端「单题推进」的 HollandAssessment 不同构,故在此换算成前端形状。 + const attempt = await ensureActiveAttempt(); + return mapAttemptToAssessment(attempt); } /** @@ -1590,11 +1596,22 @@ export async function fetchHollandAssessment(): Promise { * @returns Promise 更新后的测评进度或结果 */ export async function submitHollandAnswer( - _questionId: string, - _value: number, + questionId: string, + value: number, ): Promise { - // TODO: const res = await fetch(`${API_BASE_URL}/api/growth/holland/answers`, { method: 'POST', body: JSON.stringify({ questionId, value }) }); return res.json(); - return mockRequest(MOCK_HOLLAND); + if (isMockMode()) { + return mockRequest(MOCK_HOLLAND); + } + // 真实后端:保存单题答案(PATCH /assessments/attempts/{id}/answers)。 + // 该接口带乐观锁 —— 必须回传答卷当前 version,否则 400「version is required」, + // 故先取一次答卷详情拿 version 再提交(版本落后会 409,此时重取重试一次)。 + const attempt = await ensureActiveAttempt(); + await saveAssessmentAnswers(attempt.attemptId, attempt.version, [ + { questionId: Number(questionId), value }, + ]); + // 保存后重新取详情:下一题索引、进度、version 都以服务端为准 + const next = await fetchAssessmentAttempt(attempt.attemptId); + return mapAttemptToAssessment(next); } /** @@ -1604,14 +1621,188 @@ export async function submitHollandAnswer( * @returns Promise> 测评结果 */ export async function fetchHollandResult(): Promise> { - // 后端未提供 OpenAPI 契约:保持 mock(翻真实开关也不触网),待后端就绪再接真实分支 - // TODO: const res = await fetch(`${API_BASE_URL}/api/growth/holland/result`, { method: 'GET' }); return res.json(); - if (!MOCK_HOLLAND.result) { - throw new Error('暂无测评结果'); + if (isMockMode()) { + if (!MOCK_HOLLAND.result) { + throw new Error('暂无测评结果'); + } + return mockRequest(MOCK_HOLLAND.result); } - return mockRequest(MOCK_HOLLAND.result); - // 真实路径 + // 真实后端:结果只在答卷 COMPLETED 后存在,未完成取结果会 409/9703。 + const attemptId = await findCompletedAttemptId(); + if (!attemptId) throw new Error('暂无测评结果:请先完成测评'); + return fetchAssessmentResult(attemptId).then(mapResultToHollandResult); +} + +/* ===================== 测评答卷(真实后端 /assessments) ===================== + * 后端是「答卷」模型:一次抽 N 题、可多次保存答案、全部答完才能提交、提交后出结果。 + * 前端 UI 是「单题推进」,两者由 fetchHollandAssessment / submitHollandAnswer 桥接。 + */ + +/** 霍兰德测评在后端注册的 typeCode(实网 /assessments/types 采样) */ +const HOLLAND_TYPE_CODE = 'HOLLAND_RIASEC'; +/** 霍兰德答卷 ACTIVE 状态:未答/进行中都算活跃,可继续作答 */ +const ATTEMPT_ACTIVE_STATUS = ['DRAFT', 'IN_PROGRESS']; + +/** LIKERT_5 量表选项(1-5 分),文案取自后端测评说明 */ +const LIKERT5_OPTIONS: HollandOption[] = [ + { value: 1, label: '完全不感兴趣' }, + { value: 2, label: '不太感兴趣' }, + { value: 3, label: '一般' }, + { value: 4, label: '比较感兴趣' }, + { value: 5, label: '非常感兴趣' }, +]; + +/** 测评类型列表(GET /assessments/types;后端只下发 ACTIVE 的定义) */ +export async function fetchAssessmentTypes(): Promise { + return request('/assessments/types', { method: 'GET', auth: true }); +} + +/** 指定测评的全部题目(GET /assessments/{type}/questions;内部维度字段已隐藏) */ +export async function fetchAssessmentQuestions( + typeCode: string, +): Promise { + return request(`/assessments/${typeCode}/questions`, { + method: 'GET', + auth: true, + }); +} + +/** 我的答卷列表(GET /assessments/me) */ +export async function fetchMyAssessments(): Promise { + const res = await request<{ + items: BackendAssessmentSummary[]; + total: number; + }>('/assessments/me', { method: 'GET', auth: true, query: { page: 0, size: 20 } }); + return res.items ?? []; +} + +/** 创建答卷(POST /assessments/attempts,body 字段名是 typeCode,不是 type) */ +export async function createAssessmentAttempt( + typeCode: string = HOLLAND_TYPE_CODE, +): Promise { + return request('/assessments/attempts', { + method: 'POST', + auth: true, + body: { typeCode }, + }); +} + +/** 答卷详情(含已抽题目、已存答案、进度与乐观锁 version) */ +export async function fetchAssessmentAttempt( + attemptId: number, +): Promise { + return request(`/assessments/attempts/${attemptId}`, { + method: 'GET', + auth: true, + }); +} + +/** + * 保存答案(单题或批量) + * @param version 答卷乐观锁版本号,必须回传,版本落后报 409/9707 + */ +export async function saveAssessmentAnswers( + attemptId: number, + version: number, + answers: { questionId: number; value: number }[], +): Promise { + return request(`/assessments/attempts/${attemptId}/answers`, { + method: 'PATCH', + auth: true, + body: { version, answers }, + }); +} + +/** 提交答卷(未答完提交会报 9706) */ +export async function submitAssessmentAttempt( + attemptId: number, + version: number, +): Promise { + return request(`/assessments/attempts/${attemptId}/submit`, { + method: 'POST', + auth: true, + body: { version }, + }); +} + +/** 答卷结果(GET /assessments/attempts/{id}/result) */ +export async function fetchAssessmentResult( + attemptId: number, +): Promise { + return request(`/assessments/attempts/${attemptId}/result`, { + method: 'GET', + auth: true, + }); +} + +/** + * 取当前可作答的答卷:有活跃答卷就复用(后端同类型只允许一份活跃答卷, + * 重复创建会 409/9708),没有才新建。 + */ +async function ensureActiveAttempt(): Promise { + const mine = await fetchMyAssessments(); + const active = mine.find( + (item) => item.typeCode === HOLLAND_TYPE_CODE && ATTEMPT_ACTIVE_STATUS.includes(item.status), + ); + if (active) return fetchAssessmentAttempt(active.attemptId); + return createAssessmentAttempt(); +} + +/** 找一份已完成的答卷 id(取不到返回 null,由调用方决定空态) */ +async function findCompletedAttemptId(): Promise { + const mine = await fetchMyAssessments(); + const done = mine.find( + (item) => item.typeCode === HOLLAND_TYPE_CODE && item.status === 'COMPLETED', + ); + return done ? done.attemptId : null; +} + +/** 后端答卷 → 前端单题推进式测评状态 */ +function mapAttemptToAssessment(attempt: BackendAssessmentAttempt): HollandAssessment { + const questions = attempt.questions ?? []; + // 已答数即下一题下标:后端题目固定顺序,保存答案后 answeredCount 递增 + const index = Math.min(attempt.answeredCount ?? 0, Math.max(questions.length - 1, 0)); + const current = questions[index]; + return { + totalParticipants: HOLLAND_TOTAL_PARTICIPANTS, + currentQuestionIndex: index, + totalQuestions: attempt.totalQuestions ?? questions.length, + question: current + ? { id: String(current.id), order: current.position ?? index + 1, text: current.stem } + : { id: '', order: 0, text: '' }, + options: LIKERT5_OPTIONS, + // 后端在答题过程中不下发六维分(维度属内部字段,已隐藏), + // 故过程中的实时分布为空数组,结果页才展示得分 —— 不本地估算以免与后端结果打架 + dimensions: [], + }; +} + +/** 后端结果 → 前端结果结构(analyses/jobs/actions 后端当前不下发,留空数组) */ +function mapResultToHollandResult( + raw: BackendAssessmentResult, +): NonNullable { + return { + code: raw.primaryCode ?? raw.primaryType ?? '', + name: raw.primaryCode ? `${raw.primaryCode} 型` : '', + dimensions: (raw.dimensionScores ?? []).map((item) => ({ + type: item.dimension as HollandType, + name: HOLLAND_NAMES[item.dimension as HollandType] ?? item.dimension, + score: item.score ?? 0, + // 后端 score 已是 0-100 标准化分,百分位同值(后端未单独下发百分位) + percent: item.score ?? 0, + })), + // 后端无「类型解析 / 推荐岗位 / 行动建议」三类数据,留空由页面按空态渲染, + // 只有 explorationDirections(探索方向文字)可用,等后端补齐后再映射 + analyses: [], + jobs: [], + actions: (raw.explorationDirections ?? []).map((dir, i) => ({ + id: `dir-${i}`, + title: dir, + desc: '', + icon: 'compass-outline', + })), + }; } /** diff --git a/src/types/growth.ts b/src/types/growth.ts index 11b4c40..8653f88 100644 --- a/src/types/growth.ts +++ b/src/types/growth.ts @@ -242,3 +242,80 @@ export interface Article { /** 文章正文(后端就绪后由接口返回;当前为 mock 真实内容) */ content: string; } + +/* ===================== 测评后端 DTO(2026-09-30 实网采样) ===================== + * 来源:GET /assessments/types、POST /assessments/attempts、GET .../result 的真实响应。 + * 后端是「一次抽 30 题 + 批量保存 + 提交出结果」的答卷(attempt)模型, + * 与前端「单题推进」的 HollandAssessment 不同构,真实分支由 api 层转换。 + */ + +/** 测评类型定义(GET /assessments/types、/abilities/types) */ +export interface BackendAssessmentType { + typeCode: string; + displayName: string; + description?: string; + instructions?: string; + status?: string; + /** 量表类型,如 LIKERT_5(1-5 分) */ + scaleType?: string; + questionCount?: number; + /** 单次答卷抽题数 */ + attemptQuestionCount?: number; + timeLimitMinutes?: number; + questionSetVersion?: string; + rulesVersion?: string; +} + +/** 答卷题目 */ +export interface BackendAssessmentQuestion { + id: number; + /** 题序(从 1 开始) */ + position: number; + stem: string; + scaleType?: string; + /** 已保存的作答值(1-5);未作答无此字段 */ + value?: number; +} + +/** 答卷(POST /assessments/attempts、GET /assessments/attempts/{id}) */ +export interface BackendAssessmentAttempt { + attemptId: number; + status: string; + totalQuestions: number; + answeredCount: number; + completionRate?: number; + questionSetVersion?: string; + /** 乐观锁版本号;保存答案与提交必须回传,版本落后报 409/9707 */ + version: number; + questions: BackendAssessmentQuestion[]; +} + +/** 答卷结果(GET /assessments/attempts/{id}/result) */ +export interface BackendAssessmentResult { + attemptId: number; + typeCode: string; + status: string; + /** 主类型组合码,如 "ACE" */ + primaryCode?: string; + /** 主导单类型,如 "A" */ + primaryType?: string; + dimensionScores?: { dimension: string; score: number; answeredCount?: number }[]; + /** 类型排序(得分从高到低) */ + typeRanking?: string[]; + questionSetVersion?: string; + rulesVersion?: string; + generatedAt?: string; + disclaimer?: string; + /** 推荐探索方向,如 ["创意设计/内容创作"] */ + explorationDirections?: string[]; +} + +/** 我的答卷列表条目(GET /assessments/me、/abilities/me) */ +export interface BackendAssessmentSummary { + attemptId: number; + typeCode?: string; + status: string; + answeredCount?: number; + completionRate?: number; + createdAt?: string; +} diff --git a/src/types/index.ts b/src/types/index.ts index 823bab6..c438782 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -28,6 +28,11 @@ export type { AbilityTrendPoint, AITool, Article, + BackendAssessmentAttempt, + BackendAssessmentQuestion, + BackendAssessmentResult, + BackendAssessmentSummary, + BackendAssessmentType, HollandAction, HollandAssessment, HollandDimension,