tests: Update frontend tests

This commit is contained in:
SergeantPanda 2026-03-28 15:12:24 -05:00
parent dd97b642c7
commit 7ece48bdf2
8 changed files with 1006 additions and 280 deletions

View file

@ -57,6 +57,7 @@ vi.mock('@mantine/core', async () => {
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
ListOrdered: () => <div data-testid="icon-list-ordered" />,
CircleCheck: () => <div data-testid="circle-check-icon" />,
}));

View file

@ -33,8 +33,12 @@ vi.mock('@mantine/core', async () => {
{children}
</div>
),
PopoverTarget: ({ children }) => <div data-testid="popover-target">{children}</div>,
PopoverDropdown: ({ children }) => <div data-testid="popover-dropdown">{children}</div>,
PopoverTarget: ({ children }) => (
<div data-testid="popover-target">{children}</div>
),
PopoverDropdown: ({ children }) => (
<div data-testid="popover-dropdown">{children}</div>
),
Indicator: ({ children, label, disabled, processing }) => (
<div
data-testid="indicator"
@ -46,18 +50,53 @@ vi.mock('@mantine/core', async () => {
</div>
),
ActionIcon: ({ children, onClick, 'aria-label': ariaLabel, ...props }) => (
<button onClick={onClick} aria-label={ariaLabel} data-testid={`action-icon-${ariaLabel}`} {...props}>
<button
onClick={onClick}
aria-label={ariaLabel}
data-testid={`action-icon-${ariaLabel}`}
{...props}
>
{children}
</button>
),
ScrollAreaAutosize: ({ children }) => <div data-testid="scroll-area">{children}</div>,
Badge: ({ children, ...props }) => <span data-testid="badge" {...props}>{children}</span>,
Card: ({ children, ...props }) => <div data-testid="notification-card" {...props}>{children}</div>,
ThemeIcon: ({ children, ...props }) => <div data-testid="theme-icon" {...props}>{children}</div>,
Group: ({ children, ...props }) => <div data-testid="group" {...props}>{children}</div>,
Stack: ({ children, ...props }) => <div data-testid="stack" {...props}>{children}</div>,
Box: ({ children, ...props }) => <div data-testid="box" {...props}>{children}</div>,
Text: ({ children, ...props }) => <span data-testid="text" {...props}>{children}</span>,
ScrollAreaAutosize: ({ children }) => (
<div data-testid="scroll-area">{children}</div>
),
Badge: ({ children, ...props }) => (
<span data-testid="badge" {...props}>
{children}
</span>
),
Card: ({ children, ...props }) => (
<div data-testid="notification-card" {...props}>
{children}
</div>
),
ThemeIcon: ({ children, ...props }) => (
<div data-testid="theme-icon" {...props}>
{children}
</div>
),
Group: ({ children, ...props }) => (
<div data-testid="group" {...props}>
{children}
</div>
),
Stack: ({ children, ...props }) => (
<div data-testid="stack" {...props}>
{children}
</div>
),
Box: ({ children, ...props }) => (
<div data-testid="box" {...props}>
{children}
</div>
),
Text: ({ children, ...props }) => (
<span data-testid="text" {...props}>
{children}
</span>
),
Button: ({ children, onClick, ...props }) => (
<button onClick={onClick} data-testid="button" {...props}>
{children}
@ -84,14 +123,19 @@ vi.mock('@mantine/core', async () => {
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
ListOrdered: () => <span data-testid="icon-list-ordered">ListOrdered</span>,
Bell: () => <span data-testid="bell-icon">Bell</span>,
Check: () => <span data-testid="check-icon">Check</span>,
CheckCheck: () => <span data-testid="checkcheck-icon">CheckCheck</span>,
Download: () => <span data-testid="download-icon">Download</span>,
ExternalLink: () => <span data-testid="external-link-icon">ExternalLink</span>,
ExternalLink: () => (
<span data-testid="external-link-icon">ExternalLink</span>
),
Info: () => <span data-testid="info-icon">Info</span>,
Settings: () => <span data-testid="settings-icon">Settings</span>,
AlertTriangle: () => <span data-testid="alert-triangle-icon">AlertTriangle</span>,
AlertTriangle: () => (
<span data-testid="alert-triangle-icon">AlertTriangle</span>
),
Megaphone: () => <span data-testid="megaphone-icon">Megaphone</span>,
X: () => <span data-testid="x-icon">X</span>,
Eye: () => <span data-testid="eye-icon">Eye</span>,
@ -279,8 +323,12 @@ describe('NotificationCenter', () => {
});
it('should handle API errors gracefully', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
NotificationUtils.getNotifications.mockRejectedValue(new Error('Network error'));
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
NotificationUtils.getNotifications.mockRejectedValue(
new Error('Network error')
);
renderComponent();
@ -311,7 +359,7 @@ describe('NotificationCenter', () => {
fireEvent.click(screen.getByLabelText('Notifications'));
const eyeButtons = screen.getAllByTestId(/action-icon-/);
const toggleButton = eyeButtons.find(btn =>
const toggleButton = eyeButtons.find((btn) =>
btn.querySelector('[data-testid="eye-icon"]')
);
fireEvent.click(toggleButton);
@ -376,7 +424,10 @@ describe('NotificationCenter', () => {
fireEvent.click(xIcons[0].closest('button'));
await waitFor(() => {
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(1, 'dismissed');
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(
1,
'dismissed'
);
});
});
@ -445,7 +496,10 @@ describe('NotificationCenter', () => {
fireEvent.click(applyButton);
await waitFor(() => {
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(2, 'applied');
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(
2,
'applied'
);
expect(onSettingAction).toHaveBeenCalledWith(mockNotifications[1]);
});
});
@ -458,7 +512,10 @@ describe('NotificationCenter', () => {
fireEvent.click(ignoreButton);
await waitFor(() => {
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(2, 'dismissed');
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(
2,
'dismissed'
);
});
});
@ -520,7 +577,7 @@ describe('NotificationCenter', () => {
fireEvent.click(screen.getByLabelText('Notifications'));
const eyeButtons = screen.getAllByTestId(/action-icon-/);
const toggleButton = eyeButtons.find(btn =>
const toggleButton = eyeButtons.find((btn) =>
btn.querySelector('[data-testid="eye-icon"]')
);
fireEvent.click(toggleButton);
@ -599,7 +656,7 @@ describe('NotificationCenter', () => {
fireEvent.click(screen.getByLabelText('Notifications'));
const eyeButtons = screen.getAllByTestId(/action-icon-/);
const toggleButton = eyeButtons.find(btn =>
const toggleButton = eyeButtons.find((btn) =>
btn.querySelector('[data-testid="eye-icon"]')
);
fireEvent.click(toggleButton);
@ -610,8 +667,12 @@ describe('NotificationCenter', () => {
describe('Error Handling', () => {
it('should handle dismiss notification errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
NotificationUtils.dismissNotification.mockRejectedValue(new Error('API error'));
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
NotificationUtils.dismissNotification.mockRejectedValue(
new Error('API error')
);
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
@ -630,8 +691,12 @@ describe('NotificationCenter', () => {
});
it('should handle dismiss all notifications errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
NotificationUtils.dismissAllNotifications.mockRejectedValue(new Error('API error'));
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
NotificationUtils.dismissAllNotifications.mockRejectedValue(
new Error('API error')
);
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
@ -655,7 +720,9 @@ describe('NotificationCenter', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const expectedDate = new Date('2024-01-01T10:00:00Z').toLocaleDateString();
const expectedDate = new Date(
'2024-01-01T10:00:00Z'
).toLocaleDateString();
expect(screen.getByText(expectedDate)).toBeInTheDocument();
});
});

View file

@ -26,6 +26,7 @@ vi.mock('../../utils', () => ({
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
ListOrdered: () => <div data-testid="icon-list-ordered" />,
Play: () => <div data-testid="play-icon" />,
Copy: () => <div data-testid="copy-icon" />,
}));
@ -40,27 +41,60 @@ vi.mock('@mantine/core', async () => {
if (!opened) return null;
return (
<div data-testid="modal" data-title={title} data-size={size}>
<button onClick={onClose} data-testid="modal-close">Close</button>
<button onClick={onClose} data-testid="modal-close">
Close
</button>
<div>{children}</div>
</div>
);
},
Box: ({ children, ...props }) => <div data-testid="box" {...props}>{children}</div>,
Box: ({ children, ...props }) => (
<div data-testid="box" {...props}>
{children}
</div>
),
Button: ({ children, onClick, disabled, ...props }) => (
<button onClick={onClick} disabled={disabled} data-testid="button" {...props}>
<button
onClick={onClick}
disabled={disabled}
data-testid="button"
{...props}
>
{children}
</button>
),
Flex: ({ children, ...props }) => <div data-testid="flex" {...props}>{children}</div>,
Group: ({ children, ...props }) => <div data-testid="group" {...props}>{children}</div>,
Flex: ({ children, ...props }) => (
<div data-testid="flex" {...props}>
{children}
</div>
),
Group: ({ children, ...props }) => (
<div data-testid="group" {...props}>
{children}
</div>
),
Image: ({ src, alt, ...props }) => (
<img src={src} alt={alt} data-testid="image" {...props} />
),
Text: ({ children, ...props }) => <div data-testid="text" {...props}>{children}</div>,
Title: ({ children, order, ...props }) => (
<div data-testid="title" data-order={order} {...props}>{children}</div>
Text: ({ children, ...props }) => (
<div data-testid="text" {...props}>
{children}
</div>
),
Select: ({ value, onChange, data, label, placeholder, disabled, ...props }) => (
Title: ({ children, order, ...props }) => (
<div data-testid="title" data-order={order} {...props}>
{children}
</div>
),
Select: ({
value,
onChange,
data,
label,
placeholder,
disabled,
...props
}) => (
<div data-testid="select" data-label={label}>
<select
value={value || ''}
@ -77,39 +111,76 @@ vi.mock('@mantine/core', async () => {
</select>
</div>
),
Badge: ({ children, ...props }) => <a data-testid="badge" {...props}>{children}</a>,
Badge: ({ children, ...props }) => (
<a data-testid="badge" {...props}>
{children}
</a>
),
Loader: (props) => <div data-testid="loader" {...props} />,
Stack: ({ children, ...props }) => <div data-testid="stack" {...props}>{children}</div>,
Stack: ({ children, ...props }) => (
<div data-testid="stack" {...props}>
{children}
</div>
),
ActionIcon: ({ children, onClick, disabled, ...props }) => (
<button onClick={onClick} disabled={disabled} data-testid="action-icon" {...props}>
<button
onClick={onClick}
disabled={disabled}
data-testid="action-icon"
{...props}
>
{children}
</button>
),
Tabs: ({ children, value, onChange, ...props }) => (
<div data-testid="tabs" data-value={value} {...props}>
<div onClick={(e) => {
const tab = e.target.closest('[data-tab-value]');
if (tab) onChange?.(tab.dataset.tabValue);
}}>
<div
onClick={(e) => {
const tab = e.target.closest('[data-tab-value]');
if (tab) onChange?.(tab.dataset.tabValue);
}}
>
{children}
</div>
</div>
),
TabsList: ({ children }) => <div data-testid="tabs-list">{children}</div>,
TabsTab: ({ children, value }) => (
<button data-testid="tabs-tab" data-tab-value={value}>{children}</button>
<button data-testid="tabs-tab" data-tab-value={value}>
{children}
</button>
),
TabsPanel: ({ children, value }) => (
<div data-testid="tabs-panel" data-value={value}>{children}</div>
<div data-testid="tabs-panel" data-value={value}>
{children}
</div>
),
Table: ({ children, ...props }) => (
<table data-testid="table" {...props}>
{children}
</table>
),
TableThead: ({ children }) => (
<thead data-testid="table-thead">{children}</thead>
),
TableTbody: ({ children }) => (
<tbody data-testid="table-tbody">{children}</tbody>
),
Table: ({ children, ...props }) => <table data-testid="table" {...props}>{children}</table>,
TableThead: ({ children }) => <thead data-testid="table-thead">{children}</thead>,
TableTbody: ({ children }) => <tbody data-testid="table-tbody">{children}</tbody>,
TableTr: ({ children, onClick, ...props }) => (
<tr onClick={onClick} data-testid="table-tr" {...props}>{children}</tr>
<tr onClick={onClick} data-testid="table-tr" {...props}>
{children}
</tr>
),
TableTh: ({ children, ...props }) => (
<th data-testid="table-th" {...props}>
{children}
</th>
),
TableTd: ({ children, ...props }) => (
<td data-testid="table-td" {...props}>
{children}
</td>
),
TableTh: ({ children, ...props }) => <th data-testid="table-th" {...props}>{children}</th>,
TableTd: ({ children, ...props }) => <td data-testid="table-td" {...props}>{children}</td>,
Divider: (props) => <hr data-testid="divider" {...props} />,
};
});
@ -168,7 +239,7 @@ describe('SeriesModal', () => {
m3u_account: { name: 'Provider 2' },
stream_name: 'Test Series 720p',
quality_info: null,
}
},
];
beforeEach(() => {
@ -187,9 +258,15 @@ describe('SeriesModal', () => {
environment: { env_mode: 'prod' },
};
useVODStore.mockImplementation((selector) => selector ? selector(mockVODStore) : mockVODStore);
useVideoStore.mockImplementation((selector) => selector ? selector(mockVideoStore) : mockVideoStore);
useSettingsStore.mockImplementation((selector) => selector ? selector(mockSettingsStore) : mockSettingsStore);
useVODStore.mockImplementation((selector) =>
selector ? selector(mockVODStore) : mockVODStore
);
useVideoStore.mockImplementation((selector) =>
selector ? selector(mockVideoStore) : mockVideoStore
);
useSettingsStore.mockImplementation((selector) =>
selector ? selector(mockSettingsStore) : mockSettingsStore
);
copyToClipboard.mockResolvedValue(undefined);
});
@ -325,23 +402,37 @@ describe('SeriesModal', () => {
it('should display IMDB link when imdb_id exists', async () => {
render(
<SeriesModal series={mockDetailedSeries} opened={true} onClose={vi.fn()} />
<SeriesModal
series={mockDetailedSeries}
opened={true}
onClose={vi.fn()}
/>
);
await waitFor(() => {
const link = screen.getByText(/IMDB/i).closest('a');
expect(link).toHaveAttribute('href', 'https://www.imdb.com/title/tt1234567');
expect(link).toHaveAttribute(
'href',
'https://www.imdb.com/title/tt1234567'
);
});
});
it('should display TMDB link when tmdb_id exists', async () => {
render(
<SeriesModal series={mockDetailedSeries} opened={true} onClose={vi.fn()} />
<SeriesModal
series={mockDetailedSeries}
opened={true}
onClose={vi.fn()}
/>
);
await waitFor(() => {
const link = screen.getByText(/TMDB/i).closest('a');
expect(link).toHaveAttribute('href', 'https://www.themoviedb.org/tv/12345');
expect(link).toHaveAttribute(
'href',
'https://www.themoviedb.org/tv/12345'
);
});
});
});
@ -446,7 +537,12 @@ describe('SeriesModal', () => {
});
it('should sort episodes by episode number', async () => {
const episode2 = { ...mockEpisode, id: 2, episode_number: 2, name: 'Second Episode' };
const episode2 = {
...mockEpisode,
id: 2,
episode_number: 2,
name: 'Second Episode',
};
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episode2, mockEpisode],
@ -592,7 +688,12 @@ describe('SeriesModal', () => {
describe('Season Tabs', () => {
it('should create tabs for each season', async () => {
const season2Episode = { ...mockEpisode, id: 2, season_number: 2, episode_num: 1 };
const season2Episode = {
...mockEpisode,
id: 2,
season_number: 2,
episode_num: 1,
};
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [mockEpisode, season2Episode],
@ -670,7 +771,6 @@ describe('SeriesModal', () => {
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument();
});
@ -797,7 +897,7 @@ describe('SeriesModal', () => {
);
await waitFor(() => {
expect(screen.getByText('Test Account')).toBeInTheDocument()
expect(screen.getByText('Test Account')).toBeInTheDocument();
});
});
});

View file

@ -109,13 +109,12 @@ vi.mock('@mantine/core', async () => ({
{children}
</span>
),
Tooltip: ({ children, label }) => (
<div data-tooltip={label}>{children}</div>
),
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
}));
// lucide-react
vi.mock('lucide-react', () => ({
ListOrdered: () => <svg data-testid="icon-list-ordered" />,
AlertTriangle: () => <svg data-testid="icon-alert-triangle" />,
Plus: () => <svg data-testid="icon-plus" />,
Square: () => <svg data-testid="icon-square" />,
@ -140,8 +139,13 @@ vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' }));
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 {
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';
@ -187,7 +191,11 @@ const makeChannel = () => ({
});
/** Wire up all store/utility mocks with sensible defaults */
const setupMocks = ({ now = NOW, recording = makeRecording(), channel = makeChannel() } = {}) => {
const setupMocks = ({
now = NOW,
recording = makeRecording(),
channel = makeChannel(),
} = {}) => {
const nowMoment = makeMoment(now);
const startMoment = makeMoment(recording.start_time);
const endMoment = makeMoment(recording.end_time);
@ -226,9 +234,13 @@ const setupMocks = ({ now = NOW, recording = makeRecording(), channel = makeChan
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.getRecordingUrl).mockReturnValue(
'/recordings/test.ts'
);
vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('');
vi.mocked(RecordingCardUtils.getSeriesInfo).mockReturnValue({ seriesId: 's1' });
vi.mocked(RecordingCardUtils.getSeriesInfo).mockReturnValue({
seriesId: 's1',
});
vi.mocked(RecordingCardUtils.getShowVideoUrl).mockReturnValue('/live/ch-1');
return { mockShowVideo, mockFetchRecordings };
@ -237,10 +249,18 @@ const setupMocks = ({ now = NOW, recording = makeRecording(), channel = makeChan
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.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);
});
@ -250,17 +270,23 @@ describe('RecordingCard', () => {
describe('rendering', () => {
it('renders the recording title', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
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: {} } }),
recording: makeRecording({
custom_properties: { status: 'completed', program: {} },
}),
});
render(
<RecordingCard
recording={makeRecording({ custom_properties: { status: 'completed', program: {} } })}
recording={makeRecording({
custom_properties: { status: 'completed', program: {} },
})}
channel={makeChannel()}
/>
);
@ -269,7 +295,9 @@ describe('RecordingCard', () => {
it('renders channel info', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
expect(screen.getByText('501 • HBO')).toBeInTheDocument();
});
@ -281,27 +309,35 @@ describe('RecordingCard', () => {
it('shows description via RecordingSynopsis for non-series completed recording', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
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()} />);
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()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
expect(screen.getByText('S01E02')).toBeInTheDocument();
});
it('renders the poster image', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
const img = screen.getByAltText('Test Show');
expect(img).toHaveAttribute('src', '/poster.jpg');
});
@ -312,7 +348,9 @@ describe('RecordingCard', () => {
describe('status badge', () => {
it('shows "Completed" badge for a completed recording', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
expect(screen.getByText('Completed')).toBeInTheDocument();
});
@ -320,7 +358,10 @@ describe('RecordingCard', () => {
const recording = makeRecording({
start_time: PAST,
end_time: FUTURE,
custom_properties: { status: 'recording', program: { title: 'Live Show' } },
custom_properties: {
status: 'recording',
program: { title: 'Live Show' },
},
});
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
@ -331,7 +372,10 @@ describe('RecordingCard', () => {
const recording = makeRecording({
start_time: FUTURE,
end_time: FUTURE,
custom_properties: { status: 'scheduled', program: { title: 'Future Show' } },
custom_properties: {
status: 'scheduled',
program: { title: 'Future Show' },
},
});
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
@ -373,8 +417,7 @@ describe('RecordingCard', () => {
// Series group
describe('series group', () => {
const makeSeriesRecording = () =>
makeRecording({ _group_count: 3 });
const makeSeriesRecording = () => makeRecording({ _group_count: 3 });
it('shows "Series" badge when _group_count > 1', () => {
const recording = makeSeriesRecording();
@ -394,7 +437,9 @@ describe('RecordingCard', () => {
const recording = makeSeriesRecording();
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
expect(screen.queryByTestId('recording-synopsis')).not.toBeInTheDocument();
expect(
screen.queryByTestId('recording-synopsis')
).not.toBeInTheDocument();
});
});
@ -500,7 +545,9 @@ describe('RecordingCard', () => {
it('does not show "Watch Live" for a completed recording', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
expect(screen.queryByText('Watch Live')).not.toBeInTheDocument();
});
@ -523,7 +570,9 @@ describe('RecordingCard', () => {
it('shows "Watch" button for a completed recording', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
expect(screen.getByText('Watch')).toBeInTheDocument();
});
@ -531,7 +580,10 @@ describe('RecordingCard', () => {
const recording = makeRecording({
start_time: FUTURE,
end_time: FUTURE,
custom_properties: { status: 'scheduled', program: { title: 'Future' } },
custom_properties: {
status: 'scheduled',
program: { title: 'Future' },
},
});
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
@ -540,7 +592,9 @@ describe('RecordingCard', () => {
it('calls showVideo with vod params when Watch is clicked', () => {
const { mockShowVideo } = setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
fireEvent.click(screen.getByText('Watch'));
expect(mockShowVideo).toHaveBeenCalledWith(
'/recordings/test.ts',
@ -552,7 +606,9 @@ describe('RecordingCard', () => {
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()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
fireEvent.click(screen.getByText('Watch'));
expect(mockShowVideo).not.toHaveBeenCalled();
});
@ -575,7 +631,9 @@ describe('RecordingCard', () => {
describe('"Remove commercials" button', () => {
it('shows "Remove commercials" for a completed recording without comskip', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
expect(screen.getByText('Remove commercials')).toBeInTheDocument();
});
@ -597,7 +655,10 @@ describe('RecordingCard', () => {
const recording = makeRecording({
start_time: FUTURE,
end_time: FUTURE,
custom_properties: { status: 'scheduled', program: { title: 'Future' } },
custom_properties: {
status: 'scheduled',
program: { title: 'Future' },
},
});
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
@ -606,20 +667,31 @@ describe('RecordingCard', () => {
it('calls runComSkip and shows notification on success', async () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
fireEvent.click(screen.getByText('Remove commercials'));
await waitFor(() => {
expect(RecordingCardUtils.runComSkip).toHaveBeenCalledWith(makeRecording());
expect(RecordingCardUtils.runComSkip).toHaveBeenCalledWith(
makeRecording()
);
expect(notifications.show).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Removing commercials', color: 'blue.5' })
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'));
vi.mocked(RecordingCardUtils.runComSkip).mockRejectedValue(
new Error('fail')
);
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
fireEvent.click(screen.getByText('Remove commercials'));
await waitFor(() => {
expect(notifications.show).not.toHaveBeenCalled();
@ -634,7 +706,11 @@ describe('RecordingCard', () => {
makeRecording({
start_time: PAST,
end_time: FUTURE,
custom_properties: { status: 'recording', program: { title: 'Live Show' }, file_url: '/f.ts' },
custom_properties: {
status: 'recording',
program: { title: 'Live Show' },
file_url: '/f.ts',
},
});
it('shows extend menu for in-progress recording', () => {
@ -650,9 +726,15 @@ describe('RecordingCard', () => {
render(<RecordingCard recording={recording} channel={makeChannel()} />);
fireEvent.click(screen.getByText('+15 minutes'));
await waitFor(() => {
expect(RecordingCardUtils.extendRecordingById).toHaveBeenCalledWith('rec-1', 15);
expect(RecordingCardUtils.extendRecordingById).toHaveBeenCalledWith(
'rec-1',
15
);
expect(notifications.show).toHaveBeenCalledWith(
expect.objectContaining({ title: 'Recording extended', color: 'teal' })
expect.objectContaining({
title: 'Recording extended',
color: 'teal',
})
);
});
});
@ -663,7 +745,10 @@ describe('RecordingCard', () => {
render(<RecordingCard recording={recording} channel={makeChannel()} />);
fireEvent.click(screen.getByText('+30 minutes'));
await waitFor(() => {
expect(RecordingCardUtils.extendRecordingById).toHaveBeenCalledWith('rec-1', 30);
expect(RecordingCardUtils.extendRecordingById).toHaveBeenCalledWith(
'rec-1',
30
);
});
});
@ -673,12 +758,17 @@ describe('RecordingCard', () => {
render(<RecordingCard recording={recording} channel={makeChannel()} />);
fireEvent.click(screen.getByText('+1 hour'));
await waitFor(() => {
expect(RecordingCardUtils.extendRecordingById).toHaveBeenCalledWith('rec-1', 60);
expect(RecordingCardUtils.extendRecordingById).toHaveBeenCalledWith(
'rec-1',
60
);
});
});
it('shows error notification when extendRecordingById throws', async () => {
vi.mocked(RecordingCardUtils.extendRecordingById).mockRejectedValue(new Error('Network error'));
vi.mocked(RecordingCardUtils.extendRecordingById).mockRejectedValue(
new Error('Network error')
);
const recording = makeInProgress();
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
@ -698,7 +788,11 @@ describe('RecordingCard', () => {
makeRecording({
start_time: PAST,
end_time: FUTURE,
custom_properties: { status: 'recording', program: { title: 'Live Show' }, file_url: '/f.ts' },
custom_properties: {
status: 'recording',
program: { title: 'Live Show' },
file_url: '/f.ts',
},
});
it('shows stop modal when stop button is clicked', () => {
@ -711,7 +805,9 @@ describe('RecordingCard', () => {
fireEvent.click(stopButton);
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByTestId('modal-title')).toHaveTextContent('Stop Recording');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Stop Recording'
);
});
it('closes stop modal when Go Back is clicked', () => {
@ -736,7 +832,9 @@ describe('RecordingCard', () => {
fireEvent.click(screen.getAllByText('Stop Recording')[1]);
await waitFor(() => {
expect(RecordingCardUtils.stopRecordingById).toHaveBeenCalledWith('rec-1');
expect(RecordingCardUtils.stopRecordingById).toHaveBeenCalledWith(
'rec-1'
);
expect(mockFetchRecordings).toHaveBeenCalled();
});
});
@ -761,48 +859,71 @@ describe('RecordingCard', () => {
describe('delete recording', () => {
it('shows delete modal for a completed non-series recording', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
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');
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' } },
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');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Cancel Recording');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Cancel Recording'
);
});
it('calls removeRecording when delete is confirmed', async () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
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');
expect(RecordingCardUtils.removeRecording).toHaveBeenCalledWith(
'rec-1'
);
});
});
it('closes delete modal after confirming', async () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
fireEvent.click(screen.getByText('Delete'));
@ -813,9 +934,13 @@ describe('RecordingCard', () => {
it('closes delete modal on Go Back click', () => {
setupMocks();
render(<RecordingCard recording={makeRecording()} channel={makeChannel()} />);
render(
<RecordingCard recording={makeRecording()} channel={makeChannel()} />
);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
fireEvent.click(screen.getByText('Go Back'));
@ -842,10 +967,14 @@ describe('RecordingCard', () => {
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Cancel Series');
expect(screen.getByTestId('modal-title')).toHaveTextContent(
'Cancel Series'
);
});
it('calls deleteRecordingById when "Only this upcoming" is clicked', async () => {
@ -853,12 +982,16 @@ describe('RecordingCard', () => {
const { mockFetchRecordings } = setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
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(RecordingCardUtils.deleteRecordingById).toHaveBeenCalledWith(
'rec-1'
);
expect(mockFetchRecordings).toHaveBeenCalled();
});
});
@ -868,7 +1001,9 @@ describe('RecordingCard', () => {
const { mockFetchRecordings } = setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
fireEvent.click(screen.getByText('Entire series + rule'));
@ -883,7 +1018,9 @@ describe('RecordingCard', () => {
setupMocks({ recording });
render(<RecordingCard recording={recording} channel={makeChannel()} />);
const deleteButton = screen.getByTestId('icon-square-x').closest('button');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
fireEvent.click(screen.getByText('Only this upcoming'));
@ -918,13 +1055,18 @@ describe('RecordingCard', () => {
_group_count: 3,
start_time: FUTURE,
end_time: FUTURE,
custom_properties: { status: 'scheduled', program: { title: 'Series' } },
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');
const deleteButton = screen
.getByTestId('icon-square-x')
.closest('button');
fireEvent.click(deleteButton);
await expect(
@ -932,4 +1074,4 @@ describe('RecordingCard', () => {
).resolves.not.toThrow();
});
});
});
});

View file

@ -30,7 +30,13 @@ vi.mock('@mantine/core', async () => ({
),
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}>
<span
data-testid="text"
data-size={size}
data-fw={fw}
data-color={c}
style={style}
>
{children}
</span>
),
@ -38,6 +44,7 @@ vi.mock('@mantine/core', async () => ({
// lucide-react
vi.mock('lucide-react', () => ({
ListOrdered: () => <svg data-testid="icon-list-ordered" />,
Calendar: () => <svg data-testid="icon-calendar" />,
Play: () => <svg data-testid="icon-play" />,
Star: () => <svg data-testid="icon-star" />,
@ -78,7 +85,12 @@ describe('SeriesCard', () => {
});
it('renders a fallback image when poster_url is missing', () => {
render(<SeriesCard series={makeSeries({ poster_url: null })} onClick={vi.fn()} />);
render(
<SeriesCard
series={makeSeries({ poster_url: null })}
onClick={vi.fn()}
/>
);
const img = screen.getByRole('img');
expect(img).toBeInTheDocument();
});
@ -105,7 +117,12 @@ describe('SeriesCard', () => {
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()} />);
render(
<SeriesCard
series={makeSeries({ logo: { url: null } })}
onClick={vi.fn()}
/>
);
expect(screen.getByTestId('icon-play')).toBeInTheDocument();
});
@ -119,27 +136,52 @@ describe('SeriesCard', () => {
describe('optional fields', () => {
it('does not crash when year is missing', () => {
render(<SeriesCard series={makeSeries({ year: undefined })} onClick={vi.fn()} />);
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()} />);
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()} />);
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()} />);
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()} />);
render(
<SeriesCard
series={makeSeries({ seasons: undefined })}
onClick={vi.fn()}
/>
);
expect(screen.getByTestId('series-card')).toBeInTheDocument();
});
@ -161,4 +203,4 @@ describe('SeriesCard', () => {
expect(onClick).toHaveBeenCalledWith(series);
});
});
});
});

View file

@ -95,7 +95,12 @@ vi.mock('@mantine/core', () => ({
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} />
<div
data-testid="progress"
data-value={value}
data-size={size}
data-color={color}
/>
),
Select: ({ value, onChange, label, data, disabled, placeholder }) => (
<select
@ -129,9 +134,7 @@ vi.mock('@mantine/core', () => ({
{children}
</span>
),
Tooltip: ({ children, label }) => (
<div data-tooltip={label}>{children}</div>
),
Tooltip: ({ children, label }) => <div data-tooltip={label}>{children}</div>,
useMantineTheme: vi.fn(() => ({
tailwind: { green: { 5: '#22c55e' } },
})),
@ -139,6 +142,22 @@ vi.mock('@mantine/core', () => ({
// lucide-react
vi.mock('lucide-react', () => ({
// navigation.js icons (all must be present for the authnavigation import chain)
ListOrdered: () => <svg data-testid="icon-list-ordered" />,
Play: () => <svg data-testid="icon-play" />,
Database: () => <svg data-testid="icon-database" />,
LayoutGrid: () => <svg data-testid="icon-layout-grid" />,
Settings: () => <svg data-testid="icon-settings" />,
ChartLine: () => <svg data-testid="icon-chart-line" />,
Video: () => <svg data-testid="icon-video" />,
PlugZap: () => <svg data-testid="icon-plug-zap" />,
User: () => <svg data-testid="icon-user" />,
FileImage: () => <svg data-testid="icon-file-image" />,
Webhook: () => <svg data-testid="icon-webhook" />,
Logs: () => <svg data-testid="icon-logs" />,
Blocks: () => <svg data-testid="icon-blocks" />,
MonitorCog: () => <svg data-testid="icon-monitor-cog" />,
// StreamConnectionCard-specific icons
ChevronDown: () => <svg data-testid="icon-chevron-down" />,
ChevronRight: () => <svg data-testid="icon-chevron-right" />,
CirclePlay: () => <svg data-testid="icon-circle-play" />,
@ -149,7 +168,6 @@ vi.mock('lucide-react', () => ({
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
@ -264,13 +282,17 @@ describe('StreamConnectionCard', () => {
describe('route guard', () => {
it('renders nothing when pathname is not /stats', () => {
setupLocation('/dashboard');
const { container } = render(<StreamConnectionCard {...defaultProps()} />);
const { container } = render(
<StreamConnectionCard {...defaultProps()} />
);
expect(container.firstChild).toBeNull();
});
it('renders nothing when pathname is /channels', () => {
setupLocation('/channels');
const { container } = render(<StreamConnectionCard {...defaultProps()} />);
const { container } = render(
<StreamConnectionCard {...defaultProps()} />
);
expect(container.firstChild).toBeNull();
});
@ -337,7 +359,9 @@ describe('StreamConnectionCard', () => {
vi.mocked(getStreamsByIds).mockResolvedValue([]);
render(
<StreamConnectionCard
{...defaultProps({ channel: makeChannel({ name: '', stream_id: null }) })}
{...defaultProps({
channel: makeChannel({ name: '', stream_id: null }),
})}
/>
);
expect(screen.getByText('Unnamed Channel')).toBeInTheDocument();
@ -465,7 +489,9 @@ describe('StreamConnectionCard', () => {
describe('current program', () => {
it('does not render program section when currentProgram is null', () => {
render(<StreamConnectionCard {...defaultProps({ currentProgram: null })} />);
render(
<StreamConnectionCard {...defaultProps({ currentProgram: null })} />
);
expect(screen.queryByText('Now Playing:')).not.toBeInTheDocument();
});
@ -493,7 +519,9 @@ describe('StreamConnectionCard', () => {
{...defaultProps({ currentProgram: makeCurrentProgram() })}
/>
);
expect(screen.queryByText('Daily news broadcast.')).not.toBeInTheDocument();
expect(
screen.queryByText('Daily news broadcast.')
).not.toBeInTheDocument();
});
it('expands program description when chevron button is clicked', () => {
@ -502,7 +530,9 @@ describe('StreamConnectionCard', () => {
{...defaultProps({ currentProgram: makeCurrentProgram() })}
/>
);
const chevronBtn = screen.getByTestId('icon-chevron-right').closest('button');
const chevronBtn = screen
.getByTestId('icon-chevron-right')
.closest('button');
fireEvent.click(chevronBtn);
expect(screen.getByText('Daily news broadcast.')).toBeInTheDocument();
});
@ -513,12 +543,18 @@ describe('StreamConnectionCard', () => {
{...defaultProps({ currentProgram: makeCurrentProgram() })}
/>
);
const chevronBtn = screen.getByTestId('icon-chevron-right').closest('button');
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');
const chevronDownBtn = screen
.getByTestId('icon-chevron-down')
.closest('button');
fireEvent.click(chevronDownBtn);
expect(screen.queryByText('Daily news broadcast.')).not.toBeInTheDocument();
expect(
screen.queryByText('Daily news broadcast.')
).not.toBeInTheDocument();
});
it('renders program progress when expanded and times are present', () => {
@ -527,7 +563,9 @@ describe('StreamConnectionCard', () => {
{...defaultProps({ currentProgram: makeCurrentProgram() })}
/>
);
const chevronBtn = screen.getByTestId('icon-chevron-right').closest('button');
const chevronBtn = screen
.getByTestId('icon-chevron-right')
.closest('button');
fireEvent.click(chevronBtn);
expect(screen.getByTestId('progress')).toBeInTheDocument();
});
@ -536,11 +574,16 @@ describe('StreamConnectionCard', () => {
render(
<StreamConnectionCard
{...defaultProps({
currentProgram: makeCurrentProgram({ start_time: null, end_time: null }),
currentProgram: makeCurrentProgram({
start_time: null,
end_time: null,
}),
})}
/>
);
const chevronBtn = screen.getByTestId('icon-chevron-right').closest('button');
const chevronBtn = screen
.getByTestId('icon-chevron-right')
.closest('button');
fireEvent.click(chevronBtn);
expect(screen.queryByTestId('progress')).not.toBeInTheDocument();
});
@ -570,9 +613,8 @@ describe('StreamConnectionCard', () => {
{ 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'
);
const { getStreamOptions } =
await import('../../../utils/cards/StreamConnectionCardUtils.js');
vi.mocked(getStreamOptions).mockReturnValue([
{ value: '10', label: 'Stream A' },
{ value: '11', label: 'Stream B' },
@ -585,7 +627,12 @@ describe('StreamConnectionCard', () => {
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 },
{
id: 42,
name: 'Stream A',
url: 'http://stream.example.com/ch1',
m3u_profile: null,
},
]);
vi.mocked(getMatchingStreamByUrl).mockReturnValue({
id: 42,
@ -614,11 +661,15 @@ describe('StreamConnectionCard', () => {
describe('stream switching', () => {
beforeEach(async () => {
vi.mocked(getChannelStreams).mockResolvedValue([
{ id: 10, name: 'Stream A', url: 'http://a.com', m3u_profile: { name: 'M3U A' } },
{
id: 10,
name: 'Stream A',
url: 'http://a.com',
m3u_profile: { name: 'M3U A' },
},
]);
const { getStreamOptions } = await import(
'../../../utils/cards/StreamConnectionCardUtils.js'
);
const { getStreamOptions } =
await import('../../../utils/cards/StreamConnectionCardUtils.js');
vi.mocked(getStreamOptions).mockReturnValue([
{ value: '10', label: 'Stream A' },
]);
@ -633,7 +684,9 @@ describe('StreamConnectionCard', () => {
vi.mocked(switchStream).mockResolvedValue({});
render(<StreamConnectionCard {...defaultProps()} />);
await waitFor(() => screen.getByTestId('select'));
fireEvent.change(screen.getByTestId('select'), { target: { value: '10' } });
fireEvent.change(screen.getByTestId('select'), {
target: { value: '10' },
});
await waitFor(() => {
expect(switchStream).toHaveBeenCalledWith(
expect.objectContaining({ channel_id: 'ch-uuid-1' }),
@ -646,7 +699,9 @@ describe('StreamConnectionCard', () => {
vi.mocked(switchStream).mockResolvedValue({});
render(<StreamConnectionCard {...defaultProps()} />);
await waitFor(() => screen.getByTestId('select'));
fireEvent.change(screen.getByTestId('select'), { target: { value: '10' } });
fireEvent.change(screen.getByTestId('select'), {
target: { value: '10' },
});
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'blue.5' })
@ -658,7 +713,9 @@ describe('StreamConnectionCard', () => {
vi.mocked(switchStream).mockRejectedValue(new Error('Switch failed'));
render(<StreamConnectionCard {...defaultProps()} />);
await waitFor(() => screen.getByTestId('select'));
fireEvent.change(screen.getByTestId('select'), { target: { value: '10' } });
fireEvent.change(screen.getByTestId('select'), {
target: { value: '10' },
});
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ color: 'red.5' })
@ -672,7 +729,9 @@ describe('StreamConnectionCard', () => {
});
render(<StreamConnectionCard {...defaultProps()} />);
await waitFor(() => screen.getByTestId('select'));
fireEvent.change(screen.getByTestId('select'), { target: { value: '10' } });
fireEvent.change(screen.getByTestId('select'), {
target: { value: '10' },
});
await waitFor(() => {
expect(screen.getByText('Updated M3U')).toBeInTheDocument();
});
@ -686,7 +745,9 @@ describe('StreamConnectionCard', () => {
vi.mocked(getChannelStreams).mockResolvedValue([]);
render(<StreamConnectionCard {...defaultProps()} />);
await waitFor(() => {
expect(screen.queryByTestId('icon-circle-play')).not.toBeInTheDocument();
expect(
screen.queryByTestId('icon-circle-play')
).not.toBeInTheDocument();
});
});
@ -694,10 +755,11 @@ describe('StreamConnectionCard', () => {
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' }]);
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();
@ -712,10 +774,11 @@ describe('StreamConnectionCard', () => {
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' }]);
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'));
@ -738,10 +801,11 @@ describe('StreamConnectionCard', () => {
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' }]);
const { getStreamOptions } =
await import('../../../utils/cards/StreamConnectionCardUtils.js');
vi.mocked(getStreamOptions).mockReturnValue([
{ value: '10', label: 'Stream A' },
]);
render(
<StreamConnectionCard {...defaultProps({ channelsByUUID: {} })} />
@ -837,4 +901,4 @@ describe('StreamConnectionCard', () => {
expect(screen.getAllByText('0 Kbps').length).toBeGreaterThan(0);
});
});
});
});

View file

@ -10,12 +10,22 @@ vi.mock('../../../utils/cards/VODCardUtils.js', () => ({
// Mantine core
vi.mock('@mantine/core', () => ({
ActionIcon: ({ children, onClick, variant, size }) => (
<button data-testid="action-icon" data-variant={variant} data-size={size} onClick={onClick}>
<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}>
<span
data-testid="badge"
data-color={color}
data-variant={variant}
data-size={size}
>
{children}
</span>
),
@ -37,14 +47,27 @@ vi.mock('@mantine/core', () => ({
{children}
</div>
),
CardSection: ({ children }) => <div data-testid="card-section">{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}>
<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} />
<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}>
@ -68,6 +91,7 @@ vi.mock('@mantine/core', () => ({
// lucide-react
vi.mock('lucide-react', () => ({
ListOrdered: () => <svg data-testid="icon-list-ordered" />,
Calendar: () => <svg data-testid="icon-calendar" />,
Clock: () => <svg data-testid="icon-clock" />,
Play: () => <svg data-testid="icon-play" />,
@ -75,7 +99,10 @@ vi.mock('lucide-react', () => ({
}));
// Imports after mocks
import { formatDuration, getSeasonLabel } from '../../../utils/cards/VODCardUtils.js';
import {
formatDuration,
getSeasonLabel,
} from '../../../utils/cards/VODCardUtils.js';
import VODCard from '../VODCard';
// Helpers
@ -113,7 +140,9 @@ const makeEpisode = (overrides = {}) => ({
describe('VODCard', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(formatDuration).mockImplementation((mins) => (mins ? `${mins}m` : null));
vi.mocked(formatDuration).mockImplementation((mins) =>
mins ? `${mins}m` : null
);
vi.mocked(getSeasonLabel).mockReturnValue('S01E02');
});
@ -193,7 +222,9 @@ describe('VODCard', () => {
it('renders the season label via getSeasonLabel', () => {
render(<VODCard vod={makeEpisode()} onClick={vi.fn()} />);
expect(getSeasonLabel).toHaveBeenCalledWith(expect.objectContaining({ type: 'episode' }));
expect(getSeasonLabel).toHaveBeenCalledWith(
expect.objectContaining({ type: 'episode' })
);
expect(screen.getByText(/S01E02/)).toBeInTheDocument();
});
@ -230,7 +261,9 @@ describe('VODCard', () => {
});
it('does not render an img tag when logo.url is empty string', () => {
render(<VODCard vod={makeMovie({ logo: { url: '' } })} onClick={vi.fn()} />);
render(
<VODCard vod={makeMovie({ logo: { url: '' } })} onClick={vi.fn()} />
);
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});
});
@ -257,8 +290,9 @@ describe('VODCard', () => {
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');
const dimmedBadges = badges.filter(
(badge) => badge.getAttribute('data-color') === 'dimmed'
);
expect(dimmedBadges.length).toBe(0);
});
});
@ -291,4 +325,4 @@ describe('VODCard', () => {
expect(onClick).toHaveBeenCalledWith(vod);
});
});
});
});

View file

@ -13,7 +13,11 @@ vi.mock('../../../utils/dateTimeUtils.js', () => ({
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 })),
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'),
@ -28,42 +32,82 @@ 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}>
<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}>
<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
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>
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}>
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} />
<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>
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}>
@ -74,6 +118,7 @@ vi.mock('@mantine/core', () => ({
// lucide-react
vi.mock('lucide-react', () => ({
ListOrdered: () => <svg data-testid="icon-list-ordered" />,
ChevronDown: ({ size, style }) => (
<svg data-testid="icon-chevron-down" data-size={size} style={style} />
),
@ -100,12 +145,13 @@ import VodConnectionCard from '../VodConnectionCard';
const makeConnection = (overrides = {}) => ({
client_ip: '192.168.1.100',
client_id: 'client-abc-123', // needed for stopVODClient
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
m3u_profile: {
// M3U profile lives on connection
account_name: 'Main Account',
profile_name: 'Main M3U',
},
@ -161,14 +207,22 @@ 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(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');
vi.mocked(calculateConnectionStartTime).mockReturnValue(
'Jan 1 2024 10:00 AM'
);
});
afterEach(() => {
@ -179,39 +233,69 @@ describe('VodConnectionCard', () => {
describe('rendering', () => {
it('renders the card element', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
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');
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 } })}
vodContent={makeMovieContent({
content_metadata: { logo_url: null },
})}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByRole('img')).toHaveAttribute('src', 'default-logo.png');
expect(screen.getByRole('img')).toHaveAttribute(
'src',
'default-logo.png'
);
});
it('renders the stop client button', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
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()} />);
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 }) })}
vodContent={makeMovieContent({
individual_connection: makeConnection({ client_ip: null }),
})}
stopVODClient={vi.fn()}
/>
);
@ -219,14 +303,22 @@ describe('VodConnectionCard', () => {
});
it('renders "Hide Details" / "Show Details" toggle text', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
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 })}
vodContent={makeMovieContent({
individual_connection: null,
connections: null,
})}
stopVODClient={vi.fn()}
/>
);
@ -239,29 +331,50 @@ describe('VodConnectionCard', () => {
describe('movie content', () => {
it('calls getMovieDisplayTitle with vodContent', () => {
const vodContent = makeMovieContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
expect(getMovieDisplayTitle).toHaveBeenCalledWith(vodContent);
});
it('renders the movie display title', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
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);
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()} />);
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()} />);
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(getEpisodeDisplayTitle).not.toHaveBeenCalled();
});
});
@ -271,29 +384,52 @@ describe('VodConnectionCard', () => {
describe('episode content', () => {
it('calls getEpisodeDisplayTitle with vodContent', () => {
const vodContent = makeEpisodeContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
expect(getEpisodeDisplayTitle).toHaveBeenCalledWith(vodContent.content_metadata);
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()} />);
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);
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()} />);
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()} />);
render(
<VodConnectionCard
vodContent={makeEpisodeContent()}
stopVODClient={vi.fn()}
/>
);
expect(getMovieDisplayTitle).not.toHaveBeenCalled();
});
});
@ -302,12 +438,22 @@ describe('VodConnectionCard', () => {
describe('unknown content type', () => {
it('renders content_name as fallback title', () => {
render(<VodConnectionCard vodContent={makeUnknownContent()} stopVODClient={vi.fn()} />);
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()} />);
render(
<VodConnectionCard
vodContent={makeUnknownContent()}
stopVODClient={vi.fn()}
/>
);
expect(getMovieDisplayTitle).not.toHaveBeenCalled();
expect(getEpisodeDisplayTitle).not.toHaveBeenCalled();
});
@ -315,7 +461,12 @@ describe('VodConnectionCard', () => {
it('renders no subtitle when subtitle parts are empty', () => {
vi.mocked(getMovieSubtitle).mockReturnValue([]);
vi.mocked(getEpisodeSubtitle).mockReturnValue([]);
render(<VodConnectionCard vodContent={makeUnknownContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeUnknownContent()}
stopVODClient={vi.fn()}
/>
);
// No " " separator rendered for empty subtitle
expect(screen.queryByText(' • ')).not.toBeInTheDocument();
});
@ -326,7 +477,9 @@ describe('VodConnectionCard', () => {
describe('connection source fallback', () => {
it('uses individual_connection when present', () => {
const vodContent = makeMovieContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
expect(screen.getByText('192.168.1.100')).toBeInTheDocument();
});
@ -336,7 +489,9 @@ describe('VodConnectionCard', () => {
individual_connection: null,
connections: [connection],
});
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
expect(screen.getByText('10.0.0.1')).toBeInTheDocument();
});
});
@ -345,7 +500,12 @@ describe('VodConnectionCard', () => {
describe('M3U profile', () => {
it('renders M3U profile name when present', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
expect(screen.getByText('Main M3U')).toBeInTheDocument();
});
@ -353,7 +513,9 @@ describe('VodConnectionCard', () => {
const vodContent = makeMovieContent({
individual_connection: makeConnection({ m3u_profile: null }),
});
render(<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />);
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
expect(screen.queryByText('Main M3U')).not.toBeInTheDocument();
});
});
@ -364,14 +526,24 @@ describe('VodConnectionCard', () => {
it('calls stopVODClient with the connection when stop button is clicked', () => {
const stopVODClient = vi.fn();
const vodContent = makeMovieContent();
render(<VodConnectionCard vodContent={vodContent} stopVODClient={stopVODClient} />);
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} />);
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);
@ -382,30 +554,58 @@ describe('VodConnectionCard', () => {
describe('client expand/collapse', () => {
it('starts collapsed (Show Details visible)', () => {
render(<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />);
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"]');
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"]');
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"]');
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"]'));
fireEvent.click(
screen.getByText('Hide Details').closest('[data-testid="group"]')
);
expect(screen.getByText('Show Details')).toBeInTheDocument();
});
@ -413,8 +613,12 @@ describe('VodConnectionCard', () => {
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"]');
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();
});
@ -423,8 +627,12 @@ describe('VodConnectionCard', () => {
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"]');
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();
@ -434,8 +642,12 @@ describe('VodConnectionCard', () => {
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"]');
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();
});
@ -444,8 +656,12 @@ describe('VodConnectionCard', () => {
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"]');
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();
});
@ -455,26 +671,58 @@ describe('VodConnectionCard', () => {
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()} />);
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()} />);
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');
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()} />);
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
expect(calculateProgress).toHaveBeenCalledWith(
vodContent.individual_connection,
vodContent.content_metadata.duration_secs
@ -484,7 +732,10 @@ describe('VodConnectionCard', () => {
it('does not render ConnectionProgress when no connection', () => {
render(
<VodConnectionCard
vodContent={makeMovieContent({ individual_connection: null, connections: null })}
vodContent={makeMovieContent({
individual_connection: null,
connections: null,
})}
stopVODClient={vi.fn()}
/>
);
@ -496,11 +747,22 @@ describe('VodConnectionCard', () => {
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()} />);
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); });
act(() => {
vi.advanceTimersByTime(1000);
});
const callsAfter = vi.mocked(calculateProgress).mock.calls.length;
expect(callsAfter).toBeGreaterThan(callsBefore);
@ -509,7 +771,10 @@ describe('VodConnectionCard', () => {
it('clears the interval on unmount', () => {
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');
const { unmount } = render(
<VodConnectionCard vodContent={makeMovieContent()} stopVODClient={vi.fn()} />
<VodConnectionCard
vodContent={makeMovieContent()}
stopVODClient={vi.fn()}
/>
);
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
@ -521,23 +786,34 @@ describe('VodConnectionCard', () => {
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);
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()} />);
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()} />);
render(
<VodConnectionCard vodContent={vodContent} stopVODClient={vi.fn()} />
);
expect(calculateConnectionStartTime).toHaveBeenCalledWith(
vodContent.individual_connection,
'MM/DD/YYYY h:mm A'
);
});
});
});
});