Merge branch 'dev' of https://github.com/Dispatcharr/Dispatcharr into dev
Some checks failed
CI Pipeline / prepare (push) Has been cancelled
Build and Push Multi-Arch Docker Image / build-and-push (push) Has been cancelled
Frontend Tests / test (push) Has been cancelled
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Has been cancelled
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Has been cancelled
CI Pipeline / create-manifest (push) Has been cancelled

This commit is contained in:
SergeantPanda 2026-03-10 11:12:53 -05:00
commit 0bc4c40400
9 changed files with 3344 additions and 81 deletions

View file

@ -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'}
</Text>
<Text size="sm">
{start.format(`${dateformat}, YYYY ${timeformat}`)} {' '}
{end.format(timeformat)}
{format(start, `${dateformat}, YYYY ${timeformat}`)} {' '}
{format(end, timeformat)}
</Text>
</Group>

View file

@ -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 (
<Stack gap="xs" mt={4}>
<Group justify="space-between" align="center">
<Text size="xs" c="dimmed">
{formatProgramTime(elapsed)} elapsed
</Text>
<Text size="xs" c="dimmed">
{formatProgramTime(remaining)} remaining
</Text>
</Group>
<Progress
value={percentage}
size="sm"
color="#3BA882"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}
/>
</Stack>
);
};
// 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 (
<Stack gap="xs" mt={4}>
<Group justify="space-between" align="center">
<Text size="xs" c="dimmed">
{formatProgramTime(elapsed)} elapsed
</Text>
<Text size="xs" c="dimmed">
{formatProgramTime(remaining)} remaining
</Text>
</Group>
<Progress
value={percentage}
size="sm"
color="#3BA882"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}
/>
</Stack>
);
})()}
<ProgramProgress currentProgram={currentProgram} />}
{/* Add stream selection dropdown and preview button */}
{availableStreams.length > 0 && (

View file

@ -141,6 +141,33 @@ const ClientDetails = ({ connection, connectionStartTime }) => {
);
};
const ConnectionProgress = ({ connection, durationSecs }) => {
const { totalTime, currentTime, percentage } = calculateProgress(connection, durationSecs);
return totalTime > 0 ? (
<Stack gap="xs" mt="sm">
<Group justify="space-between" align="center">
<Text size="xs" fw={500} c="dimmed">
Progress
</Text>
<Text size="xs" c="dimmed">
{formatTime(currentTime)} / {formatTime(totalTime)}
</Text>
</Group>
<Progress
value={percentage}
size="sm"
color="blue"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}
/>
<Text size="xs" c="dimmed" ta="center">
{percentage.toFixed(1)}% watched
</Text>
</Stack>
) : 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 ? (
<Stack gap="xs" mt="sm">
<Group justify="space-between" align="center">
<Text size="xs" fw={500} c="dimmed">
Progress
</Text>
<Text size="xs" c="dimmed">
{formatTime(currentTime)} / {formatTime(totalTime)}
</Text>
</Group>
<Progress
value={percentage}
size="sm"
color="blue"
style={{
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}
/>
<Text size="xs" c="dimmed" ta="center">
{percentage.toFixed(1)}% watched
</Text>
</Stack>
) : null;
})()}
<ConnectionProgress
connection={connection}
durationSecs={metadata.duration_secs}
/>
}
{/* Client information section - collapsible like channel cards */}
{connection && (

View file

@ -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 }) => (
<div data-testid={`field-${field.id}`}>
<label>{field.label}</label>
<input
type={field.type || 'text'}
value={value || ''}
onChange={(e) => onChange(field.id, e.target.value)}
/>
</div>
),
}));
vi.mock('@mantine/core', async () => {
return {
ActionIcon: ({ children, ...props }) => <button {...props}>{children}</button>,
Anchor: ({ children, ...props }) => <a {...props}>{children}</a>,
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Avatar: ({ src, alt }) => <img src={src} alt={alt} />,
Button: ({ children, ...props }) => <button {...props}>{children}</button>,
Card: ({ children, ...props }) => <div {...props}>{children}</div>,
Divider: () => <hr />,
Group: ({ children, ...props }) => <div {...props}>{children}</div>,
Stack: ({ children, ...props }) => <div {...props}>{children}</div>,
Switch: ({ checked, onChange, disabled }) => (
<input
type="checkbox"
checked={checked}
onChange={onChange}
disabled={disabled}
/>
),
Text: ({ children, ...props }) => <span {...props}>{children}</span>,
UnstyledButton: ({ children, ...props }) => <button {...props}>{children}</button>,
Badge: ({ children, ...props }) => <span {...props}>{children}</span>,
};
});
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(<PluginCard {...defaultProps} />);
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(<PluginCard {...defaultProps} />);
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(<PluginCard {...defaultProps} />);
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(<PluginCard {...defaultProps} />);
const switchElement = screen.getByRole('checkbox');
expect(switchElement).toBeChecked();
});
it('should render switch as unchecked when plugin is disabled', () => {
const disabledPlugin = { ...mockPlugin, enabled: false };
render(<PluginCard {...defaultProps} plugin={disabledPlugin} />);
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(<PluginCard {...defaultProps} plugin={missingPlugin} />);
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(<PluginCard {...defaultProps} plugin={legacyPlugin} />);
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(<PluginCard plugin={mockPlugin} />);
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(<PluginCard {...defaultProps} />);
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(<PluginCard {...defaultProps} />);
const expandButton = screen.getAllByRole('button')[0];
fireEvent.click(expandButton);
const disabledPlugin = { ...mockPlugin, enabled: false };
rerender(<PluginCard {...defaultProps} plugin={disabledPlugin} />);
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(<PluginCard {...defaultProps} />);
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(<PluginCard {...defaultProps} plugin={firstTimePlugin} />);
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(<PluginCard {...defaultProps} plugin={firstTimePlugin} />);
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(<PluginCard {...defaultProps} />);
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(<PluginCard {...defaultProps} plugin={missingPlugin} />);
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(<PluginCard {...defaultProps} />);
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(<PluginCard {...defaultProps} />);
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(<PluginCard {...defaultProps} />);
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(<PluginCard {...defaultProps} />);
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(<PluginCard {...defaultProps} />);
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(<PluginCard {...defaultProps} />);
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(<PluginCard {...defaultProps} />);
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(<PluginCard {...errorProps} />);
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(<PluginCard {...defaultProps} plugin={pluginWithEvents} />);
expect(screen.getByText('Event Triggers')).toBeInTheDocument();
});
});
describe('Delete Plugin', () => {
it('should call onRequestDelete when delete button is clicked', () => {
render(<PluginCard {...defaultProps} />);
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(<PluginCard {...defaultProps} />);
expect(screen.getByRole('checkbox')).toBeChecked();
const disabledPlugin = { ...mockPlugin, enabled: false };
rerender(<PluginCard {...defaultProps} plugin={disabledPlugin} />);
expect(screen.getByRole('checkbox')).not.toBeChecked();
});
it('should sync settings when plugin key changes', () => {
const { rerender } = render(<PluginCard {...defaultProps} />);
const newPlugin = {
...mockPlugin,
key: 'new-plugin',
settings: { field1: 'new-value' },
};
rerender(<PluginCard {...defaultProps} plugin={newPlugin} />);
// Settings should be updated internally
expect(screen.getByText('Save Settings')).toBeInTheDocument();
});
});
});

