192 lines
5.0 KiB
TypeScript
192 lines
5.0 KiB
TypeScript
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.'],
|
|
});
|
|
});
|
|
});
|