feat: support composite practice questions

This commit is contained in:
Codex
2026-06-29 15:57:03 +08:00
parent 07a6edeea2
commit e54efbc294
14 changed files with 893 additions and 43 deletions

View File

@@ -1315,6 +1315,163 @@ async function testCatalogAndLearning() {
assert.ok(favoriteReviewSession.item?.questionIds?.includes(ids.questionThree), 'favorite review session should be assembled by backend favorites');
}
async function testCompositePracticeQuestions() {
const question = await request('/api/tenant-content/questions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
questionBankId: ids.questionBank,
subjectId: ids.subject,
categoryId: ids.category,
entryId: ids.contentEntry,
contentNodeId: ids.contentNodeSchoolTarget,
primaryCollectionId: ids.questionCollection,
legacyId: `integration-composite-${Date.now()}`,
type: 'reading',
typeLabel: '阅读理解',
difficulty: 2,
content: '阅读材料Supabase SaaS 题库需要后端统一校验租户、权限和权益。',
options: [],
correctOptionIndices: [],
answerText: null,
explanation: '复合题解析应在报告中保留子题明细。',
subQuestions: [
{
id: 'main-idea',
type: 'choice',
typeLabel: '单选题',
content: '材料强调题库权限应由谁统一校验?',
options: ['前端页面', '后端服务', '浏览器缓存'],
correctOptionIndices: [1],
explanation: '租户隔离、权限和权益必须由后端统一校验。',
},
{
id: 'reason',
type: 'short_answer',
typeLabel: '简答题',
content: '简述为什么复合题需要保存每个子题的结构化答案。',
answerText: '为了支持断点续练、逐小题复盘和统计分析。',
explanation: '结构化子题结果可以服务报告、错题和后续数据分析。',
},
],
status: 'published',
},
});
assert.ok(question.item?.id, 'tenant admin should create a composite reading question');
const collection = await request('/api/tenant-content/question-collections', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryId: ids.contentEntry,
nodeId: ids.contentNodeSchoolTarget,
regionId: ids.region,
subjectId: ids.subject,
categoryId: ids.category,
questionBankId: ids.questionBank,
name: `集成测试复合题集合 ${Date.now()}`,
collectionType: 'manual',
sourceType: 'manual_questions',
totalScore: 10,
questions: [{ questionId: question.item.id, sectionKey: 'reading', order: 1, score: 10 }],
},
});
assert.equal(collection.item?.questionCount, 1, 'composite collection should contain the reading question');
const session = await request('/api/learning/practice-sessions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
userId: TENANT_ADMIN_USER_ID,
collectionId: collection.item.id,
mode: 'sequential',
questionLimit: 1,
},
});
assert.deepEqual(session.item?.questionIds, [question.item.id], 'composite practice session should snapshot the reading question');
const topLevelRejected = await request('/api/learning/answers', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
userId: TENANT_ADMIN_USER_ID,
practiceSessionId: session.item.id,
questionId: question.item.id,
answerText: '不能用顶层答案提交复合题',
},
expectStatus: 400,
});
assert.equal(topLevelRejected.code, 'SUB_ANSWERS_REQUIRED', 'composite question should require subAnswers');
const objectiveSelfJudged = await request('/api/learning/answers', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
userId: TENANT_ADMIN_USER_ID,
practiceSessionId: session.item.id,
questionId: question.item.id,
subAnswers: [
{ subQuestionId: 'main-idea', selectedOptions: ['1'], selfJudgedCorrect: true },
],
},
expectStatus: 400,
});
assert.equal(objectiveSelfJudged.code, 'SELF_JUDGMENT_NOT_ALLOWED', 'objective sub questions must not accept self judgment');
const answer = await request('/api/learning/answers', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
userId: TENANT_ADMIN_USER_ID,
practiceSessionId: session.item.id,
questionId: question.item.id,
subAnswers: [
{ subQuestionId: 'main-idea', selectedOptions: ['1'] },
{ subQuestionId: 'reason', answerText: '已按参考答案完成自评', selfJudgedCorrect: true },
],
},
});
assert.equal(answer.item?.isCorrect, true, 'composite answer should be correct when all sub questions are correct');
assert.equal(answer.item?.compositeSummary?.correctCount, 2, 'composite answer should return sub question summary');
assert.ok(answer.item?.subResults?.some(item => item.subQuestionId === 'main-idea' && item.isCorrect === true), 'composite answer should include objective sub result');
assert.ok(answer.item?.subResults?.some(item => item.subQuestionId === 'reason' && item.selfJudged === true), 'composite answer should include subjective self judgment');
const detail = await request('/api/learning/practice-sessions/detail', {
userId: TENANT_ADMIN_USER_ID,
query: { practiceSessionId: session.item.id },
});
const storedAnswer = detail.item?.answersByQuestion?.[question.item.id];
assert.equal(storedAnswer?.answerPayload?.summary?.correctCount, 2, 'session detail should expose composite answer payload for resume');
const storedPayloadText = JSON.stringify(storedAnswer?.answerPayload || {});
assert.ok(!storedPayloadText.includes('correctAnswerText'), 'stored composite payload must not persist correct answers');
assert.ok(!storedPayloadText.includes('explanation'), 'stored composite payload must not persist explanations');
assert.ok(!storedPayloadText.includes('为了支持断点续练'), 'stored composite payload must not persist subjective reference answer');
assert.equal(detail.item?.questions?.[0]?.subQuestions?.length, 2, 'session detail should include composite sub questions');
const report = await request('/api/learning/practice-sessions/submit', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
body: {
userId: TENANT_ADMIN_USER_ID,
practiceSessionId: session.item.id,
},
});
assert.equal(report.item?.totalQuestions, 1, 'composite report should still count one top-level question');
assert.equal(report.item?.correctCount, 1, 'composite report should mark the top-level question correct when all sub questions are correct');
assert.equal(report.item?.score, 10, 'composite report should scale sub question score to collection item score');
const result = report.item?.questionResults?.find(item => item.questionId === question.item.id);
assert.equal(result?.subResults?.length, 2, 'composite report should include per-sub-question results');
assert.equal(result?.subResults?.[0]?.totalScore, 5, 'composite report should split top-level score across sub questions by default');
assert.ok(
result?.subResults?.some(item => item.subQuestionId === 'reason' && item.correctAnswerText === '为了支持断点续练、逐小题复盘和统计分析。'),
'composite report should enrich reference answers from the current question version',
);
assert.ok(
result?.subResults?.some(item => item.subQuestionId === 'main-idea' && item.explanation === '租户隔离、权限和权益必须由后端统一校验。'),
'composite report should enrich explanations from the current question version',
);
}
async function testProfile() {
const payload = await request('/api/profile/me');
assert.equal(payload.item?.userId, USER_ID, 'profile should belong to smoke user');
@@ -6084,6 +6241,7 @@ async function main() {
await check('Supabase JWT identity', testSupabaseJwtIdentity);
await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled);
await check('catalog and learning', testCatalogAndLearning);
await check('composite practice questions', testCompositePracticeQuestions);
await check('profile', testProfile);
await check('learning leaderboard', testLearningLeaderboard);
await check('scoreline', testScoreline);