Files
av-tools/tests/unit/advanced-operations.test.tsx

622 lines
18 KiB
TypeScript
Raw 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,
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 s30 s')).toBeVisible();
expect(within(ranges).getByText('30 s60 s')).toBeVisible();
expect(within(ranges).getByText('60 s90 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 s30 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('emits bounded scene analysis settings for series and contact sheets', async () => {
const user = userEvent.setup();
const onThumbnailSeries = vi.fn();
const onContactSheet = vi.fn();
render(
<AdvancedOperations
source={SOURCE}
availability={capabilities(
'thumbnail-series',
'contact-sheet',
'scene-change-analysis',
'contact-sheet-labels'
)}
imageFormatAvailability={{ jpeg: AVAILABLE }}
onThumbnailSeries={onThumbnailSeries}
onContactSheet={onContactSheet}
/>
);
await user.click(
screen.getByRole('tab', {
name: 'Thumbnails and contact sheets',
})
);
const selectionFields = screen.getAllByLabelText('Frame selection');
await user.selectOptions(selectionFields[0]!, 'scene-changes');
await user.clear(screen.getByLabelText('Scene sensitivity threshold'));
await user.type(
screen.getByLabelText('Scene sensitivity threshold'),
'0.42'
);
await user.click(
screen.getByRole('button', { name: 'Generate thumbnail series' })
);
await waitFor(() =>
expect(onThumbnailSeries).toHaveBeenCalledWith({
distribution: {
type: 'scene-changes',
count: 12,
threshold: 0.42,
},
width: 480,
format: 'jpeg',
quality: 85,
})
);
await user.selectOptions(selectionFields[1]!, 'scene-changes');
await user.clear(screen.getByLabelText('Contact-sheet scene threshold'));
await user.type(
screen.getByLabelText('Contact-sheet scene threshold'),
'0.3'
);
await user.click(
screen.getByRole('button', { name: 'Generate contact sheet' })
);
await waitFor(() =>
expect(onContactSheet).toHaveBeenCalledWith(
expect.objectContaining({
frameCount: 12,
selection: { type: 'scene-changes', threshold: 0.3 },
})
)
);
});
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();
});
});