Files
av-tools/tests/unit/components/quick-convert.test.tsx

336 lines
9.2 KiB
TypeScript

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 type { ImportedMediaAsset } from '../../../src/app/application-state';
import {
QuickConvert,
type QuickExportConfiguration,
} from '../../../src/components/QuickConvert';
import type {
EngineState,
FFmpegCapabilities,
} from '../../../src/ffmpeg/ffmpeg.types';
import { BUILT_IN_PRESETS, validateUserPreset } from '../../../src/presets';
import type { UserExportPreset } from '../../../src/presets';
afterEach(cleanup);
describe('QuickConvert', () => {
it('labels a submission honestly when another operation is active', () => {
render(
<QuickConvert
asset={createAsset('asset-a', 'source-a.mp4')}
engineState={readyEngine()}
busy={false}
queueing
onExport={vi.fn()}
/>
);
expect(screen.getByRole('button', { name: 'Add to queue' })).toBeEnabled();
});
it('preserves an explicitly empty stream selection and blocks export', async () => {
const user = userEvent.setup();
const onExport = vi.fn();
const asset = createAsset('asset-a', 'source-a.mp4');
const { rerender } = render(
<QuickConvert
asset={asset}
engineState={readyEngine()}
busy={false}
onExport={onExport}
/>
);
const videoStream = screen.getByRole('checkbox', {
name: /video.*#0/iu,
});
const audioStream = screen.getByRole('checkbox', {
name: /audio.*#1/iu,
});
await user.click(videoStream);
await user.click(audioStream);
expect(videoStream).not.toBeChecked();
expect(audioStream).not.toBeChecked();
expect(
screen.getByText('Select at least one output stream.')
).toBeVisible();
expect(screen.getByRole('button', { name: 'Convert' })).toBeDisabled();
rerender(
<QuickConvert
asset={{ ...asset }}
engineState={readyEngine()}
busy={false}
onExport={onExport}
/>
);
expect(
screen.getByRole('checkbox', { name: /video.*#0/iu })
).not.toBeChecked();
expect(
screen.getByRole('checkbox', { name: /audio.*#1/iu })
).not.toBeChecked();
expect(onExport).not.toHaveBeenCalled();
});
it('starts a newly selected asset with all of its streams selected', async () => {
const user = userEvent.setup();
const firstAsset = createAsset('asset-a', 'source-a.mp4');
const secondAsset = createAsset('asset-b', 'source-b.mp4');
const { rerender } = render(
<QuickConvert
asset={firstAsset}
engineState={readyEngine()}
busy={false}
onExport={vi.fn()}
/>
);
await user.click(screen.getByRole('checkbox', { name: /video.*#0/iu }));
expect(
screen.getByRole('checkbox', { name: /video.*#0/iu })
).not.toBeChecked();
rerender(
<QuickConvert
asset={secondAsset}
engineState={readyEngine()}
busy={false}
onExport={vi.fn()}
/>
);
expect(screen.getByText('source-b.mp4')).toBeVisible();
expect(screen.getByRole('checkbox', { name: /video.*#0/iu })).toBeChecked();
expect(screen.getByRole('checkbox', { name: /audio.*#1/iu })).toBeChecked();
});
it('disables unavailable presets and incompatible remux targets with reasons', async () => {
const user = userEvent.setup();
render(
<QuickConvert
asset={createAsset('asset-a', 'source-a.mp4')}
engineState={readyEngine()}
busy={false}
onExport={vi.fn()}
/>
);
expect(
screen.getByRole('option', {
name: /WebM · VP8 \+ Vorbis unavailable: Missing muxers: webm/iu,
})
).toBeDisabled();
await user.click(screen.getByRole('button', { name: 'Fast remux' }));
expect(
screen.getByText('The loaded core is missing muxer matroska.')
).toBeVisible();
expect(screen.getByRole('button', { name: 'Remux' })).toBeDisabled();
expect(
screen.getByRole('option', {
name: /WebM.*unavailable: missing muxer webm.*video codec h264.*audio codec aac/iu,
})
).toBeDisabled();
});
it('exposes a validated user preset and returns it unchanged on export', async () => {
const user = userEvent.setup();
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();
const userPreset = validatedUserPreset();
render(
<QuickConvert
asset={createAsset('asset-a', 'source-a.mp4')}
engineState={readyEngine()}
busy={false}
presets={[...BUILT_IN_PRESETS, userPreset]}
onExport={onExport}
/>
);
const presetSelect = screen.getByRole('combobox', {
name: /^Export preset/iu,
});
expect(
screen.getByRole('option', { name: 'My local H.264 export' })
).toBeInTheDocument();
await user.selectOptions(presetSelect, userPreset.id);
expect(
screen.getByText('A validated user-created local export preset.')
).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Convert' }));
await waitFor(() => expect(onExport).toHaveBeenCalledOnce());
expect(onExport).toHaveBeenCalledWith({
operation: 'convert',
preset: userPreset,
remuxContainer: 'matroska',
streamSelection: {
video: [0],
audio: [1],
subtitles: [],
attachments: [],
data: [],
},
removeMetadata: false,
removeChapters: false,
});
});
it('disables unsupported auxiliary streams for conversion and maps them for Matroska remux', async () => {
const user = userEvent.setup();
const onExport = vi.fn<(configuration: QuickExportConfiguration) => void>();
const asset = createAsset('asset-a', 'source-a.mkv');
asset.probe?.streams.push(
{
index: 2,
type: 'attachment',
codecName: 'ttf',
disposition: {},
tags: {},
},
{
index: 3,
type: 'data',
codecName: 'bin_data',
disposition: {},
tags: {},
}
);
render(
<QuickConvert
asset={asset}
engineState={readyEngine(['mp4', 'matroska'])}
busy={false}
onExport={onExport}
/>
);
const attachment = screen.getByRole('checkbox', {
name: /attachment.*#2/iu,
});
const data = screen.getByRole('checkbox', { name: /data.*#3/iu });
expect(attachment).toBeDisabled();
expect(attachment).not.toBeChecked();
expect(data).toBeDisabled();
expect(data).not.toBeChecked();
expect(
screen.getByText(/attachment streams are only preserved.*Matroska/iu)
).toBeVisible();
expect(
screen.getByText(/data streams are only preserved.*Matroska/iu)
).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Fast remux' }));
expect(attachment).toBeEnabled();
expect(attachment).toBeChecked();
expect(data).toBeEnabled();
expect(data).toBeChecked();
await user.click(screen.getByRole('button', { name: 'Remux' }));
await waitFor(() => expect(onExport).toHaveBeenCalledOnce());
expect(onExport.mock.calls[0]?.[0].streamSelection).toEqual({
video: [0],
audio: [1],
subtitles: [],
attachments: [2],
data: [3],
});
});
});
function createAsset(id: string, name: string): ImportedMediaAsset {
return {
id,
file: new File(['media'], name, { type: 'video/mp4' }),
objectUrl: `blob:${id}`,
phase: 'ready',
probe: {
durationSeconds: 12,
formatNames: ['mov', 'mp4'],
tags: {},
chapters: [],
warnings: [],
streams: [
{
index: 0,
type: 'video',
codecName: 'h264',
width: 640,
height: 360,
disposition: {},
tags: {},
},
{
index: 1,
type: 'audio',
codecName: 'aac',
sampleRate: 48_000,
channels: 2,
disposition: {},
tags: {},
},
],
},
};
}
function readyEngine(muxers: readonly string[] = ['mp4']): EngineState {
return {
status: 'ready',
mode: 'single-thread',
capabilities: capabilities(muxers),
};
}
function capabilities(muxers: readonly string[] = ['mp4']): FFmpegCapabilities {
return {
versionText: 'test',
buildConfiguration: [],
demuxers: new Set(['mov']),
muxers: new Set(muxers),
decoders: new Set(['h264', 'aac']),
encoders: new Set(['libx264', 'aac']),
filters: new Set(),
};
}
function validatedUserPreset(): UserExportPreset {
const candidate: UserExportPreset = {
schemaVersion: 1,
id: 'my-local-h264',
name: 'My local H.264 export',
kind: 'video',
container: 'mp4',
fileExtension: 'mp4',
video: {
codec: 'libx264',
qualityMode: 'balanced',
crf: 22,
pixelFormat: 'yuv420p',
},
audio: {
codec: 'aac',
bitrateKbps: 160,
sampleRate: 48_000,
channels: 2,
},
metadataPolicy: 'copy',
chapterPolicy: 'keep',
subtitlePolicy: 'none',
fastStart: true,
};
const result = validateUserPreset(candidate);
if (!result.valid || !result.preset) {
throw new Error(`Test preset is invalid: ${result.errors.join('; ')}`);
}
return result.preset;
}