556 lines
16 KiB
TypeScript
556 lines
16 KiB
TypeScript
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();
|
||
});
|
||
});
|