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