154 lines
5.8 KiB
TypeScript
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([]);
|
|
});
|
|
});
|