View file

@ -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 }) => (
<button
data-testid="action-icon"
data-color={color}
onClick={onClick}
onMouseDown={onMouseDown}
disabled={disabled}
>
{children}
</button>
),
Badge: ({ children, color }) => (
<span data-testid="badge" data-color={color}>
{children}
</span>
),
Box: ({ children, style, display }) => (
<div style={style} data-display={display}>
{children}
</div>
),
Button: ({ children, onClick, disabled, loading, color, variant }) => (
<button
onClick={onClick}
disabled={disabled || loading}
data-color={color}
data-variant={variant}
data-loading={loading}
>
{children}
</button>
),
Card: ({ children, onClick, style }) => (
<div data-testid="recording-card" onClick={onClick} style={style}>
{children}
</div>
),
Flex: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Image: ({ src, alt, fallbackSrc }) => (
<img src={src} alt={alt} data-fallback={fallbackSrc} />
),
Menu: Object.assign(
({ children }) => <div data-testid="menu">{children}</div>,
{
Target: ({ children }) => <div>{children}</div>,
Dropdown: ({ children, onClick }) => (
<div onClick={onClick}>{children}</div>
),
Label: ({ children }) => <div>{children}</div>,
Item: ({ children, onClick }) => (
<button onClick={onClick}>{children}</button>
),
}
),
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>
×
</button>
{children}
</div>
) : null,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, size, c, fw, title, lineClamp, style }) => (
<span data-size={size} data-color={c} data-fw={fw} style={style}>
{children}
</span>
),
Tooltip: ({ children, label }) => (
<div data-tooltip={label}>{children}</div>
),
}));
// lucide-react
vi.mock('lucide-react', () => ({
AlertTriangle: () => <svg data-testid="icon-alert-triangle" />,
Plus: () => <svg data-testid="icon-plus" />,
Square: () => <svg data-testid="icon-square" />,
SquareX: () => <svg data-testid="icon-square-x" />,
}));
// RecordingSynopsis
vi.mock('../../RecordingSynopsis', () => ({
default: ({ description, onOpen }) => (
<div data-testid="recording-synopsis" onClick={onOpen}>
{description}
</div>
),
}));
// 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(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
expect(screen.getByText('Test Show')).toBeInTheDocument();
});
it('renders "Custom Recording" when no program title', () => {
setupMocks({
recording: makeRecording({ custom_properties: { status: 'completed', program: {} } }),
});
render(
<RecordingCard
recording={makeRecording({ custom_properties: { status: 'completed', program: {} } })}
channel={makeChannel()}
/>
);
expect(screen.getByText('Custom Recording')).toBeInTheDocument();
});
it('renders channel info', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
expect(screen.getByText('501 • HBO')).toBeInTheDocument();
});
it('renders "—" when no channel provided', () => {
setupMocks({ channel: null });
render(<RecordingCard recording={makeRecording()} channel={null} />);
expect(screen.getByText('—')).toBeInTheDocument();
});
it('shows description via RecordingSynopsis for non-series completed recording', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
expect(screen.getByTestId('recording-synopsis')).toBeInTheDocument();
expect(screen.getByText('A test description')).toBeInTheDocument();
});
it('shows sub_title when present', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
expect(screen.getByText('Pilot')).toBeInTheDocument();
});
it('shows season/episode label when getSeasonLabel returns a value', () => {
setupMocks();
vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('S01E02');
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
expect(screen.getByText('S01E02')).toBeInTheDocument();
});
it('renders the poster image', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
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(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
expect(screen.getByText('Series')).toBeInTheDocument();
});
it('shows "Next of N" text for series group', () => {
const recording = makeSeriesRecording();
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
expect(screen.getByText('Next of 3')).toBeInTheDocument();
});
it('does not show description for series group', () => {
const recording = makeSeriesRecording();
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
expect(screen.getByText('Recurring')).toBeInTheDocument();
});
it('calls onOpenRecurring on card click', () => {
const recording = makeRecurring();
setupMocks({ recording });
const onOpenRecurring = vi.fn();
render(
<RecordingCard
recording={recording}
channel={makeChannel()}
onOpenRecurring={onOpenRecurring}
/>
);
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(
<RecordingCard
recording={recording}
channel={makeChannel()}
onOpenRecurring={onOpenRecurring}
/>
);
// 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(
<RecordingCard
recording={makeRecording()}
channel={makeChannel()}
onOpenDetails={onOpenDetails}
/>
);
fireEvent.click(screen.getByTestId('recording-card'));
expect(onOpenDetails).toHaveBeenCalledWith(makeRecording());
});
it('calls onOpenDetails from RecordingSynopsis onOpen', () => {
setupMocks();
const onOpenDetails = vi.fn();
render(
<RecordingCard
recording={makeRecording()}
channel={makeChannel()}
onOpenDetails={onOpenDetails}
/>
);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
expect(screen.getByText('Watch Live')).toBeInTheDocument();
});
it('does not show "Watch Live" for a completed recording', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={channel} />);
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(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
expect(screen.queryByText('Watch')).not.toBeInTheDocument();
});
it('calls showVideo with vod params when Watch is clicked', () => {
const { mockShowVideo } = setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
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(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={null} />);
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(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
expect(screen.queryByText('Remove commercials')).not.toBeInTheDocument();
});
it('calls runComSkip and shows notification on success', async () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
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(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
expect(screen.getByTestId('icon-plus')).toBeInTheDocument();
});
it('calls extendRecordingById with 15 and shows notification', async () => {
const recording = makeInProgress();
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
// 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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
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(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
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(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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(<RecordingCard recording={recording} channel={makeChannel()} />);
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();
});
});
});

