Added tests

This commit is contained in:
Nick Sandstrom 2026-02-24 00:58:36 -08:00
parent dc51ab4dd1
commit a9402207fb
17 changed files with 6242 additions and 0 deletions

View file

@ -0,0 +1,275 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ConfirmationDialog from '../ConfirmationDialog';
import useWarningsStore from '../../store/warnings';
// Mock the warnings store
vi.mock('../../store/warnings');
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
{children}
</div>
) : null,
Group: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, disabled }) => (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
),
Checkbox: ({ label, checked, onChange }) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={onChange}
/>
{label}
</label>
),
Box: ({ children }) => <div>{children}</div>,
};
});
describe('ConfirmationDialog', () => {
const mockOnClose = vi.fn();
const mockOnConfirm = vi.fn();
const mockOnSuppressChange = vi.fn();
const mockSuppressWarning = vi.fn();
const mockIsWarningSuppressed = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
useWarningsStore.mockImplementation((selector) => {
const state = {
suppressWarning: mockSuppressWarning,
isWarningSuppressed: mockIsWarningSuppressed,
};
return selector ? selector(state) : state;
});
});
it('should render when opened', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
/>
);
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument();
});
it('should not render when closed', () => {
render(
<ConfirmationDialog
opened={false}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
/>
);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('should display custom title and message', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="Delete Item"
message="This action cannot be undone"
/>
);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Delete Item');
expect(screen.getByText('This action cannot be undone')).toBeInTheDocument();
});
it('should call onConfirm when confirm button is clicked', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
confirmLabel="Delete"
/>
);
fireEvent.click(screen.getByText('Delete'));
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
});
it('should call onClose when cancel button is clicked', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
cancelLabel="Cancel"
/>
);
fireEvent.click(screen.getByText('Cancel'));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('should show suppress checkbox when actionKey is provided', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
actionKey="delete-action"
/>
);
expect(screen.getByLabelText("Don't ask me again")).toBeInTheDocument();
});
it('should not show suppress checkbox when actionKey is not provided', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
/>
);
expect(screen.queryByLabelText("Don't ask me again")).not.toBeInTheDocument();
});
it('should call suppressWarning when suppress is checked and confirmed', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
actionKey="delete-action"
/>
);
fireEvent.click(screen.getByLabelText("Don't ask me again"));
fireEvent.click(screen.getByText('Confirm'));
expect(mockSuppressWarning).toHaveBeenCalledWith('delete-action');
});
it('should call onSuppressChange when suppress checkbox is toggled', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
actionKey="delete-action"
onSuppressChange={mockOnSuppressChange}
/>
);
fireEvent.click(screen.getByLabelText("Don't ask me again"));
expect(mockOnSuppressChange).toHaveBeenCalledWith(true);
});
it('should show delete file option when enabled', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showDeleteFileOption={true}
/>
);
expect(screen.getByLabelText('Also delete files from disk')).toBeInTheDocument();
});
it('should pass deleteFiles state to onConfirm when delete option is checked', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showDeleteFileOption={true}
/>
);
fireEvent.click(screen.getByLabelText('Also delete files from disk'));
fireEvent.click(screen.getByText('Confirm'));
expect(mockOnConfirm).toHaveBeenCalledWith(true);
});
it('should reset deleteFiles state after confirmation', () => {
const { rerender } = render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showDeleteFileOption={true}
/>
);
fireEvent.click(screen.getByLabelText('Also delete files from disk'));
fireEvent.click(screen.getByText('Confirm'));
rerender(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showDeleteFileOption={true}
/>
);
expect(screen.getByLabelText('Also delete files from disk')).not.toBeChecked();
});
it('should show loading state on confirm button', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
loading={true}
/>
);
expect(screen.getByText('Confirm')).toBeDisabled();
});
it('should disable cancel button when loading', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
loading={true}
/>
);
expect(screen.getByText('Cancel')).toBeDisabled();
});
it('should initialize suppress checkbox based on store state', () => {
mockIsWarningSuppressed.mockReturnValue(true);
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
actionKey="delete-action"
/>
);
expect(screen.getByLabelText("Don't ask me again")).toBeChecked();
});
});

View file

