import { describe, expect, it } from 'vitest'; import { ProjectReducerError, projectReducer, } from '../../../src/project/project.reducer'; import { selectCanExport, selectMissingAssets, selectTimelineDuration, selectTimelineEntries, selectUnusedAssets, } from '../../../src/project/project.selectors'; import { createEmptyProject } from '../../../src/project/project.schema'; import { CREATED_AT, UPDATED_AT, mediaAsset, populatedProject, } from './project-fixture'; describe('project reducer', () => { it('adds assets and sequential clips without mutating prior state', () => { const empty = createEmptyProject({ id: 'p', timestamp: CREATED_AT, }); const withAsset = projectReducer(empty, { type: 'asset/add', asset: mediaAsset(), updatedAt: UPDATED_AT, }); const withClip = projectReducer(withAsset, { type: 'clip/add', clip: { id: 'clip-1', assetId: 'asset-1', sourceInSeconds: 0, sourceOutSeconds: 2, }, updatedAt: UPDATED_AT, }); expect(empty.assets).toEqual([]); expect(withAsset.timeline).toEqual([]); expect(withClip.timeline).toHaveLength(1); expect(withClip.updatedAt).toBe(UPDATED_AT); }); it('reorders clips deterministically and returns identity for a no-op', () => { const project = { ...populatedProject(), timeline: [ populatedProject().timeline[0]!, { id: 'clip-2', assetId: 'asset-1', sourceInSeconds: 4.5, sourceOutSeconds: 6, }, ], }; const moved = projectReducer(project, { type: 'clip/move', clipId: 'clip-2', toIndex: 0, updatedAt: UPDATED_AT, }); expect(moved.timeline.map((clip) => clip.id)).toEqual(['clip-2', 'clip-1']); expect( projectReducer(moved, { type: 'clip/move', clipId: 'clip-2', toIndex: 0, updatedAt: UPDATED_AT, }) ).toBe(moved); }); it('removes dependent clips/subtitles and clears stale UI selection', () => { const project = { ...populatedProject(), subtitles: [ { id: 'subtitle-1', assetId: 'asset-1', mode: 'mux' as const }, ], }; const result = projectReducer(project, { type: 'asset/remove', assetId: 'asset-1', updatedAt: UPDATED_AT, }); expect(result.assets).toEqual([]); expect(result.timeline).toEqual([]); expect(result.subtitles).toEqual([]); expect(result.ui).toMatchObject({ selectedAssetId: undefined, selectedClipId: undefined, }); }); it('guards duplicate IDs, missing assets and invalid ranges', () => { const project = populatedProject(); expect(() => projectReducer(project, { type: 'asset/add', asset: mediaAsset(), updatedAt: UPDATED_AT, }) ).toThrow(ProjectReducerError); expect(() => projectReducer(project, { type: 'clip/add', clip: { id: 'bad', assetId: 'missing', sourceInSeconds: 0, sourceOutSeconds: 1, }, updatedAt: UPDATED_AT, }) ).toThrow('unknown asset'); expect(() => projectReducer(project, { type: 'clip/update', clipId: 'clip-1', changes: { sourceOutSeconds: 0 }, updatedAt: UPDATED_AT, }) ).toThrow(); }); it('does not mark the document updated for transient UI state', () => { const project = populatedProject(); const result = projectReducer(project, { type: 'ui/set', ui: { inspectorPanel: 'output' }, }); expect(result.updatedAt).toBe(project.updatedAt); }); }); describe('project selectors', () => { it('derives timeline positions and total duration for a sequential edit', () => { const project = { ...populatedProject(), timeline: [ populatedProject().timeline[0]!, { id: 'clip-2', assetId: 'asset-1', sourceInSeconds: 5, sourceOutSeconds: 6.25, }, ], }; expect(selectTimelineDuration(project)).toBe(4.75); expect(selectTimelineEntries(project)).toMatchObject([ { timelineStartSeconds: 0, timelineEndSeconds: 3.5, durationSeconds: 3.5, }, { timelineStartSeconds: 3.5, timelineEndSeconds: 4.75, durationSeconds: 1.25, }, ]); }); it('identifies missing, unused and export-blocking sources', () => { const project = { ...populatedProject(), assets: [ mediaAsset({ sourceStatus: 'missing' }), mediaAsset({ id: 'asset-unused', originalName: 'unused.wav' }), ], }; expect(selectMissingAssets(project).map((asset) => asset.id)).toEqual([ 'asset-1', ]); expect(selectUnusedAssets(project).map((asset) => asset.id)).toEqual([ 'asset-unused', ]); expect(selectCanExport(project)).toEqual({ canExport: false, reasons: ['1 referenced source file is missing.'], }); }); });