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 { StructuralOperations, type StructuralCapabilityKey, type StructuralCapabilityStatus, type StructuralOperationsProps, } from '../../../src/components/StructuralOperations'; const AVAILABLE: StructuralCapabilityStatus = { available: true }; const SELECTED_CLIP: NonNullable = { id: 'clip-a', label: 'Opening shot', sourceInSeconds: 5.25, sourceOutSeconds: 18.75, sourceDurationSeconds: 60, sourceExtension: '.MP4', hasAudio: true, hasVideo: true, }; const TIMELINE: NonNullable = { clips: [ { id: 'clip-a', label: 'Opening shot', hasAudio: true, hasVideo: true, hasSubtitles: false, }, { id: 'clip-b', label: 'Silent cutaway', hasAudio: false, hasVideo: true, hasSubtitles: false, }, ], fastTargetExtension: 'mp4', fastTargetMuxer: 'mp4', fastCompatibilityDiagnostics: [], }; const PRESETS: NonNullable = [ { id: 'video-ready', name: 'MP4 · H.264 + AAC', fileExtension: 'mp4', supportsAudio: true, supportsVideo: true, }, { id: 'video-disabled', name: 'WebM · VP9 + Opus', fileExtension: 'webm', supportsAudio: true, supportsVideo: true, disabledReason: 'The Opus encoder is not available.', }, { id: 'audio-only', name: 'MP3 audio', fileExtension: 'mp3', supportsAudio: true, supportsVideo: false, }, ]; function capabilities( ...keys: readonly StructuralCapabilityKey[] ): StructuralOperationsProps['availability'] { return Object.fromEntries(keys.map((key) => [key, AVAILABLE])); } afterEach(cleanup); describe('StructuralOperations', () => { it('uses the selected clip range for a fast stream-copy trim', async () => { const user = userEvent.setup(); const onTrim = vi.fn(); render( ); expect(screen.getByText('00:00:05.250–00:00:18.750')).toBeInTheDocument(); expect( screen.getByText(/first frame may move to a nearby keyframe/iu) ).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Export fast trim' })); await waitFor(() => expect(onTrim).toHaveBeenCalledWith({ operation: 'trim', mode: 'fast', clipId: 'clip-a', startSeconds: 5.25, endSeconds: 18.75, targetExtension: 'mp4', }) ); }); it('requires and emits an available typed preset for an accurate trim', async () => { const user = userEvent.setup(); const onTrim = vi.fn(); render( ); await user.click( screen.getByRole('radio', { name: /Accurate re-encode/iu }) ); const action = screen.getByRole('button', { name: 'Export exact trim' }); expect(action).toBeDisabled(); expect( screen.getByText( 'Choose an available typed preset for the accurate trim.' ) ).toBeInTheDocument(); const preset = screen.getByLabelText('Accurate trim preset'); expect( screen.getByRole('option', { name: /WebM · VP9 \+ Opus/iu }) ).toBeDisabled(); expect(screen.getByRole('option', { name: /MP3 audio/iu })).toBeDisabled(); await user.selectOptions(preset, 'video-ready'); await user.click(action); await waitFor(() => expect(onTrim).toHaveBeenCalledWith({ operation: 'trim', mode: 'accurate', clipId: 'clip-a', startSeconds: 5.25, endSeconds: 18.75, presetId: 'video-ready', }) ); }); it('fails closed when a capability was not explicitly verified', () => { render( ); expect( screen.getByRole('button', { name: 'Export fast trim' }) ).toBeDisabled(); expect( screen.getByText( 'The required browser and FFmpeg capabilities have not been verified.' ) ).toBeInTheDocument(); }); it('shows concrete incompatibilities and blocks fast concat', () => { render( ); expect( screen.getByRole('heading', { name: 'Fast compatibility failed' }) ).toBeInTheDocument(); expect( screen.getByText('Clip 2 has incompatible width: 1280 instead of 1920.') ).toBeInTheDocument(); expect( screen.getByText('Checked field: sources.1.streams.0.width') ).toBeInTheDocument(); expect( screen.getByRole('button', { name: 'Export fast concat' }) ).toBeDisabled(); }); it('emits the verified sequential clip order for fast concat', async () => { const user = userEvent.setup(); const onConcat = vi.fn(); render( ); expect( screen.getByRole('heading', { name: 'Fast compatibility passed' }) ).toBeInTheDocument(); await user.click( screen.getByRole('button', { name: 'Export fast concat' }) ); await waitFor(() => expect(onConcat).toHaveBeenCalledWith({ operation: 'concat', mode: 'fast', clipIds: ['clip-a', 'clip-b'], targetExtension: 'mp4', targetMuxer: 'mp4', }) ); }); it('requires an explicit missing-audio policy for normalized concat', async () => { const user = userEvent.setup(); const onConcat = vi.fn(); render( ); expect(screen.getByText('No audio: Silent cutaway')).toBeInTheDocument(); await user.click( screen.getByRole('radio', { name: /Normalized re-encode/iu }) ); await user.selectOptions( screen.getByLabelText('Normalized concat preset'), 'video-ready' ); const action = screen.getByRole('button', { name: 'Export normalized concat', }); expect(action).toBeDisabled(); expect( screen.getByText('Choose an explicit missing-audio policy.') ).toBeInTheDocument(); await user.click(screen.getByRole('radio', { name: /Insert silence/iu })); expect(action).toBeDisabled(); expect( screen.getByText('Choose an explicit subtitle policy.') ).toBeInTheDocument(); await user.click( screen.getByRole('radio', { name: /Reject subtitle input/iu }) ); await user.click(action); await waitFor(() => expect(onConcat).toHaveBeenCalledWith({ operation: 'concat', mode: 'normalized', clipIds: ['clip-a', 'clip-b'], presetId: 'video-ready', missingAudioPolicy: 'insert-silence', subtitlePolicy: 'reject', crossfadeSeconds: 0, }) ); }); it('requires destructive subtitle loss to be explicit for normalized concat', async () => { const user = userEvent.setup(); const onConcat = vi.fn(); render( index === 0 ? { ...clip, hasSubtitles: true } : clip ), }} exportPresets={PRESETS} availability={capabilities('concat-normalized')} onConcat={onConcat} /> ); await user.click( screen.getByRole('radio', { name: /Normalized re-encode/iu }) ); await user.selectOptions( screen.getByLabelText('Normalized concat preset'), 'video-ready' ); await user.click(screen.getByRole('radio', { name: /Insert silence/iu })); await user.click( screen.getByRole('radio', { name: /Reject subtitle input/iu }) ); const action = screen.getByRole('button', { name: 'Export normalized concat', }); expect(action).toBeDisabled(); expect( screen.getByText(/sequence contains subtitles.*explicitly drop all/iu) ).toBeVisible(); await user.click( screen.getByRole('radio', { name: /Drop all subtitles/iu }) ); await user.click(action); await waitFor(() => expect(onConcat).toHaveBeenCalledWith({ operation: 'concat', mode: 'normalized', clipIds: ['clip-a', 'clip-b'], presetId: 'video-ready', missingAudioPolicy: 'insert-silence', subtitlePolicy: 'drop-all', crossfadeSeconds: 0, }) ); }); it('emits a bounded crossfade only after its filters are verified', async () => { const user = userEvent.setup(); const onConcat = vi.fn(); render( ({ ...clip, durationSeconds: 3, })), }} exportPresets={PRESETS} availability={capabilities('concat-normalized', 'concat-crossfade')} onConcat={onConcat} /> ); await user.click( screen.getByRole('radio', { name: /Normalized re-encode/iu }) ); await user.selectOptions( screen.getByLabelText('Normalized concat preset'), 'video-ready' ); await user.click(screen.getByRole('radio', { name: /Insert silence/iu })); await user.click( screen.getByRole('radio', { name: /Reject subtitle input/iu }) ); await user.clear(screen.getByLabelText(/^Crossfade duration/iu)); await user.type(screen.getByLabelText(/^Crossfade duration/iu), '0.5'); await user.click( screen.getByRole('button', { name: 'Export normalized concat' }) ); await waitFor(() => expect(onConcat).toHaveBeenCalledWith({ operation: 'concat', mode: 'normalized', clipIds: ['clip-a', 'clip-b'], presetId: 'video-ready', missingAudioPolicy: 'insert-silence', subtitlePolicy: 'reject', crossfadeSeconds: 0.5, }) ); }); it('treats an absent fast compatibility result as unchecked', () => { render( ); expect( screen.getByRole('heading', { name: 'Fast compatibility not checked' }) ).toBeInTheDocument(); expect( screen.getByText( 'Fast concat remains disabled until every input stream is checked.' ) ).toBeInTheDocument(); }); it('surfaces a synchronous runner rejection and releases pending state', async () => { const user = userEvent.setup(); render( { throw new Error('Queue capacity was reached.'); }} /> ); const action = screen.getByRole('button', { name: 'Export fast trim' }); await user.click(action); expect( await screen.findByText( 'The operation could not be queued: Queue capacity was reached.' ) ).toBeInTheDocument(); await waitFor(() => expect(action).toBeEnabled()); }); });