feat: productionize learning engine and classroom
This commit is contained in:
@@ -14,6 +14,8 @@ const ctl = vi.hoisted(() => ({
|
||||
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', () => ({
|
||||
@@ -28,7 +30,11 @@ vi.mock('@/lib/server/resolve-model', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/ai/llm', () => ({
|
||||
callLLM: async () => ({ text: ctl.frameworkJson }),
|
||||
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', () => ({
|
||||
@@ -160,13 +166,19 @@ const FRAMEWORK_WITH_FAILING_MODULE = {
|
||||
|
||||
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 = () => {};
|
||||
@@ -174,12 +186,16 @@ beforeEach(async () => {
|
||||
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). */
|
||||
@@ -221,14 +237,43 @@ describe('course runner — two-phase pipeline', () => {
|
||||
expect(record?.modules[0]?.generationPromptSnapshot).toContain('日常预测案例');
|
||||
});
|
||||
|
||||
test('confirmation (module phase) generates every module in order; course completes', async () => {
|
||||
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();
|
||||
const record = await readCourseRecord('course-1');
|
||||
expect(record?.status).toBe('completed');
|
||||
expect(record?.modules.map((m) => m.status)).toEqual([
|
||||
'succeeded',
|
||||
@@ -254,6 +299,10 @@ describe('course runner — two-phase pipeline', () => {
|
||||
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 () => {
|
||||
@@ -471,6 +520,52 @@ describe('course runner — two-phase pipeline', () => {
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user