From aeb6997541ad1fe372909ce2b9474ea816b39c2f Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:12:59 -0700 Subject: [PATCH 1/2] Added tests for cards/ components --- .../cards/__tests__/PluginCard.test.jsx | 483 +++++++++ .../cards/__tests__/RecordingCard.test.jsx | 935 ++++++++++++++++++ .../cards/__tests__/SeriesCard.test.jsx | 164 +++ .../__tests__/StreamConnectionCard.test.jsx | 840 ++++++++++++++++ .../cards/__tests__/VODCard.test.jsx | 294 ++++++ .../__tests__/VodConnectionCard.test.jsx | 543 ++++++++++ 6 files changed, 3259 insertions(+) create mode 100644 frontend/src/components/cards/__tests__/PluginCard.test.jsx create mode 100644 frontend/src/components/cards/__tests__/RecordingCard.test.jsx create mode 100644 frontend/src/components/cards/__tests__/SeriesCard.test.jsx create mode 100644 frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx create mode 100644 frontend/src/components/cards/__tests__/VODCard.test.jsx create mode 100644 frontend/src/components/cards/__tests__/VodConnectionCard.test.jsx diff --git a/frontend/src/components/cards/__tests__/PluginCard.test.jsx b/frontend/src/components/cards/__tests__/PluginCard.test.jsx new file mode 100644 index 00000000..22b29c88 --- /dev/null +++ b/frontend/src/components/cards/__tests__/PluginCard.test.jsx @@ -0,0 +1,483 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import PluginCard from '../PluginCard'; +import * as notificationUtils from '../../../utils/notificationUtils'; +import * as pluginCardUtils from '../../../utils/cards/PluginCardUtils'; + +// Mock the notification utils +vi.mock('../../../utils/notificationUtils', () => ({ + showNotification: vi.fn(), +})); + +// Mock the plugin card utils +vi.mock('../../../utils/cards/PluginCardUtils', () => ({ + getConfirmationDetails: vi.fn(() => ({ + requireConfirm: false, + confirmTitle: '', + confirmMessage: '', + })), +})); + +vi.mock('../../Field', () => ({ + Field: ({ field, value, onChange }) => ( +
+ + onChange(field.id, e.target.value)} + /> +
+ ), +})); + +vi.mock('@mantine/core', async () => { + return { + ActionIcon: ({ children, ...props }) => , + Anchor: ({ children, ...props }) => {children}, + Box: ({ children, ...props }) =>
{children}
, + Avatar: ({ src, alt }) => {alt}, + Button: ({ children, ...props }) => , + Card: ({ children, ...props }) =>
{children}
, + Divider: () =>
, + Group: ({ children, ...props }) =>
{children}
, + Stack: ({ children, ...props }) =>
{children}
, + Switch: ({ checked, onChange, disabled }) => ( + + ), + Text: ({ children, ...props }) => {children}, + UnstyledButton: ({ children, ...props }) => , + Badge: ({ children, ...props }) => {children}, + }; +}); + +describe('PluginCard', () => { + const mockPlugin = { + key: 'test-plugin', + name: 'Test Plugin', + description: 'A test plugin', + version: '1.0.0', + enabled: true, + ever_enabled: true, + settings: { field1: 'value1' }, + fields: [ + { + id: 'field1', + label: 'Field 1', + type: 'text', + }, + ], + actions: [ + { + id: 'action1', + label: 'Test Action', + description: 'Test action description', + button_label: 'Run Action', + }, + ], + author: 'Test Author', + help_url: 'https://example.com/help', + logo_url: 'https://example.com/logo.png', + }; + + const defaultProps = { + plugin: mockPlugin, + onSaveSettings: vi.fn(), + onRunAction: vi.fn(), + onToggleEnabled: vi.fn(), + onRequireTrust: vi.fn(), + onRequestDelete: vi.fn(), + onRequestConfirm: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(pluginCardUtils.getConfirmationDetails).mockReturnValue({ + requireConfirm: false, + confirmTitle: '', + confirmMessage: '', + }); + }); + + describe('Rendering', () => { + it('should render plugin card with basic information', () => { + render(); + + expect(screen.getByText('Test Plugin')).toBeInTheDocument(); + expect(screen.getByText('A test plugin')).toBeInTheDocument(); + expect(screen.getByText('v1.0.0')).toBeInTheDocument(); + expect(screen.getByText('By Test Author')).toBeInTheDocument(); + }); + + it('should render plugin logo when logo_url is provided', () => { + render(); + + const logo = screen.getByAltText('Test Plugin logo'); + expect(logo).toBeInTheDocument(); + expect(logo).toHaveAttribute('src', 'https://example.com/logo.png'); + }); + + it('should render help link when help_url is provided', () => { + render(); + + const docsLink = screen.getByRole('link', { name: 'Docs' }); + expect(docsLink).toBeInTheDocument(); + expect(docsLink).toHaveAttribute('href', 'https://example.com/help'); + expect(docsLink).toHaveAttribute('target', '_blank'); + }); + + it('should render switch as checked when plugin is enabled', () => { + render(); + + const switchElement = screen.getByRole('checkbox'); + expect(switchElement).toBeChecked(); + }); + + it('should render switch as unchecked when plugin is disabled', () => { + const disabledPlugin = { ...mockPlugin, enabled: false }; + render(); + + const switchElement = screen.getByRole('checkbox'); + expect(switchElement).not.toBeChecked(); + }); + + it('should show missing plugin warning when plugin is missing', () => { + const missingPlugin = { ...mockPlugin, missing: true }; + render(); + + expect( + screen.getByText('Missing plugin files. Re-import or delete this entry.') + ).toBeInTheDocument(); + }); + + it('should show legacy plugin warning', () => { + const legacyPlugin = { ...mockPlugin, legacy: true }; + render(); + + expect( + screen.getByText('Please update or ask the developer to add plugin.json.') + ).toBeInTheDocument(); + }); + }); + + describe('Expansion/Collapse', () => { + it('should toggle expanded state when clicking chevron button', async () => { + render(); + + await waitFor(() => { + fireEvent.click(screen.getByTitle('Collapse settings')); + expect(screen.queryByText('Test Action')).not.toBeInTheDocument(); + }); + + await waitFor(() => { + fireEvent.click(screen.getByTitle('Expand settings')); + }); + + expect(await screen.findByText('Test Action')).toBeInTheDocument(); + }); + + it('should toggle expanded state when clicking plugin name', () => { + render(); + + expect(screen.getByText('Test Action')).toBeInTheDocument(); + + const nameButton = screen.getByText('Test Plugin'); + + fireEvent.click(nameButton); + expect(screen.queryByText('Test Action')).not.toBeInTheDocument(); + }); + + it('should collapse when plugin is disabled', () => { + const { rerender } = render(); + + const expandButton = screen.getAllByRole('button')[0]; + fireEvent.click(expandButton); + + const disabledPlugin = { ...mockPlugin, enabled: false }; + rerender(); + + expect(screen.queryByText('Test Action')).not.toBeInTheDocument(); + }); + }); + + describe('Enable/Disable Toggle', () => { + it('should call onToggleEnabled when switch is toggled', async () => { + defaultProps.onToggleEnabled.mockResolvedValue({ success: true }); + render(); + + const switchElement = screen.getByRole('checkbox'); + fireEvent.click(switchElement); + + await waitFor(() => { + expect(defaultProps.onToggleEnabled).toHaveBeenCalledWith('test-plugin', false); + }); + }); + + it('should require trust for first-time enable', async () => { + const firstTimePlugin = { ...mockPlugin, enabled: false, ever_enabled: false }; + defaultProps.onRequireTrust.mockResolvedValue(true); + defaultProps.onToggleEnabled.mockResolvedValue({ success: true }); + + render(); + + const switchElement = screen.getByRole('checkbox'); + fireEvent.click(switchElement); + + await waitFor(() => { + expect(defaultProps.onRequireTrust).toHaveBeenCalledWith(firstTimePlugin); + expect(defaultProps.onToggleEnabled).toHaveBeenCalledWith('test-plugin', true); + }); + }); + + it('should not enable if trust is denied', async () => { + const firstTimePlugin = { ...mockPlugin, enabled: false, ever_enabled: false }; + defaultProps.onRequireTrust.mockResolvedValue(false); + + render(); + + const switchElement = screen.getByRole('checkbox'); + fireEvent.click(switchElement); + + await waitFor(() => { + expect(defaultProps.onRequireTrust).toHaveBeenCalled(); + expect(defaultProps.onToggleEnabled).not.toHaveBeenCalled(); + }); + }); + + it('should revert state if toggle fails', async () => { + defaultProps.onToggleEnabled.mockResolvedValue({ success: false }); + render(); + + const switchElement = screen.getByRole('checkbox'); + const initialState = switchElement.checked; + + fireEvent.click(switchElement); + + await waitFor(() => { + expect(switchElement.checked).toBe(initialState); + }); + }); + + it('should be disabled when plugin is missing', () => { + const missingPlugin = { ...mockPlugin, missing: true }; + render(); + + const switchElement = screen.getByRole('checkbox'); + expect(switchElement).toBeDisabled(); + }); + }); + + describe('Settings Management', () => { + it('should save settings when save button is clicked', async () => { + defaultProps.onSaveSettings.mockResolvedValue(true); + render(); + + const saveButton = screen.getByText('Save Settings'); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(defaultProps.onSaveSettings).toHaveBeenCalledWith( + 'test-plugin', + { field1: 'value1' } + ); + expect(notificationUtils.showNotification).toHaveBeenCalledWith({ + title: 'Saved', + message: 'Test Plugin settings updated', + color: 'green', + }); + }); + }); + + it('should show error notification when save fails', async () => { + defaultProps.onSaveSettings.mockResolvedValue(false); + render(); + + const saveButton = screen.getByText('Save Settings'); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(notificationUtils.showNotification).toHaveBeenCalledWith({ + title: 'Test Plugin error', + message: 'Failed to update settings', + color: 'red', + }); + }); + }); + + it('should handle save exception', async () => { + const error = new Error('Network error'); + defaultProps.onSaveSettings.mockRejectedValue(error); + render(); + + const saveButton = screen.getByText('Save Settings'); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(notificationUtils.showNotification).toHaveBeenCalledWith({ + title: 'Test Plugin error', + message: 'Network error', + color: 'red', + }); + }); + }); + }); + + describe('Actions', () => { + it('should render action buttons', () => { + render(); + + expect(screen.getByText('Run Action')).toBeInTheDocument(); + }); + + it('should run action when button is clicked', async () => { + defaultProps.onSaveSettings.mockResolvedValue(true); + defaultProps.onRunAction.mockResolvedValue({ + success: true, + result: { message: 'Action completed' } + }); + + render(); + + const actionButton = screen.getByText('Run Action'); + fireEvent.click(actionButton); + + await waitFor(() => { + expect(defaultProps.onRunAction).toHaveBeenCalledWith('test-plugin', 'action1'); + expect(notificationUtils.showNotification).toHaveBeenCalledWith({ + title: 'Test Plugin', + message: 'Action completed', + color: 'green', + }); + }); + }); + + it('should show confirmation dialog when required', async () => { + vi.mocked(pluginCardUtils.getConfirmationDetails).mockReturnValue({ + requireConfirm: true, + confirmTitle: 'Confirm Action', + confirmMessage: 'Are you sure?', + }); + defaultProps.onRequestConfirm.mockResolvedValue(true); + defaultProps.onSaveSettings.mockResolvedValue(true); + defaultProps.onRunAction.mockResolvedValue({ success: true, result: {} }); + + render(); + + const actionButton = screen.getByText('Run Action'); + fireEvent.click(actionButton); + + await waitFor(() => { + expect(defaultProps.onRequestConfirm).toHaveBeenCalledWith( + 'Confirm Action', + 'Are you sure?' + ); + expect(defaultProps.onRunAction).toHaveBeenCalled(); + }); + }); + + it('should not run action if confirmation is denied', async () => { + vi.mocked(pluginCardUtils.getConfirmationDetails).mockReturnValue({ + requireConfirm: true, + confirmTitle: 'Confirm Action', + confirmMessage: 'Are you sure?', + }); + defaultProps.onRequestConfirm.mockResolvedValue(false); + + render(); + + const actionButton = screen.getByText('Run Action'); + fireEvent.click(actionButton); + + await waitFor(() => { + expect(defaultProps.onRequestConfirm).toHaveBeenCalled(); + expect(defaultProps.onRunAction).not.toHaveBeenCalled(); + }); + }); + + it('should show error notification when action fails', async () => { + const errorProps = { + ...defaultProps, + onSaveSettings: vi.fn().mockResolvedValue(true), + onRunAction: vi.fn().mockResolvedValue({ + success: false, + error: 'Action failed', + }), + }; + + render(); + + const actionButton = screen.getByText('Run Action'); + fireEvent.click(actionButton); + + await waitFor(() => { + expect(notificationUtils.showNotification).toHaveBeenCalledWith({ + title: 'Test Plugin error', + message: 'Action failed', + color: 'red', + }); + }); + }); + + + it('should render event triggers badges', () => { + const pluginWithEvents = { + ...mockPlugin, + actions: [ + { + id: 'action1', + label: 'Test Action', + events: ['SERIES_ADDED', 'EPISODE_DOWNLOADED'], + }, + ], + }; + + render(); + + expect(screen.getByText('Event Triggers')).toBeInTheDocument(); + }); + }); + + describe('Delete Plugin', () => { + it('should call onRequestDelete when delete button is clicked', () => { + render(); + + const deleteButton = screen.getByTitle('Delete plugin'); + fireEvent.click(deleteButton); + + expect(defaultProps.onRequestDelete).toHaveBeenCalledWith(mockPlugin); + }); + }); + + describe('Props Synchronization', () => { + it('should sync enabled state with plugin prop changes', () => { + const { rerender } = render(); + + expect(screen.getByRole('checkbox')).toBeChecked(); + + const disabledPlugin = { ...mockPlugin, enabled: false }; + rerender(); + + expect(screen.getByRole('checkbox')).not.toBeChecked(); + }); + + it('should sync settings when plugin key changes', () => { + const { rerender } = render(); + + const newPlugin = { + ...mockPlugin, + key: 'new-plugin', + settings: { field1: 'new-value' }, + }; + rerender(); + + // Settings should be updated internally + expect(screen.getByText('Save Settings')).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/cards/__tests__/RecordingCard.test.jsx b/frontend/src/components/cards/__tests__/RecordingCard.test.jsx new file mode 100644 index 00000000..9566f2b5 --- /dev/null +++ b/frontend/src/components/cards/__tests__/RecordingCard.test.jsx @@ -0,0 +1,935 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import RecordingCard from '../RecordingCard'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() })); +vi.mock('../../../store/settings.jsx', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVideoStore.jsx', () => ({ default: vi.fn() })); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + useDateTimeFormat: vi.fn(), + useTimeHelpers: vi.fn(), + isAfter: vi.fn(), + isBefore: vi.fn(), + format: vi.fn(), +})); + +vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({ + deleteRecordingById: vi.fn(), + deleteSeriesAndRule: vi.fn(), + extendRecordingById: vi.fn(), + getChannelLogoUrl: vi.fn(), + getPosterUrl: vi.fn(), + getRecordingUrl: vi.fn(), + getSeasonLabel: vi.fn(), + getSeriesInfo: vi.fn(), + getShowVideoUrl: vi.fn(), + removeRecording: vi.fn(), + runComSkip: vi.fn(), + stopRecordingById: vi.fn(), +})); + +// ── Mantine notifications ────────────────────────────────────────────────────── +vi.mock('@mantine/notifications', () => ({ + notifications: { show: vi.fn() }, +})); + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', async () => ({ + ActionIcon: ({ children, onClick, onMouseDown, color, disabled }) => ( + + ), + Badge: ({ children, color }) => ( + + {children} + + ), + Box: ({ children, style, display }) => ( +
+ {children} +
+ ), + Button: ({ children, onClick, disabled, loading, color, variant }) => ( + + ), + Card: ({ children, onClick, style }) => ( +
+ {children} +
+ ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Image: ({ src, alt, fallbackSrc }) => ( + {alt} + ), + Menu: Object.assign( + ({ children }) =>
{children}
, + { + Target: ({ children }) =>
{children}
, + Dropdown: ({ children, onClick }) => ( +
{children}
+ ), + Label: ({ children }) =>
{children}
, + Item: ({ children, onClick }) => ( + + ), + } + ), + Modal: ({ children, opened, onClose, title }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Stack: ({ children }) =>
{children}
, + Text: ({ children, size, c, fw, title, lineClamp, style }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
{children}
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + AlertTriangle: () => , + Plus: () => , + Square: () => , + SquareX: () => , +})); + +// ── RecordingSynopsis ────────────────────────────────────────────────────────── +vi.mock('../../RecordingSynopsis', () => ({ + default: ({ description, onOpen }) => ( +
+ {description} +
+ ), +})); + +// ── default logo ─────────────────────────────────────────────────────────────── +vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' })); + +// ────────────────────────────────────────────────────────────────────────────── +// Imports after mocks +// ────────────────────────────────────────────────────────────────────────────── +import useChannelsStore from '../../../store/channels.jsx'; +import useSettingsStore from '../../../store/settings.jsx'; +import useVideoStore from '../../../store/useVideoStore.jsx'; +import { useDateTimeFormat, useTimeHelpers, format, isAfter, isBefore } + from '../../../utils/dateTimeUtils.js'; +import { notifications } from '@mantine/notifications'; +import * as RecordingCardUtils from '../../../utils/cards/RecordingCardUtils.js'; +import dayjs from 'dayjs'; + +/** Build a minimal dayjs-like mock with isBefore / isAfter */ +const makeMoment = (isoString) => { + const d = dayjs(isoString); + return { + isAfter: (other) => d.isAfter(other?._d ?? other), + isBefore: (other) => d.isBefore(other?._d ?? other), + format: vi.fn((fmt) => d.format(fmt)), + _d: d.toDate(), + }; +}; + +const PAST = '2020-01-01T10:00:00Z'; +const FUTURE = '2099-01-01T10:00:00Z'; +const NOW = '2024-06-01T12:00:00Z'; + +/** Factory for a completed (past) recording */ +const makeRecording = (overrides = {}) => ({ + id: 'rec-1', + start_time: PAST, + end_time: PAST, + _group_count: 1, + custom_properties: { + status: 'completed', + program: { + title: 'Test Show', + sub_title: 'Pilot', + description: 'A test description', + }, + file_url: '/recordings/test.ts', + ...overrides.custom_properties, + }, + ...overrides, +}); + +const makeChannel = () => ({ + id: 'ch-1', + name: 'HBO', + channel_number: 501, +}); + +/** Wire up all store/utility mocks with sensible defaults */ +const setupMocks = ({ now = NOW, recording = makeRecording(), channel = makeChannel() } = {}) => { + const nowMoment = makeMoment(now); + const startMoment = makeMoment(recording.start_time); + const endMoment = makeMoment(recording.end_time); + + vi.mocked(useSettingsStore).mockImplementation((sel) => + sel({ environment: { env_mode: 'production' } }) + ); + + const mockShowVideo = vi.fn(); + vi.mocked(useVideoStore).mockImplementation((sel) => + sel({ showVideo: mockShowVideo }) + ); + + const mockFetchRecordings = vi.fn().mockResolvedValue(undefined); + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ fetchRecordings: mockFetchRecordings }) + ); + + vi.mocked(useTimeHelpers).mockReturnValue({ + toUserTime: (iso) => { + if (iso === recording.start_time) return startMoment; + if (iso === recording.end_time) return endMoment; + return makeMoment(iso); + }, + userNow: () => nowMoment, + }); + + vi.mocked(useDateTimeFormat).mockReturnValue({ + timeFormat: 'HH:mm', + dateFormat: 'MM/DD', + }); + + vi.mocked(format).mockImplementation((moment, fmt) => moment.format(fmt)); + vi.mocked(isAfter).mockImplementation((a, b) => a.isAfter(b)); + vi.mocked(isBefore).mockImplementation((a, b) => a.isBefore(b)); + + vi.mocked(RecordingCardUtils.getPosterUrl).mockReturnValue('/poster.jpg'); + vi.mocked(RecordingCardUtils.getChannelLogoUrl).mockReturnValue('/logo.png'); + vi.mocked(RecordingCardUtils.getRecordingUrl).mockReturnValue('/recordings/test.ts'); + vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue(''); + vi.mocked(RecordingCardUtils.getSeriesInfo).mockReturnValue({ seriesId: 's1' }); + vi.mocked(RecordingCardUtils.getShowVideoUrl).mockReturnValue('/live/ch-1'); + + return { mockShowVideo, mockFetchRecordings }; +}; + +describe('RecordingCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(RecordingCardUtils.stopRecordingById).mockResolvedValue(undefined); + vi.mocked(RecordingCardUtils.deleteRecordingById).mockResolvedValue(undefined); + vi.mocked(RecordingCardUtils.deleteSeriesAndRule).mockResolvedValue(undefined); + vi.mocked(RecordingCardUtils.extendRecordingById).mockResolvedValue(undefined); + vi.mocked(RecordingCardUtils.runComSkip).mockResolvedValue(undefined); + vi.mocked(RecordingCardUtils.removeRecording).mockReturnValue(undefined); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the recording title', () => { + setupMocks(); + render(); + expect(screen.getByText('Test Show')).toBeInTheDocument(); + }); + + it('renders "Custom Recording" when no program title', () => { + setupMocks({ + recording: makeRecording({ custom_properties: { status: 'completed', program: {} } }), + }); + render( + + ); + expect(screen.getByText('Custom Recording')).toBeInTheDocument(); + }); + + it('renders channel info', () => { + setupMocks(); + render(); + expect(screen.getByText('501 • HBO')).toBeInTheDocument(); + }); + + it('renders "—" when no channel provided', () => { + setupMocks({ channel: null }); + render(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('shows description via RecordingSynopsis for non-series completed recording', () => { + setupMocks(); + render(); + expect(screen.getByTestId('recording-synopsis')).toBeInTheDocument(); + expect(screen.getByText('A test description')).toBeInTheDocument(); + }); + + it('shows sub_title when present', () => { + setupMocks(); + render(); + expect(screen.getByText('Pilot')).toBeInTheDocument(); + }); + + it('shows season/episode label when getSeasonLabel returns a value', () => { + setupMocks(); + vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('S01E02'); + render(); + expect(screen.getByText('S01E02')).toBeInTheDocument(); + }); + + it('renders the poster image', () => { + setupMocks(); + render(); + const img = screen.getByAltText('Test Show'); + expect(img).toHaveAttribute('src', '/poster.jpg'); + }); + }); + + // ── Status badges ────────────────────────────────────────────────────────── + + describe('status badge', () => { + it('shows "Completed" badge for a completed recording', () => { + setupMocks(); + render(); + expect(screen.getByText('Completed')).toBeInTheDocument(); + }); + + it('shows "Recording" badge for an in-progress recording', () => { + const recording = makeRecording({ + start_time: PAST, + end_time: FUTURE, + custom_properties: { status: 'recording', program: { title: 'Live Show' } }, + }); + setupMocks({ recording }); + render(); + expect(screen.getByText('Recording')).toBeInTheDocument(); + }); + + it('shows "Scheduled" badge for an upcoming recording', () => { + const recording = makeRecording({ + start_time: FUTURE, + end_time: FUTURE, + custom_properties: { status: 'scheduled', program: { title: 'Future Show' } }, + }); + setupMocks({ recording }); + render(); + expect(screen.getByText('Scheduled')).toBeInTheDocument(); + }); + + it('shows "Interrupted" badge and alert icon for interrupted recording', () => { + const recording = makeRecording({ + start_time: PAST, + end_time: FUTURE, + custom_properties: { + status: 'interrupted', + interrupted_reason: 'Disk full', + program: { title: 'Broken Show' }, + }, + }); + setupMocks({ recording }); + render(); + expect(screen.getByText('Interrupted')).toBeInTheDocument(); + expect(screen.getByTestId('icon-alert-triangle')).toBeInTheDocument(); + }); + + it('shows interrupted_reason text when present', () => { + const recording = makeRecording({ + start_time: PAST, + end_time: FUTURE, + custom_properties: { + status: 'interrupted', + interrupted_reason: 'Disk full', + program: { title: 'Broken Show' }, + }, + }); + setupMocks({ recording }); + render(); + expect(screen.getByText('Disk full')).toBeInTheDocument(); + }); + }); + + // ── Series group ─────────────────────────────────────────────────────────── + + describe('series group', () => { + const makeSeriesRecording = () => + makeRecording({ _group_count: 3 }); + + it('shows "Series" badge when _group_count > 1', () => { + const recording = makeSeriesRecording(); + setupMocks({ recording }); + render(); + expect(screen.getByText('Series')).toBeInTheDocument(); + }); + + it('shows "Next of N" text for series group', () => { + const recording = makeSeriesRecording(); + setupMocks({ recording }); + render(); + expect(screen.getByText('Next of 3')).toBeInTheDocument(); + }); + + it('does not show description for series group', () => { + const recording = makeSeriesRecording(); + setupMocks({ recording }); + render(); + expect(screen.queryByTestId('recording-synopsis')).not.toBeInTheDocument(); + }); + }); + + // ── Recurring rule ───────────────────────────────────────────────────────── + + describe('recurring rule', () => { + const makeRecurring = () => + makeRecording({ + custom_properties: { + status: 'scheduled', + rule: { type: 'recurring' }, + program: { title: 'Recurring Show' }, + }, + start_time: FUTURE, + end_time: FUTURE, + }); + + it('shows "Recurring" badge', () => { + const recording = makeRecurring(); + setupMocks({ recording }); + render(); + expect(screen.getByText('Recurring')).toBeInTheDocument(); + }); + + it('calls onOpenRecurring on card click', () => { + const recording = makeRecurring(); + setupMocks({ recording }); + const onOpenRecurring = vi.fn(); + render( + + ); + fireEvent.click(screen.getByTestId('recording-card')); + expect(onOpenRecurring).toHaveBeenCalledWith(recording, false); + }); + + it('calls onOpenRecurring(recording, true) on delete click for recurring rule', () => { + const recording = makeRecurring(); + setupMocks({ recording }); + const onOpenRecurring = vi.fn(); + render( + + ); + // The delete ActionIcon is the last action-icon rendered + const actionIcons = screen.getAllByTestId('action-icon'); + fireEvent.click(actionIcons[actionIcons.length - 1]); + expect(onOpenRecurring).toHaveBeenCalledWith(recording, true); + }); + }); + + // ── Card click ───────────────────────────────────────────────────────────── + + describe('card click', () => { + it('calls onOpenDetails when card is clicked for a normal recording', () => { + setupMocks(); + const onOpenDetails = vi.fn(); + render( + + ); + fireEvent.click(screen.getByTestId('recording-card')); + expect(onOpenDetails).toHaveBeenCalledWith(makeRecording()); + }); + + it('calls onOpenDetails from RecordingSynopsis onOpen', () => { + setupMocks(); + const onOpenDetails = vi.fn(); + render( + + ); + fireEvent.click(screen.getByTestId('recording-synopsis')); + expect(onOpenDetails).toHaveBeenCalledWith(makeRecording()); + }); + }); + + // ── Watch actions ────────────────────────────────────────────────────────── + + describe('watch actions', () => { + it('shows "Watch Live" button during in-progress recording', () => { + const recording = makeRecording({ + start_time: PAST, + end_time: FUTURE, + custom_properties: { status: 'recording', program: { title: 'Live' } }, + }); + setupMocks({ recording }); + render(); + expect(screen.getByText('Watch Live')).toBeInTheDocument(); + }); + + it('does not show "Watch Live" for a completed recording', () => { + setupMocks(); + render(); + expect(screen.queryByText('Watch Live')).not.toBeInTheDocument(); + }); + + it('calls showVideo with live params when Watch Live is clicked', () => { + const recording = makeRecording({ + start_time: PAST, + end_time: FUTURE, + custom_properties: { status: 'recording', program: { title: 'Live' } }, + }); + const { mockShowVideo } = setupMocks({ recording }); + const channel = makeChannel(); + render(); + fireEvent.click(screen.getByText('Watch Live')); + expect(mockShowVideo).toHaveBeenCalledWith( + '/live/ch-1', + 'live', + expect.objectContaining({ name: channel.name }) + ); + }); + + it('shows "Watch" button for a completed recording', () => { + setupMocks(); + render(); + expect(screen.getByText('Watch')).toBeInTheDocument(); + }); + + it('does not show "Watch" for an upcoming recording', () => { + const recording = makeRecording({ + start_time: FUTURE, + end_time: FUTURE, + custom_properties: { status: 'scheduled', program: { title: 'Future' } }, + }); + setupMocks({ recording }); + render(); + expect(screen.queryByText('Watch')).not.toBeInTheDocument(); + }); + + it('calls showVideo with vod params when Watch is clicked', () => { + const { mockShowVideo } = setupMocks(); + render(); + fireEvent.click(screen.getByText('Watch')); + expect(mockShowVideo).toHaveBeenCalledWith( + '/recordings/test.ts', + 'vod', + expect.objectContaining({ name: 'Test Show' }) + ); + }); + + it('does not call showVideo when Watch is clicked but no file url', () => { + const { mockShowVideo } = setupMocks(); + vi.mocked(RecordingCardUtils.getRecordingUrl).mockReturnValue(null); + render(); + fireEvent.click(screen.getByText('Watch')); + expect(mockShowVideo).not.toHaveBeenCalled(); + }); + + it('Watch Live does nothing when channel is null', () => { + const recording = makeRecording({ + start_time: PAST, + end_time: FUTURE, + custom_properties: { status: 'recording', program: { title: 'Live' } }, + }); + const { mockShowVideo } = setupMocks({ recording, channel: null }); + render(); + fireEvent.click(screen.getByText('Watch Live')); + expect(mockShowVideo).not.toHaveBeenCalled(); + }); + }); + + // ── Remove commercials ───────────────────────────────────────────────────── + + describe('"Remove commercials" button', () => { + it('shows "Remove commercials" for a completed recording without comskip', () => { + setupMocks(); + render(); + expect(screen.getByText('Remove commercials')).toBeInTheDocument(); + }); + + it('does not show "Remove commercials" when comskip is completed', () => { + const recording = makeRecording({ + custom_properties: { + status: 'completed', + comskip: { status: 'completed' }, + program: { title: 'Test Show' }, + file_url: '/test.ts', + }, + }); + setupMocks({ recording }); + render(); + expect(screen.queryByText('Remove commercials')).not.toBeInTheDocument(); + }); + + it('does not show "Remove commercials" for upcoming recording', () => { + const recording = makeRecording({ + start_time: FUTURE, + end_time: FUTURE, + custom_properties: { status: 'scheduled', program: { title: 'Future' } }, + }); + setupMocks({ recording }); + render(); + expect(screen.queryByText('Remove commercials')).not.toBeInTheDocument(); + }); + + it('calls runComSkip and shows notification on success', async () => { + setupMocks(); + render(); + fireEvent.click(screen.getByText('Remove commercials')); + await waitFor(() => { + expect(RecordingCardUtils.runComSkip).toHaveBeenCalledWith(makeRecording()); + expect(notifications.show).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Removing commercials', color: 'blue.5' }) + ); + }); + }); + + it('does not show notification when runComSkip throws', async () => { + vi.mocked(RecordingCardUtils.runComSkip).mockRejectedValue(new Error('fail')); + setupMocks(); + render(); + fireEvent.click(screen.getByText('Remove commercials')); + await waitFor(() => { + expect(notifications.show).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Extend recording ─────────────────────────────────────────────────────── + + describe('extend recording', () => { + const makeInProgress = () => + makeRecording({ + start_time: PAST, + end_time: FUTURE, + custom_properties: { status: 'recording', program: { title: 'Live Show' }, file_url: '/f.ts' }, + }); + + it('shows extend menu for in-progress recording', () => { + const recording = makeInProgress(); + setupMocks({ recording }); + render(); + expect(screen.getByTestId('icon-plus')).toBeInTheDocument(); + }); + + it('calls extendRecordingById with 15 and shows notification', async () => { + const recording = makeInProgress(); + setupMocks({ recording }); + render(); + fireEvent.click(screen.getByText('+15 minutes')); + await waitFor(() => { + expect(RecordingCardUtils.extendRecordingById).toHaveBeenCalledWith('rec-1', 15); + expect(notifications.show).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Recording extended', color: 'teal' }) + ); + }); + }); + + it('calls extendRecordingById with 30', async () => { + const recording = makeInProgress(); + setupMocks({ recording }); + render(); + fireEvent.click(screen.getByText('+30 minutes')); + await waitFor(() => { + expect(RecordingCardUtils.extendRecordingById).toHaveBeenCalledWith('rec-1', 30); + }); + }); + + it('calls extendRecordingById with 60', async () => { + const recording = makeInProgress(); + setupMocks({ recording }); + render(); + fireEvent.click(screen.getByText('+1 hour')); + await waitFor(() => { + expect(RecordingCardUtils.extendRecordingById).toHaveBeenCalledWith('rec-1', 60); + }); + }); + + it('shows error notification when extendRecordingById throws', async () => { + vi.mocked(RecordingCardUtils.extendRecordingById).mockRejectedValue(new Error('Network error')); + const recording = makeInProgress(); + setupMocks({ recording }); + render(); + fireEvent.click(screen.getByText('+15 minutes')); + await waitFor(() => { + expect(notifications.show).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Extension failed', color: 'red' }) + ); + }); + }); + }); + + // ── Stop recording ───────────────────────────────────────────────────────── + + describe('stop recording', () => { + const makeInProgress = () => + makeRecording({ + start_time: PAST, + end_time: FUTURE, + custom_properties: { status: 'recording', program: { title: 'Live Show' }, file_url: '/f.ts' }, + }); + + it('shows stop modal when stop button is clicked', () => { + const recording = makeInProgress(); + setupMocks({ recording }); + render(); + + // Stop ActionIcon has the Square icon + const stopButton = screen.getByTestId('icon-square').closest('button'); + fireEvent.click(stopButton); + + expect(screen.getByTestId('modal')).toBeInTheDocument(); + expect(screen.getByTestId('modal-title')).toHaveTextContent('Stop Recording'); + }); + + it('closes stop modal when Go Back is clicked', () => { + const recording = makeInProgress(); + setupMocks({ recording }); + render(); + + const stopButton = screen.getByTestId('icon-square').closest('button'); + fireEvent.click(stopButton); + fireEvent.click(screen.getByText('Go Back')); + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + + it('calls stopRecordingById and fetchRecordings when Stop Recording is confirmed', async () => { + const recording = makeInProgress(); + const { mockFetchRecordings } = setupMocks({ recording }); + render(); + + const stopButton = screen.getByTestId('icon-square').closest('button'); + fireEvent.click(stopButton); + fireEvent.click(screen.getAllByText('Stop Recording')[1]); + + await waitFor(() => { + expect(RecordingCardUtils.stopRecordingById).toHaveBeenCalledWith('rec-1'); + expect(mockFetchRecordings).toHaveBeenCalled(); + }); + }); + + it('closes stop modal after confirming stop', async () => { + const recording = makeInProgress(); + setupMocks({ recording }); + render(); + + const stopButton = screen.getByTestId('icon-square').closest('button'); + fireEvent.click(stopButton); + fireEvent.click(screen.getAllByText('Stop Recording')[1]); + + await waitFor(() => { + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + }); + + // ── Delete recording ─────────────────────────────────────────────────────── + + describe('delete recording', () => { + it('shows delete modal for a completed non-series recording', () => { + setupMocks(); + render(); + + const deleteButton = screen.getByTestId('icon-square-x').closest('button'); + fireEvent.click(deleteButton); + + expect(screen.getByTestId('modal')).toBeInTheDocument(); + expect(screen.getByTestId('modal-title')).toHaveTextContent('Delete Recording'); + }); + + it('shows "Cancel Recording" title for upcoming recording delete', () => { + const recording = makeRecording({ + start_time: FUTURE, + end_time: FUTURE, + custom_properties: { status: 'scheduled', program: { title: 'Future Show' } }, + }); + setupMocks({ recording }); + render(); + + const deleteButton = screen.getByTestId('icon-square-x').closest('button'); + fireEvent.click(deleteButton); + + expect(screen.getByTestId('modal-title')).toHaveTextContent('Cancel Recording'); + }); + + it('calls removeRecording when delete is confirmed', async () => { + setupMocks(); + render(); + + const deleteButton = screen.getByTestId('icon-square-x').closest('button'); + fireEvent.click(deleteButton); + fireEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(RecordingCardUtils.removeRecording).toHaveBeenCalledWith('rec-1'); + }); + }); + + it('closes delete modal after confirming', async () => { + setupMocks(); + render(); + + const deleteButton = screen.getByTestId('icon-square-x').closest('button'); + fireEvent.click(deleteButton); + fireEvent.click(screen.getByText('Delete')); + + await waitFor(() => { + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + it('closes delete modal on Go Back click', () => { + setupMocks(); + render(); + + const deleteButton = screen.getByTestId('icon-square-x').closest('button'); + fireEvent.click(deleteButton); + fireEvent.click(screen.getByText('Go Back')); + + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + // ── Series cancel modal ──────────────────────────────────────────────────── + + describe('series cancel modal', () => { + const makeSeriesRecording = () => + makeRecording({ + _group_count: 3, + custom_properties: { + status: 'scheduled', + program: { title: 'Series Show' }, + }, + start_time: FUTURE, + end_time: FUTURE, + }); + + it('opens Cancel Series modal for a series group delete click', () => { + const recording = makeSeriesRecording(); + setupMocks({ recording }); + render(); + + const deleteButton = screen.getByTestId('icon-square-x').closest('button'); + fireEvent.click(deleteButton); + + expect(screen.getByTestId('modal-title')).toHaveTextContent('Cancel Series'); + }); + + it('calls deleteRecordingById when "Only this upcoming" is clicked', async () => { + const recording = makeSeriesRecording(); + const { mockFetchRecordings } = setupMocks({ recording }); + render(); + + const deleteButton = screen.getByTestId('icon-square-x').closest('button'); + fireEvent.click(deleteButton); + fireEvent.click(screen.getByText('Only this upcoming')); + + await waitFor(() => { + expect(RecordingCardUtils.deleteRecordingById).toHaveBeenCalledWith('rec-1'); + expect(mockFetchRecordings).toHaveBeenCalled(); + }); + }); + + it('calls deleteSeriesAndRule when "Entire series + rule" is clicked', async () => { + const recording = makeSeriesRecording(); + const { mockFetchRecordings } = setupMocks({ recording }); + render(); + + const deleteButton = screen.getByTestId('icon-square-x').closest('button'); + fireEvent.click(deleteButton); + fireEvent.click(screen.getByText('Entire series + rule')); + + await waitFor(() => { + expect(RecordingCardUtils.deleteSeriesAndRule).toHaveBeenCalled(); + expect(mockFetchRecordings).toHaveBeenCalled(); + }); + }); + + it('closes Cancel Series modal after removing upcoming only', async () => { + const recording = makeSeriesRecording(); + setupMocks({ recording }); + render(); + + const deleteButton = screen.getByTestId('icon-square-x').closest('button'); + fireEvent.click(deleteButton); + fireEvent.click(screen.getByText('Only this upcoming')); + + await waitFor(() => { + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + }); + + describe('error resilience', () => { + it('does not throw when fetchRecordings rejects after stop', async () => { + const recording = makeRecording({ + start_time: PAST, + end_time: FUTURE, + custom_properties: { status: 'recording', program: { title: 'Live' } }, + }); + const { mockFetchRecordings } = setupMocks({ recording }); + mockFetchRecordings.mockRejectedValue(new Error('network')); + + render(); + + const stopButton = screen.getByTestId('icon-square').closest('button'); + fireEvent.click(stopButton); + + await expect( + waitFor(() => fireEvent.click(screen.getAllByText('Stop Recording')[1])) + ).resolves.not.toThrow(); + }); + + it('does not throw when fetchRecordings rejects after deleteRecordingById', async () => { + const recording = makeRecording({ + _group_count: 3, + start_time: FUTURE, + end_time: FUTURE, + custom_properties: { status: 'scheduled', program: { title: 'Series' } }, + }); + const { mockFetchRecordings } = setupMocks({ recording }); + mockFetchRecordings.mockRejectedValue(new Error('network')); + + render(); + const deleteButton = screen.getByTestId('icon-square-x').closest('button'); + fireEvent.click(deleteButton); + + await expect( + waitFor(() => fireEvent.click(screen.getByText('Only this upcoming'))) + ).resolves.not.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/cards/__tests__/SeriesCard.test.jsx b/frontend/src/components/cards/__tests__/SeriesCard.test.jsx new file mode 100644 index 00000000..fb95c882 --- /dev/null +++ b/frontend/src/components/cards/__tests__/SeriesCard.test.jsx @@ -0,0 +1,164 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import SeriesCard from '../SeriesCard'; + +// ── Mantine core ─────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', async () => ({ + Badge: ({ children, color, variant }) => ( + + {children} + + ), + Box: ({ children, pos, h, style }) => ( +
+ {children} +
+ ), + Card: ({ children, onClick, style, withBorder, shadow, padding, radius }) => ( +
+ {children} +
+ ), + CardSection: ({ children }) => ( +
{children}
+ ), + Group: ({ children, gap, justify }) => ( +
{children}
+ ), + Image: ({ src, alt, fallbackSrc, height, fit }) => ( + {alt} + ), + Stack: ({ children, gap }) =>
{children}
, + Text: ({ children, size, fw, c, lineClamp, style }) => ( + + {children} + + ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Calendar: () => , + Play: () => , + Star: () => , +})); + +const makeSeries = (overrides = {}) => ({ + id: 'series-1', + name: 'Breaking Bad', + logo: { url: '/posters/breaking-bad.jpg' }, + year: 2008, + rating: 9.5, + genre: 'Drama', + ...overrides, +}); + +describe('SeriesCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── Rendering ────────────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the series card', () => { + render(); + expect(screen.getByTestId('series-card')).toBeInTheDocument(); + }); + + it('renders the series title', () => { + render(); + expect(screen.getByText('Breaking Bad')).toBeInTheDocument(); + }); + + it('renders the poster image with correct src', () => { + render(); + const img = screen.getByAltText('Breaking Bad'); + expect(img).toHaveAttribute('src', '/posters/breaking-bad.jpg'); + }); + + it('renders a fallback image when poster_url is missing', () => { + render(); + const img = screen.getByRole('img'); + expect(img).toBeInTheDocument(); + }); + + it('renders the year when provided', () => { + render(); + expect(screen.getByText('2008')).toBeInTheDocument(); + }); + + it('renders the genre when provided', () => { + render(); + expect(screen.getByText('Drama')).toBeInTheDocument(); + }); + + it('renders the rating when provided', () => { + render(); + expect(screen.getByText('9.5')).toBeInTheDocument(); + }); + + it('renders calendar icon', () => { + render(); + expect(screen.getByTestId('icon-calendar')).toBeInTheDocument(); + }); + + it('renders play icon', () => { + //this only renders when logo.url is missing, but we want to test that the icon itself renders correctly + render(); + expect(screen.getByTestId('icon-play')).toBeInTheDocument(); + }); + + it('renders star icon', () => { + render(); + expect(screen.getByTestId('icon-star')).toBeInTheDocument(); + }); + }); + + // ── Missing/optional fields ──────────────────────────────────────────────── + + describe('optional fields', () => { + it('does not crash when year is missing', () => { + render(); + expect(screen.getByTestId('series-card')).toBeInTheDocument(); + }); + + it('does not crash when rating is missing', () => { + render(); + expect(screen.getByTestId('series-card')).toBeInTheDocument(); + }); + + it('does not crash when genre is missing', () => { + render(); + expect(screen.getByTestId('series-card')).toBeInTheDocument(); + }); + + it('does not crash when description is missing', () => { + render(); + expect(screen.getByTestId('series-card')).toBeInTheDocument(); + }); + + it('does not crash when seasons is missing', () => { + render(); + expect(screen.getByTestId('series-card')).toBeInTheDocument(); + }); + + it('renders without onClick prop', () => { + render(); + expect(screen.getByTestId('series-card')).toBeInTheDocument(); + }); + }); + + // ── Click behavior ───────────────────────────────────────────────────────── + + describe('click behavior', () => { + it('calls onClick when card is clicked', () => { + const onClick = vi.fn(); + const series = makeSeries(); + render(); + fireEvent.click(screen.getByTestId('series-card')); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledWith(series); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx b/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx new file mode 100644 index 00000000..babcf1e6 --- /dev/null +++ b/frontend/src/components/cards/__tests__/StreamConnectionCard.test.jsx @@ -0,0 +1,840 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── react-router-dom ────────────────────────────────────────────────────────── +vi.mock('react-router-dom', () => ({ + useLocation: vi.fn(), +})); + +// ── Zustand stores ──────────────────────────────────────────────────────────── +vi.mock('../../../store/playlists.jsx', () => ({ + default: vi.fn(), +})); +vi.mock('../../../store/settings.jsx', () => ({ + default: vi.fn(), +})); +vi.mock('../../../store/useVideoStore', () => ({ + default: vi.fn(), +})); + +// ── dateTimeUtils ───────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + toFriendlyDuration: vi.fn(() => '1h 23m'), + useDateTimeFormat: vi.fn(() => ({ fullDateTimeFormat: 'MM/DD/YYYY h:mm A' })), +})); + +// ── networkUtils ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/networkUtils.js', () => ({ + formatBytes: vi.fn((n) => `${n} B`), + formatSpeed: vi.fn((n) => `${n} Kbps`), +})); + +// ── notificationUtils ───────────────────────────────────────────────────────── +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +// ── StreamConnectionCardUtils ───────────────────────────────────────────────── +vi.mock('../../../utils/cards/StreamConnectionCardUtils.js', () => ({ + connectedAccessor: vi.fn(() => () => '01/01/2024 10:00 AM'), + durationAccessor: vi.fn(() => () => '5m 30s'), + getBufferingSpeedThreshold: vi.fn(() => 0.9), + getChannelStreams: vi.fn(() => Promise.resolve([])), + getLogoUrl: vi.fn(() => null), + getM3uAccountsMap: vi.fn(() => ({})), + getMatchingStreamByUrl: vi.fn(() => null), + getSelectedStream: vi.fn(() => null), + getStartDate: vi.fn(() => 'Jan 1 2024 10:00 AM'), + getStreamOptions: vi.fn(() => []), + getStreamsByIds: vi.fn(() => Promise.resolve([])), + switchStream: vi.fn(() => Promise.resolve({})), +})); + +// ── CustomTable / useTable ──────────────────────────────────────────────────── +vi.mock('../../../components/tables/CustomTable/index.jsx', () => ({ + CustomTable: () =>
, + useTable: vi.fn(() => ({ table: {} })), +})); + +vi.mock('../../../helpers/index.jsx', () => ({ + TableHelper: { + defaultProperties: {}, + }, +})); + +// ── logo image ──────────────────────────────────────────────────────────────── +vi.mock('../../../images/logo.png', () => ({ default: 'logo.png' })); + +// ── Mantine core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, color, disabled }) => ( + + ), + Badge: ({ children, color, variant }) => ( + + {children} + + ), + Box: ({ children, style, pos }) => ( +
+ {children} +
+ ), + Card: ({ children, style }) => ( +
+ {children} +
+ ), + Center: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Progress: ({ value, size, color }) => ( +
+ ), + Select: ({ value, onChange, label, data, disabled, placeholder }) => ( + + ), + Stack: ({ children }) =>
{children}
, + Text: ({ children, size, c, fw, style }) => ( + + {children} + + ), + Tooltip: ({ children, label }) => ( +
{children}
+ ), + useMantineTheme: vi.fn(() => ({ + tailwind: { green: { 5: '#22c55e' } }, + })), +})); + +// ── lucide-react ────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ChevronDown: () => , + ChevronRight: () => , + CirclePlay: () => , + Gauge: () => , + HardDriveDownload: () => , + HardDriveUpload: () => , + Radio: () => , + SquareX: () => , + Timer: () => , + Users: () => , + Video: () => , +})); + +// ── Imports after mocks ─────────────────────────────────────────────────────── +import { useLocation } from 'react-router-dom'; +import usePlaylistsStore from '../../../store/playlists.jsx'; +import useSettingsStore from '../../../store/settings.jsx'; +import useVideoStore from '../../../store/useVideoStore'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import { + getChannelStreams, + getMatchingStreamByUrl, + getSelectedStream, + getStreamsByIds, + switchStream, +} from '../../../utils/cards/StreamConnectionCardUtils.js'; +import StreamConnectionCard from '../StreamConnectionCard'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const makeChannel = (overrides = {}) => ({ + channel_id: 'ch-uuid-1', + name: 'Test Channel', + url: 'http://stream.example.com/ch1', + stream_id: 42, + logo_id: null, + uptime: 4980, + bitrates: [3500, 3600, 3700], + total_bytes: 1048576, + client_count: 2, + avg_bitrate: '3.6 Mbps', + stream_profile: { name: 'Default Profile' }, + m3u_profile: { name: 'Main M3U' }, + resolution: '1920x1080', + source_fps: 30, + video_codec: 'h264', + audio_codec: 'aac', + audio_channels: '2.0', + stream_type: 'hls', + ffmpeg_speed: '1.05', + ...overrides, +}); + +const makeClients = (channelId = 'ch-uuid-1') => [ + { + client_id: 'client-1', + channel: { channel_id: channelId, uuid: 'ch-uuid-1' }, + ip_address: '192.168.1.10', + connected_since: 330, + connection_duration: 330, + user_agent: 'VLC/3.0', + streams: [{ id: 1 }], + }, +]; + +const makeCurrentProgram = (overrides = {}) => ({ + title: 'Evening News', + description: 'Daily news broadcast.', + start_time: new Date(Date.now() - 10 * 60 * 1000).toISOString(), + end_time: new Date(Date.now() + 50 * 60 * 1000).toISOString(), + ...overrides, +}); + +const defaultProps = (overrides = {}) => ({ + channel: makeChannel(), + clients: makeClients(), + stopClient: vi.fn(), + stopChannel: vi.fn(), + logos: {}, + channelsByUUID: { 'ch-uuid-1': 1 }, + channels: { + 1: { id: 1, uuid: 'ch-uuid-1', name: 'Test Channel' }, + }, + currentProgram: null, + ...overrides, +}); + +const setupLocation = (pathname = '/stats') => { + vi.mocked(useLocation).mockReturnValue({ + pathname, + search: '', + hash: '', + state: null, + }); +}; + +const setupStores = () => { + vi.mocked(usePlaylistsStore).mockImplementation((selector) => + selector({ playlists: [] }) + ); + vi.mocked(useSettingsStore).mockImplementation((selector) => + selector({ + settings: { proxy_settings: {} }, + environment: { env_mode: 'production' }, + }) + ); + vi.mocked(useVideoStore).mockImplementation((selector) => + selector({ showVideo: vi.fn() }) + ); +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('StreamConnectionCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupLocation('/stats'); + setupStores(); + }); + + // ── Route guard ──────────────────────────────────────────────────────────── + + describe('route guard', () => { + it('renders nothing when pathname is not /stats', () => { + setupLocation('/dashboard'); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders nothing when pathname is /channels', () => { + setupLocation('/channels'); + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it('renders the card when pathname is /stats', () => { + render(); + expect(screen.getByTestId('stream-connection-card')).toBeInTheDocument(); + }); + + it('returns null when channel is missing channel_id', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + }); + + // ── Basic rendering ──────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the channel name', () => { + render(); + expect(screen.getByText('Test Channel')).toBeInTheDocument(); + }); + + it('renders the stream profile name', () => { + render(); + expect(screen.getByText('Default Profile')).toBeInTheDocument(); + }); + + it('renders the M3U profile name', () => { + render(); + expect(screen.getByText('Main M3U')).toBeInTheDocument(); + }); + + it('renders the uptime via toFriendlyDuration', () => { + render(); + expect(screen.getByText('1h 23m')).toBeInTheDocument(); + }); + + it('renders the average bitrate', () => { + render(); + expect(screen.getByText('Avg: 3.6 Mbps')).toBeInTheDocument(); + }); + + it('renders the client count', () => { + render(); + expect(screen.getByText('2')).toBeInTheDocument(); + }); + + it('renders the custom table for clients', () => { + render(); + expect(screen.getByTestId('custom-table')).toBeInTheDocument(); + }); + + it('renders the fallback logo when logo_id is null', () => { + render(); + const img = screen.getByAltText('channel logo'); + expect(img).toBeInTheDocument(); + }); + + it('falls back to "Unnamed Channel" when channel has no name and no previewed stream', () => { + vi.mocked(getStreamsByIds).mockResolvedValue([]); + render( + + ); + expect(screen.getByText('Unnamed Channel')).toBeInTheDocument(); + }); + + it('renders "Unknown Profile" when stream_profile is absent', () => { + render( + + ); + expect(screen.getByText('Unknown Profile')).toBeInTheDocument(); + }); + + it('renders "Unknown M3U Profile" when m3u_profile is absent', () => { + render( + + ); + expect(screen.getByText('Unknown M3U Profile')).toBeInTheDocument(); + }); + }); + + // ── Stream information badges ────────────────────────────────────────────── + + describe('stream info badges', () => { + it('renders resolution badge', () => { + render(); + expect(screen.getByText('1920x1080')).toBeInTheDocument(); + }); + + it('renders FPS badge', () => { + render(); + expect(screen.getByText('30 FPS')).toBeInTheDocument(); + }); + + it('renders video codec badge in uppercase', () => { + render(); + expect(screen.getByText('H264')).toBeInTheDocument(); + }); + + it('renders audio codec badge in uppercase', () => { + render(); + expect(screen.getByText('AAC')).toBeInTheDocument(); + }); + + it('renders audio channels badge', () => { + render(); + expect(screen.getByText('2.0')).toBeInTheDocument(); + }); + + it('renders stream type badge in uppercase', () => { + render(); + expect(screen.getByText('HLS')).toBeInTheDocument(); + }); + + it('does not render resolution badge when resolution is absent', () => { + render( + + ); + expect(screen.queryByText('1920x1080')).not.toBeInTheDocument(); + }); + + it('renders ffmpeg_speed badge with green color when above threshold', () => { + render( + + ); + const badges = screen.getAllByTestId('badge'); + const speedBadge = badges.find((b) => b.textContent === '1.10x'); + expect(speedBadge).toHaveAttribute('data-color', 'green'); + }); + + it('renders ffmpeg_speed badge with red color when below threshold', () => { + render( + + ); + const badges = screen.getAllByTestId('badge'); + const speedBadge = badges.find((b) => b.textContent === '0.50x'); + expect(speedBadge).toHaveAttribute('data-color', 'red'); + }); + }); + + // ── Stop channel ─────────────────────────────────────────────────────────── + + describe('stop channel', () => { + it('calls stopChannel with channel_id when stop button is clicked', () => { + const stopChannel = vi.fn(); + render(); + const stopBtns = screen + .getAllByTestId('action-icon') + .filter((btn) => btn.getAttribute('data-color') === 'red.9'); + fireEvent.click(stopBtns[0]); + expect(stopChannel).toHaveBeenCalledWith('ch-uuid-1'); + }); + }); + + // ── Stop client ──────────────────────────────────────────────────────────── + + describe('stop client', () => { + it('calls stopClient with uuid and client_id when disconnect is clicked', () => { + const stopClient = vi.fn(); + render(); + // The disconnect button is rendered inside renderBodyCell for the actions column + const stopBtns = screen + .getAllByTestId('action-icon') + .filter((btn) => btn.getAttribute('data-color') === 'red.9'); + // Second red button is the client disconnect (first is stopChannel) + if (stopBtns.length > 1) { + fireEvent.click(stopBtns[1]); + expect(stopClient).toHaveBeenCalledWith('ch-uuid-1', 'client-1'); + } + }); + }); + + // ── Current program display ──────────────────────────────────────────────── + + describe('current program', () => { + it('does not render program section when currentProgram is null', () => { + render(); + expect(screen.queryByText('Now Playing:')).not.toBeInTheDocument(); + }); + + it('renders "Now Playing" label when currentProgram is provided', () => { + render( + + ); + expect(screen.getByText('Now Playing:')).toBeInTheDocument(); + }); + + it('renders current program title', () => { + render( + + ); + expect(screen.getByText('Evening News')).toBeInTheDocument(); + }); + + it('does not render description when collapsed', () => { + render( + + ); + expect(screen.queryByText('Daily news broadcast.')).not.toBeInTheDocument(); + }); + + it('expands program description when chevron button is clicked', () => { + render( + + ); + const chevronBtn = screen.getByTestId('icon-chevron-right').closest('button'); + fireEvent.click(chevronBtn); + expect(screen.getByText('Daily news broadcast.')).toBeInTheDocument(); + }); + + it('collapses program description on second chevron click', () => { + render( + + ); + const chevronBtn = screen.getByTestId('icon-chevron-right').closest('button'); + fireEvent.click(chevronBtn); + expect(screen.getByText('Daily news broadcast.')).toBeInTheDocument(); + const chevronDownBtn = screen.getByTestId('icon-chevron-down').closest('button'); + fireEvent.click(chevronDownBtn); + expect(screen.queryByText('Daily news broadcast.')).not.toBeInTheDocument(); + }); + + it('renders program progress when expanded and times are present', () => { + render( + + ); + const chevronBtn = screen.getByTestId('icon-chevron-right').closest('button'); + fireEvent.click(chevronBtn); + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('does not render progress when program has no start_time', () => { + render( + + ); + const chevronBtn = screen.getByTestId('icon-chevron-right').closest('button'); + fireEvent.click(chevronBtn); + expect(screen.queryByTestId('progress')).not.toBeInTheDocument(); + }); + }); + + // ── Stream fetching ──────────────────────────────────────────────────────── + + describe('stream fetching', () => { + it('calls getChannelStreams on mount with channel db ID', async () => { + render(); + await waitFor(() => { + expect(getChannelStreams).toHaveBeenCalledWith(1); + }); + }); + + it('does not render Select dropdown when no available streams are returned', async () => { + vi.mocked(getChannelStreams).mockResolvedValue([]); + render(); + await waitFor(() => { + expect(screen.queryByTestId('select')).not.toBeInTheDocument(); + }); + }); + + it('renders Select when streams are available', async () => { + vi.mocked(getChannelStreams).mockResolvedValue([ + { id: 10, name: 'Stream A', url: 'http://a.com', m3u_profile: null }, + { id: 11, name: 'Stream B', url: 'http://b.com', m3u_profile: null }, + ]); + // Provide options via getStreamOptions mock + const { getStreamOptions } = await import( + '../../../utils/cards/StreamConnectionCardUtils.js' + ); + vi.mocked(getStreamOptions).mockReturnValue([ + { value: '10', label: 'Stream A' }, + { value: '11', label: 'Stream B' }, + ]); + render(); + await waitFor(() => { + expect(screen.getByTestId('select')).toBeInTheDocument(); + }); + }); + + it('sets activeStreamId when a matching stream is found by URL', async () => { + vi.mocked(getChannelStreams).mockResolvedValue([ + { id: 42, name: 'Stream A', url: 'http://stream.example.com/ch1', m3u_profile: null }, + ]); + vi.mocked(getMatchingStreamByUrl).mockReturnValue({ + id: 42, + name: 'Stream A', + url: 'http://stream.example.com/ch1', + m3u_profile: null, + }); + render(); + await waitFor(() => { + expect(getMatchingStreamByUrl).toHaveBeenCalled(); + }); + }); + + it('does not call getChannelStreams when channelId is not found in channelsByUUID', async () => { + render( + + ); + await waitFor(() => { + expect(getChannelStreams).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Stream switching ─────────────────────────────────────────────────────── + + describe('stream switching', () => { + beforeEach(async () => { + vi.mocked(getChannelStreams).mockResolvedValue([ + { id: 10, name: 'Stream A', url: 'http://a.com', m3u_profile: { name: 'M3U A' } }, + ]); + const { getStreamOptions } = await import( + '../../../utils/cards/StreamConnectionCardUtils.js' + ); + vi.mocked(getStreamOptions).mockReturnValue([ + { value: '10', label: 'Stream A' }, + ]); + vi.mocked(getSelectedStream).mockReturnValue({ + id: 10, + name: 'Stream A', + m3u_profile: { name: 'M3U A' }, + }); + }); + + it('calls switchStream with channel and streamId when Select changes', async () => { + vi.mocked(switchStream).mockResolvedValue({}); + render(); + await waitFor(() => screen.getByTestId('select')); + fireEvent.change(screen.getByTestId('select'), { target: { value: '10' } }); + await waitFor(() => { + expect(switchStream).toHaveBeenCalledWith( + expect.objectContaining({ channel_id: 'ch-uuid-1' }), + '10' + ); + }); + }); + + it('shows a blue notification after successful stream switch', async () => { + vi.mocked(switchStream).mockResolvedValue({}); + render(); + await waitFor(() => screen.getByTestId('select')); + fireEvent.change(screen.getByTestId('select'), { target: { value: '10' } }); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'blue.5' }) + ); + }); + }); + + it('shows a red error notification when switchStream throws', async () => { + vi.mocked(switchStream).mockRejectedValue(new Error('Switch failed')); + render(); + await waitFor(() => screen.getByTestId('select')); + fireEvent.change(screen.getByTestId('select'), { target: { value: '10' } }); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ color: 'red.5' }) + ); + }); + }); + + it('updates M3U profile name from switch response', async () => { + vi.mocked(switchStream).mockResolvedValue({ + m3u_profile: { name: 'Updated M3U' }, + }); + render(); + await waitFor(() => screen.getByTestId('select')); + fireEvent.change(screen.getByTestId('select'), { target: { value: '10' } }); + await waitFor(() => { + expect(screen.getByText('Updated M3U')).toBeInTheDocument(); + }); + }); + }); + + // ── Preview channel ──────────────────────────────────────────────────────── + + describe('preview channel', () => { + it('does not render preview button when availableStreams is empty', async () => { + vi.mocked(getChannelStreams).mockResolvedValue([]); + render(); + await waitFor(() => { + expect(screen.queryByTestId('icon-circle-play')).not.toBeInTheDocument(); + }); + }); + + it('renders preview button when streams are available and channel has a name', async () => { + vi.mocked(getChannelStreams).mockResolvedValue([ + { id: 10, name: 'Stream A', url: 'http://a.com', m3u_profile: null }, + ]); + const { getStreamOptions } = await import( + '../../../utils/cards/StreamConnectionCardUtils.js' + ); + vi.mocked(getStreamOptions).mockReturnValue([{ value: '10', label: 'Stream A' }]); + render(); + await waitFor(() => { + expect(screen.getByTestId('icon-circle-play')).toBeInTheDocument(); + }); + }); + + it('calls showVideo with correct url and type when preview is clicked', async () => { + const showVideo = vi.fn(); + vi.mocked(useVideoStore).mockImplementation((selector) => + selector({ showVideo }) + ); + vi.mocked(getChannelStreams).mockResolvedValue([ + { id: 10, name: 'Stream A', url: 'http://a.com', m3u_profile: null }, + ]); + const { getStreamOptions } = await import( + '../../../utils/cards/StreamConnectionCardUtils.js' + ); + vi.mocked(getStreamOptions).mockReturnValue([{ value: '10', label: 'Stream A' }]); + + render(); + await waitFor(() => screen.getByTestId('icon-circle-play')); + fireEvent.click(screen.getByTestId('icon-circle-play').closest('button')); + + await waitFor(() => { + expect(showVideo).toHaveBeenCalledWith( + expect.stringContaining('/proxy/ts/stream/ch-uuid-1'), + 'live', + expect.objectContaining({ name: 'Test Channel' }) + ); + }); + }); + + it('does not call showVideo when channelDbId is not found', async () => { + const showVideo = vi.fn(); + vi.mocked(useVideoStore).mockImplementation((selector) => + selector({ showVideo }) + ); + vi.mocked(getChannelStreams).mockResolvedValue([ + { id: 10, name: 'Stream A', url: 'http://a.com', m3u_profile: null }, + ]); + const { getStreamOptions } = await import( + '../../../utils/cards/StreamConnectionCardUtils.js' + ); + vi.mocked(getStreamOptions).mockReturnValue([{ value: '10', label: 'Stream A' }]); + + render( + + ); + await waitFor(() => { + // Select not shown since getChannelStreams won't be called + expect(showVideo).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Previewed stream (unnamed channel) ──────────────────────────────────── + + describe('previewed stream fallback', () => { + it('fetches stream name when channel has no name but has stream_id', async () => { + vi.mocked(getStreamsByIds).mockResolvedValue([ + { id: 42, name: 'Previewed Stream', logo_id: null }, + ]); + render( + + ); + await waitFor(() => { + expect(getStreamsByIds).toHaveBeenCalledWith(42); + }); + }); + + it('does not call getStreamsByIds when channel has a name', async () => { + render(); + await waitFor(() => { + expect(getStreamsByIds).not.toHaveBeenCalled(); + }); + }); + }); + + // ── M3U profile from channel data ───────────────────────────────────────── + + describe('M3U profile state', () => { + it('uses m3u_profile_name fallback when m3u_profile object is absent', () => { + render( + + ); + expect(screen.getByText('Fallback M3U')).toBeInTheDocument(); + }); + + it('updates currentM3UProfile when channel m3u_profile prop changes', async () => { + const { rerender } = render(); + expect(screen.getByText('Main M3U')).toBeInTheDocument(); + + rerender( + + ); + await waitFor(() => { + expect(screen.getByText('New M3U')).toBeInTheDocument(); + }); + }); + }); + + // ── Network stats display ────────────────────────────────────────────────── + + describe('network stats', () => { + it('renders formatted total bytes via formatBytes', () => { + render(); + expect(screen.getByText('1048576 B')).toBeInTheDocument(); + }); + + it('renders formatted current bitrate via formatSpeed', () => { + render(); + // bitrates.at(-1) = 3700 + expect(screen.getAllByText('3700 Kbps').length).toBeGreaterThan(0); + }); + + it('handles empty bitrates array gracefully', () => { + render( + + ); + expect(screen.getAllByText('0 Kbps').length).toBeGreaterThan(0); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/cards/__tests__/VODCard.test.jsx b/frontend/src/components/cards/__tests__/VODCard.test.jsx new file mode 100644 index 00000000..05e98b1e --- /dev/null +++ b/frontend/src/components/cards/__tests__/VODCard.test.jsx @@ -0,0 +1,294 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── VODCardUtils ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/cards/VODCardUtils.js', () => ({ + formatDuration: vi.fn((mins) => (mins ? `${mins}m` : null)), + getSeasonLabel: vi.fn(() => 'S01E02'), +})); + +// ── Mantine core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, variant, size }) => ( + + ), + Badge: ({ children, color, variant, size }) => ( + + {children} + + ), + Box: ({ children, pos, h, style }) => ( +
+ {children} +
+ ), + Card: ({ children, onClick, style, withBorder, shadow, radius, p }) => ( +
+ {children} +
+ ), + CardSection: ({ children }) =>
{children}
, + Group: ({ children, justify, gap, wrap }) => ( +
+ {children} +
+ ), + Image: ({ src, alt, height, fallbackSrc, fit }) => ( + {alt} + ), + Stack: ({ children, spacing, gap, p }) => ( +
+ {children} +
+ ), + Text: ({ children, size, c, weight, fw, lineClamp, style }) => ( + + {children} + + ), +})); + +// ── lucide-react ────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Calendar: () => , + Clock: () => , + Play: () => , + Star: () => , +})); + +// ── Imports after mocks ─────────────────────────────────────────────────────── +import { formatDuration, getSeasonLabel } from '../../../utils/cards/VODCardUtils.js'; +import VODCard from '../VODCard'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const makeMovie = (overrides = {}) => ({ + type: 'movie', + name: 'Test Movie', + logo: { url: 'http://example.com/poster.jpg' }, + year: 2022, + rating: 8.5, + duration: 120, + duration_secs: 120, + description: 'A great test movie.', + genre: 'Action', + ...overrides, +}); + +const makeEpisode = (overrides = {}) => ({ + type: 'episode', + name: 'Pilot', + logo: { url: 'http://example.com/ep-poster.jpg' }, + year: 2021, + rating: 7.9, + duration: 45, + description: 'The first episode.', + genre: 'Comedy', + series: { name: 'Test Series' }, + season: 1, + episode: 2, + ...overrides, +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('VODCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(formatDuration).mockImplementation((mins) => (mins ? `${mins}m` : null)); + vi.mocked(getSeasonLabel).mockReturnValue('S01E02'); + }); + + // ── Rendering: movie ─────────────────────────────────────────────────────── + + describe('movie rendering', () => { + it('renders the card element', () => { + render(); + expect(screen.getByTestId('vod-card')).toBeInTheDocument(); + }); + + it('renders the movie title', () => { + render(); + expect(screen.getByText('Test Movie')).toBeInTheDocument(); + }); + + it('renders the poster image with the logo url', () => { + render(); + const img = screen.getByRole('img'); + expect(img).toHaveAttribute('src', 'http://example.com/poster.jpg'); + }); + + it('renders the year when present', () => { + render(); + expect(screen.getByText('2022')).toBeInTheDocument(); + }); + + it('renders the rating when present', () => { + render(); + expect(screen.getByText('8.5')).toBeInTheDocument(); + }); + + it('renders the star icon when rating is present', () => { + render(); + expect(screen.getByTestId('icon-star')).toBeInTheDocument(); + }); + + it('renders the formatted duration via formatDuration', () => { + render(); + expect(formatDuration).toHaveBeenCalledWith(120); + expect(screen.getByText('120m')).toBeInTheDocument(); + }); + + it('renders the clock icon when duration is present', () => { + render(); + expect(screen.getByTestId('icon-clock')).toBeInTheDocument(); + }); + + it('renders genre badges', () => { + render(); + expect(screen.getByText('Action')).toBeInTheDocument(); + }); + + it('renders the calendar icon when year is present', () => { + render(); + expect(screen.getByTestId('icon-calendar')).toBeInTheDocument(); + }); + + it('renders the play icon', () => { + render(); + expect(screen.getByTestId('icon-play')).toBeInTheDocument(); + }); + + it('does not render series name for a movie', () => { + render(); + expect(screen.queryByText('Test Series')).not.toBeInTheDocument(); + }); + }); + + // ── Rendering: episode ───────────────────────────────────────────────────── + + describe('episode rendering', () => { + it('renders the series name for an episode', () => { + render(); + expect(screen.getByText('Test Series')).toBeInTheDocument(); + }); + + it('renders the season label via getSeasonLabel', () => { + render(); + expect(getSeasonLabel).toHaveBeenCalledWith(expect.objectContaining({ type: 'episode' })); + expect(screen.getByText(/S01E02/)).toBeInTheDocument(); + }); + + it('renders the episode name alongside the season label', () => { + render(); + expect(screen.getByText(/Pilot/)).toBeInTheDocument(); + }); + + it('does not render a plain title text when it is an episode with a series', () => { + render(); + // Series name and episode name are shown, not the bare vod.name alone as a standalone text + const texts = screen.getAllByTestId('text').map((el) => el.textContent); + // The series name should appear + expect(texts.some((t) => t.includes('Test Series'))).toBe(true); + }); + + it('renders a plain title for an episode without a series object', () => { + render(); + expect(screen.getByText('Pilot')).toBeInTheDocument(); + }); + }); + + // ── Poster fallback ──────────────────────────────────────────────────────── + + describe('poster image', () => { + it('renders an image when logo.url is present', () => { + render(); + expect(screen.getByRole('img')).toBeInTheDocument(); + }); + + it('does not render an img tag when logo is null', () => { + render(); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + + it('does not render an img tag when logo.url is empty string', () => { + render(); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + }); + + // ── Optional metadata ────────────────────────────────────────────────────── + + describe('optional metadata', () => { + it('does not render year when absent', () => { + render(); + expect(screen.queryByTestId('icon-calendar')).not.toBeInTheDocument(); + }); + + it('does not render rating when absent', () => { + render(); + expect(screen.queryByTestId('icon-star')).not.toBeInTheDocument(); + }); + + it('does not render duration when formatDuration returns null', () => { + vi.mocked(formatDuration).mockReturnValue(null); + render(); + expect(screen.queryByTestId('icon-clock')).not.toBeInTheDocument(); + }); + + it('does not render genre when absent', () => { + render(); + const badges = screen.queryAllByTestId('badge'); + const dimmedBadges = badges.filter((badge) => + badge.getAttribute('data-color') === 'dimmed'); + expect(dimmedBadges.length).toBe(0); + }); + }); + + // ── Click handling ───────────────────────────────────────────────────────── + + describe('click handling', () => { + it('calls onClick with the vod object when card is clicked', async () => { + const onClick = vi.fn(); + const vod = makeMovie(); + render(); + fireEvent.click(screen.getByTestId('vod-card')); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledWith(vod); + }); + + it('calls onClick when play button is clicked', async () => { + const onClick = vi.fn(); + const vod = makeMovie(); + render(); + fireEvent.click(screen.getByTestId('action-icon')); + expect(onClick).toHaveBeenCalledWith(vod); + }); + + it('calls onClick with episode vod object', async () => { + const onClick = vi.fn(); + const vod = makeEpisode(); + render(); + fireEvent.click(screen.getByTestId('vod-card')); + expect(onClick).toHaveBeenCalledWith(vod); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/cards/__tests__/VodConnectionCard.test.jsx b/frontend/src/components/cards/__tests__/VodConnectionCard.test.jsx new file mode 100644 index 00000000..2cd35670 --- /dev/null +++ b/frontend/src/components/cards/__tests__/VodConnectionCard.test.jsx @@ -0,0 +1,543 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ── dateTimeUtils ───────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + convertToSec: vi.fn((val) => (val ? Number(val) : 0)), + fromNow: vi.fn(() => '5 minutes ago'), + toFriendlyDuration: vi.fn((secs) => (secs ? `${secs}s` : null)), + useDateTimeFormat: vi.fn(() => ({ fullDateTimeFormat: 'MM/DD/YYYY h:mm A' })), +})); + +// ── VodConnectionCardUtils ──────────────────────────────────────────────────── +vi.mock('../../../utils/cards/VodConnectionCardUtils.js', () => ({ + calculateConnectionDuration: vi.fn(() => '5m 30s'), + calculateConnectionStartTime: vi.fn(() => 'Jan 1 2024 10:00 AM'), + calculateProgress: vi.fn(() => ({ totalTime: 0, currentTime: 0, percentage: 0 })), + formatDuration: vi.fn((secs) => (secs ? `${secs}s` : null)), + formatTime: vi.fn((secs) => `${secs}s`), + getEpisodeDisplayTitle: vi.fn(() => 'S01E02 — Pilot'), + getEpisodeSubtitle: vi.fn(() => ['Test Series', 'Season 1']), + getMovieDisplayTitle: vi.fn(() => 'Test Movie (2022)'), + getMovieSubtitle: vi.fn(() => ['120m', 'Action']), +})); + +// ── logo ────────────────────────────────────────────────────────────────────── +vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' })); + +// ── Mantine core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, color, variant }) => ( + + ), + Badge: ({ children, color, variant, size }) => ( + + {children} + + ), + Box: ({ children, style }) =>
{children}
, + Card: ({ children, shadow, style }) => ( // remove padding, radius, withBorder +
+ {children} +
+ ), + Center: ({ children }) =>
{children}
, + Flex: ({ children, justify }) => ( // remove align +
{children}
+ ), + Group: ({ children, justify, onClick, style }) => ( // remove gap, align, p +
+ {children} +
+ ), + Progress: ({ value, size, color }) => ( +
+ ), + Stack: ({ children, gap, pos, mt }) => ( +
+ {children} +
+ ), + Text: ({ children, size, c, fw, color }) => ( // remove ff, ta + + {children} + + ), + Tooltip: ({ children, label }) => ( +
+ {children} +
+ ), +})); + +// ── lucide-react ────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + ChevronDown: ({ size, style }) => ( + + ), + HardDriveUpload: () => , + SquareX: () => , + Timer: () => , + Video: () => , +})); + +// ── Imports after mocks ─────────────────────────────────────────────────────── +import { useDateTimeFormat } from '../../../utils/dateTimeUtils.js'; +import { + calculateProgress, + getEpisodeDisplayTitle, + getEpisodeSubtitle, + getMovieDisplayTitle, + getMovieSubtitle, + calculateConnectionDuration, + calculateConnectionStartTime, +} from '../../../utils/cards/VodConnectionCardUtils.js'; +import VodConnectionCard from '../VodConnectionCard'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const makeConnection = (overrides = {}) => ({ + client_ip: '192.168.1.100', + client_id: 'client-abc-123', // needed for stopVODClient + user_agent: 'Plex/1.0', + connected_at: '2024-01-01T10:00:00Z', + duration: 330, + bytes_sent: 1048576, + m3u_profile: { // M3U profile lives on connection + account_name: 'Main Account', + profile_name: 'Main M3U', + }, + ...overrides, +}); + +const makeMovieContent = (overrides = {}) => ({ + content_type: 'movie', + content_name: 'Test Movie', + content_metadata: { + logo_url: 'http://example.com/poster.jpg', + duration_secs: 7200, + year: 2022, + rating: 8.5, + genres: ['Action', 'Drama'], + resolution: '1920x1080', + m3u_profile: { name: 'Main M3U' }, + }, + individual_connection: makeConnection(), + ...overrides, +}); + +const makeEpisodeContent = (overrides = {}) => ({ + content_type: 'episode', + content_name: 'Pilot', + content_metadata: { + logo_url: 'http://example.com/ep-poster.jpg', + duration_secs: 2700, + series_name: 'Test Series', + season: 1, + episode: 2, + m3u_profile: { name: 'Main M3U' }, + }, + individual_connection: makeConnection(), + ...overrides, +}); + +const makeUnknownContent = (overrides = {}) => ({ + content_type: 'unknown', + content_name: 'Some Stream', + content_metadata: { + logo_url: null, + duration_secs: null, + m3u_profile: null, + }, + individual_connection: makeConnection(), + ...overrides, +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('VodConnectionCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.mocked(useDateTimeFormat).mockReturnValue({ fullDateTimeFormat: 'MM/DD/YYYY h:mm A' }); + vi.mocked(calculateProgress).mockReturnValue({ totalTime: 0, currentTime: 0, percentage: 0 }); + vi.mocked(getMovieDisplayTitle).mockReturnValue('Test Movie (2022)'); + vi.mocked(getMovieSubtitle).mockReturnValue(['120m', 'Action']); + vi.mocked(getEpisodeDisplayTitle).mockReturnValue('S01E02 — Pilot'); + vi.mocked(getEpisodeSubtitle).mockReturnValue(['Test Series', 'Season 1']); + vi.mocked(calculateConnectionDuration).mockReturnValue('5m 30s'); + vi.mocked(calculateConnectionStartTime).mockReturnValue('Jan 1 2024 10:00 AM'); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + // ── Basic rendering ──────────────────────────────────────────────────────── + + describe('rendering', () => { + it('renders the card element', () => { + render(); + expect(screen.getByTestId('vod-connection-card')).toBeInTheDocument(); + }); + + it('renders the poster image when logo_url is present', () => { + render(); + expect(screen.getByRole('img')).toHaveAttribute('src', 'http://example.com/poster.jpg'); + }); + + it('renders the default logo when logo_url is absent', () => { + render( + + ); + expect(screen.getByRole('img')).toHaveAttribute('src', 'default-logo.png'); + }); + + it('renders the stop client button', () => { + render(); + expect(screen.getByTestId('icon-square-x')).toBeInTheDocument(); + }); + + it('renders the client IP in the connection section', () => { + render(); + expect(screen.getByText('192.168.1.100')).toBeInTheDocument(); + }); + + it('renders "Unknown IP" when client_ip is absent', () => { + render( + + ); + expect(screen.getByText('Unknown IP')).toBeInTheDocument(); + }); + + it('renders "Hide Details" / "Show Details" toggle text', () => { + render(); + expect(screen.getByText('Show Details')).toBeInTheDocument(); + }); + + it('does not render client section when no connection is present', () => { + render( + + ); + expect(screen.queryByText('Client:')).not.toBeInTheDocument(); + }); + }); + + // ── Movie rendering ──────────────────────────────────────────────────────── + + describe('movie content', () => { + it('calls getMovieDisplayTitle with vodContent', () => { + const vodContent = makeMovieContent(); + render(); + expect(getMovieDisplayTitle).toHaveBeenCalledWith(vodContent); + }); + + it('renders the movie display title', () => { + render(); + expect(screen.getByText('Test Movie (2022)')).toBeInTheDocument(); + }); + + it('calls getMovieSubtitle with metadata', () => { + const vodContent = makeMovieContent(); + render(); + expect(getMovieSubtitle).toHaveBeenCalledWith(vodContent.content_metadata); + }); + + it('renders the movie subtitle parts', () => { + render(); + expect(screen.getByText(/120m/)).toBeInTheDocument(); + expect(screen.getByText(/Action/)).toBeInTheDocument(); + }); + + it('does not call getEpisodeDisplayTitle for a movie', () => { + render(); + expect(getEpisodeDisplayTitle).not.toHaveBeenCalled(); + }); + }); + + // ── Episode rendering ────────────────────────────────────────────────────── + + describe('episode content', () => { + it('calls getEpisodeDisplayTitle with vodContent', () => { + const vodContent = makeEpisodeContent(); + render(); + expect(getEpisodeDisplayTitle).toHaveBeenCalledWith(vodContent.content_metadata); + }); + + it('renders the episode display title', () => { + render(); + expect(screen.getByText('S01E02 — Pilot')).toBeInTheDocument(); + }); + + it('calls getEpisodeSubtitle with metadata', () => { + const vodContent = makeEpisodeContent(); + render(); + expect(getEpisodeSubtitle).toHaveBeenCalledWith(vodContent.content_metadata); + }); + + it('renders the episode subtitle parts', () => { + render(); + expect(screen.getByText(/Test Series/)).toBeInTheDocument(); + expect(screen.getByText(/Season 1/)).toBeInTheDocument(); + }); + + it('does not call getMovieDisplayTitle for an episode', () => { + render(); + expect(getMovieDisplayTitle).not.toHaveBeenCalled(); + }); + }); + + // ── Unknown / fallback content type ─────────────────────────────────────── + + describe('unknown content type', () => { + it('renders content_name as fallback title', () => { + render(); + expect(screen.getByText('Some Stream')).toBeInTheDocument(); + }); + + it('does not call getMovieDisplayTitle or getEpisodeDisplayTitle', () => { + render(); + expect(getMovieDisplayTitle).not.toHaveBeenCalled(); + expect(getEpisodeDisplayTitle).not.toHaveBeenCalled(); + }); + + it('renders no subtitle when subtitle parts are empty', () => { + vi.mocked(getMovieSubtitle).mockReturnValue([]); + vi.mocked(getEpisodeSubtitle).mockReturnValue([]); + render(); + // No " • " separator rendered for empty subtitle + expect(screen.queryByText(' • ')).not.toBeInTheDocument(); + }); + }); + + // ── connections fallback ─────────────────────────────────────────────────── + + describe('connection source fallback', () => { + it('uses individual_connection when present', () => { + const vodContent = makeMovieContent(); + render(); + expect(screen.getByText('192.168.1.100')).toBeInTheDocument(); + }); + + it('falls back to connections[0] when individual_connection is null', () => { + const connection = makeConnection({ client_ip: '10.0.0.1' }); + const vodContent = makeMovieContent({ + individual_connection: null, + connections: [connection], + }); + render(); + expect(screen.getByText('10.0.0.1')).toBeInTheDocument(); + }); + }); + + // ── M3U profile ──────────────────────────────────────────────────────────── + + describe('M3U profile', () => { + it('renders M3U profile name when present', () => { + render(); + expect(screen.getByText('Main M3U')).toBeInTheDocument(); + }); + + it('does not render M3U profile section when absent', () => { + const vodContent = makeMovieContent({ + individual_connection: makeConnection({ m3u_profile: null }), + }); + render(); + expect(screen.queryByText('Main M3U')).not.toBeInTheDocument(); + }); + }); + + // ── Stop client ──────────────────────────────────────────────────────────── + + describe('stop client', () => { + it('calls stopVODClient with the connection when stop button is clicked', () => { + const stopVODClient = vi.fn(); + const vodContent = makeMovieContent(); + render(); + fireEvent.click(screen.getByTestId('icon-square-x').closest('button')); + expect(stopVODClient).toHaveBeenCalledWith('client-abc-123'); + }); + + it('calls stopVODClient once per click', () => { + const stopVODClient = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('icon-square-x').closest('button')); + fireEvent.click(screen.getByTestId('icon-square-x').closest('button')); + expect(stopVODClient).toHaveBeenCalledTimes(2); + }); + }); + + // ── Client expand / collapse ─────────────────────────────────────────────── + + describe('client expand/collapse', () => { + it('starts collapsed (Show Details visible)', () => { + render(); + expect(screen.getByText('Show Details')).toBeInTheDocument(); + expect(screen.queryByText('Hide Details')).not.toBeInTheDocument(); + }); + + it('expands to show client details on header click', () => { + render(); + const header = screen.getByText('Show Details').closest('[data-testid="group"]'); + fireEvent.click(header); + expect(screen.getByText('Hide Details')).toBeInTheDocument(); + }); + + it('shows user agent in expanded details', () => { + render(); + const header = screen.getByText('Show Details').closest('[data-testid="group"]'); + fireEvent.click(header); + expect(screen.getByText('Plex/1.0')).toBeInTheDocument(); + }); + + it('collapses back when header is clicked again', () => { + render(); + const header = screen.getByText('Show Details').closest('[data-testid="group"]'); + fireEvent.click(header); + fireEvent.click(screen.getByText('Hide Details').closest('[data-testid="group"]')); + expect(screen.getByText('Show Details')).toBeInTheDocument(); + }); + + it('does not render user agent section when user_agent is "Unknown"', () => { + const vodContent = makeMovieContent({ + individual_connection: makeConnection({ user_agent: 'Unknown' }), + }); + render(); + const header = screen.getByText('Show Details').closest('[data-testid="group"]'); + fireEvent.click(header); + expect(screen.queryByText('Unknown')).not.toBeInTheDocument(); + }); + + it('does not render user agent section when user_agent is absent', () => { + const vodContent = makeMovieContent({ + individual_connection: makeConnection({ user_agent: null }), + }); + render(); + const header = screen.getByText('Show Details').closest('[data-testid="group"]'); + fireEvent.click(header); + // "Plex/1.0" should not appear + expect(screen.queryByText('Plex/1.0')).not.toBeInTheDocument(); + }); + + it('does not render bytes_sent row when bytes_sent is 0', () => { + const vodContent = makeMovieContent({ + individual_connection: makeConnection({ bytes_sent: 0 }), + }); + render(); + const header = screen.getByText('Show Details').closest('[data-testid="group"]'); + fireEvent.click(header); + expect(screen.queryByText('Data Sent:')).not.toBeInTheDocument(); + }); + + it('does not render duration row when duration is 0', () => { + const vodContent = makeMovieContent({ + individual_connection: makeConnection({ duration: 0 }), + }); + render(); + const header = screen.getByText('Show Details').closest('[data-testid="group"]'); + fireEvent.click(header); + expect(screen.queryByText('Watch Duration:')).not.toBeInTheDocument(); + }); + }); + + // ── Connection progress ──────────────────────────────────────────────────── + + describe('ConnectionProgress', () => { + it('does not render progress bar when totalTime is 0', () => { + vi.mocked(calculateProgress).mockReturnValue({ totalTime: 0, currentTime: 0, percentage: 0 }); + render(); + expect(screen.queryByTestId('progress')).not.toBeInTheDocument(); + }); + + it('renders progress bar when totalTime > 0', () => { + vi.mocked(calculateProgress).mockReturnValue({ totalTime: 7200, currentTime: 3600, percentage: 50 }); + render(); + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('passes correct percentage value to Progress component', () => { + vi.mocked(calculateProgress).mockReturnValue({ totalTime: 7200, currentTime: 3600, percentage: 50 }); + render(); + expect(screen.getByTestId('progress')).toHaveAttribute('data-value', '50'); + }); + + it('calls calculateProgress with connection and duration_secs', () => { + const vodContent = makeMovieContent(); + render(); + expect(calculateProgress).toHaveBeenCalledWith( + vodContent.individual_connection, + vodContent.content_metadata.duration_secs + ); + }); + + it('does not render ConnectionProgress when no connection', () => { + render( + + ); + expect(screen.queryByTestId('progress')).not.toBeInTheDocument(); + }); + }); + + // ── Periodic re-render timer ─────────────────────────────────────────────── + + describe('progress update timer', () => { + it('sets up a 1-second interval to trigger re-renders', () => { + vi.mocked(calculateProgress).mockReturnValue({ totalTime: 7200, currentTime: 0, percentage: 0 }); + render(); + + const callsBefore = vi.mocked(calculateProgress).mock.calls.length; + act(() => { vi.advanceTimersByTime(1000); }); + const callsAfter = vi.mocked(calculateProgress).mock.calls.length; + + expect(callsAfter).toBeGreaterThan(callsBefore); + }); + + it('clears the interval on unmount', () => { + const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval'); + const { unmount } = render( + + ); + unmount(); + expect(clearIntervalSpy).toHaveBeenCalled(); + }); + }); + + // ── calculateConnectionDuration / calculateConnectionStartTime ───────────── + + describe('connection duration and start time', () => { + it('calls calculateConnectionDuration with the connection', () => { + const vodContent = makeMovieContent(); + render(); + expect(calculateConnectionDuration).toHaveBeenCalledWith(vodContent.individual_connection); + }); + + it('renders the duration returned by calculateConnectionDuration', () => { + vi.mocked(calculateConnectionDuration).mockReturnValue('12m 45s'); + render(); + expect(screen.getByText('12m 45s')).toBeInTheDocument(); + }); + + it('calls calculateConnectionStartTime with connection and fullDateTimeFormat', () => { + const vodContent = makeMovieContent(); + render(); + expect(calculateConnectionStartTime).toHaveBeenCalledWith( + vodContent.individual_connection, + 'MM/DD/YYYY h:mm A' + ); + }); + }); +}); \ No newline at end of file From 5add51c3374ba2f61cbf65c645c4811512a3a662 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:13:30 -0700 Subject: [PATCH 2/2] Minor refactors for readability --- .../src/components/cards/RecordingCard.jsx | 11 ++- .../components/cards/StreamConnectionCard.jsx | 92 +++++++++---------- .../components/cards/VodConnectionCard.jsx | 63 ++++++------- 3 files changed, 85 insertions(+), 81 deletions(-) diff --git a/frontend/src/components/cards/RecordingCard.jsx b/frontend/src/components/cards/RecordingCard.jsx index 05620053..80b5ba80 100644 --- a/frontend/src/components/cards/RecordingCard.jsx +++ b/frontend/src/components/cards/RecordingCard.jsx @@ -2,6 +2,9 @@ import useChannelsStore from '../../store/channels.jsx'; import useSettingsStore from '../../store/settings.jsx'; import useVideoStore from '../../store/useVideoStore.jsx'; import { + format, + isAfter, + isBefore, useDateTimeFormat, useTimeHelpers, } from '../../utils/dateTimeUtils.js'; @@ -97,14 +100,14 @@ const RecordingCard = ({ const end = toUserTime(recording.end_time); const now = userNow(); const status = customProps.status; - const isTimeActive = now.isAfter(start) && now.isBefore(end); + const isTimeActive = isAfter(now, start) && isBefore(now, end); const isInterrupted = status === 'interrupted'; const isInProgress = isTimeActive && !isInterrupted && status !== 'completed' && status !== 'stopped'; - const isUpcoming = now.isBefore(start); + const isUpcoming = isBefore(now, start); const isSeriesGroup = Boolean( recording._group_count && recording._group_count > 1 ); @@ -471,8 +474,8 @@ const RecordingCard = ({ {isSeriesGroup ? 'Next recording' : 'Time'} - {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} - {end.format(timeformat)} + {format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '} + {format(end, timeformat)} diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index 4952f6fe..56d4d174 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -1,6 +1,5 @@ import { useLocation } from 'react-router-dom'; import React, { useEffect, useMemo, useState } from 'react'; -import useLocalStorage from '../../hooks/useLocalStorage.jsx'; import usePlaylistsStore from '../../store/playlists.jsx'; import useSettingsStore from '../../store/settings.jsx'; import { @@ -9,7 +8,6 @@ import { Box, Card, Center, - Flex, Group, Progress, Select, @@ -56,6 +54,51 @@ import { } from '../../utils/cards/StreamConnectionCardUtils.js'; import useVideoStore from '../../store/useVideoStore'; +const formatProgramTime = (seconds) => { + const absSeconds = Math.abs(seconds); + const hours = Math.floor(absSeconds / 3600); + const minutes = Math.floor((absSeconds % 3600) / 60); + const secs = Math.floor(absSeconds % 60); + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + } + return `${minutes}:${secs.toString().padStart(2, '0')}`; +}; + +const ProgramProgress = ({ currentProgram }) => { + const now = new Date(); + const startTime = new Date(currentProgram.start_time); + const endTime = new Date(currentProgram.end_time); + const totalDuration = (endTime - startTime) / 1000; // in seconds + const elapsed = (now - startTime) / 1000; // in seconds + const remaining = (endTime - now) / 1000; // in seconds + const percentage = Math.min( + 100, + Math.max(0, (elapsed / totalDuration) * 100) + ); + + return ( + + + + {formatProgramTime(elapsed)} elapsed + + + {formatProgramTime(remaining)} remaining + + + + + ); +}; + // Create a separate component for each channel card to properly handle the hook const StreamConnectionCard = ({ channel, @@ -568,50 +611,7 @@ const StreamConnectionCard = ({ isProgramDescExpanded && currentProgram.start_time && currentProgram.end_time && - (() => { - const now = new Date(); - const startTime = new Date(currentProgram.start_time); - const endTime = new Date(currentProgram.end_time); - const totalDuration = (endTime - startTime) / 1000; // in seconds - const elapsed = (now - startTime) / 1000; // in seconds - const remaining = (endTime - now) / 1000; // in seconds - const percentage = Math.min( - 100, - Math.max(0, (elapsed / totalDuration) * 100) - ); - - const formatProgramTime = (seconds) => { - const absSeconds = Math.abs(seconds); - const hours = Math.floor(absSeconds / 3600); - const minutes = Math.floor((absSeconds % 3600) / 60); - const secs = Math.floor(absSeconds % 60); - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; - } - return `${minutes}:${secs.toString().padStart(2, '0')}`; - }; - - return ( - - - - {formatProgramTime(elapsed)} elapsed - - - {formatProgramTime(remaining)} remaining - - - - - ); - })()} + } {/* Add stream selection dropdown and preview button */} {availableStreams.length > 0 && ( diff --git a/frontend/src/components/cards/VodConnectionCard.jsx b/frontend/src/components/cards/VodConnectionCard.jsx index 7c775e8f..5e081c03 100644 --- a/frontend/src/components/cards/VodConnectionCard.jsx +++ b/frontend/src/components/cards/VodConnectionCard.jsx @@ -141,6 +141,33 @@ const ClientDetails = ({ connection, connectionStartTime }) => { ); }; +const ConnectionProgress = ({ connection, durationSecs }) => { + const { totalTime, currentTime, percentage } = calculateProgress(connection, durationSecs); + return totalTime > 0 ? ( + + + + Progress + + + {formatTime(currentTime)} / {formatTime(totalTime)} + + + + + {percentage.toFixed(1)}% watched + + + ) : null; +}; + // Create a VOD Card component similar to ChannelCard const VodConnectionCard = ({ vodContent, stopVODClient }) => { const { fullDateTimeFormat } = useDateTimeFormat(); @@ -202,11 +229,6 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => { ); }; - // Calculate progress percentage and time - const getProgressInfo = useCallback(() => { - return calculateProgress(connection, metadata.duration_secs); - }, [connection, metadata.duration_secs]); - // Calculate duration for connection const getConnectionDuration = useCallback((connection) => { return calculateConnectionDuration(connection); @@ -357,32 +379,11 @@ const VodConnectionCard = ({ vodContent, stopVODClient }) => { {/* Progress bar - show current position in content */} {connection && metadata.duration_secs && - (() => { - const { totalTime, currentTime, percentage } = getProgressInfo(); - return totalTime > 0 ? ( - - - - Progress - - - {formatTime(currentTime)} / {formatTime(totalTime)} - - - - - {percentage.toFixed(1)}% watched - - - ) : null; - })()} + + } {/* Client information section - collapsible like channel cards */} {connection && (