View file

@ -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 }) => (
<span data-testid="badge" data-color={color} data-variant={variant}>
{children}
</span>
),
Box: ({ children, pos, h, style }) => (
<div data-testid="box" style={{ height: h, ...style }} data-pos={pos}>
{children}
</div>
),
Card: ({ children, onClick, style, withBorder, shadow, padding, radius }) => (
<div data-testid="series-card" onClick={onClick} style={style}>
{children}
</div>
),
CardSection: ({ children }) => (
<div data-testid="card-section">{children}</div>
),
Group: ({ children, gap, justify }) => (
<div data-testid="group">{children}</div>
),
Image: ({ src, alt, fallbackSrc, height, fit }) => (
<img src={src} alt={alt} data-fallback={fallbackSrc} data-fit={fit} />
),
Stack: ({ children, gap }) => <div data-testid="stack">{children}</div>,
Text: ({ children, size, fw, c, lineClamp, style }) => (
<span data-testid="text" data-size={size} data-fw={fw} data-color={c} style={style}>
{children}
</span>
),
}));
// lucide-react
vi.mock('lucide-react', () => ({
Calendar: () => <svg data-testid="icon-calendar" />,
Play: () => <svg data-testid="icon-play" />,
Star: () => <svg data-testid="icon-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(<SeriesCard series={makeSeries()} onClick={vi.fn()} />);
expect(screen.getByTestId('series-card')).toBeInTheDocument();
});
it('renders the series title', () => {
render(<SeriesCard series={makeSeries()} onClick={vi.fn()} />);
expect(screen.getByText('Breaking Bad')).toBeInTheDocument();
});
it('renders the poster image with correct src', () => {
render(<SeriesCard series={makeSeries()} onClick={vi.fn()} />);
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(<SeriesCard series={makeSeries({ poster_url: null })} onClick={vi.fn()} />);
const img = screen.getByRole('img');
expect(img).toBeInTheDocument();
});
it('renders the year when provided', () => {
render(<SeriesCard series={makeSeries()} onClick={vi.fn()} />);
expect(screen.getByText('2008')).toBeInTheDocument();
});
it('renders the genre when provided', () => {
render(<SeriesCard series={makeSeries()} onClick={vi.fn()} />);
expect(screen.getByText('Drama')).toBeInTheDocument();
});
it('renders the rating when provided', () => {
render(<SeriesCard series={makeSeries()} onClick={vi.fn()} />);
expect(screen.getByText('9.5')).toBeInTheDocument();
});
it('renders calendar icon', () => {
render(<SeriesCard series={makeSeries()} onClick={vi.fn()} />);
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(<SeriesCard series={makeSeries({ logo: { url: null } })} onClick={vi.fn()} />);
expect(screen.getByTestId('icon-play')).toBeInTheDocument();
});
it('renders star icon', () => {
render(<SeriesCard series={makeSeries()} onClick={vi.fn()} />);
expect(screen.getByTestId('icon-star')).toBeInTheDocument();
});
});
// Missing/optional fields
describe('optional fields', () => {
it('does not crash when year is missing', () => {
render(<SeriesCard series={makeSeries({ year: undefined })} onClick={vi.fn()} />);
expect(screen.getByTestId('series-card')).toBeInTheDocument();
});
it('does not crash when rating is missing', () => {
render(<SeriesCard series={makeSeries({ rating: undefined })} onClick={vi.fn()} />);
expect(screen.getByTestId('series-card')).toBeInTheDocument();
});
it('does not crash when genre is missing', () => {
render(<SeriesCard series={makeSeries({ genre: undefined })} onClick={vi.fn()} />);
expect(screen.getByTestId('series-card')).toBeInTheDocument();
});
it('does not crash when description is missing', () => {
render(<SeriesCard series={makeSeries({ description: undefined })} onClick={vi.fn()} />);
expect(screen.getByTestId('series-card')).toBeInTheDocument();
});
it('does not crash when seasons is missing', () => {
render(<SeriesCard series={makeSeries({ seasons: undefined })} onClick={vi.fn()} />);
expect(screen.getByTestId('series-card')).toBeInTheDocument();
});
it('renders without onClick prop', () => {
render(<SeriesCard series={makeSeries()} />);
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(<SeriesCard series={series} onClick={onClick} />);
fireEvent.click(screen.getByTestId('series-card'));
expect(onClick).toHaveBeenCalledTimes(1);
expect(onClick).toHaveBeenCalledWith(series);
});
});
});

