295 lines
8.2 KiB
TypeScript
295 lines
8.2 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 · Media inspection continues in the background.'
|
|
)
|
|
).toBeVisible();
|
|
|
|
rerender(
|
|
<MediaPreview
|
|
asset={{
|
|
...videoAsset(),
|
|
id: probingAsset.id,
|
|
file: probingAsset.file,
|
|
objectUrl: probingAsset.objectUrl,
|
|
}}
|
|
/>
|
|
);
|
|
|
|
expect(container.querySelector('video')).toBe(progressiveVideo);
|
|
});
|
|
|
|
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;
|
|
}
|