@ -0,0 +1,99 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ErrorBoundary from '../ErrorBoundary';
// Component that throws an error for testing
const ThrowError = ({ shouldThrow }) => {
if (shouldThrow) {
throw new Error('Test error');
}
return <div>Child component</div>;
};
describe('ErrorBoundary', () => {
beforeEach(() => {
vi.clearAllMocks();
// Suppress console.error for cleaner test output
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('should render children when there is no error', () => {
render(
<ErrorBoundary>
<div>Test content</div>
</ErrorBoundary>
);
expect(screen.getByText('Test content')).toBeInTheDocument();
});
it('should render error message when child component throws error', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
expect(screen.queryByText('Child component')).not.toBeInTheDocument();
});
it('should not render error message when child component does not throw', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={false} />
</ErrorBoundary>
);
expect(screen.getByText('Child component')).toBeInTheDocument();
expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
});
it('should handle multiple children', () => {
render(
<ErrorBoundary>
<div>First child</div>
<div>Second child</div>
</ErrorBoundary>
);
expect(screen.getByText('First child')).toBeInTheDocument();
expect(screen.getByText('Second child')).toBeInTheDocument();
});
it('should catch errors from nested children', () => {
render(
<ErrorBoundary>
<div>
<div>
<ThrowError shouldThrow={true} />
</div>
</div>
</ErrorBoundary>
);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});
it('should have hasError state set to true after catching error', () => {
const { container } = render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
// Verify error boundary rendered fallback UI
expect(container.querySelector('div')).toHaveTextContent('Something went wrong');
});
it('should have hasError state set to false initially', () => {
const { container } = render(
<ErrorBoundary>
<div data-testid="child">Normal content</div>
</ErrorBoundary>
);
// Verify children are rendered (not error state)
expect(screen.getByTestId('child')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,388 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Field } from '../Field';
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
TextInput: ({ label, description, value, onChange }) => (
<div>
<label htmlFor="text-input">{label}</label>
<input
id="text-input"
type="text"
value={value}
onChange={onChange}
aria-describedby={description}
/>
{description && <div>{description}</div>}
</div>
),
NumberInput: ({ label, description, value, onChange }) => (
<div>
<label htmlFor="number-input">{label}</label>
<input
id="number-input"
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
aria-describedby={description}
/>
{description && <div>{description}</div>}
</div>
),
Switch: ({ label, description, checked, onChange }) => (
<div>
<label htmlFor="switch-input">{label}</label>
<input
id="switch-input"
type="checkbox"
checked={checked}
onChange={onChange}
aria-describedby={description}
/>
{description && <div>{description}</div>}
</div>
),
Select: ({ label, description, value, data, onChange }) => (
<div>
<label htmlFor="select-input">{label}</label>
<select
id="select-input"
value={value}
onChange={(e) => onChange(e.target.value)}
aria-describedby={description}
>
{data.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{description && <div>{description}</div>}
</div>
),
};
});
describe('Field', () => {
const mockOnChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('TextInput (string type)', () => {
it('should render TextInput for string type', () => {
const field = {
id: 'name',
type: 'string',
label: 'Name',
help_text: 'Enter your name',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Name')).toBeInTheDocument();
expect(screen.getByText('Enter your name')).toBeInTheDocument();
});
it('should use provided value', () => {
const field = {
id: 'name',
type: 'string',
label: 'Name',
default: '',
};
render(<Field field={field} value="John" onChange={mockOnChange} />);
expect(screen.getByLabelText('Name')).toHaveValue('John');
});
it('should use default value when value is null', () => {
const field = {
id: 'name',
type: 'string',
label: 'Name',
default: 'Default Name',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Name')).toHaveValue('Default Name');
});
it('should call onChange with field id and value', () => {
const field = {
id: 'name',
type: 'string',
label: 'Name',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
fireEvent.change(screen.getByLabelText('Name'), {
target: { value: 'New Value' },
});
expect(mockOnChange).toHaveBeenCalledWith('name', 'New Value');
});
});
describe('NumberInput (number type)', () => {
it('should render NumberInput for number type', () => {
const field = {
id: 'age',
type: 'number',
label: 'Age',
help_text: 'Enter your age',
default: 0,
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Age')).toBeInTheDocument();
expect(screen.getByText('Enter your age')).toBeInTheDocument();
});
it('should use provided value', () => {
const field = {
id: 'age',
type: 'number',
label: 'Age',
default: 0,
};
render(<Field field={field} value={25} onChange={mockOnChange} />);
expect(screen.getByLabelText('Age')).toHaveValue(25);
});
it('should default to 0 when value and default are null', () => {
const field = {
id: 'age',
type: 'number',
label: 'Age',
default: null,
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Age')).toHaveValue(0);
});
it('should call onChange with field id and numeric value', () => {
const field = {
id: 'age',
type: 'number',
label: 'Age',
default: 0,
};
render(<Field field={field} value={0} onChange={mockOnChange} />);
fireEvent.change(screen.getByLabelText('Age'), {
target: { value: '30' },
});
expect(mockOnChange).toHaveBeenCalledWith('age', 30);
});
});
describe('Switch (boolean type)', () => {
it('should render Switch for boolean type', () => {
const field = {
id: 'active',
type: 'boolean',
label: 'Active',
help_text: 'Toggle active state',
default: false,
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Active')).toBeInTheDocument();
expect(screen.getByText('Toggle active state')).toBeInTheDocument();
});
it('should be checked when value is true', () => {
const field = {
id: 'active',
type: 'boolean',
label: 'Active',
default: false,
};
render(<Field field={field} value={true} onChange={mockOnChange} />);
expect(screen.getByLabelText('Active')).toBeChecked();
});
it('should be unchecked when value is false', () => {
const field = {
id: 'active',
type: 'boolean',
label: 'Active',
default: false,
};
render(<Field field={field} value={false} onChange={mockOnChange} />);
expect(screen.getByLabelText('Active')).not.toBeChecked();
});
it('should use default value when value is null', () => {
const field = {
id: 'active',
type: 'boolean',
label: 'Active',
default: true,
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Active')).toBeChecked();
});
it('should call onChange with field id and checked state', () => {
const field = {
id: 'active',
type: 'boolean',
label: 'Active',
default: false,
};
render(<Field field={field} value={false} onChange={mockOnChange} />);
fireEvent.click(screen.getByLabelText('Active'));
expect(mockOnChange).toHaveBeenCalledWith('active', true);
});
});
describe('Select (select type)', () => {
it('should render Select for select type', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
help_text: 'Select your country',
default: '',
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
],
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Country')).toBeInTheDocument();
expect(screen.getByText('Select your country')).toBeInTheDocument();
});
it('should render options correctly', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
default: '',
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
],
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('United States')).toBeInTheDocument();
expect(screen.getByText('Canada')).toBeInTheDocument();
});
it('should use provided value', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
default: '',
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
],
};
render(<Field field={field} value="ca" onChange={mockOnChange} />);
expect(screen.getByLabelText('Country')).toHaveValue('ca');
});
it('should convert value to string', () => {
const field = {
id: 'status',
type: 'select',
label: 'Status',
default: 1,
options: [
{ value: 1, label: 'Active' },
{ value: 2, label: 'Inactive' },
],
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Status')).toHaveValue('1');
});
it('should handle empty options array', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
default: '',
options: null,
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Country')).toBeInTheDocument();
});
it('should call onChange with field id and selected value', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
default: '',
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
],
};
render(<Field field={field} value="us" onChange={mockOnChange} />);
fireEvent.change(screen.getByLabelText('Country'), {
target: { value: 'ca' },
});
expect(mockOnChange).toHaveBeenCalledWith('country', 'ca');
});
});
describe('Default fallback', () => {
it('should render TextInput for unknown type', () => {
const field = {
id: 'custom',
type: 'unknown',
label: 'Custom Field',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Custom Field')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,454 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import FloatingVideo from '../FloatingVideo';
import useVideoStore from '../../store/useVideoStore';
// Mock the video store
vi.mock('../../store/useVideoStore');
// Mock mpegts.js
vi.mock('mpegts.js', () => ({
default: {
createPlayer: vi.fn(),
getFeatureList: vi.fn(),
Events: {
LOADING_COMPLETE: 'loading_complete',
METADATA_ARRIVED: 'metadata_arrived',
ERROR: 'error',
MEDIA_INFO: 'media_info',
},
},
}));
// Import the mocked module after mocking
const mpegts = (await import('mpegts.js')).default;
// Mock react-draggable
vi.mock('react-draggable', () => ({
default: ({ children, nodeRef }) => <div ref={nodeRef}>{children}</div>,
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
CloseButton: ({ onClick, onTouchEnd }) => (
<button
data-testid="close-button"
onClick={onClick}
onTouchEnd={onTouchEnd}
>
Close
</button>
),
Flex: ({ children, ...props }) => <div {...props}>{children}</div>,
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Loader: () => <div data-testid="loader">Loading...</div>,
Text: ({ children, ...props }) => <div {...props}>{children}</div>,
};
});
describe('FloatingVideo', () => {
const mockHideVideo = vi.fn();
let mockPlayer;
beforeEach(async () => {
vi.clearAllMocks();
// Mock HTMLVideoElement methods
HTMLVideoElement.prototype.load = vi.fn();
HTMLVideoElement.prototype.play = vi.fn(() => Promise.resolve());
HTMLVideoElement.prototype.pause = vi.fn();
mockPlayer = {
attachMediaElement: vi.fn(),
load: vi.fn(),
play: vi.fn(() => Promise.resolve()),
pause: vi.fn(),
destroy: vi.fn(),
on: vi.fn(),
};
mpegts.createPlayer.mockReturnValue(mockPlayer);
mpegts.getFeatureList.mockReturnValue({ mseLivePlayback: true });
useVideoStore.mockImplementation((selector) => {
const state = {
isVisible: false,
streamUrl: null,
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
});
});
describe('Visibility', () => {
it('should not render when isVisible is false', () => {
const { container } = render(<FloatingVideo />);
expect(container.firstChild).toBeNull();
});
it('should not render when streamUrl is null', () => {
useVideoStore.mockImplementation((selector) => { {
const state = {
isVisible: true,
streamUrl: null,
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}});
const { container } = render(<FloatingVideo />);
expect(container.firstChild).toBeNull();
});
it('should render when isVisible is true and streamUrl is provided', () => {
useVideoStore.mockImplementation((selector) => { {
const state = {
isVisible: true,
streamUrl: 'http://example.com/stream',
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}});
render(<FloatingVideo />);
expect(screen.getByTestId('close-button')).toBeInTheDocument();
});
});
describe('Live Stream Player', () => {
beforeEach(() => {
useVideoStore.mockImplementation((selector) => { {
const state = {
isVisible: true,
streamUrl: 'http://example.com/stream.ts',
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}});
});
it('should initialize mpegts player for live streams', () => {
render(<FloatingVideo />);
expect(mpegts.createPlayer).toHaveBeenCalledWith(
expect.objectContaining({
type: 'mpegts',
url: 'http://example.com/stream.ts',
isLive: true,
})
);
});
it('should show loading state initially', () => {
render(<FloatingVideo />);
expect(screen.getByTestId('loader')).toBeInTheDocument();
expect(screen.getByText('Loading stream...')).toBeInTheDocument();
});
it('should attach player to video element', () => {
render(<FloatingVideo />);
expect(mockPlayer.attachMediaElement).toHaveBeenCalled();
});
it('should handle player errors', async () => {
render(<FloatingVideo />);
const errorCallback = mockPlayer.on.mock.calls.find(
(call) => call[0] === mpegts.Events.ERROR
)?.[1];
errorCallback('MediaError', 'AC3 codec not supported');
await screen.findByText(/Audio codec not supported/i);
});
it('should handle unsupported browser', () => {
mpegts.getFeatureList.mockReturnValue({
mseLivePlayback: false,
});
render(<FloatingVideo />);
expect(
screen.getByText(/browser doesn't support live video streaming/i)
).toBeInTheDocument();
});
it('should play video on MEDIA_INFO event', async () => {
render(<FloatingVideo />);
const mediaInfoCallback = mockPlayer.on.mock.calls.find(
(call) => call[0] === mpegts.Events.MEDIA_INFO
)?.[1];
await mediaInfoCallback();
expect(mockPlayer.play).toHaveBeenCalled();
});
});
describe('VOD Player', () => {
beforeEach(() => {
useVideoStore.mockImplementation((selector) => { {
const state = {
isVisible: true,
streamUrl: 'http://example.com/video.mp4',
contentType: 'vod',
metadata: {
name: 'Test Movie',
year: '2024',
logo: { url: 'http://example.com/poster.jpg' },
},
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}});
});
it('should use native video player for VOD', () => {
render(<FloatingVideo />);
expect(mpegts.createPlayer).not.toHaveBeenCalled();
});
it('should set video source for VOD', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
expect(video).toBeInTheDocument();
expect(video.src).toBe('http://example.com/video.mp4');
expect(video.poster).toBe('http://example.com/poster.jpg');
});
it('should show metadata overlay', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
// Simulate video loaded event to clear loading state
fireEvent.loadedData(video);
expect(screen.getByText('Test Movie')).toBeInTheDocument();
expect(screen.getByText('2024')).toBeInTheDocument();
});
it('should hide overlay after 4 seconds', () => {
vi.useFakeTimers();
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
fireEvent.loadedData(video);
fireEvent.canPlay(video);
expect(screen.getByText('Test Movie')).toBeInTheDocument();
vi.advanceTimersByTime(4000);
waitFor(() => {
expect(screen.queryByText('Test Movie')).not.toBeInTheDocument();
});
vi.useRealTimers();
});
it('should show overlay on mouse enter', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
fireEvent.loadedData(video);
fireEvent.canPlay(video);
const videoContainer = screen.getByText('Test Movie').closest('div');
fireEvent.mouseEnter(videoContainer);
expect(screen.getByText('Test Movie')).toBeInTheDocument();
});
it('should hide overlay on mouse leave', () => {
vi.useFakeTimers();
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
fireEvent.loadedData(video);
fireEvent.canPlay(video);
const videoContainer = screen.getByText('Test Movie').closest('div');
fireEvent.mouseEnter(videoContainer);
fireEvent.mouseLeave(videoContainer);
vi.advanceTimersByTime(4000);
waitFor(() => {
expect(screen.queryByText('Test Movie')).not.toBeInTheDocument();
});
vi.useRealTimers();
});
});
describe('Close functionality', () => {
beforeEach(() => {
useVideoStore.mockImplementation((selector) => { {
const state = {
isVisible: true,
streamUrl: 'http://example.com/stream.ts',
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}});
});
it('should call hideVideo when close button is clicked', () => {
vi.useFakeTimers();
render(<FloatingVideo />);
fireEvent.click(screen.getByTestId('close-button'));
vi.advanceTimersByTime(50);
waitFor(() => {
expect(mockHideVideo).toHaveBeenCalled();
expect(mockPlayer.destroy).toHaveBeenCalled();
});
vi.useRealTimers();
});
});
describe('Error handling', () => {
beforeEach(() => {
useVideoStore.mockImplementation((selector) => { {
const state = {
isVisible: true,
streamUrl: 'http://example.com/video.mp4',
contentType: 'vod',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}});
});
it('should display video error messages', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
Object.defineProperty(video, 'error', {
value: { code: 3, message: 'MEDIA_ERR_DECODE' },
writable: true,
});
fireEvent.error(video);
expect(
screen.getByText(/MEDIA_ERR_DECODE/i)
).toBeInTheDocument();
});
it('should handle network errors', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
Object.defineProperty(video, 'error', {
value: { code: 2, message: 'MEDIA_ERR_NETWORK' },
writable: true,
});
fireEvent.error(video);
expect(
screen.getByText(/MEDIA_ERR_NETWORK/i)
).toBeInTheDocument();
});
});
describe('Player cleanup', () => {
it('should cleanup player on unmount', () => {
useVideoStore.mockImplementation((selector) => { {
const state = {
isVisible: true,
streamUrl: 'http://example.com/stream.ts',
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}});
const { unmount } = render(<FloatingVideo />);
unmount();
expect(mockPlayer.destroy).toHaveBeenCalled();
});
it('should cleanup player when streamUrl changes', () => {
useVideoStore.mockImplementation((selector) => { {
const state = {
isVisible: true,
streamUrl: 'http://example.com/stream1.ts',
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}});
const { rerender } = render(<FloatingVideo />);
useVideoStore.mockImplementation((selector) => { {
const state = {
isVisible: true,
streamUrl: 'http://example.com/stream2.ts',
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}});
rerender(<FloatingVideo />);
expect(mockPlayer.destroy).toHaveBeenCalled();
});
});
describe('Resize functionality', () => {
beforeEach(() => {
useVideoStore.mockImplementation((selector) => { {
const state = {
isVisible: true,
streamUrl: 'http://example.com/stream.ts',
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}});
});
it('should render resize handles', () => {
const { container } = render(<FloatingVideo />);
const handles = container.querySelectorAll(
'[class*="floating-video-no-drag"]'
);
// Should have 4 resize handles plus video element
expect(handles.length).toBeGreaterThanOrEqual(4);
});
});
});

View file

@ -0,0 +1,335 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import GuideRow from '../GuideRow';
import {
CHANNEL_WIDTH,
EXPANDED_PROGRAM_HEIGHT,
HOUR_WIDTH,
PROGRAM_HEIGHT,
} from '../../pages/guideUtils';
// Mock logo import
vi.mock('../../images/logo.png', () => ({
default: 'mocked-logo.png',
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
Play: (props) => <div data-testid="play-icon" {...props} />,
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Flex: ({ children, ...props }) => <div {...props}>{children}</div>,
Text: ({ children, ...props }) => <div {...props}>{children}</div>,
};
});
describe('GuideRow', () => {
const mockChannel = {
id: 'channel-1',
name: 'Test Channel',
channel_number: '101',
logo_id: 'logo-1',
};
const mockProgram = {
id: 'program-1',
title: 'Test Program',
start_time: '2024-01-01T10:00:00Z',
end_time: '2024-01-01T11:00:00Z',
};
const mockLogos = {
'logo-1': {
cache_url: 'https://example.com/logo.png',
},
};
const mockData = {
filteredChannels: [mockChannel],
programsByChannelId: new Map([[mockChannel.id, [mockProgram]]]),
expandedProgramId: null,
rowHeights: {},
logos: mockLogos,
hoveredChannelId: null,
setHoveredChannelId: vi.fn(),
renderProgram: vi.fn((program) => (
<div key={program.id} data-testid={`program-${program.id}`}>
{program.title}
</div>
)),
handleLogoClick: vi.fn(),
contentWidth: 1920,
};
const mockStyle = {
position: 'absolute',
left: 0,
top: 0,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render channel row with channel information', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
expect(screen.getByTestId('guide-row')).toBeInTheDocument();
expect(screen.getByAltText('Test Channel')).toBeInTheDocument();
expect(screen.getByText('101')).toBeInTheDocument();
});
it('should return null when channel does not exist', () => {
const data = { ...mockData, filteredChannels: [] };
const { container } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
expect(container.firstChild).toBeNull();
});
it('should use default logo when channel logo is not available', () => {
const channelWithoutLogo = { ...mockChannel, logo_id: 'missing-logo' };
const data = {
...mockData,
filteredChannels: [channelWithoutLogo],
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const img = screen.getByAltText('Test Channel');
expect(img).toHaveAttribute('src', 'mocked-logo.png');
});
it('should display channel number or dash if missing', () => {
const channelWithoutNumber = { ...mockChannel, channel_number: null };
const data = {
...mockData,
filteredChannels: [channelWithoutNumber],
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByText('-')).toBeInTheDocument();
});
});
describe('Row Height Calculation', () => {
it('should use default PROGRAM_HEIGHT when no expanded program', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
const row = screen.getByTestId('guide-row');
expect(row).toHaveStyle({ height: `${PROGRAM_HEIGHT}px` });
});
it('should use EXPANDED_PROGRAM_HEIGHT when program is expanded', () => {
const data = {
...mockData,
expandedProgramId: mockProgram.id,
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const row = screen.getByTestId('guide-row');
expect(row).toHaveStyle({ height: `${EXPANDED_PROGRAM_HEIGHT}px` });
});
it('should use pre-calculated row height from rowHeights array', () => {
const customHeight = 150;
const data = {
...mockData,
rowHeights: { 0: customHeight },
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const row = screen.getByTestId('guide-row');
expect(row).toHaveStyle({ height: `${customHeight}px` });
});
});
describe('Programs Rendering', () => {
it('should render programs when channel has programs', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
expect(screen.getByTestId(`program-${mockProgram.id}`)).toBeInTheDocument();
expect(screen.getByText('Test Program')).toBeInTheDocument();
expect(mockData.renderProgram).toHaveBeenCalledWith(
mockProgram,
undefined,
mockChannel
);
});
it('should render multiple programs', () => {
const programs = [
mockProgram,
{ ...mockProgram, id: 'program-2', title: 'Another Program' },
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByText('Test Program')).toBeInTheDocument();
expect(screen.getByText('Another Program')).toBeInTheDocument();
expect(mockData.renderProgram).toHaveBeenCalledTimes(2);
});
it('should render placeholder when channel has no programs', () => {
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, []]]),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const placeholders = screen.getAllByText('No program data');
expect(placeholders.length).toBe(Math.ceil(24 / 2));
});
it('should render placeholder when programsByChannelId does not contain channel', () => {
const data = {
...mockData,
programsByChannelId: new Map(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const placeholders = screen.getAllByText('No program data');
expect(placeholders.length).toBe(Math.ceil(24 / 2));
});
it('should position placeholder programs correctly', () => {
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, []]]),
};
const { container } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const placeholders = container.querySelectorAll('[pos*="absolute"]');
const filteredPlaceholders = Array.from(placeholders).filter(el =>
el.textContent.includes('No program data')
);
filteredPlaceholders.forEach((placeholder, index) => {
expect(placeholder).toHaveAttribute('left', `${index * (HOUR_WIDTH * 2)}`);
expect(placeholder).toHaveAttribute('w', `${HOUR_WIDTH * 2}`);
});
});
});
describe('Channel Logo Interactions', () => {
it('should call handleLogoClick when logo is clicked', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
fireEvent.click(logo);
expect(mockData.handleLogoClick).toHaveBeenCalledWith(
mockChannel,
expect.any(Object)
);
});
it('should show play icon on hover', () => {
const data = {
...mockData,
hoveredChannelId: mockChannel.id,
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByTestId('play-icon')).toBeInTheDocument();
});
it('should not show play icon when not hovering', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
});
it('should call setHoveredChannelId on mouse enter', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
fireEvent.mouseEnter(logo);
expect(mockData.setHoveredChannelId).toHaveBeenCalledWith(mockChannel.id);
});
it('should call setHoveredChannelId with null on mouse leave', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
fireEvent.mouseLeave(logo);
expect(mockData.setHoveredChannelId).toHaveBeenCalledWith(null);
});
});
describe('Layout and Styling', () => {
it('should set correct channel logo width', () => {
const { container } = render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
const logoContainer = container.querySelector('.channel-logo');
expect(logoContainer).toHaveAttribute('w', `${CHANNEL_WIDTH}`);
expect(logoContainer).toHaveAttribute('miw', `${CHANNEL_WIDTH}`);
});
it('should apply content width to row', () => {
const customWidth = 2400;
const data = {
...mockData,
contentWidth: customWidth,
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const row = screen.getByTestId('guide-row');
expect(row).toHaveStyle({ width: `${customWidth}px` });
});
it('should adjust logo image container height based on row height', () => {
const customHeight = 200;
const data = {
...mockData,
rowHeights: { 0: customHeight },
};
const { container } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const imageContainer = container.querySelector('img').parentElement;
expect(imageContainer).toHaveAttribute('h', `${customHeight - 32}px`);
});
});
});

View file

@ -0,0 +1,318 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import HourTimeline from '../HourTimeline';
import { format } from '../../utils/dateTimeUtils';
import { HOUR_WIDTH } from '../../pages/guideUtils';
// Mock date utilities
vi.mock('../../utils/dateTimeUtils', () => ({
format: vi.fn((date, formatStr) => {
if (!formatStr) return date.toISOString();
return formatStr === 'h:mm a' ? '12:00 PM' : 'Jan 1';
}),
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Text: ({ children, ...props }) => <span {...props}>{children}</span>,
};
});
describe('HourTimeline', () => {
const mockTime1 = new Date('2024-01-01T10:00:00Z');
const mockTime2 = new Date('2024-01-01T11:00:00Z');
const mockTime3 = new Date('2024-01-02T00:00:00Z');
const mockHourTimeline = [
{ time: mockTime1, isNewDay: false },
{ time: mockTime2, isNewDay: false },
];
const mockTimeFormat = 'h:mm a';
const mockFormatDayLabel = vi.fn((time) => 'Mon');
const mockHandleTimeClick = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render all hour blocks', () => {
render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
expect(mockFormatDayLabel).toHaveBeenCalledTimes(2);
expect(format).toHaveBeenCalledWith(mockTime1, mockTimeFormat);
expect(format).toHaveBeenCalledWith(mockTime2, mockTimeFormat);
});
it('should render empty when hourTimeline is empty', () => {
const { container } = render(
<HourTimeline
hourTimeline={[]}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
expect(container.firstChild).toBeNull();
});
it('should render formatted time labels', () => {
render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const timeLabels = screen.getAllByText('12:00 PM');
expect(timeLabels.length).toBe(2);
});
it('should render day labels', () => {
render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const dayLabels = screen.getAllByText('Mon');
expect(dayLabels.length).toBe(2);
});
});
describe('HourBlock Styling', () => {
it('should apply correct width and height to hour blocks', () => {
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
hourBlocks.forEach((block) => {
expect(block).toHaveAttribute('w', `${HOUR_WIDTH}`);
expect(block).toHaveAttribute('h', '40px');
expect(block).toHaveAttribute('pos', 'relative');
});
});
it('should apply default styling for non-new-day blocks', () => {
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
expect(hourBlocks[0]).toHaveStyle({
backgroundColor: '#1B2421',
});
});
it('should apply special styling for new day blocks', () => {
const newDayTimeline = [
{ time: mockTime3, isNewDay: true },
];
const { container } = render(
<HourTimeline
hourTimeline={newDayTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const hourBlock = container.querySelector('[style*="cursor: pointer"]');
expect(hourBlock).toHaveStyle({
borderLeft: '2px solid #3BA882',
backgroundColor: '#1E2A27',
});
});
it('should apply bold font weight to day label on new day', () => {
const newDayTimeline = [
{ time: mockTime3, isNewDay: true },
];
const { container } = render(
<HourTimeline
hourTimeline={newDayTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const dayLabel = screen.getByText('Mon');
expect(dayLabel).toHaveAttribute('fw', '600');
expect(dayLabel).toHaveAttribute('c', '#3BA882');
});
it('should apply normal font weight to day label on regular day', () => {
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const dayLabels = screen.getAllByText('Mon');
expect(dayLabels[0]).toHaveAttribute('fw', '400');
});
});
describe('Quarter Hour Markers', () => {
it('should render quarter hour markers', () => {
const { container } = render(
<HourTimeline
hourTimeline={[mockHourTimeline[0]]}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const markers = container.querySelectorAll('[style*="background-color: rgb(113, 128, 150);"]');
expect(markers.length).toBe(3); // 15, 30, 45 minute markers
});
it('should position quarter hour markers correctly', () => {
const { container } = render(
<HourTimeline
hourTimeline={[mockHourTimeline[0]]}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const markers = container.querySelectorAll('[style*="backgroundColor: #718096"]');
const positions = ['25%', '50%', '75%'];
markers.forEach((marker, index) => {
expect(marker).toHaveStyle({
left: positions[index],
width: '1px',
height: '8px',
position: 'absolute',
bottom: '0px',
});
});
});
});
describe('Click Interactions', () => {
it('should call handleTimeClick when hour block is clicked', () => {
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
fireEvent.click(hourBlocks[0]);
expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime1, expect.any(Object));
});
it('should call handleTimeClick with correct time for each block', () => {
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
fireEvent.click(hourBlocks[0]);
expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime1, expect.any(Object));
fireEvent.click(hourBlocks[1]);
expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime2, expect.any(Object));
});
});
describe('Component Keys', () => {
it('should use formatted time as key for each hour block', () => {
format.mockImplementation((date) => date.toISOString());
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
expect(format).toHaveBeenCalledWith(mockTime1);
expect(format).toHaveBeenCalledWith(mockTime2);
});
});
describe('Time Label Positioning', () => {
it('should position time label correctly', () => {
const { container } = render(
<HourTimeline
hourTimeline={[mockHourTimeline[0]]}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const timeLabel = container.querySelector('[pos*="absolute"][top*="8"]');
expect(timeLabel).toHaveAttribute('left', '4');
});
});
describe('Visual Separators', () => {
it('should render left separator box', () => {
const { container } = render(
<HourTimeline
hourTimeline={[mockHourTimeline[0]]}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const separator = container.querySelector('[w*="1px"][left*="0"]');
expect(separator).toHaveAttribute('pos', 'absolute');
expect(separator).toHaveAttribute('top', '0');
expect(separator).toHaveAttribute('bottom', '0');
});
});
});

View file

@ -0,0 +1,398 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import LazyLogo from '../LazyLogo';
import useLogosStore from '../../store/logos';
// Mock the logos store
vi.mock('../../store/logos', () => ({
default: vi.fn(),
}));
// Mock the logo import
vi.mock('../../images/logo.png', () => ({
default: 'mocked-default-logo.png',
}));
// Mock Mantine Skeleton component
vi.mock('@mantine/core', async () => {
return {
Skeleton: ({ height, width, style, ...props }) => {
return <div data-testid="skeleton" style={{ height, width, ...style }} {...props} />;
},
};
});
describe('LazyLogo', () => {
let mockFetchLogosByIds;
let mockStore;
beforeEach(() => {
vi.clearAllMocks();
vi.clearAllTimers();
vi.useFakeTimers();
mockFetchLogosByIds = vi.fn().mockResolvedValue(undefined);
mockStore = {
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
allowLogoRendering: true,
};
useLogosStore.mockImplementation((selector) => selector(mockStore));
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
describe('Rendering', () => {
it('should render image with logo data', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
render(<LazyLogo logoId="logo-1" alt="Test Logo" />);
const img = screen.getByAltText('Test Logo');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', 'https://example.com/logo.png');
});
it('should render with default style', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
render(<LazyLogo logoId="logo-1" />);
const img = screen.getByAltText('logo');
expect(img).toHaveStyle({
maxHeight: '18px',
maxWidth: '55px',
});
});
it('should render with custom style', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
const customStyle = { maxHeight: 30, maxWidth: 100 };
render(<LazyLogo logoId="logo-1" style={customStyle} />);
const img = screen.getByAltText('logo');
expect(img).toHaveStyle({
maxHeight: '30px',
maxWidth: '100px',
});
});
it('should render fallback logo when no logoId provided', () => {
render(<LazyLogo />);
const img = screen.getByAltText('logo');
expect(img).toHaveAttribute('src', 'mocked-default-logo.png');
});
it('should pass through additional props to img element', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
render(
<LazyLogo logoId="logo-1" className="test-class" data-testid="custom-logo" />
);
const img = screen.getByTestId('custom-logo');
expect(img).toHaveClass('test-class');
});
});
describe('Skeleton Loading State', () => {
it('should show skeleton when logo rendering is not allowed', () => {
mockStore.allowLogoRendering = false;
render(<LazyLogo logoId="logo-1" />);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
expect(screen.queryByAltText('logo')).not.toBeInTheDocument();
});
it('should show skeleton when logo data is not available', () => {
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
});
it('should render skeleton with default dimensions', () => {
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
const skeleton = screen.getByTestId('skeleton');
expect(skeleton).toHaveStyle({
height: '18px',
width: '55px',
});
});
it('should render skeleton with custom dimensions', () => {
mockStore.logos = {};
const customStyle = { maxHeight: 40, maxWidth: 120 };
render(<LazyLogo logoId="logo-1" style={customStyle} />);
const skeleton = screen.getByTestId('skeleton');
expect(skeleton).toHaveStyle({
height: '40px',
width: '120px',
});
});
it('should apply border radius to skeleton', () => {
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
const skeleton = screen.getByTestId('skeleton');
expect(skeleton).toHaveStyle({
borderRadius: '4px',
});
});
});
describe('Logo Fetching', () => {
it('should fetch logo when logoId is provided and logo data is missing', async () => {
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo-1']);
});
it('should not fetch logo when logo data already exists', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).not.toHaveBeenCalled();
});
it('should not fetch logo when allowLogoRendering is false', () => {
mockStore.allowLogoRendering = false;
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).not.toHaveBeenCalled();
});
it('should not fetch logo when no logoId is provided', () => {
render(<LazyLogo />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).not.toHaveBeenCalled();
});
it('should batch multiple logo requests', async () => {
mockStore.logos = {};
render(
<>
<LazyLogo logoId="logo-1" />
<LazyLogo logoId="logo-2" />
<LazyLogo logoId="logo-3" />
</>
);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
expect(mockFetchLogosByIds).toHaveBeenCalledWith(
expect.arrayContaining(['logo-1', 'logo-2', 'logo-3'])
);
});
it('should debounce fetch requests with 100ms delay', async () => {
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(50);
expect(mockFetchLogosByIds).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
expect(mockFetchLogosByIds).toHaveBeenCalled();
});
it('should handle fetch errors gracefully', async () => {
mockFetchLogosByIds.mockRejectedValueOnce(new Error('Fetch failed'));
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
});
it('should not fetch same logo twice', async () => {
mockStore.logos = {};
const { rerender } = render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
rerender(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
});
});
describe('Error Handling', () => {
it('should fallback to default logo on image load error', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/invalid-logo.png' },
};
render(<LazyLogo logoId="logo-1" />);
const img = screen.getByAltText('logo');
// Simulate image load error
img.dispatchEvent(new Event('error'));
expect(img).toHaveAttribute('src', 'mocked-default-logo.png');
});
it('should use custom fallback source on error', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/invalid-logo.png' },
};
const customFallback = 'custom-fallback.png';
render(<LazyLogo logoId="logo-1" fallbackSrc={customFallback} />);
const img = screen.getByAltText('logo');
img.dispatchEvent(new Event('error'));
expect(img).toHaveAttribute('src', customFallback);
});
it('should only set fallback once to prevent infinite error loop', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/invalid-logo.png' },
};
render(<LazyLogo logoId="logo-1" />);
const img = screen.getByAltText('logo');
// First error - should set fallback
img.dispatchEvent(new Event('error'));
expect(img).toHaveAttribute('src', 'mocked-default-logo.png');
// Reset src to test second error
img.src = 'https://example.com/another-invalid.png';
// Second error - should not change src again
img.dispatchEvent(new Event('error'));
expect(img).toHaveAttribute('src', 'mocked-default-logo.png');
});
it('should reset error state when logoId changes', async () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo1.png' },
'logo-2': { cache_url: 'https://example.com/logo2.png' },
};
const { rerender } = render(<LazyLogo logoId="logo-1" />);
const img = screen.getByAltText('logo');
img.dispatchEvent(new Event('error'));
rerender(<LazyLogo logoId="logo-2" />);
const newImg = screen.getByAltText('logo');
expect(newImg).toHaveAttribute('src', 'https://example.com/logo2.png');
});
});
describe('Component Lifecycle', () => {
it('should cleanup on unmount', () => {
const { unmount } = render(<LazyLogo logoId="logo-1" />);
unmount();
// Should not throw errors after unmount
vi.advanceTimersByTime(100);
});
it('should handle rapid logoId changes', async () => {
mockStore.logos = {};
const { rerender } = render(<LazyLogo logoId="logo-1" />);
rerender(<LazyLogo logoId="logo-2" />);
rerender(<LazyLogo logoId="logo-3" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalled();
});
});
describe('Store Integration', () => {
it('should react to store updates', async () => {
mockStore.logos = {};
const { rerender } = render(<LazyLogo logoId="logo-1" />);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
// Update store with logo data
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
rerender(<LazyLogo logoId="logo-1" />);
expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
expect(screen.getByAltText('logo')).toBeInTheDocument();
});
it('should handle allowLogoRendering becoming true', async () => {
mockStore.allowLogoRendering = false;
mockStore.logos = {};
const { rerender } = render(<LazyLogo logoId="logo-1" />);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
expect(mockFetchLogosByIds).not.toHaveBeenCalled();
mockStore.allowLogoRendering = true;
rerender(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo-1']);
});
});
});

