Browse Source

feat(api): 接入四维能力测评(/abilities)并桥接结果页

- 新增 fetchAbilityTypes / createAbilityAttempt / fetchAbilityAttempt /
  saveAbilityAnswers / submitAbilityAttempt / fetchAbilityResult / fetchMyAbilities
- fetchAbilityAssessment 补真实分支:取最近一份 COMPLETED 结果并映射
- 实网实测契约(文档未标注):
  题目 id 是 attemptQuestionId、答案字段是 selectedOptionId(写 optionId 报 9805);
  答卷详情不带 version,保存与提交均无需乐观锁;未答完提交报 9806
- 四维 code 为 LOGIC/COMMUNICATION/EXECUTION/LEARNING,与 mock 阶段的
  「专业能力/项目经验/技能广度」不是同一套口径,真实态以后端为准(产品口径待定夺)
- 结果有效期 validUntil/validityStatus 由后端判定,前端不自行作废

新增 src/__tests__/ability-attempt.test.ts(5 用例)
tsc 0 错误;jest 530 全过;eslint 0 error
master
doublex001 4 weeks ago
parent
commit
5f47b8e38b
  1. 170
      src/__tests__/ability-attempt.test.ts
  2. 136
      src/api/api.ts
  3. 60
      src/types/growth.ts
  4. 4
      src/types/index.ts

170
src/__tests__/ability-attempt.test.ts

@ -0,0 +1,170 @@
/**
* 四维能力测评(/abilities)真实分支测试
*
* 与霍兰德答卷的两个关键差异(实网踩坑点):
* - 题目 id 是 attemptQuestionId、答案字段是 selectedOptionId,写错报 9805
* - 答卷详情不带 version,保存/提交都不需要乐观锁
*/
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) => {
mem = { accessToken: a, refreshToken: r };
}),
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 {
fetchAbilityAssessment,
fetchAbilityTypes,
createAbilityAttempt,
saveAbilityAnswers,
submitAbilityAttempt,
} 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' } };
}
const ABILITY_RESULT = {
attemptId: 1,
typeCode: 'WORKPLACE_ABILITY',
status: 'COMPLETED',
validityStatus: 'VALID',
overallScore: 44.79,
dimensionScores: [
{ code: 'LOGIC', score: 37.5 },
{ code: 'COMMUNICATION', score: 58.33 },
],
improvementAreas: [
{
dimension: 'LEARNING',
currentScore: 12.5,
summary: '把反馈变成具体行动并通过再次验证确认改进。',
actions: ['把模糊反馈转化为具体例子'],
},
],
};
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('/abilities/me'))
return okData({
items: [{ attemptId: 1, typeCode: 'WORKPLACE_ABILITY', status: 'COMPLETED' }],
total: 1,
});
if (url.includes('/result')) return okData(ABILITY_RESULT);
return okData(null);
};
const data = await fetchAbilityAssessment();
expect(data.overallScore).toBe(44.79);
expect(data.level).toBe('medium'); // <75
expect(data.dimensions).toEqual([
{ label: '逻辑分析', value: 37.5 },
{ label: '沟通协作', value: 58.33 },
]);
expect(data.improvements[0].title).toBe('学习成长');
// 后端不下发百分位与趋势:留空,不拿 mock 顶替
expect(data.percentile).toBeUndefined();
expect(data.trends).toEqual([]);
});
it('未完成时抛错,不静默返回 0 分', async () => {
responder = () => okData({ items: [], total: 0 });
await expect(fetchAbilityAssessment()).rejects.toThrow('暂无能力测评结果');
});
it('创建/答题/提交打真实路径,答案字段为 selectedOptionId', async () => {
responder = (url) => {
if (url.includes('/abilities/attempts') && !url.includes('/answers'))
return okData({
attemptId: 7,
status: 'DRAFT',
totalQuestions: 24,
answeredCount: 0,
questions: [
{
attemptQuestionId: 1,
position: 1,
itemType: 'SITUATIONAL_JUDGMENT',
stem: '估算工期',
options: [{ optionId: 'A', text: '给大致天数' }],
},
],
});
return okData(null);
};
await createAbilityAttempt();
expect(calls[0].url).toBe(`${API_BASE_URL}/abilities/attempts`);
expect(JSON.parse(String(calls[0].init.body))).toEqual({ typeCode: 'WORKPLACE_ABILITY' });
await saveAbilityAnswers(7, [{ attemptQuestionId: 1, selectedOptionId: 'A' }]);
const patch = calls.find((c) => c.init.method === 'PATCH');
expect(patch?.url).toBe(`${API_BASE_URL}/abilities/attempts/7/answers`);
expect(JSON.parse(String(patch?.init.body))).toEqual({
answers: [{ attemptQuestionId: 1, selectedOptionId: 'A' }],
});
await submitAbilityAttempt(7);
const post = calls.find((c) => c.url.endsWith('/submit'));
expect(post?.url).toBe(`${API_BASE_URL}/abilities/attempts/7/submit`);
});
it('类型列表打 /abilities/types', async () => {
responder = () => okData([{ typeCode: 'WORKPLACE_ABILITY', displayName: '四维能力测评' }]);
await fetchAbilityTypes();
expect(calls[0].url).toBe(`${API_BASE_URL}/abilities/types`);
});
});
describe('能力测评 mock 态', () => {
beforeEach(() => setMockMode(true));
it('mock 模式不发请求且保留原四维文案', async () => {
const data = await fetchAbilityAssessment();
expect(calls.length).toBe(0);
expect(data.dimensions[0].label).toBe('专业能力');
});
});

