Files
openmaic/OpenMAIC/tests/course-framework/runner.test.ts
2026-08-16 14:58:47 +08:00

516 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
// ─── Shared mock control (hoisted so vi.mock factories can see it) ────────
const ctl = vi.hoisted(() => ({
frameworkJson: '{}',
/** Deferred gate awaited inside the fake generateClassroom (cancel test). */
gate: Promise.resolve() as Promise<void>,
releaseGate: () => {},
failMarkers: [] as string[],
requirements: [] as string[],
interactiveModes: [] as boolean[],
classrooms: {} as Record<string, unknown>,
}));
vi.mock('@/lib/server/resolve-model', () => ({
resolveModel: async () => ({
model: {},
modelInfo: { outputWindow: 8000 },
modelString: 'test/model',
providerId: 'test',
apiKey: 'k',
thinkingConfig: undefined,
}),
}));
vi.mock('@/lib/ai/llm', () => ({
callLLM: async () => ({ text: ctl.frameworkJson }),
}));
vi.mock('@/lib/ai/providers', () => ({
isProviderKeyRequired: () => false,
}));
vi.mock('@/lib/server/classroom-storage', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/server/classroom-storage')>()),
readClassroom: async (classroomId: string) => ctl.classrooms[classroomId] ?? null,
}));
vi.mock('@/lib/server/classroom-generation', () => ({
generateClassroom: async (
input: { requirement: string; interactiveMode?: boolean },
options: { signal?: AbortSignal },
) => {
await ctl.gate;
if (options?.signal?.aborted) throw new DOMException('cancelled', 'AbortError');
ctl.requirements.push(input.requirement);
ctl.interactiveModes.push(input.interactiveMode === true);
if (ctl.failMarkers.some((marker) => input.requirement.includes(marker))) {
throw new Error('simulated module failure');
}
const hash = Buffer.from(input.requirement).toString('hex').slice(0, 8);
const classroomId = `classroom-${hash}`;
const moduleNumber = ctl.requirements.length;
const classroom = {
id: classroomId,
stage: { id: `stage-${moduleNumber}`, name: `实际生成模块 ${moduleNumber}` },
scenes: [
{
id: `scene-${moduleNumber}`,
stageId: `stage-${moduleNumber}`,
order: 1,
title: `实际互动场景 ${moduleNumber}`,
type: 'interactive',
content: {
type: 'interactive',
widgetType: 'simulation',
widgetConfig: { type: 'simulation', concept: `实际知识 ${moduleNumber}` },
html: `<h1>实际覆盖知识 ${moduleNumber}</h1>`,
},
actions: [],
},
],
createdAt: new Date().toISOString(),
};
ctl.classrooms[classroomId] = classroom;
return {
...classroom,
url: `http://localhost/classroom/${hash}`,
scenesCount: classroom.scenes.length,
};
},
}));
// ─── Environment ─────────────────────────────────────────────
const FRAMEWORK = {
courseTitle: '机器学习入门',
languageDirective: '用中文教学。',
targetAudience: '零基础成人学习者。',
summary: '导论课程。',
courseGoals: ['建立机器学习基础框架', '能够选择和评估基础模型'],
continuityContract: {
terminology: ['统一使用“特征”“标签”和“模型”'],
teachingStyle: '案例引入、可视化解释、互动练习。',
difficultyProgression: '由概念识别逐步递进到综合应用。',
assessmentStrategy: '每模块形成性检查,末模块综合测验。',
},
modules: [
{
index: 1,
title: '模块一',
description: '基础概念。',
learningObjectives: ['目标1'],
generationPrompt: '用日常预测案例建立机器学习基础概念。',
incomingKnowledge: [],
outgoingKnowledge: ['基础概念'],
excludedTopics: ['模型调优'],
estimatedMinutes: 18,
},
{
index: 2,
title: '模块二',
description: '进阶内容。',
learningObjectives: ['目标2'],
generationPrompt: '承接基础概念,通过可视化解释模型训练。',
prerequisites: '模块 1',
incomingKnowledge: ['基础概念'],
outgoingKnowledge: ['模型训练流程'],
excludedTopics: ['综合评估'],
estimatedMinutes: 22,
},
{
index: 3,
title: '模块三',
description: '综合实践。',
learningObjectives: ['目标3'],
generationPrompt: '用完整实践任务整合模型训练流程。',
prerequisites: '模块 2',
incomingKnowledge: ['模型训练流程'],
outgoingKnowledge: ['完整实践能力'],
excludedTopics: ['最终总结'],
estimatedMinutes: 24,
},
{
index: 4,
title: '模块四',
description: '总结与测验。',
learningObjectives: ['目标4'],
generationPrompt: '组织综合测验并引导学习者反思迁移。',
prerequisites: '模块 13',
incomingKnowledge: ['完整实践能力'],
outgoingKnowledge: ['迁移应用能力'],
excludedTopics: [],
estimatedMinutes: 20,
},
],
};
/** Same framework, but module 2's brief carries the failure marker. */
const FRAMEWORK_WITH_FAILING_MODULE = {
...FRAMEWORK,
modules: FRAMEWORK.modules.map((m) =>
m.index === 2 ? { ...m, description: '进阶内容 [FAIL]。' } : m,
),
};
let frameworksDir: string;
let jobsDir: string;
beforeEach(async () => {
frameworksDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-fw-'));
jobsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-jobs-'));
vi.resetModules();
vi.stubEnv('COURSE_FRAMEWORK_DIR', frameworksDir);
vi.stubEnv('CLASSROOM_JOBS_DIR', jobsDir);
ctl.frameworkJson = JSON.stringify(FRAMEWORK);
ctl.gate = Promise.resolve();
ctl.releaseGate = () => {};
ctl.failMarkers = ['[FAIL]'];
ctl.requirements = [];
ctl.interactiveModes = [];
ctl.classrooms = {};
});
afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(frameworksDir, { recursive: true, force: true });
await fs.rm(jobsDir, { recursive: true, force: true });
});
/** Wait until a condition holds (poll every 10ms, fail after 3s). */
async function waitFor(cond: () => Promise<boolean> | boolean, label: string) {
const deadline = Date.now() + 3000;
for (;;) {
if (await cond()) return;
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${label}`);
await new Promise((r) => setTimeout(r, 10));
}
}
async function setupCourse(requirement = '大型课题需求') {
const { createCourse } = await import('@/lib/course-framework/runner');
const { readCourseRecord } = await import('@/lib/course-framework/store');
await createCourse('course-1', { requirement });
return { readCourseRecord: () => readCourseRecord('course-1') };
}
describe('course runner — two-phase pipeline', () => {
test('creation runs ONLY the framework phase and waits for confirmation', async () => {
const { readCourseRecord } = await setupCourse();
const { runCourseFrameworkGeneration } = await import('@/lib/course-framework/runner');
await runCourseFrameworkGeneration('course-1', 'http://localhost');
const record = await readCourseRecord();
expect(record?.status).toBe('framework_ready');
expect(record?.framework?.courseTitle).toBe('机器学习入门');
expect(record?.framework?.modules).toHaveLength(4);
// NO module was generated before confirmation.
expect(record?.modules.map((m) => m.status)).toEqual([
'pending',
'pending',
'pending',
'pending',
]);
expect(record?.modules.every((m) => !m.classroomId)).toBe(true);
expect(record?.modules[0]?.generationPromptSnapshot).toContain('日常预测案例');
});
test('confirmation (module phase) generates every module in order; course completes', async () => {
const { readCourseRecord } = await setupCourse();
const { runCourseFrameworkGeneration, runCourseModuleGeneration } =
await import('@/lib/course-framework/runner');
await runCourseFrameworkGeneration('course-1', 'http://localhost');
await runCourseModuleGeneration('course-1', 'http://localhost');
const record = await readCourseRecord();
expect(record?.status).toBe('completed');
expect(record?.modules.map((m) => m.status)).toEqual([
'succeeded',
'succeeded',
'succeeded',
'succeeded',
]);
expect(record?.modules.every((m) => m.classroomId?.startsWith('classroom-'))).toBe(true);
expect(record?.modules.every((m) => m.outputDigest?.sceneCount === 1)).toBe(true);
expect(record?.modules[1]?.continuityInputRefs).toEqual([
{
moduleIndex: 1,
classroomId: record?.modules[0]?.classroomId,
semanticHash: record?.modules[0]?.outputDigest?.semanticHash,
},
]);
expect(record?.modules[3]?.continuityInputRefs).toHaveLength(3);
expect(ctl.requirements).toHaveLength(4);
expect(ctl.interactiveModes).toEqual([true, true, true, true]);
expect(ctl.requirements[0]).toContain('教学语言与术语处理:用中文教学');
expect(ctl.requirements[0]).toContain('主 Agent 模块生成提示词:用日常预测案例');
expect(ctl.requirements[0]).toContain('约 18 分钟');
expect(ctl.requirements[1]).toContain('可假定的入门知识:基础概念');
expect(ctl.requirements[1]).toContain('前序模块的实际生成产出');
expect(ctl.requirements[1]).toContain('实际覆盖知识 1');
});
test('enforces one active generation run per course', async () => {
let release!: () => void;
ctl.gate = new Promise<void>((resolve) => {
release = resolve;
});
const { readCourseRecord } = await setupCourse();
const { runCourseFrameworkGeneration, runCourseModuleGeneration, runCourseGenerationJob } =
await import('@/lib/course-framework/runner');
const { readClassroomGenerationJob } = await import('@/lib/server/classroom-job-store');
await runCourseFrameworkGeneration('course-1', 'http://localhost');
const fullRun = runCourseModuleGeneration('course-1', 'http://localhost');
await waitFor(async () => {
const record = await readCourseRecord();
const moduleRec = record?.modules[0];
if (moduleRec?.status !== 'generating' || !moduleRec.jobId) return false;
return (await readClassroomGenerationJob(moduleRec.jobId))?.status === 'running';
}, 'full course module run');
expect(() =>
runCourseGenerationJob('course-1', 'http://localhost', { onlyModuleIndex: 2 }),
).toThrow('already has an active generation run');
release();
await fullRun;
expect((await readCourseRecord())?.status).toBe('completed');
});
test('resumes legacy frameworks through read-time compatibility defaults', async () => {
const { readCourseRecord } = await setupCourse('旧版大型课程需求');
const { updateCourseRecord } = await import('@/lib/course-framework/store');
const { runCourseModuleGeneration } = await import('@/lib/course-framework/runner');
const legacyModules = FRAMEWORK.modules.map((module) => ({
index: module.index,
title: module.title,
description: module.description,
learningObjectives: module.learningObjectives,
prerequisites: module.prerequisites,
estimatedMinutes: module.estimatedMinutes,
}));
await updateCourseRecord('course-1', {
framework: {
courseTitle: FRAMEWORK.courseTitle,
languageDirective: FRAMEWORK.languageDirective,
summary: FRAMEWORK.summary,
modules: legacyModules,
} as never,
modules: legacyModules.map((module) => ({
index: module.index,
title: module.title,
description: module.description,
status: 'pending' as const,
})),
status: 'framework_ready',
});
await runCourseModuleGeneration('course-1', 'http://localhost');
const record = await readCourseRecord();
expect(record?.status).toBe('completed');
expect(record?.framework?.courseGoals.length).toBeGreaterThan(0);
expect(record?.modules[0]?.generationPromptSnapshot).toContain('基础概念');
expect(ctl.requirements[0]).toContain('教学语言与术语处理:用中文教学');
expect(ctl.requirements[0]).toContain('重点覆盖:基础概念');
});
test('a failed module pauses later modules to preserve continuity', async () => {
ctl.frameworkJson = JSON.stringify(FRAMEWORK_WITH_FAILING_MODULE);
const { readCourseRecord } = await setupCourse();
const { runCourseFrameworkGeneration, runCourseModuleGeneration } =
await import('@/lib/course-framework/runner');
await runCourseFrameworkGeneration('course-1', 'http://localhost');
await runCourseModuleGeneration('course-1', 'http://localhost');
const record = await readCourseRecord();
expect(record?.status).toBe('failed');
expect(record?.modules.map((m) => m.status)).toEqual([
'succeeded',
'failed',
'pending',
'pending',
]);
expect(record?.modules[1]?.error).toContain('simulated module failure');
expect(ctl.requirements).toHaveLength(2);
});
test('resume skips succeeded modules and finishes the failed one', async () => {
ctl.frameworkJson = JSON.stringify(FRAMEWORK_WITH_FAILING_MODULE);
const { readCourseRecord } = await setupCourse();
const { runCourseFrameworkGeneration, runCourseModuleGeneration } =
await import('@/lib/course-framework/runner');
await runCourseFrameworkGeneration('course-1', 'http://localhost');
await runCourseModuleGeneration('course-1', 'http://localhost');
expect((await readCourseRecord())?.status).toBe('failed');
// The operator's fix: the module no longer fails.
ctl.failMarkers = [];
await runCourseModuleGeneration('course-1', 'http://localhost');
const record = await readCourseRecord();
expect(record?.status).toBe('completed');
expect(record?.modules.map((m) => m.status)).toEqual([
'succeeded',
'succeeded',
'succeeded',
'succeeded',
]);
});
test('regenerates a failed module and every pending downstream module in order', async () => {
ctl.frameworkJson = JSON.stringify(FRAMEWORK_WITH_FAILING_MODULE);
const { readCourseRecord } = await setupCourse();
const { runCourseFrameworkGeneration, runCourseModuleGeneration, runCourseGenerationJob } =
await import('@/lib/course-framework/runner');
await runCourseFrameworkGeneration('course-1', 'http://localhost');
await runCourseModuleGeneration('course-1', 'http://localhost');
expect((await readCourseRecord())?.status).toBe('failed');
ctl.failMarkers = [];
await runCourseGenerationJob('course-1', 'http://localhost', { onlyModuleIndex: 2 });
const record = await readCourseRecord();
expect(record?.status).toBe('completed');
expect(record?.modules[1]?.status).toBe('succeeded');
expect(ctl.requirements).toHaveLength(5);
expect(record?.modules[3]?.continuityInputRefs).toHaveLength(3);
});
test('regenerating a succeeded module immediately invalidates and rebuilds its tail', async () => {
const { readCourseRecord } = await setupCourse();
const { runCourseFrameworkGeneration, runCourseModuleGeneration, runCourseGenerationJob } =
await import('@/lib/course-framework/runner');
const { updateCourseModule } = await import('@/lib/course-framework/store');
await runCourseFrameworkGeneration('course-1', 'http://localhost');
await runCourseModuleGeneration('course-1', 'http://localhost');
const before = await readCourseRecord();
expect(before?.status).toBe('completed');
const upstreamClassroomId = before?.modules[0]?.classroomId;
const oldDigests = before?.modules.slice(1).map((moduleRec) => moduleRec.outputDigest);
await updateCourseModule('course-1', 3, {
generationPromptSnapshot: 'stale prompt from an older attempt',
});
let release!: () => void;
ctl.gate = new Promise<void>((resolve) => {
release = resolve;
});
const regeneration = runCourseGenerationJob('course-1', 'http://localhost', {
onlyModuleIndex: 2,
});
await waitFor(async () => {
const record = await readCourseRecord();
return record?.modules[1]?.status === 'generating';
}, 'module 2 regeneration cascade');
const during = await readCourseRecord();
expect(during?.status).toBe('generating');
expect(during?.modules[0]?.classroomId).toBe(upstreamClassroomId);
expect(during?.modules[1]?.status).toBe('generating');
expect(during?.modules[1]).not.toHaveProperty('classroomId');
expect(during?.modules[1]).not.toHaveProperty('outputDigest');
expect(during?.modules.slice(2)).toEqual([
{
index: 3,
title: '模块三',
description: '综合实践。',
status: 'pending',
generationPromptSnapshot: FRAMEWORK.modules[2].generationPrompt,
},
{
index: 4,
title: '模块四',
description: '总结与测验。',
status: 'pending',
generationPromptSnapshot: FRAMEWORK.modules[3].generationPrompt,
},
]);
release();
await regeneration;
const after = await readCourseRecord();
expect(after?.status).toBe('completed');
expect(after?.modules.map((moduleRec) => moduleRec.status)).toEqual([
'succeeded',
'succeeded',
'succeeded',
'succeeded',
]);
expect(ctl.requirements).toHaveLength(7);
expect(after?.modules[1]?.outputDigest?.semanticHash).not.toBe(oldDigests?.[0]?.semanticHash);
expect(after?.modules[2]?.outputDigest?.semanticHash).not.toBe(oldDigests?.[1]?.semanticHash);
expect(after?.modules[2]?.continuityInputRefs?.[1]).toEqual({
moduleIndex: 2,
classroomId: after?.modules[1]?.classroomId,
semanticHash: after?.modules[1]?.outputDigest?.semanticHash,
});
});
test('regenerating the framework resets modules back to pending', async () => {
const { readCourseRecord } = await setupCourse();
const { runCourseFrameworkGeneration, runCourseModuleGeneration, regenerateCourseFramework } =
await import('@/lib/course-framework/runner');
await runCourseFrameworkGeneration('course-1', 'http://localhost');
await runCourseModuleGeneration('course-1', 'http://localhost');
expect((await readCourseRecord())?.status).toBe('completed');
await regenerateCourseFramework('course-1', 'http://localhost');
const record = await readCourseRecord();
expect(record?.status).toBe('framework_ready');
expect(record?.modules).toHaveLength(4);
expect(record?.modules.every((m) => m.status === 'pending' && !m.classroomId)).toBe(true);
});
test('cancel aborts the current module and keeps the course cancellable', async () => {
let release!: () => void;
ctl.gate = new Promise<void>((r) => {
release = r;
});
ctl.releaseGate = release;
const {
createCourse,
runCourseFrameworkGeneration,
runCourseModuleGeneration,
cancelCourseGenerationJob,
} = await import('@/lib/course-framework/runner');
const { readCourseRecord, readCourseRecordReconciled } =
await import('@/lib/course-framework/store');
const { readClassroomGenerationJob } = await import('@/lib/server/classroom-job-store');
await createCourse('course-1', { requirement: '大型课题需求' });
await runCourseFrameworkGeneration('course-1', 'http://localhost');
const runPromise = runCourseModuleGeneration('course-1', 'http://localhost');
// Wait until module 1's classroom job is running.
await waitFor(async () => {
const record = await readCourseRecord('course-1');
const moduleRec = record?.modules[0];
if (!record?.framework || moduleRec?.status !== 'generating' || !moduleRec.jobId)
return false;
const job = await readClassroomGenerationJob(moduleRec.jobId);
return job?.status === 'running';
}, 'module 1 classroom job running');
cancelCourseGenerationJob('course-1');
release();
await runPromise;
const record = await readCourseRecordReconciled('course-1');
expect(record?.status).toBe('cancelled');
// The in-flight module returned to pending; nothing succeeded yet.
expect(record?.modules.every((m) => m.status === 'pending')).toBe(true);
});
});