611 lines
24 KiB
TypeScript
611 lines
24 KiB
TypeScript
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>,
|
||
frameworkGate: Promise.resolve() as Promise<void>,
|
||
frameworkAbortSignals: [] as AbortSignal[],
|
||
}));
|
||
|
||
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 (params: { abortSignal?: AbortSignal }) => {
|
||
if (params.abortSignal) ctl.frameworkAbortSignals.push(params.abortSignal);
|
||
await ctl.frameworkGate;
|
||
return { 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: '模块 1–3',
|
||
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;
|
||
let frozenCoursesDir: string;
|
||
let frozenPackagesDir: string;
|
||
|
||
beforeEach(async () => {
|
||
frameworksDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-fw-'));
|
||
jobsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-jobs-'));
|
||
frozenCoursesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-frozen-courses-'));
|
||
frozenPackagesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'runner-frozen-packages-'));
|
||
vi.resetModules();
|
||
vi.stubEnv('COURSE_FRAMEWORK_DIR', frameworksDir);
|
||
vi.stubEnv('CLASSROOM_JOBS_DIR', jobsDir);
|
||
vi.stubEnv('LEARNING_COURSE_STORE_DIR', frozenCoursesDir);
|
||
vi.stubEnv('LEARNING_COURSE_PACKAGE_DIR', frozenPackagesDir);
|
||
ctl.frameworkJson = JSON.stringify(FRAMEWORK);
|
||
ctl.gate = Promise.resolve();
|
||
ctl.releaseGate = () => {};
|
||
ctl.failMarkers = ['[FAIL]'];
|
||
ctl.requirements = [];
|
||
ctl.interactiveModes = [];
|
||
ctl.classrooms = {};
|
||
ctl.frameworkGate = Promise.resolve();
|
||
ctl.frameworkAbortSignals = [];
|
||
});
|
||
|
||
afterEach(async () => {
|
||
vi.unstubAllEnvs();
|
||
await fs.rm(frameworksDir, { recursive: true, force: true });
|
||
await fs.rm(jobsDir, { recursive: true, force: true });
|
||
await fs.rm(frozenCoursesDir, { recursive: true, force: true });
|
||
await fs.rm(frozenPackagesDir, { 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('framework cancellation reaches the LLM signal and cannot later overwrite cancelled state', async () => {
|
||
let releaseFramework!: () => void;
|
||
ctl.frameworkGate = new Promise<void>((resolve) => {
|
||
releaseFramework = resolve;
|
||
});
|
||
const { readCourseRecord } = await setupCourse();
|
||
const {
|
||
cancelCourseGenerationJob,
|
||
runCourseFrameworkGeneration,
|
||
} = await import('@/lib/course-framework/runner');
|
||
|
||
const run = runCourseFrameworkGeneration('course-1', 'http://localhost');
|
||
await waitFor(() => ctl.frameworkAbortSignals.length === 1, 'framework LLM call');
|
||
expect(cancelCourseGenerationJob('course-1')).toBe(true);
|
||
expect(ctl.frameworkAbortSignals[0]?.aborted).toBe(true);
|
||
releaseFramework();
|
||
await run;
|
||
|
||
const record = await readCourseRecord();
|
||
expect(record?.status).toBe('cancelled');
|
||
expect(record?.framework).toBeUndefined();
|
||
});
|
||
|
||
test('confirmation (module phase) generates every module in order; course completes', async () => {
|
||
const { createCourse } = await import('@/lib/course-framework/runner');
|
||
const { readCourseRecord } = await import('@/lib/course-framework/store');
|
||
await createCourse(
|
||
'course-1',
|
||
{ requirement: '大型课题需求' },
|
||
{ ownerPrincipalId: 'works-owner-1' },
|
||
);
|
||
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('course-1');
|
||
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');
|
||
const { listClassroomGenerationJobs } = await import('@/lib/server/classroom-job-store');
|
||
const moduleJobs = await listClassroomGenerationJobs(10);
|
||
expect(moduleJobs).toHaveLength(4);
|
||
expect(moduleJobs.every((job) => job.ownerPrincipalId === 'works-owner-1')).toBe(true);
|
||
});
|
||
|
||
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('rejects every source generation entry after the course id is frozen', async () => {
|
||
const { readCourseRecord } = await setupCourse();
|
||
const {
|
||
runCourseFrameworkGeneration,
|
||
runCourseModuleGeneration,
|
||
runCourseGenerationJob,
|
||
regenerateCourseFramework,
|
||
} = await import('@/lib/course-framework/runner');
|
||
await runCourseFrameworkGeneration('course-1', 'http://localhost');
|
||
await runCourseModuleGeneration('course-1', 'http://localhost');
|
||
const before = await readCourseRecord();
|
||
expect(before?.status).toBe('completed');
|
||
|
||
const { persistFrozenLearningPackage } = await import('@/lib/makelore-course/package');
|
||
await persistFrozenLearningPackage({
|
||
schemaVersion: 2,
|
||
courseId: 'course-1',
|
||
mode: 'large',
|
||
sourceJobId: 'course-1',
|
||
sourceDigest: 'c'.repeat(64),
|
||
contentHash: 'a'.repeat(64),
|
||
archiveSha256: 'b'.repeat(64),
|
||
archiveBytes: 4,
|
||
formatVersion: 2,
|
||
minPlayerVersion: '2.0.0',
|
||
title: '机器学习入门',
|
||
sceneCount: 4,
|
||
moduleCount: 4,
|
||
capabilities: { modular: true },
|
||
createdAt: '2026-08-16T00:00:00.000Z',
|
||
}, new Uint8Array([0x50, 0x4b, 0x03, 0x04]));
|
||
|
||
const mutations = [
|
||
() => runCourseFrameworkGeneration('course-1', 'http://localhost'),
|
||
() => runCourseModuleGeneration('course-1', 'http://localhost'),
|
||
() => runCourseGenerationJob('course-1', 'http://localhost', { onlyModuleIndex: 2 }),
|
||
() => regenerateCourseFramework('course-1', 'http://localhost'),
|
||
];
|
||
for (const mutate of mutations) {
|
||
await expect(mutate()).rejects.toMatchObject({
|
||
name: 'FrozenLearningCourseMutationError',
|
||
});
|
||
}
|
||
expect(await readCourseRecord()).toEqual(before);
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|