308 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|