Files
av-tools/tests/unit/components/editor-integration.test.tsx

364 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}