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

308 lines
12 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';
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<string, unknown> = {}) {
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: `<h1>${concept}</h1>`,
},
},
],
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);
});
});