View file

@ -0,0 +1,712 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { BrowserRouter } from 'react-router-dom';
import M3URefreshNotification from '../M3URefreshNotification';
import usePlaylistsStore from '../../store/playlists';
import useStreamsStore from '../../store/streams';
import useChannelsStore from '../../store/channels';
import useEPGsStore from '../../store/epgs';
import useVODStore from '../../store/useVODStore';
import API from '../../api';
import { showNotification } from '../../utils/notificationUtils';
// Mock all stores
vi.mock('../../store/playlists', () => ({
default: vi.fn(),
}));
vi.mock('../../store/streams', () => ({
default: vi.fn(),
}));
vi.mock('../../store/channels', () => ({
default: vi.fn(),
}));
vi.mock('../../store/epgs', () => ({
default: vi.fn(),
}));
vi.mock('../../store/useVODStore', () => ({
default: vi.fn(),
}));
// Mock API
vi.mock('../../api', () => ({
default: {
refreshPlaylist: vi.fn(),
requeryChannels: vi.fn(),
},
}));
// Mock notification utility
vi.mock('../../utils/notificationUtils', () => ({
showNotification: vi.fn(),
}));
vi.mock('@mantine/core', async () => {
return {
Stack: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick }) => (
<button onClick={onClick}>{children}</button>
),
};
});
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
CircleCheck: () => <div data-testid="circle-check-icon" />,
}));
const renderWithProviders = (component) => {
return render(
<BrowserRouter>{component}</BrowserRouter>
);
};
describe('M3URefreshNotification', () => {
let mockPlaylistsStore;
let mockStreamsStore;
let mockChannelsStore;
let mockEPGsStore;
let mockVODStore;
const mockPlaylist = {
id: 1,
name: 'Test Playlist',
url: 'https://example.com/playlist.m3u',
};
beforeEach(() => {
vi.clearAllMocks();
// Setup default store mocks
mockPlaylistsStore = {
playlists: [mockPlaylist],
refreshProgress: {},
fetchPlaylists: vi.fn(),
setEditPlaylistId: vi.fn(),
};
mockStreamsStore = {
fetchStreams: vi.fn(),
};
mockChannelsStore = {
fetchChannelGroups: vi.fn(),
fetchChannels: vi.fn(),
};
mockEPGsStore = {
fetchEPGData: vi.fn(),
};
mockVODStore = {
fetchCategories: vi.fn(),
};
usePlaylistsStore.mockImplementation((selector) => selector(mockPlaylistsStore));
useStreamsStore.mockImplementation((selector) => selector(mockStreamsStore));
useChannelsStore.mockImplementation((selector) => selector(mockChannelsStore));
useEPGsStore.mockImplementation((selector) => selector(mockEPGsStore));
useVODStore.mockImplementation((selector) => selector(mockVODStore));
});
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = renderWithProviders(<M3URefreshNotification />);
expect(container).toBeInTheDocument();
});
it('should render empty fragment', () => {
const { container } = renderWithProviders(<M3URefreshNotification />);
expect(container.firstChild).toBeNull();
});
});
describe('Download Progress Notifications', () => {
it('should show notification when download starts', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'downloading',
progress: 0,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'Downloading starting...',
loading: true,
autoClose: 2000,
icon: null,
});
});
});
it('should show notification when download completes', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'downloading',
progress: 100,
status: 'completed',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'Downloading complete!',
loading: false,
autoClose: 2000,
icon: expect.anything(),
});
});
});
it('should not show notification for intermediate progress', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'downloading',
progress: 50,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
describe('Parsing Progress Notifications', () => {
it('should show notification when parsing starts', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'Stream parsing starting...',
loading: true,
autoClose: 2000,
icon: null,
});
});
});
it('should show notification and trigger fetches when parsing completes', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 100,
status: 'completed',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalled();
expect(mockStreamsStore.fetchStreams).toHaveBeenCalled();
expect(API.requeryChannels).toHaveBeenCalled();
expect(mockChannelsStore.fetchChannels).toHaveBeenCalled();
});
});
});
describe('Group Processing Notifications', () => {
it('should show notification when processing groups starts', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'processing_groups',
progress: 0,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'Group parsing starting...',
loading: true,
autoClose: 2000,
icon: null,
});
});
});
it('should trigger multiple fetches when processing groups completes', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'processing_groups',
progress: 100,
status: 'completed',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(mockStreamsStore.fetchStreams).toHaveBeenCalled();
expect(mockChannelsStore.fetchChannelGroups).toHaveBeenCalled();
expect(mockEPGsStore.fetchEPGData).toHaveBeenCalled();
expect(mockPlaylistsStore.fetchPlaylists).toHaveBeenCalled();
});
});
});
describe('VOD Refresh Notifications', () => {
it('should show notification when VOD refresh starts', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'vod_refresh',
progress: 0,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'VOD content refresh starting...',
loading: true,
autoClose: 2000,
icon: null,
});
});
});
it('should trigger VOD-specific fetches when VOD refresh completes', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'vod_refresh',
progress: 100,
status: 'completed',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(mockPlaylistsStore.fetchPlaylists).toHaveBeenCalled();
expect(mockVODStore.fetchCategories).toHaveBeenCalled();
});
});
});
describe('Pending Setup Status', () => {
it('should show setup notification and trigger fetches for pending_setup status', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
status: 'pending_setup',
message: 'Test setup message',
progress: 100,
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Setup: Test Playlist',
message: expect.anything(),
color: 'orange.5',
autoClose: 5000,
});
expect(mockChannelsStore.fetchChannelGroups).toHaveBeenCalled();
expect(mockPlaylistsStore.fetchPlaylists).toHaveBeenCalled();
});
});
it('should use default message when no message provided in pending_setup', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
status: 'pending_setup',
progress: 100,
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalled();
});
});
});
describe('Error Handling', () => {
it('should show error notification when status is error and progress is 100', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
status: 'error',
progress: 100,
error: 'Connection timeout',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'parsing failed: Connection timeout',
color: 'red',
autoClose: 5000,
});
});
});
it('should not show error notification when progress is not 100', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
status: 'error',
progress: 50,
error: 'Connection timeout',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
it('should use default error message when error field is missing', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'downloading',
status: 'error',
progress: 100,
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'downloading failed: Unknown error',
color: 'red',
autoClose: 5000,
});
});
});
it('should use default action when action field is missing in error', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
status: 'error',
progress: 100,
error: 'Test error',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'Processing failed: Test error',
color: 'red',
autoClose: 5000,
});
});
});
it('should not show further notifications after error status', async () => {
// First update with error
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
status: 'error',
progress: 100,
error: 'Test error',
},
};
const { rerender } = renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledTimes(1);
});
vi.clearAllMocks();
// Second update with success
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
status: 'completed',
progress: 100,
},
};
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
// Should not show notification due to previous error
expect(showNotification).not.toHaveBeenCalled();
});
});
describe('Playlist Validation', () => {
it('should not show notification if playlist not found', async () => {
mockPlaylistsStore.playlists = [];
mockPlaylistsStore.refreshProgress = {
999: {
account: 999,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
it('should handle multiple playlists correctly', async () => {
const secondPlaylist = { id: 2, name: 'Second Playlist' };
mockPlaylistsStore.playlists = [mockPlaylist, secondPlaylist];
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
2: {
account: 2,
action: 'downloading',
progress: 100,
status: 'completed',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledTimes(2);
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'M3U Processing: Test Playlist',
})
);
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'M3U Processing: Second Playlist',
})
);
});
});
});
describe('Notification Deduplication', () => {
it('should not show duplicate notification for same status', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
const { rerender } = renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledTimes(1);
});
vi.clearAllMocks();
// Re-render with same data
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
expect(showNotification).not.toHaveBeenCalled();
});
it('should show notification when status changes', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
const { rerender } = renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledTimes(1);
});
vi.clearAllMocks();
// Update with different progress
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 100,
status: 'completed',
},
};
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledTimes(1);
});
});
});
describe('State Cleanup', () => {
it('should reset notification status when playlists change', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
const { rerender } = renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalled();
});
vi.clearAllMocks();
// Change playlists - remove existing playlist
mockPlaylistsStore.playlists = [];
mockPlaylistsStore.refreshProgress = {
2: {
account: 2,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
// Should not show notification because playlist doesn't exist
expect(showNotification).not.toHaveBeenCalled();
});
it('should handle empty playlists array', async () => {
mockPlaylistsStore.playlists = [];
mockPlaylistsStore.refreshProgress = {};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
describe('Effect Dependencies', () => {
it('should re-run effect when refreshProgress changes', async () => {
mockPlaylistsStore.refreshProgress = {};
const { rerender } = renderWithProviders(<M3URefreshNotification />);
expect(showNotification).not.toHaveBeenCalled();
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
await waitFor(() => {
expect(showNotification).toHaveBeenCalled();
});
});
it('should re-run effect when playlists change', async () => {
const { rerender } = renderWithProviders(<M3URefreshNotification />);
const newPlaylist = { id: 2, name: 'New Playlist' };
mockPlaylistsStore.playlists = [mockPlaylist, newPlaylist];
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
});
});
});

View file

@ -0,0 +1,169 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import RecordingSynopsis from '../RecordingSynopsis';
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Text: ({ children, size, c, lineClamp, onClick, title, style, ...props }) => {
return (
<div
data-testid="text"
data-size={size}
data-color={c}
data-line-clamp={lineClamp}
title={title}
onClick={onClick}
style={style}
{...props}
>
{children}
</div>
);
},
};
});
describe('RecordingSynopsis', () => {
let mockOnOpen;
beforeEach(() => {
vi.clearAllMocks();
mockOnOpen = vi.fn();
});
describe('Rendering', () => {
it('should render with short description', () => {
const shortDescription = 'This is a short description.';
render(
<RecordingSynopsis description={shortDescription} onOpen={mockOnOpen} />
);
const text = screen.getByText(shortDescription);
expect(text).toBeInTheDocument();
});
it('should return null when description is undefined', () => {
const { container } = render(
<RecordingSynopsis description={undefined} onOpen={mockOnOpen} />
);
expect(container.firstChild).toBeNull();
});
it('should return null when description is null', () => {
const { container } = render(
<RecordingSynopsis description={null} onOpen={mockOnOpen} />
);
expect(container.firstChild).toBeNull();
});
it('should return null when description is empty string', () => {
const { container } = render(
<RecordingSynopsis description="" onOpen={mockOnOpen} />
);
expect(container.firstChild).toBeNull();
});
it('should render without onOpen callback', () => {
const description = 'Test description';
render(<RecordingSynopsis description={description} />);
expect(screen.getByText(description)).toBeInTheDocument();
});
});
describe('Text Truncation', () => {
it('should not truncate description with exactly 140 characters', () => {
const exactLength = 'A'.repeat(140);
render(
<RecordingSynopsis description={exactLength} onOpen={mockOnOpen} />
);
expect(screen.getByText(exactLength)).toBeInTheDocument();
expect(screen.queryByText(/\.\.\./)).not.toBeInTheDocument();
});
it('should truncate description with 141 characters', () => {
const overLength = 'A'.repeat(141);
render(
<RecordingSynopsis description={overLength} onOpen={mockOnOpen} />
);
const expectedPreview = `${overLength.slice(0, 140).trim()}...`;
expect(screen.getByText(expectedPreview)).toBeInTheDocument();
});
it('should trim whitespace before adding ellipsis', () => {
const description = 'A'.repeat(135) + ' B'.repeat(10);
render(
<RecordingSynopsis description={description} onOpen={mockOnOpen} />
);
const preview = screen.getByTestId('text').textContent;
expect(preview).toMatch(/\.\.\./);
expect(preview).not.toMatch(/\s+\.\.\./);
});
});
describe('Click Interactions', () => {
it('should call onOpen when clicked', () => {
const description = 'Test description';
render(
<RecordingSynopsis description={description} onOpen={mockOnOpen} />
);
const text = screen.getByText(description);
fireEvent.click(text);
expect(mockOnOpen).toHaveBeenCalledTimes(1);
});
it('should not throw error when clicked without onOpen callback', () => {
const description = 'Test description';
render(<RecordingSynopsis description={description} />);
const text = screen.getByText(description);
expect(() => fireEvent.click(text)).not.toThrow();
});
it('should handle multiple clicks', () => {
const description = 'Test description';
render(
<RecordingSynopsis description={description} onOpen={mockOnOpen} />
);
const text = screen.getByText(description);
fireEvent.click(text);
fireEvent.click(text);
fireEvent.click(text);
expect(mockOnOpen).toHaveBeenCalledTimes(3);
});
it('should be clickable with truncated text', () => {
const longDescription = 'A'.repeat(200);
render(
<RecordingSynopsis description={longDescription} onOpen={mockOnOpen} />
);
const text = screen.getByTestId('text');
fireEvent.click(text);
expect(mockOnOpen).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,864 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import SeriesModal from '../SeriesModal';
import useVODStore from '../../store/useVODStore';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
import { copyToClipboard } from '../../utils';
// Mock stores
vi.mock('../../store/useVODStore', () => ({
default: vi.fn(),
}));
vi.mock('../../store/useVideoStore', () => ({
default: vi.fn(),
}));
vi.mock('../../store/settings', () => ({
default: vi.fn(),
}));
// Mock utils
vi.mock('../../utils', () => ({
copyToClipboard: vi.fn(),
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
Play: () => <div data-testid="play-icon" />,
Copy: () => <div data-testid="copy-icon" />,
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
const actual = await vi.importActual('@mantine/core');
return {
...actual,
Modal: ({ opened, onClose, title, children, size, ...props }) => {
if (!opened) return null;
return (
<div data-testid="modal" data-title={title} data-size={size}>
<button onClick={onClose} data-testid="modal-close">Close</button>
<div>{children}</div>
</div>
);
},
Box: ({ children, ...props }) => <div data-testid="box" {...props}>{children}</div>,
Button: ({ children, onClick, disabled, ...props }) => (
<button onClick={onClick} disabled={disabled} data-testid="button" {...props}>
{children}
</button>
),
Flex: ({ children, ...props }) => <div data-testid="flex" {...props}>{children}</div>,
Group: ({ children, ...props }) => <div data-testid="group" {...props}>{children}</div>,
Image: ({ src, alt, ...props }) => (
<img src={src} alt={alt} data-testid="image" {...props} />
),
Text: ({ children, ...props }) => <div data-testid="text" {...props}>{children}</div>,
Title: ({ children, order, ...props }) => (
<div data-testid="title" data-order={order} {...props}>{children}</div>
),
Select: ({ value, onChange, data, label, placeholder, disabled, ...props }) => (
<div data-testid="select" data-label={label}>
<select
value={value || ''}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
{...props}
>
<option value="">{placeholder}</option>
{data?.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</div>
),
Badge: ({ children, ...props }) => <a data-testid="badge" {...props}>{children}</a>,
Loader: (props) => <div data-testid="loader" {...props} />,
Stack: ({ children, ...props }) => <div data-testid="stack" {...props}>{children}</div>,
ActionIcon: ({ children, onClick, disabled, ...props }) => (
<button onClick={onClick} disabled={disabled} data-testid="action-icon" {...props}>
{children}
</button>
),
Tabs: ({ children, value, onChange, ...props }) => (
<div data-testid="tabs" data-value={value} {...props}>
<div onClick={(e) => {
const tab = e.target.closest('[data-tab-value]');
if (tab) onChange?.(tab.dataset.tabValue);
}}>
{children}
</div>
</div>
),
TabsList: ({ children }) => <div data-testid="tabs-list">{children}</div>,
TabsTab: ({ children, value }) => (
<button data-testid="tabs-tab" data-tab-value={value}>{children}</button>
),
TabsPanel: ({ children, value }) => (
<div data-testid="tabs-panel" data-value={value}>{children}</div>
),
Table: ({ children, ...props }) => <table data-testid="table" {...props}>{children}</table>,
TableThead: ({ children }) => <thead data-testid="table-thead">{children}</thead>,
TableTbody: ({ children }) => <tbody data-testid="table-tbody">{children}</tbody>,
TableTr: ({ children, onClick, ...props }) => (
<tr onClick={onClick} data-testid="table-tr" {...props}>{children}</tr>
),
TableTh: ({ children, ...props }) => <th data-testid="table-th" {...props}>{children}</th>,
TableTd: ({ children, ...props }) => <td data-testid="table-td" {...props}>{children}</td>,
Divider: (props) => <hr data-testid="divider" {...props} />,
};
});
describe('SeriesModal', () => {
let mockVODStore;
let mockVideoStore;
let mockSettingsStore;
const mockSeries = {
id: 1,
name: 'Test Series',
series_image: 'https://example.com/cover.jpg',
genre: 'Drama, Action',
cast: 'Actor 1, Actor 2',
director: 'Director Name',
m3u_account: { name: 'Test Account' },
rating: '8.5',
release_date: '2020-01-01',
youtube_trailer: 'dQw4w9WgXcQ',
};
const mockEpisode = {
id: 1,
uuid: 'episode-uuid-1',
series_id: 1,
season_number: 1,
episode_number: 1,
name: 'Pilot',
duration_secs: 3600,
rating: '8.0',
container_extension: 'mkv',
added: '2024-01-01T00:00:00Z',
};
const mockDetailedSeries = {
...mockSeries,
episodesList: [mockEpisode],
tmdb_id: '12345',
imdb_id: 'tt1234567',
};
const mockProviders = [
{
id: 1,
stream_id: 100,
account_id: 5,
m3u_account: { name: 'Provider 1' },
stream_name: 'Test Series 1080p',
quality_info: { quality: '1080p' },
},
{
id: 2,
stream_id: 101,
account_id: 5,
m3u_account: { name: 'Provider 2' },
stream_name: 'Test Series 720p',
quality_info: null,
}
];
beforeEach(() => {
vi.clearAllMocks();
mockVODStore = {
fetchSeriesInfo: vi.fn().mockResolvedValue(mockDetailedSeries),
fetchSeriesProviders: vi.fn().mockResolvedValue(mockProviders),
};
mockVideoStore = {
showVideo: vi.fn(),
};
mockSettingsStore = {
environment: { env_mode: 'prod' },
};
useVODStore.mockImplementation((selector) => selector ? selector(mockVODStore) : mockVODStore);
useVideoStore.mockImplementation((selector) => selector ? selector(mockVideoStore) : mockVideoStore);
useSettingsStore.mockImplementation((selector) => selector ? selector(mockSettingsStore) : mockSettingsStore);
copyToClipboard.mockResolvedValue(undefined);
});
describe('Rendering', () => {
it('should render nothing when series is null', () => {
const { container } = render(
<SeriesModal series={null} opened={true} onClose={vi.fn()} />
);
expect(container.firstChild).toBeNull();
});
it('should render nothing when modal is closed', () => {
render(
<SeriesModal series={mockSeries} opened={false} onClose={vi.fn()} />
);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('should render modal when opened with series', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
});
it('should display series name as title', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText('Test Series')).toBeInTheDocument();
});
});
it('should display cover image', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const image = screen.getByAltText('Test Series');
expect(image).toHaveAttribute('src', 'https://example.com/cover.jpg');
});
});
it('should show loader while fetching details', () => {
mockVODStore.fetchSeriesInfo = vi.fn(() => new Promise(() => {}));
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
//expect multiple loader instances since we have one for details and one for providers
const loaders = screen.getAllByTestId('loader');
expect(loaders.length).toBeGreaterThan(0);
});
});
describe('Data Fetching', () => {
it('should fetch series info when opened', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalledWith(1);
});
});
it('should fetch series providers when opened', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(mockVODStore.fetchSeriesProviders).toHaveBeenCalledWith(1);
});
});
it('should not fetch data when modal is closed', () => {
render(
<SeriesModal series={mockSeries} opened={false} onClose={vi.fn()} />
);
expect(mockVODStore.fetchSeriesInfo).not.toHaveBeenCalled();
expect(mockVODStore.fetchSeriesProviders).not.toHaveBeenCalled();
});
it('should reset state when modal closes', async () => {
const { rerender } = render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalled();
});
rerender(
<SeriesModal series={mockSeries} opened={false} onClose={vi.fn()} />
);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
describe('Series Information Display', () => {
it('should display genre', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Drama, Action/)).toBeInTheDocument();
});
});
it('should display rating', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/8\.5/)).toBeInTheDocument();
});
});
it('should display IMDB link when imdb_id exists', async () => {
render(
<SeriesModal series={mockDetailedSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const link = screen.getByText(/IMDB/i).closest('a');
expect(link).toHaveAttribute('href', 'https://www.imdb.com/title/tt1234567');
});
});
it('should display TMDB link when tmdb_id exists', async () => {
render(
<SeriesModal series={mockDetailedSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const link = screen.getByText(/TMDB/i).closest('a');
expect(link).toHaveAttribute('href', 'https://www.themoviedb.org/tv/12345');
});
});
});
describe('Provider Selection', () => {
it('should display provider select with fetched providers', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const select = screen.getByTestId('select');
expect(select).toBeInTheDocument();
});
});
it('should format provider label correctly with quality info', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument();
});
});
it('should handle provider selection', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
let select;
await waitFor(() => {
select = screen.getByTestId('select').querySelector('select');
fireEvent.change(select, { target: { value: '1' } });
});
await waitFor(() => {
expect(select.value).toBe('1');
});
});
it('should show loader while fetching providers', () => {
mockVODStore.fetchSeriesProviders = vi.fn(() => new Promise(() => {}));
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
// Should show loading text and loader
expect(screen.getByText('Stream Selection')).toBeInTheDocument();
const loaders = screen.getAllByTestId('loader');
expect(loaders.length).toBeGreaterThan(0);
});
});
describe('Episodes Display', () => {
it('should display episodes grouped by season', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Season 1/i)).toBeInTheDocument();
});
});
it('should display episode information', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Pilot/)).toBeInTheDocument();
});
});
it('should format episode duration correctly', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/1h 0m/)).toBeInTheDocument();
});
});
it('should handle episodes with no duration', async () => {
const episodeNoDuration = { ...mockEpisode, duration_secs: null };
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episodeNoDuration],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Pilot/)).toBeInTheDocument();
});
});
it('should sort episodes by episode number', async () => {
const episode2 = { ...mockEpisode, id: 2, episode_number: 2, name: 'Second Episode' };
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episode2, mockEpisode],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const episodes = screen.getAllByTestId('table-tr');
expect(episodes[1]).toHaveTextContent('Pilot');
expect(episodes[2]).toHaveTextContent('Second Episode');
});
});
});
describe('Episode Actions', () => {
it('should play episode when play button clicked', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const playButtons = screen.getAllByTestId('action-icon');
fireEvent.click(playButtons[0]);
});
expect(mockVideoStore.showVideo).toHaveBeenCalledWith(
expect.stringContaining('/proxy/vod/episode/episode-uuid-1'),
'vod',
mockEpisode
);
});
it('should include provider stream_id in URL when provider selected', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const select = screen.getByTestId('select').querySelector('select');
fireEvent.change(select, { target: { value: '1' } });
});
await waitFor(() => {
const playButtons = screen.getAllByTestId('action-icon');
fireEvent.click(playButtons[0]);
});
expect(mockVideoStore.showVideo).toHaveBeenCalledWith(
expect.stringContaining('stream_id=100'),
'vod',
mockEpisode
);
});
it('should use dev mode URL in development', async () => {
mockSettingsStore.environment.env_mode = 'dev';
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const playButtons = screen.getAllByTestId('action-icon');
fireEvent.click(playButtons[0]);
});
expect(mockVideoStore.showVideo).toHaveBeenCalledWith(
expect.stringContaining('localhost:5656'),
'vod',
mockEpisode
);
});
it('should copy episode link when copy button clicked', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const copyButtons = screen.getAllByTestId('action-icon');
fireEvent.click(copyButtons[1]);
});
expect(copyToClipboard).toHaveBeenCalledWith(
expect.stringContaining('/proxy/vod/episode/episode-uuid-1'),
expect.objectContaining({
successTitle: 'Link Copied!',
successMessage: 'Episode link copied to clipboard',
})
);
});
it('should expand episode details when row clicked', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const rows = screen.getAllByTestId('table-tr');
fireEvent.click(rows[1]);
});
await waitFor(() => {
expect(screen.getByText(/8\.0/)).toBeInTheDocument();
});
});
it('should collapse episode details when clicked again', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const rows = screen.getAllByTestId('table-tr');
fireEvent.click(rows[1]);
});
await waitFor(() => {
expect(screen.getByText(/8\.0/)).toBeInTheDocument();
});
await waitFor(() => {
const rows = screen.getAllByTestId('table-tr');
fireEvent.click(rows[1]);
});
await waitFor(() => {
expect(screen.queryByText(/8\.0/)).not.toBeInTheDocument();
});
});
});
it('should show trailer button when youtube_trailer exists', async () => {
render(<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Watch Trailer')).toBeInTheDocument();
});
});
describe('Season Tabs', () => {
it('should create tabs for each season', async () => {
const season2Episode = { ...mockEpisode, id: 2, season_number: 2, episode_num: 1 };
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [mockEpisode, season2Episode],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Season 1/i)).toBeInTheDocument();
expect(screen.getByText(/Season 2/i)).toBeInTheDocument();
});
});
it('should sort seasons in ascending order', async () => {
const season3Episode = { ...mockEpisode, id: 3, season_number: 3 };
const season2Episode = { ...mockEpisode, id: 2, season_number: 2 };
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [season3Episode, mockEpisode, season2Episode],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const tabs = screen.getAllByTestId('tabs-tab');
expect(tabs[0]).toHaveTextContent('Season 1');
expect(tabs[1]).toHaveTextContent('Season 2');
expect(tabs[2]).toHaveTextContent('Season 3');
});
});
it('should activate first season tab by default', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const tabs = screen.getByTestId('tabs');
expect(tabs).toHaveAttribute('data-value', 'season-1');
});
});
});
describe('Quality Info Extraction', () => {
it('should extract quality from quality_info field', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument();
});
});
it('should extract quality from custom_properties.detailed_info.video', async () => {
const customProviders = [
{
...mockProviders[0],
quality_info: null,
custom_properties: {
detailed_info: {
video: { width: 1920, height: 1080 },
},
},
},
mockProviders[1],
];
mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders);
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument();
});
});
it('should extract quality from stream_name as fallback', async () => {
const customProviders = [
{
...mockProviders[0],
quality_info: null,
stream_name: 'Test Series 720p',
},
mockProviders[1],
];
mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders);
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 720p/)).toBeInTheDocument();
});
});
it('should detect 4K quality', async () => {
const customProviders = [
{
...mockProviders[0],
quality_info: null,
custom_properties: {
detailed_info: {
video: { width: 3840, height: 2160 },
},
},
},
mockProviders[1],
];
mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders);
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 4K/)).toBeInTheDocument();
});
});
it('should show resolution when standard quality not detected', async () => {
const customProviders = [
{
...mockProviders[0],
quality_info: null,
custom_properties: {
detailed_info: {
video: { width: 720, height: 480 },
},
},
},
mockProviders[1],
];
mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders);
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/720x480/)).toBeInTheDocument();
});
});
});
describe('Edge Cases', () => {
it('should handle series with no episodes', async () => {
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.queryByTestId('table')).not.toBeInTheDocument();
});
});
it('should handle fetch errors gracefully', async () => {
mockVODStore.fetchSeriesInfo.mockRejectedValue(new Error('Fetch failed'));
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalled();
});
});
it('should handle episodes with null season_number', async () => {
const episodeNoSeason = { ...mockEpisode, season_number: null };
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episodeNoSeason],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Season 1/i)).toBeInTheDocument();
});
});
it('should handle empty provider list', async () => {
mockVODStore.fetchSeriesProviders.mockResolvedValue([]);
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText('Test Account')).toBeInTheDocument()
});
});
});
describe('Modal Close', () => {
it('should call onClose when close button clicked', async () => {
const onClose = vi.fn();
render(
<SeriesModal series={mockSeries} opened={true} onClose={onClose} />
);
await waitFor(() => {
const closeButton = screen.getByTestId('modal-close');
fireEvent.click(closeButton);
});
expect(onClose).toHaveBeenCalled();
});
});
describe('Helper Functions', () => {
it('should format duration with hours and minutes', () => {
const duration = 7265; // 2h 1m 5s
// This tests the formatDuration function indirectly through episode display
const episode = {
...mockEpisode,
info: { ...mockEpisode.info, duration },
};
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episode],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
waitFor(() => {
expect(screen.getByText(/2h 1m/)).toBeInTheDocument();
});
});
it('should format duration with minutes and seconds when under an hour', () => {
const duration = 125; // 2m 5s
const episode = {
...mockEpisode,
info: { ...mockEpisode.info, duration },
};
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episode],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
waitFor(() => {
expect(screen.getByText(/2m 5s/)).toBeInTheDocument();
});
});
});
});

