Files
av-tools/tests/unit/project/project-reducer-selectors.test.ts

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.'],
});
});
});