feat: Phase 2.5 video player, upload UI, and LTTPage layout refactor

- VideoUpload: native drag-and-drop with axios progress bar, file validation
- VideoPlayer: forwardRef wrapper for <video> element (used by useVideoASR)
- LTTPage: replaced VideoPlaceholder, wired useVideoASR/useFullTranscript,
  Full Transcript button, resizable left/right panels (min 30%)
- Tests: 25 new (VideoUpload 8, VideoPlayer 7, LTTPage integration 10)
This commit is contained in:
Woody 2026-05-06 14:31:27 +08:00
parent a4e067822b
commit f3b94381ae
8 changed files with 859 additions and 29 deletions

View File

@ -0,0 +1,65 @@
import React, { forwardRef, useState } from 'react'
import { Loader2, AlertCircle } from 'lucide-react'
export interface VideoPlayerProps {
src: string
}
export const VideoPlayer = forwardRef<HTMLVideoElement, VideoPlayerProps>(({ src }, ref) => {
const [isLoading, setIsLoading] = useState(true)
const [hasError, setHasError] = useState(false)
const handleCanPlay = () => {
setIsLoading(false)
setHasError(false)
}
const handleError = () => {
setIsLoading(false)
setHasError(true)
}
const handleLoadStart = () => {
setIsLoading(true)
setHasError(false)
}
if (hasError) {
return (
<div
data-testid="video-error"
className="w-full max-h-60 rounded-lg bg-gray-100 border border-gray-200 flex flex-col items-center justify-center p-6 min-h-[200px]"
>
<AlertCircle className="w-10 h-10 text-red-500 mb-2" />
<div className="text-sm text-gray-600 font-medium">Failed to load video</div>
<div className="text-xs text-gray-400 mt-1">Please check the video source and try again.</div>
</div>
)
}
return (
<div className="relative w-full">
{isLoading && (
<div
data-testid="video-loading"
className="absolute inset-0 z-10 bg-gray-100 rounded-lg flex flex-col items-center justify-center"
>
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mb-2" />
<div className="text-sm text-gray-600">Loading video...</div>
</div>
)}
<video
ref={ref}
data-testid="video-player"
src={src}
controls
className="w-full max-h-60 rounded-lg bg-black"
onLoadStart={handleLoadStart}
onCanPlay={handleCanPlay}
onError={handleError}
/>
</div>
)
})
VideoPlayer.displayName = 'VideoPlayer'

View File