View file

@ -0,0 +1,455 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Sidebar from '../Sidebar';
import useChannelsStore from '../../store/channels';
import useSettingsStore from '../../store/settings';
import useAuthStore from '../../store/auth';
import { copyToClipboard } from '../../utils';
import { USER_LEVELS } from '../../constants';
// Mock stores
vi.mock('../../store/channels');
vi.mock('../../store/settings');
vi.mock('../../store/auth');
vi.mock('../../utils', () => ({
copyToClipboard: vi.fn(),
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
ListOrdered: ({ onClick }) => <div data-testid="list-ordered-icon" onClick={onClick} />,
Play: ({ onClick }) => <div data-testid="play-icon" onClick={onClick} />,
Database: ({ onClick }) => <div data-testid="database-icon" onClick={onClick} />,
LayoutGrid: ({ onClick }) => <div data-testid="layout-grid-icon" onClick={onClick} />,
Settings: ({ onClick }) => <div data-testid="settings-icon" onClick={onClick} />,
Copy: ({ onClick }) => <div data-testid="copy-icon" onClick={onClick} />,
ChartLine: ({ onClick }) => <div data-testid="chart-line-icon" onClick={onClick} />,
Video: ({ onClick }) => <div data-testid="video-icon" onClick={onClick} />,
PlugZap: ({ onClick }) => <div data-testid="plug-zap-icon" onClick={onClick} />,
LogOut: ({ onClick }) => <div data-testid="logout-icon" onClick={onClick} />,
User: ({ onClick }) => <div data-testid="user-icon" onClick={onClick} />,
FileImage: ({ onClick }) => <div data-testid="file-image-icon" onClick={onClick} />,
}));
// Mock UserForm component
vi.mock('../forms/User', () => ({
default: ({ isOpen, onClose, user }) => (
isOpen ? (
<div data-testid="user-form">
User Form for {user?.username}
<button onClick={onClose}>Close</button>
</div>
) : null
),
}));
vi.mock('@mantine/core', async () => {
return {
Avatar: ({ children }) => <div>{children}</div>,
Group: ({ children, onClick, ...props }) => (
<div onClick={onClick} {...props}>{children}</div>
),
Stack: ({ children }) => <div>{children}</div>,
Box: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <div>{children}</div>,
UnstyledButton: ({ children, onClick, component, to, className }) => {
const Component = component || 'button';
return (
<Component onClick={onClick} to={to} className={className}>
{children}
</Component>
);
},
TextInput: ({ value, onChange, leftSection, rightSection, label }) => (
<div>
{label && <label>{label}</label>}
{leftSection}
<input value={value} onChange={onChange} />
{rightSection}
</div>
),
ActionIcon: ({ children, onClick, ...props }) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
AppShellNavbar: ({ children, style, width, ...props }) => (
<nav
style={{
...style,
width: typeof width?.base === 'number' ? `${width.base}px` : width?.base
}}
{...props}
>
{children}
</nav>
),
};
});
const mockChannels = {
'channel-1': { id: 'channel-1', name: 'Channel 1' },
'channel-2': { id: 'channel-2', name: 'Channel 2' },
'channel-3': { id: 'channel-3', name: 'Channel 3' },
};
const mockEnvironment = {
public_ip: '192.168.1.1',
country_code: 'US',
country_name: 'United States',
};
const mockVersion = {
version: '1.2.3',
timestamp: '20240115',
};
const mockAdminUser = {
id: 1,
username: 'admin',
first_name: 'Admin',
user_level: USER_LEVELS.ADMIN,
};
const mockRegularUser = {
id: 2,
username: 'user',
first_name: 'John',
user_level: USER_LEVELS.USER,
};
const renderSidebar = (props = {}) => {
const defaultProps = {
collapsed: false,
toggleDrawer: vi.fn(),
drawerWidth: 250,
miniDrawerWidth: 80,
};
return render(
<BrowserRouter>
<Sidebar {...defaultProps} {...props} />
</BrowserRouter>
);
};
describe('Sidebar', () => {
beforeEach(() => {
vi.clearAllMocks();
useChannelsStore.mockReturnValue(mockChannels);
useSettingsStore.mockImplementation((selector) => {
const state = {
environment: mockEnvironment,
version: mockVersion,
};
return selector(state);
});
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: true,
user: mockAdminUser,
logout: vi.fn(),
};
return selector(state);
});
});
describe('Brand Section', () => {
it('should render logo and brand name when expanded', () => {
const { container } = renderSidebar();
expect(screen.getByText('Dispatcharr')).toBeInTheDocument();
const logo = container.querySelectorAll('img[src="/src/images/logo.png"]');
expect(logo).toHaveLength(1);
});
it('should hide brand name when collapsed', () => {
renderSidebar({ collapsed: true });
expect(screen.queryByText('Dispatcharr')).not.toBeInTheDocument();
});
it('should toggle drawer when brand is clicked', () => {
const toggleDrawer = vi.fn();
renderSidebar({ toggleDrawer });
const brand = screen.getByText('Dispatcharr').closest('div');
fireEvent.click(brand);
expect(toggleDrawer).toHaveBeenCalledTimes(1);
});
});
describe('Navigation Links - Admin User', () => {
it('should render all admin navigation items', () => {
renderSidebar();
expect(screen.getByText('Channels')).toBeInTheDocument();
expect(screen.getByText('VODs')).toBeInTheDocument();
expect(screen.getByText('M3U & EPG Manager')).toBeInTheDocument();
expect(screen.getByText('TV Guide')).toBeInTheDocument();
expect(screen.getByText('DVR')).toBeInTheDocument();
expect(screen.getByText('Stats')).toBeInTheDocument();
expect(screen.getByText('Plugins')).toBeInTheDocument();
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Logo Manager')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});
it('should display channel count badge', () => {
renderSidebar();
expect(screen.getByText('(3)')).toBeInTheDocument();
});
it('should hide labels and badges when collapsed', () => {
renderSidebar({ collapsed: true });
expect(screen.queryByText('Channels')).not.toBeInTheDocument();
expect(screen.queryByText('(3)')).not.toBeInTheDocument();
});
});
describe('Navigation Links - Regular User', () => {
beforeEach(() => {
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: true,
user: mockRegularUser,
logout: vi.fn(),
};
return selector(state);
});
});
it('should render limited navigation items for regular user', () => {
renderSidebar();
expect(screen.getByText('Channels')).toBeInTheDocument();
expect(screen.getByText('TV Guide')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
expect(screen.queryByText('VODs')).not.toBeInTheDocument();
expect(screen.queryByText('M3U & EPG Manager')).not.toBeInTheDocument();
expect(screen.queryByText('DVR')).not.toBeInTheDocument();
expect(screen.queryByText('Stats')).not.toBeInTheDocument();
expect(screen.queryByText('Plugins')).not.toBeInTheDocument();
expect(screen.queryByText('Users')).not.toBeInTheDocument();
expect(screen.queryByText('Logo Manager')).not.toBeInTheDocument();
});
});
describe('Profile Section - Authenticated', () => {
it('should render public IP with country flag', () => {
renderSidebar();
const ipInput = screen.getByDisplayValue('192.168.1.1');
expect(ipInput).toBeInTheDocument();
const flag = screen.getByAltText('United States');
expect(flag).toHaveAttribute('src', 'https://flagcdn.com/16x12/us.png');
});
it('should copy public IP to clipboard when copy button is clicked', async () => {
copyToClipboard.mockResolvedValue();
renderSidebar();
const copyButton = screen.getByTestId('copy-icon').closest('button');
fireEvent.click(copyButton);
await waitFor(() => {
expect(copyToClipboard).toHaveBeenCalledWith('192.168.1.1', {
successTitle: 'Success',
successMessage: 'Public IP copied to clipboard',
});
});
});
it('should render user avatar and name', () => {
renderSidebar();
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('should fallback to username if first_name is not set', () => {
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: true,
user: { ...mockAdminUser, first_name: null },
logout: vi.fn(),
};
return selector(state);
});
renderSidebar();
expect(screen.getByText('admin')).toBeInTheDocument();
});
it('should open user form when username is clicked', () => {
renderSidebar();
const nameButton = screen.getByText('Admin');
fireEvent.click(nameButton);
expect(screen.getByTestId('user-form')).toBeInTheDocument();
expect(screen.getByText('User Form for admin')).toBeInTheDocument();
});
it('should close user form when close button is clicked', () => {
renderSidebar();
const nameButton = screen.getByText('Admin');
fireEvent.click(nameButton);
expect(screen.getByTestId('user-form')).toBeInTheDocument();
const closeButton = screen.getByText('Close');
fireEvent.click(closeButton);
expect(screen.queryByTestId('user-form')).not.toBeInTheDocument();
});
it('should logout when logout button is clicked', () => {
const mockLogout = vi.fn();
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: true,
user: mockAdminUser,
logout: mockLogout,
};
return selector(state);
});
renderSidebar();
const logoutIcon = screen.getByTestId('logout-icon');
fireEvent.click(logoutIcon);
expect(mockLogout).toHaveBeenCalledTimes(1);
});
it('should hide profile details when collapsed', () => {
renderSidebar({ collapsed: true });
expect(screen.queryByDisplayValue('192.168.1.1')).not.toBeInTheDocument();
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
});
});
describe('Profile Section - Not Authenticated', () => {
beforeEach(() => {
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: false,
user: null,
logout: vi.fn(),
};
return selector(state);
});
});
it('should not render profile section when not authenticated', () => {
renderSidebar();
expect(screen.queryByDisplayValue('192.168.1.1')).not.toBeInTheDocument();
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
});
});
describe('Version Display', () => {
it('should render version when expanded', () => {
renderSidebar();
expect(screen.getByText('v1.2.3-20240115')).toBeInTheDocument();
});
it('should render version without timestamp if not available', () => {
useSettingsStore.mockImplementation((selector) => {
const state = {
environment: mockEnvironment,
version: { version: '1.2.3' },
};
return selector(state);
});
renderSidebar();
expect(screen.getByText('v1.2.3')).toBeInTheDocument();
});
it('should render default version if not loaded', () => {
useSettingsStore.mockImplementation((selector) => {
const state = {
environment: mockEnvironment,
version: null,
};
return selector(state);
});
renderSidebar();
expect(screen.getByText('v0.0.0')).toBeInTheDocument();
});
it('should hide version when collapsed', () => {
renderSidebar({ collapsed: true });
expect(screen.queryByText(/v1.2.3-20240115/)).not.toBeInTheDocument();
});
});
describe('Collapsed State', () => {
it('should apply collapsed class to navigation links', () => {
renderSidebar({ collapsed: true });
const links = screen.getAllByRole('link');
links.forEach(link => {
expect(link).toHaveClass('navlink-collapsed');
});
});
it('should adjust width based on collapsed state', () => {
const { rerender } = renderSidebar({ collapsed: false, drawerWidth: 250 });
const navbar = screen.getByRole('navigation');
expect(navbar).toHaveStyle({ width: '250px' });
rerender(
<BrowserRouter>
<Sidebar collapsed={true} drawerWidth={250} miniDrawerWidth={80} toggleDrawer={vi.fn()} />
</BrowserRouter>
);
expect(navbar).toHaveStyle({ width: '80px' });
});
});
describe('Environment Edge Cases', () => {
it('should handle missing country code gracefully', () => {
useSettingsStore.mockImplementation((selector) => {
const state = {
environment: { public_ip: '192.168.1.1' },
version: mockVersion,
};
return selector(state);
});
renderSidebar();
expect(screen.getByDisplayValue('192.168.1.1')).toBeInTheDocument();
expect(screen.queryByRole('img', { name: /flag/i })).not.toBeInTheDocument();
});
it('should use country code as alt text if country name is missing', () => {
useSettingsStore.mockImplementation((selector) => {
const state = {
environment: {
public_ip: '192.168.1.1',
country_code: 'US',
},
version: mockVersion,
};
return selector(state);
});
renderSidebar();
const flag = screen.getByAltText('US');
expect(flag).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,251 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import SystemEvents from '../SystemEvents';
import API from '../../api';
import useLocalStorage from "../../hooks/useLocalStorage";
// Mock the API module
vi.mock('../../api', () => ({
default: {
getSystemEvents: vi.fn(),
},
}));
// Mock the useLocalStorage hook
vi.mock('../../hooks/useLocalStorage', () => ({
default: vi.fn((key, defaultValue) => {
const mockSetters = {
'events-refresh-interval': vi.fn(),
'events-limit': vi.fn(),
'date-format': vi.fn(),
};
return [defaultValue, mockSetters[key] || vi.fn()];
}),
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
ActionIcon: ({ children, onClick }) => (
<button onClick={onClick}>{children}</button>
),
Box: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick }) => (
<button onClick={onClick}>{children}</button>
),
Card: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
NumberInput: ({ value, onChange, label }) => (
<input
type="number"
value={value}
aria-label={label}
onChange={(e) => onChange(Number(e.target.value))}
/>
),
Pagination: ({ page, onChange, total }) => (
<div>
{Array.from({ length: Math.ceil(total / 100) }, (_, i) => (
<button key={i} onClick={() => onChange(i + 1)}>
{i + 1}
</button>
))}
</div>
),
Select: ({ value, onChange, data }) => (
<select value={value} onChange={(e) => onChange(e.target.value)}>
{data.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
),
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <div>{children}</div>,
Title: ({ children }) => <h1>{children}</h1>,
};
});
// Mock the dateTimeUtils
vi.mock('../../utils/dateTimeUtils.js', () => ({
format: vi.fn((timestamp, format) => '01/15 10:30:45'),
}));
const mockEventsResponse = {
events: [
{
id: 1,
event_type: 'channel_start',
event_type_display: 'Channel Started',
channel_name: 'Test Channel',
timestamp: '2024-01-15T10:30:45Z',
details: { bitrate: '5000kbps' },
},
{
id: 2,
event_type: 'login_success',
event_type_display: 'Login Successful',
timestamp: '2024-01-15T10:25:30Z',
details: {},
},
],
total: 2,
};
describe('SystemEvents', () => {
beforeEach(() => {
vi.clearAllMocks();
API.getSystemEvents.mockResolvedValue(mockEventsResponse);
});
afterEach(() => {
vi.clearAllTimers();
});
it('should render component with title', async () => {
render(<SystemEvents />);
await waitFor(() => {
expect(screen.getByText('System Events')).toBeInTheDocument();
});
});
it('should fetch and display events on mount', async () => {
render(<SystemEvents />);
await waitFor(() => {
expect(API.getSystemEvents).toHaveBeenCalledWith(100, 0);
});
});
it('should expand and show events when chevron is clicked', async () => {
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText('Channel Started')).toBeInTheDocument();
expect(screen.getByText('Login Successful')).toBeInTheDocument();
});
});
it('should display "No events recorded yet" when events array is empty', async () => {
API.getSystemEvents.mockResolvedValue({ events: [], total: 0 });
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText('No events recorded yet')).toBeInTheDocument();
});
});
it('should call fetchEvents when refresh button is clicked', async () => {
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText('Refresh')).toBeInTheDocument();
});
const refreshButton = screen.getByText('Refresh');
fireEvent.click(refreshButton);
await waitFor(() => {
expect(API.getSystemEvents).toHaveBeenCalledTimes(3)
});
});
it('should update events limit when changed', async () => {
const mockSetEventsLimit = vi.fn();
// Update the mock to return the setter for this test
useLocalStorage.mockImplementation((key, defaultValue) => {
if (key === 'events-limit') {
return [100, mockSetEventsLimit];
}
return [defaultValue, vi.fn()];
});
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
const input = screen.getByLabelText('Events Per Page');
fireEvent.change(input, { target: { value: '50' } });
});
expect(mockSetEventsLimit).toHaveBeenCalled();
});
it('should show pagination when total events exceed limit', async () => {
API.getSystemEvents.mockResolvedValue({
events: mockEventsResponse.events,
total: 150,
});
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText(/Showing 1-100 of 150/)).toBeInTheDocument();
});
});
it('should handle API errors gracefully', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
API.getSystemEvents.mockRejectedValue(new Error('API Error'));
render(<SystemEvents />);
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error fetching system events:',
expect.any(Error)
);
});
consoleErrorSpy.mockRestore();
});
it('should display event details correctly', async () => {
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText('Test Channel')).toBeInTheDocument();
expect(screen.getByText('bitrate: 5000kbps')).toBeInTheDocument();
});
});
it('should change page when pagination is clicked', async () => {
API.getSystemEvents.mockResolvedValue({
events: mockEventsResponse.events,
total: 250,
});
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText(/Showing 1-100 of 250/)).toBeInTheDocument();
});
// Note: Pagination interaction would require more specific selectors
// based on Mantine's Pagination component implementation
});
});

