forked from wangziqi/gongxue-base
feat: support composite practice questions
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user