Files
openmaic/OpenMAIC/tests/courseware/migrate.test.ts
2026-08-16 14:58:47 +08:00

154 lines
5.8 KiB
TypeScript

import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { promises as fs } from 'fs';
import os from 'os';
import path from 'path';
import { migrateAllClassrooms, migrateClassroomToCourseware } from '@/lib/courseware-repo/migrate';
import { createFileCoursewareRepo } from '@/lib/courseware-repo/store';
import { createFileBundleByteStore } from '@/lib/courseware-repo/bundle-store';
import { writeJsonFileAtomic } from '@/lib/server/classroom-storage';
import { readFrozenBundleDocuments } from '@/lib/bundle/packager';
import { AUDIO_BYTES, makeScenes, makeStage } from '../bundle/fixtures';
import type { PersistedClassroomData } from '@/lib/server/classroom-storage';
let recordsDir: string;
let bytesDir: string;
let classroomsDir: string;
beforeAll(async () => {
recordsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mig-records-'));
bytesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mig-bytes-'));
classroomsDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mig-classrooms-'));
});
afterAll(async () => {
await fs.rm(recordsDir, { recursive: true, force: true });
await fs.rm(bytesDir, { recursive: true, force: true });
await fs.rm(classroomsDir, { recursive: true, force: true });
});
function makeClassroom(id: string): PersistedClassroomData {
return {
id,
stage: makeStage(),
scenes: makeScenes(),
createdAt: '2026-08-15T00:00:00.000Z',
};
}
const resolvers = {
resolveAudioBytes: async (audioId: string) => (audioId === 'aud-1' ? AUDIO_BYTES : null),
resolveMediaBytes: async (record: { id: string }) =>
record.id.includes('img')
? new Blob([new Uint8Array([7, 7, 7])], { type: 'image/png' })
: new Blob([new Uint8Array([8, 8, 8])], { type: 'video/mp4' }),
};
function repos() {
return {
records: createFileCoursewareRepo(recordsDir),
bytes: createFileBundleByteStore(bytesDir),
};
}
describe('migrateClassroomToCourseware', () => {
test('publishes a complete classroom (audio missing → media still resolves?)', async () => {
// The fixture classroom's scenes reference audio aud-1; media records are
// derived from the resolvers' record list below.
const classroom = makeClassroom('legacy-1');
const result = await migrateClassroomToCourseware(classroom, {
coursewareId: 'legacy-1',
baseUrl: 'http://localhost:3000',
resolvers: {
resolveAudioBytes: resolvers.resolveAudioBytes,
resolveMediaBytes: async () => null, // no media records supplied → none required
},
repos: repos(),
});
expect(result.status).toBe('published');
if (result.status === 'published') {
expect(result.version).toBe(1);
// The published bundle round-trips as a frozen bundle.
const stored = await repos().bytes.read('legacy-1', 1);
const documents = await readFrozenBundleDocuments(stored!);
expect(documents.meta.coursewareId).toBe('legacy-1');
expect(documents.meta.stageName).toBe('示例课件:光合作用');
expect(documents.knowledge.scenes).toHaveLength(3);
}
});
test('reports incomplete instead of publishing when audio is missing', async () => {
const result = await migrateClassroomToCourseware(makeClassroom('legacy-2'), {
coursewareId: 'legacy-2',
baseUrl: 'http://localhost:3000',
resolvers: { resolveAudioBytes: async () => null },
repos: repos(),
});
expect(result.status).toBe('incomplete');
if (result.status === 'incomplete') {
expect(result.missing.some((m) => m.includes('audio:aud-1'))).toBe(true);
}
// Nothing was published for it.
const record = await repos().records.getLatestRecord('legacy-2');
expect(record).toBeNull();
});
test('writes an explicit migration version into both registry and frozen ZIP', async () => {
const coursewareId = 'legacy-explicit-version';
const result = await migrateClassroomToCourseware(makeClassroom(coursewareId), {
coursewareId,
version: 7,
baseUrl: 'http://localhost:3000',
resolvers: {
resolveAudioBytes: resolvers.resolveAudioBytes,
resolveMediaBytes: async () => null,
},
repos: repos(),
});
expect(result.status).toBe('published');
if (result.status === 'published') {
expect(result.record.version).toBe(7);
const stored = await repos().bytes.read(coursewareId, 7);
expect(stored).not.toBeNull();
expect((await readFrozenBundleDocuments(stored!)).meta.version).toBe(7);
}
});
});
describe('migrateAllClassrooms', () => {
test('scans the classrooms dir and reports published/incomplete/skipped', async () => {
// legacy-ok: full resolvers (audio + media records driven by stage content)
await writeJsonFileAtomic(
path.join(classroomsDir, 'legacy-ok.json'),
makeClassroom('legacy-ok'),
);
// legacy-broken: references an audio id the resolvers cannot provide
const broken = makeClassroom('legacy-broken');
broken.scenes = broken.scenes.map((scene) => ({
...scene,
actions: (scene.actions ?? []).map((action) =>
action.type === 'speech' ? { ...action, audioId: 'aud-missing' } : action,
),
}));
await writeJsonFileAtomic(path.join(classroomsDir, 'legacy-broken.json'), broken);
const report = await migrateAllClassrooms({
baseUrl: 'http://localhost:3000',
classroomsDir,
resolvers: {
resolveAudioBytes: async (audioId) => (audioId === 'aud-1' ? AUDIO_BYTES : null),
resolveMediaBytes: async () => null,
},
repos: repos(),
});
expect(report.published.map((r) => (r.status === 'published' ? r.classroomId : ''))).toContain(
'legacy-ok',
);
expect(
report.incomplete.map((r) => (r.status === 'incomplete' ? r.classroomId : '')),
).toContain('legacy-broken');
expect(report.skipped).toEqual([]);
});
});