import { describe, expect, test, vi } from 'vitest'; import { generateCourseFramework, validateCourseFramework, } from '@/lib/course-framework/generate-framework'; import { buildModuleRequirement } from '@/lib/course-framework/types'; const VALID_RAW = { courseTitle: '机器学习入门', languageDirective: '用中文教学,术语保留英文。', targetAudience: '没有机器学习经验、具备高中数学基础的成人学习者。', summary: '零基础机器学习导论课程。', courseGoals: ['建立机器学习问题框架', '能够训练并评估基础模型', '能够解释模型结果'], continuityContract: { terminology: ['首次出现时使用中文术语(English term),后续使用中文术语'], teachingStyle: '先用真实问题引入,再通过可视化和练习建立概念。', difficultyProgression: '从直觉理解递进到模型训练、评估和综合应用。', assessmentStrategy: '每模块用形成性练习检查,最后以综合案例串联能力。', }, modules: [ { title: '什么是机器学习', description: '机器学习的定义、类型与典型应用。', learningObjectives: ['能区分监督/无监督学习', '能举例机器学习应用'], generationPrompt: '从生活中的预测问题出发,建立机器学习的基本分类框架,并用分类练习检查理解。', prerequisites: '', incomingKnowledge: [], outgoingKnowledge: ['监督学习与无监督学习的区别', '机器学习问题的基本识别方法'], excludedTopics: ['具体算法的数学推导'], estimatedMinutes: 20, }, { title: '线性回归', description: '一元与多元线性回归、最小二乘。', learningObjectives: ['能解释损失函数', '能读懂回归结果'], generationPrompt: '用房价预测案例讲授线性回归,连接损失函数、拟合过程和结果解释,并安排参数调整练习。', prerequisites: '模块 1', incomingKnowledge: ['监督学习的定义', '特征与标签的概念'], outgoingKnowledge: ['损失函数的作用', '线性回归结果的解释方法'], excludedTopics: ['分类模型', '交叉验证'], estimatedMinutes: 25, }, { title: '逻辑回归与分类', description: '二分类、sigmoid 与决策边界。', learningObjectives: ['能解释 sigmoid 的作用', '能描述决策边界'], generationPrompt: '承接回归建模思路,用互动决策边界展示逻辑回归,并通过分类判断练习形成迁移。', prerequisites: '模块 2', incomingKnowledge: ['损失函数的作用', '线性模型的基本结构'], outgoingKnowledge: ['sigmoid 的作用', '分类决策边界的解释方法'], excludedTopics: ['模型调优方法'], estimatedMinutes: 20, }, { title: '模型评估与调优', description: '训练/测试划分、过拟合、交叉验证。', learningObjectives: ['能识别过拟合', '能使用交叉验证'], generationPrompt: '综合前述回归与分类案例,比较训练和测试表现,设计识别过拟合与选择评估方案的任务。', prerequisites: '模块 3', incomingKnowledge: ['回归与分类模型的基本训练流程'], outgoingKnowledge: ['过拟合识别方法', '交叉验证的使用条件'], excludedTopics: [], estimatedMinutes: 20, }, ], }; describe('validateCourseFramework', () => { test('accepts a valid framework and normalizes module indices', () => { const result = validateCourseFramework(VALID_RAW); expect(result).not.toBeNull(); expect(result!.courseTitle).toBe('机器学习入门'); expect(result!.continuityContract.terminology).toHaveLength(1); expect(result!.modules).toHaveLength(4); expect(result!.modules.map((m) => m.index)).toEqual([1, 2, 3, 4]); expect(result!.modules[0]?.generationPrompt).toContain('分类框架'); }); test('rejects fewer than 4 modules', () => { const raw = { ...VALID_RAW, modules: VALID_RAW.modules.slice(0, 2), }; expect(validateCourseFramework(raw)).toBeNull(); }); test('rejects more than 15 modules', () => { const modules = Array.from({ length: 16 }, (_, i) => ({ ...VALID_RAW.modules[0], title: `模块 ${i + 1}`, })); expect(validateCourseFramework({ ...VALID_RAW, modules })).toBeNull(); }); test('rejects modules missing objectives or description', () => { const raw = { ...VALID_RAW, modules: VALID_RAW.modules.map((m, i) => (i === 1 ? { ...m, description: '' } : m)), }; expect(validateCourseFramework(raw)).toBeNull(); }); test('rejects missing title or empty module list', () => { expect(validateCourseFramework({ ...VALID_RAW, courseTitle: '' })).toBeNull(); expect(validateCourseFramework({ ...VALID_RAW, modules: [] })).toBeNull(); expect(validateCourseFramework(null)).toBeNull(); expect( validateCourseFramework({ ...VALID_RAW, modules: [null, ...VALID_RAW.modules] }), ).toBeNull(); }); test('rejects a missing continuity contract or module generation contract', () => { expect(validateCourseFramework({ ...VALID_RAW, continuityContract: undefined })).toBeNull(); expect( validateCourseFramework({ ...VALID_RAW, modules: VALID_RAW.modules.map((module, index) => index === 0 ? { ...module, generationPrompt: '' } : module, ), }), ).toBeNull(); expect( validateCourseFramework({ ...VALID_RAW, modules: VALID_RAW.modules.map((module, index) => index === 0 ? { ...module, incomingKnowledge: undefined } : module, ), }), ).toBeNull(); }); test('builds a Layer-2 requirement with language, duration, prompt, and knowledge boundaries', () => { const framework = validateCourseFramework(VALID_RAW); expect(framework).not.toBeNull(); const requirement = buildModuleRequirement( framework!, framework!.modules[1]!, framework!.modules.length, '为零基础学习者设计完整机器学习课程', '模块 1《基础》\n- 实际覆盖:监督学习;特征与标签', ); expect(requirement).toContain('用中文教学,术语保留英文'); expect(requirement).toContain('约 25 分钟'); expect(requirement).toContain('主 Agent 模块生成提示词'); expect(requirement).toContain('用房价预测案例讲授线性回归'); expect(requirement).toContain('可假定的入门知识:监督学习的定义'); expect(requirement).toContain('避免在本模块深入重复:分类模型;交叉验证'); expect(requirement).toContain('前序模块的实际生成产出(不是原计划'); expect(requirement).toContain('实际覆盖:监督学习;特征与标签'); }); }); describe('generateCourseFramework', () => { test('returns the framework on a valid first response', async () => { const aiCall = vi.fn(async (_systemPrompt: string, _userPrompt: string) => JSON.stringify(VALID_RAW), ); const result = await generateCourseFramework({ requirement: 'test' }, aiCall); expect(result.success).toBe(true); expect(result.data?.modules).toHaveLength(4); expect(aiCall).toHaveBeenCalledTimes(1); expect(String(aiCall.mock.calls[0]?.[0])).toContain('generationPrompt'); expect(String(aiCall.mock.calls[0]?.[0])).toContain('continuityContract'); expect(String(aiCall.mock.calls[0]?.[0])).toContain('incomingKnowledge'); }); test('retries once when the first response is not valid JSON', async () => { const aiCall = vi .fn() .mockResolvedValueOnce('```json\nnot json at all') .mockResolvedValueOnce(JSON.stringify(VALID_RAW)); const result = await generateCourseFramework({ requirement: 'test' }, aiCall); expect(result.success).toBe(true); expect(aiCall).toHaveBeenCalledTimes(2); // The retry prompt carries the failure hint. expect(String(aiCall.mock.calls[1][1])).toContain('无法解析'); }); test('retries once on validation failure, then reports the error', async () => { const aiCall = vi.fn(async () => JSON.stringify({ ...VALID_RAW, modules: VALID_RAW.modules.slice(0, 2) }), ); const result = await generateCourseFramework({ requirement: 'test' }, aiCall); expect(result.success).toBe(false); expect(result.error).toContain('validation'); expect(aiCall).toHaveBeenCalledTimes(2); }); test('fails on an empty response', async () => { const aiCall = vi.fn(async () => ''); const result = await generateCourseFramework({ requirement: 'test' }, aiCall); expect(result.success).toBe(false); }); });