@ -0,0 +1,153 @@
import React, { useState, useRef, useCallback } from 'react'
import { Upload, Loader2, AlertCircle } from 'lucide-react'
import { useVideoUpload } from '../lib/queries'
import type { VideoUploadResponse } from '../types'
const SUPPORTED_TYPES = ['.mp4', '.webm', '.mov', '.avi', '.mkv']
const SUPPORTED_MIME_TYPES = ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo', 'video/x-matroska']
export interface VideoUploadProps {
onUploadSuccess: (videoId: string, url: string) => void
}
export const VideoUpload: React.FC<VideoUploadProps> = ({ onUploadSuccess }) => {
const [isDragging, setIsDragging] = useState(false)
const [uploadProgress, setUploadProgress] = useState(0)
const [fileError, setFileError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const upload = useVideoUpload()
const validateFile = (file: File): boolean => {
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
if (!SUPPORTED_TYPES.includes(ext) && !SUPPORTED_MIME_TYPES.includes(file.type)) {
setFileError(`File type "${ext || file.type}" is not supported. Please use: ${SUPPORTED_TYPES.join(', ')}`)
return false
}
return true
}
const handleUpload = useCallback((file: File) => {
setFileError(null)
setUploadProgress(0)
if (!validateFile(file)) return
upload.mutate(
{ file, onProgress: (pct) => setUploadProgress(pct) },
{
onSuccess: (data: VideoUploadResponse) => {
onUploadSuccess(data.video_id, data.url)
},
onError: (err: Error) => {
setFileError(err.message)
},
}
)
}, [upload, onUploadSuccess])
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files?.[0]
if (file) {
handleUpload(file)
}
}, [handleUpload])
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
handleUpload(file)
}
e.target.value = ''
}, [handleUpload])
const handleClickDropzone = useCallback(() => {
fileInputRef.current?.click()
}, [])
const handleRetry = useCallback(() => {
setFileError(null)
upload.reset()
}, [upload])
if (upload.isPending) {
return (
<div className="h-full flex flex-col items-center justify-center space-y-4" data-testid="video-upload-progress">
<Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
<div className="text-sm text-gray-600 font-medium">Uploading video...</div>
<div className="w-64 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-600 transition-all duration-200"
style={{ width: `${uploadProgress}%` }}
/>
</div>
<div className="text-xs text-gray-500">{uploadProgress}%</div>
</div>
)
}
const hasError = fileError || upload.isError
const errorMessage = fileError || (upload.error?.message ?? null)
return (
<div className="h-full flex flex-col">
<div
data-testid="video-dropzone"
onClick={handleClickDropzone}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={[
'flex-1 flex flex-col items-center justify-center border-2 border-dashed rounded-lg cursor-pointer transition-colors duration-200',
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300 bg-gray-50 hover:border-gray-400 hover:bg-gray-100',
].join(' ')}
>
<Upload className="w-10 h-10 text-gray-400 mb-3" />
<div className="text-sm text-gray-600 font-medium">Drag and drop a video file here</div>
<div className="text-xs text-gray-400 mt-1">or click to browse</div>
<div className="text-xs text-gray-400 mt-2">Supported: {SUPPORTED_TYPES.join(', ')}</div>
<input
ref={fileInputRef}
data-testid="video-file-input"
type="file"
accept={SUPPORTED_TYPES.join(',')}
onChange={handleFileInputChange}
className="hidden"
/>
</div>
{hasError && errorMessage && (
<div
data-testid="video-upload-error"
className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-2"
>
<AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />
<div className="flex-1">
<div className="text-sm text-red-700">{errorMessage}</div>
{upload.isError && (
<button
onClick={handleRetry}
className="mt-2 text-xs text-red-600 hover:text-red-800 font-medium underline"
>
Retry
</button>
)}
</div>
</div>
)}
</div>
)
}

View File