View file

@ -0,0 +1,439 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import VODModal from '../VODModal';
import useVODStore from '../../store/useVODStore';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
// Mock stores
vi.mock('../../store/useVODStore');
vi.mock('../../store/useVideoStore');
vi.mock('../../store/settings');
// Mock utils
vi.mock('../../utils', () => ({
copyToClipboard: vi.fn(() => Promise.resolve()),
}));
vi.mock('../../utils/components/SeriesModalUtils.js', () => ({
formatStreamLabel: vi.fn((provider) => `${provider.m3u_account.name} - Stream ${provider.stream_id}`),
imdbUrl: vi.fn((id) => `https://www.imdb.com/title/${id}`),
tmdbUrl: vi.fn((id, type) => `https://www.themoviedb.org/${type}/${id}`),
formatDuration: vi.fn((secs) => `${Math.floor(secs / 60)} min`),
getYouTubeEmbedUrl: vi.fn((url) => `https://www.youtube.com/embed/${url}`),
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>Close</button>
{children}
</div>
) : null,
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Flex: ({ children }) => <div>{children}</div>,
Image: ({ src, alt }) => <img src={src} alt={alt} />,
Text: ({ children, ...props }) => <span {...props}>{children}</span>,
Title: ({ children }) => <h3>{children}</h3>,
Button: ({ children, onClick, disabled, leftSection }) => (
<button onClick={onClick} disabled={disabled}>
{leftSection}
{children}
</button>
),
Badge: ({ children, component, href, ...props }) =>
component === 'a' ? (
<a href={href} {...props}>
{children}
</a>
) : (
<span {...props}>{children}</span>
),
Select: ({ data, value, onChange, placeholder, disabled }) => (
<select data-testid="provider-select" value={value}
onChange={(e) => onChange(e.target.value)} disabled={disabled}>
<option value="">{placeholder}</option>
{data.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
),
Loader: () => <div data-testid="loader">Loading...</div>,
};
});
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
Play: () => <span>Play Icon</span>,
Copy: () => <span>Copy Icon</span>,
}));
describe('VODModal', () => {
const mockShowVideo = vi.fn();
const mockFetchMovieDetailsFromProvider = vi.fn();
const mockFetchMovieProviders = vi.fn();
const mockOnClose = vi.fn();
const mockVOD = {
id: 1,
uuid: 'test-uuid',
name: 'Test Movie',
o_name: 'Original Test Movie',
year: 2023,
duration_secs: 7200,
rating: '8.5',
age: 'PG-13',
imdb_id: 'tt1234567',
tmdb_id: '12345',
release_date: '2023-01-15',
genre: 'Action, Sci-Fi',
director: 'Test Director',
actors: 'Actor 1, Actor 2',
country: 'USA',
description: 'A test movie description',
youtube_trailer: 'test-trailer-id',
movie_image: 'https://example.com/poster.jpg',
backdrop_path: ['https://example.com/backdrop.jpg'],
bitrate: 5000,
m3u_account: { name: 'Test Account', id: 1 },
};
const mockProvider = {
id: 1,
stream_id: 'stream-123',
m3u_account: { name: 'Test Provider', id: 1 },
bitrate: 6000,
};
beforeEach(() => {
vi.clearAllMocks();
useVideoStore.mockImplementation((selector) => {
const state = { showVideo: mockShowVideo };
return selector ? selector(state) : state;
});
useVODStore.mockImplementation((selector) => {
const state = {
fetchMovieDetailsFromProvider: mockFetchMovieDetailsFromProvider,
fetchMovieProviders: mockFetchMovieProviders,
};
return selector ? selector(state) : state;
});
useSettingsStore.mockImplementation((selector) => {
const state = { environment: { env_mode: 'prod' } };
return selector ? selector(state) : state;
});
mockFetchMovieDetailsFromProvider.mockResolvedValue(mockVOD);
mockFetchMovieProviders.mockResolvedValue([mockProvider]);
});
it('should not render when vod is null', () => {
render(<VODModal vod={null} opened={true} onClose={mockOnClose} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('should render modal when opened with vod', () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie');
});
it('should not render when closed', () => {
render(<VODModal vod={mockVOD} opened={false} onClose={mockOnClose} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('should display movie details correctly', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Original: Original Test Movie')).toBeInTheDocument();
});
expect(screen.getByText('2023')).toBeInTheDocument();
expect(screen.getByText('8.5')).toBeInTheDocument();
expect(screen.getByText('PG-13')).toBeInTheDocument();
expect(screen.getByText('Action, Sci-Fi')).toBeInTheDocument();
expect(screen.getByText(/Test Director/)).toBeInTheDocument();
expect(screen.getByText(/Actor 1, Actor 2/)).toBeInTheDocument();
expect(screen.getByText(/A test movie description/)).toBeInTheDocument();
});
it('should fetch movie details on mount', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockFetchMovieDetailsFromProvider).toHaveBeenCalledWith(mockVOD.id);
});
});
it('should fetch movie providers on mount', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockFetchMovieProviders).toHaveBeenCalledWith(mockVOD.id);
});
});
it('should show loading state while fetching details', () => {
mockFetchMovieDetailsFromProvider.mockImplementation(
() => new Promise(() => {})
);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
expect(screen.getByText('Loading additional details...')).toBeInTheDocument();
});
it('should handle play button click', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockFetchMovieProviders).toHaveBeenCalled();
});
const playButton = screen.getByText('Play Movie');
fireEvent.click(playButton);
expect(mockShowVideo).toHaveBeenCalled();
});
it('should disable play button when multiple providers and none selected', async () => {
mockFetchMovieProviders.mockResolvedValue([mockProvider, { ...mockProvider, id: 2 }]);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Play Movie')).toBeInTheDocument();
});
// Note: Testing for disabled state would require checking the button's disabled attribute
});
it('should handle provider selection', async () => {
const providers = [
mockProvider,
{ ...mockProvider, id: 2, stream_id: 'stream-456' },
];
mockFetchMovieProviders.mockResolvedValue(providers);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const select = screen.getByTestId('provider-select');
expect(select).toBeInTheDocument();
});
const select = screen.getByTestId('provider-select');
fireEvent.change(select, { target: { value: '2' } });
await waitFor(() => {
const select = screen.getByTestId('provider-select');
expect(select).toHaveValue('2');
});
});
it('should display single provider as badge', async () => {
mockFetchMovieProviders.mockResolvedValue([mockProvider]);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Test Provider')).toBeInTheDocument();
});
});
it('should handle fetch details error gracefully', async () => {
mockFetchMovieDetailsFromProvider.mockRejectedValue(new Error('Fetch failed'));
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockFetchMovieDetailsFromProvider).toHaveBeenCalled();
});
// Should still display basic VOD info
expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie');
});
it('should handle fetch providers error gracefully', async () => {
mockFetchMovieProviders.mockRejectedValue(new Error('Fetch failed'));
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockFetchMovieProviders).toHaveBeenCalled();
});
// Should still render without providers
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('should display technical details when available', async () => {
const vodWithTech = {
...mockVOD,
bitrate: 5000,
video: {
codec_name: 'h264',
width: 1920,
height: 1080,
},
audio: {
codec_name: 'aac',
channels: 2,
},
};
mockFetchMovieDetailsFromProvider.mockResolvedValue(vodWithTech);
render(<VODModal vod={vodWithTech} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText(/Technical Details:/)).toBeInTheDocument();
});
});
it('should render IMDb and TMDb badges with correct links', () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
const imdbLink = screen.getByText('IMDb');
const tmdbLink = screen.getByText('TMDb');
expect(imdbLink).toHaveAttribute('href', 'https://www.imdb.com/title/tt1234567');
expect(tmdbLink).toHaveAttribute('href', 'https://www.themoviedb.org/movie/12345');
});
describe('Copy Link Functionality', () => {
it('should copy movie link when copy button clicked', async () => {
const { copyToClipboard } = await import('../../utils');
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const copyButton = screen.getByText('Copy Link');
fireEvent.click(copyButton);
});
expect(copyToClipboard).toHaveBeenCalledWith(
expect.stringContaining('/proxy/vod/movie/test-uuid'),
expect.objectContaining({
successTitle: expect.any(String),
successMessage: expect.any(String),
})
);
});
it('should include provider stream_id in copied URL when provider selected', async () => {
const { copyToClipboard } = await import('../../utils');
mockFetchMovieProviders.mockResolvedValue([
{ ...mockProvider, id: 1 },
{ ...mockProvider, id: 2, stream_id: 'stream-456' },
]);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const select = screen.getByTestId('provider-select');
fireEvent.change(select, { target: { value: '2' } });
});
await waitFor(() => {
const copyButton = screen.getByText('Copy Link')
fireEvent.click(copyButton);
});
expect(copyToClipboard).toHaveBeenCalledWith(
expect.stringContaining('stream_id=stream-456'),
expect.any(Object)
);
});
});
describe('URL Generation', () => {
it('should use dev mode URL in development', async () => {
useSettingsStore.mockImplementation((selector) => {
const state = { environment: { env_mode: 'dev' } };
return selector ? selector(state) : state;
});
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const playButton = screen.getByText('Play Movie');
fireEvent.click(playButton);
});
expect(mockShowVideo).toHaveBeenCalledWith(
expect.stringContaining('localhost:5656'),
'vod',
expect.any(Object)
);
});
it('should use production URL in production', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const playButton = screen.getByText('Play Movie');
fireEvent.click(playButton);
});
expect(mockShowVideo).toHaveBeenCalledWith(
expect.not.stringContaining('localhost:5656'),
'vod',
expect.any(Object)
);
});
});
describe('Modal Interaction', () => {
it('should call onClose when close button clicked', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const closeButton = screen.getByTestId('modal-close');
fireEvent.click(closeButton);
});
expect(mockOnClose).toHaveBeenCalled();
});
});
describe('Edge Cases', () => {
it('should handle VOD with no image', async () => {
const vodNoImage = { ...mockVOD, movie_image: null };
render(<VODModal vod={vodNoImage} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
});
it('should handle VOD with missing optional fields', async () => {
const minimalVOD = {
id: 1,
uuid: 'test-uuid',
name: 'Test Movie',
m3u_account: { name: 'Test Account', id: 1 },
};
render(<VODModal vod={minimalVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie');
});
});
});
});

