Files
av-tools/tests/unit/components/structural-operations.test.tsx

427 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
StructuralOperations,
type StructuralCapabilityKey,
type StructuralCapabilityStatus,
type StructuralOperationsProps,
} from '../../../src/components/StructuralOperations';
const AVAILABLE: StructuralCapabilityStatus = { available: true };
const SELECTED_CLIP: NonNullable<StructuralOperationsProps['selectedClip']> = {
id: 'clip-a',
label: 'Opening shot',
sourceInSeconds: 5.25,
sourceOutSeconds: 18.75,
sourceDurationSeconds: 60,
sourceExtension: '.MP4',
hasAudio: true,
hasVideo: true,
};
const TIMELINE: NonNullable<StructuralOperationsProps['timeline']> = {
clips: [
{
id: 'clip-a',
label: 'Opening shot',
hasAudio: true,
hasVideo: true,
hasSubtitles: false,
},
{
id: 'clip-b',
label: 'Silent cutaway',
hasAudio: false,
hasVideo: true,
hasSubtitles: false,
},
],
fastTargetExtension: 'mp4',
fastTargetMuxer: 'mp4',
fastCompatibilityDiagnostics: [],
};
const PRESETS: NonNullable<StructuralOperationsProps['exportPresets']> = [
{
id: 'video-ready',
name: 'MP4 · H.264 + AAC',
fileExtension: 'mp4',
supportsAudio: true,
supportsVideo: true,
},
{
id: 'video-disabled',
name: 'WebM · VP9 + Opus',
fileExtension: 'webm',
supportsAudio: true,
supportsVideo: true,
disabledReason: 'The Opus encoder is not available.',
},
{
id: 'audio-only',
name: 'MP3 audio',
fileExtension: 'mp3',
supportsAudio: true,
supportsVideo: false,
},
];
function capabilities(
...keys: readonly StructuralCapabilityKey[]
): StructuralOperationsProps['availability'] {
return Object.fromEntries(keys.map((key) => [key, AVAILABLE]));
}
afterEach(cleanup);
describe('StructuralOperations', () => {
it('uses the selected clip range for a fast stream-copy trim', async () => {
const user = userEvent.setup();
const onTrim = vi.fn();
render(
<StructuralOperations
selectedClip={SELECTED_CLIP}
availability={capabilities('trim-fast')}
onTrim={onTrim}
/>
);
expect(screen.getByText('00:00:05.25000:00:18.750')).toBeInTheDocument();
expect(
screen.getByText(/first frame may move to a nearby keyframe/iu)
).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Export fast trim' }));
await waitFor(() =>
expect(onTrim).toHaveBeenCalledWith({
operation: 'trim',
mode: 'fast',
clipId: 'clip-a',
startSeconds: 5.25,
endSeconds: 18.75,
targetExtension: 'mp4',
})
);
});
it('requires and emits an available typed preset for an accurate trim', async () => {
const user = userEvent.setup();
const onTrim = vi.fn();
render(
<StructuralOperations
selectedClip={SELECTED_CLIP}
exportPresets={PRESETS}
availability={capabilities('trim-accurate')}
onTrim={onTrim}
/>
);
await user.click(
screen.getByRole('radio', { name: /Accurate re-encode/iu })
);
const action = screen.getByRole('button', { name: 'Export exact trim' });
expect(action).toBeDisabled();
expect(
screen.getByText(
'Choose an available typed preset for the accurate trim.'
)
).toBeInTheDocument();
const preset = screen.getByLabelText('Accurate trim preset');
expect(
screen.getByRole('option', { name: /WebM · VP9 \+ Opus/iu })
).toBeDisabled();
expect(screen.getByRole('option', { name: /MP3 audio/iu })).toBeDisabled();
await user.selectOptions(preset, 'video-ready');
await user.click(action);
await waitFor(() =>
expect(onTrim).toHaveBeenCalledWith({
operation: 'trim',
mode: 'accurate',
clipId: 'clip-a',
startSeconds: 5.25,
endSeconds: 18.75,
presetId: 'video-ready',
})
);
});
it('fails closed when a capability was not explicitly verified', () => {
render(
<StructuralOperations selectedClip={SELECTED_CLIP} onTrim={vi.fn()} />
);
expect(
screen.getByRole('button', { name: 'Export fast trim' })
).toBeDisabled();
expect(
screen.getByText(
'The required browser and FFmpeg capabilities have not been verified.'
)
).toBeInTheDocument();
});
it('shows concrete incompatibilities and blocks fast concat', () => {
render(
<StructuralOperations
timeline={{
...TIMELINE,
fastCompatibilityDiagnostics: [
{
code: 'stream-property',
severity: 'error',
message: 'Clip 2 has incompatible width: 1280 instead of 1920.',
path: 'sources.1.streams.0.width',
},
],
}}
availability={capabilities('concat-fast')}
onConcat={vi.fn()}
/>
);
expect(
screen.getByRole('heading', { name: 'Fast compatibility failed' })
).toBeInTheDocument();
expect(
screen.getByText('Clip 2 has incompatible width: 1280 instead of 1920.')
).toBeInTheDocument();
expect(
screen.getByText('Checked field: sources.1.streams.0.width')
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Export fast concat' })
).toBeDisabled();
});
it('emits the verified sequential clip order for fast concat', async () => {
const user = userEvent.setup();
const onConcat = vi.fn();
render(
<StructuralOperations
timeline={TIMELINE}
availability={capabilities('concat-fast')}
onConcat={onConcat}
/>
);
expect(
screen.getByRole('heading', { name: 'Fast compatibility passed' })
).toBeInTheDocument();
await user.click(
screen.getByRole('button', { name: 'Export fast concat' })
);
await waitFor(() =>
expect(onConcat).toHaveBeenCalledWith({
operation: 'concat',
mode: 'fast',
clipIds: ['clip-a', 'clip-b'],
targetExtension: 'mp4',
targetMuxer: 'mp4',
})
);
});
it('requires an explicit missing-audio policy for normalized concat', async () => {
const user = userEvent.setup();
const onConcat = vi.fn();
render(
<StructuralOperations
timeline={TIMELINE}
exportPresets={PRESETS}
availability={capabilities('concat-normalized')}
onConcat={onConcat}
/>
);
expect(screen.getByText('No audio: Silent cutaway')).toBeInTheDocument();
await user.click(
screen.getByRole('radio', { name: /Normalized re-encode/iu })
);
await user.selectOptions(
screen.getByLabelText('Normalized concat preset'),
'video-ready'
);
const action = screen.getByRole('button', {
name: 'Export normalized concat',
});
expect(action).toBeDisabled();
expect(
screen.getByText('Choose an explicit missing-audio policy.')
).toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: /Insert silence/iu }));
expect(action).toBeDisabled();
expect(
screen.getByText('Choose an explicit subtitle policy.')
).toBeInTheDocument();
await user.click(
screen.getByRole('radio', { name: /Reject subtitle input/iu })
);
await user.click(action);
await waitFor(() =>
expect(onConcat).toHaveBeenCalledWith({
operation: 'concat',
mode: 'normalized',
clipIds: ['clip-a', 'clip-b'],
presetId: 'video-ready',
missingAudioPolicy: 'insert-silence',
subtitlePolicy: 'reject',
crossfadeSeconds: 0,
})
);
});
it('requires destructive subtitle loss to be explicit for normalized concat', async () => {
const user = userEvent.setup();
const onConcat = vi.fn();
render(
<StructuralOperations
timeline={{
...TIMELINE,
clips: TIMELINE.clips.map((clip, index) =>
index === 0 ? { ...clip, hasSubtitles: true } : clip
),
}}
exportPresets={PRESETS}
availability={capabilities('concat-normalized')}
onConcat={onConcat}
/>
);
await user.click(
screen.getByRole('radio', { name: /Normalized re-encode/iu })
);
await user.selectOptions(
screen.getByLabelText('Normalized concat preset'),
'video-ready'
);
await user.click(screen.getByRole('radio', { name: /Insert silence/iu }));
await user.click(
screen.getByRole('radio', { name: /Reject subtitle input/iu })
);
const action = screen.getByRole('button', {
name: 'Export normalized concat',
});
expect(action).toBeDisabled();
expect(
screen.getByText(/sequence contains subtitles.*explicitly drop all/iu)
).toBeVisible();
await user.click(
screen.getByRole('radio', { name: /Drop all subtitles/iu })
);
await user.click(action);
await waitFor(() =>
expect(onConcat).toHaveBeenCalledWith({
operation: 'concat',
mode: 'normalized',
clipIds: ['clip-a', 'clip-b'],
presetId: 'video-ready',
missingAudioPolicy: 'insert-silence',
subtitlePolicy: 'drop-all',
crossfadeSeconds: 0,
})
);
});
it('emits a bounded crossfade only after its filters are verified', async () => {
const user = userEvent.setup();
const onConcat = vi.fn();
render(
<StructuralOperations
timeline={{
...TIMELINE,
clips: TIMELINE.clips.map((clip) => ({
...clip,
durationSeconds: 3,
})),
}}
exportPresets={PRESETS}
availability={capabilities('concat-normalized', 'concat-crossfade')}
onConcat={onConcat}
/>
);
await user.click(
screen.getByRole('radio', { name: /Normalized re-encode/iu })
);
await user.selectOptions(
screen.getByLabelText('Normalized concat preset'),
'video-ready'
);
await user.click(screen.getByRole('radio', { name: /Insert silence/iu }));
await user.click(
screen.getByRole('radio', { name: /Reject subtitle input/iu })
);
await user.clear(screen.getByLabelText(/^Crossfade duration/iu));
await user.type(screen.getByLabelText(/^Crossfade duration/iu), '0.5');
await user.click(
screen.getByRole('button', { name: 'Export normalized concat' })
);
await waitFor(() =>
expect(onConcat).toHaveBeenCalledWith({
operation: 'concat',
mode: 'normalized',
clipIds: ['clip-a', 'clip-b'],
presetId: 'video-ready',
missingAudioPolicy: 'insert-silence',
subtitlePolicy: 'reject',
crossfadeSeconds: 0.5,
})
);
});
it('treats an absent fast compatibility result as unchecked', () => {
render(
<StructuralOperations
timeline={{ ...TIMELINE, fastCompatibilityDiagnostics: undefined }}
availability={capabilities('concat-fast')}
onConcat={vi.fn()}
/>
);
expect(
screen.getByRole('heading', { name: 'Fast compatibility not checked' })
).toBeInTheDocument();
expect(
screen.getByText(
'Fast concat remains disabled until every input stream is checked.'
)
).toBeInTheDocument();
});
it('surfaces a synchronous runner rejection and releases pending state', async () => {
const user = userEvent.setup();
render(
<StructuralOperations
selectedClip={SELECTED_CLIP}
availability={capabilities('trim-fast')}
onTrim={() => {
throw new Error('Queue capacity was reached.');
}}
/>
);
const action = screen.getByRole('button', { name: 'Export fast trim' });
await user.click(action);
expect(
await screen.findByText(
'The operation could not be queued: Queue capacity was reached.'
)
).toBeInTheDocument();
await waitFor(() => expect(action).toBeEnabled());
});
});