test(useVideoHoverPreview): Add hover delay for desktop

This commit is contained in:
Peifan Li
2025-12-28 21:07:56 -05:00
parent 5b78b8aa42
commit 694b4f3be9
12 changed files with 946 additions and 0 deletions

View File

@@ -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(<FullscreenControl isFullscreen={false} onToggle={() => { }} />);
expect(screen.getByRole('button')).toBeInTheDocument();
// Check for specific icon if possible, or just button presence
});
it('should render exit fullscreen icon when active', () => {
render(<FullscreenControl isFullscreen={true} onToggle={() => { }} />);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('should call onToggle when clicked', () => {
const toggleMock = vi.fn();
render(<FullscreenControl isFullscreen={false} onToggle={toggleMock} />);
fireEvent.click(screen.getByRole('button'));
expect(toggleMock).toHaveBeenCalled();
});
});

View File

@@ -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(<LoopControl isLooping={false} onToggle={() => { }} />);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('should show active color when looping', () => {
render(<LoopControl isLooping={true} onToggle={() => { }} />);
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(<LoopControl isLooping={false} onToggle={toggleMock} />);
fireEvent.click(screen.getByRole('button'));
expect(toggleMock).toHaveBeenCalled();
});
});

View File

@@ -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(<SubtitleControl {...defaultProps} />);
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(<SubtitleControl {...defaultProps} subtitles={[]} />);
const button = screen.queryByRole('button');
expect(button).not.toBeInTheDocument();
});
it('should call onSubtitleClick when clicked', () => {
render(<SubtitleControl {...defaultProps} />);
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(<SubtitleControl {...defaultProps} subtitleMenuAnchor={anchor} />);
// 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(<SubtitleControl {...defaultProps} subtitleMenuAnchor={anchor} />);
const enOption = screen.getByText('EN');
fireEvent.click(enOption);
expect(defaultProps.onSelectSubtitle).toHaveBeenCalledWith(0);
});
});

View File

@@ -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 }) => (
<QueryClientProvider client={queryClient}>
<SnackbarProvider>
<LanguageProvider>
<CollectionProvider>{children}</CollectionProvider>
</LanguageProvider>
</SnackbarProvider>
</QueryClientProvider>
);
};
describe('CollectionContext', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock global localStorage
const storageMock: Record<string, string> = {};
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()
);
});
});

View File

@@ -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 }) => (
<QueryClientProvider client={queryClient}>
<SnackbarProvider>
<LanguageProvider>
<VideoProvider>
<CollectionProvider>
<DownloadProvider>{children}</DownloadProvider>
</CollectionProvider>
</VideoProvider>
</LanguageProvider>
</SnackbarProvider>
</QueryClientProvider>
);
};
describe('DownloadContext', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock global localStorage
const storageMock: Record<string, string> = {};
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()
);
});
});

View File

@@ -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 }) => (
<QueryClientProvider client={queryClient}>
<SnackbarProvider>
<LanguageProvider>
<VisitorModeProvider>
<VideoProvider>{children}</VideoProvider>
</VisitorModeProvider>
</LanguageProvider>
</SnackbarProvider>
</QueryClientProvider>
);
};
describe('VideoContext', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock global localStorage
const storageMock: Record<string, string> = {};
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'));
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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();
});
});