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:
parent
a4e067822b
commit
f3b94381ae
|
|
@ -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'
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,31 +1,56 @@
|
||||||
import React from 'react'
|
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
||||||
import { Film } from 'lucide-react'
|
import { Loader2, AlertCircle, FileText } from 'lucide-react'
|
||||||
import { Group, Panel, Separator } from 'react-resizable-panels'
|
import { Group, Panel, Separator } from 'react-resizable-panels'
|
||||||
import { useQueryDocumentStream } from '../lib/queries'
|
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 { QueryInput } from '../components/QueryInput'
|
||||||
import { ExtractedQuestionsDisplay } from '../components/ExtractedQuestionsDisplay'
|
import { ExtractedQuestionsDisplay } from '../components/ExtractedQuestionsDisplay'
|
||||||
import { ResponsePanel } from '../components/ResponsePanel'
|
import { ResponsePanel } from '../components/ResponsePanel'
|
||||||
|
import { VideoUpload } from '../components/VideoUpload'
|
||||||
const VideoPlaceholder: React.FC = () => {
|
import { VideoPlayer } from '../components/VideoPlayer'
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LTTPage: React.FC = () => {
|
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 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 => {
|
const handleQuerySubmit = (question: string): void => {
|
||||||
queryStream.mutate({ question })
|
queryStream.mutate({ question })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRequestFullTranscript = useCallback(() => {
|
||||||
|
ft.requestFullTranscript()
|
||||||
|
}, [ft])
|
||||||
|
|
||||||
const isLoading = queryStream.phase !== 'idle' && queryStream.phase !== 'completed' && queryStream.phase !== 'error'
|
const isLoading = queryStream.phase !== 'idle' && queryStream.phase !== 'completed' && queryStream.phase !== 'error'
|
||||||
|
|
||||||
|
const videoUrl = currentVideoId ? getVideoUrl(currentVideoId) : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full bg-gray-50">
|
<div className="h-full bg-gray-50">
|
||||||
<Group
|
<Group
|
||||||
|
|
@ -35,18 +60,69 @@ export const LTTPage: React.FC = () => {
|
||||||
defaultLayout={{ 'ltt-upper-panel': 30, 'ltt-lower-panel': 70 }}
|
defaultLayout={{ 'ltt-upper-panel': 30, 'ltt-lower-panel': 70 }}
|
||||||
>
|
>
|
||||||
<Panel id="ltt-upper-panel" minSize="15%" maxSize="60%">
|
<Panel id="ltt-upper-panel" minSize="15%" maxSize="60%">
|
||||||
<div className="h-full grid grid-cols-2 min-h-0">
|
<div className="h-full min-h-0">
|
||||||
<div className="border-r border-gray-200 p-4 min-h-0 overflow-hidden">
|
<Group orientation="horizontal" id="ltt-upper-group" className="h-full">
|
||||||
<VideoPlaceholder />
|
<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>
|
||||||
<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
|
<ExtractedQuestionsDisplay
|
||||||
extractedQuestions={queryStream.extractedQuestions}
|
extractedQuestions={queryStream.extractedQuestions}
|
||||||
subQuestionSources={queryStream.subQuestionSources}
|
subQuestionSources={queryStream.subQuestionSources}
|
||||||
isLoading={queryStream.phase === 'decomposing'}
|
isLoading={queryStream.phase === 'decomposing'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@ import { render, screen } from '@testing-library/react'
|
||||||
import App from '../../App'
|
import App from '../../App'
|
||||||
|
|
||||||
describe('Layout', () => {
|
describe('Layout', () => {
|
||||||
test('renders VideoPlaceholder with Phase 2 text', () => {
|
test('renders VideoUpload dropzone', () => {
|
||||||
render(<App />)
|
render(<App />)
|
||||||
const text = screen.getByText(/Video upload coming in Phase 2/i)
|
const dropzone = screen.getByTestId('video-dropzone')
|
||||||
expect(text).toBeInTheDocument()
|
expect(dropzone).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ describe('Query flow integration (App-level)', () => {
|
||||||
it('shows empty state initially', () => {
|
it('shows empty state initially', () => {
|
||||||
render(<App />)
|
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(
|
expect(
|
||||||
screen.getByText(/Ask a question to see the answer here/i),
|
screen.getByText(/Ask a question to see the answer here/i),
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue