import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; let frameworksDir: string; let jobsDir: string; let classroomsDir: string; beforeEach(async () => { frameworksDir = await fs.mkdtemp(path.join(os.tmpdir(), 'course-fw-')); jobsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'course-jobs-')); classroomsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'course-classrooms-')); vi.resetModules(); vi.stubEnv('COURSE_FRAMEWORK_DIR', frameworksDir); vi.stubEnv('CLASSROOM_JOBS_DIR', jobsDir); vi.stubEnv('CLASSROOM_DATA_DIR', classroomsDir); }); afterEach(async () => { vi.unstubAllEnvs(); await fs.rm(frameworksDir, { recursive: true, force: true }); await fs.rm(jobsDir, { recursive: true, force: true }); await fs.rm(classroomsDir, { recursive: true, force: true }); }); const FRAMEWORK = { courseTitle: '机器学习入门', languageDirective: '用中文教学。', summary: '导论课程。', modules: [ { index: 1, title: '模块一', description: '基础', learningObjectives: ['目标'], estimatedMinutes: 20, }, { index: 2, title: '模块二', description: '进阶', learningObjectives: ['目标'], estimatedMinutes: 20, }, ], }; const MODULE_RECORDS = FRAMEWORK.modules.map((m) => ({ index: m.index, title: m.title, description: m.description, status: 'pending' as const, })); async function seedFramework(courseId: string) { const { updateCourseRecord } = await import('@/lib/course-framework/store'); await updateCourseRecord(courseId, { framework: FRAMEWORK as never, modules: MODULE_RECORDS, status: 'framework_ready', }); } /** Simulate the module phase being active (the runner sets this explicitly). */ async function startModulePhase(courseId: string) { const { updateCourseRecord } = await import('@/lib/course-framework/store'); await updateCourseRecord(courseId, { status: 'generating' }); } async function writeJob(jobId: string, status: string, extra: Record = {}) { const job = { id: jobId, status, step: status, progress: status === 'succeeded' ? 100 : 0, message: status, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), inputSummary: { requirementPreview: 'x', hasPdf: false, pdfTextLength: 0, pdfImageCount: 0 }, input: { requirement: 'x' }, scenesGenerated: 0, ...extra, }; await fs.writeFile(path.join(jobsDir, `${jobId}.json`), JSON.stringify(job), 'utf-8'); } async function writeClassroom(classroomId: string, concept = '实际生成知识') { const classroom = { id: classroomId, stage: { id: `stage-${classroomId}`, name: `课堂 ${classroomId}` }, scenes: [ { id: `scene-${classroomId}`, stageId: `stage-${classroomId}`, order: 1, title: concept, type: 'interactive', content: { type: 'interactive', widgetType: 'simulation', widgetConfig: { type: 'simulation', concept }, html: `

${concept}

`, }, }, ], createdAt: '2026-08-15T00:00:00.000Z', }; await fs.writeFile( path.join(classroomsDir, `${classroomId}.json`), JSON.stringify(classroom), 'utf-8', ); } describe('course-framework store', () => { test('create, read, update round-trip', async () => { const { createCourseRecord, readCourseRecord, updateCourseRecord } = await import('@/lib/course-framework/store'); const created = await createCourseRecord('course-1', '需求文本', { enableWebSearch: true }); expect(created.status).toBe('queued'); expect(created.modules).toEqual([]); await updateCourseRecord('course-1', { framework: FRAMEWORK as never }); const updated = await readCourseRecord('course-1'); expect(updated?.framework?.courseTitle).toBe('机器学习入门'); expect(updated?.framework?.courseGoals).toEqual(['目标']); expect(updated?.framework?.continuityContract.terminology[0]).toContain('一致术语'); expect(updated?.framework?.modules[0]?.generationPrompt).toContain('模块一'); expect(updated?.framework?.modules[1]?.incomingKnowledge).toEqual([]); expect(updated?.framework?.modules[0]?.outgoingKnowledge).toEqual(['目标']); expect(updated?.enableWebSearch).toBe(true); }); test('normalizes a legacy framework at read/list boundaries without rewriting on read', async () => { const { createCourseRecord, updateCourseRecord, readCourseRecord, listCourseRecords } = await import('@/lib/course-framework/store'); await createCourseRecord('legacy-course', '旧课程需求'); await updateCourseRecord('legacy-course', { framework: FRAMEWORK as never, modules: MODULE_RECORDS, status: 'framework_ready', }); const file = path.join(frameworksDir, 'legacy-course.json'); const beforeRead = await fs.readFile(file, 'utf-8'); const record = await readCourseRecord('legacy-course'); const listed = (await listCourseRecords()).find((item) => item.id === 'legacy-course'); expect(record?.framework?.targetAudience).toContain('原始课程需求'); expect(record?.framework?.modules[0]?.generationPrompt).toContain('基础'); expect(record?.framework?.modules[0]?.incomingKnowledge).toEqual([]); expect(listed?.framework?.continuityContract.assessmentStrategy).toContain('形成性检查'); expect(await fs.readFile(file, 'utf-8')).toBe(beforeRead); }); test('list returns newest first', async () => { const { createCourseRecord, listCourseRecords } = await import('@/lib/course-framework/store'); await createCourseRecord('a', 'first'); await createCourseRecord('b', 'second'); const records = await listCourseRecords(); expect(records.map((r) => r.id)).toEqual(['b', 'a']); }); test('reconcile promotes a generating module when its classroom job succeeded', async () => { const { createCourseRecord, updateCourseModule, readCourseRecordReconciled } = await import('@/lib/course-framework/store'); await createCourseRecord('course-1', '需求'); await seedFramework('course-1'); await startModulePhase('course-1'); await updateCourseModule('course-1', 1, { status: 'generating', jobId: 'job-1', generationPromptSnapshot: '本次生成使用的主 Agent 提示词', startedAt: new Date().toISOString(), }); await writeJob('job-1', 'succeeded', { result: { classroomId: 'classroom-1', url: 'http://x', scenesCount: 5 }, completedAt: '2026-08-15T00:00:00.000Z', }); await writeClassroom('classroom-1', '监督学习实际内容'); const reconciled = await readCourseRecordReconciled('course-1'); expect(reconciled?.modules[0]?.status).toBe('succeeded'); expect(reconciled?.modules[0]?.classroomId).toBe('classroom-1'); expect(reconciled?.modules[0]?.generationPromptSnapshot).toBe('本次生成使用的主 Agent 提示词'); expect(reconciled?.modules[0]?.jobId).toBeUndefined(); expect(reconciled?.modules[0]?.outputDigest?.coveredConcepts).toContain('监督学习实际内容'); expect(reconciled?.status).toBe('generating'); // module 2 still pending, phase active }); test('reconcile marks a failed classroom job as module failure', async () => { const { createCourseRecord, updateCourseModule, readCourseRecordReconciled } = await import('@/lib/course-framework/store'); await createCourseRecord('course-1', '需求'); await seedFramework('course-1'); await updateCourseModule('course-1', 2, { status: 'generating', jobId: 'job-2', startedAt: new Date().toISOString(), }); await writeJob('job-2', 'failed', { error: 'boom' }); const reconciled = await readCourseRecordReconciled('course-1'); expect(reconciled?.modules[1]?.status).toBe('failed'); expect(reconciled?.modules[1]?.error).toBe('boom'); }); test('backfills a digest for legacy succeeded modules and persists it once', async () => { const { createCourseRecord, updateCourseModule, readCourseRecordReconciled } = await import('@/lib/course-framework/store'); await createCourseRecord('course-1', '需求'); await seedFramework('course-1'); await writeClassroom('classroom-legacy', '旧课堂实际覆盖'); await updateCourseModule('course-1', 1, { status: 'succeeded', classroomId: 'classroom-legacy', completedAt: '2026-08-15T00:00:00.000Z', }); const first = await readCourseRecordReconciled('course-1'); const second = await readCourseRecordReconciled('course-1'); expect(first?.modules[0]?.outputDigest?.coveredConcepts).toContain('旧课堂实际覆盖'); expect(first?.modules[0]?.outputDigest?.semanticHash).toMatch(/^sha256:/); expect(second?.updatedAt).toBe(first?.updatedAt); }); test('reconcile returns a stale generating module to pending when the job was cancelled', async () => { const { createCourseRecord, updateCourseModule, readCourseRecordReconciled } = await import('@/lib/course-framework/store'); await createCourseRecord('course-1', '需求'); await seedFramework('course-1'); await updateCourseModule('course-1', 1, { status: 'generating', jobId: 'job-3', startedAt: new Date().toISOString(), }); await writeJob('job-3', 'cancelled'); const reconciled = await readCourseRecordReconciled('course-1'); expect(reconciled?.modules[0]?.status).toBe('pending'); expect(reconciled?.modules[0]?.jobId).toBeUndefined(); }); test('aggregate status: all succeeded → completed', async () => { const { createCourseRecord, updateCourseModule, readCourseRecordReconciled } = await import('@/lib/course-framework/store'); await createCourseRecord('course-1', '需求'); await seedFramework('course-1'); for (const index of [1, 2]) { await updateCourseModule('course-1', index, { status: 'succeeded', classroomId: `classroom-${index}`, completedAt: new Date().toISOString(), }); } const record = await readCourseRecordReconciled('course-1'); expect(record?.status).toBe('completed'); }); test('atomically invalidates a regeneration tail and clears stale attempt output', async () => { const { createCourseRecord, invalidateCourseModulesFrom, readCourseRecord, updateCourseModule, } = await import('@/lib/course-framework/store'); await createCourseRecord('course-1', '需求'); await seedFramework('course-1'); await updateCourseModule('course-1', 1, { status: 'succeeded', classroomId: 'classroom-1', completedAt: '2026-08-15T00:00:00.000Z', }); await updateCourseModule('course-1', 2, { status: 'failed', classroomId: 'stale-classroom', jobId: 'stale-job', error: 'old failure', generationPromptSnapshot: 'old attempt prompt', outputDigest: { semanticHash: 'sha256:stale' } as never, continuityInputRefs: [ { moduleIndex: 1, classroomId: 'old-upstream', semanticHash: 'sha256:old' }, ], startedAt: '2026-08-15T00:00:00.000Z', completedAt: '2026-08-15T00:01:00.000Z', }); const invalidated = await invalidateCourseModulesFrom('course-1', 2); expect(invalidated.status).toBe('generating'); expect(invalidated.error).toBeUndefined(); expect(invalidated.modules[0]).toMatchObject({ status: 'succeeded', classroomId: 'classroom-1', }); expect(invalidated.modules[1]).toEqual({ index: 2, title: '模块二', description: '进阶', status: 'pending', generationPromptSnapshot: expect.stringContaining('模块二'), }); expect(await readCourseRecord('course-1')).toEqual(invalidated); }); });