mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
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
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:
commit
0bc4c40400
9 changed files with 3344 additions and 81 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
483
frontend/src/components/cards/__tests__/PluginCard.test.jsx
Normal file
483
frontend/src/components/cards/__tests__/PluginCard.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
935
frontend/src/components/cards/__tests__/RecordingCard.test.jsx
Normal file
935
frontend/src/components/cards/__tests__/RecordingCard.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
164
frontend/src/components/cards/__tests__/SeriesCard.test.jsx
Normal file
164
frontend/src/components/cards/__tests__/SeriesCard.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
294
frontend/src/components/cards/__tests__/VODCard.test.jsx
Normal file
294
frontend/src/components/cards/__tests__/VODCard.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue