diff --git a/frontend/src/components/VideoPlayer.tsx b/frontend/src/components/VideoPlayer.tsx new file mode 100644 index 0000000..059d6aa --- /dev/null +++ b/frontend/src/components/VideoPlayer.tsx @@ -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(({ 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 ( +
+ +
Failed to load video
+
Please check the video source and try again.
+
+ ) + } + + return ( +
+ {isLoading && ( +
+ +
Loading video...
+
+ )} +
+ ) +}) + +VideoPlayer.displayName = 'VideoPlayer' diff --git a/frontend/src/components/VideoUpload.tsx b/frontend/src/components/VideoUpload.tsx new file mode 100644 index 0000000..9abd12f --- /dev/null +++ b/frontend/src/components/VideoUpload.tsx @@ -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 = ({ onUploadSuccess }) => { + const [isDragging, setIsDragging] = useState(false) + const [uploadProgress, setUploadProgress] = useState(0) + const [fileError, setFileError] = useState(null) + const fileInputRef = useRef(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) => { + e.preventDefault() + setIsDragging(true) + }, []) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + }, []) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + setIsDragging(false) + + const file = e.dataTransfer.files?.[0] + if (file) { + handleUpload(file) + } + }, [handleUpload]) + + const handleFileInputChange = useCallback((e: React.ChangeEvent) => { + 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 ( +
+ +
Uploading video...
+
+
+
+
{uploadProgress}%
+
+ ) + } + + const hasError = fileError || upload.isError + const errorMessage = fileError || (upload.error?.message ?? null) + + return ( +
+
+ +
Drag and drop a video file here
+
or click to browse
+
Supported: {SUPPORTED_TYPES.join(', ')}
+ +
+ + {hasError && errorMessage && ( +
+ +
+
{errorMessage}
+ {upload.isError && ( + + )} +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/LTTPage.tsx b/frontend/src/pages/LTTPage.tsx index 3f7e83b..b536444 100644 --- a/frontend/src/pages/LTTPage.tsx +++ b/frontend/src/pages/LTTPage.tsx @@ -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 ( -
-
- -
Video upload coming in Phase 2
-
-
- ) -} +import { VideoUpload } from '../components/VideoUpload' +import { VideoPlayer } from '../components/VideoPlayer' export const LTTPage: React.FC = () => { + const [currentVideoId, setCurrentVideoId] = useState(null) + const [queryText, setQueryText] = useState('') + const videoRef = useRef(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 (
{ defaultLayout={{ 'ltt-upper-panel': 30, 'ltt-lower-panel': 70 }} > -
-
- -
-
- - -
+
+ + +
+ {currentVideoId ? ( + <> + + + {ft.error && ( +
+ + {ft.error} +
+ )} + {asr.status === 'error' && ( +
+ + ASR error +
+ )} + + ) : ( + + )} +
+
+ + +
+ + + +
+ + +
+
+
diff --git a/frontend/src/test/components/Layout.test.tsx b/frontend/src/test/components/Layout.test.tsx index ac9e532..bde0465 100644 --- a/frontend/src/test/components/Layout.test.tsx +++ b/frontend/src/test/components/Layout.test.tsx @@ -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() - const text = screen.getByText(/Video upload coming in Phase 2/i) - expect(text).toBeInTheDocument() + const dropzone = screen.getByTestId('video-dropzone') + expect(dropzone).toBeInTheDocument() }) }) diff --git a/frontend/src/test/e2e/query_flow.test.tsx b/frontend/src/test/e2e/query_flow.test.tsx index d60a4ab..f9b7654 100644 --- a/frontend/src/test/e2e/query_flow.test.tsx +++ b/frontend/src/test/e2e/query_flow.test.tsx @@ -60,7 +60,7 @@ describe('Query flow integration (App-level)', () => { it('shows empty state initially', () => { render() - 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() diff --git a/frontend/src/test/test_phase2_LTTPage_integration.test.tsx b/frontend/src/test/test_phase2_LTTPage_integration.test.tsx new file mode 100644 index 0000000..31c3190 --- /dev/null +++ b/frontend/src/test/test_phase2_LTTPage_integration.test.tsx @@ -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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + expect(screen.getAllByTestId('extracted-question-skeleton')).toHaveLength(3) + }) +}) diff --git a/frontend/src/test/test_phase2_VideoPlayer.test.tsx b/frontend/src/test/test_phase2_VideoPlayer.test.tsx new file mode 100644 index 0000000..235fdc2 --- /dev/null +++ b/frontend/src/test/test_phase2_VideoPlayer.test.tsx @@ -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() + + const video = screen.getByTestId('video-player') + expect(video).toBeInTheDocument() + expect(video).toHaveAttribute('src', mockSrc) + }) + + it('renders controls attribute', () => { + render() + + const video = screen.getByTestId('video-player') + expect(video).toHaveAttribute('controls') + }) + + it('shows loading state when video not loaded', () => { + render() + + expect(screen.getByTestId('video-loading')).toBeInTheDocument() + }) + + it('forwards ref to video element', () => { + const ref = React.createRef() + render() + + expect(ref.current).not.toBeNull() + expect(ref.current?.tagName.toLowerCase()).toBe('video') + }) + + it('handles video error state', () => { + render() + + 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() + + 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() + + expect(screen.getByTestId('video-player')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/test_phase2_VideoUpload.test.tsx b/frontend/src/test/test_phase2_VideoUpload.test.tsx new file mode 100644 index 0000000..e10bf9c --- /dev/null +++ b/frontend/src/test/test_phase2_VideoUpload.test.tsx @@ -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() + + 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() + + 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() + + 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() + + 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() + + const errorEl = screen.getByTestId('video-upload-error') + expect(errorEl).toBeInTheDocument() + expect(errorEl).toHaveTextContent(errorMessage) + }) + + it('validates unsupported file type', () => { + render() + + 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() + + 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() + + const retryButton = screen.getByRole('button', { name: /retry/i }) + expect(retryButton).toBeInTheDocument() + + fireEvent.click(retryButton) + expect(mockReset).toHaveBeenCalled() + }) +})