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 && (
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 }) =>
,
+ 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 }) => (
+
+ ),
+ 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 }) => (
+
+ ),
+ 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 }) => (
+
+ ),
+ 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