View file

@ -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: () => <div data-testid="custom-table" />,
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 }) => (
<button
data-testid="action-icon"
data-color={color}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
),
Badge: ({ children, color, variant }) => (
<span data-testid="badge" data-color={color} data-variant={variant}>
{children}
</span>
),
Box: ({ children, style, pos }) => (
<div style={style} data-pos={pos}>
{children}
</div>
),
Card: ({ children, style }) => (
<div data-testid="stream-connection-card" style={style}>
{children}
</div>
),
Center: ({ children }) => <div data-testid="center">{children}</div>,
Group: ({ children }) => <div data-testid="group">{children}</div>,
Progress: ({ value, size, color }) => (
<div data-testid="progress" data-value={value} data-size={size} data-color={color} />
),
Select: ({ value, onChange, label, data, disabled, placeholder }) => (
<select
data-testid="select"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
aria-label={label}
>
<option value="">{placeholder}</option>
{(data ?? []).map((opt) => {
const val = typeof opt === 'string' ? opt : opt.value;
const lbl = typeof opt === 'string' ? opt : opt.label;
return (
<option key={val} value={val}>
{lbl}
</option>
);
})}
</select>
),
Stack: ({ children }) => <div data-testid="stack">{children}</div>,
Text: ({ children, size, c, fw, style }) => (
<span
data-testid="text"
data-size={size}
data-color={c}
data-fw={fw}
style={style}
>
{children}
</span>
),
Tooltip: ({ children, label }) => (
<div data-tooltip={label}>{children}</div>
),
useMantineTheme: vi.fn(() => ({
tailwind: { green: { 5: '#22c55e' } },
})),
}));
// lucide-react
vi.mock('lucide-react', () => ({
ChevronDown: () => <svg data-testid="icon-chevron-down" />,
ChevronRight: () => <svg data-testid="icon-chevron-right" />,
CirclePlay: () => <svg data-testid="icon-circle-play" />,
Gauge: () => <svg data-testid="icon-gauge" />,
HardDriveDownload: () => <svg data-testid="icon-hdd-download" />,
HardDriveUpload: () => <svg data-testid="icon-hdd-upload" />,
Radio: () => <svg data-testid="icon-radio" />,
SquareX: () => <svg data-testid="icon-square-x" />,
Timer: () => <svg data-testid="icon-timer" />,
Users: () => <svg data-testid="icon-users" />,
Video: () => <svg data-testid="icon-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(<StreamConnectionCard {...defaultProps()} />);
expect(container.firstChild).toBeNull();
});
it('renders nothing when pathname is /channels', () => {
setupLocation('/channels');
const { container } = render(<StreamConnectionCard {...defaultProps()} />);
expect(container.firstChild).toBeNull();
});
it('renders the card when pathname is /stats', () => {
render(<StreamConnectionCard {...defaultProps()} />);
expect(screen.getByTestId('stream-connection-card')).toBeInTheDocument();
});
it('returns null when channel is missing channel_id', () => {
const { container } = render(
<StreamConnectionCard
{...defaultProps({ channel: makeChannel({ channel_id: undefined }) })}
/>
);
expect(container.firstChild).toBeNull();
});
});
// Basic rendering
describe('rendering', () => {
it('renders the channel name', () => {
render(<StreamConnectionCard {...defaultProps()} />);
expect(screen.getByText('Test Channel')).toBeInTheDocument();
});
it('renders the stream profile name', () => {
render(<StreamConnectionCard {...defaultProps()} />);
expect(screen.getByText('Default Profile')).toBeInTheDocument();
});
it('renders the M3U profile name', () => {
render(<StreamConnectionCard {...defaultProps()} />);
expect(screen.getByText('Main M3U')).toBeInTheDocument();
});
it('renders the uptime via toFriendlyDuration', () => {
render(<StreamConnectionCard {...defaultProps()} />);
expect(screen.getByText('1h 23m')).toBeInTheDocument();
});
it('renders the average bitrate', () => {
render(<StreamConnectionCard {...defaultProps()} />);
expect(screen.getByText('Avg: 3.6 Mbps')).toBeInTheDocument();
});
it('renders the client count', () => {
render(<StreamConnectionCard {...defaultProps()} />);
expect(screen.getByText('2')).toBeInTheDocument();
});
it('renders the custom table for clients', () => {
render(<StreamConnectionCard {...defaultProps()} />);
expect(screen.getByTestId('custom-table')).toBeInTheDocument();
});
it('renders the fallback logo when logo_id is null', () => {
render(<StreamConnectionCard {...defaultProps()} />);
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(
<StreamConnectionCard
{...defaultProps({ channel: makeChannel({ name: '', stream_id: null }) })}
/>
);
expect(screen.getByText('Unnamed Channel')).toBeInTheDocument();
});
it('renders "Unknown Profile" when stream_profile is absent', () => {
render(
<StreamConnectionCard
{...defaultProps({ channel: makeChannel({ stream_profile: null }) })}
/>
);
expect(screen.getByText('Unknown Profile')).toBeInTheDocument();
});
it('renders "Unknown M3U Profile" when m3u_profile is absent', () => {
render(
<StreamConnectionCard
{...defaultProps({
channel: makeChannel({ m3u_profile: null, m3u_profile_name: null }),
})}
/>
);
expect(screen.getByText('Unknown M3U Profile')).toBeInTheDocument();
});
});
// Stream information badges
describe('stream info badges', () => {
it('renders resolution badge', () => {
render(<StreamConnectionCard {...defaultProps()} />);
expect(screen.getByText('1920x1080')).toBeInTheDocument();
});
it('renders FPS badge', () => {
render(<StreamConnectionCard {...defaultProps()} />);
expect(screen.getByText('30 FPS')).toBeInTheDocument();
});
it('renders video codec badge in uppercase', () => {
render(<StreamConnectionCard {...defaultProps()} />);
expect(screen.getByText('H264')).toBeInTheDocument();
});
it('renders audio codec badge in uppercase', () => {
render(<StreamConnectionCard {...defaultProps()} />);
expect(screen.getByText('AAC')).toBeInTheDocument();
});
it('renders audio channels badge', () => {
render(<StreamConnectionCard {...defaultProps()} />);
expect(screen.getByText('2.0')).toBeInTheDocument();
});
it('renders stream type badge in uppercase', () => {
render(<StreamConnectionCard {...defaultProps()} />);
expect(screen.getByText('HLS')).toBeInTheDocument();
});
it('does not render resolution badge when resolution is absent', () => {
render(
<StreamConnectionCard
{...defaultProps({ channel: makeChannel({ resolution: null }) })}
/>
);
expect(screen.queryByText('1920x1080')).not.toBeInTheDocument();
});
it('renders ffmpeg_speed badge with green color when above threshold', () => {
render(
<StreamConnectionCard
{...defaultProps({ channel: makeChannel({ ffmpeg_speed: '1.10' }) })}
/>
);
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(
<StreamConnectionCard
{...defaultProps({ channel: makeChannel({ ffmpeg_speed: '0.50' }) })}
/>
);
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(<StreamConnectionCard {...defaultProps({ stopChannel })} />);
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(<StreamConnectionCard {...defaultProps({ stopClient })} />);
// 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(<StreamConnectionCard {...defaultProps({ currentProgram: null })} />);
expect(screen.queryByText('Now Playing:')).not.toBeInTheDocument();
});
it('renders "Now Playing" label when currentProgram is provided', () => {
render(
<StreamConnectionCard
{...defaultProps({ currentProgram: makeCurrentProgram() })}
/>
);
expect(screen.getByText('Now Playing:')).toBeInTheDocument();
});
it('renders current program title', () => {
render(
<StreamConnectionCard
{...defaultProps({ currentProgram: makeCurrentProgram() })}
/>
);
expect(screen.getByText('Evening News')).toBeInTheDocument();
});
it('does not render description when collapsed', () => {
render(
<StreamConnectionCard
{...defaultProps({ currentProgram: makeCurrentProgram() })}
/>
);
expect(screen.queryByText('Daily news broadcast.')).not.toBeInTheDocument();
});
it('expands program description when chevron button is clicked', () => {
render(
<StreamConnectionCard
{...defaultProps({ currentProgram: makeCurrentProgram() })}
/>
);
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(
<StreamConnectionCard
{...defaultProps({ currentProgram: makeCurrentProgram() })}
/>
);
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(
<StreamConnectionCard
{...defaultProps({ currentProgram: makeCurrentProgram() })}
/>
);
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(
<StreamConnectionCard
{...defaultProps({
currentProgram: makeCurrentProgram({ start_time: null, end_time: null }),
})}
/>
);
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(<StreamConnectionCard {...defaultProps()} />);
await waitFor(() => {
expect(getChannelStreams).toHaveBeenCalledWith(1);
});
});
it('does not render Select dropdown when no available streams are returned', async () => {
vi.mocked(getChannelStreams).mockResolvedValue([]);
render(<StreamConnectionCard {...defaultProps()} />);
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(<StreamConnectionCard {...defaultProps()} />);
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(<StreamConnectionCard {...defaultProps()} />);
await waitFor(() => {
expect(getMatchingStreamByUrl).toHaveBeenCalled();
});
});
it('does not call getChannelStreams when channelId is not found in channelsByUUID', async () => {
render(
<StreamConnectionCard {...defaultProps({ channelsByUUID: {} })} />
);
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(<StreamConnectionCard {...defaultProps()} />);
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(<StreamConnectionCard {...defaultProps()} />);
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(<StreamConnectionCard {...defaultProps()} />);
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(<StreamConnectionCard {...defaultProps()} />);
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(<StreamConnectionCard {...defaultProps()} />);
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(<StreamConnectionCard {...defaultProps()} />);
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(<StreamConnectionCard {...defaultProps()} />);
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(
<StreamConnectionCard {...defaultProps({ channelsByUUID: {} })} />
);
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(
<StreamConnectionCard
{...defaultProps({
channel: makeChannel({ name: '', stream_id: 42 }),
})}
/>
);
await waitFor(() => {
expect(getStreamsByIds).toHaveBeenCalledWith(42);
});
});
it('does not call getStreamsByIds when channel has a name', async () => {
render(<StreamConnectionCard {...defaultProps()} />);
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(
<StreamConnectionCard
{...defaultProps({
channel: makeChannel({
m3u_profile: null,
m3u_profile_name: 'Fallback M3U',
}),
})}
/>
);
expect(screen.getByText('Fallback M3U')).toBeInTheDocument();
});
it('updates currentM3UProfile when channel m3u_profile prop changes', async () => {
const { rerender } = render(<StreamConnectionCard {...defaultProps()} />);
expect(screen.getByText('Main M3U')).toBeInTheDocument();
rerender(
<StreamConnectionCard
{...defaultProps({
channel: makeChannel({ m3u_profile: { name: 'New M3U' } }),
})}
/>
);
await waitFor(() => {
expect(screen.getByText('New M3U')).toBeInTheDocument();
});
});
});
// Network stats display
describe('network stats', () => {
it('renders formatted total bytes via formatBytes', () => {
render(<StreamConnectionCard {...defaultProps()} />);
expect(screen.getByText('1048576 B')).toBeInTheDocument();
});
it('renders formatted current bitrate via formatSpeed', () => {
render(<StreamConnectionCard {...defaultProps()} />);
// bitrates.at(-1) = 3700
expect(screen.getAllByText('3700 Kbps').length).toBeGreaterThan(0);
});
it('handles empty bitrates array gracefully', () => {
render(
<StreamConnectionCard
{...defaultProps({ channel: makeChannel({ bitrates: [] }) })}
/>
);
expect(screen.getAllByText('0 Kbps').length).toBeGreaterThan(0);
});
});
});

