feat: publish av-tools 0.1.0
This commit is contained in:
555
tests/unit/advanced-operations.test.tsx
Normal file
555
tests/unit/advanced-operations.test.tsx
Normal file
@@ -0,0 +1,555 @@
|
||||
import {
|
||||
cleanup,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
AdvancedOperations,
|
||||
type AdvancedCapabilityKey,
|
||||
type AdvancedCapabilityStatus,
|
||||
type AdvancedOperationsProps,
|
||||
} from '../../src/components/AdvancedOperations';
|
||||
|
||||
const AVAILABLE: AdvancedCapabilityStatus = { available: true };
|
||||
|
||||
function capabilities(
|
||||
...keys: readonly AdvancedCapabilityKey[]
|
||||
): AdvancedOperationsProps['availability'] {
|
||||
return Object.fromEntries(keys.map((key) => [key, AVAILABLE]));
|
||||
}
|
||||
|
||||
const SOURCE: NonNullable<AdvancedOperationsProps['source']> = {
|
||||
durationSeconds: 90,
|
||||
sourceExtension: 'mp4',
|
||||
hasAudio: true,
|
||||
hasVideo: true,
|
||||
selectedAudioStreamIndex: 1,
|
||||
};
|
||||
|
||||
const EXPORT_PRESETS: NonNullable<AdvancedOperationsProps['exportPresets']> = [
|
||||
{
|
||||
id: 'mp4-h264-balanced',
|
||||
name: 'MP4 · H.264 + AAC',
|
||||
fileExtension: 'mp4',
|
||||
supportsAudio: true,
|
||||
supportsVideo: true,
|
||||
},
|
||||
{
|
||||
id: 'audio-mp3',
|
||||
name: 'MP3',
|
||||
fileExtension: 'mp3',
|
||||
supportsAudio: true,
|
||||
supportsVideo: false,
|
||||
},
|
||||
];
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('AdvancedOperations', () => {
|
||||
it('fails closed when a runtime capability was not explicitly verified', () => {
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
splitMarkers={[30]}
|
||||
onSplit={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const action = screen.getByRole('button', { name: 'Queue 2 segments' });
|
||||
expect(action).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'The required browser and FFmpeg capabilities have not been verified.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('builds a validated marker request and explains fast versus accurate mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSplit = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
splitMarkers={[60, 30]}
|
||||
exportPresets={EXPORT_PRESETS}
|
||||
availability={capabilities('split-fast', 'split-accurate')}
|
||||
onSplit={onSplit}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/boundaries may move to nearby keyframes/iu)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/practical frame accuracy/iu)).toBeInTheDocument();
|
||||
const ranges = screen.getByLabelText('Exact planned segment ranges');
|
||||
expect(within(ranges).getByText('0 s–30 s')).toBeVisible();
|
||||
expect(within(ranges).getByText('30 s–60 s')).toBeVisible();
|
||||
expect(within(ranges).getByText('60 s–90 s')).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Queue 3 segments' }));
|
||||
await waitFor(() =>
|
||||
expect(onSplit).toHaveBeenCalledWith({
|
||||
mode: 'fast',
|
||||
definition: { type: 'markers', markers: [30, 60] },
|
||||
targetExtension: 'mp4',
|
||||
})
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('radio', { name: /Accurate re-encode/iu })
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Queue 3 segments' }));
|
||||
await waitFor(() =>
|
||||
expect(onSplit).toHaveBeenLastCalledWith({
|
||||
mode: 'accurate',
|
||||
definition: { type: 'markers', markers: [30, 60] },
|
||||
targetExtension: 'mp4',
|
||||
presetId: 'mp4-h264-balanced',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('shows exact latest-batch status and individual segment failure details', () => {
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
splitMarkers={[30]}
|
||||
splitBatch={{
|
||||
planId: 'split-plan',
|
||||
segments: [
|
||||
{
|
||||
outputId: 'segment-1',
|
||||
fileName: 'source-split-001.mp4',
|
||||
startSeconds: 0,
|
||||
endSeconds: 30,
|
||||
state: 'verified',
|
||||
},
|
||||
{
|
||||
outputId: 'segment-2',
|
||||
fileName: 'source-split-002.mp4',
|
||||
startSeconds: 30,
|
||||
endSeconds: 90,
|
||||
state: 'failed',
|
||||
message: 'FFmpeg did not return this expected output.',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const status = screen.getByLabelText('Split segment status');
|
||||
expect(within(status).getByText('source-split-001.mp4')).toBeVisible();
|
||||
expect(within(status).getByText('0 s–30 s')).toBeVisible();
|
||||
expect(within(status).getByText('verified')).toBeVisible();
|
||||
expect(
|
||||
within(status).getByText('FFmpeg did not return this expected output.')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('supports keyboard tab navigation and emits typed loudness targets', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAudioAction = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
exportPresets={EXPORT_PRESETS}
|
||||
availability={capabilities(
|
||||
'loudness-measure',
|
||||
'loudness-normalize',
|
||||
'peak-normalize',
|
||||
'waveform-static',
|
||||
'waveform-peaks'
|
||||
)}
|
||||
onAudioAction={onAudioAction}
|
||||
/>
|
||||
);
|
||||
|
||||
const splitTab = screen.getByRole('tab', { name: 'Split media' });
|
||||
splitTab.focus();
|
||||
await user.keyboard('{ArrowRight}');
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'Waveform and loudness' })
|
||||
).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByLabelText(/^Target profile/iu),
|
||||
'podcast'
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Measure EBU R128' }));
|
||||
await waitFor(() =>
|
||||
expect(onAudioAction).toHaveBeenCalledWith({
|
||||
type: 'loudness-measurement',
|
||||
audioStreamIndex: 1,
|
||||
targets: {
|
||||
integratedLufs: -16,
|
||||
truePeakDbtp: -1,
|
||||
loudnessRangeLu: 11,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const normalize = screen.getByRole('button', {
|
||||
name: 'Apply measured normalization',
|
||||
});
|
||||
expect(normalize).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Run the first-pass loudness measurement before normalization.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses an audio-only preset for measured normalization', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAudioAction = vi.fn();
|
||||
const measurement = {
|
||||
inputIntegratedLufs: -19.7,
|
||||
inputTruePeakDbtp: -2.1,
|
||||
inputLoudnessRangeLu: 4.2,
|
||||
inputThresholdLufs: -30,
|
||||
targetOffsetLu: -0.3,
|
||||
};
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
exportPresets={EXPORT_PRESETS}
|
||||
loudnessMeasurement={measurement}
|
||||
availability={capabilities('loudness-normalize')}
|
||||
onAudioAction={onAudioAction}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('tab', { name: 'Waveform and loudness' })
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Apply measured normalization' })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onAudioAction).toHaveBeenCalledWith({
|
||||
type: 'loudness-normalization',
|
||||
audioStreamIndex: 1,
|
||||
targets: {
|
||||
integratedLufs: -14,
|
||||
truePeakDbtp: -1,
|
||||
loudnessRangeLu: 11,
|
||||
},
|
||||
measurement,
|
||||
presetId: 'audio-mp3',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps unverified contact-sheet labels off and emits a bounded series request', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onThumbnailSeries = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
availability={capabilities('thumbnail-series', 'contact-sheet')}
|
||||
imageFormatAvailability={{ jpeg: AVAILABLE }}
|
||||
onThumbnailSeries={onThumbnailSeries}
|
||||
onContactSheet={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('tab', {
|
||||
name: 'Thumbnails and contact sheets',
|
||||
})
|
||||
);
|
||||
expect(screen.getByLabelText('Timestamp labels')).toBeDisabled();
|
||||
expect(screen.getByLabelText('Source filename label')).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Labels stay off until drawtext and a safe local font are verified.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Generate thumbnail series' })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onThumbnailSeries).toHaveBeenCalledWith({
|
||||
distribution: { type: 'evenly-spaced', count: 12 },
|
||||
width: 480,
|
||||
format: 'jpeg',
|
||||
quality: 85,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('classifies bitmap subtitles conservatively before enabling extraction', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubtitleAction = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
subtitleStreams={[
|
||||
{ index: 2, codec: 'dvd_subtitle', language: 'deu' },
|
||||
{ index: 3, codec: 'subrip', language: 'eng' },
|
||||
]}
|
||||
availability={capabilities('subtitle-extract')}
|
||||
onSubtitleAction={onSubtitleAction}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Subtitle operations' }));
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Extract subtitle' })
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(/bitmap subtitle extraction is not text conversion/iu)
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.selectOptions(screen.getByLabelText('Subtitle stream'), '3');
|
||||
await user.click(screen.getByRole('button', { name: 'Extract subtitle' }));
|
||||
await waitFor(() =>
|
||||
expect(onSubtitleAction).toHaveBeenCalledWith({
|
||||
type: 'extract',
|
||||
stream: { index: 3, codec: 'subrip', language: 'eng' },
|
||||
outputFormat: 'srt',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('retains validated stream metadata JSON for apply and export', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMetadataAction = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={SOURCE}
|
||||
availability={capabilities('metadata-write')}
|
||||
onMetadataAction={onMetadataAction}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Metadata policies' }));
|
||||
await user.upload(
|
||||
screen.getByLabelText('Import metadata JSON'),
|
||||
new File(
|
||||
[
|
||||
JSON.stringify({
|
||||
format: { title: 'Imported title', genre: 'Synthetic' },
|
||||
streams: {
|
||||
1: { language: 'deu', title: 'German dialogue' },
|
||||
},
|
||||
}),
|
||||
],
|
||||
'metadata.json',
|
||||
{ type: 'application/json' }
|
||||
)
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onMetadataAction).toHaveBeenCalledWith({
|
||||
type: 'import',
|
||||
edits: {
|
||||
format: { title: 'Imported title', genre: 'Synthetic' },
|
||||
streams: {
|
||||
1: { language: 'deu', title: 'German dialogue' },
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByText(/1 stream metadata edit loaded/iu)).toBeVisible();
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Apply metadata policies' })
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(onMetadataAction).toHaveBeenLastCalledWith({
|
||||
type: 'apply',
|
||||
edits: {
|
||||
format: { genre: 'Synthetic', title: 'Imported title' },
|
||||
streams: {
|
||||
1: { language: 'deu', title: 'German dialogue' },
|
||||
},
|
||||
},
|
||||
sourceMetadataPolicy: 'copy',
|
||||
chapterPolicy: 'keep',
|
||||
coverArtPolicy: 'keep',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('edits probed stream language, title, and typed dispositions directly', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMetadataAction = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={{ ...SOURCE, sourceId: 'source-a' }}
|
||||
metadataStreams={[
|
||||
{
|
||||
index: 1,
|
||||
type: 'audio',
|
||||
codec: 'aac',
|
||||
language: 'eng',
|
||||
title: 'Original',
|
||||
disposition: { default: false, forced: false },
|
||||
},
|
||||
]}
|
||||
availability={capabilities('metadata-write')}
|
||||
onMetadataAction={onMetadataAction}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Metadata policies' }));
|
||||
const language = screen.getByLabelText('Stream #1 language');
|
||||
await user.clear(language);
|
||||
await user.type(language, 'deu');
|
||||
const title = screen.getByLabelText('Stream #1 title');
|
||||
await user.clear(title);
|
||||
await user.type(title, 'German dialogue');
|
||||
await user.click(screen.getByLabelText('Default stream #1'));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Apply metadata policies' })
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onMetadataAction).toHaveBeenCalledWith({
|
||||
type: 'apply',
|
||||
edits: {
|
||||
format: {},
|
||||
streams: {
|
||||
1: { language: 'deu', title: 'German dialogue' },
|
||||
},
|
||||
dispositions: { 1: ['default'] },
|
||||
},
|
||||
sourceMetadataPolicy: 'copy',
|
||||
chapterPolicy: 'keep',
|
||||
coverArtPolicy: 'keep',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not clear untouched source container tags', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMetadataAction = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
source={{ ...SOURCE, sourceId: 'source-tags' }}
|
||||
metadataFormatTags={{
|
||||
title: 'Original title',
|
||||
artist: 'Original artist',
|
||||
album: 'Original album',
|
||||
}}
|
||||
availability={capabilities('metadata-write')}
|
||||
onMetadataAction={onMetadataAction}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Metadata policies' }));
|
||||
expect(screen.getByLabelText('Artist')).toHaveValue('Original artist');
|
||||
const title = screen.getByLabelText('Title');
|
||||
await user.clear(title);
|
||||
await user.type(title, 'Replacement title');
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Apply metadata policies' })
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onMetadataAction).toHaveBeenCalledWith({
|
||||
type: 'apply',
|
||||
edits: { format: { title: 'Replacement title' } },
|
||||
sourceMetadataPolicy: 'copy',
|
||||
chapterPolicy: 'keep',
|
||||
coverArtPolicy: 'keep',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('creates only an allowlisted typed user preset', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onUserPresetAction = vi.fn();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
availability={capabilities('preset-storage')}
|
||||
onUserPresetAction={onUserPresetAction}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'User export presets' }));
|
||||
const editor = screen
|
||||
.getByRole('heading', { name: 'Create typed preset' })
|
||||
.closest('section');
|
||||
expect(editor).not.toBeNull();
|
||||
const editorQueries = within(editor as HTMLElement);
|
||||
await user.type(editorQueries.getByLabelText('Safe ID'), 'my-web-export');
|
||||
await user.type(editorQueries.getByLabelText('Name'), 'My web export');
|
||||
await user.click(
|
||||
editorQueries.getByRole('button', { name: 'Create preset' })
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onUserPresetAction).toHaveBeenCalledWith({
|
||||
type: 'create',
|
||||
preset: {
|
||||
schemaVersion: 1,
|
||||
id: 'my-web-export',
|
||||
name: 'My web export',
|
||||
kind: 'video',
|
||||
container: 'mp4',
|
||||
fileExtension: 'mp4',
|
||||
video: {
|
||||
codec: 'libx264',
|
||||
qualityMode: 'balanced',
|
||||
crf: 23,
|
||||
},
|
||||
audio: {
|
||||
codec: 'aac',
|
||||
bitrateKbps: 160,
|
||||
sampleRate: 48000,
|
||||
channels: 2,
|
||||
},
|
||||
metadataPolicy: 'copy',
|
||||
chapterPolicy: 'keep',
|
||||
subtitlePolicy: 'none',
|
||||
fastStart: true,
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(JSON.stringify(onUserPresetAction.mock.calls)).not.toMatch(
|
||||
/(?:args|filter|protocol|inputPath)/u
|
||||
);
|
||||
});
|
||||
|
||||
it('does not offer preset policies that require explicit advanced inputs', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdvancedOperations
|
||||
availability={capabilities('preset-storage')}
|
||||
onUserPresetAction={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'User export presets' }));
|
||||
const editor = screen
|
||||
.getByRole('heading', { name: 'Create typed preset' })
|
||||
.closest('section');
|
||||
expect(editor).not.toBeNull();
|
||||
const editorQueries = within(editor as HTMLElement);
|
||||
|
||||
expect(
|
||||
within(editorQueries.getByLabelText('Metadata')).queryByRole('option', {
|
||||
name: 'Apply edits',
|
||||
})
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(editorQueries.getByLabelText('Chapters')).queryByRole('option', {
|
||||
name: 'Replace',
|
||||
})
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(editorQueries.getByLabelText('Subtitles')).queryByRole('option', {
|
||||
name: 'Burn in',
|
||||
})
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
editorQueries.getByText(/remain explicit operations/iu)
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user