import type { ReactNode } from 'react' import { act, fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { MultiTrackSegmentBlock } from '@/components/widgets/multitrack/MultiTrackSegmentBlock' import { TooltipProvider } from '@/components/ui/tooltip' import type { MultiTrackSegment, MultiTrackType } from '@/types/multitrack' vi.mock('@/components/ui/context-menu', () => ({ ContextMenu: ({ children }: { children: ReactNode }) => <>{children}, ContextMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}, ContextMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, ContextMenuItem: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => ( ), ContextMenuSub: ({ children }: { children: ReactNode }) => <>{children}, ContextMenuSubContent: ({ children }: { children: ReactNode }) =>
{children}
, ContextMenuSubTrigger: ({ children }: { children: ReactNode }) =>
{children}
, })) vi.mock('@/components/widgets/timeline/AudioWaveform', () => ({ AudioWaveform: ({ startRatio, endRatio }: { startRatio?: number; endRatio?: number }) => ( ), })) function segment(type: MultiTrackType): MultiTrackSegment { return { id: `${type}-segment`, start_frame: 0, end_frame: 5, color: 'var(--primary)', content: { media_type: type === 'video' ? 'video' : type === 'audio' ? 'audio' : 'none', task_mode: type === 'task' ? 'default' : undefined, }, } } function renderBlock(trackType: MultiTrackType) { const onDelete = vi.fn() const onDistribute = vi.fn() const onClone = vi.fn() const onMerge = vi.fn() const onSplitTask = vi.fn() const onSmartSplit = vi.fn() const onSmartSplitTasks = vi.fn() const onRecognizeSubtitles = vi.fn() render( , ) return { onDelete, onDistribute, onClone, onMerge, onSplitTask, onSmartSplit, onSmartSplitTasks, onRecognizeSubtitles } } describe('MultiTrackSegmentBlock context menu', () => { beforeEach(() => { vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }) }) it('toggles the track lock from a highlighted audio-segment control while trimming', () => { const onAudioLockToggle = vi.fn() const { container } = render( , ) const lockButton = screen.getByTestId('audio-segment-lock') expect(lockButton.className).toContain('text-highlight') expect(container.querySelector('[data-multitrack-segment]')?.className).toContain('overflow-visible') expect(lockButton.className).toContain('right-2') fireEvent.click(lockButton) expect(onAudioLockToggle).toHaveBeenCalledWith(false) }) it('places the shared-reference control left of the smaller audio lock control', () => { const onSharedReferenceToggle = vi.fn() render( , ) const sharedButton = screen.getByTestId('audio-shared-reference') expect(sharedButton.className).toContain('right-8') expect(sharedButton.className).toContain('h-5') expect(screen.getByTestId('audio-segment-lock').className).toContain('h-5') fireEvent.click(sharedButton) expect(onSharedReferenceToggle).toHaveBeenCalledWith(false) }) it('shows the same audio lock control on a video segment', () => { const onAudioLockToggle = vi.fn() render( , ) fireEvent.click(screen.getByTestId('video-segment-audio-lock')) expect(onAudioLockToggle).toHaveBeenCalledWith(true) expect(screen.getByTestId('video-shared-reference').className).toContain('right-8') }) it('offers distribute, clone, merge, split, and delete actions for task segments', () => { const { onDelete, onDistribute, onClone, onMerge, onSplitTask } = renderBlock('task') expect(screen.getByText(/Segment 1/)).not.toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Distribute segments evenly' })) fireEvent.click(screen.getByRole('button', { name: 'Clone segment' })) fireEvent.click(screen.getByRole('button', { name: 'Merge segments' })) fireEvent.click(screen.getByRole('button', { name: 'Split segment' })) fireEvent.click(screen.getByRole('button', { name: 'Delete segment' })) expect(onDistribute).toHaveBeenCalledOnce() expect(onClone).toHaveBeenCalledWith('task-segment') expect(onMerge).toHaveBeenCalledOnce() expect(onSplitTask).toHaveBeenCalledWith('task-segment') expect(onDelete).toHaveBeenCalledWith('task-segment') }) it('hides the task index in marker mode and tiles images with prompt text in overview mode', () => { const { container } = render( , ) expect(screen.queryByText(/Task3/)).toBeNull() expect(screen.getByText('A prompt shown at the bottom')).not.toBeNull() expect(screen.queryByText('00:00:05')).toBeNull() expect(container.querySelectorAll('img').length).toBeGreaterThan(1) expect(container.querySelector('img')?.getAttribute('src')).toContain('reference.png') }) it('clamps a text-only task prompt to three lines in overview mode without showing its duration', () => { render( , ) const prompt = screen.getByText('A long text-only task prompt') expect(prompt.className).toContain('line-clamp-3') expect(screen.queryByText('00:00:05')).toBeNull() }) it('offers clone and delete actions for subtitle segments', () => { const { onClone, onDelete } = renderBlock('subtitle') expect(screen.queryByRole('button', { name: 'Distribute segments evenly' })).toBeNull() expect(screen.queryByRole('button', { name: 'Split segment' })).toBeNull() fireEvent.click(screen.getByRole('button', { name: 'Clone segment' })) fireEvent.click(screen.getByRole('button', { name: 'Delete segment' })) expect(onClone).toHaveBeenCalledWith('subtitle-segment') expect(onDelete).toHaveBeenCalledWith('subtitle-segment') }) it('offers smart split for video segments', () => { const { onSmartSplit, onSmartSplitTasks } = renderBlock('video') fireEvent.click(screen.getByRole('button', { name: 'Smart split' })) fireEvent.click(screen.getByRole('button', { name: 'Smart split (tasks only)' })) expect(onSmartSplit).toHaveBeenCalledWith('video-segment') expect(onSmartSplitTasks).toHaveBeenCalledWith('video-segment') }) it.each([ ['video', 'Clone video'], ['audio', 'Clone audio'], ] as const)('offers the matching clone action for %s segments', (trackType, actionName) => { const { onClone } = renderBlock(trackType) fireEvent.click(screen.getByRole('button', { name: actionName })) expect(onClone).toHaveBeenCalledWith(`${trackType}-segment`) }) it.each(['video', 'audio'] as const)('offers subtitle recognition for %s segments', (trackType) => { const { onRecognizeSubtitles } = renderBlock(trackType) fireEvent.click(screen.getByRole('button', { name: 'Qwen3-ASR' })) fireEvent.click(screen.getByRole('button', { name: 'Whisper Large V3' })) expect(onRecognizeSubtitles).toHaveBeenCalledWith(`${trackType}-segment`, 'qwen3-asr') expect(onRecognizeSubtitles).toHaveBeenCalledWith(`${trackType}-segment`, 'whisper-large-v3') }) it('cuts at the clicked frame without starting a drag', () => { const onCut = vi.fn() const { container } = render( , ) const block = container.querySelector('[role="button"]') as HTMLElement vi.spyOn(block, 'getBoundingClientRect').mockReturnValue({ left: 0, right: 200, top: 0, bottom: 50, width: 200, height: 50, x: 0, y: 0, toJSON: () => ({}), }) fireEvent.mouseDown(block, { button: 0, clientX: 100 }) fireEvent.click(block, { clientX: 100 }) expect(block.style.cursor).toBe('text') expect(onCut).toHaveBeenCalledWith('video-segment', 3) }) it('maps right-edge trimming to the pointer timeline position regardless of grab offset', () => { const onResize = vi.fn() const { container } = render( , ) const block = container.querySelector('[role="button"]') as HTMLElement vi.spyOn(block, 'getBoundingClientRect').mockReturnValue({ left: 0, right: 480, top: 0, bottom: 50, width: 480, height: 50, x: 0, y: 0, toJSON: () => ({}), }) fireEvent.mouseDown(block, { button: 0, clientX: 474, clientY: 25 }) act(() => { window.dispatchEvent(new MouseEvent('mousemove', { clientX: 320, clientY: 25 })) window.dispatchEvent(new MouseEvent('mouseup', { clientX: 320, clientY: 25 })) }) expect(onResize).toHaveBeenCalledWith('video-segment', 'end', 240, expect.any(Number)) }) it('trims a video to 12 seconds when task gaps extend the timeline to 20 seconds', () => { const onResize = vi.fn() const { container } = render( , ) const block = container.querySelector('[role="button"]') as HTMLElement vi.spyOn(block, 'getBoundingClientRect').mockReturnValue({ left: 0, right: 360, top: 0, bottom: 50, width: 360, height: 50, x: 0, y: 0, toJSON: () => ({}), }) fireEvent.mouseDown(block, { button: 0, clientX: 358, clientY: 25 }) act(() => { window.dispatchEvent(new MouseEvent('mousemove', { clientX: 288, clientY: 25 })) window.dispatchEvent(new MouseEvent('mouseup', { clientX: 288, clientY: 25 })) }) expect(onResize).toHaveBeenCalledWith('video-segment', 'end', 288, expect.any(Number)) }) it('does not open the video replacement action when double-clicking in cut mode', () => { const onDoubleClick = vi.fn() const { container } = render( , ) fireEvent.doubleClick(container.querySelector('[role="button"]') as HTMLElement) expect(onDoubleClick).not.toHaveBeenCalled() }) it('renders a waveform canvas for audio segments', () => { const { container } = render( , ) const canvas = container.querySelector('canvas') expect(canvas).not.toBeNull() expect(canvas?.parentElement?.className).toContain('flex-1') }) it('renders only the waveform range visible after trimming an audio segment', () => { const { container } = render( , ) const canvas = container.querySelector('canvas') expect(canvas?.dataset.startRatio).toBe('0.25') expect(canvas?.dataset.endRatio).toBe('0.75') }) })