Files
openmaic/OpenMAIC/tests/pbl/v2/persistence-hydration-roundtrip.test.ts
2026-08-16 14:58:47 +08:00

294 lines
10 KiB
TypeScript

import { BrowserRuntimeStore } from '@openmaic/storage';
import { IDBFactory, IDBKeyRange } from 'fake-indexeddb';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { PBLProjectV2 } from '@/lib/pbl/v2/types';
import { extractLearnerState, stripToDesignTemplate } from '@/lib/pbl/v2/runtime/learner-state';
import type { Scene, Stage } from '@/lib/types/stage';
import { legacyPBLSceneFixture } from '@/tests/fixtures/pbl-v1-scene';
const LEARNER_KEY = 'anon:persistence-roundtrip';
class MemoryStorage implements Storage {
private readonly values = new Map<string, string>();
get length(): number {
return this.values.size;
}
clear(): void {
this.values.clear();
}
getItem(key: string): string | null {
return this.values.get(key) ?? null;
}
key(index: number): string | null {
return [...this.values.keys()][index] ?? null;
}
removeItem(key: string): void {
this.values.delete(key);
}
setItem(key: string, value: string): void {
this.values.set(key, value);
}
}
function makeStage(id: string): Stage {
return {
id,
name: `Persistence roundtrip ${id}`,
createdAt: 1_786_032_000_000,
updatedAt: 1_786_032_000_000,
};
}
function makeProject(): PBLProjectV2 {
return {
uiPhase: 'workspace',
title: 'Persistence boundary project',
description: 'Keep learner work outside the design document',
proficiency: 'intermediate',
language: 'en-US',
tags: ['persistence'],
status: 'active',
roles: [{ id: 'instructor-1', type: 'instructor', name: 'Instructor' }],
milestones: [
{
id: 'milestone-1',
title: 'Build the prototype',
status: 'active',
order: 0,
microtasks: [
{
id: 'microtask-1',
title: 'Explain the design',
status: 'completed',
assignee: 'user',
hints: [],
order: 0,
completionReason: 'The learner submitted a concrete explanation.',
},
{
id: 'microtask-2',
title: 'Review the result',
status: 'todo',
assignee: 'user',
hints: [],
order: 1,
},
],
},
],
submissions: [
{
id: 'submission-1',
microtaskId: 'microtask-1',
milestoneId: 'milestone-1',
kind: 'text',
content: 'The persisted learner submission.',
createdAt: '2026-08-07T08:00:02.000Z',
},
],
evaluations: [],
threads: [
{
agentId: 'instructor-1',
messages: [
{
id: 'message-1',
roleType: 'user',
content: 'Here is my design explanation.',
ts: '2026-08-07T08:00:01.000Z',
microtaskId: 'microtask-1',
},
{
id: 'message-2',
agentId: 'instructor-1',
roleType: 'instructor',
content: 'That explanation is complete.',
ts: '2026-08-07T08:00:03.000Z',
microtaskId: 'microtask-1',
},
],
},
],
engagementEvents: [],
runtimeEvents: [],
createdAt: '2026-08-07T08:00:00.000Z',
updatedAt: '2026-08-07T08:00:03.000Z',
};
}
function makePBLScene(stageId: string, sceneId: string, project: PBLProjectV2): Scene {
return {
id: sceneId,
stageId,
type: 'pbl',
title: 'PBL project',
order: 0,
content: { type: 'pbl', projectV2: project },
actions: [],
createdAt: 1_786_032_000_000,
updatedAt: 1_786_032_003_000,
};
}
function pblProject(scene: Scene): PBLProjectV2 {
if (scene.content.type !== 'pbl' || !scene.content.projectV2) {
throw new Error(`expected scene ${scene.id} to contain PBL v2`);
}
return scene.content.projectV2;
}
async function createHarness(label: string) {
const documentIndexedDB = new IDBFactory();
const runtimeIndexedDB = new IDBFactory();
vi.stubGlobal('indexedDB', new IDBFactory());
const documentStoreModule = await import('@/lib/document-store');
const runtimeStoreModule = await import('@/lib/runtime/store');
const documentStore = documentStoreModule.getDocumentStore({
indexedDB: documentIndexedDB,
dbName: `pbl-persistence-roundtrip-documents-${label}`,
});
const runtimeStore = new BrowserRuntimeStore({
indexedDB: runtimeIndexedDB,
dbName: `pbl-persistence-roundtrip-runtime-${label}`,
});
documentStoreModule.configureDocumentStorage({ store: documentStore });
runtimeStoreModule.configureRuntimeStorage({ store: runtimeStore });
return { documentStore, runtimeStore };
}
beforeEach(() => {
vi.resetModules();
const localStorage = new MemoryStorage();
localStorage.setItem('maic:device:runtime.learnerKey', JSON.stringify(LEARNER_KEY));
vi.stubGlobal('localStorage', localStorage);
vi.stubGlobal('IDBKeyRange', IDBKeyRange);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe('PBL persistence boundary roundtrip', () => {
it('saves a design template and restores learner state from the real runtime store', async () => {
const stageId = 'stage-full-roundtrip';
const sceneId = 'scene-full-roundtrip';
const project = makeProject();
const scene = makePBLScene(stageId, sceneId, project);
const { documentStore, runtimeStore } = await createHarness('full');
const { preparePBLScenesForDocumentPersistence } =
await import('@/lib/pbl/v2/runtime/document-persistence');
const { hydratePBLScenesFromRuntime } = await import('@/lib/pbl/v2/runtime/hydration');
const persistedScenes = await preparePBLScenesForDocumentPersistence(stageId, [scene]);
await documentStore.saveDocument({ stage: makeStage(stageId), scenes: persistedScenes });
const reloaded = await documentStore.loadDocument(stageId);
expect(reloaded).not.toBeNull();
const storedProject = pblProject(reloaded!.scenes[0]!);
expect(storedProject).toEqual(stripToDesignTemplate(project));
expect(storedProject.threads[0]!.messages).toEqual([]);
expect(storedProject.submissions).toEqual([]);
expect(storedProject.milestones[0]!.microtasks[0]!.status).toBe('todo');
const hydratedScenes = await hydratePBLScenesFromRuntime(stageId, reloaded!.scenes);
const hydratedProject = pblProject(hydratedScenes[0]!);
expect(extractLearnerState(hydratedProject)).toEqual(extractLearnerState(project));
expect(hydratedProject.threads[0]!.messages).toEqual(project.threads[0]!.messages);
expect(hydratedProject.submissions).toEqual(project.submissions);
expect(hydratedProject.milestones[0]!.microtasks[0]!.status).toBe('completed');
const sessions = await runtimeStore.listSessions(stageId, LEARNER_KEY);
expect(sessions).toHaveLength(1);
expect(await runtimeStore.listRecords(sessions[0]!.id, { sceneId })).not.toHaveLength(0);
});
it('round-trips learner state through the real incremental single-scene save path', async () => {
const stageId = 'stage-incremental-roundtrip';
const sceneId = 'scene-incremental-roundtrip';
const stage = makeStage(stageId);
const project = makeProject();
const designScene = makePBLScene(stageId, sceneId, stripToDesignTemplate(project));
const learnerScene = makePBLScene(stageId, sceneId, project);
const untouchedLegacy = {
...structuredClone(legacyPBLSceneFixture),
id: 'scene-incremental-legacy',
stageId,
order: 1,
} as Scene;
const { documentStore } = await createHarness('incremental');
await documentStore.saveDocument({ stage, scenes: [designScene, untouchedLegacy] });
const { saveStageDataIncremental } = await import('@/lib/utils/stage-storage');
const { stageDeletionEpoch } = await import('@/lib/utils/deleted-stages');
await saveStageDataIncremental(
stageId,
[{ kind: 'scene', sceneId }],
{
stage,
scenes: [learnerScene, untouchedLegacy],
currentSceneId: sceneId,
chats: [],
outline: { outlines: [], createdAt: 1, updatedAt: 1 },
},
stageDeletionEpoch(stageId),
);
const reloaded = await documentStore.loadDocument(stageId);
expect(reloaded).not.toBeNull();
expect(pblProject(reloaded!.scenes[0]!)).toEqual(stripToDesignTemplate(project));
expect(reloaded!.scenes[1]).toEqual(untouchedLegacy);
const { hydratePBLScenesFromRuntime } = await import('@/lib/pbl/v2/runtime/hydration');
const hydrated = await hydratePBLScenesFromRuntime(stageId, reloaded!.scenes);
expect(extractLearnerState(pblProject(hydrated[0]!))).toEqual(extractLearnerState(project));
expect(hydrated[1]).toEqual(untouchedLegacy);
});
it('keeps legacy-only and damaged-hybrid scenes byte-identical end to end', async () => {
const stageId = 'stage-legacy-hybrid-roundtrip';
const legacyScene = {
...structuredClone(legacyPBLSceneFixture),
id: 'scene-legacy-only',
stageId,
order: 0,
} as Scene;
const damagedHybrid = {
...structuredClone(legacyPBLSceneFixture),
id: 'scene-damaged-hybrid',
stageId,
order: 1,
} as Scene;
if (damagedHybrid.content.type !== 'pbl') throw new Error('expected PBL scene');
Reflect.set(damagedHybrid.content, 'projectV2', { title: 'damaged v2 bytes' });
const originalBytes = [legacyScene, damagedHybrid].map((scene) => JSON.stringify(scene));
const { documentStore, runtimeStore } = await createHarness('legacy-hybrid');
const { preparePBLScenesForDocumentPersistence } =
await import('@/lib/pbl/v2/runtime/document-persistence');
const { hydratePBLScenesFromRuntime } = await import('@/lib/pbl/v2/runtime/hydration');
const persistedScenes = await preparePBLScenesForDocumentPersistence(stageId, [
legacyScene,
damagedHybrid,
]);
await documentStore.saveDocument({ stage: makeStage(stageId), scenes: persistedScenes });
const reloaded = await documentStore.loadDocument(stageId);
expect(reloaded).not.toBeNull();
expect(reloaded!.scenes.map((scene) => JSON.stringify(scene))).toEqual(originalBytes);
const hydrated = await hydratePBLScenesFromRuntime(stageId, reloaded!.scenes);
expect(hydrated.map((scene) => JSON.stringify(scene))).toEqual(originalBytes);
expect(await runtimeStore.listSessions(stageId, LEARNER_KEY)).toEqual([]);
});
});