mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-23 18:18:18 +00:00
Added tests
This commit is contained in:
parent
dc51ab4dd1
commit
a9402207fb
17 changed files with 6242 additions and 0 deletions
275
frontend/src/components/__tests__/ConfirmationDialog.test.jsx
Normal file
275
frontend/src/components/__tests__/ConfirmationDialog.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
99
frontend/src/components/__tests__/ErrorBoundary.test.jsx
Normal file
99
frontend/src/components/__tests__/ErrorBoundary.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
388
frontend/src/components/__tests__/Field.test.jsx
Normal file
388
frontend/src/components/__tests__/Field.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
454
frontend/src/components/__tests__/FloatingVideo.test.jsx
Normal file
454
frontend/src/components/__tests__/FloatingVideo.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
335
frontend/src/components/__tests__/GuideRow.test.jsx
Normal file
335
frontend/src/components/__tests__/GuideRow.test.jsx
Normal 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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
318
frontend/src/components/__tests__/HourTimeline.test.jsx
Normal file
318
frontend/src/components/__tests__/HourTimeline.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
398
frontend/src/components/__tests__/LazyLogo.test.jsx
Normal file
398
frontend/src/components/__tests__/LazyLogo.test.jsx
Normal 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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
169
frontend/src/components/__tests__/RecordingSynopsis.test.jsx
Normal file
169
frontend/src/components/__tests__/RecordingSynopsis.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
864
frontend/src/components/__tests__/SeriesModal.test.jsx
Normal file
864
frontend/src/components/__tests__/SeriesModal.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
455
frontend/src/components/__tests__/Sidebar.test.jsx
Normal file
455
frontend/src/components/__tests__/Sidebar.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
251
frontend/src/components/__tests__/SystemEvents.test.jsx
Normal file
251
frontend/src/components/__tests__/SystemEvents.test.jsx
Normal 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
|
||||
});
|
||||
});
|
||||
439
frontend/src/components/__tests__/VODModal.test.jsx
Normal file
439
frontend/src/components/__tests__/VODModal.test.jsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
397
frontend/src/utils/components/__tests__/SeriesModalUtils.test.js
Normal file
397
frontend/src/utils/components/__tests__/SeriesModalUtils.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
344
frontend/src/utils/components/__tests__/VODModalUtils.test.js
Normal file
344
frontend/src/utils/components/__tests__/VODModalUtils.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue