From 694b4f3be9b71b6628d5ecfe1968b81d3769f0db Mon Sep 17 00:00:00 2001 From: Peifan Li Date: Sun, 28 Dec 2025 21:07:56 -0500 Subject: [PATCH] test(useVideoHoverPreview): Add hover delay for desktop --- .../__tests__/FullscreenControl.test.tsx | 29 ++++ .../__tests__/LoopControl.test.tsx | 30 ++++ .../__tests__/SubtitleControl.test.tsx | 64 ++++++++ .../__tests__/CollectionContext.test.tsx | 142 +++++++++++++++++ .../__tests__/DownloadContext.test.tsx | 149 ++++++++++++++++++ .../contexts/__tests__/VideoContext.test.tsx | 143 +++++++++++++++++ .../src/hooks/__tests__/useShareVideo.test.ts | 52 ++++++ .../__tests__/useVideoCardActions.test.ts | 91 +++++++++++ .../__tests__/useVideoCardMetadata.test.ts | 60 +++++++ .../__tests__/useVideoCardNavigation.test.ts | 45 ++++++ .../__tests__/useVideoCollections.test.ts | 79 ++++++++++ .../__tests__/useVideoHoverPreview.test.ts | 62 ++++++++ 12 files changed, 946 insertions(+) create mode 100644 frontend/src/components/VideoPlayer/VideoControls/__tests__/FullscreenControl.test.tsx create mode 100644 frontend/src/components/VideoPlayer/VideoControls/__tests__/LoopControl.test.tsx create mode 100644 frontend/src/components/VideoPlayer/VideoControls/__tests__/SubtitleControl.test.tsx create mode 100644 frontend/src/contexts/__tests__/CollectionContext.test.tsx create mode 100644 frontend/src/contexts/__tests__/DownloadContext.test.tsx create mode 100644 frontend/src/contexts/__tests__/VideoContext.test.tsx create mode 100644 frontend/src/hooks/__tests__/useShareVideo.test.ts create mode 100644 frontend/src/hooks/__tests__/useVideoCardActions.test.ts create mode 100644 frontend/src/hooks/__tests__/useVideoCardMetadata.test.ts create mode 100644 frontend/src/hooks/__tests__/useVideoCardNavigation.test.ts create mode 100644 frontend/src/hooks/__tests__/useVideoCollections.test.ts create mode 100644 frontend/src/hooks/__tests__/useVideoHoverPreview.test.ts diff --git a/frontend/src/components/VideoPlayer/VideoControls/__tests__/FullscreenControl.test.tsx b/frontend/src/components/VideoPlayer/VideoControls/__tests__/FullscreenControl.test.tsx new file mode 100644 index 0000000..31fb4cf --- /dev/null +++ b/frontend/src/components/VideoPlayer/VideoControls/__tests__/FullscreenControl.test.tsx @@ -0,0 +1,29 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import FullscreenControl from '../FullscreenControl'; + +// Mock useLanguage +vi.mock('../../../../contexts/LanguageContext', () => ({ + useLanguage: () => ({ t: (key: string) => key }) +})); + +describe('FullscreenControl', () => { + it('should render enter fullscreen icon initially', () => { + render( { }} />); + expect(screen.getByRole('button')).toBeInTheDocument(); + // Check for specific icon if possible, or just button presence + }); + + it('should render exit fullscreen icon when active', () => { + render( { }} />); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('should call onToggle when clicked', () => { + const toggleMock = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button')); + expect(toggleMock).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/VideoPlayer/VideoControls/__tests__/LoopControl.test.tsx b/frontend/src/components/VideoPlayer/VideoControls/__tests__/LoopControl.test.tsx new file mode 100644 index 0000000..49639df --- /dev/null +++ b/frontend/src/components/VideoPlayer/VideoControls/__tests__/LoopControl.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import LoopControl from '../LoopControl'; + +// Mock useLanguage +vi.mock('../../../../contexts/LanguageContext', () => ({ + useLanguage: () => ({ t: (key: string) => key }) +})); + +describe('LoopControl', () => { + it('should render loop button', () => { + render( { }} />); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('should show active color when looping', () => { + render( { }} />); + const button = screen.getByRole('button'); + expect(button).toBeInTheDocument(); + // Check class or style if needed, but presence is good first step + }); + + it('should call onToggle when clicked', () => { + const toggleMock = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button')); + expect(toggleMock).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/VideoPlayer/VideoControls/__tests__/SubtitleControl.test.tsx b/frontend/src/components/VideoPlayer/VideoControls/__tests__/SubtitleControl.test.tsx new file mode 100644 index 0000000..1cedf17 --- /dev/null +++ b/frontend/src/components/VideoPlayer/VideoControls/__tests__/SubtitleControl.test.tsx @@ -0,0 +1,64 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import SubtitleControl from '../SubtitleControl'; + +// Correct mock path (4 levels deep) +vi.mock('../../../../contexts/LanguageContext', () => ({ + useLanguage: () => ({ t: (key: string) => key }) +})); + +describe('SubtitleControl', () => { + // Props suitable for the component + const defaultProps = { + subtitles: [ + { language: 'en', filename: 'sub.en.vtt', path: '/subs/sub.en.vtt' }, + { language: 'es', filename: 'sub.es.vtt', path: '/subs/sub.es.vtt' } + ], + subtitlesEnabled: false, + subtitleMenuAnchor: null, + onSubtitleClick: vi.fn(), + onCloseMenu: vi.fn(), + onSelectSubtitle: vi.fn(), + showOnMobile: false + }; + + it('should render subtitle button when subtitles exist', () => { + render(); + expect(screen.getByRole('button')).toBeInTheDocument(); + // Check for icon (using testid or implied presence) + // Since we don't have icon testids handy in source without checking imports, using getByRole is safe. + }); + + it('should not render anything if no subtitles', () => { + render(); + const button = screen.queryByRole('button'); + expect(button).not.toBeInTheDocument(); + }); + + it('should call onSubtitleClick when clicked', () => { + render(); + const button = screen.getByRole('button'); + fireEvent.click(button); + expect(defaultProps.onSubtitleClick).toHaveBeenCalled(); + }); + + it('should render menu when anchor is provided', () => { + // Create a dummy anchor + const anchor = document.createElement('div'); + render(); + + // Menu should be open, verify items + // MUI Menu renders into a portal, so queryByText for items + expect(screen.getByText('EN')).toBeInTheDocument(); + expect(screen.getByText('ES')).toBeInTheDocument(); + }); + + it('should call onSelectSubtitle when item clicked', () => { + const anchor = document.createElement('div'); + render(); + + const enOption = screen.getByText('EN'); + fireEvent.click(enOption); + expect(defaultProps.onSelectSubtitle).toHaveBeenCalledWith(0); + }); +}); diff --git a/frontend/src/contexts/__tests__/CollectionContext.test.tsx b/frontend/src/contexts/__tests__/CollectionContext.test.tsx new file mode 100644 index 0000000..7f6374d --- /dev/null +++ b/frontend/src/contexts/__tests__/CollectionContext.test.tsx @@ -0,0 +1,142 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import axios from 'axios'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CollectionProvider, useCollection } from '../CollectionContext'; +import { LanguageProvider } from '../LanguageContext'; +import { SnackbarProvider } from '../SnackbarContext'; + +// Mock axios +vi.mock('axios'); +const mockedAxios = vi.mocked(axios, true); + +// Wrappers +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + + + + {children} + + + + ); +}; + +describe('CollectionContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Mock global localStorage + const storageMock: Record = {}; + Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn((key) => storageMock[key] || null), + setItem: vi.fn((key, value) => { + storageMock[key] = value.toString(); + }), + clear: vi.fn(() => { + for (const key in storageMock) delete storageMock[key]; + }), + removeItem: vi.fn((key) => delete storageMock[key]), + length: 0, + key: vi.fn(), + }, + writable: true + }); + + // Default mocks + mockedAxios.get.mockResolvedValue({ data: [] }); + }); + + it('should provide collections data', async () => { + const mockCollections = [{ id: '1', name: 'My Collection', videos: [] }]; + mockedAxios.get.mockResolvedValueOnce({ data: mockCollections }); + + const { result } = renderHook(() => useCollection(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.collections).toEqual(mockCollections); + }); + }); + + it('should create a collection', async () => { + const mockCollection = { id: 'new', name: 'New Col', videos: ['v1'] }; + mockedAxios.post.mockResolvedValueOnce({ data: mockCollection }); + + const { result } = renderHook(() => useCollection(), { wrapper: createWrapper() }); + + await act(async () => { + await result.current.createCollection('New Col', 'v1'); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining('/collections'), + { name: 'New Col', videoId: 'v1' } + ); + }); + + it('should add video to collection', async () => { + mockedAxios.put.mockResolvedValueOnce({ data: { success: true } }); + + const { result } = renderHook(() => useCollection(), { wrapper: createWrapper() }); + + await act(async () => { + await result.current.addToCollection('col1', 'vid1'); + }); + + expect(mockedAxios.put).toHaveBeenCalledWith( + expect.stringContaining('/collections/col1'), + { videoId: 'vid1', action: 'add' } + ); + }); + + it('should remove video from collection', async () => { + const mockCollections = [{ id: '1', name: 'C1', videos: ['vid1'] }]; + // First get is initialization + mockedAxios.get.mockResolvedValueOnce({ data: mockCollections }); + + const { result } = renderHook(() => useCollection(), { wrapper: createWrapper() }); + + // Wait for load + await waitFor(() => { + expect(result.current.collections).toHaveLength(1); + }); + + mockedAxios.put.mockResolvedValueOnce({ data: { success: true } }); + + await act(async () => { + await result.current.removeFromCollection('vid1'); + }); + + expect(mockedAxios.put).toHaveBeenCalledWith( + expect.stringContaining('/collections/1'), + { videoId: 'vid1', action: 'remove' } + ); + }); + + it('should delete a collection', async () => { + mockedAxios.delete.mockResolvedValueOnce({ data: { success: true } }); + + const { result } = renderHook(() => useCollection(), { wrapper: createWrapper() }); + + await act(async () => { + const res = await result.current.deleteCollection('col1'); + expect(res.success).toBe(true); + }); + + expect(mockedAxios.delete).toHaveBeenCalledWith( + expect.stringContaining('/collections/col1'), + expect.anything() + ); + }); +}); diff --git a/frontend/src/contexts/__tests__/DownloadContext.test.tsx b/frontend/src/contexts/__tests__/DownloadContext.test.tsx new file mode 100644 index 0000000..a8d4ccf --- /dev/null +++ b/frontend/src/contexts/__tests__/DownloadContext.test.tsx @@ -0,0 +1,149 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import axios from 'axios'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CollectionProvider } from '../CollectionContext'; +import { DownloadProvider, useDownload } from '../DownloadContext'; +import { LanguageProvider } from '../LanguageContext'; +import { SnackbarProvider } from '../SnackbarContext'; +import { VideoProvider } from '../VideoContext'; + +vi.mock('axios'); +const mockedAxios = vi.mocked(axios, true); + +// Create a wrapper with all necessary providers +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + + + + + + {children} + + + + + + ); +}; + +describe('DownloadContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Mock global localStorage + const storageMock: Record = {}; + Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn((key) => storageMock[key] || null), + setItem: vi.fn((key, value) => { + storageMock[key] = value.toString(); + }), + clear: vi.fn(() => { + for (const key in storageMock) delete storageMock[key]; + }), + removeItem: vi.fn((key) => delete storageMock[key]), + length: 0, + key: vi.fn(), + }, + writable: true + }); + + // Setup default mocks for initialization + mockedAxios.get.mockImplementation((url) => { + if (url.includes('/download-status')) { + return Promise.resolve({ data: { activeDownloads: [], queuedDownloads: [] } }); + } + if (url.includes('/videos')) { + return Promise.resolve({ data: [] }); + } + if (url.includes('/settings')) { + return Promise.resolve({ data: {} }); + } + if (url.includes('/collections')) { + return Promise.resolve({ data: [] }); + } + return Promise.resolve({ data: {} }); + }); + }); + + it('should initialize with empty downloads', async () => { + const { result } = renderHook(() => useDownload(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.activeDownloads).toEqual([]); + expect(result.current.queuedDownloads).toEqual([]); + }); + }); + + it('should handle single video submission', async () => { + mockedAxios.post.mockResolvedValueOnce({ data: { downloadId: '123' } }); + + const { result } = renderHook(() => useDownload(), { wrapper: createWrapper() }); + + await act(async () => { + const res = await result.current.handleVideoSubmit('https://youtube.com/watch?v=123'); + expect(res.success).toBe(true); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining('/download'), + expect.objectContaining({ youtubeUrl: 'https://youtube.com/watch?v=123' }) + ); + }); + + it('should detect playlist and set modal state', async () => { + mockedAxios.get.mockImplementation((url) => { + if (url.includes('/check-playlist')) { + return Promise.resolve({ + data: { success: true, title: 'My Playlist', videoCount: 10 } + }); + } + return Promise.resolve({ data: {} }); + }); + + const { result } = renderHook(() => useDownload(), { wrapper: createWrapper() }); + const playlistUrl = 'https://youtube.com/playlist?list=PL123'; + + await act(async () => { + await result.current.handleVideoSubmit(playlistUrl); + }); + + expect(result.current.showBilibiliPartsModal).toBe(true); + expect(result.current.bilibiliPartsInfo.type).toBe('playlist'); + }); + + it('should handle playlist download confirmation', async () => { + const { result } = renderHook(() => useDownload(), { wrapper: createWrapper() }); + + mockedAxios.get.mockImplementation((url) => { + if (url.includes('/check-playlist')) { + return Promise.resolve({ + data: { success: true, title: 'PL', videoCount: 5 } + }); + } + return Promise.resolve({ data: {} }); + }); + + await act(async () => { + await result.current.handleVideoSubmit('https://youtube.com/playlist?list=PL123'); + }); + + mockedAxios.post.mockResolvedValueOnce({ data: { success: true } }); + + await act(async () => { + await result.current.handleDownloadAllBilibiliParts('My Playlist'); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + expect.stringContaining('/subscriptions/tasks/playlist'), + expect.anything() + ); + }); +}); diff --git a/frontend/src/contexts/__tests__/VideoContext.test.tsx b/frontend/src/contexts/__tests__/VideoContext.test.tsx new file mode 100644 index 0000000..ac918df --- /dev/null +++ b/frontend/src/contexts/__tests__/VideoContext.test.tsx @@ -0,0 +1,143 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import axios from 'axios'; +import React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { LanguageProvider } from '../LanguageContext'; +import { SnackbarProvider } from '../SnackbarContext'; +import { VideoProvider, useVideo } from '../VideoContext'; +import { VisitorModeProvider } from '../VisitorModeContext'; + +// Mock axios +vi.mock('axios'); +const mockedAxios = vi.mocked(axios, true); + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + + ); +}; + +describe('VideoContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Mock global localStorage + const storageMock: Record = {}; + Object.defineProperty(window, 'localStorage', { + value: { + getItem: vi.fn((key) => storageMock[key] || null), + setItem: vi.fn((key, value) => { + storageMock[key] = value.toString(); + }), + clear: vi.fn(() => { + for (const key in storageMock) delete storageMock[key]; + }), + removeItem: vi.fn((key) => delete storageMock[key]), + length: 0, + key: vi.fn(), + }, + writable: true + }); + + // Default mocks + mockedAxios.get.mockImplementation((url) => { + if (url.includes('/videos')) return Promise.resolve({ data: [] }); + if (url.includes('/settings')) return Promise.resolve({ data: { tags: [] } }); + return Promise.resolve({ data: {} }); + }); + }); + + it('should fetch videos on mount', async () => { + const mockVideos = [{ id: '1', title: 'Test Video', author: 'Test Author' }]; + mockedAxios.get.mockImplementation((url) => { + if (url.includes('/videos')) return Promise.resolve({ data: mockVideos }); + return Promise.resolve({ data: {} }); + }); + + const { result } = renderHook(() => useVideo(), { wrapper: createWrapper() }); + + await waitFor(() => { + expect(result.current.videos).toEqual(mockVideos); + }); + }); + + it('should delete a video', async () => { + const mockVideos = [{ id: '1', title: 'Video 1' }]; + mockedAxios.get.mockImplementation((url) => { + if (url.includes('/videos')) return Promise.resolve({ data: mockVideos }); + return Promise.resolve({ data: {} }); + }); + + const { result } = renderHook(() => useVideo(), { wrapper: createWrapper() }); + + await waitFor(() => expect(result.current.videos).toHaveLength(1)); + + mockedAxios.delete.mockResolvedValueOnce({ data: { success: true } }); + + await act(async () => { + const res = await result.current.deleteVideo('1'); + expect(res.success).toBe(true); + }); + + expect(mockedAxios.delete).toHaveBeenCalledWith(expect.stringContaining('/videos/1')); + }); + + it('should handle search (local)', async () => { + const mockVideos = [ + { id: '1', title: 'React Tutorial', author: 'User A' }, + { id: '2', title: 'Vue Guide', author: 'User B' } + ]; + + mockedAxios.get.mockImplementation((url) => { + if (url.includes('/videos')) return Promise.resolve({ data: mockVideos }); + if (url.includes('/settings')) return Promise.resolve({ data: { showYoutubeSearch: false } }); // Disable YT search for this test + return Promise.resolve({ data: {} }); + }); + + const { result } = renderHook(() => useVideo(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.videos).toHaveLength(2)); + + await act(async () => { + await result.current.handleSearch('React'); + }); + + expect(result.current.isSearchMode).toBe(true); + expect(result.current.localSearchResults).toHaveLength(1); + expect(result.current.localSearchResults[0].title).toBe('React Tutorial'); + }); + + it('should increment view count', async () => { + const mockVideos = [{ id: '1', title: 'V1', viewCount: 0 }]; + mockedAxios.get.mockImplementation((url) => { + if (url.includes('/videos')) return Promise.resolve({ data: mockVideos }); + return Promise.resolve({ data: {} }); + }); + + const { result } = renderHook(() => useVideo(), { wrapper: createWrapper() }); + await waitFor(() => expect(result.current.videos).toHaveLength(1)); + + mockedAxios.post.mockResolvedValueOnce({ data: { success: true, viewCount: 1 } }); + + await act(async () => { + await result.current.incrementView('1'); + }); + + // The queryClient setQueryData is synchronous, but we might need to wait for re-render + // However, useQuery data reference might not update immediately in "videos" since it's from state/memo + // Let's verify axios call + expect(mockedAxios.post).toHaveBeenCalledWith(expect.stringContaining('/videos/1/view')); + }); +}); diff --git a/frontend/src/hooks/__tests__/useShareVideo.test.ts b/frontend/src/hooks/__tests__/useShareVideo.test.ts new file mode 100644 index 0000000..999a967 --- /dev/null +++ b/frontend/src/hooks/__tests__/useShareVideo.test.ts @@ -0,0 +1,52 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useShareVideo } from '../useShareVideo'; + +const mockShowSnackbar = vi.fn(); +const mockT = vi.fn((key) => key); + +vi.mock('../../contexts/SnackbarContext', () => ({ + useSnackbar: () => ({ showSnackbar: mockShowSnackbar }) +})); + +vi.mock('../../contexts/LanguageContext', () => ({ + useLanguage: () => ({ t: mockT }) +})); + +describe('useShareVideo', () => { + const mockVideo = { id: '1', title: 'Test' }; + + beforeEach(() => { + vi.clearAllMocks(); + // Reset navigator mocks + Object.defineProperty(navigator, 'share', { value: undefined, configurable: true }); + Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true }); + }); + + it('should use navigator.share if available', async () => { + const mockShare = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'share', { value: mockShare, configurable: true }); + + const { result } = renderHook(() => useShareVideo(mockVideo as any)); + await result.current.handleShare(); + + expect(mockShare).toHaveBeenCalledWith(expect.objectContaining({ + title: 'Test', + url: expect.any(String) + })); + }); + + it('should use clipboard API if navigator.share unavailable', async () => { + const mockWriteText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: mockWriteText }, + configurable: true + }); + + const { result } = renderHook(() => useShareVideo(mockVideo as any)); + await result.current.handleShare(); + + expect(mockWriteText).toHaveBeenCalled(); + expect(mockShowSnackbar).toHaveBeenCalledWith('linkCopied', 'success'); + }); +}); diff --git a/frontend/src/hooks/__tests__/useVideoCardActions.test.ts b/frontend/src/hooks/__tests__/useVideoCardActions.test.ts new file mode 100644 index 0000000..c9d5e59 --- /dev/null +++ b/frontend/src/hooks/__tests__/useVideoCardActions.test.ts @@ -0,0 +1,91 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useVideoCardActions } from '../useVideoCardActions'; + +// Mocks +const mockShowSnackbar = vi.fn(); +const mockUpdateVideo = vi.fn(); +const mockT = vi.fn((key) => key); + +vi.mock('../../contexts/SnackbarContext', () => ({ + useSnackbar: () => ({ showSnackbar: mockShowSnackbar }) +})); + +vi.mock('../../contexts/LanguageContext', () => ({ + useLanguage: () => ({ t: mockT }) +})); + +vi.mock('../../contexts/VideoContext', () => ({ + useVideo: () => ({ updateVideo: mockUpdateVideo }) +})); + +describe('useVideoCardActions', () => { + const mockVideo = { + id: '1', + title: 'Test Video', + visibility: 1 + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should delete video successfully', async () => { + const mockOnDelete = vi.fn().mockResolvedValue(true); + const { result } = renderHook(() => useVideoCardActions({ + video: mockVideo as any, + onDeleteVideo: mockOnDelete, + showDeleteButton: true + })); + + await act(async () => { + await result.current.confirmDelete(); + }); + + expect(mockOnDelete).toHaveBeenCalledWith('1'); + }); + + it('should toggle visibility (hide)', async () => { + mockUpdateVideo.mockResolvedValue({ success: true }); + + const { result } = renderHook(() => useVideoCardActions({ + video: { ...mockVideo, visibility: 1 } as any + })); + + await act(async () => { + await result.current.handleToggleVisibility(); + }); + + expect(mockUpdateVideo).toHaveBeenCalledWith('1', { visibility: 0 }); + expect(mockShowSnackbar).toHaveBeenCalledWith('hideVideo', 'success'); + }); + + it('should toggle visibility (show)', async () => { + mockUpdateVideo.mockResolvedValue({ success: true }); + + const { result } = renderHook(() => useVideoCardActions({ + video: { ...mockVideo, visibility: 0 } as any + })); + + await act(async () => { + await result.current.handleToggleVisibility(); + }); + + expect(mockUpdateVideo).toHaveBeenCalledWith('1', { visibility: 1 }); + expect(mockShowSnackbar).toHaveBeenCalledWith('showVideo', 'success'); + }); + + it('should handle update error', async () => { + mockUpdateVideo.mockResolvedValue({ success: false }); + + const { result } = renderHook(() => useVideoCardActions({ + video: mockVideo as any + })); + + await act(async () => { + await result.current.handleToggleVisibility(); + }); + + expect(mockShowSnackbar).toHaveBeenCalledWith('error', 'error'); + }); +}); diff --git a/frontend/src/hooks/__tests__/useVideoCardMetadata.test.ts b/frontend/src/hooks/__tests__/useVideoCardMetadata.test.ts new file mode 100644 index 0000000..74dbb79 --- /dev/null +++ b/frontend/src/hooks/__tests__/useVideoCardMetadata.test.ts @@ -0,0 +1,60 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useVideoCardMetadata } from '../useVideoCardMetadata'; + +// Mock dependencies +vi.mock('../useCloudStorageUrl', () => ({ + useCloudStorageUrl: (path: string | null) => path ? `cloud-url/${path}` : null +})); + +// Mock isNewVideo util +vi.mock('../../utils/videoCardUtils', () => ({ + isNewVideo: () => true +})); + +describe('useVideoCardMetadata', () => { + it('should return cloud url if available', async () => { + const mockVideo = { + id: '1', + videoPath: 'cloud:video.mp4', + thumbnailPath: 'cloud:thumb.jpg' + }; + + const { result } = renderHook(() => useVideoCardMetadata({ video: mockVideo as any })); + + expect(result.current.videoUrl).toBe('cloud-url/cloud:video.mp4'); + expect(result.current.thumbnailSrc).toBe('cloud-url/cloud:thumb.jpg'); + + const url = await result.current.getVideoUrl(); + expect(url).toBe('cloud-url/cloud:video.mp4'); + }); + + it('should return local url fallback', async () => { + const mockVideo = { + id: '1', + videoPath: '/local/video.mp4', + thumbnailPath: '/local/thumb.jpg', + thumbnailUrl: 'http://thumb.url' + }; + + const { result } = renderHook(() => useVideoCardMetadata({ video: mockVideo as any })); + + // Mock import.meta.env behavior or window.location if needed for exact string match + // Based on implementation: `${window.location.origin}${videoPath}` + // In test env, window.location.origin is usually http://localhost:3000 + + const url = await result.current.getVideoUrl(); + expect(url).toContain('/local/video.mp4'); + expect(result.current.thumbnailSrc).toContain('/local/thumb.jpg'); + }); + + it('should prioritize thumbnailUrl if no local/cloud path', () => { + const mockVideo = { + id: '1', + thumbnailUrl: 'http://external.com/thumb.jpg' + }; + + const { result } = renderHook(() => useVideoCardMetadata({ video: mockVideo as any })); + expect(result.current.thumbnailSrc).toBe('http://external.com/thumb.jpg'); + }); +}); diff --git a/frontend/src/hooks/__tests__/useVideoCardNavigation.test.ts b/frontend/src/hooks/__tests__/useVideoCardNavigation.test.ts new file mode 100644 index 0000000..57ea73e --- /dev/null +++ b/frontend/src/hooks/__tests__/useVideoCardNavigation.test.ts @@ -0,0 +1,45 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { useVideoCardNavigation } from '../useVideoCardNavigation'; + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigate +})); + +describe('useVideoCardNavigation', () => { + const mockVideo = { id: 'v1', author: 'Test Author' }; + + it('should navigate to video player normally', () => { + const { result } = renderHook(() => useVideoCardNavigation({ + video: mockVideo as any, + collectionInfo: { isFirstInAnyCollection: false, firstCollectionId: null, videoCollections: [], firstInCollectionNames: [] } + })); + + result.current.handleVideoNavigation(); + expect(mockNavigate).toHaveBeenCalledWith('/video/v1'); + }); + + it('should navigate to collection if first in collection', () => { + const { result } = renderHook(() => useVideoCardNavigation({ + video: mockVideo as any, + collectionInfo: { isFirstInAnyCollection: true, firstCollectionId: 'c1', videoCollections: [], firstInCollectionNames: [] } + })); + + result.current.handleVideoNavigation(); + expect(mockNavigate).toHaveBeenCalledWith('/collection/c1'); + }); + + it('should handle author click navigation', () => { + const { result } = renderHook(() => useVideoCardNavigation({ + video: mockVideo as any, + collectionInfo: { isFirstInAnyCollection: false, firstCollectionId: null, videoCollections: [], firstInCollectionNames: [] } + })); + + const mockEvent = { stopPropagation: vi.fn() }; + result.current.handleAuthorClick(mockEvent as any); + + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/author/Test%20Author'); + }); +}); diff --git a/frontend/src/hooks/__tests__/useVideoCollections.test.ts b/frontend/src/hooks/__tests__/useVideoCollections.test.ts new file mode 100644 index 0000000..85b226f --- /dev/null +++ b/frontend/src/hooks/__tests__/useVideoCollections.test.ts @@ -0,0 +1,79 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useVideoCollections } from '../useVideoCollections'; + +// Mocks +const mockCollections = [ + { id: 'c1', name: 'Collection 1', videos: ['v1', 'v2'] }, + { id: 'c2', name: 'Collection 2', videos: ['v3'] } +]; + +const mockAddToCollection = vi.fn(); +const mockCreateCollection = vi.fn(); +const mockRemoveFromCollection = vi.fn(); + +vi.mock('../../contexts/CollectionContext', () => ({ + useCollection: () => ({ + collections: mockCollections, + addToCollection: mockAddToCollection, + createCollection: mockCreateCollection, + removeFromCollection: mockRemoveFromCollection + }) +})); + +describe('useVideoCollections', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should filter collections containing the video', () => { + const { result } = renderHook(() => useVideoCollections({ videoId: 'v1' })); + + expect(result.current.videoCollections).toHaveLength(1); + expect(result.current.videoCollections[0].id).toBe('c1'); + }); + + it('should return empty if video not in any collection', () => { + const { result } = renderHook(() => useVideoCollections({ videoId: 'v99' })); + expect(result.current.videoCollections).toHaveLength(0); + }); + + it('should handle add to collection modal', () => { + const { result } = renderHook(() => useVideoCollections({ videoId: 'v1' })); + + act(() => { + result.current.handleAddToCollection(); + }); + + expect(result.current.showCollectionModal).toBe(true); + expect(result.current.activeCollectionVideoId).toBe('v1'); + }); + + it('should create new collection', async () => { + const { result } = renderHook(() => useVideoCollections({ videoId: 'v1' })); + + act(() => { + result.current.handleAddToCollection(); + }); + + await act(async () => { + await result.current.handleCreateCollection('New List'); + }); + + expect(mockCreateCollection).toHaveBeenCalledWith('New List', 'v1'); + }); + + it('should remove from collection', async () => { + const { result } = renderHook(() => useVideoCollections({ videoId: 'v1' })); + + act(() => { + result.current.handleAddToCollection(); + }); + + await act(async () => { + await result.current.handleRemoveFromCollection(); + }); + + expect(mockRemoveFromCollection).toHaveBeenCalledWith('v1'); + }); +}); diff --git a/frontend/src/hooks/__tests__/useVideoHoverPreview.test.ts b/frontend/src/hooks/__tests__/useVideoHoverPreview.test.ts new file mode 100644 index 0000000..280d858 --- /dev/null +++ b/frontend/src/hooks/__tests__/useVideoHoverPreview.test.ts @@ -0,0 +1,62 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useVideoHoverPreview } from '../useVideoHoverPreview'; + +// Mocks +vi.mock('@mui/material', () => ({ + useTheme: () => ({ breakpoints: { down: () => 'sm' } }), + useMediaQuery: (query: string) => query === 'mobile-query' // Simulate desktop usually unless specified +})); + +describe('useVideoHoverPreview', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should set hovered state after delay on desktop', () => { + const { result } = renderHook(() => useVideoHoverPreview({ videoPath: 'path.mp4' })); + + act(() => { + result.current.handleMouseEnter(); + }); + + expect(result.current.isHovered).toBe(false); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(result.current.isHovered).toBe(true); + }); + + it('should clear timeout and state on mouse leave', () => { + const { result } = renderHook(() => useVideoHoverPreview({ videoPath: 'path.mp4' })); + + // Mock video ref + // @ts-ignore + result.current.videoRef.current = { + pause: vi.fn(), + load: vi.fn(), + removeAttribute: vi.fn(), + src: 'blob:...' + }; + + act(() => { + result.current.handleMouseEnter(); + // Leave before timeout finishes + result.current.handleMouseLeave(); + }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(result.current.isHovered).toBe(false); + // @ts-ignore + expect(result.current.videoRef.current.pause).toHaveBeenCalled(); + }); +});