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( ); 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( ); 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( ); 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( ); await user.click(screen.getByRole('checkbox', { name: /video.*#0/iu })); expect( screen.getByRole('checkbox', { name: /video.*#0/iu }) ).not.toBeChecked(); rerender( ); 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( ); 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('enables Opus after the reviewed core passed both regression modes', () => { const engineState: EngineState = { status: 'ready', mode: 'single-thread', capabilities: { ...capabilities(['mp4', 'opus']), encoders: new Set(['libx264', 'aac', 'libopus']), }, }; render( ); expect(screen.getByRole('option', { name: 'Opus' })).toBeEnabled(); }); 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( ); 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( ); 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; }