import { cleanup, fireEvent, render, screen, waitFor, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ImportedMediaAsset } from '../../../src/app/application-state'; import { MediaPreview, type PreviewExportResult, } from '../../../src/components/MediaPreview'; import { aggregatePcmPeaks } from '../../../src/waveform'; beforeEach(() => { vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue( 'probably' ); }); afterEach(() => { cleanup(); vi.restoreAllMocks(); }); describe('MediaPreview', () => { it('starts declared local video playback while inspection continues and preserves the element', () => { const probingAsset: ImportedMediaAsset = { id: 'asset-probing-video', file: new File(['video'], 'lecture.mp4', { type: 'application/octet-stream', }), objectUrl: 'blob:probing-video', phase: 'probing', }; const { container, rerender } = render( ); const progressiveVideo = requiredElement(container.querySelector('video')); expect(progressiveVideo).toHaveAttribute('src', probingAsset.objectUrl); expect( screen.getByText( 'Local source preview · Detailed inspection runs separately.' ) ).toBeVisible(); rerender( ); expect(container.querySelector('video')).toBe(progressiveVideo); }); it('keeps browser playback mounted after detailed inspection fails', () => { const asset: ImportedMediaAsset = { id: 'asset-probe-error', file: new File(['video'], 'lecture.mp4', { type: 'video/mp4' }), objectUrl: 'blob:probe-error', phase: 'error', error: 'Detailed inspection timed out.', }; const { container } = render(); expect(container.querySelector('video')).toHaveAttribute( 'src', asset.objectUrl ); expect( screen.getByText( 'Local source preview · Detailed inspection unavailable.' ) ).toBeVisible(); }); it('keeps the video-container player when browser metadata has no track hints', () => { const file = new File(['audio-only'], 'audio-only.mp4', { type: 'video/mp4', }); const initial: ImportedMediaAsset = { id: 'asset-audio-only-container', file, objectUrl: 'blob:audio-only-container', phase: 'idle', }; const { container, rerender } = render(); const player = requiredElement(container.querySelector('video')); rerender( ); expect(container.querySelector('video')).toBe(player); }); it('offers bounded configurable proxy settings after playback fails', async () => { const user = userEvent.setup(); const onCreateProxy = vi.fn(); const onProxySettingsChange = vi.fn(); const { container } = render( ); fireEvent.error(requiredElement(container.querySelector('video'))); expect(screen.getByText('Create a disposable preview proxy')).toBeVisible(); fireEvent.change( screen.getByRole('spinbutton', { name: 'Maximum seconds' }), { target: { value: '60' } } ); expect(onProxySettingsChange).toHaveBeenLastCalledWith({ maxDurationSeconds: 60, maxWidth: 960, }); await user.selectOptions( screen.getByRole('combobox', { name: 'Maximum width' }), '1280' ); expect(onProxySettingsChange).toHaveBeenLastCalledWith({ maxDurationSeconds: 30, maxWidth: 1280, }); await user.click(screen.getByRole('button', { name: 'Create proxy' })); expect(onCreateProxy).toHaveBeenCalledOnce(); }); it('seeks a source preview externally and reports its actual playback time', () => { const onCurrentTimeChange = vi.fn(); const { container, rerender } = render( ); const video = requiredElement(container.querySelector('video')); expect(video.currentTime).toBeCloseTo(3.25); video.currentTime = 7.75; fireEvent.timeUpdate(video); expect(onCurrentTimeChange).toHaveBeenLastCalledWith(7.75); rerender( ); expect(video.currentTime).toBeCloseTo(1.5); }); it('uses the result media kind, honors result seeks, and reports result time', () => { const onCurrentTimeChange = vi.fn(); const result: PreviewExportResult = { name: 'audio-result.mp3', mimeType: 'audio/mpeg', objectUrl: 'blob:audio-result', }; const { container, rerender } = render( ); expect(container.querySelector('video')).toBeNull(); const audio = requiredElement(container.querySelector('audio')); expect(audio).toHaveAttribute('src', result.objectUrl); expect(audio.currentTime).toBeCloseTo(2); rerender( ); expect(audio.currentTime).toBeCloseTo(5.5); audio.currentTime = 8.125; fireEvent.timeUpdate(audio); expect(onCurrentTimeChange).toHaveBeenLastCalledWith(8.125); }); it('uses bounded peaks for an audio-only source and offers an AAC/M4A proxy', async () => { vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue(''); vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue( canvasContext() ); const peaks = aggregatePcmPeaks( { container: 'raw', data: new Int16Array([-32_768, 0, 16_384, 32_767]), encoding: 's16le', sampleRate: 2, }, { bucketCount: 4 } ); const { container } = render( ); await waitFor(() => expect(container.querySelector('canvas')).toHaveAttribute( 'aria-label', 'Bounded waveform preview for source.flac.' ) ); expect(container.querySelector('.audio-preview__disc')).toBeNull(); expect(container.querySelector('audio')).toBeInTheDocument(); expect(screen.getByText(/AAC\/M4A/iu)).toBeVisible(); expect( screen.queryByRole('combobox', { name: 'Maximum width' }) ).toBeNull(); }); it('shows an explicit download-only result instead of mounting unsupported playback', () => { vi.mocked(HTMLMediaElement.prototype.canPlayType).mockReturnValue(''); const result: PreviewExportResult = { name: 'archive.flac', mimeType: 'audio/flac', objectUrl: 'blob:unsupported-result', }; const { container } = render(); expect(container.querySelector('audio')).toBeNull(); expect( screen.getByText( 'Download only: this browser cannot preview this result.' ) ).toBeVisible(); expect(screen.getByText('Download only · archive.flac')).toBeVisible(); }); }); function videoAsset(): ImportedMediaAsset { return { id: 'asset-video', file: new File(['video'], 'source.mp4', { type: 'video/mp4' }), objectUrl: 'blob:source-video', phase: 'ready', probe: { durationSeconds: 10, formatNames: ['mov', 'mp4'], tags: {}, chapters: [], warnings: [], streams: [ { index: 0, type: 'video', codecName: 'h264', width: 640, height: 360, disposition: {}, tags: {}, }, ], }, }; } function audioAsset(): ImportedMediaAsset { return { id: 'asset-audio', file: new File(['audio'], 'source.flac', { type: 'audio/flac' }), objectUrl: 'blob:source-audio', phase: 'ready', probe: { durationSeconds: 2, formatNames: ['flac'], tags: {}, chapters: [], warnings: [], streams: [ { index: 0, type: 'audio', codecName: 'flac', sampleRate: 2, channels: 1, disposition: {}, tags: {}, }, ], }, }; } function canvasContext(): CanvasRenderingContext2D { return { setTransform: vi.fn(), clearRect: vi.fn(), fillRect: vi.fn(), beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), stroke: vi.fn(), fillStyle: '', strokeStyle: '', lineWidth: 1, } as unknown as CanvasRenderingContext2D; } function requiredElement(element: T | null): T { if (!element) { throw new Error('Expected media element to be rendered'); } return element; }