Files
av-tools/tests/unit/components/media-preview.test.tsx

353 lines
9.8 KiB
TypeScript

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(
<MediaPreview asset={probingAsset} />
);
const progressiveVideo = requiredElement(container.querySelector('video'));
expect(progressiveVideo).toHaveAttribute('src', probingAsset.objectUrl);
expect(
screen.getByText(
'Local source preview · Detailed inspection runs separately.'
)
).toBeVisible();
rerender(
<MediaPreview
asset={{
...videoAsset(),
id: probingAsset.id,
file: probingAsset.file,
objectUrl: probingAsset.objectUrl,
}}
/>
);
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(<MediaPreview asset={asset} />);
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(<MediaPreview asset={initial} />);
const player = requiredElement(container.querySelector('video'));
rerender(
<MediaPreview
asset={{
...initial,
browserMetadata: {
source: 'browser',
file: {
name: file.name,
sizeBytes: file.size,
declaredMimeType: file.type,
inferredMimeType: file.type,
lastModified: file.lastModified,
},
kind: 'video',
durationSeconds: 8,
},
}}
/>
);
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(
<MediaPreview
asset={videoAsset()}
onCreateProxy={onCreateProxy}
proxySettings={{ maxDurationSeconds: 30, maxWidth: 960 }}
onProxySettingsChange={onProxySettingsChange}
/>
);
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(
<MediaPreview
asset={videoAsset()}
currentTimeSeconds={3.25}
onCurrentTimeChange={onCurrentTimeChange}
/>
);
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(
<MediaPreview
asset={videoAsset()}
currentTimeSeconds={1.5}
onCurrentTimeChange={onCurrentTimeChange}
/>
);
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(
<MediaPreview
asset={videoAsset()}
result={result}
currentTimeSeconds={2}
onCurrentTimeChange={onCurrentTimeChange}
/>
);
expect(container.querySelector('video')).toBeNull();
const audio = requiredElement(container.querySelector('audio'));
expect(audio).toHaveAttribute('src', result.objectUrl);
expect(audio.currentTime).toBeCloseTo(2);
rerender(
<MediaPreview
asset={videoAsset()}
result={result}
currentTimeSeconds={5.5}
onCurrentTimeChange={onCurrentTimeChange}
/>
);
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(
<MediaPreview
asset={audioAsset()}
waveformPeaks={peaks}
currentTimeSeconds={1}
onCreateProxy={vi.fn()}
proxySettings={{ maxDurationSeconds: 30, maxWidth: 960 }}
/>
);
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(<MediaPreview result={result} />);
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<T extends Element>(element: T | null): T {
if (!element) {
throw new Error('Expected media element to be rendered');
}
return element;
}