feat: add content navigation practice assembly

This commit is contained in:
Codex
2026-06-21 23:04:39 +08:00
parent efce192844
commit c1159f8745
18 changed files with 2358 additions and 74 deletions

View File

@@ -16,6 +16,14 @@ const ids = {
region: '00000000-0000-0000-0000-000000000301',
subject: '00000000-0000-0000-0000-000000000501',
category: '00000000-0000-0000-0000-000000000601',
contentEntry: '00000000-0000-0000-0000-000000000611',
contentNodeCulture: '00000000-0000-0000-0000-000000000612',
contentNodeProfessional: '00000000-0000-0000-0000-000000000613',
contentNodeSchoolTarget: '00000000-0000-0000-0000-000000000614',
questionCollection: '00000000-0000-0000-0000-000000000615',
practiceBlueprintSequential: '00000000-0000-0000-0000-000000000616',
practiceBlueprintRandom: '00000000-0000-0000-0000-000000000617',
practiceBlueprintMock: '00000000-0000-0000-0000-000000000618',
question: '00000000-0000-0000-0000-000000000401',
vocabularyUnit: '00000000-0000-0000-0000-000000000811',
vocabularyWord: '00000000-0000-0000-0000-000000000812',
@@ -126,6 +134,45 @@ async function testCatalogAndLearning() {
const question = questions.items?.find(item => item.id === ids.question);
assert.ok(question, 'main tenant should return smoke question');
assert.equal(question.hasVideoExplanation, true, 'smoke question should expose video marker');
assert.equal(question.contentNodeId, ids.contentNodeSchoolTarget, 'question should expose new content node binding');
const entries = await request('/api/catalog/content-entries', {
query: { regionId: ids.region, entryType: 'question_practice' },
});
assert.ok(entries.items?.some(item => item.id === ids.contentEntry), 'catalog should expose question practice entry');
const rootNodes = await request('/api/catalog/content-nodes', {
query: { entryId: ids.contentEntry, parentId: 'root' },
});
assert.ok(rootNodes.items?.some(item => item.id === ids.contentNodeProfessional), 'catalog should expose root professional node');
const schoolNodes = await request('/api/catalog/content-nodes', {
query: { entryId: ids.contentEntry, markerType: 'school', mode: 'flat' },
});
assert.ok(
schoolNodes.items?.some(item => item.id === ids.contentNodeSchoolTarget && item.markerConfig?.salesIntent === true),
'catalog should expose school target marker for sales intent',
);
const collections = await request('/api/catalog/question-collections', {
query: { nodeId: ids.contentNodeSchoolTarget },
});
assert.ok(collections.items?.some(item => item.id === ids.questionCollection), 'catalog should expose node question collection');
const collectionQuestions = await request('/api/catalog/question-collections/questions', {
query: { collectionId: ids.questionCollection },
});
assert.ok(collectionQuestions.items?.some(item => item.id === ids.question), 'collection questions should include smoke question');
const blueprintList = await request('/api/catalog/practice-blueprints', {
query: { collectionId: ids.questionCollection },
});
assert.ok(blueprintList.items?.some(item => item.id === ids.practiceBlueprintMock), 'catalog should expose mock exam blueprint');
const questionsByNode = await request('/api/catalog/questions', {
query: { contentNodeId: ids.contentNodeSchoolTarget, limit: 20 },
});
assert.ok(questionsByNode.items?.some(item => item.id === ids.question), 'catalog questions should filter by contentNodeId');
const session = await request('/api/learning/practice-sessions', {
method: 'POST',
@@ -133,6 +180,43 @@ async function testCatalogAndLearning() {
});
assert.ok(session.item?.id, 'practice session should be created');
const sequentialSession = await request('/api/learning/practice-sessions', {
method: 'POST',
body: {
userId: USER_ID,
mode: 'sequential',
collectionId: ids.questionCollection,
questionLimit: 5,
},
});
assert.equal(sequentialSession.item?.collectionId, ids.questionCollection, 'collection session should bind collection');
assert.ok(sequentialSession.item?.questionIds?.includes(ids.question), 'collection session should snapshot question ids');
const nodeSession = await request('/api/learning/practice-sessions', {
method: 'POST',
body: {
userId: USER_ID,
mode: 'random',
contentNodeId: ids.contentNodeProfessional,
questionLimit: 5,
},
});
assert.equal(nodeSession.item?.contentNodeId, ids.contentNodeProfessional, 'node session should bind parent content node');
assert.ok(nodeSession.item?.questionIds?.includes(ids.question), 'node session should include descendant questions');
const mockSession = await request('/api/learning/practice-sessions', {
method: 'POST',
body: {
userId: USER_ID,
blueprintId: ids.practiceBlueprintMock,
},
});
assert.equal(mockSession.item?.mode, 'mock_exam', 'mock blueprint should create mock_exam session');
assert.equal(mockSession.item?.blueprintId, ids.practiceBlueprintMock, 'mock session should bind blueprint');
assert.equal(mockSession.item?.durationMinutes, 120, 'mock session should inherit duration');
assert.equal(Number(mockSession.item?.totalScore), 100, 'mock session should inherit total score');
assert.ok(mockSession.item?.questionIds?.includes(ids.question), 'mock session should snapshot assembled questions');
const answer = await request('/api/learning/answers', {
method: 'POST',
body: {
@@ -251,6 +335,26 @@ async function testTenantIsolation() {
query: { unitId: ids.vocabularyUnit },
});
assert.equal(partnerWordStats.item?.totalWords, 0, 'partner tenant must not see main tenant vocabulary words');
const partnerEntries = await request('/api/catalog/content-entries', {
tenantId: PARTNER_TENANT_ID,
query: { regionId: ids.region },
});
assert.ok(!partnerEntries.items?.some(item => item.id === ids.contentEntry), 'partner tenant must not see main tenant content entries');
const partnerCollections = await request('/api/catalog/question-collections', {
tenantId: PARTNER_TENANT_ID,
query: { nodeId: ids.contentNodeSchoolTarget },
});
assert.ok(!partnerCollections.items?.some(item => item.id === ids.questionCollection), 'partner tenant must not see main tenant collections');
const partnerBlueprintSession = await request('/api/learning/practice-sessions', {
tenantId: PARTNER_TENANT_ID,
method: 'POST',
body: { userId: USER_ID, blueprintId: ids.practiceBlueprintMock },
expectStatus: 404,
});
assert.equal(partnerBlueprintSession.code, 'PRACTICE_BLUEPRINT_NOT_FOUND', 'partner tenant must not assemble main tenant blueprint');
}
async function testTenantContentAdmin() {
@@ -261,6 +365,64 @@ async function testTenantContentAdmin() {
});
assert.equal(denied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not write tenant content');
const deniedEntry = await request('/api/tenant-content/content-entries', {
method: 'PUT',
body: { entryKey: 'student-denied', name: '学生不能配置入口' },
expectStatus: 403,
});
assert.equal(deniedEntry.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not manage content navigation');
const entry = await request('/api/tenant-content/content-entries', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryKey: 'integration-practice',
regionId: ids.region,
name: '集成测试刷题入口',
entryType: 'question_practice',
route: '/practice/integration',
layoutConfig: { tabs: ['all', 'paper', 'chapter', 'type'] },
order: 11,
},
});
assert.equal(entry.item?.entryKey, 'integration-practice', 'tenant admin should create content entry');
const rootNode = await request('/api/tenant-content/content-nodes', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryId: entry.item.id,
nodeKey: 'integration-professional',
regionId: ids.region,
name: '专业课',
nodeType: 'category',
markerType: 'exam_track',
markerConfig: { intentKey: 'professional' },
isLeaf: false,
order: 1,
},
});
assert.equal(rootNode.item?.markerType, 'exam_track', 'tenant admin should create marked root node');
const childNode = await request('/api/tenant-content/content-nodes', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryId: entry.item.id,
parentId: rootNode.item.id,
nodeKey: 'integration-school-target',
regionId: ids.region,
name: '集成测试学院',
nodeType: 'school',
markerType: 'school',
markerConfig: { salesIntent: true, schoolName: '集成测试学院' },
isLeaf: true,
order: 1,
},
});
assert.equal(childNode.item?.parentId, rootNode.item.id, 'tenant admin should create child node');
assert.ok(String(childNode.item?.path || '').includes(String(rootNode.item?.path || '')), 'child node should have hierarchical path');
const unit = await request('/api/tenant-content/vocabulary-units', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
@@ -304,6 +466,8 @@ async function testTenantContentAdmin() {
body: {
subjectId: '00000000-0000-0000-0000-000000000501',
categoryId: '00000000-0000-0000-0000-000000000601',
entryId: entry.item.id,
contentNodeId: childNode.item.id,
type: 'choice',
typeLabel: '单选题',
difficulty: 2,
@@ -317,11 +481,71 @@ async function testTenantContentAdmin() {
answerText: '4',
explanation: '基础加法。',
status: 'published',
examMarkers: { schoolName: '集成测试学院', salesIntent: true },
},
});
assert.ok(question.item?.id, 'tenant admin should create question');
assert.equal(question.item?.currentVersion?.correctOptionIndex, 1, 'created question should have a version');
const collection = await request('/api/tenant-content/question-collections', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryId: entry.item.id,
nodeId: childNode.item.id,
regionId: ids.region,
subjectId: ids.subject,
categoryId: ids.category,
questionBankId: '00000000-0000-0000-0000-000000000400',
name: '集成测试题目列表',
collectionType: 'manual',
sourceType: 'manual_questions',
durationMinutes: 90,
totalScore: 100,
questions: [{ questionId: question.item.id, sectionKey: 'choice', order: 1, score: 2 }],
},
});
assert.equal(collection.item?.questionCount, 1, 'tenant admin should create question collection with items');
const blueprint = await request('/api/tenant-content/practice-blueprints', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryId: entry.item.id,
nodeId: childNode.item.id,
collectionId: collection.item.id,
regionId: ids.region,
name: '集成测试全真模拟',
mode: 'mock_exam',
assemblyType: 'collection',
questionLimit: 10,
durationMinutes: 90,
totalScore: 100,
passScore: 60,
sections: [{ key: 'choice', questionType: 'choice', questionCount: 10, scoreEach: 2 }],
rules: { randomize: true },
},
});
assert.equal(blueprint.item?.mode, 'mock_exam', 'tenant admin should create mock exam blueprint');
const adminNodes = await request('/api/tenant-content/content-nodes', {
userId: TENANT_ADMIN_USER_ID,
query: { entryId: entry.item.id, mode: 'flat' },
});
assert.ok(adminNodes.items?.some(item => item.id === childNode.item.id), 'tenant admin should list created navigation nodes');
const publicCreatedCollections = await request('/api/catalog/question-collections', {
query: { nodeId: childNode.item.id },
});
assert.ok(publicCreatedCollections.items?.some(item => item.id === collection.item.id), 'catalog should expose created collection');
const createdBlueprintSession = await request('/api/learning/practice-sessions', {
method: 'POST',
body: { userId: USER_ID, blueprintId: blueprint.item.id },
});
assert.equal(createdBlueprintSession.item?.blueprintId, blueprint.item.id, 'created blueprint should assemble a practice session');
assert.ok(createdBlueprintSession.item?.questionIds?.includes(question.item.id), 'created blueprint session should include created question');
const binding = await request('/api/tenant-content/question-videos', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
@@ -517,6 +741,9 @@ async function testTenantContentAssetsAndImports() {
subjectId: ids.subject,
categoryId: ids.category,
regionId: ids.region,
entryId: ids.contentEntry,
contentNodeId: ids.contentNodeSchoolTarget,
collectionId: ids.questionCollection,
items: [
{
legacyId: 'integration-import-choice-001',
@@ -568,11 +795,11 @@ async function testTenantContentAssetsAndImports() {
assert.ok(jobs.items?.some(item => item.id === validPreview.job.id && item.status === 'completed'), 'import job list should include completed job');
const importedQuestions = await request('/api/catalog/questions', {
query: { categoryId: ids.category, limit: 100 },
query: { collectionId: ids.questionCollection, limit: 100 },
});
assert.ok(
importedQuestions.items?.some(item => item.content === '批量导入题:企业级 SaaS 应优先使用哪种数据库?'),
'catalog should expose imported question',
'catalog should expose imported question through the new collection binding',
);
const partnerImports = await request('/api/tenant-content/imports', {