import { describe, expect, it } from 'vitest'; import { ProjectValidationError, assertValidProject, validateProject, } from '../../../src/project/project.validation'; import { populatedProject } from './project-fixture'; describe('project validation', () => { it('returns an isolated, typed copy for a valid version-1 project', () => { const input = populatedProject(); const result = validateProject(input); expect(result.valid).toBe(true); if (result.valid) { expect(result.project).toEqual(input); expect(result.project).not.toBe(input); } }); it('reports duplicate identities, missing references and bad ranges', () => { const project = populatedProject(); const invalid = { ...project, assets: [...project.assets, { ...project.assets[0] }], timeline: [ { ...project.timeline[0], assetId: 'missing', sourceInSeconds: 5, sourceOutSeconds: 3, crop: { x: -1, y: 0, width: 0, height: 100 }, }, ], }; const result = validateProject(invalid); expect(result.valid).toBe(false); if (!result.valid) { expect(result.issues.map((issue) => issue.code)).toEqual( expect.arrayContaining([ 'duplicate-id', 'missing-reference', 'invalid-range', 'invalid-value', ]) ); } }); it('checks clip bounds, fade bounds, chapter bounds and output streams', () => { const project = populatedProject(); const invalid = { ...project, timeline: [ { ...project.timeline[0], sourceOutSeconds: 11, audio: { enabled: true, fadeInSeconds: 8, fadeOutSeconds: 8, }, }, ], chapters: [ { id: 'chapter-1', startSeconds: 0, endSeconds: 20, title: 'Too long', }, ], output: { ...project.output, videoEnabled: false, audioEnabled: false, }, }; const result = validateProject(invalid); expect(result.valid).toBe(false); if (!result.valid) { expect(result.issues).toEqual( expect.arrayContaining([ expect.objectContaining({ code: 'invalid-range', path: 'timeline[0]', }), expect.objectContaining({ code: 'out-of-bounds', path: 'timeline[0].audio', }), expect.objectContaining({ code: 'out-of-bounds', path: 'chapters[0].endSeconds', }), expect.objectContaining({ code: 'invalid-value', path: 'output', }), ]) ); } }); it('validates persisted fade curves and chapter time-base resolution', () => { const project = populatedProject(); const invalid = { ...project, timeline: [ { ...project.timeline[0], audio: { enabled: true, fadeInSeconds: 0.2, fadeCurve: 'log', }, }, ], chapters: [ { id: 'chapter-1', startSeconds: 0, endSeconds: 0.1, title: 'Too short for this base', timeBase: '1/1', }, ], }; const result = validateProject(invalid); expect(result.valid).toBe(false); if (!result.valid) { expect(result.issues).toEqual( expect.arrayContaining([ expect.objectContaining({ code: 'invalid-value', path: 'timeline[0].audio.fadeCurve', }), expect.objectContaining({ code: 'invalid-value', path: 'chapters[0].timeBase', }), ]) ); } }); it('rejects unsupported schema versions with a useful typed exception', () => { const project = { ...populatedProject(), schemaVersion: 99 }; expect(() => assertValidProject(project)).toThrow(ProjectValidationError); const result = validateProject(project); expect(result.valid).toBe(false); if (!result.valid) { expect(result.issues[0]).toMatchObject({ code: 'unsupported-version', path: 'schemaVersion', }); } }); it('does not trust malformed normalized probe fields from an import', () => { const project = populatedProject(); const invalid = structuredClone(project) as unknown as { assets: Array<{ probe?: { streams: Array<{ width?: unknown; rotationDegrees?: unknown; }>; }; }>; }; const stream = invalid.assets[0]?.probe?.streams[0]; if (stream) { stream.width = '1920'; stream.rotationDegrees = '90'; } const result = validateProject(invalid); expect(result.valid).toBe(false); if (!result.valid) { expect(result.issues).toContainEqual( expect.objectContaining({ code: 'invalid-type', path: 'assets[0].probe.streams[0].width', }) ); expect(result.issues).toContainEqual( expect.objectContaining({ code: 'invalid-type', path: 'assets[0].probe.streams[0].rotationDegrees', }) ); } }); });