import { validateScene, type PBLContent as ContractPBLContent, type WidgetConfigBase, } from '@openmaic/dsl'; import type { DocumentStore } from '@openmaic/storage'; import { describe, expect, test, vi } from 'vitest'; import { IDBFactory } from 'fake-indexeddb'; import { canonicalizeLegacyOutline, canonicalizeLegacyScene, canonicalizeLegacyStage, } from '@/lib/document-store/canonicalize'; import { getDocumentStore } from '@/lib/document-store/store'; import type { AppDocument } from '@/lib/document-store/persistence-types'; import { validateAppScene, validateAppStage } from '@/lib/document-store/validators'; import { upgradeLegacyPBLConfigToProjectV2, type PBLProjectConfig } from '@/lib/pbl/legacy/read'; import type { SceneOutline } from '@/lib/types/generation'; import type { AppScene, InteractiveContent, PBLContent } from '@/lib/types/stage'; import type { SimulationConfig } from '@/lib/types/widgets'; import type { SceneRecord, StageOutlinesRecord, StageRecord } from '@/lib/utils/database'; import { legacyPBLSceneFixture } from '@/tests/fixtures/pbl-v1-scene'; const stageRecord: StageRecord = { id: 'stage-1', name: 'Complete stage', description: 'Every legacy field', createdAt: 100, updatedAt: 200, languageDirective: 'Use English', style: 'academic', currentSceneId: 'scene-1', agentIds: ['teacher'], videoManifest: { media: { type: 'video', prompt: 'A demo', aspectRatio: '16:9' } }, interactiveMode: true, taskEngineMode: true, generatedAgentConfigs: [ { id: 'teacher', name: 'Teacher', role: 'teacher', persona: 'Clear', avatar: 'avatar.png', color: '#fff', priority: 1, }, ], }; function slideScene(overrides: Record = {}): AppScene { return { id: 'slide-1', stageId: 'stage-1', title: 'Slide', order: 0, type: 'slide', content: { type: 'slide', canvas: { id: 'canvas-1', elements: [] } }, ...overrides, } as unknown as AppScene; } function quizScene(): AppScene { return { id: 'quiz-1', stageId: 'stage-1', title: 'Quiz', order: 1, type: 'quiz', content: { type: 'quiz', questions: [] }, } as AppScene; } function interactiveScene(): AppScene { return { id: 'interactive-1', stageId: 'stage-1', title: 'Interactive', order: 2, type: 'interactive', content: { type: 'interactive', url: 'https://example.test/widget' }, } as AppScene; } function pblScene(): AppScene { return { id: 'pbl-1', stageId: 'stage-1', title: 'PBL', order: 3, type: 'pbl', content: { type: 'pbl', projectConfig: { projectInfo: { title: 'Project', description: 'Build it' }, agents: [], issueboard: { agent_ids: [], issues: [], current_issue_id: null }, chat: { messages: [] }, }, }, } as AppScene; } describe('app document persistence seam', () => { test('round-trips a document containing a v1-native PBL scene', async () => { const document: AppDocument = { stage: canonicalizeLegacyStage(stageRecord).stage, scenes: [{ ...legacyPBLSceneFixture, stageId: stageRecord.id }], }; const store = getDocumentStore({ indexedDB: new IDBFactory(), dbName: 'app-document-legacy-pbl-roundtrip', }); await store.saveDocument(document); expect((await store.loadDocument(stageRecord.id))?.scenes).toEqual(document.scenes); }); test('round-trips a stored hybrid with inert damaged projectV2 bytes', async () => { const damagedHybrid = structuredClone(legacyPBLSceneFixture) as AppScene; if (damagedHybrid.content.type !== 'pbl') throw new Error('expected PBL scene'); Reflect.set(damagedHybrid.content, 'projectV2', { title: 'broken' }); const document: AppDocument = { stage: canonicalizeLegacyStage(stageRecord).stage, scenes: [{ ...damagedHybrid, stageId: stageRecord.id }], }; const store = getDocumentStore({ indexedDB: new IDBFactory(), dbName: 'app-document-damaged-hybrid-roundtrip', }); expect(validateAppScene(document.scenes[0])).toEqual({ valid: true }); await store.saveDocument(document); expect((await store.loadDocument(stageRecord.id))?.scenes).toEqual(document.scenes); }); test('omits nested undefined PBL state before calling the configured store', async () => { const saveDocument = vi.fn(async (_document: AppDocument) => undefined); const store = getDocumentStore({ store: { saveDocument } as unknown as DocumentStore, }); const document = { stage: canonicalizeLegacyStage(stageRecord).stage, scenes: [ { ...pblScene(), content: { ...pblScene().content, projectV2: { roles: [], milestones: [ { microtasks: [{ id: 'mt-1', internalAssessment: undefined }], }, ], submissions: [], evaluations: [], threads: [], engagementEvents: [], }, }, }, ], } as unknown as AppDocument; await store.saveDocument(document); const persisted = saveDocument.mock.calls[0]![0] as AppDocument; expect( ( persisted.scenes[0]!.content as unknown as { projectV2: { milestones: Array<{ microtasks: Array> }> }; } ).projectV2.milestones[0]!.microtasks[0], ).not.toHaveProperty('internalAssessment'); expect( ( document.scenes[0]!.content as unknown as { projectV2: { milestones: Array<{ microtasks: Array> }> }; } ).projectV2.milestones[0]!.microtasks[0], ).toHaveProperty('internalAssessment', undefined); }); test('round-trips every StageRecord field after separating playback position', async () => { const canonical = canonicalizeLegacyStage(stageRecord); const document: AppDocument = { stage: canonical.stage, scenes: [slideScene()], }; const store = getDocumentStore({ indexedDB: new IDBFactory(), dbName: 'app-document-stage-roundtrip', }); await store.saveDocument(document); const loaded = await store.loadDocument(stageRecord.id); expect(loaded!.stage).toEqual(canonical.stage); expect({ ...loaded!.stage, currentSceneId: canonical.currentSceneId }).toEqual(stageRecord); }); test('round-trips the outline envelope including generationComplete', async () => { const outline: SceneOutline = { id: 'outline-1', type: 'slide', title: 'Outline', description: 'Intent', keyPoints: ['Point'], order: 0, }; const legacy: StageOutlinesRecord = { stageId: 'stage-1', outlines: [outline], generationComplete: true, createdAt: 10, updatedAt: 20, }; const canonical = canonicalizeLegacyOutline(legacy); const store = getDocumentStore({ indexedDB: new IDBFactory(), dbName: 'app-document-outline-roundtrip', }); await store.saveDocument({ stage: canonicalizeLegacyStage(stageRecord).stage, scenes: [slideScene()], outline: canonical, }); expect((await store.loadDocument('stage-1'))!.outline).toEqual(canonical); }); }); describe('app document validators', () => { test.each([ ['slide', slideScene()], ['quiz', quizScene()], ['interactive', interactiveScene()], ['pbl', pblScene()], ])('accepts a valid %s scene', (_kind, scene) => { expect(validateAppScene(scene)).toEqual({ valid: true }); }); test('rejects projectV2 without the renderer-required containers', () => { const scene = { ...pblScene(), content: { type: 'pbl', projectV2: { title: 'V2 project' } }, } as unknown as AppScene; expect(validateAppScene(scene)).toEqual({ valid: false, errors: [ { path: '/content/projectV2', message: '`projectV2` must contain milestones, roles and threads arrays', }, ], }); }); test('accepts damaged projectV2 as inert bytes on a stored hybrid', () => { const scene = structuredClone(legacyPBLSceneFixture) as AppScene; if (scene.content.type !== 'pbl') throw new Error('expected PBL scene'); Reflect.set(scene.content, 'projectV2', { title: 'broken' }); expect(validateAppScene(scene)).toEqual({ valid: true }); }); test('an empty projectConfig stub does not disable projectV2 validation', () => { const scene = { ...pblScene(), content: { type: 'pbl', projectConfig: {}, projectV2: { title: 'broken' } }, } as unknown as AppScene; const result = validateAppScene(scene); expect(result.valid).toBe(false); if (!result.valid) { expect(result.errors).toContainEqual({ path: '/content/projectV2', message: '`projectV2` must contain milestones, roles and threads arrays', }); } }); test('accepts a complete v2 project', () => { const content = structuredClone(legacyPBLSceneFixture.content); if (content.type !== 'pbl' || !content.projectConfig) { throw new Error('expected legacy PBL projectConfig fixture'); } const scene = { ...pblScene(), content: { type: 'pbl', projectV2: upgradeLegacyPBLConfigToProjectV2(content.projectConfig), }, } as AppScene; expect(validateAppScene(scene)).toEqual({ valid: true }); }); test('accepts PBL content with only a legacy projectConfig', () => { expect(validateAppScene(legacyPBLSceneFixture)).toEqual({ valid: true }); }); test('keeps a historical projectConfig-only scene inside the write-validator matrix', () => { const historicalScene = structuredClone(legacyPBLSceneFixture); expect(historicalScene.content).not.toHaveProperty('projectV2'); expect(validateAppScene(historicalScene)).toEqual({ valid: true }); }); test('accepts PBL content with an empty legacy projectConfig object', () => { expect( validateAppScene({ ...pblScene(), content: { type: 'pbl', projectConfig: {} }, }), ).toEqual({ valid: true }); }); test('accepts a null projectV2 as if absent', () => { expect( validateAppScene({ ...pblScene(), content: { type: 'pbl', projectV2: null }, }), ).toEqual({ valid: true }); }); test('rejects an array projectV2', () => { const result = validateAppScene({ ...pblScene(), content: { type: 'pbl', projectV2: [] }, }); expect(result.valid).toBe(false); if (!result.valid) { expect(result.errors).toContainEqual({ path: '/content/projectV2', message: '`projectV2` must contain milestones, roles and threads arrays', }); } }); test('rejects an array projectConfig', () => { const result = validateAppScene({ ...pblScene(), content: { type: 'pbl', projectConfig: [] }, }); expect(result.valid).toBe(false); if (!result.valid) { expect(result.errors).toContainEqual({ path: '/content/projectConfig', message: '`projectConfig` must be an object', }); } }); test('rejects content/type mismatches with a clear path', () => { const result = validateAppScene({ ...interactiveScene(), content: { type: 'pbl', projectConfig: {} }, }); expect(result.valid).toBe(false); if (!result.valid) expect(result.errors).toContainEqual(expect.objectContaining({ path: '/content/type' })); }); test('accepts generated interactive HTML with an empty URL', () => { expect( validateAppScene({ ...interactiveScene(), content: { type: 'interactive', html: '
Widget
', url: '' }, }), ).toEqual({ valid: true }); }); test('accepts stored interactive widget config without a type', () => { const scene = { ...interactiveScene(), content: { type: 'interactive', html: '
Diagram
', widgetType: 'diagram', widgetConfig: { nodes: [], edges: [], revealOrder: [] }, }, } as unknown as AppScene; expect(validateAppScene(scene)).toEqual({ valid: true }); expect(validateScene(scene).valid).toBe(false); }); test('rejects a primitive widget config that would crash hydration', () => { const scene = { ...interactiveScene(), content: { type: 'interactive', html: '
Broken
', widgetType: 'diagram', widgetConfig: 'diagram', }, } as unknown as AppScene; const result = validateAppScene(scene); expect(result.valid).toBe(false); expect( result.valid === false && result.errors.some((e) => e.path === '/content/widgetConfig'), ).toBe(true); }); test('accepts an unknown widget type at the app write boundary', () => { const scene = { ...interactiveScene(), content: { type: 'interactive', html: '
Future widget
', widgetType: 'future-kind', }, } as unknown as AppScene; expect(validateAppScene(scene)).toEqual({ valid: true }); expect(validateScene(scene).valid).toBe(false); }); test('keeps canonical app interactive content compatible with the contract validator', () => { const content: InteractiveContent = { type: 'interactive', html: '
Simulation
', }; const scene: AppScene = { id: 'interactive-contract-1', stageId: 'stage-1', title: 'Contract interactive', order: 4, type: 'interactive', content, }; expect(validateScene(JSON.parse(JSON.stringify(scene)))).toEqual({ valid: true }); }); test('rejects stages carrying currentSceneId with a clear path', () => { const result = validateAppStage(stageRecord); expect(result.valid).toBe(false); if (!result.valid) { expect(result.errors).toContainEqual(expect.objectContaining({ path: '/currentSceneId' })); } }); }); type Assert = T; test('typed legacy PBL config remains assignable to app PBLContent', () => { type LegacyPBLContent = { type: 'pbl'; projectConfig: PBLProjectConfig }; const assignable: Assert = true; expect(assignable).toBe(true); }); test('app PBLContent remains assignable to the contract PBLContent', () => { const assignable: Assert = true; expect(assignable).toBe(true); }); test('SimulationConfig satisfies the contract widget config base', () => { const assignable: Assert = true; expect(assignable).toBe(true); }); describe('legacy scene canonicalization', () => { test('rebinds type, renames whiteboard, and preserves app and unknown fields', () => { const legacy = { id: 'scene-1', stageId: 'stage-1', type: 'quiz', title: 'Legacy', order: 0, content: { type: 'slide', canvas: { id: 'canvas', elements: [] } }, actions: [], whiteboard: [{ id: 'whiteboard-1', elements: [] }], multiAgent: { enabled: true, agentIds: ['teacher'] }, outlineId: 'outline-1', createdAt: 1, updatedAt: 2, appExtension: { retained: true }, } as unknown as SceneRecord & Record; const canonical = canonicalizeLegacyScene(legacy); expect(canonical.type).toBe('slide'); expect(canonical.whiteboards).toEqual(legacy.whiteboard); expect(canonical.outlineId).toBe('outline-1'); expect(canonical).toMatchObject({ multiAgent: legacy.multiAgent, createdAt: 1, updatedAt: 2, appExtension: { retained: true }, }); expect(canonical).not.toHaveProperty('whiteboard'); }); test('keeps canonical whiteboards when both aliases are present', () => { const canonicalWhiteboards = [{ id: 'canonical', elements: [] }]; const scene = canonicalizeLegacyScene({ ...slideScene(), whiteboard: [{ id: 'legacy', elements: [] }], whiteboards: canonicalWhiteboards, }); expect(scene.whiteboards).toEqual(canonicalWhiteboards); }); });