From a9402207fb95c2ab6ab473b0b0e7b8a1f2defae1 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:58:36 -0800 Subject: [PATCH 01/55] Added tests --- .../__tests__/ConfirmationDialog.test.jsx | 275 ++++++ .../__tests__/ErrorBoundary.test.jsx | 99 ++ .../src/components/__tests__/Field.test.jsx | 388 ++++++++ .../__tests__/FloatingVideo.test.jsx | 454 +++++++++ .../components/__tests__/GuideRow.test.jsx | 335 +++++++ .../__tests__/HourTimeline.test.jsx | 318 +++++++ .../components/__tests__/LazyLogo.test.jsx | 398 ++++++++ .../__tests__/M3URefreshNotification.test.jsx | 712 +++++++++++++++ .../__tests__/RecordingSynopsis.test.jsx | 169 ++++ .../components/__tests__/SeriesModal.test.jsx | 864 ++++++++++++++++++ .../src/components/__tests__/Sidebar.test.jsx | 455 +++++++++ .../__tests__/SystemEvents.test.jsx | 251 +++++ .../components/__tests__/VODModal.test.jsx | 439 +++++++++ .../__tests__/YouTubeTrailerModal.test.jsx | 109 +++ .../__tests__/FloatingVideoUtils.test.js | 235 +++++ .../__tests__/SeriesModalUtils.test.js | 397 ++++++++ .../__tests__/VODModalUtils.test.js | 344 +++++++ 17 files changed, 6242 insertions(+) create mode 100644 frontend/src/components/__tests__/ConfirmationDialog.test.jsx create mode 100644 frontend/src/components/__tests__/ErrorBoundary.test.jsx create mode 100644 frontend/src/components/__tests__/Field.test.jsx create mode 100644 frontend/src/components/__tests__/FloatingVideo.test.jsx create mode 100644 frontend/src/components/__tests__/GuideRow.test.jsx create mode 100644 frontend/src/components/__tests__/HourTimeline.test.jsx create mode 100644 frontend/src/components/__tests__/LazyLogo.test.jsx create mode 100644 frontend/src/components/__tests__/M3URefreshNotification.test.jsx create mode 100644 frontend/src/components/__tests__/RecordingSynopsis.test.jsx create mode 100644 frontend/src/components/__tests__/SeriesModal.test.jsx create mode 100644 frontend/src/components/__tests__/Sidebar.test.jsx create mode 100644 frontend/src/components/__tests__/SystemEvents.test.jsx create mode 100644 frontend/src/components/__tests__/VODModal.test.jsx create mode 100644 frontend/src/components/modals/__tests__/YouTubeTrailerModal.test.jsx create mode 100644 frontend/src/utils/components/__tests__/FloatingVideoUtils.test.js create mode 100644 frontend/src/utils/components/__tests__/SeriesModalUtils.test.js create mode 100644 frontend/src/utils/components/__tests__/VODModalUtils.test.js 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 ? ( +
+
{title}
+ {children} +
+ ) : 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..bab0778d --- /dev/null +++ b/frontend/src/components/__tests__/ErrorBoundary.test.jsx @@ -0,0 +1,99 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import ErrorBoundary from '../ErrorBoundary'; + +// Component that throws an error for testing +const ThrowError = ({ shouldThrow }) => { + if (shouldThrow) { + throw new Error('Test error'); + } + return
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..3f57356d --- /dev/null +++ b/frontend/src/components/__tests__/Field.test.jsx @@ -0,0 +1,388 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Field } from '../Field'; + +// Mock Mantine components +vi.mock('@mantine/core', async () => { + return { + TextInput: ({ label, description, value, onChange }) => ( +
+ + + {description &&
{description}
} +
+ ), + NumberInput: ({ label, description, value, onChange }) => ( +
+ + onChange(Number(e.target.value))} + aria-describedby={description} + /> + {description &&
{description}
} +
+ ), + Switch: ({ label, description, checked, onChange }) => ( +
+ + + {description &&
{description}
} +
+ ), + Select: ({ label, description, value, data, onChange }) => ( +
+ + + {description &&
{description}
} +
+ ), + }; +}); + +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('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..9c537d2f --- /dev/null +++ b/frontend/src/components/__tests__/FloatingVideo.test.jsx @@ -0,0 +1,454 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import FloatingVideo from '../FloatingVideo'; +import useVideoStore from '../../store/useVideoStore'; + +// Mock the video store +vi.mock('../../store/useVideoStore'); + +// Mock mpegts.js +vi.mock('mpegts.js', () => ({ + default: { + createPlayer: vi.fn(), + getFeatureList: vi.fn(), + Events: { + LOADING_COMPLETE: 'loading_complete', + METADATA_ARRIVED: 'metadata_arrived', + ERROR: 'error', + MEDIA_INFO: 'media_info', + }, + }, +})); + +// Import the mocked module after mocking +const mpegts = (await import('mpegts.js')).default; + +// Mock react-draggable +vi.mock('react-draggable', () => ({ + default: ({ children, nodeRef }) =>
{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.getByText('Test Movie')).toBeInTheDocument(); + 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.getByText('Test Movie')).toBeInTheDocument(); + + + vi.advanceTimersByTime(4000); + + waitFor(() => { + expect(screen.queryByText('Test Movie')).not.toBeInTheDocument(); + }); + + vi.useRealTimers(); + }); + + it('should show overlay on mouse enter', () => { + const { container } = render(); + const video = container.querySelector('video'); + + fireEvent.loadedData(video); + fireEvent.canPlay(video); + + const videoContainer = screen.getByText('Test Movie').closest('div'); + + fireEvent.mouseEnter(videoContainer); + + expect(screen.getByText('Test Movie')).toBeInTheDocument(); + }); + + it('should hide overlay on mouse leave', () => { + vi.useFakeTimers(); + + const { container } = render(); + const video = container.querySelector('video'); + + fireEvent.loadedData(video); + fireEvent.canPlay(video); + + const videoContainer = screen.getByText('Test Movie').closest('div'); + + fireEvent.mouseEnter(videoContainer); + fireEvent.mouseLeave(videoContainer); + + vi.advanceTimersByTime(4000); + + waitFor(() => { + expect(screen.queryByText('Test Movie')).not.toBeInTheDocument(); + }); + + vi.useRealTimers(); + }); + }); + + describe('Close functionality', () => { + beforeEach(() => { + useVideoStore.mockImplementation((selector) => { { + const state = { + isVisible: true, + streamUrl: 'http://example.com/stream.ts', + contentType: 'live', + metadata: null, + hideVideo: mockHideVideo, + }; + return selector ? selector(state) : state; + }}); + }); + + it('should call hideVideo when close button is clicked', () => { + vi.useFakeTimers(); + + render(); + + 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..3f103305 --- /dev/null +++ b/frontend/src/components/__tests__/GuideRow.test.jsx @@ -0,0 +1,335 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import GuideRow from '../GuideRow'; +import { + CHANNEL_WIDTH, + EXPANDED_PROGRAM_HEIGHT, + HOUR_WIDTH, + PROGRAM_HEIGHT, +} from '../../pages/guideUtils'; + +// Mock logo import +vi.mock('../../images/logo.png', () => ({ + default: 'mocked-logo.png', +})); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + Play: (props) =>
, +})); + +// Mock Mantine components +vi.mock('@mantine/core', async () => { + return { + Box: ({ children, ...props }) =>
{children}
, + Flex: ({ children, ...props }) =>
{children}
, + Text: ({ children, ...props }) =>
{children}
, + }; +}); + +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, + }; + + 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', () => { + render( + + ); + + expect(screen.getByTestId(`program-${mockProgram.id}`)).toBeInTheDocument(); + expect(screen.getByText('Test Program')).toBeInTheDocument(); + expect(mockData.renderProgram).toHaveBeenCalledWith( + mockProgram, + undefined, + mockChannel + ); + }); + + it('should render multiple programs', () => { + const programs = [ + mockProgram, + { ...mockProgram, id: 'program-2', title: 'Another Program' }, + ]; + const data = { + ...mockData, + programsByChannelId: new Map([[mockChannel.id, programs]]), + }; + + render(); + + expect(screen.getByText('Test Program')).toBeInTheDocument(); + expect(screen.getByText('Another Program')).toBeInTheDocument(); + expect(mockData.renderProgram).toHaveBeenCalledTimes(2); + }); + + it('should render placeholder when channel has no programs', () => { + const data = { + ...mockData, + programsByChannelId: new Map([[mockChannel.id, []]]), + }; + + render(); + + const placeholders = screen.getAllByText('No program data'); + expect(placeholders.length).toBe(Math.ceil(24 / 2)); + }); + + it('should render placeholder when programsByChannelId does not contain channel', () => { + const data = { + ...mockData, + programsByChannelId: new Map(), + }; + + render(); + + const placeholders = screen.getAllByText('No program data'); + expect(placeholders.length).toBe(Math.ceil(24 / 2)); + }); + + it('should position placeholder programs correctly', () => { + const data = { + ...mockData, + programsByChannelId: new Map([[mockChannel.id, []]]), + }; + + const { container } = render( + + ); + + 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', () => { + const data = { + ...mockData, + hoveredChannelId: mockChannel.id, + }; + + render(); + + expect(screen.getByTestId('play-icon')).toBeInTheDocument(); + }); + + it('should not show play icon when not hovering', () => { + render( + + ); + + expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument(); + }); + + it('should call setHoveredChannelId on mouse enter', () => { + render( + + ); + + const logo = screen.getByAltText('Test Channel').closest('.channel-logo'); + fireEvent.mouseEnter(logo); + + expect(mockData.setHoveredChannelId).toHaveBeenCalledWith(mockChannel.id); + }); + + it('should call setHoveredChannelId with null on mouse leave', () => { + render( + + ); + + const logo = screen.getByAltText('Test Channel').closest('.channel-logo'); + fireEvent.mouseLeave(logo); + + expect(mockData.setHoveredChannelId).toHaveBeenCalledWith(null); + }); + }); + + describe('Layout and Styling', () => { + it('should set correct channel logo width', () => { + const { container } = render( + + ); + + 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`); + }); + }); +}); 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..14be3a7f --- /dev/null +++ b/frontend/src/components/__tests__/M3URefreshNotification.test.jsx @@ -0,0 +1,712 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { BrowserRouter } from 'react-router-dom'; +import M3URefreshNotification from '../M3URefreshNotification'; +import usePlaylistsStore from '../../store/playlists'; +import useStreamsStore from '../../store/streams'; +import useChannelsStore from '../../store/channels'; +import useEPGsStore from '../../store/epgs'; +import useVODStore from '../../store/useVODStore'; +import API from '../../api'; +import { showNotification } from '../../utils/notificationUtils'; + +// Mock all stores +vi.mock('../../store/playlists', () => ({ + default: vi.fn(), +})); + +vi.mock('../../store/streams', () => ({ + default: vi.fn(), +})); + +vi.mock('../../store/channels', () => ({ + default: vi.fn(), +})); + +vi.mock('../../store/epgs', () => ({ + default: vi.fn(), +})); + +vi.mock('../../store/useVODStore', () => ({ + default: vi.fn(), +})); + +// Mock API +vi.mock('../../api', () => ({ + default: { + refreshPlaylist: vi.fn(), + requeryChannels: vi.fn(), + }, +})); + +// Mock notification utility +vi.mock('../../utils/notificationUtils', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('@mantine/core', async () => { + return { + Stack: ({ children }) =>
{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(), + fetchChannels: vi.fn(), + }; + + mockEPGsStore = { + fetchEPGData: vi.fn(), + }; + + mockVODStore = { + fetchCategories: vi.fn(), + }; + + usePlaylistsStore.mockImplementation((selector) => selector(mockPlaylistsStore)); + useStreamsStore.mockImplementation((selector) => selector(mockStreamsStore)); + useChannelsStore.mockImplementation((selector) => selector(mockChannelsStore)); + useEPGsStore.mockImplementation((selector) => selector(mockEPGsStore)); + useVODStore.mockImplementation((selector) => selector(mockVODStore)); + }); + + describe('Rendering', () => { + it('should render without crashing', () => { + const { container } = renderWithProviders(); + 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.fetchChannels).toHaveBeenCalled(); + }); + }); + }); + + describe('Group Processing Notifications', () => { + it('should show notification when processing groups starts', async () => { + mockPlaylistsStore.refreshProgress = { + 1: { + account: 1, + action: 'processing_groups', + progress: 0, + status: 'in_progress', + }, + }; + + renderWithProviders(); + + 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__/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 ( +
+ +
{children}
+
+ ); + }, + Box: ({ children, ...props }) =>
{children}
, + Button: ({ children, onClick, disabled, ...props }) => ( + + ), + Flex: ({ children, ...props }) =>
{children}
, + Group: ({ children, ...props }) =>
{children}
, + Image: ({ src, alt, ...props }) => ( + {alt} + ), + 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 }) => {children}
, + 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..341d0c32 --- /dev/null +++ b/frontend/src/components/__tests__/Sidebar.test.jsx @@ -0,0 +1,455 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import Sidebar from '../Sidebar'; +import useChannelsStore from '../../store/channels'; +import useSettingsStore from '../../store/settings'; +import useAuthStore from '../../store/auth'; +import { copyToClipboard } from '../../utils'; +import { USER_LEVELS } from '../../constants'; + +// Mock stores +vi.mock('../../store/channels'); +vi.mock('../../store/settings'); +vi.mock('../../store/auth'); +vi.mock('../../utils', () => ({ + copyToClipboard: vi.fn(), +})); + +// Mock lucide-react icons +vi.mock('lucide-react', () => ({ + ListOrdered: ({ onClick }) =>
, + Play: ({ onClick }) =>
, + Database: ({ onClick }) =>
, + LayoutGrid: ({ onClick }) =>
, + Settings: ({ onClick }) =>
, + Copy: ({ onClick }) =>
, + ChartLine: ({ onClick }) =>
, + Video: ({ onClick }) =>
, + PlugZap: ({ onClick }) =>
, + LogOut: ({ onClick }) =>
, + User: ({ onClick }) =>
, + FileImage: ({ onClick }) =>
, +})); + +// 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 }) => ( + + ), + }; +}); + +const mockChannels = { + 'channel-1': { id: 'channel-1', name: 'Channel 1' }, + 'channel-2': { id: 'channel-2', name: 'Channel 2' }, + 'channel-3': { id: 'channel-3', name: 'Channel 3' }, +}; + +const mockEnvironment = { + public_ip: '192.168.1.1', + country_code: 'US', + country_name: 'United States', +}; + +const mockVersion = { + version: '1.2.3', + timestamp: '20240115', +}; + +const mockAdminUser = { + id: 1, + username: 'admin', + first_name: 'Admin', + user_level: USER_LEVELS.ADMIN, +}; + +const mockRegularUser = { + id: 2, + username: 'user', + first_name: 'John', + user_level: USER_LEVELS.USER, +}; + +const renderSidebar = (props = {}) => { + const defaultProps = { + collapsed: false, + toggleDrawer: vi.fn(), + drawerWidth: 250, + miniDrawerWidth: 80, + }; + + return render( + + + + ); +}; + +describe('Sidebar', () => { + beforeEach(() => { + vi.clearAllMocks(); + + useChannelsStore.mockReturnValue(mockChannels); + useSettingsStore.mockImplementation((selector) => { + const state = { + environment: mockEnvironment, + version: mockVersion, + }; + return selector(state); + }); + useAuthStore.mockImplementation((selector) => { + const state = { + isAuthenticated: true, + user: mockAdminUser, + logout: vi.fn(), + }; + return selector(state); + }); + }); + + describe('Brand Section', () => { + it('should render logo and brand name when expanded', () => { + const { container } = renderSidebar(); + + expect(screen.getByText('Dispatcharr')).toBeInTheDocument(); + const logo = container.querySelectorAll('img[src="/src/images/logo.png"]'); + expect(logo).toHaveLength(1); + }); + + it('should hide brand name when collapsed', () => { + renderSidebar({ collapsed: true }); + expect(screen.queryByText('Dispatcharr')).not.toBeInTheDocument(); + }); + + it('should toggle drawer when brand is clicked', () => { + const toggleDrawer = vi.fn(); + renderSidebar({ toggleDrawer }); + + const brand = screen.getByText('Dispatcharr').closest('div'); + fireEvent.click(brand); + + expect(toggleDrawer).toHaveBeenCalledTimes(1); + }); + }); + + describe('Navigation Links - Admin User', () => { + it('should render all admin navigation items', () => { + renderSidebar(); + + expect(screen.getByText('Channels')).toBeInTheDocument(); + expect(screen.getByText('VODs')).toBeInTheDocument(); + expect(screen.getByText('M3U & EPG Manager')).toBeInTheDocument(); + expect(screen.getByText('TV Guide')).toBeInTheDocument(); + expect(screen.getByText('DVR')).toBeInTheDocument(); + expect(screen.getByText('Stats')).toBeInTheDocument(); + expect(screen.getByText('Plugins')).toBeInTheDocument(); + expect(screen.getByText('Users')).toBeInTheDocument(); + expect(screen.getByText('Logo Manager')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + it('should display channel count badge', () => { + renderSidebar(); + expect(screen.getByText('(3)')).toBeInTheDocument(); + }); + + it('should hide labels and badges when collapsed', () => { + renderSidebar({ collapsed: true }); + + expect(screen.queryByText('Channels')).not.toBeInTheDocument(); + expect(screen.queryByText('(3)')).not.toBeInTheDocument(); + }); + }); + + describe('Navigation Links - Regular User', () => { + beforeEach(() => { + useAuthStore.mockImplementation((selector) => { + const state = { + isAuthenticated: true, + user: mockRegularUser, + logout: vi.fn(), + }; + return selector(state); + }); + }); + + it('should render limited navigation items for regular user', () => { + renderSidebar(); + + expect(screen.getByText('Channels')).toBeInTheDocument(); + expect(screen.getByText('TV Guide')).toBeInTheDocument(); + expect(screen.getByText('Settings')).toBeInTheDocument(); + + expect(screen.queryByText('VODs')).not.toBeInTheDocument(); + expect(screen.queryByText('M3U & EPG Manager')).not.toBeInTheDocument(); + expect(screen.queryByText('DVR')).not.toBeInTheDocument(); + expect(screen.queryByText('Stats')).not.toBeInTheDocument(); + expect(screen.queryByText('Plugins')).not.toBeInTheDocument(); + expect(screen.queryByText('Users')).not.toBeInTheDocument(); + expect(screen.queryByText('Logo Manager')).not.toBeInTheDocument(); + }); + }); + + describe('Profile Section - Authenticated', () => { + it('should render public IP with country flag', () => { + renderSidebar(); + + const ipInput = screen.getByDisplayValue('192.168.1.1'); + expect(ipInput).toBeInTheDocument(); + + const flag = screen.getByAltText('United States'); + expect(flag).toHaveAttribute('src', 'https://flagcdn.com/16x12/us.png'); + }); + + it('should copy public IP to clipboard when copy button is clicked', async () => { + copyToClipboard.mockResolvedValue(); + renderSidebar(); + + const copyButton = screen.getByTestId('copy-icon').closest('button'); + fireEvent.click(copyButton); + + await waitFor(() => { + expect(copyToClipboard).toHaveBeenCalledWith('192.168.1.1', { + successTitle: 'Success', + successMessage: 'Public IP copied to clipboard', + }); + }); + }); + + it('should render user avatar and name', () => { + renderSidebar(); + + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + it('should fallback to username if first_name is not set', () => { + useAuthStore.mockImplementation((selector) => { + const state = { + isAuthenticated: true, + user: { ...mockAdminUser, first_name: null }, + logout: vi.fn(), + }; + return selector(state); + }); + + renderSidebar(); + expect(screen.getByText('admin')).toBeInTheDocument(); + }); + + it('should open user form when username is clicked', () => { + renderSidebar(); + + const nameButton = screen.getByText('Admin'); + fireEvent.click(nameButton); + + expect(screen.getByTestId('user-form')).toBeInTheDocument(); + expect(screen.getByText('User Form for admin')).toBeInTheDocument(); + }); + + it('should close user form when close button is clicked', () => { + renderSidebar(); + + const nameButton = screen.getByText('Admin'); + fireEvent.click(nameButton); + + expect(screen.getByTestId('user-form')).toBeInTheDocument(); + + const closeButton = screen.getByText('Close'); + fireEvent.click(closeButton); + + expect(screen.queryByTestId('user-form')).not.toBeInTheDocument(); + }); + + it('should logout when logout button is clicked', () => { + const mockLogout = vi.fn(); + useAuthStore.mockImplementation((selector) => { + const state = { + isAuthenticated: true, + user: mockAdminUser, + logout: mockLogout, + }; + return selector(state); + }); + + renderSidebar(); + + const logoutIcon = screen.getByTestId('logout-icon'); + fireEvent.click(logoutIcon); + + expect(mockLogout).toHaveBeenCalledTimes(1); + }); + + it('should hide profile details when collapsed', () => { + renderSidebar({ collapsed: true }); + + expect(screen.queryByDisplayValue('192.168.1.1')).not.toBeInTheDocument(); + expect(screen.queryByText('Admin')).not.toBeInTheDocument(); + }); + }); + + describe('Profile Section - Not Authenticated', () => { + beforeEach(() => { + useAuthStore.mockImplementation((selector) => { + const state = { + isAuthenticated: false, + user: null, + logout: vi.fn(), + }; + return selector(state); + }); + }); + + it('should not render profile section when not authenticated', () => { + renderSidebar(); + + expect(screen.queryByDisplayValue('192.168.1.1')).not.toBeInTheDocument(); + expect(screen.queryByText('Admin')).not.toBeInTheDocument(); + }); + }); + + describe('Version Display', () => { + it('should render version when expanded', () => { + renderSidebar(); + expect(screen.getByText('v1.2.3-20240115')).toBeInTheDocument(); + }); + + it('should render version without timestamp if not available', () => { + useSettingsStore.mockImplementation((selector) => { + const state = { + environment: mockEnvironment, + version: { version: '1.2.3' }, + }; + return selector(state); + }); + + renderSidebar(); + expect(screen.getByText('v1.2.3')).toBeInTheDocument(); + }); + + it('should render default version if not loaded', () => { + useSettingsStore.mockImplementation((selector) => { + const state = { + environment: mockEnvironment, + version: null, + }; + return selector(state); + }); + + renderSidebar(); + expect(screen.getByText('v0.0.0')).toBeInTheDocument(); + }); + + it('should hide version when collapsed', () => { + renderSidebar({ collapsed: true }); + expect(screen.queryByText(/v1.2.3-20240115/)).not.toBeInTheDocument(); + }); + }); + + describe('Collapsed State', () => { + it('should apply collapsed class to navigation links', () => { + renderSidebar({ collapsed: true }); + + const links = screen.getAllByRole('link'); + links.forEach(link => { + expect(link).toHaveClass('navlink-collapsed'); + }); + }); + + it('should adjust width based on collapsed state', () => { + const { rerender } = renderSidebar({ collapsed: false, drawerWidth: 250 }); + const navbar = screen.getByRole('navigation'); + + expect(navbar).toHaveStyle({ width: '250px' }); + + rerender( + + + + ); + + 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(); + }); + }); +}); 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 }) => {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/__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 ( +
+ +
{children}
+
+ ); + }, + 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/__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__/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'); + }); + }); +}); From b595e845bbcaeaa82ab96f6d2f9d198fcbb6cc87 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:59:41 -0800 Subject: [PATCH 02/55] Extracted utils --- .../utils/components/FloatingVideoUtils.js | 124 +++++++++++++ .../src/utils/components/SeriesModalUtils.js | 166 ++++++++++++++++++ .../src/utils/components/VODModalUtils.js | 124 +++++++++++++ 3 files changed, 414 insertions(+) create mode 100644 frontend/src/utils/components/FloatingVideoUtils.js create mode 100644 frontend/src/utils/components/SeriesModalUtils.js create mode 100644 frontend/src/utils/components/VODModalUtils.js 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/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(', '); +}; From 8f11ef417b52ceec566d5a3c0db2f3af6a24c2c4 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:59:59 -0800 Subject: [PATCH 03/55] Extracted YouTubeTrailerModal component --- .../components/modals/YouTubeTrailerModal.jsx | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 frontend/src/components/modals/YouTubeTrailerModal.jsx 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 && ( +