feat: publish av-tools 0.1.0
This commit is contained in:
335
tests/unit/components/quick-convert.test.tsx
Normal file
335
tests/unit/components/quick-convert.test.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user