@ -1,31 +1,56 @@
import React from 'react'
import { Film } from 'lucide-react'
import React, { useState, useRef, useCallback, useEffect } from 'react'
import { Loader2, AlertCircle, FileText } from 'lucide-react'
import { Group, Panel, Separator } from 'react-resizable-panels'
import { useQueryDocumentStream } from '../lib/queries'
import { useVideoASR } from '../hooks/useVideoASR'
import { useFullTranscript } from '../hooks/useFullTranscript'
import { getVideoUrl } from '../lib/api'
import { QueryInput } from '../components/QueryInput'
import { ExtractedQuestionsDisplay } from '../components/ExtractedQuestionsDisplay'
import { ResponsePanel } from '../components/ResponsePanel'
const VideoPlaceholder: React.FC = () => {
return (
<div className="h-full flex items-center justify-center bg-white/50">
<div className="text-center space-y-2">
<Film className="mx-auto w-12 h-12 text-gray-600" />
<div className="text-gray-700 font-semibold">Video upload coming in Phase 2</div>
</div>
</div>
)
}
import { VideoUpload } from '../components/VideoUpload'
import { VideoPlayer } from '../components/VideoPlayer'
export const LTTPage: React.FC = () => {
const [currentVideoId, setCurrentVideoId] = useState<string | null>(null)
const [queryText, setQueryText] = useState('')
const videoRef = useRef<HTMLVideoElement>(null)
const queryStream = useQueryDocumentStream()
const asr = useVideoASR({
videoId: currentVideoId ?? '',
videoElement: videoRef.current,
language: 'yue',
onFinalTranscript: (text) => {
setQueryText(text)
},
})
const ft = useFullTranscript({ videoId: currentVideoId ?? '' })
useEffect(() => {
if (ft.fullTranscript) {
setQueryText(ft.fullTranscript)
}
}, [ft.fullTranscript])
const handleUploadSuccess = useCallback((videoId: string) => {
setCurrentVideoId(videoId)
}, [])
const handleQuerySubmit = (question: string): void => {
queryStream.mutate({ question })
}
const handleRequestFullTranscript = useCallback(() => {
ft.requestFullTranscript()
}, [ft])
const isLoading = queryStream.phase !== 'idle' && queryStream.phase !== 'completed' && queryStream.phase !== 'error'
const videoUrl = currentVideoId ? getVideoUrl(currentVideoId) : ''
return (
<div className="h-full bg-gray-50">
<Group
@ -35,18 +60,69 @@ export const LTTPage: React.FC = () => {
defaultLayout={{ 'ltt-upper-panel': 30, 'ltt-lower-panel': 70 }}
>
<Panel id="ltt-upper-panel" minSize="15%" maxSize="60%">
<div className="h-full grid grid-cols-2 min-h-0">
<div className="border-r border-gray-200 p-4 min-h-0 overflow-hidden">
<VideoPlaceholder />
<div className="h-full min-h-0">
<Group orientation="horizontal" id="ltt-upper-group" className="h-full">
<Panel id="ltt-upper-left" minSize="30%" defaultSize={50}>
<div className="h-full p-4 overflow-hidden flex flex-col gap-3">
{currentVideoId ? (
<>
<VideoPlayer ref={videoRef} src={videoUrl} />
<button
onClick={handleRequestFullTranscript}
disabled={ft.isLoading}
className="shrink-0 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
{ft.isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<FileText className="w-4 h-4" />
)}
<span>{ft.isLoading ? 'Transcribing...' : 'Full Transcript'}</span>
</button>
{ft.error && (
<div
data-testid="full-transcript-error"
className="flex items-start gap-2 text-sm text-red-600"
>
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{ft.error}</span>
</div>
<div className="p-6 flex flex-col gap-4 overflow-y-auto min-h-0">
<QueryInput onSubmit={handleQuerySubmit} isLoading={isLoading} />
)}
{asr.status === 'error' && (
<div
data-testid="asr-error-indicator"
className="flex items-center gap-2 text-xs text-red-600 bg-red-50 border border-red-200 rounded px-2 py-1"
>
<AlertCircle className="w-3 h-3" />
<span>ASR error</span>
</div>
)}
</>
) : (
<VideoUpload onUploadSuccess={handleUploadSuccess} />
)}
</div>
</Panel>
<Separator className="w-3 cursor-col-resize flex flex-col items-center justify-center bg-gray-300 hover:bg-blue-400 active:bg-blue-500 transition-colors shrink-0">
<div className="h-10 w-1.5 rounded-full bg-gray-500" />
</Separator>
<Panel id="ltt-upper-right" minSize="30%" defaultSize={50}>
<div className="h-full p-6 flex flex-col gap-4 overflow-y-auto">
<QueryInput
onSubmit={handleQuerySubmit}
isLoading={isLoading}
partialText={asr.partialTranscript}
/>
<ExtractedQuestionsDisplay
extractedQuestions={queryStream.extractedQuestions}
subQuestionSources={queryStream.subQuestionSources}
isLoading={queryStream.phase === 'decomposing'}
/>
</div>
</Panel>
</Group>
</div>
</Panel>

View File

@ -3,9 +3,9 @@ import { render, screen } from '@testing-library/react'
import App from '../../App'
describe('Layout', () => {
test('renders VideoPlaceholder with Phase 2 text', () => {
test('renders VideoUpload dropzone', () => {
render(<App />)
const text = screen.getByText(/Video upload coming in Phase 2/i)
expect(text).toBeInTheDocument()
const dropzone = screen.getByTestId('video-dropzone')
expect(dropzone).toBeInTheDocument()
})
})

View File

@ -60,7 +60,7 @@ describe('Query flow integration (App-level)', () => {
it('shows empty state initially', () => {
render(<App />)
expect(screen.getByText(/Video upload coming in Phase 2/i)).toBeInTheDocument()
expect(screen.getByText(/Drag and drop a video file here/i)).toBeInTheDocument()
expect(
screen.getByText(/Ask a question to see the answer here/i),
).toBeInTheDocument()

View File

@ -0,0 +1,313 @@
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { LTTPage } from '../pages/LTTPage'
const mockQueryStreamMutate = vi.fn()
const mockMutate = vi.fn()
const mockReset = vi.fn()
const mockRequestFullTranscript = vi.fn()
const mockStartStreaming = vi.fn()
const mockStopStreaming = vi.fn()
let mockQueryStreamPhase = 'idle'
let mockQueryStreamExtractedQuestions: string[] | null = null
let mockIsPending = false
let mockIsError = false
let mockError: Error | null = null
let mockData: import('../types').VideoUploadResponse | null = null
let mockASRTranscript = ''
let mockASRPartialTranscript = ''
let mockASRIsStreaming = false
let mockASRStatus = 'idle'
let mockFTFullTranscript = ''
let mockFTIsLoading = false
let mockFTError: string | null = null
vi.mock('../lib/queries', () => ({
useQueryDocumentStream: () => ({
phase: mockQueryStreamPhase,
extractedQuestions: mockQueryStreamExtractedQuestions,
answer: null,
sources: null,
subQuestionSources: null,
historyId: null,
error: null,
mutate: mockQueryStreamMutate,
reset: vi.fn(),
}),
useVideoUpload: () => ({
mutate: mockMutate,
isPending: mockIsPending,
isError: mockIsError,
error: mockError,
data: mockData,
reset: mockReset,
}),
}))
vi.mock('../hooks/useVideoASR', () => ({
useVideoASR: () => ({
transcript: mockASRTranscript,
partialTranscript: mockASRPartialTranscript,
isStreaming: mockASRIsStreaming,
status: mockASRStatus,
startStreaming: mockStartStreaming,
stopStreaming: mockStopStreaming,
}),
}))
vi.mock('../hooks/useFullTranscript', () => ({
useFullTranscript: () => ({
fullTranscript: mockFTFullTranscript,
isLoading: mockFTIsLoading,
error: mockFTError,
requestFullTranscript: mockRequestFullTranscript,
}),
}))
vi.mock('../lib/api', () => ({
getVideoUrl: (videoId: string) => `http://localhost:8000/api/v1/video/${videoId}`,
}))
describe('LTTPage integration (Phase 2.5)', () => {
beforeEach(() => {
vi.clearAllMocks()
mockQueryStreamMutate.mockClear()
mockMutate.mockClear()
mockReset.mockClear()
mockRequestFullTranscript.mockClear()
mockQueryStreamPhase = 'idle'
mockQueryStreamExtractedQuestions = null
mockIsPending = false
mockIsError = false
mockError = null
mockData = null
mockASRTranscript = ''
mockASRPartialTranscript = ''
mockASRIsStreaming = false
mockASRStatus = 'idle'
mockFTFullTranscript = ''
mockFTIsLoading = false
mockFTError = null
})
it('shows VideoUpload when no video uploaded', () => {
render(<LTTPage />)
expect(screen.getByTestId('video-dropzone')).toBeInTheDocument()
expect(screen.queryByTestId('video-player')).not.toBeInTheDocument()
})
it('shows QueryInput with partialText from ASR transcript', () => {
mockASRPartialTranscript = 'Hello wor'
render(<LTTPage />)
const textarea = screen.getByPlaceholderText('Ask a question about your documents...')
expect(textarea).toHaveValue('Hello wor')
expect(textarea).toHaveClass('text-gray-400')
expect(textarea).toHaveClass('italic')
})
it('shows Full Transcript button when video is present', async () => {
const mockResponse = {
video_id: 'vid-123',
filename: 'test.mp4',
size_bytes: 1024,
url: 'http://localhost:8000/api/v1/video/vid-123',
}
mockMutate.mockImplementation((_vars: any, options?: any) => {
if (options?.onSuccess) {
options.onSuccess(mockResponse)
}
})
render(<LTTPage />)
const dropzone = screen.getByTestId('video-dropzone')
const file = new File(['dummy'], 'test.mp4', { type: 'video/mp4' })
fireEvent.drop(dropzone, {
dataTransfer: {
files: [file],
},
})
await waitFor(() => {
expect(screen.getByTestId('video-player')).toBeInTheDocument()
})
expect(screen.getByRole('button', { name: /full transcript/i })).toBeInTheDocument()
})
it('Full Transcript button triggers requestFullTranscript', async () => {
const mockResponse = {
video_id: 'vid-123',
filename: 'test.mp4',
size_bytes: 1024,
url: 'http://localhost:8000/api/v1/video/vid-123',
}
mockMutate.mockImplementation((_vars: any, options?: any) => {
if (options?.onSuccess) {
options.onSuccess(mockResponse)
}
})
render(<LTTPage />)
const dropzone = screen.getByTestId('video-dropzone')
const file = new File(['dummy'], 'test.mp4', { type: 'video/mp4' })
fireEvent.drop(dropzone, {
dataTransfer: {
files: [file],
},
})
await waitFor(() => {
expect(screen.getByRole('button', { name: /full transcript/i })).toBeInTheDocument()
})
const ftButton = screen.getByRole('button', { name: /full transcript/i })
fireEvent.click(ftButton)
expect(mockRequestFullTranscript).toHaveBeenCalledTimes(1)
})
it('Full Transcript button shows loading state', async () => {
const mockResponse = {
video_id: 'vid-123',
filename: 'test.mp4',
size_bytes: 1024,
url: 'http://localhost:8000/api/v1/video/vid-123',
}
mockMutate.mockImplementation((_vars: any, options?: any) => {
if (options?.onSuccess) {
options.onSuccess(mockResponse)
}
})
mockFTIsLoading = true
render(<LTTPage />)
const dropzone = screen.getByTestId('video-dropzone')
const file = new File(['dummy'], 'test.mp4', { type: 'video/mp4' })
fireEvent.drop(dropzone, {
dataTransfer: {
files: [file],
},
})
await waitFor(() => {
const ftButton = screen.getByRole('button', { name: /transcribing/i })
expect(ftButton).toBeInTheDocument()
expect(ftButton).toBeDisabled()
})
})
it('Full Transcript button shows error message on failure', async () => {
const mockResponse = {
video_id: 'vid-123',
filename: 'test.mp4',
size_bytes: 1024,
url: 'http://localhost:8000/api/v1/video/vid-123',
}
mockMutate.mockImplementation((_vars: any, options?: any) => {
if (options?.onSuccess) {
options.onSuccess(mockResponse)
}
})
mockFTError = 'Transcription service unavailable'
render(<LTTPage />)
const dropzone = screen.getByTestId('video-dropzone')
const file = new File(['dummy'], 'test.mp4', { type: 'video/mp4' })
fireEvent.drop(dropzone, {
dataTransfer: {
files: [file],
},
})
await waitFor(() => {
expect(screen.getByTestId('full-transcript-error')).toHaveTextContent('Transcription service unavailable')
})
})
it('ASR status error shows error indicator', async () => {
const mockResponse = {
video_id: 'vid-123',
filename: 'test.mp4',
size_bytes: 1024,
url: 'http://localhost:8000/api/v1/video/vid-123',
}
mockMutate.mockImplementation((_vars: any, options?: any) => {
if (options?.onSuccess) {
options.onSuccess(mockResponse)
}
})
mockASRStatus = 'error'
render(<LTTPage />)
const dropzone = screen.getByTestId('video-dropzone')
const file = new File(['dummy'], 'test.mp4', { type: 'video/mp4' })
fireEvent.drop(dropzone, {
dataTransfer: {
files: [file],
},
})
await waitFor(() => {
expect(screen.getByTestId('asr-error-indicator')).toBeInTheDocument()
expect(screen.getByText(/asr error/i)).toBeInTheDocument()
})
})
it('partially renders all 3 panels', () => {
render(<LTTPage />)
expect(screen.getByTestId('video-dropzone')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Ask a question about your documents...')).toBeInTheDocument()
expect(screen.getByText(/Ask a question to see the answer here/i)).toBeInTheDocument()
})
it('submit button calls handleQuerySubmit with question text', () => {
render(<LTTPage />)
const textarea = screen.getByPlaceholderText('Ask a question about your documents...')
const button = screen.getByRole('button', { name: /submit/i })
fireEvent.change(textarea, { target: { value: 'What is NEC4?' } })
fireEvent.click(button)
expect(mockQueryStreamMutate).toHaveBeenCalledWith({ question: 'What is NEC4?' })
})
it('ExtractedQuestionsDisplay renders in right panel when decomposing', () => {
mockQueryStreamPhase = 'decomposing'
render(<LTTPage />)
expect(screen.getAllByTestId('extracted-question-skeleton')).toHaveLength(3)
})
})

View File

@ -0,0 +1,77 @@
/**
* Phase 2.5 tests: VideoPlayer component.
*
* Covers:
* - Renders video element with correct src
* - Renders controls attribute
* - Shows loading state when video not loaded
* - Forwards ref to video element
* - Handles video error state
* - Has proper dimensions styling
* - Renders without crashing with null src
*/
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { VideoPlayer } from '../components/VideoPlayer'
describe('VideoPlayer (Phase 2.5)', () => {
const mockSrc = 'http://localhost:8000/api/v1/video/vid-123'
beforeEach(() => {
vi.clearAllMocks()
})
it('renders video element with correct src', () => {
render(<VideoPlayer src={mockSrc} />)
const video = screen.getByTestId('video-player')
expect(video).toBeInTheDocument()
expect(video).toHaveAttribute('src', mockSrc)
})
it('renders controls attribute', () => {
render(<VideoPlayer src={mockSrc} />)
const video = screen.getByTestId('video-player')
expect(video).toHaveAttribute('controls')
})
it('shows loading state when video not loaded', () => {
render(<VideoPlayer src={mockSrc} />)
expect(screen.getByTestId('video-loading')).toBeInTheDocument()
})
it('forwards ref to video element', () => {
const ref = React.createRef<HTMLVideoElement>()
render(<VideoPlayer ref={ref} src={mockSrc} />)
expect(ref.current).not.toBeNull()
expect(ref.current?.tagName.toLowerCase()).toBe('video')
})
it('handles video error state', () => {
render(<VideoPlayer src={mockSrc} />)
const video = screen.getByTestId('video-player')
fireEvent.error(video)
expect(screen.getByTestId('video-error')).toBeInTheDocument()
expect(screen.getByText(/failed to load video/i)).toBeInTheDocument()
})
it('has proper dimensions styling', () => {
render(<VideoPlayer src={mockSrc} />)
const video = screen.getByTestId('video-player')
expect(video).toHaveClass('w-full')
expect(video).toHaveClass('max-h-60')
})
it('renders without crashing with null src', () => {
render(<VideoPlayer src="" />)
expect(screen.getByTestId('video-player')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,146 @@
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { VideoUpload } from '../components/VideoUpload'
const mockMutate = vi.fn()
const mockReset = vi.fn()
let mockIsPending = false
let mockIsError = false
let mockError: Error | null = null
let mockData: import('../types').VideoUploadResponse | null = null
vi.mock('../lib/queries', () => ({
useVideoUpload: () => ({
mutate: mockMutate,
isPending: mockIsPending,
isError: mockIsError,
error: mockError,
data: mockData,
reset: mockReset,
}),
}))
describe('VideoUpload (Phase 2.5)', () => {
const mockOnUploadSuccess = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockMutate.mockClear()
mockReset.mockClear()
mockIsPending = false
mockIsError = false
mockError = null
mockData = null
})
it('renders dropzone in idle state', () => {
render(<VideoUpload onUploadSuccess={mockOnUploadSuccess} />)
const dropzone = screen.getByTestId('video-dropzone')
expect(dropzone).toBeInTheDocument()
expect(screen.getByText(/drag.*drop.*video/i)).toBeInTheDocument()
expect(screen.getByText(/click.*browse/i)).toBeInTheDocument()
})
it('accepts drag-over event on dropzone', () => {
render(<VideoUpload onUploadSuccess={mockOnUploadSuccess} />)
const dropzone = screen.getByTestId('video-dropzone')
fireEvent.dragOver(dropzone)
expect(dropzone).toHaveClass('border-blue-500')
expect(dropzone).toHaveClass('bg-blue-50')
})
it('shows progress bar during upload', () => {
mockIsPending = true
render(<VideoUpload onUploadSuccess={mockOnUploadSuccess} />)
const progressBar = screen.getByTestId('video-upload-progress')
expect(progressBar).toBeInTheDocument()
expect(screen.getByText(/uploading video/i)).toBeInTheDocument()
})
it('calls onUploadSuccess after successful upload', async () => {
const mockResponse = {
video_id: 'vid-123',
filename: 'test.mp4',
size_bytes: 1024,
url: 'http://localhost:8000/api/v1/video/vid-123',
}
mockMutate.mockImplementation((_vars: any, options?: any) => {
if (options?.onSuccess) {
options.onSuccess(mockResponse)
}
})
render(<VideoUpload onUploadSuccess={mockOnUploadSuccess} />)
const dropzone = screen.getByTestId('video-dropzone')
const file = new File(['dummy'], 'test.mp4', { type: 'video/mp4' })
fireEvent.drop(dropzone, {
dataTransfer: {
files: [file],
},
})
await waitFor(() => {
expect(mockOnUploadSuccess).toHaveBeenCalledWith('vid-123', mockResponse.url)
})
})
it('shows error message on upload failure', () => {
const errorMessage = 'Network error occurred'
mockIsError = true
mockError = new Error(errorMessage)
render(<VideoUpload onUploadSuccess={mockOnUploadSuccess} />)
const errorEl = screen.getByTestId('video-upload-error')
expect(errorEl).toBeInTheDocument()
expect(errorEl).toHaveTextContent(errorMessage)
})
it('validates unsupported file type', () => {
render(<VideoUpload onUploadSuccess={mockOnUploadSuccess} />)
const dropzone = screen.getByTestId('video-dropzone')
const file = new File(['dummy'], 'test.exe', { type: 'application/x-msdownload' })
fireEvent.drop(dropzone, {
dataTransfer: {
files: [file],
},
})
expect(screen.getByTestId('video-upload-error')).toHaveTextContent(/not supported/i)
expect(mockMutate).not.toHaveBeenCalled()
})
it('renders file input for click-to-browse fallback', () => {
render(<VideoUpload onUploadSuccess={mockOnUploadSuccess} />)
const fileInput = screen.getByTestId('video-file-input')
expect(fileInput).toBeInTheDocument()
expect(fileInput).toHaveAttribute('type', 'file')
expect(fileInput).toHaveAttribute('accept', '.mp4,.webm,.mov,.avi,.mkv')
})
it('retry button appears after error', () => {
mockIsError = true
mockError = new Error('Upload failed')
render(<VideoUpload onUploadSuccess={mockOnUploadSuccess} />)
const retryButton = screen.getByRole('button', { name: /retry/i })
expect(retryButton).toBeInTheDocument()
fireEvent.click(retryButton)
expect(mockReset).toHaveBeenCalled()
})
})