0}
+ selectedProvider={selectedProvider}
+ onClickYouTubeTrailer={onClickYouTubeTrailer}
+ />
{/* Provider Information & Play Button Row */}
@@ -542,12 +452,7 @@ const VODModal = ({ vod, opened, onClose }) => {
label: formatStreamLabel(provider),
}))}
value={selectedProvider?.id?.toString() || ''}
- onChange={(value) => {
- const provider = providers.find(
- (p) => p.id.toString() === value
- );
- setSelectedProvider(provider);
- }}
+ onChange={(value) => onChangeSelectedProvider(value)}
placeholder="Select stream..."
style={{ minWidth: 250 }}
disabled={loadingProviders}
@@ -576,135 +481,21 @@ const VODModal = ({ vod, opened, onClose }) => {
{/* Technical Details */}
- {(() => {
- const techDetails = getTechnicalDetails(
- selectedProvider,
- displayVOD
- );
- const hasDetails =
- techDetails.bitrate || techDetails.video || techDetails.audio;
-
- return (
- hasDetails && (
-
-
- Technical Details:
- {selectedProvider && (
-
- (from {selectedProvider.m3u_account.name}
- {selectedProvider.stream_id &&
- ` - Stream ${selectedProvider.stream_id}`}
- )
-
- )}
-
- {techDetails.bitrate && techDetails.bitrate > 0 && (
-
- Bitrate: {techDetails.bitrate} kbps
-
- )}
- {techDetails.video &&
- Object.keys(techDetails.video).length > 0 && (
-
- Video:{' '}
- {techDetails.video.codec_long_name &&
- techDetails.video.codec_long_name !== 'unknown'
- ? techDetails.video.codec_long_name
- : techDetails.video.codec_name}
- {techDetails.video.profile
- ? ` (${techDetails.video.profile})`
- : ''}
- {techDetails.video.width && techDetails.video.height
- ? `, ${techDetails.video.width}x${techDetails.video.height}`
- : ''}
- {techDetails.video.display_aspect_ratio
- ? `, Aspect Ratio: ${techDetails.video.display_aspect_ratio}`
- : ''}
- {techDetails.video.bit_rate
- ? `, Bitrate: ${Math.round(Number(techDetails.video.bit_rate) / 1000)} kbps`
- : ''}
- {techDetails.video.r_frame_rate
- ? `, Frame Rate: ${techDetails.video.r_frame_rate.replace('/', '/')} fps`
- : ''}
- {techDetails.video.tags?.encoder
- ? `, Encoder: ${techDetails.video.tags.encoder}`
- : ''}
-
- )}
- {techDetails.audio &&
- Object.keys(techDetails.audio).length > 0 && (
-
- Audio:{' '}
- {techDetails.audio.codec_long_name &&
- techDetails.audio.codec_long_name !== 'unknown'
- ? techDetails.audio.codec_long_name
- : techDetails.audio.codec_name}
- {techDetails.audio.profile
- ? ` (${techDetails.audio.profile})`
- : ''}
- {techDetails.audio.channel_layout
- ? `, Channels: ${techDetails.audio.channel_layout}`
- : techDetails.audio.channels
- ? `, Channels: ${techDetails.audio.channels}`
- : ''}
- {techDetails.audio.sample_rate
- ? `, Sample Rate: ${techDetails.audio.sample_rate} Hz`
- : ''}
- {techDetails.audio.bit_rate
- ? `, Bitrate: ${Math.round(Number(techDetails.audio.bit_rate) / 1000)} kbps`
- : ''}
- {techDetails.audio.tags?.handler_name
- ? `, Handler: ${techDetails.audio.tags.handler_name}`
- : ''}
-
- )}
-
- )
- );
- })()}
- {/* YouTube trailer if available */}
+
+
{/* YouTube Trailer Modal */}
- setTrailerModalOpened(false)}
- title="Trailer"
- size="xl"
- centered
- withCloseButton
- >
-
- {trailerUrl && (
-
- )}
-
-
+ trailerUrl={trailerUrl}
+ />
>
);
};
diff --git a/frontend/src/components/__tests__/ConfirmationDialog.test.jsx b/frontend/src/components/__tests__/ConfirmationDialog.test.jsx
new file mode 100644
index 00000000..741f2f16
--- /dev/null
+++ b/frontend/src/components/__tests__/ConfirmationDialog.test.jsx
@@ -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 ? (
+
+ ) : null,
+ Group: ({ children }) => {children}
,
+ Button: ({ children, onClick, disabled }) => (
+
+ ),
+ Checkbox: ({ label, checked, onChange }) => (
+
+ ),
+ Box: ({ children }) => {children}
,
+ };
+});
+
+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(
+
+ );
+
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument();
+ });
+
+ it('should not render when closed', () => {
+ render(
+
+ );
+
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('should display custom title and message', () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ fireEvent.click(screen.getByText('Delete'));
+ expect(mockOnConfirm).toHaveBeenCalledTimes(1);
+ });
+
+ it('should call onClose when cancel button is clicked', () => {
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('should show suppress checkbox when actionKey is provided', () => {
+ render(
+
+ );
+
+ expect(screen.getByLabelText("Don't ask me again")).toBeInTheDocument();
+ });
+
+ it('should not show suppress checkbox when actionKey is not provided', () => {
+ render(
+
+ );
+
+ expect(screen.queryByLabelText("Don't ask me again")).not.toBeInTheDocument();
+ });
+
+ it('should call suppressWarning when suppress is checked and confirmed', () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ fireEvent.click(screen.getByLabelText("Don't ask me again"));
+ expect(mockOnSuppressChange).toHaveBeenCalledWith(true);
+ });
+
+ it('should show delete file option when enabled', () => {
+ render(
+
+ );
+
+ expect(screen.getByLabelText('Also delete files from disk')).toBeInTheDocument();
+ });
+
+ it('should pass deleteFiles state to onConfirm when delete option is checked', () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ fireEvent.click(screen.getByLabelText('Also delete files from disk'));
+ fireEvent.click(screen.getByText('Confirm'));
+
+ rerender(
+
+ );
+
+ expect(screen.getByLabelText('Also delete files from disk')).not.toBeChecked();
+ });
+
+ it('should show loading state on confirm button', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Confirm')).toBeDisabled();
+ });
+
+ it('should disable cancel button when loading', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Cancel')).toBeDisabled();
+ });
+
+ it('should initialize suppress checkbox based on store state', () => {
+ mockIsWarningSuppressed.mockReturnValue(true);
+
+ render(
+
+ );
+
+ expect(screen.getByLabelText("Don't ask me again")).toBeChecked();
+ });
+});
diff --git a/frontend/src/components/__tests__/ErrorBoundary.test.jsx b/frontend/src/components/__tests__/ErrorBoundary.test.jsx
new file mode 100644
index 00000000..76c51110
--- /dev/null
+++ b/frontend/src/components/__tests__/ErrorBoundary.test.jsx
@@ -0,0 +1,101 @@
+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 Child component
;
+};
+
+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(
+
+ Test content
+
+ );
+
+ expect(screen.getByText('Test content')).toBeInTheDocument();
+ });
+
+ it('should render error message when child component throws error', () => {
+ render(
+
+
+
+ );
+
+ 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(
+
+
+
+ );
+
+ expect(screen.getByText('Child component')).toBeInTheDocument();
+ expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
+ });
+
+ it('should handle multiple children', () => {
+ render(
+
+ First child
+ Second child
+
+ );
+
+ expect(screen.getByText('First child')).toBeInTheDocument();
+ expect(screen.getByText('Second child')).toBeInTheDocument();
+ });
+
+ it('should catch errors from nested children', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
+ });
+
+ it('should have hasError state set to true after catching error', () => {
+ const { container } = render(
+
+
+
+ );
+
+ // 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(
+
+ Normal content
+
+ );
+
+ // Verify children are rendered (not error state)
+ expect(screen.getByTestId('child')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/__tests__/Field.test.jsx b/frontend/src/components/__tests__/Field.test.jsx
new file mode 100644
index 00000000..092871f1
--- /dev/null
+++ b/frontend/src/components/__tests__/Field.test.jsx
@@ -0,0 +1,802 @@
+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, type, placeholder }) => (
+
+
+
+ {description &&
{description}
}
+
+ ),
+ NumberInput: ({ label, description, value, onChange, placeholder }) => (
+
+
+
onChange(Number(e.target.value))}
+ placeholder={placeholder}
+ aria-describedby={description}
+ />
+ {description &&
{description}
}
+
+ ),
+ Textarea: ({ label, description, value, onChange, placeholder }) => (
+
+
+
+ {description &&
{description}
}
+
+ ),
+ Switch: ({ label, description, checked, onChange }) => (
+
+
+
+ {description &&
{description}
}
+
+ ),
+ Select: ({ label, description, value, data, onChange, placeholder }) => (
+
+
+
+ {description &&
{description}
}
+
+ ),
+ Text: ({ children, fw, size, c }) => (
+
+ {children}
+
+ ),
+ };
+});
+
+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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ expect(screen.getByLabelText('Active')).toBeChecked();
+ });
+
+ it('should be unchecked when value is false', () => {
+ const field = {
+ id: 'active',
+ type: 'boolean',
+ label: 'Active',
+ default: false,
+ };
+
+ render();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ expect(screen.getByLabelText('Status')).toHaveValue('1');
+ });
+
+ it('should handle empty options array', () => {
+ const field = {
+ id: 'country',
+ type: 'select',
+ label: 'Country',
+ default: '',
+ options: null,
+ };
+
+ render();
+
+ 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();
+
+ fireEvent.change(screen.getByLabelText('Country'), {
+ target: { value: 'ca' },
+ });
+
+ expect(mockOnChange).toHaveBeenCalledWith('country', 'ca');
+ });
+ });
+
+ describe('Textarea (text type)', () => {
+ it('should render Textarea for text type', () => {
+ const field = {
+ id: 'bio',
+ type: 'text',
+ label: 'Biography',
+ help_text: 'Enter your biography',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Biography')).toBeInTheDocument();
+ expect(screen.getByText('Enter your biography')).toBeInTheDocument();
+ });
+
+ it('should use provided value', () => {
+ const field = {
+ id: 'bio',
+ type: 'text',
+ label: 'Biography',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Biography')).toHaveValue('My bio');
+ });
+
+ it('should use default value when value is null', () => {
+ const field = {
+ id: 'bio',
+ type: 'text',
+ label: 'Biography',
+ default: 'Default bio',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Biography')).toHaveValue('Default bio');
+ });
+
+ it('should call onChange with field id and value', () => {
+ const field = {
+ id: 'bio',
+ type: 'text',
+ label: 'Biography',
+ default: '',
+ };
+
+ render();
+
+ fireEvent.change(screen.getByLabelText('Biography'), {
+ target: { value: 'New bio text' },
+ });
+
+ expect(mockOnChange).toHaveBeenCalledWith('bio', 'New bio text');
+ });
+
+ it('should render with placeholder', () => {
+ const field = {
+ id: 'bio',
+ type: 'text',
+ label: 'Biography',
+ placeholder: 'Enter your bio here...',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Biography')).toHaveAttribute(
+ 'placeholder',
+ 'Enter your bio here...'
+ );
+ });
+ });
+
+ describe('Info type', () => {
+ it('should render info with label and description', () => {
+ const field = {
+ id: 'info1',
+ type: 'info',
+ label: 'Important Information',
+ description: 'This is important info',
+ };
+
+ render();
+
+ expect(screen.getByText('Important Information')).toBeInTheDocument();
+ expect(screen.getByText('This is important info')).toBeInTheDocument();
+ });
+
+ it('should render info with only description', () => {
+ const field = {
+ id: 'info2',
+ type: 'info',
+ description: 'Just a description',
+ };
+
+ render();
+
+ expect(screen.getByText('Just a description')).toBeInTheDocument();
+ });
+
+ it('should render info with only label', () => {
+ const field = {
+ id: 'info3',
+ type: 'info',
+ label: 'Just a label',
+ };
+
+ render();
+
+ expect(screen.getByText('Just a label')).toBeInTheDocument();
+ });
+
+ it('should prioritize help_text over description', () => {
+ const field = {
+ id: 'info4',
+ type: 'info',
+ label: 'Title',
+ help_text: 'Help text takes priority',
+ description: 'This should not appear',
+ };
+
+ render();
+
+ expect(screen.getByText('Help text takes priority')).toBeInTheDocument();
+ expect(screen.queryByText('This should not appear')).not.toBeInTheDocument();
+ });
+
+ it('should use field.value if no help_text or description', () => {
+ const field = {
+ id: 'info5',
+ type: 'info',
+ label: 'Title',
+ value: 'Value text',
+ };
+
+ render();
+
+ expect(screen.getByText('Value text')).toBeInTheDocument();
+ });
+
+ it('should not call onChange for info type', () => {
+ const field = {
+ id: 'info6',
+ type: 'info',
+ label: 'Read-only Info',
+ description: 'Cannot be changed',
+ };
+
+ render();
+
+ expect(mockOnChange).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Password input type', () => {
+ it('should render password input when input_type is password', () => {
+ const field = {
+ id: 'password',
+ type: 'string',
+ label: 'Password',
+ input_type: 'password',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password');
+ });
+
+ it('should render text input when input_type is not password', () => {
+ const field = {
+ id: 'username',
+ type: 'string',
+ label: 'Username',
+ input_type: 'text',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Username')).toHaveAttribute('type', 'text');
+ });
+
+ it('should default to text input when input_type is undefined', () => {
+ const field = {
+ id: 'email',
+ type: 'string',
+ label: 'Email',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Email')).toHaveAttribute('type', 'text');
+ });
+ });
+
+ describe('Description priority', () => {
+ it('should prioritize help_text over description', () => {
+ const field = {
+ id: 'test',
+ type: 'string',
+ label: 'Test',
+ help_text: 'Help text',
+ description: 'Description text',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByText('Help text')).toBeInTheDocument();
+ expect(screen.queryByText('Description text')).not.toBeInTheDocument();
+ });
+
+ it('should use description when help_text is not provided', () => {
+ const field = {
+ id: 'test',
+ type: 'string',
+ label: 'Test',
+ description: 'Description text',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByText('Description text')).toBeInTheDocument();
+ });
+
+ it('should use field.value when neither help_text nor description provided', () => {
+ const field = {
+ id: 'test',
+ type: 'string',
+ label: 'Test',
+ value: 'Value text',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByText('Value text')).toBeInTheDocument();
+ });
+
+ it('should not show description when all are undefined', () => {
+ const field = {
+ id: 'test',
+ type: 'string',
+ label: 'Test',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Test')).toBeInTheDocument();
+ // No description should be present
+ });
+ });
+
+ describe('Placeholder handling', () => {
+ it('should render placeholder for TextInput', () => {
+ const field = {
+ id: 'name',
+ type: 'string',
+ label: 'Name',
+ placeholder: 'Enter your name',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Name')).toHaveAttribute(
+ 'placeholder',
+ 'Enter your name'
+ );
+ });
+
+ it('should render placeholder for NumberInput', () => {
+ const field = {
+ id: 'age',
+ type: 'number',
+ label: 'Age',
+ placeholder: 'Enter your age',
+ default: 0,
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Age')).toHaveAttribute(
+ 'placeholder',
+ 'Enter your age'
+ );
+ });
+
+ it('should render placeholder for Select', () => {
+ const field = {
+ id: 'country',
+ type: 'select',
+ label: 'Country',
+ placeholder: 'Select a country',
+ default: '',
+ options: [
+ { value: 'us', label: 'United States' },
+ { value: 'ca', label: 'Canada' },
+ ],
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Country')).toHaveAttribute(
+ 'placeholder',
+ 'Select a country'
+ );
+ });
+
+ it('should render placeholder for Textarea', () => {
+ const field = {
+ id: 'bio',
+ type: 'text',
+ label: 'Biography',
+ placeholder: 'Tell us about yourself',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Biography')).toHaveAttribute(
+ 'placeholder',
+ 'Tell us about yourself'
+ );
+ });
+ });
+
+ describe('Edge cases', () => {
+ it('should handle empty string default value', () => {
+ const field = {
+ id: 'test',
+ type: 'string',
+ label: 'Test',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Test')).toHaveValue('');
+ });
+
+ it('should handle 0 as valid number value', () => {
+ const field = {
+ id: 'count',
+ type: 'number',
+ label: 'Count',
+ default: 10,
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Count')).toHaveValue(0);
+ });
+
+ it('should handle false as valid boolean value', () => {
+ const field = {
+ id: 'enabled',
+ type: 'boolean',
+ label: 'Enabled',
+ default: true,
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Enabled')).not.toBeChecked();
+ });
+
+ it('should handle empty string as valid select value', () => {
+ const field = {
+ id: 'status',
+ type: 'select',
+ label: 'Status',
+ default: 'active',
+ options: [
+ { value: '', label: 'None' },
+ { value: 'active', label: 'Active' },
+ ],
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Status')).toHaveValue('');
+ });
+ });
+
+ describe('Default fallback', () => {
+ it('should render TextInput for unknown type', () => {
+ const field = {
+ id: 'custom',
+ type: 'unknown',
+ label: 'Custom Field',
+ default: '',
+ };
+
+ render();
+
+ expect(screen.getByLabelText('Custom Field')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/FloatingVideo.test.jsx b/frontend/src/components/__tests__/FloatingVideo.test.jsx
new file mode 100644
index 00000000..63f3ee3d
--- /dev/null
+++ b/frontend/src/components/__tests__/FloatingVideo.test.jsx
@@ -0,0 +1,477 @@
+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 }) => {children}
,
+}));
+
+// Mock Mantine components
+vi.mock('@mantine/core', async () => {
+ return {
+ CloseButton: ({ onClick, onTouchEnd }) => (
+
+ ),
+ Flex: ({ children, ...props }) => {children}
,
+ Box: ({ children, ...props }) => {children}
,
+ Loader: () => Loading...
,
+ Text: ({ children, ...props }) => {children}
,
+ };
+});
+
+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();
+ 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();
+ 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();
+ 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();
+
+ expect(mpegts.createPlayer).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'mpegts',
+ url: 'http://example.com/stream.ts',
+ isLive: true,
+ })
+ );
+ });
+
+ it('should show loading state initially', () => {
+ render();
+ expect(screen.getByTestId('loader')).toBeInTheDocument();
+ expect(screen.getByText('Loading stream...')).toBeInTheDocument();
+ });
+
+ it('should attach player to video element', () => {
+ render();
+ expect(mockPlayer.attachMediaElement).toHaveBeenCalled();
+ });
+
+ it('should handle player errors', async () => {
+ render();
+
+ 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();
+
+ expect(
+ screen.getByText(/browser doesn't support live video streaming/i)
+ ).toBeInTheDocument();
+ });
+
+ it('should play video on MEDIA_INFO event', async () => {
+ render();
+
+ 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();
+ expect(mpegts.createPlayer).not.toHaveBeenCalled();
+ });
+
+ it('should set video source for VOD', () => {
+ const { container } = render();
+ 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();
+ const video = container.querySelector('video');
+
+ // Simulate video loaded event to clear loading state
+ fireEvent.loadedData(video);
+
+ expect(screen.getAllByText('Test Movie').length).toBeGreaterThanOrEqual(
+ 1
+ );
+ expect(screen.getByText('2024')).toBeInTheDocument();
+ });
+
+ it('should hide overlay after 4 seconds', () => {
+ vi.useFakeTimers();
+
+ const { container } = render();
+ const video = container.querySelector('video');
+
+ fireEvent.loadedData(video);
+ fireEvent.canPlay(video);
+
+ expect(screen.getAllByText('Test Movie').length).toBeGreaterThanOrEqual(
+ 1
+ );
+
+ vi.advanceTimersByTime(4000);
+
+ waitFor(() => {
+ // After overlay hides, only the header title remains
+ expect(screen.getAllByText('Test Movie').length).toBe(1);
+ });
+
+ vi.useRealTimers();
+ });
+
+ it('should show overlay on mouse enter', () => {
+ const { container } = render();
+ const video = container.querySelector('video');
+
+ fireEvent.loadedData(video);
+ fireEvent.canPlay(video);
+
+ const videoContainer = video.parentElement;
+
+ fireEvent.mouseEnter(videoContainer);
+
+ expect(screen.getAllByText('Test Movie').length).toBeGreaterThanOrEqual(
+ 1
+ );
+ });
+
+ it('should hide overlay on mouse leave', () => {
+ vi.useFakeTimers();
+
+ const { container } = render();
+ const video = container.querySelector('video');
+
+ fireEvent.loadedData(video);
+ fireEvent.canPlay(video);
+
+ const videoContainer = video.parentElement;
+
+ fireEvent.mouseEnter(videoContainer);
+ fireEvent.mouseLeave(videoContainer);
+
+ vi.advanceTimersByTime(4000);
+
+ waitFor(() => {
+ // After overlay hides, only the header title remains
+ expect(screen.getAllByText('Test Movie').length).toBe(1);
+ });
+
+ 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();
+
+ 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();
+ 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();
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+ const handles = container.querySelectorAll(
+ '[class*="floating-video-no-drag"]'
+ );
+
+ // Should have 4 resize handles plus video element
+ expect(handles.length).toBeGreaterThanOrEqual(4);
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/GuideRow.test.jsx b/frontend/src/components/__tests__/GuideRow.test.jsx
new file mode 100644
index 00000000..8dc18eaf
--- /dev/null
+++ b/frontend/src/components/__tests__/GuideRow.test.jsx
@@ -0,0 +1,715 @@
+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) => ,
+}));
+
+// Mock Mantine components
+vi.mock('@mantine/core', async () => {
+ return {
+ Box: ({ children, ...props }) => {children}
,
+ Flex: ({ children, ...props }) => {children}
,
+ Text: ({ children, ...props }) => {children}
,
+ };
+});
+
+// Helper function to create programs at specific times
+const createProgramAtTime = (id, startHour, durationMinutes) => {
+ const timelineStart = new Date('2024-01-01T00:00:00Z');
+ const startMs = timelineStart.getTime() + startHour * 60 * 60 * 1000;
+ return {
+ id,
+ title: `Program ${id}`,
+ startMs,
+ endMs: startMs + durationMinutes * 60 * 1000,
+ };
+};
+
+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) => (
+
+ {program.title}
+
+ )),
+ handleLogoClick: vi.fn(),
+ contentWidth: 1920,
+ guideScrollLeftRef: { current: 0 },
+ viewportWidth: 1920,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ const mockStyle = {
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Rendering', () => {
+ it('should render channel row with channel information', () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ 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();
+
+ 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();
+
+ expect(screen.getByText('-')).toBeInTheDocument();
+ });
+ });
+
+ describe('Row Height Calculation', () => {
+ it('should use default PROGRAM_HEIGHT when no expanded program', () => {
+ render(
+
+ );
+
+ 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();
+
+ 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();
+
+ const row = screen.getByTestId('guide-row');
+ expect(row).toHaveStyle({ height: `${customHeight}px` });
+ });
+ });
+
+ describe('Programs Rendering', () => {
+ it('should render programs when channel has programs', () => {
+ // Create program at hour 0 (definitely within viewport at scrollLeft 0)
+ const visibleProgram = createProgramAtTime('program-1', 0, 60);
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, [visibleProgram]]]),
+ };
+
+ render();
+
+ expect(screen.getByTestId('program-program-1')).toBeInTheDocument();
+ expect(mockData.renderProgram).toHaveBeenCalledWith(visibleProgram, undefined, mockChannel);
+ });
+
+ it('should render multiple programs', () => {
+ const programs = [
+ createProgramAtTime('prog-1', 0, 60),
+ createProgramAtTime('prog-2', 1, 30),
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ };
+
+ render();
+
+ expect(screen.getByTestId('program-prog-1')).toBeInTheDocument();
+ expect(screen.getByTestId('program-prog-2')).toBeInTheDocument();
+ });
+
+ it('should render placeholder when channel has no programs', () => {
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, []]]),
+ };
+
+ render();
+
+ const placeholders = screen.getAllByText('No program data');
+ expect(placeholders.length).toBeGreaterThan(0);
+ });
+
+ it('should render placeholder when programsByChannelId does not contain channel', () => {
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map(),
+ };
+
+ render();
+
+ const placeholders = screen.getAllByText('No program data');
+ expect(placeholders.length).toBeGreaterThan(0);
+ });
+
+ it('should position placeholder programs correctly', () => {
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, []]]),
+ };
+
+ const { container } = render(
+
+ );
+
+ 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(
+
+ );
+
+ 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', () => {
+ render();
+
+ const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
+ fireEvent.mouseEnter(logo);
+
+ expect(screen.getByTestId('play-icon')).toBeInTheDocument();
+ });
+
+ it('should not show play icon when not hovering', () => {
+ render(
+
+ );
+
+ expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Layout and Styling', () => {
+ it('should set correct channel logo width', () => {
+ const { container } = render(
+
+ );
+
+ 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();
+
+ 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(
+
+ );
+
+ const imageContainer = container.querySelector('img').parentElement;
+ expect(imageContainer).toHaveAttribute('h', `${customHeight - 32}px`);
+ });
+ });
+
+ describe('Horizontal Viewport Culling', () => {
+ it('should only render programs visible in viewport', () => {
+ const programs = [
+ createProgramAtTime('prog-1', 0, 60), // Hour 0
+ createProgramAtTime('prog-2', 6, 60), // Hour 6
+ createProgramAtTime('prog-3', 12, 60), // Hour 12
+ createProgramAtTime('prog-4', 18, 60), // Hour 18
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 6 }, // Scroll to hour 6
+ viewportWidth: HOUR_WIDTH * 4, // Show 4 hours
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ // Program at hour 6 should be visible
+ expect(screen.getByTestId('program-prog-2')).toBeInTheDocument();
+
+ // Programs outside viewport + buffer should not be rendered
+ expect(mockData.renderProgram).toHaveBeenCalledTimes(1);
+ });
+
+ it('should render programs within buffer zone', () => {
+ const programs = [
+ createProgramAtTime('prog-1', 0, 60),
+ createProgramAtTime('prog-2', 1, 60),
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 2 },
+ viewportWidth: HOUR_WIDTH * 2,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ // Programs within H_BUFFER (600px) should be rendered
+ const renderedPrograms = mockData.renderProgram.mock.calls.map(
+ call => call[0]
+ );
+
+ expect(renderedPrograms.length).toBeGreaterThan(0);
+ });
+
+ it('should not render programs completely outside viewport and buffer', () => {
+ const programs = [
+ createProgramAtTime('prog-far-left', 0, 60),
+ createProgramAtTime('prog-visible', 10, 60),
+ createProgramAtTime('prog-far-right', 22, 60),
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 10 },
+ viewportWidth: HOUR_WIDTH * 2,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ const renderedPrograms = mockData.renderProgram.mock.calls.map(
+ call => call[0].id
+ );
+
+ // Only visible program should be rendered
+ expect(renderedPrograms).toContain('prog-visible');
+ expect(renderedPrograms).not.toContain('prog-far-left');
+ expect(renderedPrograms).not.toContain('prog-far-right');
+ });
+
+ it('should handle edge case where program spans viewport boundary', () => {
+ const programs = [
+ createProgramAtTime('prog-spanning', 5, 180), // 3-hour program
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 6 },
+ viewportWidth: HOUR_WIDTH * 2,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ // Program spanning viewport should be visible
+ expect(screen.getByTestId('program-prog-spanning')).toBeInTheDocument();
+ });
+
+ it('should update visible programs when scroll position changes', () => {
+ const programs = [
+ createProgramAtTime('prog-1', 0, 60),
+ createProgramAtTime('prog-2', 12, 60),
+ ];
+
+ const scrollRef = { current: 0 };
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: scrollRef,
+ viewportWidth: HOUR_WIDTH * 4,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ const { rerender } = render(
+
+ );
+
+ const initialCalls = mockData.renderProgram.mock.calls.length;
+
+ // Scroll to different position
+ scrollRef.current = HOUR_WIDTH * 12;
+ const newData = { ...data, guideScrollLeftRef: scrollRef };
+
+ rerender();
+
+ // Different programs should be rendered
+ expect(mockData.renderProgram.mock.calls.length).toBeGreaterThan(initialCalls);
+ });
+ });
+
+ describe('Placeholder Culling', () => {
+ it('should only render placeholders visible in viewport', () => {
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, []]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 10 },
+ viewportWidth: HOUR_WIDTH * 4,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ const { container } = render(
+
+ );
+
+ const visiblePlaceholders = screen.getAllByText('No program data');
+
+ // Should render fewer than total placeholders due to culling
+ expect(visiblePlaceholders.length).toBeLessThan(Math.ceil(24 / 2));
+ expect(visiblePlaceholders.length).toBeGreaterThan(0);
+ });
+
+ it('should not render placeholders outside viewport', () => {
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, []]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 20 },
+ viewportWidth: HOUR_WIDTH * 2,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ const { container } = render(
+
+ );
+
+ const visiblePlaceholders = screen.getAllByText('No program data');
+
+ // Near the end of timeline, should show fewer placeholders
+ expect(visiblePlaceholders.length).toBeGreaterThan(0);
+ expect(visiblePlaceholders.length).toBeLessThanOrEqual(3);
+ });
+ });
+
+ describe('Hover State Management', () => {
+ it('should show play icon on mouse enter', () => {
+ render();
+
+ const logoContainer = screen.getByAltText('Test Channel').closest('.channel-logo');
+
+ expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
+
+ fireEvent.mouseEnter(logoContainer);
+
+ expect(screen.getByTestId('play-icon')).toBeInTheDocument();
+ });
+
+ it('should hide play icon on mouse leave', () => {
+ render();
+
+ const logoContainer = screen.getByAltText('Test Channel').closest('.channel-logo');
+
+ fireEvent.mouseEnter(logoContainer);
+ expect(screen.getByTestId('play-icon')).toBeInTheDocument();
+
+ fireEvent.mouseLeave(logoContainer);
+ expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
+ });
+
+ it('should maintain hover state independently per row', () => {
+ const data = {
+ ...mockData,
+ filteredChannels: [mockChannel, { ...mockChannel, id: 'channel-2', name: 'Channel 2' }],
+ };
+
+ // Render both rows separately
+ const { container: container1 } = render(
+
+ );
+ const { container: container2 } = render(
+
+ );
+
+ // Hover over first row
+ const logo1 = container1.querySelector('.channel-logo');
+ fireEvent.mouseEnter(logo1);
+
+ // First row should show play icon
+ expect(container1.querySelector('[data-testid="play-icon"]')).toBeInTheDocument();
+
+ // Second row should not show play icon
+ expect(container2.querySelector('[data-testid="play-icon"]')).not.toBeInTheDocument();
+ });
+
+ });
+
+ describe('Program Time Positioning', () => {
+ const createTimedProgram = (startHour, durationMinutes) => {
+ const timelineStart = new Date('2024-01-01T00:00:00Z');
+ const startMs = timelineStart.getTime() + startHour * 60 * 60 * 1000;
+ return {
+ id: `program-${startHour}`,
+ title: `Program at ${startHour}h`,
+ startMs,
+ endMs: startMs + durationMinutes * 60 * 1000,
+ };
+ };
+
+ it('should calculate correct viewport boundaries', () => {
+ const programs = [
+ createTimedProgram(6, 60),
+ createTimedProgram(12, 60),
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 8 },
+ viewportWidth: HOUR_WIDTH * 4,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ // Both programs should be rendered since they're within viewport range
+ expect(mockData.renderProgram).toHaveBeenCalled();
+ });
+
+ it('should handle programs at timeline boundaries', () => {
+ const programs = [
+ createTimedProgram(0, 60), // Start of timeline
+ createTimedProgram(23, 60), // End of timeline
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: 0 },
+ viewportWidth: HOUR_WIDTH * 24,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ expect(screen.getByText('Program at 0h')).toBeInTheDocument();
+ expect(screen.getByText('Program at 23h')).toBeInTheDocument();
+ });
+
+ it('should handle very short programs', () => {
+ const programs = [
+ createTimedProgram(12, 5), // 5-minute program
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 12 },
+ viewportWidth: HOUR_WIDTH * 2,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ expect(screen.getByTestId('program-program-12')).toBeInTheDocument();
+ });
+
+ it('should handle very long programs', () => {
+ const programs = [
+ createTimedProgram(6, 360), // 6-hour program
+ ];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: HOUR_WIDTH * 8 },
+ viewportWidth: HOUR_WIDTH * 2,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ // Long program spanning viewport should be visible
+ expect(screen.getByTestId('program-program-6')).toBeInTheDocument();
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle empty programsByChannelId gracefully', () => {
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map(),
+ guideScrollLeftRef: { current: 0 },
+ viewportWidth: HOUR_WIDTH * 4,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ expect(screen.getAllByText('No program data').length).toBeGreaterThan(0);
+ });
+
+ it('should handle zero viewport width', () => {
+ const programs = [mockProgram];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: 0 },
+ viewportWidth: 0,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ // Should still render due to buffer
+ expect(screen.getByTestId('guide-row')).toBeInTheDocument();
+ });
+
+ it('should handle negative scroll position', () => {
+ const programs = [createProgramAtTime('prog-1', 0, 60)];
+
+ const data = {
+ ...mockData,
+ programsByChannelId: new Map([[mockChannel.id, programs]]),
+ guideScrollLeftRef: { current: -100 },
+ viewportWidth: HOUR_WIDTH * 4,
+ timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
+ };
+
+ render();
+
+ expect(screen.getByTestId('guide-row')).toBeInTheDocument();
+ });
+
+ it('should handle row index out of bounds', () => {
+ const data = {
+ ...mockData,
+ filteredChannels: [mockChannel],
+ };
+
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should memoize component to prevent unnecessary re-renders', () => {
+ const { rerender } = render(
+
+ );
+
+ const renderCount = mockData.renderProgram.mock.calls.length;
+
+ // Re-render with same props
+ rerender();
+
+ // Should not cause additional renders due to React.memo
+ expect(mockData.renderProgram.mock.calls.length).toBe(renderCount);
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/HourTimeline.test.jsx b/frontend/src/components/__tests__/HourTimeline.test.jsx
new file mode 100644
index 00000000..372ec155
--- /dev/null
+++ b/frontend/src/components/__tests__/HourTimeline.test.jsx
@@ -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 }) => {children}
,
+ Text: ({ children, ...props }) => {children},
+ };
+});
+
+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(
+
+ );
+
+ 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(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should render formatted time labels', () => {
+ render(
+
+ );
+
+ const timeLabels = screen.getAllByText('12:00 PM');
+ expect(timeLabels.length).toBe(2);
+ });
+
+ it('should render day labels', () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ const dayLabels = screen.getAllByText('Mon');
+ expect(dayLabels[0]).toHaveAttribute('fw', '400');
+ });
+ });
+
+ describe('Quarter Hour Markers', () => {
+ it('should render quarter hour markers', () => {
+ const { container } = render(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ expect(format).toHaveBeenCalledWith(mockTime1);
+ expect(format).toHaveBeenCalledWith(mockTime2);
+ });
+ });
+
+ describe('Time Label Positioning', () => {
+ it('should position time label correctly', () => {
+ const { container } = render(
+
+ );
+
+ 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(
+
+ );
+
+ const separator = container.querySelector('[w*="1px"][left*="0"]');
+ expect(separator).toHaveAttribute('pos', 'absolute');
+ expect(separator).toHaveAttribute('top', '0');
+ expect(separator).toHaveAttribute('bottom', '0');
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/LazyLogo.test.jsx b/frontend/src/components/__tests__/LazyLogo.test.jsx
new file mode 100644
index 00000000..dfd9d27c
--- /dev/null
+++ b/frontend/src/components/__tests__/LazyLogo.test.jsx
@@ -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 ;
+ },
+ };
+});
+
+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();
+
+ 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();
+
+ 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();
+
+ const img = screen.getByAltText('logo');
+ expect(img).toHaveStyle({
+ maxHeight: '30px',
+ maxWidth: '100px',
+ });
+ });
+
+ it('should render fallback logo when no logoId provided', () => {
+ render();
+
+ 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(
+
+ );
+
+ 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();
+
+ expect(screen.getByTestId('skeleton')).toBeInTheDocument();
+ expect(screen.queryByAltText('logo')).not.toBeInTheDocument();
+ });
+
+ it('should show skeleton when logo data is not available', () => {
+ mockStore.logos = {};
+
+ render();
+
+ expect(screen.getByTestId('skeleton')).toBeInTheDocument();
+ });
+
+ it('should render skeleton with default dimensions', () => {
+ mockStore.logos = {};
+
+ render();
+
+ 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();
+
+ const skeleton = screen.getByTestId('skeleton');
+ expect(skeleton).toHaveStyle({
+ height: '40px',
+ width: '120px',
+ });
+ });
+
+ it('should apply border radius to skeleton', () => {
+ mockStore.logos = {};
+
+ render();
+
+ 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();
+
+ 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();
+
+ vi.advanceTimersByTime(100);
+
+ expect(mockFetchLogosByIds).not.toHaveBeenCalled();
+ });
+
+ it('should not fetch logo when allowLogoRendering is false', () => {
+ mockStore.allowLogoRendering = false;
+ mockStore.logos = {};
+
+ render();
+
+ vi.advanceTimersByTime(100);
+
+ expect(mockFetchLogosByIds).not.toHaveBeenCalled();
+ });
+
+ it('should not fetch logo when no logoId is provided', () => {
+ render();
+
+ vi.advanceTimersByTime(100);
+
+ expect(mockFetchLogosByIds).not.toHaveBeenCalled();
+ });
+
+ it('should batch multiple logo requests', async () => {
+ mockStore.logos = {};
+
+ render(
+ <>
+
+
+
+ >
+ );
+
+ 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();
+
+ 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();
+
+ vi.advanceTimersByTime(100);
+ });
+
+ it('should not fetch same logo twice', async () => {
+ mockStore.logos = {};
+
+ const { rerender } = render();
+
+ vi.advanceTimersByTime(100);
+
+ expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
+
+ rerender();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ const img = screen.getByAltText('logo');
+ img.dispatchEvent(new Event('error'));
+
+ rerender();
+
+ 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();
+
+ unmount();
+
+ // Should not throw errors after unmount
+ vi.advanceTimersByTime(100);
+ });
+
+ it('should handle rapid logoId changes', async () => {
+ mockStore.logos = {};
+
+ const { rerender } = render();
+
+ rerender();
+ rerender();
+
+ vi.advanceTimersByTime(100);
+
+ expect(mockFetchLogosByIds).toHaveBeenCalled();
+ });
+ });
+
+ describe('Store Integration', () => {
+ it('should react to store updates', async () => {
+ mockStore.logos = {};
+
+ const { rerender } = render();
+
+ expect(screen.getByTestId('skeleton')).toBeInTheDocument();
+
+ // Update store with logo data
+ mockStore.logos = {
+ 'logo-1': { cache_url: 'https://example.com/logo.png' },
+ };
+
+ rerender();
+
+ 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();
+
+ expect(screen.getByTestId('skeleton')).toBeInTheDocument();
+ expect(mockFetchLogosByIds).not.toHaveBeenCalled();
+
+ mockStore.allowLogoRendering = true;
+ rerender();
+
+ vi.advanceTimersByTime(100);
+
+ expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo-1']);
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/M3URefreshNotification.test.jsx b/frontend/src/components/__tests__/M3URefreshNotification.test.jsx
new file mode 100644
index 00000000..2c5a5572
--- /dev/null
+++ b/frontend/src/components/__tests__/M3URefreshNotification.test.jsx
@@ -0,0 +1,716 @@
+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 }) => {children}
,
+ Group: ({ children }) => {children}
,
+ Button: ({ children, onClick }) => (
+
+ ),
+ };
+});
+
+// Mock lucide-react icons
+vi.mock('lucide-react', () => ({
+ CircleCheck: () => ,
+}));
+
+const renderWithProviders = (component) => {
+ return render({component});
+};
+
+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(),
+ fetchChannelIds: 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();
+ expect(container).toBeInTheDocument();
+ });
+
+ it('should render empty fragment', () => {
+ const { container } = renderWithProviders();
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalled();
+ expect(mockStreamsStore.fetchStreams).toHaveBeenCalled();
+ expect(API.requeryChannels).toHaveBeenCalled();
+ expect(mockChannelsStore.fetchChannelIds).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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledTimes(1);
+ });
+
+ vi.clearAllMocks();
+
+ // Second update with success
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'parsing',
+ status: 'completed',
+ progress: 100,
+ },
+ };
+
+ rerender(
+
+
+
+ );
+
+ // 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledTimes(1);
+ });
+
+ vi.clearAllMocks();
+
+ // Re-render with same data
+ rerender(
+
+
+
+ );
+
+ 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();
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledTimes(1);
+ });
+
+ vi.clearAllMocks();
+
+ // Update with different progress
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'parsing',
+ progress: 100,
+ status: 'completed',
+ },
+ };
+
+ rerender(
+
+
+
+ );
+
+ 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();
+
+ 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(
+
+
+
+ );
+
+ // 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();
+
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('Effect Dependencies', () => {
+ it('should re-run effect when refreshProgress changes', async () => {
+ mockPlaylistsStore.refreshProgress = {};
+
+ const { rerender } = renderWithProviders();
+
+ expect(showNotification).not.toHaveBeenCalled();
+
+ mockPlaylistsStore.refreshProgress = {
+ 1: {
+ account: 1,
+ action: 'parsing',
+ progress: 0,
+ status: 'in_progress',
+ },
+ };
+
+ rerender(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalled();
+ });
+ });
+
+ it('should re-run effect when playlists change', async () => {
+ const { rerender } = renderWithProviders();
+
+ const newPlaylist = { id: 2, name: 'New Playlist' };
+ mockPlaylistsStore.playlists = [mockPlaylist, newPlaylist];
+
+ rerender(
+
+
+
+ );
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/NotificationCenter.test.jsx b/frontend/src/components/__tests__/NotificationCenter.test.jsx
new file mode 100644
index 00000000..9dfb13f5
--- /dev/null
+++ b/frontend/src/components/__tests__/NotificationCenter.test.jsx
@@ -0,0 +1,662 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { BrowserRouter } from 'react-router-dom';
+import NotificationCenter from '../NotificationCenter';
+import useNotificationsStore from '../../store/notifications';
+import * as NotificationUtils from '../../utils/components/NotificationCenterUtils';
+
+// Mock the notifications store
+vi.mock('../../store/notifications');
+
+// Mock the notification utils
+vi.mock('../../utils/components/NotificationCenterUtils', () => ({
+ getNotifications: vi.fn(),
+ dismissNotification: vi.fn(),
+ dismissAllNotifications: vi.fn(),
+}));
+
+// Mock react-router-dom
+const mockNavigate = vi.fn();
+vi.mock('react-router-dom', async () => {
+ const actual = await vi.importActual('react-router-dom');
+ return {
+ ...actual,
+ useNavigate: () => mockNavigate,
+ };
+});
+
+// Mock Mantine components
+vi.mock('@mantine/core', async () => {
+ return {
+ Popover: ({ children, opened }) => (
+
+ {children}
+
+ ),
+ PopoverTarget: ({ children }) => {children}
,
+ PopoverDropdown: ({ children }) => {children}
,
+ Indicator: ({ children, label, disabled, processing }) => (
+
+ {children}
+
+ ),
+ ActionIcon: ({ children, onClick, 'aria-label': ariaLabel, ...props }) => (
+
+ ),
+ ScrollAreaAutosize: ({ children }) => {children}
,
+ Badge: ({ children, ...props }) => {children},
+ Card: ({ children, ...props }) => {children}
,
+ ThemeIcon: ({ children, ...props }) => {children}
,
+ Group: ({ children, ...props }) => {children}
,
+ Stack: ({ children, ...props }) => {children}
,
+ Box: ({ children, ...props }) => {children}
,
+ Text: ({ children, ...props }) => {children},
+ Button: ({ children, onClick, ...props }) => (
+
+ ),
+ Divider: () =>
,
+ Tooltip: ({ children, label }) => (
+
+ {children}
+
+ ),
+ useMantineTheme: () => ({
+ colors: {
+ red: ['', '', '', '', '', '#fa5252', '', '', '', '#c92a2a'],
+ orange: ['', '', '', '', '', '#fd7e14'],
+ blue: ['', '', '', '', '', '#228be6'],
+ gray: ['', '', '', '', '', '#adb5bd'],
+ green: ['', '', '', '', '', '#51cf66'],
+ violet: ['', '', '', '', '', '#7950f2'],
+ },
+ }),
+ };
+});
+
+// Mock lucide-react icons
+vi.mock('lucide-react', () => ({
+ Bell: () => Bell,
+ Check: () => Check,
+ CheckCheck: () => CheckCheck,
+ Download: () => Download,
+ ExternalLink: () => ExternalLink,
+ Info: () => Info,
+ Settings: () => Settings,
+ AlertTriangle: () => AlertTriangle,
+ Megaphone: () => Megaphone,
+ X: () => X,
+ Eye: () => Eye,
+ EyeOff: () => EyeOff,
+ ArrowRight: () => ArrowRight,
+}));
+
+const mockNotifications = [
+ {
+ id: 1,
+ title: 'Version Update Available',
+ message: 'Version 2.0 is available',
+ notification_type: 'version_update',
+ priority: 'high',
+ is_dismissed: false,
+ created_at: '2024-01-01T10:00:00Z',
+ action_data: {
+ release_url: 'https://github.com/releases/v2.0',
+ },
+ },
+ {
+ id: 2,
+ title: 'Setting Recommendation',
+ message: 'Enable dark mode for better experience',
+ notification_type: 'setting_recommendation',
+ priority: 'normal',
+ is_dismissed: false,
+ created_at: '2024-01-02T10:00:00Z',
+ action_data: {},
+ },
+ {
+ id: 3,
+ title: 'System Announcement',
+ message: 'Maintenance scheduled for tomorrow',
+ notification_type: 'announcement',
+ priority: 'normal',
+ is_dismissed: true,
+ created_at: '2024-01-03T10:00:00Z',
+ action_data: {},
+ },
+ {
+ id: 4,
+ title: 'Critical Warning',
+ message: 'System issue detected',
+ notification_type: 'warning',
+ priority: 'critical',
+ is_dismissed: false,
+ created_at: '2024-01-04T10:00:00Z',
+ action_data: {},
+ },
+];
+
+describe('NotificationCenter', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // vi.useFakeTimers();
+
+ // Default store mock
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: mockNotifications,
+ unreadCount: 3,
+ getUnreadNotifications: vi.fn(() =>
+ mockNotifications.filter((n) => !n.is_dismissed)
+ ),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ NotificationUtils.getNotifications.mockResolvedValue();
+ NotificationUtils.dismissNotification.mockResolvedValue();
+ NotificationUtils.dismissAllNotifications.mockResolvedValue();
+ });
+
+ afterEach(() => {
+ vi.clearAllTimers();
+ });
+
+ const renderComponent = (props = {}) => {
+ return render(
+
+
+
+ );
+ };
+
+ describe('Bell Icon and Indicator', () => {
+ it('should render bell icon', () => {
+ renderComponent();
+ expect(screen.getByLabelText('Notifications')).toBeInTheDocument();
+ expect(screen.getByTestId('bell-icon')).toBeInTheDocument();
+ });
+
+ it('should show unread count indicator when there are unread notifications', () => {
+ renderComponent();
+ const indicator = screen.getByTestId('indicator');
+ expect(indicator).toHaveAttribute('data-label', '3');
+ expect(indicator).toHaveAttribute('data-disabled', 'false');
+ expect(indicator).toHaveAttribute('data-processing', 'true');
+ });
+
+ it('should not show indicator when unread count is zero', () => {
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: [],
+ unreadCount: 0,
+ getUnreadNotifications: vi.fn(() => []),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ const indicator = screen.getByTestId('indicator');
+ expect(indicator).toHaveAttribute('data-disabled', 'true');
+ });
+
+ it('should show "9+" when unread count exceeds 9', () => {
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: mockNotifications,
+ unreadCount: 15,
+ getUnreadNotifications: vi.fn(() => mockNotifications),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ const indicator = screen.getByTestId('indicator');
+ expect(indicator).toHaveAttribute('data-label', '9+');
+ });
+ });
+
+ describe('Popover Toggle', () => {
+ it('should open popover when bell icon is clicked', () => {
+ renderComponent();
+ const bellButton = screen.getByLabelText('Notifications');
+
+ fireEvent.click(bellButton);
+
+ const popover = screen.getByTestId('popover');
+ expect(popover).toHaveAttribute('data-opened', 'true');
+ });
+
+ it('should close popover when bell icon is clicked again', () => {
+ renderComponent();
+ const bellButton = screen.getByLabelText('Notifications');
+
+ fireEvent.click(bellButton);
+ fireEvent.click(bellButton);
+
+ const popover = screen.getByTestId('popover');
+ expect(popover).toHaveAttribute('data-opened', 'false');
+ });
+
+ it('should display notification header with count', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByText('Notifications')).toBeInTheDocument();
+ expect(screen.getByText('3 new')).toBeInTheDocument();
+ });
+ });
+
+ describe('API Calls', () => {
+ it('should fetch notifications on mount', async () => {
+ renderComponent();
+
+ await waitFor(() => {
+ expect(NotificationUtils.getNotifications).toHaveBeenCalledWith(false);
+ });
+ });
+
+ it('should fetch notifications every 5 minutes', () => {
+ vi.useFakeTimers();
+
+ renderComponent();
+
+ expect(NotificationUtils.getNotifications).toHaveBeenCalledTimes(1);
+
+ vi.advanceTimersByTime(5 * 60 * 1000);
+
+ expect(NotificationUtils.getNotifications).toHaveBeenCalledTimes(2);
+
+ vi.useRealTimers();
+ });
+
+ it('should handle API errors gracefully', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ NotificationUtils.getNotifications.mockRejectedValue(new Error('Network error'));
+
+ renderComponent();
+
+ await waitFor(() => {
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to fetch notifications:',
+ expect.any(Error)
+ );
+ });
+
+ consoleError.mockRestore();
+ });
+ });
+
+ describe('Notification Display', () => {
+ it('should display unread notifications by default', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByText('Version Update Available')).toBeInTheDocument();
+ expect(screen.getByText('Setting Recommendation')).toBeInTheDocument();
+ expect(screen.getByText('Critical Warning')).toBeInTheDocument();
+ expect(screen.queryByText('System Announcement')).not.toBeInTheDocument();
+ });
+
+ it('should display all notifications when show dismissed is toggled', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const eyeButtons = screen.getAllByTestId(/action-icon-/);
+ const toggleButton = eyeButtons.find(btn =>
+ btn.querySelector('[data-testid="eye-icon"]')
+ );
+ fireEvent.click(toggleButton);
+
+ expect(screen.getByText('System Announcement')).toBeInTheDocument();
+ });
+
+ it('should show empty state when no unread notifications', () => {
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: [],
+ unreadCount: 0,
+ getUnreadNotifications: vi.fn(() => []),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByText('All caught up!')).toBeInTheDocument();
+ expect(screen.getByText('No new notifications')).toBeInTheDocument();
+ });
+
+ it('should show dismissed count in footer', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByText('1 dismissed notification')).toBeInTheDocument();
+ });
+
+ it('should show correct dismissed count with multiple dismissed notifications', () => {
+ const notifications = [
+ ...mockNotifications,
+ { ...mockNotifications[2], id: 5, is_dismissed: true },
+ ];
+
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications,
+ unreadCount: 3,
+ getUnreadNotifications: vi.fn(() =>
+ notifications.filter((n) => !n.is_dismissed)
+ ),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByText('2 dismissed notifications')).toBeInTheDocument();
+ });
+ });
+
+ describe('Notification Actions', () => {
+ it('should dismiss notification when X button is clicked', async () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const xIcons = screen.getAllByTestId('x-icon');
+ fireEvent.click(xIcons[0].closest('button'));
+
+ await waitFor(() => {
+ expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(1, 'dismissed');
+ });
+ });
+
+ it('should dismiss all notifications when CheckCheck button is clicked', async () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const checkCheckIcon = screen.getByTestId('checkcheck-icon');
+ fireEvent.click(checkCheckIcon.closest('button'));
+
+ await waitFor(() => {
+ expect(NotificationUtils.dismissAllNotifications).toHaveBeenCalled();
+ });
+ });
+
+ it('should open release URL for version update notification', () => {
+ const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => {});
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const viewReleaseButton = screen.getByText('View Release');
+ fireEvent.click(viewReleaseButton);
+
+ expect(windowOpen).toHaveBeenCalledWith(
+ 'https://github.com/releases/v2.0',
+ '_blank'
+ );
+
+ windowOpen.mockRestore();
+ });
+
+ it('should navigate for notifications with action_url', () => {
+ const notificationWithUrl = {
+ ...mockNotifications[0],
+ action_data: {
+ action_url: '/settings',
+ action_text: 'Go to Settings',
+ },
+ };
+
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: [notificationWithUrl],
+ unreadCount: 1,
+ getUnreadNotifications: vi.fn(() => [notificationWithUrl]),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const actionButton = screen.getByText('Go to Settings');
+ fireEvent.click(actionButton);
+
+ expect(mockNavigate).toHaveBeenCalledWith('/settings');
+ });
+
+ it('should call onSettingAction when applying setting recommendation', async () => {
+ const onSettingAction = vi.fn();
+ renderComponent({ onSettingAction });
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const applyButton = screen.getByText('Apply');
+ fireEvent.click(applyButton);
+
+ await waitFor(() => {
+ expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(2, 'applied');
+ expect(onSettingAction).toHaveBeenCalledWith(mockNotifications[1]);
+ });
+ });
+
+ it('should dismiss setting recommendation when Ignore is clicked', async () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const ignoreButton = screen.getByText('Ignore');
+ fireEvent.click(ignoreButton);
+
+ await waitFor(() => {
+ expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(2, 'dismissed');
+ });
+ });
+
+ it('should close popover when navigating with action_url', () => {
+ const notificationWithUrl = {
+ ...mockNotifications[0],
+ action_data: {
+ action_url: '/settings',
+ action_text: 'Go to Settings',
+ },
+ };
+
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: [notificationWithUrl],
+ unreadCount: 1,
+ getUnreadNotifications: vi.fn(() => [notificationWithUrl]),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const actionButton = screen.getByText('Go to Settings');
+ fireEvent.click(actionButton);
+
+ const popover = screen.getByTestId('popover');
+ expect(popover).toHaveAttribute('data-opened', 'false');
+ });
+ });
+
+ describe('Notification Types and Icons', () => {
+ it('should render correct icon for version_update', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByTestId('download-icon')).toBeInTheDocument();
+ });
+
+ it('should render correct icon for setting_recommendation', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByTestId('settings-icon')).toBeInTheDocument();
+ });
+
+ it('should render correct icon for announcement', () => {
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: [mockNotifications[2]],
+ unreadCount: 0,
+ getUnreadNotifications: vi.fn(() => []),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const eyeButtons = screen.getAllByTestId(/action-icon-/);
+ const toggleButton = eyeButtons.find(btn =>
+ btn.querySelector('[data-testid="eye-icon"]')
+ );
+ fireEvent.click(toggleButton);
+
+ expect(screen.getByTestId('megaphone-icon')).toBeInTheDocument();
+ });
+
+ it('should render correct icon for warning', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByTestId('alert-triangle-icon')).toBeInTheDocument();
+ });
+
+ it('should render info icon for unknown type', () => {
+ const unknownTypeNotification = {
+ ...mockNotifications[0],
+ notification_type: 'unknown',
+ is_dismissed: false,
+ };
+
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: [unknownTypeNotification],
+ unreadCount: 1,
+ getUnreadNotifications: vi.fn(() => [unknownTypeNotification]),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByTestId('info-icon')).toBeInTheDocument();
+ });
+ });
+
+ describe('Priority Badges', () => {
+ it('should show priority badge for high priority notifications', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByText('high')).toBeInTheDocument();
+ });
+
+ it('should show priority badge for critical priority notifications', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.getByText('critical')).toBeInTheDocument();
+ });
+
+ it('should not show priority badge for normal priority', () => {
+ const normalNotification = {
+ ...mockNotifications[1],
+ priority: 'normal',
+ };
+
+ useNotificationsStore.mockImplementation((selector) => {
+ const state = {
+ notifications: [normalNotification],
+ unreadCount: 1,
+ getUnreadNotifications: vi.fn(() => [normalNotification]),
+ };
+ return selector ? selector(state) : state;
+ });
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ expect(screen.queryByText('normal')).not.toBeInTheDocument();
+ });
+
+ it('should show dismissed badge for dismissed notifications', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const eyeButtons = screen.getAllByTestId(/action-icon-/);
+ const toggleButton = eyeButtons.find(btn =>
+ btn.querySelector('[data-testid="eye-icon"]')
+ );
+ fireEvent.click(toggleButton);
+
+ expect(screen.getByText('Dismissed')).toBeInTheDocument();
+ });
+ });
+
+ describe('Error Handling', () => {
+ it('should handle dismiss notification errors', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ NotificationUtils.dismissNotification.mockRejectedValue(new Error('API error'));
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const xIcons = screen.getAllByTestId('x-icon');
+ fireEvent.click(xIcons[0].closest('button'));
+
+ await waitFor(() => {
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to dismiss notification:',
+ expect.any(Error)
+ );
+ });
+
+ consoleError.mockRestore();
+ });
+
+ it('should handle dismiss all notifications errors', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ NotificationUtils.dismissAllNotifications.mockRejectedValue(new Error('API error'));
+
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const checkCheckIcon = screen.getByTestId('checkcheck-icon');
+ fireEvent.click(checkCheckIcon.closest('button'));
+
+ await waitFor(() => {
+ expect(consoleError).toHaveBeenCalledWith(
+ 'Failed to dismiss all notifications:',
+ expect.any(Error)
+ );
+ });
+
+ consoleError.mockRestore();
+ });
+ });
+
+ describe('Date Display', () => {
+ it('should display formatted date for notifications', () => {
+ renderComponent();
+ fireEvent.click(screen.getByLabelText('Notifications'));
+
+ const expectedDate = new Date('2024-01-01T10:00:00Z').toLocaleDateString();
+ expect(screen.getByText(expectedDate)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/RecordingSynopsis.test.jsx b/frontend/src/components/__tests__/RecordingSynopsis.test.jsx
new file mode 100644
index 00000000..482d7e41
--- /dev/null
+++ b/frontend/src/components/__tests__/RecordingSynopsis.test.jsx
@@ -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 (
+
+ {children}
+
+ );
+ },
+ };
+});
+
+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(
+
+ );
+
+ const text = screen.getByText(shortDescription);
+ expect(text).toBeInTheDocument();
+ });
+
+ it('should return null when description is undefined', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should return null when description is null', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should return null when description is empty string', () => {
+ const { container } = render(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should render without onOpen callback', () => {
+ const description = 'Test description';
+
+ render();
+
+ expect(screen.getByText(description)).toBeInTheDocument();
+ });
+ });
+
+ describe('Text Truncation', () => {
+ it('should not truncate description with exactly 140 characters', () => {
+ const exactLength = 'A'.repeat(140);
+
+ render(
+
+ );
+
+ expect(screen.getByText(exactLength)).toBeInTheDocument();
+ expect(screen.queryByText(/\.\.\./)).not.toBeInTheDocument();
+ });
+
+ it('should truncate description with 141 characters', () => {
+ const overLength = 'A'.repeat(141);
+
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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();
+
+ const text = screen.getByText(description);
+ expect(() => fireEvent.click(text)).not.toThrow();
+ });
+
+ it('should handle multiple clicks', () => {
+ const description = 'Test description';
+
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ const text = screen.getByTestId('text');
+ fireEvent.click(text);
+
+ expect(mockOnOpen).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/SeriesModal.test.jsx b/frontend/src/components/__tests__/SeriesModal.test.jsx
new file mode 100644
index 00000000..acbd35c1
--- /dev/null
+++ b/frontend/src/components/__tests__/SeriesModal.test.jsx
@@ -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: () => ,
+ Copy: () => ,
+}));
+
+// 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 (
+
+ );
+ },
+ Box: ({ children, ...props }) => {children}
,
+ Button: ({ children, onClick, disabled, ...props }) => (
+
+ ),
+ Flex: ({ children, ...props }) => {children}
,
+ Group: ({ children, ...props }) => {children}
,
+ Image: ({ src, alt, ...props }) => (
+
+ ),
+ Text: ({ children, ...props }) => {children}
,
+ Title: ({ children, order, ...props }) => (
+ {children}
+ ),
+ Select: ({ value, onChange, data, label, placeholder, disabled, ...props }) => (
+
+
+
+ ),
+ Badge: ({ children, ...props }) => {children},
+ Loader: (props) => ,
+ Stack: ({ children, ...props }) => {children}
,
+ ActionIcon: ({ children, onClick, disabled, ...props }) => (
+
+ ),
+ Tabs: ({ children, value, onChange, ...props }) => (
+
+
{
+ const tab = e.target.closest('[data-tab-value]');
+ if (tab) onChange?.(tab.dataset.tabValue);
+ }}>
+ {children}
+
+
+ ),
+ TabsList: ({ children }) => {children}
,
+ TabsTab: ({ children, value }) => (
+
+ ),
+ TabsPanel: ({ children, value }) => (
+ {children}
+ ),
+ Table: ({ children, ...props }) => ,
+ TableThead: ({ children }) => {children},
+ TableTbody: ({ children }) => {children},
+ TableTr: ({ children, onClick, ...props }) => (
+ {children}
+ ),
+ TableTh: ({ children, ...props }) => {children} | ,
+ TableTd: ({ children, ...props }) => {children} | ,
+ 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(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('should render nothing when modal is closed', () => {
+ render(
+
+ );
+
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('should render modal when opened with series', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+ });
+
+ it('should display series name as title', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Series')).toBeInTheDocument();
+ });
+ });
+
+ it('should display cover image', async () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ //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(
+
+ );
+
+ await waitFor(() => {
+ expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalledWith(1);
+ });
+ });
+
+ it('should fetch series providers when opened', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(mockVODStore.fetchSeriesProviders).toHaveBeenCalledWith(1);
+ });
+ });
+
+ it('should not fetch data when modal is closed', () => {
+ render(
+
+ );
+
+ expect(mockVODStore.fetchSeriesInfo).not.toHaveBeenCalled();
+ expect(mockVODStore.fetchSeriesProviders).not.toHaveBeenCalled();
+ });
+
+ it('should reset state when modal closes', async () => {
+ const { rerender } = render(
+
+ );
+
+ await waitFor(() => {
+ expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalled();
+ });
+
+ rerender(
+
+ );
+
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Series Information Display', () => {
+ it('should display genre', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Drama, Action/)).toBeInTheDocument();
+ });
+ });
+
+ it('should display rating', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/8\.5/)).toBeInTheDocument();
+ });
+ });
+
+ it('should display IMDB link when imdb_id exists', async () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ await waitFor(() => {
+ const select = screen.getByTestId('select');
+ expect(select).toBeInTheDocument();
+ });
+ });
+
+ it('should format provider label correctly with quality info', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument();
+ });
+ });
+
+ it('should handle provider selection', async () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ // 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(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Season 1/i)).toBeInTheDocument();
+ });
+ });
+
+ it('should display episode information', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Pilot/)).toBeInTheDocument();
+ });
+ });
+
+ it('should format episode duration correctly', async () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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();
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/720x480/)).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('should handle series with no episodes', async () => {
+ mockVODStore.fetchSeriesInfo.mockResolvedValue({
+ ...mockDetailedSeries,
+ episodesList: [],
+ });
+
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('table')).not.toBeInTheDocument();
+ });
+ });
+
+ it('should handle fetch errors gracefully', async () => {
+ mockVODStore.fetchSeriesInfo.mockRejectedValue(new Error('Fetch failed'));
+
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText(/Season 1/i)).toBeInTheDocument();
+ });
+ });
+
+ it('should handle empty provider list', async () => {
+ mockVODStore.fetchSeriesProviders.mockResolvedValue([]);
+
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ waitFor(() => {
+ expect(screen.getByText(/2m 5s/)).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/Sidebar.test.jsx b/frontend/src/components/__tests__/Sidebar.test.jsx
new file mode 100644
index 00000000..d3163b1d
--- /dev/null
+++ b/frontend/src/components/__tests__/Sidebar.test.jsx
@@ -0,0 +1,628 @@
+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(),
+}));
+
+vi.mock('../NotificationCenter', () => ({
+ default: () => Notification Center
,
+}));
+
+// Mock lucide-react icons
+vi.mock('lucide-react', () => ({
+ ListOrdered: ({ onClick }) => ,
+ Play: ({ onClick }) => ,
+ Database: ({ onClick }) => ,
+ LayoutGrid: ({ onClick }) => ,
+ Settings: ({ onClick }) => ,
+ Copy: ({ onClick }) => ,
+ ChartLine: ({ onClick }) => ,
+ Video: ({ onClick }) => ,
+ PlugZap: ({ onClick }) => ,
+ LogOut: ({ onClick }) => ,
+ User: ({ onClick }) => ,
+ FileImage: ({ onClick }) => ,
+ Webhook: () => ,
+ Logs: () => ,
+ ChevronDown: () => ,
+ ChevronRight: () => ,
+ MonitorCog: () => ,
+ Blocks: () => ,
+}));
+
+// Mock UserForm component
+vi.mock('../forms/User', () => ({
+ default: ({ isOpen, onClose, user }) => (
+ isOpen ? (
+
+ User Form for {user?.username}
+
+
+ ) : null
+ ),
+}));
+
+vi.mock('@mantine/core', async () => {
+ return {
+ Avatar: ({ children }) => {children}
,
+ Group: ({ children, onClick, ...props }) => (
+ {children}
+ ),
+ Stack: ({ children }) => {children}
,
+ Box: ({ children }) => {children}
,
+ Text: ({ children }) => {children}
,
+ UnstyledButton: ({ children, onClick, component, to, className }) => {
+ const Component = component || 'button';
+ return (
+
+ {children}
+
+ );
+ },
+ TextInput: ({ value, onChange, leftSection, rightSection, label }) => (
+
+ {label && }
+ {leftSection}
+
+ {rightSection}
+
+ ),
+ ActionIcon: ({ children, onClick, ...props }) => (
+
+ ),
+ AppShellNavbar: ({ children, style, width, ...props }) => (
+
+ ),
+ ScrollArea: ({ children }) => {children}
,
+ };
+});
+
+const mockChannels = [ 'channel-1', 'channel-2', '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(
+
+
+
+ );
+};
+
+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', async () => {
+ 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();
+
+ // Expand System group to access Users
+ const systemButton = screen.getByText('System');
+ fireEvent.click(systemButton);
+
+ await waitFor(() => {
+ 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(
+
+
+
+ );
+
+ 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();
+ });
+ });
+
+ describe('NavGroup Component', () => {
+ it('should render Integrations group with children collapsed by default', () => {
+ renderSidebar();
+
+ expect(screen.getByText('Integrations')).toBeInTheDocument();
+ expect(screen.queryByText('Connections')).not.toBeInTheDocument();
+ expect(screen.queryByText('Logs')).not.toBeInTheDocument();
+ });
+
+ it('should expand Integrations group when clicked', async () => {
+ renderSidebar();
+
+ const integrationsGroup = screen.getByText('Integrations').closest('button');
+ fireEvent.click(integrationsGroup);
+
+ await waitFor(() => {
+ expect(screen.getByText('Connections')).toBeInTheDocument();
+ expect(screen.getByText('Logs')).toBeInTheDocument();
+ });
+ });
+
+ it('should collapse Integrations group when clicked again', async () => {
+ renderSidebar();
+
+ const integrationsGroup = screen.getByText('Integrations').closest('button');
+
+ // Expand
+ fireEvent.click(integrationsGroup);
+ await waitFor(() => {
+ expect(screen.getByText('Connections')).toBeInTheDocument();
+ });
+
+ // Collapse
+ fireEvent.click(integrationsGroup);
+ await waitFor(() => {
+ expect(screen.queryByText('Connections')).not.toBeInTheDocument();
+ expect(screen.queryByText('Logs')).not.toBeInTheDocument();
+ });
+ });
+
+ it('should render System group with children collapsed by default', () => {
+ renderSidebar();
+
+ expect(screen.getByText('System')).toBeInTheDocument();
+ expect(screen.queryByText('Users')).not.toBeInTheDocument();
+ expect(screen.queryByText('Logo Manager')).not.toBeInTheDocument();
+ });
+
+ it('should expand System group when clicked', async () => {
+ renderSidebar();
+
+ const systemGroup = screen.getByText('System').closest('button');
+ fireEvent.click(systemGroup);
+
+ await waitFor(() => {
+ expect(screen.getByText('Users')).toBeInTheDocument();
+ expect(screen.getByText('Logo Manager')).toBeInTheDocument();
+ expect(screen.getByText('Settings')).toBeInTheDocument();
+ });
+ });
+
+ it('should hide group label when collapsed sidebar', () => {
+ renderSidebar({ collapsed: true });
+
+ expect(screen.queryByText('Integrations')).not.toBeInTheDocument();
+ expect(screen.queryByText('System')).not.toBeInTheDocument();
+ });
+
+ it('should not show multiple groups collapsed when both expanded', async () => {
+ renderSidebar();
+
+ const integrationsGroup = screen.getByText('Integrations').closest('button');
+ const systemGroup = screen.getByText('System').closest('button');
+
+ // Expand Integrations
+ fireEvent.click(integrationsGroup);
+ await waitFor(() => {
+ expect(screen.getByText('Connections')).toBeInTheDocument();
+ });
+
+ // Expand System (Integrations should remain expanded)
+ fireEvent.click(systemGroup);
+ await waitFor(() => {
+ expect(screen.getByText('Users')).toBeInTheDocument();
+ expect(screen.getByText('Connections')).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('NotificationCenter Integration', () => {
+ it('should render NotificationCenter when authenticated and expanded', () => {
+ renderSidebar();
+
+ expect(screen.getByTestId('notification-center')).toBeInTheDocument();
+ });
+
+ it('should render NotificationCenter when authenticated and collapsed', () => {
+ renderSidebar({ collapsed: true });
+
+ expect(screen.getByTestId('notification-center')).toBeInTheDocument();
+ });
+
+ it('should not render NotificationCenter when not authenticated', () => {
+ useAuthStore.mockImplementation((selector) => {
+ const state = {
+ isAuthenticated: false,
+ user: null,
+ logout: vi.fn(),
+ };
+ return selector(state);
+ });
+
+ renderSidebar();
+
+ expect(screen.queryByTestId('notification-center')).not.toBeInTheDocument();
+ });
+
+ it('should not render NotificationCenter when not authenticated and collapsed', () => {
+ useAuthStore.mockImplementation((selector) => {
+ const state = {
+ isAuthenticated: false,
+ user: null,
+ logout: vi.fn(),
+ };
+ return selector(state);
+ });
+
+ renderSidebar({ collapsed: true });
+
+ expect(screen.queryByTestId('notification-center')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('Channel Count Badge', () => {
+ it('should display 0 when no channels exist', () => {
+ useChannelsStore.mockReturnValue({});
+
+ renderSidebar();
+
+ expect(screen.getByText('(0)')).toBeInTheDocument();
+ });
+
+ it('should handle null channelIds gracefully', () => {
+ useChannelsStore.mockReturnValue(null);
+
+ renderSidebar();
+
+ expect(screen.getByText('(0)')).toBeInTheDocument();
+ });
+
+ it('should handle array of channel IDs', () => {
+ useChannelsStore.mockReturnValue(['channel-1', 'channel-2', 'channel-3']);
+
+ renderSidebar();
+
+ expect(screen.getByText('(3)')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/components/__tests__/SystemEvents.test.jsx b/frontend/src/components/__tests__/SystemEvents.test.jsx
new file mode 100644
index 00000000..238d3fce
--- /dev/null
+++ b/frontend/src/components/__tests__/SystemEvents.test.jsx
@@ -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 }) => (
+
+ ),
+ Box: ({ children }) => {children}
,
+ Button: ({ children, onClick }) => (
+
+ ),
+ Card: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ NumberInput: ({ value, onChange, label }) => (
+ onChange(Number(e.target.value))}
+ />
+ ),
+ Pagination: ({ page, onChange, total }) => (
+
+ {Array.from({ length: Math.ceil(total / 100) }, (_, i) => (
+
+ ))}
+
+ ),
+ Select: ({ value, onChange, data }) => (
+
+ ),
+ Stack: ({ children }) => {children}
,
+ Text: ({ children }) => {children}
,
+ Title: ({ children }) => {children}
,
+ };
+});
+
+// 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();
+
+ await waitFor(() => {
+ expect(screen.getByText('System Events')).toBeInTheDocument();
+ });
+ });
+
+ it('should fetch and display events on mount', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(API.getSystemEvents).toHaveBeenCalledWith(100, 0);
+ });
+ });
+
+ it('should expand and show events when chevron is clicked', async () => {
+ render();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ 'Error fetching system events:',
+ expect.any(Error)
+ );
+ });
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should display event details correctly', async () => {
+ render();
+
+ 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();
+
+ 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
+ });
+});
diff --git a/frontend/src/components/__tests__/VODModal.test.jsx b/frontend/src/components/__tests__/VODModal.test.jsx
new file mode 100644
index 00000000..7dc6ed40
--- /dev/null
+++ b/frontend/src/components/__tests__/VODModal.test.jsx
@@ -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 ? (
+
+
{title}
+
+ {children}
+
+ ) : null,
+ Box: ({ children, ...props }) => {children}
,
+ Stack: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ Flex: ({ children }) => {children}
,
+ Image: ({ src, alt }) =>
,
+ Text: ({ children, ...props }) => {children},
+ Title: ({ children }) => {children}
,
+ Button: ({ children, onClick, disabled, leftSection }) => (
+
+ ),
+ Badge: ({ children, component, href, ...props }) =>
+ component === 'a' ? (
+
+ {children}
+
+ ) : (
+ {children}
+ ),
+ Select: ({ data, value, onChange, placeholder, disabled }) => (
+
+ ),
+ Loader: () => Loading...
,
+ };
+});
+
+// Mock lucide-react icons
+vi.mock('lucide-react', () => ({
+ Play: () => Play Icon,
+ Copy: () => Copy Icon,
+}));
+
+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();
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('should render modal when opened with vod', () => {
+ render();
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie');
+ });
+
+ it('should not render when closed', () => {
+ render();
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('should display movie details correctly', async () => {
+ render();
+
+ 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();
+
+ await waitFor(() => {
+ expect(mockFetchMovieDetailsFromProvider).toHaveBeenCalledWith(mockVOD.id);
+ });
+ });
+
+ it('should fetch movie providers on mount', async () => {
+ render();
+
+ await waitFor(() => {
+ expect(mockFetchMovieProviders).toHaveBeenCalledWith(mockVOD.id);
+ });
+ });
+
+ it('should show loading state while fetching details', () => {
+ mockFetchMovieDetailsFromProvider.mockImplementation(
+ () => new Promise(() => {})
+ );
+
+ render();
+
+ expect(screen.getByText('Loading additional details...')).toBeInTheDocument();
+ });
+
+ it('should handle play button click', async () => {
+ render();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(screen.getByText('Test Provider')).toBeInTheDocument();
+ });
+ });
+
+ it('should handle fetch details error gracefully', async () => {
+ mockFetchMovieDetailsFromProvider.mockRejectedValue(new Error('Fetch failed'));
+
+ render();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(screen.getByText(/Technical Details:/)).toBeInTheDocument();
+ });
+ });
+
+ it('should render IMDb and TMDb badges with correct links', () => {
+ render();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie');
+ });
+ });
+ });
+});
diff --git a/frontend/src/components/modals/YouTubeTrailerModal.jsx b/frontend/src/components/modals/YouTubeTrailerModal.jsx
new file mode 100644
index 00000000..d873fea0
--- /dev/null
+++ b/frontend/src/components/modals/YouTubeTrailerModal.jsx
@@ -0,0 +1,29 @@
+import { Box, Modal } from '@mantine/core';
+import React from 'react';
+
+export const YouTubeTrailerModal = ({ opened, onClose, trailerUrl }) => {
+ return (
+
+
+ {trailerUrl && (
+
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/modals/__tests__/YouTubeTrailerModal.test.jsx b/frontend/src/components/modals/__tests__/YouTubeTrailerModal.test.jsx
new file mode 100644
index 00000000..da549949
--- /dev/null
+++ b/frontend/src/components/modals/__tests__/YouTubeTrailerModal.test.jsx
@@ -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 (
+
+ );
+ },
+ Box: ({ children, ...props }) => (
+ {children}
+ ),
+}));
+
+describe('YouTubeTrailerModal', () => {
+ const mockTrailerUrl = 'https://www.youtube.com/embed/dQw4w9WgXcQ';
+ const mockOnClose = vi.fn();
+
+ it('should not render when opened is false', () => {
+ render(
+
+ );
+
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('should render modal when opened is true', () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('should display correct modal title', () => {
+ render(
+
+ );
+
+ const modal = screen.getByTestId('modal');
+ expect(modal).toHaveAttribute('data-title', 'Trailer');
+ });
+
+ it('should render iframe with correct trailerUrl', () => {
+ render(
+
+ );
+
+ const iframe = screen.getByTitle('YouTube Trailer');
+ expect(iframe).toBeInTheDocument();
+ expect(iframe).toHaveAttribute('src', mockTrailerUrl);
+ });
+
+ it('should not render iframe when trailerUrl is null', () => {
+ render(
+
+ );
+
+ expect(screen.queryByTitle('YouTube Trailer')).not.toBeInTheDocument();
+ });
+
+ it('should call onClose when close button clicked', () => {
+ const mockClose = vi.fn();
+
+ render(
+
+ );
+
+ const closeButton = screen.getByTestId('modal-close');
+ closeButton.click();
+
+ expect(mockClose).toHaveBeenCalledOnce();
+ });
+});
diff --git a/frontend/src/utils/components/FloatingVideoUtils.js b/frontend/src/utils/components/FloatingVideoUtils.js
new file mode 100644
index 00000000..a14f511e
--- /dev/null
+++ b/frontend/src/utils/components/FloatingVideoUtils.js
@@ -0,0 +1,124 @@
+export const getLivePlayerErrorMessage = (errorType, errorDetail) => {
+ if (errorType !== 'MediaError') {
+ return errorDetail
+ ? `Error: ${errorType} - ${errorDetail}`
+ : `Error: ${errorType}`;
+ }
+
+ const errorString = errorDetail?.toLowerCase() || '';
+
+ if (
+ errorString.includes('audio') ||
+ errorString.includes('ac3') ||
+ errorString.includes('ac-3')
+ ) {
+ return 'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.';
+ }
+
+ if (
+ errorString.includes('video') ||
+ errorString.includes('h264') ||
+ errorString.includes('h.264')
+ ) {
+ return 'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.';
+ }
+
+ if (errorString.includes('mse')) {
+ return "Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility.";
+ }
+
+ return 'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.';
+};
+
+export const getVODPlayerErrorMessage = (error) => {
+ if (!error) return 'Video playback error';
+
+ switch (error.code) {
+ case error.MEDIA_ERR_ABORTED:
+ return 'Video playback was aborted';
+ case error.MEDIA_ERR_NETWORK:
+ return 'Network error while loading video';
+ case error.MEDIA_ERR_DECODE:
+ return 'Video codec not supported by your browser';
+ case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
+ return 'Video format not supported by your browser';
+ default:
+ return error.message || 'Unknown video error';
+ }
+};
+
+export const getClientCoordinates = (event) => ({
+ clientX: event.touches?.[0]?.clientX ?? event.clientX,
+ clientY: event.touches?.[0]?.clientY ?? event.clientY,
+});
+
+export const calculateNewDimensions = (
+ deltaX,
+ deltaY,
+ startWidth,
+ startHeight,
+ handle,
+ ratio
+) => {
+ const widthDelta = deltaX * handle.xDir;
+ const heightDelta = deltaY * handle.yDir;
+
+ let width = startWidth + widthDelta;
+ let height = width / ratio;
+
+ // Use vertical-driven resize if user drags mostly vertically
+ if (Math.abs(deltaY) > Math.abs(deltaX)) {
+ height = startHeight + heightDelta;
+ width = height * ratio;
+ }
+
+ return { width, height };
+};
+
+export const applyConstraints = (
+ width,
+ height,
+ ratio,
+ startPos,
+ handle,
+ minWidth,
+ minHeight,
+ visibleMargin
+) => {
+ // Apply minimum constraints
+ if (width < minWidth) {
+ width = minWidth;
+ height = width / ratio;
+ }
+ if (height < minHeight) {
+ height = minHeight;
+ width = height * ratio;
+ }
+
+ // Apply viewport constraints
+ const posX = startPos?.x ?? 0;
+ const posY = startPos?.y ?? 0;
+ const maxWidth = !handle.isLeft
+ ? Math.max(minWidth, window.innerWidth - posX - visibleMargin)
+ : null;
+ const maxHeight = !handle.isTop
+ ? Math.max(minHeight, window.innerHeight - posY - visibleMargin)
+ : null;
+
+ if (maxWidth && width > maxWidth) {
+ width = maxWidth;
+ height = width / ratio;
+ }
+ if (maxHeight && height > maxHeight) {
+ height = maxHeight;
+ width = height * ratio;
+ }
+
+ // Final adjustment to maintain aspect ratio
+ if (maxWidth && width > maxWidth) {
+ width = maxWidth;
+ height = width / ratio;
+ }
+
+ return { width, height };
+};
diff --git a/frontend/src/utils/components/NotificationCenterUtils.js b/frontend/src/utils/components/NotificationCenterUtils.js
new file mode 100644
index 00000000..a253c0d3
--- /dev/null
+++ b/frontend/src/utils/components/NotificationCenterUtils.js
@@ -0,0 +1,11 @@
+import API from '../../api.js';
+
+export const getNotifications = (showDismissed) => {
+ return API.getNotifications(showDismissed);
+};
+export const dismissNotification = (notificationId, actionTaken) => {
+ return API.dismissNotification(notificationId, actionTaken);
+};
+export const dismissAllNotifications = () => {
+ return API.dismissAllNotifications();
+};
\ No newline at end of file
diff --git a/frontend/src/utils/components/SeriesModalUtils.js b/frontend/src/utils/components/SeriesModalUtils.js
new file mode 100644
index 00000000..11ab2697
--- /dev/null
+++ b/frontend/src/utils/components/SeriesModalUtils.js
@@ -0,0 +1,166 @@
+export const imdbUrl = (imdb_id) =>
+ imdb_id ? `https://www.imdb.com/title/${imdb_id}` : '';
+
+export const tmdbUrl = (tmdb_id, type = 'movie') =>
+ tmdb_id ? `https://www.themoviedb.org/${type}/${tmdb_id}` : '';
+
+export const formatDuration = (seconds) => {
+ if (!seconds) return '';
+ const hours = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+ return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`;
+};
+
+export const formatStreamLabel = (relation) => {
+ const provider = relation.m3u_account.name;
+ const streamId = relation.stream_id;
+ const quality = extractQuality(relation);
+
+ return `${provider}${quality ?? ''}${streamId ? ` (Stream ${streamId})` : ''}`;
+};
+
+const extractQuality = (relation) => {
+ // 1. Primary: Backend quality_info field
+ const fromQualityInfo = getQualityFromBackend(relation.quality_info);
+ if (fromQualityInfo) return fromQualityInfo;
+
+ // 2. Secondary: Custom properties detailed_info
+ if (relation.custom_properties?.detailed_info) {
+ const fromDetailedInfo = getQualityFromDetailedInfo(relation.custom_properties.detailed_info);
+ if (fromDetailedInfo) return fromDetailedInfo;
+ }
+
+ // 3. Fallback: Stream name
+ if (relation.stream_name) {
+ return getQualityFromStreamName(relation.stream_name);
+ }
+
+ return '';
+};
+
+const getQualityFromBackend = (qualityInfo) => {
+ if (!qualityInfo) return '';
+
+ if (qualityInfo.quality) {
+ return ` - ${qualityInfo.quality}`;
+ } else if (qualityInfo.resolution) {
+ return ` - ${qualityInfo.resolution}`;
+ } else if (qualityInfo.bitrate) {
+ return ` - ${qualityInfo.bitrate}`;
+ }
+ return '';
+};
+
+const getQualityFromDetailedInfo = (detailedInfo) => {
+ // Check video dimensions first
+ if (detailedInfo.video?.width && detailedInfo.video?.height) {
+ return getQualityInfoFromDimensions(detailedInfo.video.width, detailedInfo.video.height);
+ }
+
+ // Check name field
+ if (detailedInfo.name) {
+ return parseQualityFromText(detailedInfo.name);
+ }
+
+ return '';
+};
+
+const getQualityFromStreamName = (streamName) => {
+ return parseQualityFromText(streamName);
+};
+
+const parseQualityFromText = (text) => {
+ if (text.includes('4K') || text.includes('2160p')) return ' - 4K';
+ if (text.includes('1080p') || text.includes('FHD')) return ' - 1080p';
+ if (text.includes('720p') || text.includes('HD')) return ' - 720p';
+ if (text.includes('480p')) return ' - 480p';
+ return '';
+};
+
+const getQualityInfoFromDimensions = (width, height) => {
+ // Prioritize width for quality detection (handles ultrawide/cinematic aspect ratios)
+ if (width >= 3840) {
+ return ' - 4K';
+ } else if (width >= 1920) {
+ return ' - 1080p';
+ } else if (width >= 1280) {
+ return ' - 720p';
+ } else if (width >= 854) {
+ return ' - 480p';
+ } else {
+ return ` - ${width}x${height}`;
+ }
+}
+
+export const sortEpisodesList = (episodesList) => {
+ return episodesList.sort((a, b) => {
+ if (a.season_number !== b.season_number) {
+ return (a.season_number || 0) - (b.season_number || 0);
+ }
+ return (a.episode_number || 0) - (b.episode_number || 0);
+ });
+};
+
+export const groupEpisodesBySeason = (seriesEpisodes) => {
+ const grouped = {};
+ seriesEpisodes.forEach((episode) => {
+ const season = episode.season_number || 1;
+ if (!grouped[season]) {
+ grouped[season] = [];
+ }
+ grouped[season].push(episode);
+ });
+ return grouped;
+};
+
+export const sortBySeasonNumber = (episodesBySeason) => {
+ return Object.keys(episodesBySeason)
+ .map(Number)
+ .sort((a, b) => a - b);
+};
+
+export const getEpisodeStreamUrl = (episode, selectedProvider, env_mode) => {
+ let streamUrl = `/proxy/vod/episode/${episode.uuid}`;
+
+ // Add selected provider as query parameter if available
+ if (selectedProvider) {
+ // Use stream_id for most specific selection, fallback to account_id
+ if (selectedProvider.stream_id) {
+ streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
+ } else {
+ streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
+ }
+ }
+
+ if (env_mode === 'dev') {
+ streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
+ } else {
+ streamUrl = `${window.location.origin}${streamUrl}`;
+ }
+ return streamUrl;
+};
+
+// Helper to get embeddable YouTube URL
+export const getYouTubeEmbedUrl = (url) => {
+ if (!url) return '';
+ // Accepts full YouTube URLs or just IDs
+ const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/);
+ const videoId = match ? match[1] : url;
+ return `https://www.youtube.com/embed/${videoId}`;
+};
+
+export const getEpisodeAirdate = (episode) => {
+ return episode.air_date
+ ? new Date(episode.air_date).toLocaleDateString()
+ : 'N/A';
+};
+
+export const getTmdbUrlLink = (displaySeries, episode) => {
+ return (
+ tmdbUrl(displaySeries.tmdb_id, 'tv') +
+ (episode.season_number && episode.episode_number
+ ? `/season/${episode.season_number}/episode/${episode.episode_number}`
+ : '')
+ );
+};
diff --git a/frontend/src/utils/components/VODModalUtils.js b/frontend/src/utils/components/VODModalUtils.js
new file mode 100644
index 00000000..111fd477
--- /dev/null
+++ b/frontend/src/utils/components/VODModalUtils.js
@@ -0,0 +1,124 @@
+const hasValidTechnicalDetails = (obj) => {
+ return obj?.bitrate || obj?.video || obj?.audio;
+};
+
+const extractFromDetailedInfo = (customProperties) => {
+ const detailedInfo = customProperties?.detailed_info;
+ if (!detailedInfo) return null;
+
+ return {
+ bitrate: detailedInfo.bitrate || null,
+ video: detailedInfo.video || null,
+ audio: detailedInfo.audio || null,
+ };
+};
+
+export const getTechnicalDetails = (selectedProvider, defaultVOD) => {
+ if (!selectedProvider) {
+ return {
+ bitrate: defaultVOD?.bitrate,
+ video: defaultVOD?.video,
+ audio: defaultVOD?.audio,
+ };
+ }
+
+ // Try movie/episode content first
+ const content = selectedProvider.movie || selectedProvider.episode;
+ if (content && hasValidTechnicalDetails(content)) {
+ return {
+ bitrate: content.bitrate,
+ video: content.video,
+ audio: content.audio,
+ };
+ }
+
+ // Try provider object directly
+ if (hasValidTechnicalDetails(selectedProvider)) {
+ return {
+ bitrate: selectedProvider.bitrate,
+ video: selectedProvider.video,
+ audio: selectedProvider.audio,
+ };
+ }
+
+ // Try custom_properties.detailed_info
+ const detailedInfo = extractFromDetailedInfo(
+ selectedProvider.custom_properties
+ );
+ if (detailedInfo && hasValidTechnicalDetails(detailedInfo)) {
+ return detailedInfo;
+ }
+
+ // Fallback to defaultVOD
+ return {
+ bitrate: defaultVOD?.bitrate,
+ video: defaultVOD?.video,
+ audio: defaultVOD?.audio,
+ };
+};
+
+export const getMovieStreamUrl = (vod, selectedProvider, env_mode) => {
+ let streamUrl = `/proxy/vod/movie/${vod.uuid}`;
+
+ // Add selected provider as query parameter if available
+ if (selectedProvider) {
+ // Use stream_id for most specific selection, fallback to account_id
+ if (selectedProvider.stream_id) {
+ streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
+ } else {
+ streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
+ }
+ }
+
+ if (env_mode === 'dev') {
+ streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
+ } else {
+ streamUrl = `${window.location.origin}${streamUrl}`;
+ }
+ return streamUrl;
+};
+
+export const formatVideoDetails = (video) => {
+ const parts = [];
+
+ const codec =
+ video.codec_long_name && video.codec_long_name !== 'unknown'
+ ? video.codec_long_name
+ : video.codec_name;
+ parts.push(codec);
+
+ if (video.profile) parts.push(`(${video.profile})`);
+ if (video.width && video.height) parts.push(`${video.width}x${video.height}`);
+ if (video.display_aspect_ratio)
+ parts.push(`Aspect Ratio: ${video.display_aspect_ratio}`);
+ if (video.bit_rate)
+ parts.push(`Bitrate: ${Math.round(Number(video.bit_rate) / 1000)} kbps`);
+ if (video.r_frame_rate) parts.push(`Frame Rate: ${video.r_frame_rate} fps`);
+ if (video.tags?.encoder) parts.push(`Encoder: ${video.tags.encoder}`);
+
+ return parts.join(', ');
+};
+
+export const formatAudioDetails = (audio) => {
+ const parts = [];
+
+ const codec =
+ audio.codec_long_name && audio.codec_long_name !== 'unknown'
+ ? audio.codec_long_name
+ : audio.codec_name;
+ parts.push(codec);
+
+ if (audio.profile) parts.push(`(${audio.profile})`);
+
+ const channels =
+ audio.channel_layout || (audio.channels ? `${audio.channels}` : null);
+ if (channels) parts.push(`Channels: ${channels}`);
+
+ if (audio.sample_rate) parts.push(`Sample Rate: ${audio.sample_rate} Hz`);
+ if (audio.bit_rate)
+ parts.push(`Bitrate: ${Math.round(Number(audio.bit_rate) / 1000)} kbps`);
+ if (audio.tags?.handler_name)
+ parts.push(`Handler: ${audio.tags.handler_name}`);
+
+ return parts.join(', ');
+};
diff --git a/frontend/src/utils/components/__tests__/FloatingVideoUtils.test.js b/frontend/src/utils/components/__tests__/FloatingVideoUtils.test.js
new file mode 100644
index 00000000..8524a310
--- /dev/null
+++ b/frontend/src/utils/components/__tests__/FloatingVideoUtils.test.js
@@ -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);
+ });
+ });
+});
diff --git a/frontend/src/utils/components/__tests__/NotificationCenterUtils.test.js b/frontend/src/utils/components/__tests__/NotificationCenterUtils.test.js
new file mode 100644
index 00000000..26628b98
--- /dev/null
+++ b/frontend/src/utils/components/__tests__/NotificationCenterUtils.test.js
@@ -0,0 +1,83 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import * as NotificationUtils from '../NotificationCenterUtils';
+import API from '../../../api';
+
+vi.mock('../../../api');
+
+describe('NotificationCenterUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('getNotifications', () => {
+ it('should call API.getNotifications with showDismissed parameter', async () => {
+ const mockNotifications = [
+ { id: 1, message: 'Test notification' }
+ ];
+ API.getNotifications.mockResolvedValue(mockNotifications);
+
+ const result = await NotificationUtils.getNotifications(false);
+
+ expect(API.getNotifications).toHaveBeenCalledWith(false);
+ expect(result).toEqual(mockNotifications);
+ });
+
+ it('should call API.getNotifications with showDismissed=true', async () => {
+ const mockNotifications = [
+ { id: 2, message: 'Dismissed notification', is_dismissed: true }
+ ];
+ API.getNotifications.mockResolvedValue(mockNotifications);
+
+ const result = await NotificationUtils.getNotifications(true);
+
+ expect(API.getNotifications).toHaveBeenCalledWith(true);
+ expect(result).toEqual(mockNotifications);
+ });
+
+ it('should handle API errors', async () => {
+ const error = new Error('API error');
+ API.getNotifications.mockRejectedValue(error);
+
+ await expect(NotificationUtils.getNotifications(false)).rejects.toThrow('API error');
+ expect(API.getNotifications).toHaveBeenCalledWith(false);
+ });
+ });
+
+ describe('dismissNotification', () => {
+ it('should call API.dismissNotification with notificationId and actionTaken', async () => {
+ API.dismissNotification.mockResolvedValue({ success: true });
+
+ const result = await NotificationUtils.dismissNotification(1, 'dismissed');
+
+ expect(API.dismissNotification).toHaveBeenCalledWith(1, 'dismissed');
+ expect(result).toEqual({ success: true });
+ });
+
+ it('should handle API errors when dismissing', async () => {
+ const error = new Error('Dismiss failed');
+ API.dismissNotification.mockRejectedValue(error);
+
+ await expect(NotificationUtils.dismissNotification(1, 'dismissed')).rejects.toThrow('Dismiss failed');
+ expect(API.dismissNotification).toHaveBeenCalledWith(1, 'dismissed');
+ });
+ });
+
+ describe('dismissAllNotifications', () => {
+ it('should call API.dismissAllNotifications', async () => {
+ API.dismissAllNotifications.mockResolvedValue({ success: true, count: 5 });
+
+ const result = await NotificationUtils.dismissAllNotifications();
+
+ expect(API.dismissAllNotifications).toHaveBeenCalled();
+ expect(result).toEqual({ success: true, count: 5 });
+ });
+
+ it('should handle API errors when dismissing all', async () => {
+ const error = new Error('Dismiss all failed');
+ API.dismissAllNotifications.mockRejectedValue(error);
+
+ await expect(NotificationUtils.dismissAllNotifications()).rejects.toThrow('Dismiss all failed');
+ expect(API.dismissAllNotifications).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/frontend/src/utils/components/__tests__/SeriesModalUtils.test.js b/frontend/src/utils/components/__tests__/SeriesModalUtils.test.js
new file mode 100644
index 00000000..d986cfcc
--- /dev/null
+++ b/frontend/src/utils/components/__tests__/SeriesModalUtils.test.js
@@ -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');
+ });
+ });
+});
diff --git a/frontend/src/utils/components/__tests__/VODModalUtils.test.js b/frontend/src/utils/components/__tests__/VODModalUtils.test.js
new file mode 100644
index 00000000..04a5edb4
--- /dev/null
+++ b/frontend/src/utils/components/__tests__/VODModalUtils.test.js
@@ -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');
+ });
+ });
+});