136
src/api/api.ts

@ -16,6 +16,8 @@
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import type { import type {
AbilityAssessment, AbilityAssessment,
BackendAbilityAttempt,
BackendAbilityResult,
BackendAssessmentAttempt, BackendAssessmentAttempt,
BackendAssessmentQuestion, BackendAssessmentQuestion,
BackendAssessmentResult, BackendAssessmentResult,
@ -1812,14 +1814,136 @@ function mapResultToHollandResult(
* @returns Promise<AbilityAssessment> 能力测评结果 * @returns Promise<AbilityAssessment> 能力测评结果
*/ */
export async function fetchAbilityAssessment(): Promise<AbilityAssessment> { export async function fetchAbilityAssessment(): Promise<AbilityAssessment> {
// 后端未提供 OpenAPI 契约:保持 mock(翻真实开关也不触网),待后端就绪再接真实分支 if (isMockMode()) {
// TODO: const res = await fetch(`${API_BASE_URL}/api/growth/ability`, { method: 'GET' }); return res.json(); return mockRequest({
return mockRequest({ ...MOCK_ABILITY,
...MOCK_ABILITY, level: abilityScoreToLevel(MOCK_ABILITY.overallScore),
level: abilityScoreToLevel(MOCK_ABILITY.overallScore), });
}
// 真实后端:取最近一份已完成的能力测评结果(未完成时抛错,页面走空态)。
// 结果带有效期(validUntil / validityStatus),过期后需重测 —— 由后端判定,前端不自行作废。
const attemptId = await findCompletedAbilityAttemptId();
if (!attemptId) throw new Error('暂无能力测评结果:请先完成测评');
const raw = await fetchAbilityResult(attemptId);
return mapAbilityResult(raw);
}
/* ===================== 能力测评(真实后端 /abilities) ===================== */
/** 四维能力测评在后端注册的 typeCode(实网 /abilities/types 采样) */
const ABILITY_TYPE_CODE = 'WORKPLACE_ABILITY';
/**
* 后端四维 code → 中文名。
*
* 注意:后端四维是「职场通用能力」(LOGIC/COMMUNICATION/EXECUTION/LEARNING),
* 与 mock 阶段的「专业能力/项目经验/技能广度」不是同一套口径。真实态以后端口径为准,
* 产品若要求沿用旧文案,需后端改维度定义而不是前端改映射。
*/
const ABILITY_DIMENSION_LABEL: Record<string, string> = {
LOGIC: '逻辑分析',
COMMUNICATION: '沟通协作',
EXECUTION: '执行落地',
LEARNING: '学习成长',
};
/** 能力测评类型列表(GET /abilities/types) */
export async function fetchAbilityTypes(): Promise<BackendAssessmentType[]> {
return request<BackendAssessmentType[]>('/abilities/types', { method: 'GET', auth: true });
}
/** 创建能力测评答卷(POST /abilities/attempts) */
export async function createAbilityAttempt(
typeCode: string = ABILITY_TYPE_CODE,
): Promise<BackendAbilityAttempt> {
return request<BackendAbilityAttempt>('/abilities/attempts', {
method: 'POST',
auth: true,
body: { typeCode },
}); });
// 真实路径 }
/** 答卷详情(含情境判断题与选项) */
export async function fetchAbilityAttempt(attemptId: number): Promise<BackendAbilityAttempt> {
return request<BackendAbilityAttempt>(`/abilities/attempts/${attemptId}`, {
method: 'GET',
auth: true,
});
}
/**
* 保存能力测评答案。
* 答案字段是 `selectedOptionId`(不是 optionId / value),
* 题目 id 字段是 `attemptQuestionId` —— 写错任一字段名后端报 9805。
*/
export async function saveAbilityAnswers(
attemptId: number,
answers: { attemptQuestionId: number; selectedOptionId: string }[],
): Promise<BackendAbilityAttempt> {
return request<BackendAbilityAttempt>(`/abilities/attempts/${attemptId}/answers`, {
method: 'PATCH',
auth: true,
body: { answers },
});
}
/** 提交能力测评(未答完报 9806) */
export async function submitAbilityAttempt(attemptId: number): Promise<BackendAbilityAttempt> {
return request<BackendAbilityAttempt>(`/abilities/attempts/${attemptId}/submit`, {
method: 'POST',
auth: true,
});
}
/** 能力测评结果(GET /abilities/attempts/{id}/result) */
export async function fetchAbilityResult(attemptId: number): Promise<BackendAbilityResult> {
return request<BackendAbilityResult>(`/abilities/attempts/${attemptId}/result`, {
method: 'GET',
auth: true,
});
}
/** 我的能力测评列表(GET /abilities/me) */
export async function fetchMyAbilities(): Promise<BackendAssessmentSummary[]> {
const res = await request<{ items: BackendAssessmentSummary[]; total: number }>('/abilities/me', {
method: 'GET',
auth: true,
query: { page: 0, size: 20 },
});
return res.items ?? [];
}
/** 取一份已完成的能力测评 attemptId */
async function findCompletedAbilityAttemptId(): Promise<number | null> {
const mine = await fetchMyAbilities();
const done = mine.find(
(item) => item.typeCode === ABILITY_TYPE_CODE && item.status === 'COMPLETED',
);
return done ? done.attemptId : null;
}
/** 后端能力测评结果 → 前端 AbilityAssessment */
function mapAbilityResult(raw: BackendAbilityResult): AbilityAssessment {
const overallScore = raw.overallScore ?? 0;
return {
overallScore,
level: abilityScoreToLevel(overallScore),
dimensions: (raw.dimensionScores ?? []).map((item) => ({
label: ABILITY_DIMENSION_LABEL[item.code] ?? item.code,
value: item.score ?? 0,
})),
// 后端不下发百分位与成长趋势:留空,不拿 mock 的「超过 78% 同岗位求职者」顶替
percentile: undefined,
trends: [],
// 后端 improvementAreas 是「维度 + 小结 + 行动项」,前端是「标题 + 描述 + 图标」的卡片,
// 标题用维度名、描述用后端小结(行动项在详情页展开,卡片层不铺开)
improvements: (raw.improvementAreas ?? []).map((area, index) => ({
id: `imp-${index}`,
title: ABILITY_DIMENSION_LABEL[area.dimension] ?? area.dimension,
description: area.summary ?? '',
icon: 'trending-up-outline',
})),
};
} }
/** /**

60
src/types/growth.ts

@ -319,3 +319,63 @@ export interface BackendAssessmentSummary {
completionRate?: number; completionRate?: number;
createdAt?: string; createdAt?: string;
} }
/* ===================== 能力测评后端 DTO(2026-09-30 实网采样) =====================
* 与霍兰德答卷同构但细节不同,分开建模:
* - 题目是**情境判断选择题**(带 options),不是 1-5 量表
* - 题目 id 字段是 attemptQuestionId,答案字段是 selectedOptionId
* - 答卷详情不带 version,保存/提交都不需要乐观锁
*/
export interface BackendAbilityOption {
optionId: string;
text: string;
}
export interface BackendAbilityQuestion {
attemptQuestionId: number;
position: number;
/** 题型,如 SITUATIONAL_JUDGMENT */
itemType?: string;
stem: string;
options: BackendAbilityOption[];
/** 已选选项(回显时用) */
selectedOptionId?: string;
}
export interface BackendAbilityAttempt {
attemptId: number;
typeCode?: string;
status: string;
totalQuestions: number;
answeredCount: number;
completionRate?: number;
questions: BackendAbilityQuestion[];
}
export interface BackendAbilityResult {
attemptId: number;
typeCode: string;
status: string;
/** 结果有效期状态:VALID / EXPIRED */
validityStatus?: string;
validUntil?: string;
overallScore?: number;
/** 四维得分,code 为 LOGIC / COMMUNICATION / EXECUTION / LEARNING */
dimensionScores?: { code: string; score: number; answeredCount?: number }[];
weaknesses?: { code: string; score: number }[];
improvementAreas?: {
dimension: string;
currentScore?: number;
summary?: string;
actions?: string[];
}[];
radar?: { code: string; value: number }[];
questionFeedback?: {
attemptQuestionId: number;
selectedOptionId?: string;
awardedScore?: number;
maxScore?: number;
explanation?: string;
}[];
}

4
src/types/index.ts

@ -28,6 +28,10 @@ export type {
AbilityTrendPoint, AbilityTrendPoint,
AITool, AITool,
Article, Article,
BackendAbilityAttempt,
BackendAbilityOption,
BackendAbilityQuestion,
BackendAbilityResult,
BackendAssessmentAttempt, BackendAssessmentAttempt,
BackendAssessmentQuestion, BackendAssessmentQuestion,
BackendAssessmentResult, BackendAssessmentResult,

Loading…
Cancel
Save