import { describe, expect, it } from 'vitest'; import { ProjectImportError, importProjectDocument, projectDocumentFileName, serializeProject, } from '../../../src/project/project.serialization'; import { ProjectValidationError } from '../../../src/project/project.validation'; import { populatedProject } from './project-fixture'; describe('project serialization', () => { it('serializes readable, versioned JSON and can omit transient UI state', () => { const project = populatedProject(); const json = serializeProject(project, { includeUiState: false }); const parsed = JSON.parse(json) as Record; expect(json.endsWith('\n')).toBe(true); expect(parsed.schemaVersion).toBe(1); expect(parsed.ui).toBeUndefined(); expect(project.ui).toBeDefined(); }); it('validates before serializing', () => { const project = { ...populatedProject(), output: { ...populatedProject().output, fileName: '' }, }; expect(() => serializeProject(project)).toThrow(ProjectValidationError); }); it('imports a round trip but correctly marks browser sources missing', () => { const project = populatedProject(); const result = importProjectDocument(serializeProject(project)); expect(result.fromVersion).toBe(1); expect(result.project.assets[0]?.sourceStatus).toBe('missing'); expect(result.warnings).toContainEqual( expect.objectContaining({ code: 'source-reattachment-required' }) ); }); it('can retain runtime source status only when explicitly requested', () => { const project = populatedProject(); const result = importProjectDocument(serializeProject(project), { markSourcesMissing: false, }); expect(result.project).toEqual(project); expect(result.warnings).toEqual([]); }); it('round-trips fade curves and editable chapter time bases', () => { const project = populatedProject(); const persisted = { ...project, timeline: [ { ...project.timeline[0]!, audio: { enabled: true, fadeInSeconds: 0.2, fadeCurve: 'qsin' as const, }, video: { enabled: true, fadeOutSeconds: 0.2, fadeCurve: 'exp' as const, }, }, ], chapters: [ { id: 'chapter-1', title: 'Intro', startSeconds: 0, endSeconds: 1, timeBase: '1/48000', }, ], }; const result = importProjectDocument(serializeProject(persisted), { markSourcesMissing: false, }); expect(result.project.timeline[0]?.audio?.fadeCurve).toBe('qsin'); expect(result.project.timeline[0]?.video?.fadeCurve).toBe('exp'); expect(result.project.chapters[0]?.timeBase).toBe('1/48000'); }); it('still reports reattachment when an exported project was already missing sources', () => { const project = { ...populatedProject(), assets: [ { ...populatedProject().assets[0]!, sourceStatus: 'missing' as const }, ], }; const result = importProjectDocument(serializeProject(project)); expect(result.warnings).toContainEqual( expect.objectContaining({ code: 'source-reattachment-required' }) ); }); it('migrates the documented legacy version zero shape', () => { const legacy = { schemaVersion: 0, id: 'legacy-project', title: 'Legacy project', createdAt: '2025-01-01T00:00:00.000Z', updatedAt: '2025-01-01T00:00:00.000Z', media: [ { id: 'legacy-source', fileName: 'legacy.mp4', size: 42, lastModified: 100, type: 'video/mp4', sourceStatus: 'attached', }, ], clips: [ { id: 'legacy-clip', assetId: 'legacy-source', inSeconds: 0, outSeconds: 2, }, ], }; const result = importProjectDocument(JSON.stringify(legacy)); expect(result.fromVersion).toBe(0); expect(result.project).toMatchObject({ schemaVersion: 1, name: 'Legacy project', assets: [ { originalName: 'legacy.mp4', mimeType: 'video/mp4', sourceStatus: 'missing', }, ], timeline: [ { sourceInSeconds: 0, sourceOutSeconds: 2, }, ], }); expect(result.warnings.map((warning) => warning.code)).toEqual( expect.arrayContaining([ 'renamed-field', 'defaulted-field', 'source-reattachment-required', ]) ); }); it('rejects malformed, unversioned, future and oversized documents', () => { expect(() => importProjectDocument('{')).toThrow(ProjectImportError); expect(() => importProjectDocument('{}')).toThrow( 'does not declare a schema version' ); expect(() => importProjectDocument('{"schemaVersion":999}')).toThrow( 'is not supported' ); expect(() => importProjectDocument('{"schemaVersion":1}', { maximumBytes: 5 }) ).toThrow('exceeds'); }); it('creates a safe project-document filename', () => { expect( projectDocumentFileName({ ...populatedProject(), name: '../../My Project', }) ).toBe('My-Project.avproject.json'); }); });