feat: publish av-tools 0.1.0
This commit is contained in:
376
tests/unit/components/structural-operations.test.tsx
Normal file
376
tests/unit/components/structural-operations.test.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
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.250–00: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',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user