feat: publish av-tools 0.1.0
This commit is contained in:
139
tests/unit/components/capability-panel.test.tsx
Normal file
139
tests/unit/components/capability-panel.test.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { cleanup, render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CapabilityPanel } from '../../../src/components/CapabilityPanel';
|
||||
import type {
|
||||
EngineState,
|
||||
FFmpegCapabilities,
|
||||
} from '../../../src/ffmpeg/ffmpeg.types';
|
||||
import { createResourcePolicy } from '../../../src/storage';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('CapabilityPanel', () => {
|
||||
it('shows the active device-adjusted resource policy', async () => {
|
||||
const user = userEvent.setup();
|
||||
const policy = createResourcePolicy({
|
||||
softInputBytes: 128 * 1024 * 1024,
|
||||
hardSingleInputBytes: 512 * 1024 * 1024,
|
||||
hardTotalInputBytes: 768 * 1024 * 1024,
|
||||
softOutputEstimateBytes: 256 * 1024 * 1024,
|
||||
hardOutputEstimateBytes: 512 * 1024 * 1024,
|
||||
});
|
||||
|
||||
render(
|
||||
<CapabilityPanel
|
||||
state={{ status: 'idle' }}
|
||||
preference="automatic"
|
||||
resourcePolicy={policy}
|
||||
deviceMemoryGiB={2}
|
||||
onPreferenceChange={vi.fn()}
|
||||
onInitialize={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Engine & browser'));
|
||||
expect(screen.getByText('Active resource limits')).toBeVisible();
|
||||
expect(screen.getByText('Adjusted for 2 GiB device memory')).toBeVisible();
|
||||
expect(screen.getByText('128 MiB / 512 MiB')).toBeVisible();
|
||||
expect(
|
||||
screen.getByText(/conservative estimates, not media format limits/i)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('reports the exact FFmpeg version, build flags, and capability counts', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<CapabilityPanel
|
||||
state={readyEngine()}
|
||||
preference="automatic"
|
||||
resourcePolicy={createResourcePolicy()}
|
||||
onPreferenceChange={vi.fn()}
|
||||
onInitialize={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Engine & browser'));
|
||||
expect(screen.getByTitle(/ffmpeg version n5\.1\.4/iu)).toHaveTextContent(
|
||||
'ffmpeg version n5.1.4'
|
||||
);
|
||||
expect(valueFor('Demuxers')).toHaveTextContent('3');
|
||||
expect(valueFor('Muxers')).toHaveTextContent('2');
|
||||
expect(valueFor('Decoders')).toHaveTextContent('4');
|
||||
expect(valueFor('Encoders')).toHaveTextContent('5');
|
||||
expect(valueFor('Filters')).toHaveTextContent('6');
|
||||
expect(
|
||||
screen.getByText('Detected capability inventory (20 entries)')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Demuxers \(3\)\s+matroska, mov, wav[\s\S]+Encoders \(5\)\s+aac, libvorbis, libvpx, libx264, png/u
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('FFmpeg build configuration (2 flags)')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/--enable-gpl\s+--enable-libx264/u)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('exposes controlled initialization failure details and retries', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onInitialize = vi.fn();
|
||||
const state: EngineState = {
|
||||
status: 'error',
|
||||
error: {
|
||||
code: 'load-failed',
|
||||
message: 'The compatibility core could not be loaded.',
|
||||
details: 'worker bootstrap failed',
|
||||
exitCode: 1,
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<CapabilityPanel
|
||||
state={state}
|
||||
preference="automatic"
|
||||
resourcePolicy={createResourcePolicy()}
|
||||
onPreferenceChange={vi.fn()}
|
||||
onInitialize={onInitialize}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Engine & browser'));
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'The compatibility core could not be loaded.'
|
||||
);
|
||||
expect(screen.getByText('load-failed')).toBeVisible();
|
||||
expect(screen.getByText('worker bootstrap failed')).toBeInTheDocument();
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Retry initialization' })
|
||||
);
|
||||
expect(onInitialize).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
function valueFor(label: string): HTMLElement {
|
||||
const container = screen.getByText(label).closest('div');
|
||||
if (!container) throw new Error(`Missing capability cell for ${label}`);
|
||||
return within(container).getByRole('definition');
|
||||
}
|
||||
|
||||
function readyEngine(): EngineState {
|
||||
const capabilities: FFmpegCapabilities = {
|
||||
versionText:
|
||||
'ffmpeg version n5.1.4 Copyright the FFmpeg developers\nconfiguration: test',
|
||||
buildConfiguration: ['--enable-gpl', '--enable-libx264'],
|
||||
demuxers: new Set(['mov', 'matroska', 'wav']),
|
||||
muxers: new Set(['mp4', 'webm']),
|
||||
decoders: new Set(['h264', 'aac', 'vp8', 'vorbis']),
|
||||
encoders: new Set(['libx264', 'aac', 'libvpx', 'libvorbis', 'png']),
|
||||
filters: new Set(['scale', 'crop', 'fps', 'format', 'aresample', 'volume']),
|
||||
};
|
||||
return {
|
||||
status: 'ready',
|
||||
mode: 'single-thread',
|
||||
capabilities,
|
||||
};
|
||||
}
|
||||
149
tests/unit/components/crop-overlay.test.tsx
Normal file
149
tests/unit/components/crop-overlay.test.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { CropOverlay } from '../../../src/components/CropOverlay';
|
||||
|
||||
describe('CropOverlay', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('maps a clockwise-oriented preview back to encoded source pixels', () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<CropOverlay
|
||||
sourceWidth={100}
|
||||
sourceHeight={80}
|
||||
orientation={90}
|
||||
value={{ x: 10, y: 20, width: 30, height: 40 }}
|
||||
onChange={onChange}
|
||||
>
|
||||
<img src="blob:local-preview" alt="Local preview" />
|
||||
</CropOverlay>
|
||||
);
|
||||
|
||||
const rectangle = container.querySelector('.crop-editor__rectangle');
|
||||
expect(rectangle).toHaveStyle({
|
||||
left: '25%',
|
||||
top: '10%',
|
||||
width: '50%',
|
||||
height: '30%',
|
||||
});
|
||||
|
||||
fireEvent.keyDown(
|
||||
screen.getByRole('button', { name: 'Move crop rectangle' }),
|
||||
{ key: 'ArrowRight' }
|
||||
);
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
x: 10,
|
||||
y: 19,
|
||||
width: 30,
|
||||
height: 40,
|
||||
});
|
||||
});
|
||||
|
||||
it('supports pointer dragging with bounded percentage-to-pixel math', () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<CropOverlay
|
||||
sourceWidth={200}
|
||||
sourceHeight={100}
|
||||
value={{ x: 20, y: 10, width: 100, height: 60 }}
|
||||
onChange={onChange}
|
||||
>
|
||||
<div>Preview</div>
|
||||
</CropOverlay>
|
||||
);
|
||||
const stage = container.querySelector('.crop-editor__stage');
|
||||
if (!(stage instanceof HTMLElement)) throw new Error('Missing crop stage');
|
||||
vi.spyOn(stage, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 200,
|
||||
bottom: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
const move = screen.getByRole('button', { name: 'Move crop rectangle' });
|
||||
|
||||
fireEvent.pointerDown(move, {
|
||||
pointerId: 7,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
});
|
||||
fireEvent.pointerMove(move, {
|
||||
pointerId: 7,
|
||||
clientX: 60,
|
||||
clientY: 30,
|
||||
});
|
||||
fireEvent.pointerUp(move, { pointerId: 7 });
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
x: 70,
|
||||
y: 30,
|
||||
width: 100,
|
||||
height: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it('locks common display ratios without confusing rotated dimensions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<CropOverlay
|
||||
sourceWidth={100}
|
||||
sourceHeight={80}
|
||||
orientation={90}
|
||||
value={{ x: 0, y: 0, width: 100, height: 80 }}
|
||||
onChange={onChange}
|
||||
>
|
||||
<div>Preview</div>
|
||||
</CropOverlay>
|
||||
);
|
||||
|
||||
await user.selectOptions(
|
||||
screen.getByRole('combobox', { name: 'Common ratio' }),
|
||||
'16:9'
|
||||
);
|
||||
|
||||
const crop = onChange.mock.lastCall?.[0] as
|
||||
{ width: number; height: number } | undefined;
|
||||
expect(crop).toBeDefined();
|
||||
expect((crop?.width ?? 0) / (crop?.height ?? 1)).toBeCloseTo(9 / 16, 2);
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: 'Lock aspect ratio' })
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
it('rejects invalid numeric edits and emits even reset bounds when required', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<CropOverlay
|
||||
sourceWidth={101}
|
||||
sourceHeight={99}
|
||||
requireEvenDimensions
|
||||
value={{ x: 0, y: 0, width: 100, height: 98 }}
|
||||
onChange={onChange}
|
||||
>
|
||||
<div>Preview</div>
|
||||
</CropOverlay>
|
||||
);
|
||||
|
||||
await user.clear(screen.getByRole('spinbutton', { name: 'Width' }));
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'All crop fields are required.'
|
||||
);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Reset crop' }));
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 98,
|
||||
});
|
||||
});
|
||||
});
|
||||
363
tests/unit/components/editor-integration.test.tsx
Normal file
363
tests/unit/components/editor-integration.test.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { cleanup, fireEvent, render, screen } 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 { Inspector } from '../../../src/components/Inspector';
|
||||
import { Timeline } from '../../../src/components/Timeline';
|
||||
import type { AvProjectV1, TimelineClip } from '../../../src/project';
|
||||
import { aggregatePcmPeaks } from '../../../src/waveform';
|
||||
|
||||
describe('visual editor integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
createCanvasContext()
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it('shows encoded dimensions separately from normalized display metadata', () => {
|
||||
const project = createProject();
|
||||
|
||||
render(
|
||||
<Inspector
|
||||
asset={createImportedAsset()}
|
||||
project={project}
|
||||
clip={project.timeline[0]}
|
||||
onClipUpdate={vi.fn()}
|
||||
onMetadataChange={vi.fn()}
|
||||
onChaptersChange={vi.fn()}
|
||||
onSubtitlesChange={vi.fn()}
|
||||
onThumbnail={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText('Encoded pixels 101×99 · coded 102×100 · 25.000 fps')
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByText('SAR 4:3 · DAR 16:9 · rotation -90°')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('uses the local video object URL in the rotation-aware crop editor', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClipUpdate = vi.fn();
|
||||
const project = createProject();
|
||||
const clip = {
|
||||
...project.timeline[0]!,
|
||||
crop: { x: 0, y: 0, width: 100, height: 98 },
|
||||
rotation: 90 as const,
|
||||
};
|
||||
const asset = createImportedAsset();
|
||||
|
||||
render(
|
||||
<Inspector
|
||||
asset={asset}
|
||||
project={project}
|
||||
clip={clip}
|
||||
disabled
|
||||
onClipUpdate={onClipUpdate}
|
||||
onMetadataChange={vi.fn()}
|
||||
onChaptersChange={vi.fn()}
|
||||
onSubtitlesChange={vi.fn()}
|
||||
onThumbnail={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Transform' }));
|
||||
|
||||
const cropEditor = screen.getByRole('region', {
|
||||
name: 'Crop local-video.mp4',
|
||||
});
|
||||
expect(cropEditor).toHaveAttribute('data-orientation', '90');
|
||||
expect(
|
||||
screen.getByLabelText('Local preview of local-video.mp4')
|
||||
).toHaveAttribute('src', 'blob:local-video');
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Move crop rectangle' })
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole('group', { name: 'Encoded source pixels' })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it('keeps numeric crop inputs when a visual video source is unavailable', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClipUpdate = vi.fn();
|
||||
const project = createProject();
|
||||
|
||||
render(
|
||||
<Inspector
|
||||
project={project}
|
||||
clip={project.timeline[0]}
|
||||
onClipUpdate={onClipUpdate}
|
||||
onMetadataChange={vi.fn()}
|
||||
onChaptersChange={vi.fn()}
|
||||
onSubtitlesChange={vi.fn()}
|
||||
onThumbnail={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Transform' }));
|
||||
expect(
|
||||
screen.getByText(/visual crop preview is unavailable/i)
|
||||
).toBeVisible();
|
||||
|
||||
fireEvent.change(screen.getByRole('spinbutton', { name: 'WIDTH' }), {
|
||||
target: { value: '101' },
|
||||
});
|
||||
expect(onClipUpdate).toHaveBeenLastCalledWith({
|
||||
crop: { x: 0, y: 0, width: 100, height: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('edits allowlisted fade curves and persists chapter time bases', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClipUpdate = vi.fn();
|
||||
const onChaptersChange = vi.fn();
|
||||
const baseProject = createProject();
|
||||
const project: AvProjectV1 = {
|
||||
...baseProject,
|
||||
chapters: [
|
||||
{
|
||||
id: 'chapter-1',
|
||||
title: 'Intro',
|
||||
startSeconds: 0,
|
||||
endSeconds: 1,
|
||||
timeBase: '1/1000',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
render(
|
||||
<Inspector
|
||||
project={project}
|
||||
clip={project.timeline[0]}
|
||||
onClipUpdate={onClipUpdate}
|
||||
onMetadataChange={vi.fn()}
|
||||
onChaptersChange={onChaptersChange}
|
||||
onSubtitlesChange={vi.fn()}
|
||||
onThumbnail={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Transform' }));
|
||||
await user.selectOptions(
|
||||
screen.getByRole('combobox', { name: 'Video fade curve' }),
|
||||
'exp'
|
||||
);
|
||||
expect(onClipUpdate).toHaveBeenLastCalledWith({
|
||||
video: { enabled: true, fadeCurve: 'exp' },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Audio' }));
|
||||
await user.selectOptions(
|
||||
screen.getByRole('combobox', { name: 'Audio fade curve' }),
|
||||
'qsin'
|
||||
);
|
||||
expect(onClipUpdate).toHaveBeenLastCalledWith({
|
||||
audio: { enabled: true, fadeCurve: 'qsin' },
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Chapters' }));
|
||||
fireEvent.change(screen.getByLabelText('Chapter 1 time base'), {
|
||||
target: { value: '1/48000' },
|
||||
});
|
||||
expect(onChaptersChange).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ id: 'chapter-1', timeBase: '1/48000' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('updates the selected clip and forwards validated waveform selections', () => {
|
||||
const project = createProject();
|
||||
const onUpdate = vi.fn();
|
||||
const onSelectionChange = vi.fn();
|
||||
|
||||
render(
|
||||
<Timeline
|
||||
project={project}
|
||||
selectedClipId="clip-1"
|
||||
onSelect={vi.fn()}
|
||||
onMove={vi.fn()}
|
||||
onUpdate={onUpdate}
|
||||
onRemove={vi.fn()}
|
||||
waveformPeaks={createPeaks()}
|
||||
currentTimeSeconds={4}
|
||||
splitMarkers={[3]}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('local-video.mp4')).toBeVisible();
|
||||
expect(screen.getByText('Single sequential audio track')).toBeVisible();
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('slider', { name: 'In trim handle' }), {
|
||||
key: 'ArrowRight',
|
||||
});
|
||||
|
||||
expect(onUpdate).toHaveBeenLastCalledWith('clip-1', {
|
||||
sourceInSeconds: 1.1,
|
||||
sourceOutSeconds: 9,
|
||||
});
|
||||
expect(onSelectionChange).toHaveBeenLastCalledWith({
|
||||
inSeconds: 1.1,
|
||||
outSeconds: 9,
|
||||
markers: [3],
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves the original clip-card timeline when waveform data is omitted', () => {
|
||||
const project = createProject();
|
||||
|
||||
render(
|
||||
<Timeline
|
||||
project={project}
|
||||
selectedClipId="clip-1"
|
||||
onSelect={vi.fn()}
|
||||
onMove={vi.fn()}
|
||||
onUpdate={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('local-video.mp4')).toBeVisible();
|
||||
expect(
|
||||
screen.queryByText('Single sequential audio track')
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('spinbutton', { name: 'In' })).toHaveValue(1);
|
||||
expect(screen.getByRole('spinbutton', { name: 'Out' })).toHaveValue(9);
|
||||
});
|
||||
|
||||
it('resets the selected clip trim to its full probed source duration', async () => {
|
||||
const user = userEvent.setup();
|
||||
const project = createProject();
|
||||
const onUpdate = vi.fn();
|
||||
|
||||
render(
|
||||
<Timeline
|
||||
project={project}
|
||||
selectedClipId="clip-1"
|
||||
onSelect={vi.fn()}
|
||||
onMove={vi.fn()}
|
||||
onUpdate={onUpdate}
|
||||
onRemove={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Reset trim to full source' })
|
||||
);
|
||||
|
||||
expect(onUpdate).toHaveBeenCalledWith('clip-1', {
|
||||
sourceInSeconds: 0,
|
||||
sourceOutSeconds: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createProject(): AvProjectV1 {
|
||||
const clip: TimelineClip = {
|
||||
id: 'clip-1',
|
||||
assetId: 'asset-1',
|
||||
sourceInSeconds: 1,
|
||||
sourceOutSeconds: 9,
|
||||
};
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
id: 'project-1',
|
||||
name: 'Test project',
|
||||
createdAt: '2026-07-24T12:00:00.000Z',
|
||||
updatedAt: '2026-07-24T12:00:00.000Z',
|
||||
assets: [
|
||||
{
|
||||
id: 'asset-1',
|
||||
originalName: 'local-video.mp4',
|
||||
size: 4,
|
||||
lastModified: 0,
|
||||
mimeType: 'video/mp4',
|
||||
sourceStatus: 'attached',
|
||||
probe: createProbe(),
|
||||
},
|
||||
],
|
||||
timeline: [clip],
|
||||
output: {
|
||||
presetId: 'video-h264',
|
||||
fileName: 'output.mp4',
|
||||
container: 'mp4',
|
||||
videoEnabled: true,
|
||||
audioEnabled: true,
|
||||
},
|
||||
metadata: { custom: {} },
|
||||
chapters: [],
|
||||
subtitles: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createImportedAsset(): ImportedMediaAsset {
|
||||
return {
|
||||
id: 'asset-1',
|
||||
file: new File(['test'], 'local-video.mp4', { type: 'video/mp4' }),
|
||||
objectUrl: 'blob:local-video',
|
||||
phase: 'ready',
|
||||
probe: createProbe(),
|
||||
};
|
||||
}
|
||||
|
||||
function createProbe() {
|
||||
return {
|
||||
durationSeconds: 10,
|
||||
formatNames: ['mov', 'mp4'],
|
||||
tags: {},
|
||||
chapters: [],
|
||||
warnings: [],
|
||||
streams: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'video' as const,
|
||||
codecName: 'h264',
|
||||
width: 101,
|
||||
height: 99,
|
||||
codedWidth: 102,
|
||||
codedHeight: 100,
|
||||
sampleAspectRatio: '4:3',
|
||||
displayAspectRatio: '16:9',
|
||||
rotationDegrees: -90,
|
||||
frameRate: 25,
|
||||
disposition: {},
|
||||
tags: {},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createPeaks() {
|
||||
return aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: new Int16Array(
|
||||
Array.from({ length: 100 }, (_, index) =>
|
||||
Math.round(Math.sin(index / 5) * 20_000)
|
||||
)
|
||||
),
|
||||
encoding: 's16le',
|
||||
sampleRate: 10,
|
||||
},
|
||||
{ bucketCount: 100 }
|
||||
);
|
||||
}
|
||||
|
||||
function createCanvasContext(): 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;
|
||||
}
|
||||
48
tests/unit/components/help-dialog.test.tsx
Normal file
48
tests/unit/components/help-dialog.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { HelpDialog } from '../../../src/components/HelpDialog';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('HelpDialog', () => {
|
||||
it('exposes bundled engine, licence, source and privacy context', () => {
|
||||
render(<HelpDialog open={false} onClose={vi.fn()} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', {
|
||||
name: 'About & licences',
|
||||
hidden: true,
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('n5.1.4')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.12.15')).toBeInTheDocument();
|
||||
expect(screen.getByText('0.12.10 · ST & MT')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/copyright © 2026 Albrecht Degering/i)
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'Application GPL', hidden: true })
|
||||
).toHaveAttribute('href', expect.stringContaining('LICENSE'));
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'MIT text', hidden: true })
|
||||
).toHaveAttribute('href', expect.stringContaining('ffmpeg.wasm-MIT'));
|
||||
expect(
|
||||
screen.getByRole('link', { name: 'GPL text', hidden: true })
|
||||
).toHaveAttribute('href', expect.stringContaining('GPL-2.0-or-later'));
|
||||
expect(
|
||||
screen.getByRole('link', {
|
||||
name: 'Complete inventory',
|
||||
hidden: true,
|
||||
})
|
||||
).toHaveAttribute('href', expect.stringContaining('THIRD_PARTY_NOTICES'));
|
||||
expect(
|
||||
screen.getByRole('link', {
|
||||
name: 'Source & reproducibility record',
|
||||
hidden: true,
|
||||
})
|
||||
).toHaveAttribute('href', expect.stringContaining('SOURCE'));
|
||||
expect(
|
||||
screen.getByText(/source files stay inside the browser session/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
196
tests/unit/components/job-panel.test.tsx
Normal file
196
tests/unit/components/job-panel.test.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
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 { JobPanel } from '../../../src/components/JobPanel';
|
||||
import type { ExportResult } from '../../../src/export';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('JobPanel result selection', () => {
|
||||
it('downloads only the selected generated results as ZIP', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaveZip = vi.fn();
|
||||
const first = result('one', 'one.png');
|
||||
const second = result('two', 'two.png');
|
||||
|
||||
render(
|
||||
<JobPanel
|
||||
engineState={{
|
||||
status: 'ready',
|
||||
mode: 'single-thread',
|
||||
capabilities: {
|
||||
versionText: 'test',
|
||||
buildConfiguration: [],
|
||||
demuxers: new Set(),
|
||||
muxers: new Set(),
|
||||
decoders: new Set(),
|
||||
encoders: new Set(),
|
||||
filters: new Set(),
|
||||
},
|
||||
}}
|
||||
jobs={[]}
|
||||
results={[first, second]}
|
||||
verifications={{}}
|
||||
logs={[]}
|
||||
onCancel={vi.fn()}
|
||||
onCancelJob={vi.fn()}
|
||||
onRetryJob={vi.fn()}
|
||||
onClearJobHistory={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onSaveZip={onSaveZip}
|
||||
onSaveReport={vi.fn()}
|
||||
onPreview={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onClearResults={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'Download selected as ZIP (2)',
|
||||
})
|
||||
).toBeEnabled()
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('checkbox', { name: 'Select two.png for ZIP' })
|
||||
);
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Download selected as ZIP (1)' })
|
||||
);
|
||||
expect(onSaveZip).toHaveBeenCalledWith([first]);
|
||||
});
|
||||
|
||||
it('marks browser-rejected media as download-only and does not offer Preview', () => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'canPlayType').mockReturnValue('');
|
||||
const output = mediaResult('audio', 'archive.flac', 'audio/flac');
|
||||
|
||||
render(
|
||||
<JobPanel
|
||||
engineState={{
|
||||
status: 'ready',
|
||||
mode: 'single-thread',
|
||||
capabilities: {
|
||||
versionText: 'test',
|
||||
buildConfiguration: [],
|
||||
demuxers: new Set(),
|
||||
muxers: new Set(),
|
||||
decoders: new Set(),
|
||||
encoders: new Set(),
|
||||
filters: new Set(),
|
||||
},
|
||||
}}
|
||||
jobs={[]}
|
||||
results={[output]}
|
||||
verifications={{}}
|
||||
logs={[]}
|
||||
onCancel={vi.fn()}
|
||||
onCancelJob={vi.fn()}
|
||||
onRetryJob={vi.fn()}
|
||||
onClearJobHistory={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onSaveZip={vi.fn()}
|
||||
onSaveReport={vi.fn()}
|
||||
onPreview={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onClearResults={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Preview' })).toBeNull();
|
||||
expect(
|
||||
screen.getByText(/Download only: The browser reports no native/iu)
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: 'Save result' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('shows the exact split interval and per-result verification state', () => {
|
||||
const output = {
|
||||
...mediaResult('segment', 'clip-001.mp4', 'video/mp4'),
|
||||
timeRange: { startSeconds: 1.25, endSeconds: 3.5 },
|
||||
};
|
||||
|
||||
render(
|
||||
<JobPanel
|
||||
engineState={{
|
||||
status: 'ready',
|
||||
mode: 'single-thread',
|
||||
capabilities: {
|
||||
versionText: 'test',
|
||||
buildConfiguration: [],
|
||||
demuxers: new Set(),
|
||||
muxers: new Set(),
|
||||
decoders: new Set(),
|
||||
encoders: new Set(),
|
||||
filters: new Set(),
|
||||
},
|
||||
}}
|
||||
jobs={[]}
|
||||
results={[output]}
|
||||
verifications={{
|
||||
segment: {
|
||||
warning: 'Output was created, but its verification probe failed.',
|
||||
},
|
||||
}}
|
||||
logs={[]}
|
||||
onCancel={vi.fn()}
|
||||
onCancelJob={vi.fn()}
|
||||
onRetryJob={vi.fn()}
|
||||
onClearJobHistory={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
onSaveZip={vi.fn()}
|
||||
onSaveReport={vi.fn()}
|
||||
onPreview={vi.fn()}
|
||||
onRemove={vi.fn()}
|
||||
onClearResults={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Segment range 00:00:01.250–00:00:03.500 · 00:00:02.250 duration'
|
||||
)
|
||||
).toBeVisible();
|
||||
expect(screen.getByText(/created · verification warning/iu)).toBeVisible();
|
||||
expect(
|
||||
screen.getByText('Output was created, but its verification probe failed.')
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
function result(id: string, fileName: string): ExportResult {
|
||||
const blob = new Blob([id], { type: 'image/png' });
|
||||
return {
|
||||
id,
|
||||
planId: `plan-${id}`,
|
||||
outputId: `output-${id}`,
|
||||
fileName,
|
||||
blob,
|
||||
mimeType: blob.type,
|
||||
role: 'thumbnail',
|
||||
size: blob.size,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function mediaResult(
|
||||
id: string,
|
||||
fileName: string,
|
||||
mimeType: string
|
||||
): ExportResult {
|
||||
const blob = new Blob([id], { type: mimeType });
|
||||
return {
|
||||
id,
|
||||
planId: `plan-${id}`,
|
||||
outputId: `output-${id}`,
|
||||
fileName,
|
||||
blob,
|
||||
mimeType,
|
||||
role: 'media',
|
||||
size: blob.size,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
259
tests/unit/components/media-preview.test.tsx
Normal file
259
tests/unit/components/media-preview.test.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
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('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;
|
||||
}
|
||||
335
tests/unit/components/quick-convert.test.tsx
Normal file
335
tests/unit/components/quick-convert.test.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
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(
|
||||
<QuickConvert
|
||||
asset={createAsset('asset-a', 'source-a.mp4')}
|
||||
engineState={readyEngine()}
|
||||
busy={false}
|
||||
queueing
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<QuickConvert
|
||||
asset={asset}
|
||||
engineState={readyEngine()}
|
||||
busy={false}
|
||||
onExport={onExport}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<QuickConvert
|
||||
asset={{ ...asset }}
|
||||
engineState={readyEngine()}
|
||||
busy={false}
|
||||
onExport={onExport}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<QuickConvert
|
||||
asset={firstAsset}
|
||||
engineState={readyEngine()}
|
||||
busy={false}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('checkbox', { name: /video.*#0/iu }));
|
||||
expect(
|
||||
screen.getByRole('checkbox', { name: /video.*#0/iu })
|
||||
).not.toBeChecked();
|
||||
|
||||
rerender(
|
||||
<QuickConvert
|
||||
asset={secondAsset}
|
||||
engineState={readyEngine()}
|
||||
busy={false}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<QuickConvert
|
||||
asset={createAsset('asset-a', 'source-a.mp4')}
|
||||
engineState={readyEngine()}
|
||||
busy={false}
|
||||
onExport={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
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('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(
|
||||
<QuickConvert
|
||||
asset={createAsset('asset-a', 'source-a.mp4')}
|
||||
engineState={readyEngine()}
|
||||
busy={false}
|
||||
presets={[...BUILT_IN_PRESETS, userPreset]}
|
||||
onExport={onExport}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<QuickConvert
|
||||
asset={asset}
|
||||
engineState={readyEngine(['mp4', 'matroska'])}
|
||||
busy={false}
|
||||
onExport={onExport}
|
||||
/>
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
376
tests/unit/components/structural-operations.test.tsx
Normal file
376
tests/unit/components/structural-operations.test.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
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<StructuralOperationsProps['selectedClip']> = {
|
||||
id: 'clip-a',
|
||||
label: 'Opening shot',
|
||||
sourceInSeconds: 5.25,
|
||||
sourceOutSeconds: 18.75,
|
||||
sourceDurationSeconds: 60,
|
||||
sourceExtension: '.MP4',
|
||||
hasAudio: true,
|
||||
hasVideo: true,
|
||||
};
|
||||
|
||||
const TIMELINE: NonNullable<StructuralOperationsProps['timeline']> = {
|
||||
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<StructuralOperationsProps['exportPresets']> = [
|
||||
{
|
||||
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(
|
||||
<StructuralOperations
|
||||
selectedClip={SELECTED_CLIP}
|
||||
availability={capabilities('trim-fast')}
|
||||
onTrim={onTrim}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<StructuralOperations
|
||||
selectedClip={SELECTED_CLIP}
|
||||
exportPresets={PRESETS}
|
||||
availability={capabilities('trim-accurate')}
|
||||
onTrim={onTrim}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<StructuralOperations selectedClip={SELECTED_CLIP} onTrim={vi.fn()} />
|
||||
);
|
||||
|
||||
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(
|
||||
<StructuralOperations
|
||||
timeline={{
|
||||
...TIMELINE,
|
||||
fastCompatibilityDiagnostics: [
|
||||
{
|
||||
code: 'stream-property',
|
||||
severity: 'error',
|
||||
message: 'Clip 2 has incompatible width: 1280 instead of 1920.',
|
||||
path: 'sources.1.streams.0.width',
|
||||
},
|
||||
],
|
||||
}}
|
||||
availability={capabilities('concat-fast')}
|
||||
onConcat={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<StructuralOperations
|
||||
timeline={TIMELINE}
|
||||
availability={capabilities('concat-fast')}
|
||||
onConcat={onConcat}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<StructuralOperations
|
||||
timeline={TIMELINE}
|
||||
exportPresets={PRESETS}
|
||||
availability={capabilities('concat-normalized')}
|
||||
onConcat={onConcat}
|
||||
/>
|
||||
);
|
||||
|
||||
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',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('requires destructive subtitle loss to be explicit for normalized concat', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConcat = vi.fn();
|
||||
render(
|
||||
<StructuralOperations
|
||||
timeline={{
|
||||
...TIMELINE,
|
||||
clips: TIMELINE.clips.map((clip, index) =>
|
||||
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',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('treats an absent fast compatibility result as unchecked', () => {
|
||||
render(
|
||||
<StructuralOperations
|
||||
timeline={{ ...TIMELINE, fastCompatibilityDiagnostics: undefined }}
|
||||
availability={capabilities('concat-fast')}
|
||||
onConcat={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<StructuralOperations
|
||||
selectedClip={SELECTED_CLIP}
|
||||
availability={capabilities('trim-fast')}
|
||||
onTrim={() => {
|
||||
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());
|
||||
});
|
||||
});
|
||||
216
tests/unit/components/waveform-editor.test.tsx
Normal file
216
tests/unit/components/waveform-editor.test.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
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 { WaveformEditor } from '../../../src/components/WaveformEditor';
|
||||
import { aggregatePcmPeaks } from '../../../src/waveform';
|
||||
|
||||
describe('WaveformEditor', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(
|
||||
createCanvasContext()
|
||||
);
|
||||
});
|
||||
|
||||
it('renders a bounded canvas with accessible trim and marker controls', async () => {
|
||||
render(
|
||||
<WaveformEditor
|
||||
peaks={createPeaks()}
|
||||
inSeconds={1}
|
||||
outSeconds={9}
|
||||
markers={[3]}
|
||||
currentTimeSeconds={5}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const canvas = document.querySelector('canvas');
|
||||
if (!(canvas instanceof HTMLCanvasElement)) {
|
||||
throw new Error('Missing waveform canvas');
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(canvas).toHaveAttribute(
|
||||
'aria-label',
|
||||
expect.stringContaining('Selected 00:00:01.000–00:00:09.000')
|
||||
)
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('slider', { name: 'In trim handle' })
|
||||
).toHaveAttribute('aria-valuenow', '1');
|
||||
expect(
|
||||
screen.getByRole('slider', { name: 'Out trim handle' })
|
||||
).toHaveAttribute('aria-valuenow', '9');
|
||||
expect(
|
||||
screen.getByRole('slider', { name: 'Split marker 1' })
|
||||
).toHaveAttribute('aria-valuenow', '3');
|
||||
expect(screen.getByText('Single sequential audio track')).toBeVisible();
|
||||
});
|
||||
|
||||
it('emits validated keyboard edits and current-time actions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<WaveformEditor
|
||||
peaks={createPeaks()}
|
||||
inSeconds={1}
|
||||
outSeconds={9}
|
||||
markers={[3]}
|
||||
currentTimeSeconds={4}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('slider', { name: 'In trim handle' }), {
|
||||
key: 'ArrowRight',
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
inSeconds: 1.1,
|
||||
outSeconds: 9,
|
||||
markers: [3],
|
||||
});
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Add split at current time' })
|
||||
);
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
inSeconds: 1,
|
||||
outSeconds: 9,
|
||||
markers: [3, 4],
|
||||
});
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('slider', { name: 'Split marker 1' }), {
|
||||
key: 'Delete',
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
inSeconds: 1,
|
||||
outSeconds: 9,
|
||||
markers: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('drags split markers and seeks using the visible zoomed range', async () => {
|
||||
const onChange = vi.fn();
|
||||
const onSeek = vi.fn();
|
||||
const { container } = render(
|
||||
<WaveformEditor
|
||||
peaks={createPeaks()}
|
||||
inSeconds={0}
|
||||
outSeconds={10}
|
||||
markers={[2]}
|
||||
onChange={onChange}
|
||||
onSeek={onSeek}
|
||||
/>
|
||||
);
|
||||
const stage = container.querySelector('.waveform-editor__stage');
|
||||
if (!(stage instanceof HTMLElement)) {
|
||||
throw new Error('Missing waveform stage');
|
||||
}
|
||||
vi.spyOn(stage, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 168,
|
||||
width: 100,
|
||||
height: 168,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
const marker = screen.getByRole('slider', { name: 'Split marker 1' });
|
||||
|
||||
fireEvent.pointerDown(marker, {
|
||||
pointerId: 11,
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
});
|
||||
fireEvent.pointerMove(marker, {
|
||||
pointerId: 11,
|
||||
clientX: 50,
|
||||
clientY: 20,
|
||||
});
|
||||
fireEvent.pointerUp(marker, { pointerId: 11 });
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
inSeconds: 0,
|
||||
outSeconds: 10,
|
||||
markers: [5],
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(stage, {
|
||||
button: 0,
|
||||
clientX: 75,
|
||||
clientY: 30,
|
||||
});
|
||||
expect(onSeek).toHaveBeenLastCalledWith(7.5);
|
||||
|
||||
fireEvent.change(screen.getByRole('slider', { name: 'Zoom waveform' }), {
|
||||
target: { value: '2' },
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('slider', { name: 'Scroll waveform' })
|
||||
).toBeEnabled();
|
||||
});
|
||||
|
||||
it('normalizes untrusted selection values and caps marker count', () => {
|
||||
render(
|
||||
<WaveformEditor
|
||||
peaks={createPeaks()}
|
||||
inSeconds={Number.NaN}
|
||||
outSeconds={999}
|
||||
markers={[
|
||||
Number.NaN,
|
||||
-1,
|
||||
2,
|
||||
2.0004,
|
||||
...Array.from({ length: 510 }, (_, index) => 2.01 + index * 0.01),
|
||||
]}
|
||||
onChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Selected 00:00:00.000–00:00:10.000/u)
|
||||
).toBeVisible();
|
||||
expect(screen.getByText('254/254')).toBeVisible();
|
||||
expect(
|
||||
screen.getAllByRole('slider', { name: /Split marker/u })
|
||||
).toHaveLength(254);
|
||||
});
|
||||
});
|
||||
|
||||
function createPeaks() {
|
||||
return aggregatePcmPeaks(
|
||||
{
|
||||
container: 'raw',
|
||||
data: new Int16Array(
|
||||
Array.from({ length: 100 }, (_, index) =>
|
||||
Math.round(Math.sin(index / 5) * 20_000)
|
||||
)
|
||||
),
|
||||
encoding: 's16le',
|
||||
sampleRate: 10,
|
||||
},
|
||||
{ bucketCount: 100 }
|
||||
);
|
||||
}
|
||||
|
||||
function createCanvasContext(): 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;
|
||||
}
|
||||
Reference in New Issue
Block a user