View file

@ -0,0 +1,109 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { YouTubeTrailerModal } from '../YouTubeTrailerModal';
// Mock Mantine components
vi.mock('@mantine/core', () => ({
Modal: ({ opened, onClose, title, children, size, centered }) => {
if (!opened) return null;
return (
<div
data-testid="modal"
data-title={title}
data-size={size}
data-centered={centered}
>
<button onClick={onClose} data-testid="modal-close">Close</button>
<div>{children}</div>
</div>
);
},
Box: ({ children, ...props }) => (
<div data-testid="box" {...props}>{children}</div>
),
}));
describe('YouTubeTrailerModal', () => {
const mockTrailerUrl = 'https://www.youtube.com/embed/dQw4w9WgXcQ';
const mockOnClose = vi.fn();
it('should not render when opened is false', () => {
render(
<YouTubeTrailerModal
opened={false}
onClose={mockOnClose}
trailerUrl={mockTrailerUrl}
/>
);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('should render modal when opened is true', () => {
render(
<YouTubeTrailerModal
opened={true}
onClose={mockOnClose}
trailerUrl={mockTrailerUrl}
/>
);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('should display correct modal title', () => {
render(
<YouTubeTrailerModal
opened={true}
onClose={mockOnClose}
trailerUrl={mockTrailerUrl}
/>
);
const modal = screen.getByTestId('modal');
expect(modal).toHaveAttribute('data-title', 'Trailer');
});
it('should render iframe with correct trailerUrl', () => {
render(
<YouTubeTrailerModal
opened={true}
onClose={mockOnClose}
trailerUrl={mockTrailerUrl}
/>
);
const iframe = screen.getByTitle('YouTube Trailer');
expect(iframe).toBeInTheDocument();
expect(iframe).toHaveAttribute('src', mockTrailerUrl);
});
it('should not render iframe when trailerUrl is null', () => {
render(
<YouTubeTrailerModal
opened={true}
onClose={mockOnClose}
trailerUrl={null}
/>
);
expect(screen.queryByTitle('YouTube Trailer')).not.toBeInTheDocument();
});
it('should call onClose when close button clicked', () => {
const mockClose = vi.fn();
render(
<YouTubeTrailerModal
opened={true}
onClose={mockClose}
trailerUrl={mockTrailerUrl}
/>
);
const closeButton = screen.getByTestId('modal-close');
closeButton.click();
expect(mockClose).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,235 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
getLivePlayerErrorMessage,
getVODPlayerErrorMessage,
getClientCoordinates,
calculateNewDimensions,
applyConstraints,
} from '../FloatingVideoUtils';
describe('FloatingVideoUtils', () => {
describe('getLivePlayerErrorMessage', () => {
it('should return formatted error for non-MediaError types', () => {
expect(getLivePlayerErrorMessage('NetworkError', 'Connection failed')).toBe(
'Error: NetworkError - Connection failed'
);
});
it('should return error type only when no detail provided', () => {
expect(getLivePlayerErrorMessage('NetworkError')).toBe('Error: NetworkError');
});
it('should return audio codec message for audio-related errors', () => {
const result = getLivePlayerErrorMessage('MediaError', 'audio codec not supported');
expect(result).toBe(
'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.'
);
});
it('should return audio codec message for AC3 errors', () => {
const result = getLivePlayerErrorMessage('MediaError', 'AC3 codec issue');
expect(result).toContain('Audio codec not supported');
});
it('should return video codec message for video-related errors', () => {
const result = getLivePlayerErrorMessage('MediaError', 'video codec h264 failed');
expect(result).toBe(
'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.'
);
});
it('should return MSE message for MSE-related errors', () => {
const result = getLivePlayerErrorMessage('MediaError', 'MSE not supported');
expect(result).toBe(
"Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility."
);
});
it('should return generic codec message for other MediaError cases', () => {
const result = getLivePlayerErrorMessage('MediaError', 'unknown codec issue');
expect(result).toBe(
'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.'
);
});
it('should handle null errorDetail for MediaError', () => {
const result = getLivePlayerErrorMessage('MediaError', null);
expect(result).toBe(
'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.'
);
});
});
describe('getVODPlayerErrorMessage', () => {
it('should return default message when error is null', () => {
expect(getVODPlayerErrorMessage(null)).toBe('Video playback error');
});
it('should return aborted message for MEDIA_ERR_ABORTED', () => {
const error = { code: 1, MEDIA_ERR_ABORTED: 1 };
expect(getVODPlayerErrorMessage(error)).toBe('Video playback was aborted');
});
it('should return network message for MEDIA_ERR_NETWORK', () => {
const error = { code: 2, MEDIA_ERR_NETWORK: 2 };
expect(getVODPlayerErrorMessage(error)).toBe('Network error while loading video');
});
it('should return codec message for MEDIA_ERR_DECODE', () => {
const error = { code: 3, MEDIA_ERR_DECODE: 3 };
expect(getVODPlayerErrorMessage(error)).toBe('Video codec not supported by your browser');
});
it('should return format message for MEDIA_ERR_SRC_NOT_SUPPORTED', () => {
const error = { code: 4, MEDIA_ERR_SRC_NOT_SUPPORTED: 4 };
expect(getVODPlayerErrorMessage(error)).toBe('Video format not supported by your browser');
});
it('should return error message for unknown error codes', () => {
const error = { code: 99, message: 'Custom error message' };
expect(getVODPlayerErrorMessage(error)).toBe('Custom error message');
});
it('should return default message for unknown error without message', () => {
const error = { code: 99 };
expect(getVODPlayerErrorMessage(error)).toBe('Unknown video error');
});
});
describe('getClientCoordinates', () => {
it('should extract coordinates from mouse event', () => {
const event = { clientX: 100, clientY: 200 };
expect(getClientCoordinates(event)).toEqual({ clientX: 100, clientY: 200 });
});
it('should extract coordinates from touch event', () => {
const event = { touches: [{ clientX: 150, clientY: 250 }] };
expect(getClientCoordinates(event)).toEqual({ clientX: 150, clientY: 250 });
});
it('should prioritize touch coordinates over mouse coordinates', () => {
const event = {
touches: [{ clientX: 150, clientY: 250 }],
clientX: 100,
clientY: 200,
};
expect(getClientCoordinates(event)).toEqual({ clientX: 150, clientY: 250 });
});
it('should handle undefined coordinates', () => {
const event = {};
expect(getClientCoordinates(event)).toEqual({ clientX: undefined, clientY: undefined });
});
});
describe('calculateNewDimensions', () => {
const ratio = 16 / 9;
it('should calculate dimensions based on horizontal drag', () => {
const handle = { xDir: 1, yDir: 0, isLeft: false, isTop: false };
const result = calculateNewDimensions(100, 10, 400, 225, handle, ratio);
expect(result.width).toBe(500);
expect(result.height).toBeCloseTo(281.25, 1);
});
it('should calculate dimensions based on vertical drag', () => {
const handle = { xDir: 0, yDir: 1, isLeft: false, isTop: false };
const result = calculateNewDimensions(10, 100, 400, 225, handle, ratio);
expect(result.height).toBe(325);
expect(result.width).toBeCloseTo(577.78, 1);
});
it('should use vertical-driven resize when vertical delta is larger', () => {
const handle = { xDir: 1, yDir: 1, isLeft: false, isTop: false };
const result = calculateNewDimensions(20, 100, 400, 225, handle, ratio);
expect(result.height).toBe(325);
expect(result.width).toBeCloseTo(577.78, 1);
});
it('should handle negative deltas', () => {
const handle = { xDir: -1, yDir: 0, isLeft: true, isTop: false };
const result = calculateNewDimensions(-50, 0, 400, 225, handle, ratio);
expect(result.width).toBe(450);
expect(result.height).toBeCloseTo(253.13, 1);
});
});
describe('applyConstraints', () => {
const ratio = 16 / 9;
const minWidth = 200;
const minHeight = 112.5;
const visibleMargin = 50;
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', { writable: true, value: 1920 });
Object.defineProperty(window, 'innerHeight', { writable: true, value: 1080 });
});
it('should apply minimum width constraint', () => {
const handle = { isLeft: false, isTop: false };
const result = applyConstraints(100, 50, ratio, { x: 0, y: 0 }, handle, minWidth, minHeight, visibleMargin);
expect(result.width).toBe(minWidth);
expect(result.height).toBeCloseTo(minWidth / ratio, 1);
});
it('should apply minimum height constraint', () => {
const handle = { isLeft: false, isTop: false };
const result = applyConstraints(300, 100, ratio, { x: 0, y: 0 }, handle, minWidth, minHeight, visibleMargin);
expect(result.height).toBe(minHeight);
expect(result.width).toBeCloseTo(minHeight * ratio, 1);
});
it('should apply maximum width constraint based on viewport', () => {
const handle = { isLeft: false, isTop: false };
const startPos = { x: 1200, y: 100 };
const result = applyConstraints(800, 450, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
const maxWidth = 1920 - 1200 - 50; // 670
expect(result.width).toBe(maxWidth);
expect(result.height).toBeCloseTo(maxWidth / ratio, 1);
});
it('should apply maximum height constraint based on viewport', () => {
const handle = { isLeft: false, isTop: false };
const startPos = { x: 100, y: 700 };
const result = applyConstraints(500, 400, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
const maxHeight = 1080 - 700 - 50; // 330
expect(result.height).toBe(maxHeight);
expect(result.width).toBeCloseTo(maxHeight * ratio, 1);
});
it('should not apply max width constraint for left handle', () => {
const handle = { isLeft: true, isTop: false };
const startPos = { x: 1800, y: 100 };
const result = applyConstraints(500, 281.25, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
expect(result.width).toBe(500);
expect(result.height).toBeCloseTo(281.25, 1);
});
it('should not apply max height constraint for top handle', () => {
const handle = { isLeft: false, isTop: true };
const startPos = { x: 100, y: 1000 };
const result = applyConstraints(500, 400, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
expect(result.width).toBe(500);
expect(result.height).toBe(400);
});
it('should handle null startPos', () => {
const handle = { isLeft: false, isTop: false };
const result = applyConstraints(300, 168.75, ratio, null, handle, minWidth, minHeight, visibleMargin);
expect(result.width).toBe(300);
expect(result.height).toBeCloseTo(168.75, 1);
});
});
});

View file

@ -0,0 +1,397 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
imdbUrl,
tmdbUrl,
formatDuration,
formatStreamLabel,
sortEpisodesList,
groupEpisodesBySeason,
sortBySeasonNumber,
getEpisodeStreamUrl,
getYouTubeEmbedUrl,
getEpisodeAirdate,
getTmdbUrlLink
} from '../SeriesModalUtils';
describe('SeriesModalUtils', () => {
describe('imdbUrl', () => {
it('should return IMDb URL with valid ID', () => {
expect(imdbUrl('tt1234567')).toBe('https://www.imdb.com/title/tt1234567');
});
it('should return empty string when ID is null', () => {
expect(imdbUrl(null)).toBe('');
});
it('should return empty string when ID is undefined', () => {
expect(imdbUrl(undefined)).toBe('');
});
});
describe('tmdbUrl', () => {
it('should return TMDB movie URL by default', () => {
expect(tmdbUrl('12345')).toBe('https://www.themoviedb.org/movie/12345');
});
it('should return TMDB TV URL when type is tv', () => {
expect(tmdbUrl('12345', 'tv')).toBe('https://www.themoviedb.org/tv/12345');
});
it('should return empty string when ID is null', () => {
expect(tmdbUrl(null)).toBe('');
});
});
describe('formatDuration', () => {
it('should format hours and minutes', () => {
expect(formatDuration(7200)).toBe('2h 0m');
});
it('should format hours, minutes and seconds', () => {
expect(formatDuration(3665)).toBe('1h 1m');
});
it('should format minutes and seconds when under an hour', () => {
expect(formatDuration(125)).toBe('2m 5s');
});
it('should return empty string for 0 seconds', () => {
expect(formatDuration(0)).toBe('');
});
it('should return empty string for null', () => {
expect(formatDuration(null)).toBe('');
});
});
describe('formatStreamLabel', () => {
const baseRelation = {
m3u_account: { name: 'Provider 1' },
stream_id: 100
};
it('should format label with quality from quality_info', () => {
const relation = {
...baseRelation,
quality_info: { quality: '1080p' }
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)');
});
it('should format label with quality from resolution', () => {
const relation = {
...baseRelation,
quality_info: { resolution: '4K' }
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
});
it('should format label with quality from bitrate', () => {
const relation = {
...baseRelation,
quality_info: { bitrate: '8000kbps' }
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 8000kbps (Stream 100)');
});
it('should detect 4K from video dimensions', () => {
const relation = {
...baseRelation,
custom_properties: {
detailed_info: {
video: { width: 3840, height: 2160 }
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
});
it('should detect 1080p from video dimensions', () => {
const relation = {
...baseRelation,
custom_properties: {
detailed_info: {
video: { width: 1920, height: 1080 }
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)');
});
it('should detect 720p from video dimensions', () => {
const relation = {
...baseRelation,
custom_properties: {
detailed_info: {
video: { width: 1280, height: 720 }
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 720p (Stream 100)');
});
it('should show custom dimensions for non-standard resolutions', () => {
const relation = {
...baseRelation,
custom_properties: {
detailed_info: {
video: { width: 640, height: 480 }
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 640x480 (Stream 100)');
});
it('should detect quality from detailed_info name field', () => {
const relation = {
...baseRelation,
custom_properties: {
detailed_info: {
name: 'Stream 4K HDR'
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
});
it('should detect quality from stream_name', () => {
const relation = {
...baseRelation,
stream_name: 'Channel 1080p FHD'
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)');
});
it('should detect 2160p as 4K', () => {
const relation = {
...baseRelation,
stream_name: 'Stream 2160p'
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
});
it('should detect FHD as 1080p', () => {
const relation = {
...baseRelation,
stream_name: 'Stream FHD'
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)');
});
it('should detect HD as 720p', () => {
const relation = {
...baseRelation,
stream_name: 'Stream HD'
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 720p (Stream 100)');
});
it('should format without stream ID when not provided', () => {
const relation = {
m3u_account: { name: 'Provider 1' },
quality_info: { quality: '1080p' }
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p');
});
it('should format without quality when not detected', () => {
const relation = {
m3u_account: { name: 'Provider 1' },
stream_id: 100
};
expect(formatStreamLabel(relation)).toBe('Provider 1 (Stream 100)');
});
it('should prioritize quality_info over detailed_info', () => {
const relation = {
...baseRelation,
quality_info: { quality: '4K' },
custom_properties: {
detailed_info: {
video: { width: 1920, height: 1080 }
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
});
});
describe('sortEpisodesList', () => {
it('should sort episodes by season then episode number', () => {
const episodes = [
{ season_number: 2, episode_number: 1 },
{ season_number: 1, episode_number: 2 },
{ season_number: 1, episode_number: 1 }
];
const sorted = sortEpisodesList(episodes);
expect(sorted).toEqual([
{ season_number: 1, episode_number: 1 },
{ season_number: 1, episode_number: 2 },
{ season_number: 2, episode_number: 1 }
]);
});
it('should handle missing season numbers as 0', () => {
const episodes = [
{ season_number: 1, episode_number: 1 },
{ episode_number: 1 }
];
const sorted = sortEpisodesList(episodes);
expect(sorted[0].season_number).toBeUndefined();
});
});
describe('groupEpisodesBySeason', () => {
it('should group episodes by season number', () => {
const episodes = [
{ season_number: 1, episode_number: 1 },
{ season_number: 1, episode_number: 2 },
{ season_number: 2, episode_number: 1 }
];
const grouped = groupEpisodesBySeason(episodes);
expect(grouped[1]).toHaveLength(2);
expect(grouped[2]).toHaveLength(1);
});
it('should default missing season numbers to 1', () => {
const episodes = [{ episode_number: 1 }];
const grouped = groupEpisodesBySeason(episodes);
expect(grouped[1]).toHaveLength(1);
});
});
describe('sortBySeasonNumber', () => {
it('should return season numbers in ascending order', () => {
const episodesBySeason = {
3: [],
1: [],
2: []
};
const sorted = sortBySeasonNumber(episodesBySeason);
expect(sorted).toEqual([1, 2, 3]);
});
});
describe('getEpisodeStreamUrl', () => {
beforeEach(() => {
delete window.location;
window.location = {
protocol: 'https:',
hostname: 'example.com',
origin: 'https://example.com'
};
});
it('should generate stream URL without provider in production', () => {
const episode = { uuid: 'episode-123' };
const url = getEpisodeStreamUrl(episode, null, 'prod');
expect(url).toBe('https://example.com/proxy/vod/episode/episode-123');
});
it('should generate stream URL with stream_id parameter', () => {
const episode = { uuid: 'episode-123' };
const provider = {
stream_id: 'stream-456',
m3u_account: { id: 1 }
};
const url = getEpisodeStreamUrl(episode, provider, 'prod');
expect(url).toBe('https://example.com/proxy/vod/episode/episode-123?stream_id=stream-456');
});
it('should generate stream URL with m3u_account_id when no stream_id', () => {
const episode = { uuid: 'episode-123' };
const provider = {
m3u_account: { id: 1 }
};
const url = getEpisodeStreamUrl(episode, provider, 'prod');
expect(url).toBe('https://example.com/proxy/vod/episode/episode-123?m3u_account_id=1');
});
it('should use dev port in dev mode', () => {
const episode = { uuid: 'episode-123' };
const url = getEpisodeStreamUrl(episode, null, 'dev');
expect(url).toBe('https://example.com:5656/proxy/vod/episode/episode-123');
});
it('should encode special characters in stream_id', () => {
const episode = { uuid: 'episode-123' };
const provider = {
stream_id: 'stream with spaces',
m3u_account: { id: 1 }
};
const url = getEpisodeStreamUrl(episode, provider, 'prod');
expect(url).toContain('stream%20with%20spaces');
});
});
describe('getYouTubeEmbedUrl', () => {
it('should convert youtube.com watch URL to embed URL', () => {
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
expect(getYouTubeEmbedUrl(url)).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ');
});
it('should convert youtu.be URL to embed URL', () => {
const url = 'https://youtu.be/dQw4w9WgXcQ';
expect(getYouTubeEmbedUrl(url)).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ');
});
it('should handle bare video ID', () => {
const videoId = 'dQw4w9WgXcQ';
expect(getYouTubeEmbedUrl(videoId)).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ');
});
it('should return empty string for null', () => {
expect(getYouTubeEmbedUrl(null)).toBe('');
});
it('should return empty string for empty string', () => {
expect(getYouTubeEmbedUrl('')).toBe('');
});
});
describe('getEpisodeAirdate', () => {
it('should format valid air date', () => {
const episode = { air_date: '2024-01-15' };
const formatted = getEpisodeAirdate(episode);
expect(formatted).toMatch(/1\/1[4|5]\/2024/);
});
it('should return N/A for missing air date', () => {
const episode = {};
expect(getEpisodeAirdate(episode)).toBe('N/A');
});
it('should return N/A for null air date', () => {
const episode = { air_date: null };
expect(getEpisodeAirdate(episode)).toBe('N/A');
});
});
describe('getTmdbUrlLink', () => {
const displaySeries = { tmdb_id: '12345' };
it('should return TV show URL without episode details', () => {
const episode = {};
const url = getTmdbUrlLink(displaySeries, episode);
expect(url).toBe('https://www.themoviedb.org/tv/12345');
});
it('should return TV show URL with season and episode', () => {
const episode = { season_number: 2, episode_number: 5 };
const url = getTmdbUrlLink(displaySeries, episode);
expect(url).toBe('https://www.themoviedb.org/tv/12345/season/2/episode/5');
});
it('should not append episode details if only season is present', () => {
const episode = { season_number: 2 };
const url = getTmdbUrlLink(displaySeries, episode);
expect(url).toBe('https://www.themoviedb.org/tv/12345');
});
it('should not append episode details if only episode is present', () => {
const episode = { episode_number: 5 };
const url = getTmdbUrlLink(displaySeries, episode);
expect(url).toBe('https://www.themoviedb.org/tv/12345');
});
});
});

View file

@ -0,0 +1,344 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
getTechnicalDetails,
getMovieStreamUrl,
formatVideoDetails,
formatAudioDetails,
} from '../VODModalUtils';
describe('VODModalUtils', () => {
describe('getTechnicalDetails', () => {
const defaultVOD = {
bitrate: '5000 kbps',
video: 'H.264',
audio: 'AAC',
};
it('should return defaultVOD details when no provider selected', () => {
const result = getTechnicalDetails(null, defaultVOD);
expect(result).toEqual({
bitrate: '5000 kbps',
video: 'H.264',
audio: 'AAC',
});
});
it('should extract details from movie content', () => {
const provider = {
movie: {
bitrate: '8000 kbps',
video: 'H.265',
audio: 'AC3',
},
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result).toEqual({
bitrate: '8000 kbps',
video: 'H.265',
audio: 'AC3',
});
});
it('should extract details from episode content', () => {
const provider = {
episode: {
bitrate: '6000 kbps',
video: 'AVC',
audio: 'DTS',
},
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result).toEqual({
bitrate: '6000 kbps',
video: 'AVC',
audio: 'DTS',
});
});
it('should extract details from provider object directly', () => {
const provider = {
bitrate: '7000 kbps',
video: 'VP9',
audio: 'Opus',
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result).toEqual({
bitrate: '7000 kbps',
video: 'VP9',
audio: 'Opus',
});
});
it('should extract details from custom_properties.detailed_info', () => {
const provider = {
custom_properties: {
detailed_info: {
bitrate: '9000 kbps',
video: 'AV1',
audio: 'EAC3',
},
},
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result).toEqual({
bitrate: '9000 kbps',
video: 'AV1',
audio: 'EAC3',
});
});
it('should fallback to defaultVOD when provider has no valid details', () => {
const provider = {
custom_properties: {},
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result).toEqual({
bitrate: '5000 kbps',
video: 'H.264',
audio: 'AAC',
});
});
it('should prioritize movie content over provider object', () => {
const provider = {
movie: {
bitrate: '8000 kbps',
},
bitrate: '7000 kbps',
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result.bitrate).toBe('8000 kbps');
});
it('should handle undefined defaultVOD', () => {
const result = getTechnicalDetails(null, undefined);
expect(result).toEqual({
bitrate: undefined,
video: undefined,
audio: undefined,
});
});
});
describe('getMovieStreamUrl', () => {
const vod = { uuid: 'test-uuid-123' };
const originalLocation = window.location;
beforeEach(() => {
delete window.location;
window.location = {
protocol: 'https:',
hostname: 'example.com',
origin: 'https://example.com',
};
});
afterEach(() => {
window.location = originalLocation;
});
it('should generate basic stream URL without provider', () => {
const result = getMovieStreamUrl(vod, null, 'production');
expect(result).toBe('https://example.com/proxy/vod/movie/test-uuid-123');
});
it('should include stream_id when available', () => {
const provider = {
stream_id: 'stream-123',
m3u_account: { id: 'account-456' },
};
const result = getMovieStreamUrl(vod, provider, 'production');
expect(result).toBe(
'https://example.com/proxy/vod/movie/test-uuid-123?stream_id=stream-123'
);
});
it('should include m3u_account_id when stream_id not available', () => {
const provider = {
m3u_account: { id: 'account-456' },
};
const result = getMovieStreamUrl(vod, provider, 'production');
expect(result).toBe(
'https://example.com/proxy/vod/movie/test-uuid-123?m3u_account_id=account-456'
);
});
it('should use dev port in dev mode', () => {
const result = getMovieStreamUrl(vod, null, 'dev');
expect(result).toBe(
'https://example.com:5656/proxy/vod/movie/test-uuid-123'
);
});
it('should encode stream_id', () => {
const provider = {
stream_id: 'stream with spaces',
};
const result = getMovieStreamUrl(vod, provider, 'production');
expect(result).toContain('stream_id=stream%20with%20spaces');
});
});
describe('formatVideoDetails', () => {
it('should format complete video details', () => {
const video = {
codec_name: 'h264',
codec_long_name: 'H.264 / AVC / MPEG-4 AVC',
profile: 'High',
width: 1920,
height: 1080,
display_aspect_ratio: '16:9',
bit_rate: '8000000',
r_frame_rate: '23.98',
tags: { encoder: 'x264' },
};
const result = formatVideoDetails(video);
expect(result).toBe(
'H.264 / AVC / MPEG-4 AVC, (High), 1920x1080, Aspect Ratio: ' +
'16:9, Bitrate: 8000 kbps, Frame Rate: 23.98 fps, Encoder: x264'
);
});
it('should use codec_name when codec_long_name is unknown', () => {
const video = {
codec_name: 'h264',
codec_long_name: 'unknown',
};
const result = formatVideoDetails(video);
expect(result).toBe('h264');
});
it('should use codec_name when codec_long_name is not available', () => {
const video = {
codec_name: 'h265',
};
const result = formatVideoDetails(video);
expect(result).toBe('h265');
});
it('should handle minimal video details', () => {
const video = {
codec_name: 'vp9',
};
const result = formatVideoDetails(video);
expect(result).toBe('vp9');
});
it('should format bitrate correctly', () => {
const video = {
codec_name: 'h264',
bit_rate: '5500000',
};
const result = formatVideoDetails(video);
expect(result).toContain('Bitrate: 5500 kbps');
});
});
describe('formatAudioDetails', () => {
it('should format complete audio details', () => {
const audio = {
codec_name: 'aac',
codec_long_name: 'AAC (Advanced Audio Coding)',
profile: 'LC',
channel_layout: '5.1',
sample_rate: '48000',
bit_rate: '256000',
tags: { handler_name: 'SoundHandler' },
};
const result = formatAudioDetails(audio);
expect(result).toBe(
'AAC (Advanced Audio Coding), (LC), Channels: 5.1, ' +
'Sample Rate: 48000 Hz, Bitrate: 256 kbps, Handler: SoundHandler'
);
});
it('should use codec_name when codec_long_name is unknown', () => {
const audio = {
codec_name: 'ac3',
codec_long_name: 'unknown',
};
const result = formatAudioDetails(audio);
expect(result).toBe('ac3');
});
it('should use channels count when channel_layout not available', () => {
const audio = {
codec_name: 'aac',
channels: '2',
};
const result = formatAudioDetails(audio);
expect(result).toBe('aac, Channels: 2');
});
it('should handle minimal audio details', () => {
const audio = {
codec_name: 'opus',
};
const result = formatAudioDetails(audio);
expect(result).toBe('opus');
});
it('should format bitrate correctly', () => {
const audio = {
codec_name: 'aac',
bit_rate: '192000',
};
const result = formatAudioDetails(audio);
expect(result).toContain('Bitrate: 192 kbps');
});
it('should prefer channel_layout over channels', () => {
const audio = {
codec_name: 'dts',
channel_layout: '7.1',
channels: '8',
};
const result = formatAudioDetails(audio);
expect(result).toContain('Channels: 7.1');
});
});
});