View file

@ -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 }) => (
<button data-testid="action-icon" data-variant={variant} data-size={size} onClick={onClick}>
{children}
</button>
),
Badge: ({ children, color, variant, size }) => (
<span data-testid="badge" data-color={color} data-variant={variant} data-size={size}>
{children}
</span>
),
Box: ({ children, pos, h, style }) => (
<div data-testid="box" data-pos={pos} style={{ height: h, ...style }}>
{children}
</div>
),
Card: ({ children, onClick, style, withBorder, shadow, radius, p }) => (
<div
data-testid="vod-card"
onClick={onClick}
style={style}
data-with-border={withBorder}
data-shadow={shadow}
data-radius={radius}
data-p={p}
>
{children}
</div>
),
CardSection: ({ children }) => <div data-testid="card-section">{children}</div>,
Group: ({ children, justify, gap, wrap }) => (
<div data-testid="group" data-justify={justify} data-gap={gap} data-wrap={wrap}>
{children}
</div>
),
Image: ({ src, alt, height, fallbackSrc, fit }) => (
<img src={src} alt={alt} data-height={height} data-fallback={fallbackSrc} data-fit={fit} />
),
Stack: ({ children, spacing, gap, p }) => (
<div data-testid="stack" data-spacing={spacing} data-gap={gap} data-p={p}>
{children}
</div>
),
Text: ({ children, size, c, weight, fw, lineClamp, style }) => (
<span
data-testid="text"
data-size={size}
data-color={c}
data-weight={weight}
data-fw={fw}
data-line-clamp={lineClamp}
style={style}
>
{children}
</span>
),
}));
// lucide-react
vi.mock('lucide-react', () => ({
Calendar: () => <svg data-testid="icon-calendar" />,
Clock: () => <svg data-testid="icon-clock" />,
Play: () => <svg data-testid="icon-play" />,
Star: () => <svg data-testid="icon-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(<VODCard vod={makeMovie()} onClick={vi.fn()} />);
expect(screen.getByTestId('vod-card')).toBeInTheDocument();
});
it('renders the movie title', () => {
render(<VODCard vod={makeMovie()} onClick={vi.fn()} />);
expect(screen.getByText('Test Movie')).toBeInTheDocument();
});
it('renders the poster image with the logo url', () => {
render(<VODCard vod={makeMovie()} onClick={vi.fn()} />);
const img = screen.getByRole('img');
expect(img).toHaveAttribute('src', 'http://example.com/poster.jpg');
});
it('renders the year when present', () => {
render(<VODCard vod={makeMovie()} onClick={vi.fn()} />);
expect(screen.getByText('2022')).toBeInTheDocument();
});
it('renders the rating when present', () => {
render(<VODCard vod={makeMovie()} onClick={vi.fn()} />);
expect(screen.getByText('8.5')).toBeInTheDocument();
});
it('renders the star icon when rating is present', () => {
render(<VODCard vod={makeMovie()} onClick={vi.fn()} />);
expect(screen.getByTestId('icon-star')).toBeInTheDocument();
});
it('renders the formatted duration via formatDuration', () => {
render(<VODCard vod={makeMovie()} onClick={vi.fn()} />);
expect(formatDuration).toHaveBeenCalledWith(120);
expect(screen.getByText('120m')).toBeInTheDocument();
});
it('renders the clock icon when duration is present', () => {
render(<VODCard vod={makeMovie()} onClick={vi.fn()} />);
expect(screen.getByTestId('icon-clock')).toBeInTheDocument();
});
it('renders genre badges', () => {
render(<VODCard vod={makeMovie()} onClick={vi.fn()} />);
expect(screen.getByText('Action')).toBeInTheDocument();
});
it('renders the calendar icon when year is present', () => {
render(<VODCard vod={makeMovie()} onClick={vi.fn()} />);
expect(screen.getByTestId('icon-calendar')).toBeInTheDocument();
});
it('renders the play icon', () => {
render(<VODCard vod={makeMovie()} onClick={vi.fn()} />);
expect(screen.getByTestId('icon-play')).toBeInTheDocument();
});
it('does not render series name for a movie', () => {
render(<VODCard vod={makeMovie()} onClick={vi.fn()} />);
expect(screen.queryByText('Test Series')).not.toBeInTheDocument();
});
});
// Rendering: episode
describe('episode rendering', () => {
it('renders the series name for an episode', () => {
render(<VODCard vod={makeEpisode()} onClick={vi.fn()} />);
expect(screen.getByText('Test Series')).toBeInTheDocument();
});
it('renders the season label via getSeasonLabel', () => {
render(<VODCard vod={makeEpisode()} onClick={vi.fn()} />);
expect(getSeasonLabel).toHaveBeenCalledWith(expect.objectContaining({ type: 'episode' }));
expect(screen.getByText(/S01E02/)).toBeInTheDocument();
});
it('renders the episode name alongside the season label', () => {
render(<VODCard vod={makeEpisode()} onClick={vi.fn()} />);
expect(screen.getByText(/Pilot/)).toBeInTheDocument();
});
it('does not render a plain title text when it is an episode with a series', () => {
render(<VODCard vod={makeEpisode()} onClick={vi.fn()} />);
// 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(<VODCard vod={makeEpisode({ series: null })} onClick={vi.fn()} />);
expect(screen.getByText('Pilot')).toBeInTheDocument();
});
});
// Poster fallback
describe('poster image', () => {
it('renders an image when logo.url is present', () => {
render(<VODCard vod={makeMovie()} onClick={vi.fn()} />);
expect(screen.getByRole('img')).toBeInTheDocument();
});
it('does not render an img tag when logo is null', () => {
render(<VODCard vod={makeMovie({ logo: null })} onClick={vi.fn()} />);
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
it('does not render an img tag when logo.url is empty string', () => {
render(<VODCard vod={makeMovie({ logo: { url: '' } })} onClick={vi.fn()} />);
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
});
// Optional metadata
describe('optional metadata', () => {
it('does not render year when absent', () => {
render(<VODCard vod={makeMovie({ year: null })} onClick={vi.fn()} />);
expect(screen.queryByTestId('icon-calendar')).not.toBeInTheDocument();
});
it('does not render rating when absent', () => {
render(<VODCard vod={makeMovie({ rating: null })} onClick={vi.fn()} />);
expect(screen.queryByTestId('icon-star')).not.toBeInTheDocument();
});
it('does not render duration when formatDuration returns null', () => {
vi.mocked(formatDuration).mockReturnValue(null);
render(<VODCard vod={makeMovie({ duration: null })} onClick={vi.fn()} />);
expect(screen.queryByTestId('icon-clock')).not.toBeInTheDocument();
});
it('does not render genre when absent', () => {
render(<VODCard vod={makeMovie({ genre: null })} onClick={vi.fn()} />);
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(<VODCard vod={vod} onClick={onClick} />);
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(<VODCard vod={vod} onClick={onClick} />);
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(<VODCard vod={vod} onClick={onClick} />);
fireEvent.click(screen.getByTestId('vod-card'));
expect(onClick).toHaveBeenCalledWith(vod);
});
});
});

View file

@ -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 }) => (
<button data-testid="action-icon" data-color={color} data-variant={variant} onClick={onClick}>
{children}
</button>
),
Badge: ({ children, color, variant, size }) => (
<span data-testid="badge" data-color={color} data-variant={variant} data-size={size}>
{children}
</span>
),
Box: ({ children, style }) => <div data-testid="box" style={style}>{children}</div>,
Card: ({ children, shadow, style }) => ( // remove padding, radius, withBorder
<div data-testid="vod-connection-card" data-shadow={shadow} style={style}>
{children}
</div>
),
Center: ({ children }) => <div data-testid="center">{children}</div>,
Flex: ({ children, justify }) => ( // remove align
<div data-testid="flex" data-justify={justify}>{children}</div>
),
Group: ({ children, justify, onClick, style }) => ( // remove gap, align, p
<div data-testid="group" data-justify={justify} onClick={onClick} style={style}>
{children}
</div>
),
Progress: ({ value, size, color }) => (
<div data-testid="progress" data-value={value} data-size={size} data-color={color} />
),
Stack: ({ children, gap, pos, mt }) => (
<div data-testid="stack" data-gap={gap} data-pos={pos} data-mt={mt}>
{children}
</div>
),
Text: ({ children, size, c, fw, color }) => ( // remove ff, ta
<span data-testid="text" data-size={size} data-c={c} data-fw={fw} data-color={color}>
{children}
</span>
),
Tooltip: ({ children, label }) => (
<div data-testid="tooltip" data-label={label}>
{children}
</div>
),
}));
// lucide-react
vi.mock('lucide-react', () => ({
ChevronDown: ({ size, style }) => (
<svg data-testid="icon-chevron-down" data-size={size} style={style} />
),
HardDriveUpload: () => <svg data-testid="icon-hdd-upload" />,
SquareX: () => <svg data-testid="icon-square-x" />,
Timer: () => <svg data-testid="icon-timer" />,
Video: () => <svg data-testid="icon-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(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
expect(screen.getByTestId('vod-connection-card')).toBeInTheDocument();
});
it('renders the poster image when logo_url is present', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
expect(screen.getByRole('img')).toHaveAttribute('src', 'http://example.com/poster.jpg');
});
it('renders the default logo when logo_url is absent', () => {
render(
<VodConnectionCard
vodContent={makeMovieContent({ content_metadata: { logo_url: null } })}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByRole('img')).toHaveAttribute('src', 'default-logo.png');
});
it('renders the stop client button', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
expect(screen.getByTestId('icon-square-x')).toBeInTheDocument();
});
it('renders the client IP in the connection section', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
expect(screen.getByText('192.168.1.100')).toBeInTheDocument();
});
it('renders "Unknown IP" when client_ip is absent', () => {
render(
<VodConnectionCard
vodContent={makeMovieContent({ individual_connection: makeConnection({ client_ip: null }) })}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByText('Unknown IP')).toBeInTheDocument();
});
it('renders "Hide Details" / "Show Details" toggle text', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
expect(screen.getByText('Show Details')).toBeInTheDocument();
});
it('does not render client section when no connection is present', () => {
render(
<VodConnectionCard
vodContent={makeMovieContent({ individual_connection: null, connections: null })}
stopVODClient={vi.fn()}
/>
);
expect(screen.queryByText('Client:')).not.toBeInTheDocument();
});
});
// Movie rendering
describe('movie content', () => {
it('calls getMovieDisplayTitle with vodContent', () => {
const vodContent = makeMovieContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
expect(getMovieDisplayTitle).toHaveBeenCalledWith(vodContent);
});
it('renders the movie display title', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
expect(screen.getByText('Test Movie (2022)')).toBeInTheDocument();
});
it('calls getMovieSubtitle with metadata', () => {
const vodContent = makeMovieContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
expect(getMovieSubtitle).toHaveBeenCalledWith(vodContent.content_metadata);
});
it('renders the movie subtitle parts', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
expect(screen.getByText(/120m/)).toBeInTheDocument();
expect(screen.getByText(/Action/)).toBeInTheDocument();
});
it('does not call getEpisodeDisplayTitle for a movie', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
expect(getEpisodeDisplayTitle).not.toHaveBeenCalled();
});
});
// Episode rendering
describe('episode content', () => {
it('calls getEpisodeDisplayTitle with vodContent', () => {
const vodContent = makeEpisodeContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
expect(getEpisodeDisplayTitle).toHaveBeenCalledWith(vodContent.content_metadata);
});
it('renders the episode display title', () => {
render(<VodConnectionCard vodContent={makeEpisodeContent()} stopVODClient={vi.fn()} />);
expect(screen.getByText('S01E02 — Pilot')).toBeInTheDocument();
});
it('calls getEpisodeSubtitle with metadata', () => {
const vodContent = makeEpisodeContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
expect(getEpisodeSubtitle).toHaveBeenCalledWith(vodContent.content_metadata);
});
it('renders the episode subtitle parts', () => {
render(<VodConnectionCard vodContent={makeEpisodeContent()} stopVODClient={vi.fn()} />);
expect(screen.getByText(/Test Series/)).toBeInTheDocument();
expect(screen.getByText(/Season 1/)).toBeInTheDocument();
});
it('does not call getMovieDisplayTitle for an episode', () => {
render(<VodConnectionCard vodContent={makeEpisodeContent()} stopVODClient={vi.fn()} />);
expect(getMovieDisplayTitle).not.toHaveBeenCalled();
});
});
// Unknown / fallback content type
describe('unknown content type', () => {
it('renders content_name as fallback title', () => {
render(<VodConnectionCard vodContent={makeUnknownContent()} stopVODClient={vi.fn()} />);
expect(screen.getByText('Some Stream')).toBeInTheDocument();
});
it('does not call getMovieDisplayTitle or getEpisodeDisplayTitle', () => {
render(<VodConnectionCard vodContent={makeUnknownContent()} stopVODClient={vi.fn()} />);
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(<VodConnectionCard vodContent={makeUnknownContent()} stopVODClient={vi.fn()} />);
// 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(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
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(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
expect(screen.getByText('10.0.0.1')).toBeInTheDocument();
});
});
// M3U profile
describe('M3U profile', () => {
it('renders M3U profile name when present', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
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(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
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(<VodConnectionCard vodContent={vodContent} stopVODClient={stopVODClient} />);
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(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={stopVODClient} />);
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(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
expect(screen.getByText('Show Details')).toBeInTheDocument();
expect(screen.queryByText('Hide Details')).not.toBeInTheDocument();
});
it('expands to show client details on header click', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
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(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
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(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
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(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
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(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
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(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
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(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
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(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
expect(screen.queryByTestId('progress')).not.toBeInTheDocument();
});
it('renders progress bar when totalTime > 0', () => {
vi.mocked(calculateProgress).mockReturnValue({ totalTime: 7200, currentTime: 3600, percentage: 50 });
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
expect(screen.getByTestId('progress')).toBeInTheDocument();
});
it('passes correct percentage value to Progress component', () => {
vi.mocked(calculateProgress).mockReturnValue({ totalTime: 7200, currentTime: 3600, percentage: 50 });
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
expect(screen.getByTestId('progress')).toHaveAttribute('data-value', '50');
});
it('calls calculateProgress with connection and duration_secs', () => {
const vodContent = makeMovieContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
expect(calculateProgress).toHaveBeenCalledWith(
vodContent.individual_connection,
vodContent.content_metadata.duration_secs
);
});
it('does not render ConnectionProgress when no connection', () => {
render(
<VodConnectionCard
vodContent={makeMovieContent({ individual_connection: null, connections: null })}
stopVODClient={vi.fn()}
/>
);
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(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
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(
<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />
);
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
});
});
// calculateConnectionDuration / calculateConnectionStartTime
describe('connection duration and start time', () => {
it('calls calculateConnectionDuration with the connection', () => {
const vodContent = makeMovieContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
expect(calculateConnectionDuration).toHaveBeenCalledWith(vodContent.individual_connection);
});
it('renders the duration returned by calculateConnectionDuration', () => {
vi.mocked(calculateConnectionDuration).mockReturnValue('12m 45s');
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
expect(screen.getByText('12m 45s')).toBeInTheDocument();
});
it('calls calculateConnectionStartTime with connection and fullDateTimeFormat', () => {
const vodContent = makeMovieContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
expect(calculateConnectionStartTime).toHaveBeenCalledWith(
vodContent.individual_connection,
'MM/DD/YYYY h:mm A'
);
});
});
});