Merge pull request #1049 from nick4810:tests/frontend-unit-tests

Tests/frontend unit tests
This commit is contained in:
SergeantPanda 2026-03-06 15:56:58 -06:00 committed by GitHub
commit 29943c823f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 9442 additions and 1501 deletions

View file

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Unit tests for `NotificationCenter`, `NotificationCenterUtils`, and `M3URefreshNotification` components. — Thanks [@nick4810](https://github.com/nick4810)
- Floating video player now displays the channel, stream, or VOD title in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal.
- New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 5 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings → Proxy as "New Client Buffer (seconds)".
- Channel table filter for channels that have stale streams: A new "Has Stale Streams" filter option in the channel table header menu highlights and filters channels containing at least one stale stream. Channels with stale streams are visually distinguished with an orange tint. The filter is mutually exclusive with "Only Empty Channels". - Thanks [@JCBird1012](https://github.com/JCBird1012)
@ -25,6 +26,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Frontend component refactoring and cleanup — Thanks [@nick4810](https://github.com/nick4810)
- `FloatingVideo`, `SeriesModal`, `VODModal`, `SystemEvents`, `M3URefreshNotification`, and `NotificationCenter` significantly reduced in size by separating business logic into dedicated utility modules under `utils/components/` (`FloatingVideoUtils.js`, `SeriesModalUtils.js`, `VODModalUtils.js`, `NotificationCenterUtils.js`).
- `FloatingVideo` resize handle elements extracted into a standalone `ResizeHandles` sub-component.
- `YouTubeTrailerModal` extracted into a standalone component (`components/modals/YouTubeTrailerModal.jsx`).
- `NotificationCenter` and `Sidebar` updated from Mantine dot-notation sub-components (`Popover.Target`, `Popover.Dropdown`, `ScrollArea.Autosize`, `AppShell.Navbar`) to Mantine v7 named imports (`PopoverTarget`, `PopoverDropdown`, `ScrollAreaAutosize`, `AppShellNavbar`).
- `M3URefreshNotification` now uses the centralized `showNotification()` utility (from `notificationUtils.js`) instead of calling `notifications.show()` directly, bringing it in line with the rest of the app. State updates also converted to functional updater form (`prev => ...`) to eliminate potential stale-closure bugs.
- `SystemEvents` now imports `format` from `dateTimeUtils` for consistent date/time formatting.
- Removed a dead `onLogout` handler in `Sidebar` that called `logout()` and `window.location.reload()` but was never wired to any UI element.
- EPG output when no `days` parameter is specified now excludes already-ended programs instead of returning all historical data.
### Fixed

View file

@ -1,4 +1,4 @@
import { Modal, Group, Text, Button, Checkbox, Box } from '@mantine/core';
import { Modal, Group, Button, Checkbox, Box } from '@mantine/core';
import React, { useState } from 'react';
import useWarningsStore from '../store/warnings';

View file

@ -12,6 +12,7 @@ export const Field = ({ field, value, onChange }) => {
const description = field.help_text ?? field.description ?? field.value;
const common = { label: field.label, description };
const effective = value ?? field.default;
switch (field.type) {
case 'info':
return (

View file

@ -4,36 +4,18 @@ import Draggable from 'react-draggable';
import useVideoStore from '../store/useVideoStore';
import mpegts from 'mpegts.js';
import { CloseButton, Flex, Loader, Text, Box } from '@mantine/core';
import {
applyConstraints,
calculateNewDimensions,
getClientCoordinates,
getLivePlayerErrorMessage,
getVODPlayerErrorMessage,
} from '../utils/components/FloatingVideoUtils.js';
export default function FloatingVideo() {
const isVisible = useVideoStore((s) => s.isVisible);
const streamUrl = useVideoStore((s) => s.streamUrl);
const contentType = useVideoStore((s) => s.contentType);
const metadata = useVideoStore((s) => s.metadata);
const hideVideo = useVideoStore((s) => s.hideVideo);
const videoRef = useRef(null);
const playerRef = useRef(null);
const videoContainerRef = useRef(null);
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState(null);
const [showOverlay, setShowOverlay] = useState(true);
const [videoSize, setVideoSize] = useState({ width: 320, height: 180 });
const [isResizing, setIsResizing] = useState(false);
const resizeStateRef = useRef(null);
const overlayTimeoutRef = useRef(null);
const aspectRatioRef = useRef(320 / 180);
const [dragPosition, setDragPosition] = useState(null);
const dragPositionRef = useRef(null);
const dragOffsetRef = useRef({ x: 0, y: 0 });
const initialPositionRef = useRef(null);
const MIN_WIDTH = 220;
const MIN_HEIGHT = 124;
const VISIBLE_MARGIN = 48; // keep part of the window visible when dragging
const HEADER_HEIGHT = 38; // height of the close button header area
const ERROR_HEIGHT = 45; // approximate height of error message area when displayed
const ResizeHandles = ({ startResize }) => {
const HANDLE_SIZE = 18;
const HANDLE_OFFSET = 0;
const resizeHandleBaseStyle = {
position: 'absolute',
width: HANDLE_SIZE,
@ -43,6 +25,7 @@ export default function FloatingVideo() {
zIndex: 8,
touchAction: 'none',
};
const resizeHandles = [
{
id: 'bottom-right',
@ -106,6 +89,56 @@ export default function FloatingVideo() {
},
];
return (
<>
{/* Resize handles */}
{resizeHandles.map((handle) => (
<Box
key={handle.id}
className="floating-video-no-drag"
onMouseDown={(event) => startResize(event, handle)}
onTouchStart={(event) => startResize(event, handle)}
style={{
...resizeHandleBaseStyle,
...handle.style,
cursor: handle.cursor,
}}
/>
))}
</>
);
}
export default function FloatingVideo() {
const isVisible = useVideoStore((s) => s.isVisible);
const streamUrl = useVideoStore((s) => s.streamUrl);
const contentType = useVideoStore((s) => s.contentType);
const metadata = useVideoStore((s) => s.metadata);
const hideVideo = useVideoStore((s) => s.hideVideo);
const videoRef = useRef(null);
const playerRef = useRef(null);
const videoContainerRef = useRef(null);
const resizeStateRef = useRef(null);
const overlayTimeoutRef = useRef(null);
const aspectRatioRef = useRef(320 / 180);
const dragPositionRef = useRef(null);
const dragOffsetRef = useRef({ x: 0, y: 0 });
const initialPositionRef = useRef(null);
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState(null);
const [showOverlay, setShowOverlay] = useState(true);
const [videoSize, setVideoSize] = useState({ width: 320, height: 180 });
const [isResizing, setIsResizing] = useState(false);
const [dragPosition, setDragPosition] = useState(null);
const MIN_WIDTH = 220;
const MIN_HEIGHT = 124;
const VISIBLE_MARGIN = 48; // keep part of the window visible when dragging
const HEADER_HEIGHT = 38; // height of the close button header area
const ERROR_HEIGHT = 45; // approximate height of error message area when displayed
// Safely destroy the mpegts player to prevent errors
const safeDestroyPlayer = () => {
try {
@ -190,41 +223,20 @@ export default function FloatingVideo() {
};
const handleError = (e) => {
setIsLoading(false);
const error = e.target.error;
let errorMessage = 'Video playback error';
if (error) {
switch (error.code) {
case error.MEDIA_ERR_ABORTED:
errorMessage = 'Video playback was aborted';
break;
case error.MEDIA_ERR_NETWORK:
errorMessage = 'Network error while loading video';
break;
case error.MEDIA_ERR_DECODE:
errorMessage = 'Video codec not supported by your browser';
break;
case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
errorMessage = 'Video format not supported by your browser';
break;
default:
errorMessage = error.message || 'Unknown video error';
}
}
setLoadError(errorMessage);
setLoadError(getVODPlayerErrorMessage(e.target.error));
};
// Enhanced progress tracking for VOD
const handleProgress = () => {
if (video.buffered.length > 0) {
const bufferedEnd = video.buffered.end(video.buffered.length - 1);
const duration = video.duration;
if (duration > 0) {
const bufferedPercent = (bufferedEnd / duration) * 100;
// You could emit this to a store for UI feedback
}
}
// if (video.buffered.length > 0) {
// const bufferedEnd = video.buffered.end(video.buffered.length - 1);
// const duration = video.duration;
// if (duration > 0) {
// const bufferedPercent = (bufferedEnd / duration) * 100;
// // You could emit this to a store for UI feedback
// }
// }
};
// Add event listeners
@ -301,37 +313,7 @@ export default function FloatingVideo() {
if (errorType !== 'NetworkError' || !errorDetail?.includes('aborted')) {
console.error('Player error:', errorType, errorDetail);
let errorMessage = `Error: ${errorType}`;
if (errorType === 'MediaError') {
const errorString = errorDetail?.toLowerCase() || '';
if (
errorString.includes('audio') ||
errorString.includes('ac3') ||
errorString.includes('ac-3')
) {
errorMessage =
'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.';
} else if (
errorString.includes('video') ||
errorString.includes('h264') ||
errorString.includes('h.264')
) {
errorMessage =
'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.';
} else if (errorString.includes('mse')) {
errorMessage =
"Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility.";
} else {
errorMessage =
'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.';
}
} else if (errorDetail) {
errorMessage += ` - ${errorDetail}`;
}
setLoadError(errorMessage);
setLoadError(getLivePlayerErrorMessage(errorType, errorDetail));
}
});
@ -448,15 +430,7 @@ export default function FloatingVideo() {
(event) => {
if (!resizeStateRef.current) return;
const clientX =
event.touches && event.touches.length
? event.touches[0].clientX
: event.clientX;
const clientY =
event.touches && event.touches.length
? event.touches[0].clientY
: event.clientY;
const { clientX, clientY } = getClientCoordinates(event);
const {
startX,
startY,
@ -466,104 +440,62 @@ export default function FloatingVideo() {
handle,
aspectRatio,
} = resizeStateRef.current;
const deltaX = clientX - startX;
const deltaY = clientY - startY;
const widthDelta = deltaX * handle.xDir;
const heightDelta = deltaY * handle.yDir;
const ratio = aspectRatio || aspectRatioRef.current;
const { width: nextWidth, height: nextHeight } = calculateNewDimensions(
clientX - startX,
clientY - startY,
startWidth,
startHeight,
handle,
ratio
);
// Derive width/height while keeping the original aspect ratio
let nextWidth = startWidth + widthDelta;
let nextHeight = nextWidth / ratio;
// Allow vertical-driven resize if the user drags mostly vertically
if (Math.abs(deltaY) > Math.abs(deltaX)) {
nextHeight = startHeight + heightDelta;
nextWidth = nextHeight * ratio;
}
// Respect minimums while keeping the ratio
if (nextWidth < MIN_WIDTH) {
nextWidth = MIN_WIDTH;
nextHeight = nextWidth / ratio;
}
if (nextHeight < MIN_HEIGHT) {
nextHeight = MIN_HEIGHT;
nextWidth = nextHeight * ratio;
}
// Keep within viewport with a margin based on current position
const posX = startPos?.x ?? 0;
const posY = startPos?.y ?? 0;
const margin = VISIBLE_MARGIN;
let maxWidth = null;
let maxHeight = null;
if (!handle.isLeft) {
maxWidth = Math.max(MIN_WIDTH, window.innerWidth - posX - margin);
}
if (!handle.isTop) {
maxHeight = Math.max(MIN_HEIGHT, window.innerHeight - posY - margin);
}
if (maxWidth != null && nextWidth > maxWidth) {
nextWidth = maxWidth;
nextHeight = nextWidth / ratio;
}
if (maxHeight != null && nextHeight > maxHeight) {
nextHeight = maxHeight;
nextWidth = nextHeight * ratio;
}
// Final pass to honor both bounds while keeping the ratio
if (maxWidth != null && nextWidth > maxWidth) {
nextWidth = maxWidth;
nextHeight = nextWidth / ratio;
}
const constrainedSize = applyConstraints(
nextWidth,
nextHeight,
ratio,
startPos,
handle,
MIN_WIDTH,
MIN_HEIGHT,
VISIBLE_MARGIN
);
setVideoSize({
width: Math.round(nextWidth),
height: Math.round(nextHeight),
width: Math.round(constrainedSize.width),
height: Math.round(constrainedSize.height),
});
if (handle.isLeft || handle.isTop) {
let nextX = posX;
let nextY = posY;
if (handle.isLeft) {
nextX = posX + (startWidth - nextWidth);
}
if (handle.isTop) {
nextY = posY + (startHeight - nextHeight);
}
const clamped = clampToVisibleWithSize(
nextX,
nextY,
nextWidth,
nextHeight
);
if (handle.isLeft) {
nextX = clamped.x;
}
if (handle.isTop) {
nextY = clamped.y;
}
const nextPos = { x: nextX, y: nextY };
setDragPosition(nextPos);
dragPositionRef.current = nextPos;
}
updatePositionIfNeeded(
handle,
startPos,
startWidth,
startHeight,
constrainedSize
);
},
[MIN_HEIGHT, MIN_WIDTH, VISIBLE_MARGIN, clampToVisibleWithSize]
);
const updatePositionIfNeeded = (handle, startPos, startWidth, startHeight, newSize) => {
if (!handle.isLeft && !handle.isTop) return;
const posX = startPos?.x ?? 0;
const posY = startPos?.y ?? 0;
let nextX = handle.isLeft ? posX + (startWidth - newSize.width) : posX;
let nextY = handle.isTop ? posY + (startHeight - newSize.height) : posY;
const clamped = clampToVisibleWithSize(nextX, nextY, newSize.width, newSize.height);
const nextPos = {
x: handle.isLeft ? clamped.x : nextX,
y: handle.isTop ? clamped.y : nextY,
};
setDragPosition(nextPos);
dragPositionRef.current = nextPos;
};
const endResize = useCallback(() => {
setIsResizing(false);
resizeStateRef.current = null;
@ -577,14 +509,7 @@ export default function FloatingVideo() {
event.stopPropagation();
event.preventDefault();
const clientX =
event.touches && event.touches.length
? event.touches[0].clientX
: event.clientX;
const clientY =
event.touches && event.touches.length
? event.touches[0].clientY
: event.clientY;
const { clientX, clientY } = getClientCoordinates(event);
const aspectRatio =
videoSize.height > 0
@ -876,20 +801,7 @@ export default function FloatingVideo() {
</Box>
)}
{/* Resize handles */}
{resizeHandles.map((handle) => (
<Box
key={handle.id}
className="floating-video-no-drag"
onMouseDown={(event) => startResize(event, handle)}
onTouchStart={(event) => startResize(event, handle)}
style={{
...resizeHandleBaseStyle,
...handle.style,
cursor: handle.cursor,
}}
/>
))}
<ResizeHandles startResize={startResize}/>
</div>
</Draggable>
);

View file

@ -1,7 +1,6 @@
// frontend/src/components/FloatingVideo.js
import React, { useEffect, useState } from 'react';
import usePlaylistsStore from '../store/playlists';
import { notifications } from '@mantine/notifications';
import useStreamsStore from '../store/streams';
import useChannelsStore from '../store/channels';
import useEPGsStore from '../store/epgs';
@ -10,6 +9,39 @@ import { Stack, Button, Group } from '@mantine/core';
import API from '../api';
import { useNavigate } from 'react-router-dom';
import { CircleCheck } from 'lucide-react';
import { showNotification } from '../utils/notificationUtils.js';
const M3uSetupSuccess = ({ data }) => {
const navigate = useNavigate();
const onClickRefresh = () => {
API.refreshPlaylist(data.account);
};
const onClickConfigure = () => {
// Store the ID we want to edit in the store first
usePlaylistsStore.getState().setEditPlaylistId(data.account);
// Then navigate to the content sources page
// Using the exact path that matches your app's routing structure
navigate('/sources');
};
return (
<Stack>
{data.message ||
'M3U groups loaded. Configure group filters and auto channel sync settings.'}
<Group grow>
<Button size="xs" variant="default" onClick={onClickRefresh}>
Refresh Now
</Button>
<Button size="xs" variant="outline" onClick={onClickConfigure}>
Configure Groups
</Button>
</Group>
</Stack>
);
};
export default function M3URefreshNotification() {
const playlists = usePlaylistsStore((s) => s.playlists);
@ -22,9 +54,9 @@ export default function M3URefreshNotification() {
const fetchCategories = useVODStore((s) => s.fetchCategories);
const [notificationStatus, setNotificationStatus] = useState({});
const navigate = useNavigate();
const handleM3UUpdate = (data) => {
// Skip if status hasn't changed
if (
JSON.stringify(notificationStatus[data.account]) == JSON.stringify(data)
) {
@ -36,132 +68,101 @@ export default function M3URefreshNotification() {
return;
}
// Store the updated status first
setNotificationStatus({
...notificationStatus,
// Update notification status
setNotificationStatus((prev) => ({
...prev,
[data.account]: data,
});
}));
// Special handling for pending setup status
// Handle different status types
if (data.status === 'pending_setup') {
fetchChannelGroups();
fetchPlaylists();
notifications.show({
title: `M3U Setup: ${playlist.name}`,
message: (
<Stack>
{data.message ||
'M3U groups loaded. Configure group filters and auto channel sync settings.'}
<Group grow>
<Button
size="xs"
variant="default"
onClick={() => {
API.refreshPlaylist(data.account);
}}
>
Refresh Now
</Button>
<Button
size="xs"
variant="outline"
onClick={() => {
// Store the ID we want to edit in the store first
usePlaylistsStore.getState().setEditPlaylistId(data.account);
// Then navigate to the content sources page
// Using the exact path that matches your app's routing structure
navigate('/sources');
}}
>
Configure Groups
</Button>
</Group>
</Stack>
),
color: 'orange.5',
autoClose: 5000, // Keep visible a bit longer
});
handlePendingSetup(playlist, data);
return;
}
// Check for error status FIRST before doing anything else
if (data.status === 'error') {
// Only show the error notification if we have a complete task (progress=100)
// or if it's explicitly flagged as an error
if (data.progress === 100) {
notifications.show({
title: `M3U Processing: ${playlist.name}`,
message: `${data.action || 'Processing'} failed: ${data.error || 'Unknown error'}`,
color: 'red',
autoClose: 5000, // Keep error visible a bit longer
});
}
return; // Exit early for any error status
handleError(playlist, data);
return;
}
// Check if we already have an error stored for this account, and if so, don't show further notifications
// Skip if already errored
const currentStatus = notificationStatus[data.account];
if (currentStatus && currentStatus.status === 'error') {
// Don't show any other notifications once we've hit an error
return;
}
const taskProgress = data.progress;
// Handle normal progress updates (0% start, 100% completion)
if (data.progress === 0 || data.progress === 100) {
handleProgressNotification(playlist, data);
}
};
// Only show start and completion notifications for normal operation
if (data.progress != 0 && data.progress != 100) {
return;
const handlePendingSetup = (playlist, data) => {
fetchChannelGroups();
fetchPlaylists();
showNotification({
title: `M3U Setup: ${playlist.name}`,
message: <M3uSetupSuccess data={data} />,
color: 'orange.5',
autoClose: 5000,
});
};
const handleError = (playlist, data) => {
if (data.progress === 100) {
showNotification({
title: `M3U Processing: ${playlist.name}`,
message: `${data.action || 'Processing'} failed: ${data.error || 'Unknown error'}`,
color: 'red',
autoClose: 5000,
});
}
};
const getActionMessage = (action) => {
const messages = {
downloading: 'Downloading',
parsing: 'Stream parsing',
processing_groups: 'Group parsing',
vod_refresh: 'VOD content refresh',
};
return messages[action] || 'Processing';
};
const triggerPostCompletionFetches = (action) => {
if (action == 'parsing') {
fetchStreams();
API.requeryChannels();
fetchChannelIds();
} else if (action == 'processing_groups') {
fetchStreams();
fetchChannelGroups();
fetchEPGData();
fetchPlaylists();
} else if (action == 'vod_refresh') {
fetchPlaylists();
fetchCategories();
}
};
const handleProgressNotification = (playlist, data) => {
const baseMessage = getActionMessage(data.action);
const message =
data.progress == 0
? `${baseMessage} starting...`
: `${baseMessage} complete!`;
if (data.progress == 100) {
triggerPostCompletionFetches(data.action);
}
let message = '';
switch (data.action) {
case 'downloading':
message = 'Downloading';
break;
case 'parsing':
message = 'Stream parsing';
break;
case 'processing_groups':
message = 'Group parsing';
break;
case 'vod_refresh':
message = 'VOD content refresh';
break;
}
if (taskProgress == 0) {
message = `${message} starting...`;
} else if (taskProgress == 100) {
message = `${message} complete!`;
// Only trigger additional fetches on successful completion
if (data.action == 'parsing') {
fetchStreams();
API.requeryChannels();
fetchChannelIds();
} else if (data.action == 'processing_groups') {
fetchStreams();
fetchChannelGroups();
fetchEPGData();
fetchPlaylists();
} else if (data.action == 'vod_refresh') {
// VOD refresh completed, trigger VOD categories refresh
fetchPlaylists(); // Refresh playlist data to show updated VOD info
fetchCategories(); // Refresh VOD categories to make them visible
}
}
notifications.show({
showNotification({
title: `M3U Processing: ${playlist.name}`,
message,
loading: taskProgress == 0,
loading: data.progress == 0,
autoClose: 2000,
icon: taskProgress == 100 ? <CircleCheck /> : null,
icon: data.progress == 100 ? <CircleCheck /> : null,
});
};

View file

@ -10,7 +10,9 @@ import {
Group,
Indicator,
Popover,
ScrollArea,
PopoverDropdown,
PopoverTarget,
ScrollAreaAutosize,
Stack,
Text,
ThemeIcon,
@ -33,7 +35,11 @@ import {
ArrowRight,
} from 'lucide-react';
import useNotificationsStore from '../store/notifications';
import API from '../api';
import {
dismissAllNotifications,
dismissNotification,
getNotifications,
} from '../utils/components/NotificationCenterUtils.js';
// Get icon for notification type
const getNotificationIcon = (type) => {
@ -249,7 +255,7 @@ const NotificationCenter = ({ onSettingAction }) => {
// Fetch notifications on mount and periodically
const fetchNotifications = useCallback(async () => {
try {
await API.getNotifications(showDismissed);
await getNotifications(showDismissed);
} catch (error) {
console.error('Failed to fetch notifications:', error);
}
@ -265,7 +271,7 @@ const NotificationCenter = ({ onSettingAction }) => {
const handleDismiss = async (notificationId, actionTaken = null) => {
try {
await API.dismissNotification(notificationId, actionTaken);
await dismissNotification(notificationId, actionTaken);
} catch (error) {
console.error('Failed to dismiss notification:', error);
}
@ -273,7 +279,7 @@ const NotificationCenter = ({ onSettingAction }) => {
const handleDismissAll = async () => {
try {
await API.dismissAllNotifications();
await dismissAllNotifications();
} catch (error) {
console.error('Failed to dismiss all notifications:', error);
}
@ -302,7 +308,7 @@ const NotificationCenter = ({ onSettingAction }) => {
shadow="lg"
withArrow
>
<Popover.Target>
<PopoverTarget>
<Indicator
color="red"
size={16}
@ -321,9 +327,9 @@ const NotificationCenter = ({ onSettingAction }) => {
<Bell size={20} />
</ActionIcon>
</Indicator>
</Popover.Target>
</PopoverTarget>
<Popover.Dropdown p={0}>
<PopoverDropdown p={0}>
{/* Header */}
<Group justify="space-between" p="sm" pb="xs">
<Group gap="xs">
@ -367,7 +373,7 @@ const NotificationCenter = ({ onSettingAction }) => {
<Divider />
{/* Notification list */}
<ScrollArea.Autosize mah={400} type="auto" offsetScrollbars>
<ScrollAreaAutosize mah={400} type="auto" offsetScrollbars>
{displayedNotifications.length === 0 ? (
<Box p="lg" ta="center">
<ThemeIcon
@ -403,7 +409,7 @@ const NotificationCenter = ({ onSettingAction }) => {
))}
</Stack>
)}
</ScrollArea.Autosize>
</ScrollAreaAutosize>
{/* Footer with info text */}
{!showDismissed &&
@ -421,7 +427,7 @@ const NotificationCenter = ({ onSettingAction }) => {
</Box>
</>
)}
</Popover.Dropdown>
</PopoverDropdown>
</Popover>
);
};

File diff suppressed because it is too large Load diff

View file

@ -23,7 +23,6 @@ import {
} from 'lucide-react';
import {
Avatar,
AppShell,
Group,
Stack,
Box,
@ -31,6 +30,7 @@ import {
UnstyledButton,
TextInput,
ActionIcon,
AppShellNavbar,
ScrollArea,
} from '@mantine/core';
import logo from '../images/logo.png';
@ -246,7 +246,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
};
return (
<AppShell.Navbar
<AppShellNavbar
width={{ base: collapsed ? miniDrawerWidth : drawerWidth }}
p="xs"
style={{
@ -427,7 +427,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
)}
<UserForm user={authUser} isOpen={userFormOpen} onClose={closeUserForm} />
</AppShell.Navbar>
</AppShellNavbar>
);
};

View file

@ -19,11 +19,9 @@ import {
Download,
Gauge,
HardDriveDownload,
List,
LogIn,
LogOut,
RefreshCw,
Shield,
ShieldAlert,
SquareX,
Timer,
@ -31,9 +29,143 @@ import {
Video,
XCircle,
} from 'lucide-react';
import dayjs from 'dayjs';
import API from '../api';
import useLocalStorage from '../hooks/useLocalStorage';
import { format } from '../utils/dateTimeUtils.js';
const getEventIcon = (eventType) => {
switch (eventType) {
case 'channel_start':
return <CirclePlay size={16} />;
case 'channel_stop':
return <SquareX size={16} />;
case 'channel_reconnect':
return <RefreshCw size={16} />;
case 'channel_buffering':
return <Timer size={16} />;
case 'channel_failover':
return <HardDriveDownload size={16} />;
case 'client_connect':
return <Users size={16} />;
case 'client_disconnect':
return <Users size={16} />;
case 'recording_start':
return <Video size={16} />;
case 'recording_end':
return <Video size={16} />;
case 'stream_switch':
return <HardDriveDownload size={16} />;
case 'm3u_refresh':
return <RefreshCw size={16} />;
case 'm3u_download':
return <Download size={16} />;
case 'epg_refresh':
return <RefreshCw size={16} />;
case 'epg_download':
return <Download size={16} />;
case 'login_success':
return <LogIn size={16} />;
case 'login_failed':
return <ShieldAlert size={16} />;
case 'logout':
return <LogOut size={16} />;
case 'm3u_blocked':
return <XCircle size={16} />;
case 'epg_blocked':
return <XCircle size={16} />;
default:
return <Gauge size={16} />;
}
};
const getEventColor = (eventType) => {
switch (eventType) {
case 'channel_start':
case 'client_connect':
case 'recording_start':
case 'login_success':
return 'green';
case 'channel_reconnect':
return 'yellow';
case 'channel_stop':
case 'client_disconnect':
case 'recording_end':
case 'logout':
return 'gray';
case 'channel_buffering':
return 'yellow';
case 'channel_failover':
case 'channel_error':
return 'orange';
case 'stream_switch':
return 'blue';
case 'm3u_refresh':
case 'epg_refresh':
return 'cyan';
case 'm3u_download':
case 'epg_download':
return 'teal';
case 'login_failed':
case 'm3u_blocked':
case 'epg_blocked':
return 'red';
default:
return 'gray';
}
};
const getSystemEvents = (eventsLimit, offset) => {
return API.getSystemEvents(eventsLimit, offset);
}
const Event = ({ event }) => {
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
return (
<Box
p="xs"
bdrs={4}
style={{
backgroundColor: '#1A1B1E',
borderLeft: `3px solid var(--mantine-color-${getEventColor(event.event_type)}-6)`,
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" flex={1} miw={0}>
<Box c={`${getEventColor(event.event_type)}.6`}>
{getEventIcon(event.event_type)}
</Box>
<Stack gap={2} flex={1} miw={0}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500}>
{event.event_type_display || event.event_type}
</Text>
{event.channel_name && (
<Text size="sm" c="dimmed" truncate maw={300}>
{event.channel_name}
</Text>
)}
</Group>
{event.details &&
Object.keys(event.details).length > 0 && (
<Text size="xs" c="dimmed">
{Object.entries(event.details)
.filter(([key]) =>
!['stream_url', 'new_url'].includes(key))
.map(([key, value]) => `${key}: ${value}`)
.join(', ')}
</Text>
)}
</Stack>
</Group>
<Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap' }}>
{format(event.timestamp, `${dateFormat} HH:mm:ss`)}
</Text>
</Group>
</Box>
);
};
const SystemEvents = () => {
const [events, setEvents] = useState([]);
@ -42,8 +174,7 @@ const SystemEvents = () => {
const { ref: cardRef, width: cardWidth } = useElementSize();
const isNarrow = cardWidth < 650;
const [isLoading, setIsLoading] = useState(false);
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
const [eventsRefreshInterval, setEventsRefreshInterval] = useLocalStorage(
'events-refresh-interval',
0
@ -58,7 +189,7 @@ const SystemEvents = () => {
const fetchEvents = useCallback(async () => {
try {
setIsLoading(true);
const response = await API.getSystemEvents(eventsLimit, offset);
const response = await getSystemEvents(eventsLimit, offset);
if (response && response.events) {
setEvents(response.events);
setTotalEvents(response.total || 0);
@ -86,87 +217,6 @@ const SystemEvents = () => {
setCurrentPage(1);
}, [eventsLimit]);
const getEventIcon = (eventType) => {
switch (eventType) {
case 'channel_start':
return <CirclePlay size={16} />;
case 'channel_stop':
return <SquareX size={16} />;
case 'channel_reconnect':
return <RefreshCw size={16} />;
case 'channel_buffering':
return <Timer size={16} />;
case 'channel_failover':
return <HardDriveDownload size={16} />;
case 'client_connect':
return <Users size={16} />;
case 'client_disconnect':
return <Users size={16} />;
case 'recording_start':
return <Video size={16} />;
case 'recording_end':
return <Video size={16} />;
case 'stream_switch':
return <HardDriveDownload size={16} />;
case 'm3u_refresh':
return <RefreshCw size={16} />;
case 'm3u_download':
return <Download size={16} />;
case 'epg_refresh':
return <RefreshCw size={16} />;
case 'epg_download':
return <Download size={16} />;
case 'login_success':
return <LogIn size={16} />;
case 'login_failed':
return <ShieldAlert size={16} />;
case 'logout':
return <LogOut size={16} />;
case 'm3u_blocked':
return <XCircle size={16} />;
case 'epg_blocked':
return <XCircle size={16} />;
default:
return <Gauge size={16} />;
}
};
const getEventColor = (eventType) => {
switch (eventType) {
case 'channel_start':
case 'client_connect':
case 'recording_start':
case 'login_success':
return 'green';
case 'channel_reconnect':
return 'yellow';
case 'channel_stop':
case 'client_disconnect':
case 'recording_end':
case 'logout':
return 'gray';
case 'channel_buffering':
return 'yellow';
case 'channel_failover':
case 'channel_error':
return 'orange';
case 'stream_switch':
return 'blue';
case 'm3u_refresh':
case 'epg_refresh':
return 'cyan';
case 'm3u_download':
case 'epg_download':
return 'teal';
case 'login_failed':
case 'm3u_blocked':
case 'epg_blocked':
return 'red';
default:
return 'gray';
}
};
return (
<Card
ref={cardRef}
@ -272,55 +322,7 @@ const SystemEvents = () => {
</Text>
) : (
events.map((event) => (
<Box
key={event.id}
p="xs"
style={{
backgroundColor: '#1A1B1E',
borderRadius: '4px',
borderLeft: `3px solid var(--mantine-color-${getEventColor(event.event_type)}-6)`,
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" style={{ flex: 1, minWidth: 0 }}>
<Box c={`${getEventColor(event.event_type)}.6`}>
{getEventIcon(event.event_type)}
</Box>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500}>
{event.event_type_display || event.event_type}
</Text>
{event.channel_name && (
<Text
size="sm"
c="dimmed"
truncate
style={{ maxWidth: '300px' }}
>
{event.channel_name}
</Text>
)}
</Group>
{event.details &&
Object.keys(event.details).length > 0 && (
<Text size="xs" c="dimmed">
{Object.entries(event.details)
.filter(
([key]) =>
!['stream_url', 'new_url'].includes(key)
)
.map(([key, value]) => `${key}: ${value}`)
.join(', ')}
</Text>
)}
</Stack>
</Group>
<Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap' }}>
{dayjs(event.timestamp).format(`${dateFormat} HH:mm:ss`)}
</Text>
</Group>
</Box>
<Event key={event.id} event={event} />
))
)}
</Stack>

View file

@ -14,152 +14,233 @@ import {
Modal,
} from '@mantine/core';
import { Play, Copy } from 'lucide-react';
import { notifications } from '@mantine/notifications';
import { copyToClipboard } from '../utils';
import useVODStore from '../store/useVODStore';
import useVideoStore from '../store/useVideoStore';
import useSettingsStore from '../store/settings';
import {
formatDuration,
formatStreamLabel,
getYouTubeEmbedUrl,
imdbUrl,
tmdbUrl
} from '../utils/components/SeriesModalUtils.js';
import { YouTubeTrailerModal } from './modals/YouTubeTrailerModal.jsx';
import {
formatAudioDetails,
formatVideoDetails,
getMovieStreamUrl,
getTechnicalDetails,
} from '../utils/components/VODModalUtils.js';
const imdbUrl = (imdb_id) =>
imdb_id ? `https://www.imdb.com/title/${imdb_id}` : '';
const tmdbUrl = (tmdb_id, type = 'movie') =>
tmdb_id ? `https://www.themoviedb.org/${type}/${tmdb_id}` : '';
const formatDuration = (seconds) => {
if (!seconds) return '';
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`;
};
const Movie = ({
onClickYouTubeTrailer,
hasMultipleProviders,
selectedProvider,
detailedVOD,
vod
}) => {
const showVideo = useVideoStore((s) => s.showVideo);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
const formatStreamLabel = (relation) => {
// Create a label for the stream that includes provider name and stream-specific info
const provider = relation.m3u_account.name;
const streamId = relation.stream_id;
const displayVOD = detailedVOD || vod;
// Try to extract quality info - prioritizing the new quality_info field from backend
let qualityInfo = '';
const getStreamUrl = () => {
if (!displayVOD) return null;
// 1. Check the new quality_info field from backend (PRIMARY)
if (relation.quality_info) {
if (relation.quality_info.quality) {
qualityInfo = ` - ${relation.quality_info.quality}`;
} else if (relation.quality_info.resolution) {
qualityInfo = ` - ${relation.quality_info.resolution}`;
} else if (relation.quality_info.bitrate) {
qualityInfo = ` - ${relation.quality_info.bitrate}`;
}
}
// 2. Fallback: Check custom_properties detailed info structure
if (qualityInfo === '' && relation.custom_properties) {
const props = relation.custom_properties;
// Check detailed_info structure (where the real data is!)
if (qualityInfo === '' && props.detailed_info) {
const detailedInfo = props.detailed_info;
// Extract from video resolution
if (
detailedInfo.video &&
detailedInfo.video.width &&
detailedInfo.video.height
) {
const width = detailedInfo.video.width;
const height = detailedInfo.video.height;
// Prioritize width for quality detection (handles ultrawide/cinematic aspect ratios)
if (width >= 3840) {
qualityInfo = ' - 4K';
} else if (width >= 1920) {
qualityInfo = ' - 1080p';
} else if (width >= 1280) {
qualityInfo = ' - 720p';
} else if (width >= 854) {
qualityInfo = ' - 480p';
} else {
qualityInfo = ` - ${width}x${height}`;
}
}
// Extract from movie name in detailed_info
if (qualityInfo === '' && detailedInfo.name) {
const name = detailedInfo.name;
if (name.includes('4K') || name.includes('2160p')) {
qualityInfo = ' - 4K';
} else if (name.includes('1080p') || name.includes('FHD')) {
qualityInfo = ' - 1080p';
} else if (name.includes('720p') || name.includes('HD')) {
qualityInfo = ' - 720p';
} else if (name.includes('480p')) {
qualityInfo = ' - 480p';
}
}
}
}
// 3. Final fallback: Check stream name for quality markers
if (qualityInfo === '' && relation.stream_name) {
const streamName = relation.stream_name;
if (streamName.includes('4K') || streamName.includes('2160p')) {
qualityInfo = ' - 4K';
} else if (streamName.includes('1080p') || streamName.includes('FHD')) {
qualityInfo = ' - 1080p';
} else if (streamName.includes('720p') || streamName.includes('HD')) {
qualityInfo = ' - 720p';
} else if (streamName.includes('480p')) {
qualityInfo = ' - 480p';
}
}
return `${provider}${qualityInfo}${streamId ? ` (Stream ${streamId})` : ''}`;
};
const getTechnicalDetails = (selectedProvider, defaultVOD) => {
let source = defaultVOD; // Default fallback
// If a provider is selected, try to get technical details from various locations
if (selectedProvider) {
// 1. First try the movie/episode relation content
const content = selectedProvider.movie || selectedProvider.episode;
if (content && (content.bitrate || content.video || content.audio)) {
source = content;
}
// 2. Try technical details directly on the relation object
else if (
selectedProvider.bitrate ||
selectedProvider.video ||
selectedProvider.audio
) {
source = selectedProvider;
}
// 3. Try to extract from custom_properties detailed_info (where quality data is stored)
else if (selectedProvider.custom_properties?.detailed_info) {
const detailedInfo = selectedProvider.custom_properties.detailed_info;
// Create a synthetic source from detailed_info
const syntheticSource = {
bitrate: detailedInfo.bitrate || null,
video: detailedInfo.video || null,
audio: detailedInfo.audio || null,
};
if (
syntheticSource.bitrate ||
syntheticSource.video ||
syntheticSource.audio
) {
source = syntheticSource;
}
}
}
return {
bitrate: source?.bitrate,
video: source?.video,
audio: source?.audio,
return getMovieStreamUrl(vod, selectedProvider, env_mode);
};
const handlePlayVOD = () => {
const streamUrl = getStreamUrl();
if (!streamUrl) return;
showVideo(streamUrl, 'vod', displayVOD);
};
const handleCopyLink = async () => {
const streamUrl = getStreamUrl();
if (!streamUrl) return;
await copyToClipboard(streamUrl, {
successTitle: 'Link Copied!',
successMessage: 'Stream link copied to clipboard',
});
};
return (
<Stack spacing="md" flex={1}>
<Title order={3}>{displayVOD.name}</Title>
{/* Original name if different */}
{displayVOD.o_name &&
displayVOD.o_name !== displayVOD.name && (
<Text size="sm" c="dimmed" fs="italic">
Original: {displayVOD.o_name}
</Text>
)}
<Group spacing="md">
{displayVOD.year && (
<Badge color="blue">{displayVOD.year}</Badge>
)}
{displayVOD.duration_secs && (
<Badge color="gray">
{formatDuration(displayVOD.duration_secs)}
</Badge>
)}
{displayVOD.rating && (
<Badge color="yellow">{displayVOD.rating}</Badge>
)}
{displayVOD.age && (
<Badge color="orange">{displayVOD.age}</Badge>
)}
<Badge color="green">Movie</Badge>
{/* imdb_id and tmdb_id badges */}
{displayVOD.imdb_id && (
<Badge
color="yellow"
component="a"
href={imdbUrl(displayVOD.imdb_id)}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
IMDb
</Badge>
)}
{displayVOD.tmdb_id && (
<Badge
color="cyan"
component="a"
href={tmdbUrl(displayVOD.tmdb_id, 'movie')}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
TMDb
</Badge>
)}
</Group>
{/* Release date */}
{displayVOD.release_date && (
<Text size="sm" c="dimmed">
<strong>Release Date:</strong> {displayVOD.release_date}
</Text>
)}
{displayVOD.genre && (
<Text size="sm" c="dimmed">
<strong>Genre:</strong> {displayVOD.genre}
</Text>
)}
{displayVOD.director && (
<Text size="sm" c="dimmed">
<strong>Director:</strong> {displayVOD.director}
</Text>
)}
{displayVOD.actors && (
<Text size="sm" c="dimmed">
<strong>Cast:</strong> {displayVOD.actors}
</Text>
)}
{displayVOD.country && (
<Text size="sm" c="dimmed">
<strong>Country:</strong> {displayVOD.country}
</Text>
)}
{/* Description */}
{displayVOD.description && (
<Box>
<Text size="sm" weight={500} mb={8}>
Description
</Text>
<Text size="sm">{displayVOD.description}</Text>
</Box>
)}
{/* Play and Watch Trailer buttons */}
<Group spacing="xs" mt="sm">
<Button
leftSection={<Play size={16} />}
variant="filled"
color="blue"
size="sm"
onClick={handlePlayVOD}
disabled={hasMultipleProviders && !selectedProvider}
style={{ alignSelf: 'flex-start' }}
>
Play Movie
</Button>
{displayVOD.youtube_trailer && (
<Button
variant="outline"
color="red"
size="sm"
onClick={onClickYouTubeTrailer}
style={{ alignSelf: 'flex-start' }}
>
Watch Trailer
</Button>
)}
<Button
leftSection={<Copy size={16} />}
variant="outline"
color="gray"
size="sm"
onClick={handleCopyLink}
style={{ alignSelf: 'flex-start' }}
>
Copy Link
</Button>
</Group>
</Stack>
);
};
const MovieTechnicalDetails = ({ selectedProvider, displayVOD }) => {
const techDetails = getTechnicalDetails(selectedProvider, displayVOD);
const hasDetails = techDetails.bitrate || techDetails.video || techDetails.audio;
if (!hasDetails) return null;
const hasVideo = techDetails.video && Object.keys(techDetails.video).length > 0;
const hasAudio = techDetails.audio && Object.keys(techDetails.audio).length > 0;
return (
<Stack spacing={4} mt="xs">
<Text size="sm" weight={500}>
Technical Details:
{selectedProvider && (
<Text size="xs" c="dimmed" weight="normal" span ml={8}>
(from {selectedProvider.m3u_account.name}
{selectedProvider.stream_id && ` - Stream ${selectedProvider.stream_id}`})
</Text>
)}
</Text>
{techDetails.bitrate && techDetails.bitrate > 0 && (
<Text size="xs" c="dimmed">
<strong>Bitrate:</strong> {techDetails.bitrate} kbps
</Text>
)}
{hasVideo && (
<Text size="xs" c="dimmed">
<strong>Video:</strong> {formatVideoDetails(techDetails.video)}
</Text>
)}
{hasAudio && (
<Text size="xs" c="dimmed">
<strong>Audio:</strong> {formatAudioDetails(techDetails.audio)}
</Text>
)}
</Stack>
);
};
const VODModal = ({ vod, opened, onClose }) => {
@ -170,9 +251,8 @@ const VODModal = ({ vod, opened, onClose }) => {
const [providers, setProviders] = useState([]);
const [selectedProvider, setSelectedProvider] = useState(null);
const [loadingProviders, setLoadingProviders] = useState(false);
const { fetchMovieDetailsFromProvider, fetchMovieProviders } = useVODStore();
const showVideo = useVideoStore((s) => s.showVideo);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
useEffect(() => {
if (opened && vod) {
@ -234,54 +314,17 @@ const VODModal = ({ vod, opened, onClose }) => {
}
}, [opened]);
const getStreamUrl = () => {
const vodToPlay = detailedVOD || vod;
if (!vodToPlay) return null;
const onClickYouTubeTrailer = () => {
setTrailerUrl(
getYouTubeEmbedUrl(displayVOD.youtube_trailer)
);
setTrailerModalOpened(true);
}
let streamUrl = `/proxy/vod/movie/${vod.uuid}`;
// Add selected provider as query parameter if available
if (selectedProvider) {
// Use stream_id for most specific selection, fallback to account_id
if (selectedProvider.stream_id) {
streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
} else {
streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
}
}
if (env_mode === 'dev') {
streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
} else {
streamUrl = `${window.location.origin}${streamUrl}`;
}
return streamUrl;
};
const handlePlayVOD = () => {
const streamUrl = getStreamUrl();
if (!streamUrl) return;
const vodToPlay = detailedVOD || vod;
showVideo(streamUrl, 'vod', vodToPlay);
};
const handleCopyLink = async () => {
const streamUrl = getStreamUrl();
if (!streamUrl) return;
await copyToClipboard(streamUrl, {
successTitle: 'Link Copied!',
successMessage: 'Stream link copied to clipboard',
});
};
// Helper to get embeddable YouTube URL
const getEmbedUrl = (url) => {
if (!url) return '';
// Accepts full YouTube URLs or just IDs
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/);
const videoId = match ? match[1] : url;
return `https://www.youtube.com/embed/${videoId}`;
};
const onChangeSelectedProvider = (value) => {
const provider = providers.find((p) => p.id.toString() === value);
setSelectedProvider(provider);
}
if (!vod) return null;
@ -376,146 +419,13 @@ const VODModal = ({ vod, opened, onClose }) => {
</Box>
)}
<Stack spacing="md" style={{ flex: 1 }}>
<Title order={3}>{displayVOD.name}</Title>
{/* Original name if different */}
{displayVOD.o_name &&
displayVOD.o_name !== displayVOD.name && (
<Text
size="sm"
color="dimmed"
style={{ fontStyle: 'italic' }}
>
Original: {displayVOD.o_name}
</Text>
)}
<Group spacing="md">
{displayVOD.year && (
<Badge color="blue">{displayVOD.year}</Badge>
)}
{displayVOD.duration_secs && (
<Badge color="gray">
{formatDuration(displayVOD.duration_secs)}
</Badge>
)}
{displayVOD.rating && (
<Badge color="yellow">{displayVOD.rating}</Badge>
)}
{displayVOD.age && (
<Badge color="orange">{displayVOD.age}</Badge>
)}
<Badge color="green">Movie</Badge>
{/* imdb_id and tmdb_id badges */}
{displayVOD.imdb_id && (
<Badge
color="yellow"
component="a"
href={imdbUrl(displayVOD.imdb_id)}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
IMDb
</Badge>
)}
{displayVOD.tmdb_id && (
<Badge
color="cyan"
component="a"
href={tmdbUrl(displayVOD.tmdb_id, 'movie')}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
TMDb
</Badge>
)}
</Group>
{/* Release date */}
{displayVOD.release_date && (
<Text size="sm" color="dimmed">
<strong>Release Date:</strong> {displayVOD.release_date}
</Text>
)}
{displayVOD.genre && (
<Text size="sm" color="dimmed">
<strong>Genre:</strong> {displayVOD.genre}
</Text>
)}
{displayVOD.director && (
<Text size="sm" color="dimmed">
<strong>Director:</strong> {displayVOD.director}
</Text>
)}
{displayVOD.actors && (
<Text size="sm" color="dimmed">
<strong>Cast:</strong> {displayVOD.actors}
</Text>
)}
{displayVOD.country && (
<Text size="sm" color="dimmed">
<strong>Country:</strong> {displayVOD.country}
</Text>
)}
{/* Description */}
{displayVOD.description && (
<Box>
<Text size="sm" weight={500} mb={8}>
Description
</Text>
<Text size="sm">{displayVOD.description}</Text>
</Box>
)}
{/* Play and Watch Trailer buttons */}
<Group spacing="xs" mt="sm">
<Button
leftSection={<Play size={16} />}
variant="filled"
color="blue"
size="sm"
onClick={handlePlayVOD}
disabled={providers.length > 0 && !selectedProvider}
style={{ alignSelf: 'flex-start' }}
>
Play Movie
</Button>
{displayVOD.youtube_trailer && (
<Button
variant="outline"
color="red"
size="sm"
onClick={() => {
setTrailerUrl(
getEmbedUrl(displayVOD.youtube_trailer)
);
setTrailerModalOpened(true);
}}
style={{ alignSelf: 'flex-start' }}
>
Watch Trailer
</Button>
)}
<Button
leftSection={<Copy size={16} />}
variant="outline"
color="gray"
size="sm"
onClick={handleCopyLink}
style={{ alignSelf: 'flex-start' }}
>
Copy Link
</Button>
</Group>
</Stack>
<Movie
detailedVOD={detailedVOD}
vod={vod}
hasMultipleProviders={providers.length > 0}
selectedProvider={selectedProvider}
onClickYouTubeTrailer={onClickYouTubeTrailer}
/>
</Flex>
{/* Provider Information & Play Button Row */}
@ -542,12 +452,7 @@ const VODModal = ({ vod, opened, onClose }) => {
label: formatStreamLabel(provider),
}))}
value={selectedProvider?.id?.toString() || ''}
onChange={(value) => {
const provider = providers.find(
(p) => p.id.toString() === value
);
setSelectedProvider(provider);
}}
onChange={(value) => onChangeSelectedProvider(value)}
placeholder="Select stream..."
style={{ minWidth: 250 }}
disabled={loadingProviders}
@ -576,135 +481,21 @@ const VODModal = ({ vod, opened, onClose }) => {
</Group>
{/* Technical Details */}
{(() => {
const techDetails = getTechnicalDetails(
selectedProvider,
displayVOD
);
const hasDetails =
techDetails.bitrate || techDetails.video || techDetails.audio;
return (
hasDetails && (
<Stack spacing={4} mt="xs">
<Text size="sm" weight={500}>
Technical Details:
{selectedProvider && (
<Text
size="xs"
color="dimmed"
weight="normal"
span
style={{ marginLeft: 8 }}
>
(from {selectedProvider.m3u_account.name}
{selectedProvider.stream_id &&
` - Stream ${selectedProvider.stream_id}`}
)
</Text>
)}
</Text>
{techDetails.bitrate && techDetails.bitrate > 0 && (
<Text size="xs" color="dimmed">
<strong>Bitrate:</strong> {techDetails.bitrate} kbps
</Text>
)}
{techDetails.video &&
Object.keys(techDetails.video).length > 0 && (
<Text size="xs" color="dimmed">
<strong>Video:</strong>{' '}
{techDetails.video.codec_long_name &&
techDetails.video.codec_long_name !== 'unknown'
? techDetails.video.codec_long_name
: techDetails.video.codec_name}
{techDetails.video.profile
? ` (${techDetails.video.profile})`
: ''}
{techDetails.video.width && techDetails.video.height
? `, ${techDetails.video.width}x${techDetails.video.height}`
: ''}
{techDetails.video.display_aspect_ratio
? `, Aspect Ratio: ${techDetails.video.display_aspect_ratio}`
: ''}
{techDetails.video.bit_rate
? `, Bitrate: ${Math.round(Number(techDetails.video.bit_rate) / 1000)} kbps`
: ''}
{techDetails.video.r_frame_rate
? `, Frame Rate: ${techDetails.video.r_frame_rate.replace('/', '/')} fps`
: ''}
{techDetails.video.tags?.encoder
? `, Encoder: ${techDetails.video.tags.encoder}`
: ''}
</Text>
)}
{techDetails.audio &&
Object.keys(techDetails.audio).length > 0 && (
<Text size="xs" color="dimmed">
<strong>Audio:</strong>{' '}
{techDetails.audio.codec_long_name &&
techDetails.audio.codec_long_name !== 'unknown'
? techDetails.audio.codec_long_name
: techDetails.audio.codec_name}
{techDetails.audio.profile
? ` (${techDetails.audio.profile})`
: ''}
{techDetails.audio.channel_layout
? `, Channels: ${techDetails.audio.channel_layout}`
: techDetails.audio.channels
? `, Channels: ${techDetails.audio.channels}`
: ''}
{techDetails.audio.sample_rate
? `, Sample Rate: ${techDetails.audio.sample_rate} Hz`
: ''}
{techDetails.audio.bit_rate
? `, Bitrate: ${Math.round(Number(techDetails.audio.bit_rate) / 1000)} kbps`
: ''}
{techDetails.audio.tags?.handler_name
? `, Handler: ${techDetails.audio.tags.handler_name}`
: ''}
</Text>
)}
</Stack>
)
);
})()}
{/* YouTube trailer if available */}
<MovieTechnicalDetails
selectedProvider={selectedProvider}
displayVOD={displayVOD}
/>
</Stack>
</Box>
</Box>
</Modal>
{/* YouTube Trailer Modal */}
<Modal
<YouTubeTrailerModal
opened={trailerModalOpened}
onClose={() => setTrailerModalOpened(false)}
title="Trailer"
size="xl"
centered
withCloseButton
>
<Box
style={{ position: 'relative', paddingBottom: '56.25%', height: 0 }}
>
{trailerUrl && (
<iframe
src={trailerUrl}
title="YouTube Trailer"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
borderRadius: 8,
}}
/>
)}
</Box>
</Modal>
trailerUrl={trailerUrl}
/>
</>
);
};

View file

@ -0,0 +1,275 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ConfirmationDialog from '../ConfirmationDialog';
import useWarningsStore from '../../store/warnings';
// Mock the warnings store
vi.mock('../../store/warnings');
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
{children}
</div>
) : null,
Group: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick, disabled }) => (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
),
Checkbox: ({ label, checked, onChange }) => (
<label>
<input
type="checkbox"
checked={checked}
onChange={onChange}
/>
{label}
</label>
),
Box: ({ children }) => <div>{children}</div>,
};
});
describe('ConfirmationDialog', () => {
const mockOnClose = vi.fn();
const mockOnConfirm = vi.fn();
const mockOnSuppressChange = vi.fn();
const mockSuppressWarning = vi.fn();
const mockIsWarningSuppressed = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
useWarningsStore.mockImplementation((selector) => {
const state = {
suppressWarning: mockSuppressWarning,
isWarningSuppressed: mockIsWarningSuppressed,
};
return selector ? selector(state) : state;
});
});
it('should render when opened', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
/>
);
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByText('Are you sure you want to proceed?')).toBeInTheDocument();
});
it('should not render when closed', () => {
render(
<ConfirmationDialog
opened={false}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
/>
);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('should display custom title and message', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
title="Delete Item"
message="This action cannot be undone"
/>
);
expect(screen.getByTestId('modal-title')).toHaveTextContent('Delete Item');
expect(screen.getByText('This action cannot be undone')).toBeInTheDocument();
});
it('should call onConfirm when confirm button is clicked', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
confirmLabel="Delete"
/>
);
fireEvent.click(screen.getByText('Delete'));
expect(mockOnConfirm).toHaveBeenCalledTimes(1);
});
it('should call onClose when cancel button is clicked', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
cancelLabel="Cancel"
/>
);
fireEvent.click(screen.getByText('Cancel'));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it('should show suppress checkbox when actionKey is provided', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
actionKey="delete-action"
/>
);
expect(screen.getByLabelText("Don't ask me again")).toBeInTheDocument();
});
it('should not show suppress checkbox when actionKey is not provided', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
/>
);
expect(screen.queryByLabelText("Don't ask me again")).not.toBeInTheDocument();
});
it('should call suppressWarning when suppress is checked and confirmed', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
actionKey="delete-action"
/>
);
fireEvent.click(screen.getByLabelText("Don't ask me again"));
fireEvent.click(screen.getByText('Confirm'));
expect(mockSuppressWarning).toHaveBeenCalledWith('delete-action');
});
it('should call onSuppressChange when suppress checkbox is toggled', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
actionKey="delete-action"
onSuppressChange={mockOnSuppressChange}
/>
);
fireEvent.click(screen.getByLabelText("Don't ask me again"));
expect(mockOnSuppressChange).toHaveBeenCalledWith(true);
});
it('should show delete file option when enabled', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showDeleteFileOption={true}
/>
);
expect(screen.getByLabelText('Also delete files from disk')).toBeInTheDocument();
});
it('should pass deleteFiles state to onConfirm when delete option is checked', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showDeleteFileOption={true}
/>
);
fireEvent.click(screen.getByLabelText('Also delete files from disk'));
fireEvent.click(screen.getByText('Confirm'));
expect(mockOnConfirm).toHaveBeenCalledWith(true);
});
it('should reset deleteFiles state after confirmation', () => {
const { rerender } = render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showDeleteFileOption={true}
/>
);
fireEvent.click(screen.getByLabelText('Also delete files from disk'));
fireEvent.click(screen.getByText('Confirm'));
rerender(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
showDeleteFileOption={true}
/>
);
expect(screen.getByLabelText('Also delete files from disk')).not.toBeChecked();
});
it('should show loading state on confirm button', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
loading={true}
/>
);
expect(screen.getByText('Confirm')).toBeDisabled();
});
it('should disable cancel button when loading', () => {
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
loading={true}
/>
);
expect(screen.getByText('Cancel')).toBeDisabled();
});
it('should initialize suppress checkbox based on store state', () => {
mockIsWarningSuppressed.mockReturnValue(true);
render(
<ConfirmationDialog
opened={true}
onClose={mockOnClose}
onConfirm={mockOnConfirm}
actionKey="delete-action"
/>
);
expect(screen.getByLabelText("Don't ask me again")).toBeChecked();
});
});

View file

@ -0,0 +1,101 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import ErrorBoundary from '../ErrorBoundary';
// Component that throws an error for testing
const ThrowError = ({ shouldThrow }) => {
if (shouldThrow) {
throw new Error('Test error');
}
return <div>Child component</div>;
};
describe('ErrorBoundary', () => {
beforeEach(() => {
vi.clearAllMocks();
// Suppress console.error for cleaner test output
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('should render children when there is no error', () => {
render(
<ErrorBoundary>
<div>Test content</div>
</ErrorBoundary>
);
expect(screen.getByText('Test content')).toBeInTheDocument();
});
it('should render error message when child component throws error', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
expect(screen.queryByText('Child component')).not.toBeInTheDocument();
});
it('should not render error message when child component does not throw', () => {
render(
<ErrorBoundary>
<ThrowError shouldThrow={false} />
</ErrorBoundary>
);
expect(screen.getByText('Child component')).toBeInTheDocument();
expect(screen.queryByText('Something went wrong')).not.toBeInTheDocument();
});
it('should handle multiple children', () => {
render(
<ErrorBoundary>
<div>First child</div>
<div>Second child</div>
</ErrorBoundary>
);
expect(screen.getByText('First child')).toBeInTheDocument();
expect(screen.getByText('Second child')).toBeInTheDocument();
});
it('should catch errors from nested children', () => {
render(
<ErrorBoundary>
<div>
<div>
<ThrowError shouldThrow={true} />
</div>
</div>
</ErrorBoundary>
);
expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
});
it('should have hasError state set to true after catching error', () => {
const { container } = render(
<ErrorBoundary>
<ThrowError shouldThrow={true} />
</ErrorBoundary>
);
// Verify error boundary rendered fallback UI
expect(container.querySelector('div')).toHaveTextContent(
'Something went wrong'
);
});
it('should have hasError state set to false initially', () => {
const { container } = render(
<ErrorBoundary>
<div data-testid="child">Normal content</div>
</ErrorBoundary>
);
// Verify children are rendered (not error state)
expect(screen.getByTestId('child')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,802 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Field } from '../Field';
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
TextInput: ({ label, description, value, onChange, type, placeholder }) => (
<div>
<label htmlFor="text-input">{label}</label>
<input
id="text-input"
type={type || 'text'}
value={value}
onChange={onChange}
placeholder={placeholder}
aria-describedby={description}
/>
{description && <div>{description}</div>}
</div>
),
NumberInput: ({ label, description, value, onChange, placeholder }) => (
<div>
<label htmlFor="number-input">{label}</label>
<input
id="number-input"
type="number"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
placeholder={placeholder}
aria-describedby={description}
/>
{description && <div>{description}</div>}
</div>
),
Textarea: ({ label, description, value, onChange, placeholder }) => (
<div>
<label htmlFor="textarea-input">{label}</label>
<textarea
id="textarea-input"
value={value}
onChange={onChange}
placeholder={placeholder}
aria-describedby={description}
/>
{description && <div>{description}</div>}
</div>
),
Switch: ({ label, description, checked, onChange }) => (
<div>
<label htmlFor="switch-input">{label}</label>
<input
id="switch-input"
type="checkbox"
checked={checked}
onChange={onChange}
aria-describedby={description}
/>
{description && <div>{description}</div>}
</div>
),
Select: ({ label, description, value, data, onChange, placeholder }) => (
<div>
<label htmlFor="select-input">{label}</label>
<select
id="select-input"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-describedby={description}
>
{data.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{description && <div>{description}</div>}
</div>
),
Text: ({ children, fw, size, c }) => (
<div data-fw={fw} data-size={size} data-color={c}>
{children}
</div>
),
};
});
describe('Field', () => {
const mockOnChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('TextInput (string type)', () => {
it('should render TextInput for string type', () => {
const field = {
id: 'name',
type: 'string',
label: 'Name',
help_text: 'Enter your name',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Name')).toBeInTheDocument();
expect(screen.getByText('Enter your name')).toBeInTheDocument();
});
it('should use provided value', () => {
const field = {
id: 'name',
type: 'string',
label: 'Name',
default: '',
};
render(<Field field={field} value="John" onChange={mockOnChange} />);
expect(screen.getByLabelText('Name')).toHaveValue('John');
});
it('should use default value when value is null', () => {
const field = {
id: 'name',
type: 'string',
label: 'Name',
default: 'Default Name',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Name')).toHaveValue('Default Name');
});
it('should call onChange with field id and value', () => {
const field = {
id: 'name',
type: 'string',
label: 'Name',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
fireEvent.change(screen.getByLabelText('Name'), {
target: { value: 'New Value' },
});
expect(mockOnChange).toHaveBeenCalledWith('name', 'New Value');
});
});
describe('NumberInput (number type)', () => {
it('should render NumberInput for number type', () => {
const field = {
id: 'age',
type: 'number',
label: 'Age',
help_text: 'Enter your age',
default: 0,
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Age')).toBeInTheDocument();
expect(screen.getByText('Enter your age')).toBeInTheDocument();
});
it('should use provided value', () => {
const field = {
id: 'age',
type: 'number',
label: 'Age',
default: 0,
};
render(<Field field={field} value={25} onChange={mockOnChange} />);
expect(screen.getByLabelText('Age')).toHaveValue(25);
});
it('should default to 0 when value and default are null', () => {
const field = {
id: 'age',
type: 'number',
label: 'Age',
default: null,
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Age')).toHaveValue(0);
});
it('should call onChange with field id and numeric value', () => {
const field = {
id: 'age',
type: 'number',
label: 'Age',
default: 0,
};
render(<Field field={field} value={0} onChange={mockOnChange} />);
fireEvent.change(screen.getByLabelText('Age'), {
target: { value: '30' },
});
expect(mockOnChange).toHaveBeenCalledWith('age', 30);
});
});
describe('Switch (boolean type)', () => {
it('should render Switch for boolean type', () => {
const field = {
id: 'active',
type: 'boolean',
label: 'Active',
help_text: 'Toggle active state',
default: false,
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Active')).toBeInTheDocument();
expect(screen.getByText('Toggle active state')).toBeInTheDocument();
});
it('should be checked when value is true', () => {
const field = {
id: 'active',
type: 'boolean',
label: 'Active',
default: false,
};
render(<Field field={field} value={true} onChange={mockOnChange} />);
expect(screen.getByLabelText('Active')).toBeChecked();
});
it('should be unchecked when value is false', () => {
const field = {
id: 'active',
type: 'boolean',
label: 'Active',
default: false,
};
render(<Field field={field} value={false} onChange={mockOnChange} />);
expect(screen.getByLabelText('Active')).not.toBeChecked();
});
it('should use default value when value is null', () => {
const field = {
id: 'active',
type: 'boolean',
label: 'Active',
default: true,
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Active')).toBeChecked();
});
it('should call onChange with field id and checked state', () => {
const field = {
id: 'active',
type: 'boolean',
label: 'Active',
default: false,
};
render(<Field field={field} value={false} onChange={mockOnChange} />);
fireEvent.click(screen.getByLabelText('Active'));
expect(mockOnChange).toHaveBeenCalledWith('active', true);
});
});
describe('Select (select type)', () => {
it('should render Select for select type', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
help_text: 'Select your country',
default: '',
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
],
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Country')).toBeInTheDocument();
expect(screen.getByText('Select your country')).toBeInTheDocument();
});
it('should render options correctly', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
default: '',
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
],
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('United States')).toBeInTheDocument();
expect(screen.getByText('Canada')).toBeInTheDocument();
});
it('should use provided value', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
default: '',
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
],
};
render(<Field field={field} value="ca" onChange={mockOnChange} />);
expect(screen.getByLabelText('Country')).toHaveValue('ca');
});
it('should convert value to string', () => {
const field = {
id: 'status',
type: 'select',
label: 'Status',
default: 1,
options: [
{ value: 1, label: 'Active' },
{ value: 2, label: 'Inactive' },
],
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Status')).toHaveValue('1');
});
it('should handle empty options array', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
default: '',
options: null,
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Country')).toBeInTheDocument();
});
it('should call onChange with field id and selected value', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
default: '',
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
],
};
render(<Field field={field} value="us" onChange={mockOnChange} />);
fireEvent.change(screen.getByLabelText('Country'), {
target: { value: 'ca' },
});
expect(mockOnChange).toHaveBeenCalledWith('country', 'ca');
});
});
describe('Textarea (text type)', () => {
it('should render Textarea for text type', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
help_text: 'Enter your biography',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Biography')).toBeInTheDocument();
expect(screen.getByText('Enter your biography')).toBeInTheDocument();
});
it('should use provided value', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
default: '',
};
render(<Field field={field} value="My bio" onChange={mockOnChange} />);
expect(screen.getByLabelText('Biography')).toHaveValue('My bio');
});
it('should use default value when value is null', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
default: 'Default bio',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Biography')).toHaveValue('Default bio');
});
it('should call onChange with field id and value', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
fireEvent.change(screen.getByLabelText('Biography'), {
target: { value: 'New bio text' },
});
expect(mockOnChange).toHaveBeenCalledWith('bio', 'New bio text');
});
it('should render with placeholder', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
placeholder: 'Enter your bio here...',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Biography')).toHaveAttribute(
'placeholder',
'Enter your bio here...'
);
});
});
describe('Info type', () => {
it('should render info with label and description', () => {
const field = {
id: 'info1',
type: 'info',
label: 'Important Information',
description: 'This is important info',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('Important Information')).toBeInTheDocument();
expect(screen.getByText('This is important info')).toBeInTheDocument();
});
it('should render info with only description', () => {
const field = {
id: 'info2',
type: 'info',
description: 'Just a description',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('Just a description')).toBeInTheDocument();
});
it('should render info with only label', () => {
const field = {
id: 'info3',
type: 'info',
label: 'Just a label',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('Just a label')).toBeInTheDocument();
});
it('should prioritize help_text over description', () => {
const field = {
id: 'info4',
type: 'info',
label: 'Title',
help_text: 'Help text takes priority',
description: 'This should not appear',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('Help text takes priority')).toBeInTheDocument();
expect(screen.queryByText('This should not appear')).not.toBeInTheDocument();
});
it('should use field.value if no help_text or description', () => {
const field = {
id: 'info5',
type: 'info',
label: 'Title',
value: 'Value text',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByText('Value text')).toBeInTheDocument();
});
it('should not call onChange for info type', () => {
const field = {
id: 'info6',
type: 'info',
label: 'Read-only Info',
description: 'Cannot be changed',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(mockOnChange).not.toHaveBeenCalled();
});
});
describe('Password input type', () => {
it('should render password input when input_type is password', () => {
const field = {
id: 'password',
type: 'string',
label: 'Password',
input_type: 'password',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Password')).toHaveAttribute('type', 'password');
});
it('should render text input when input_type is not password', () => {
const field = {
id: 'username',
type: 'string',
label: 'Username',
input_type: 'text',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Username')).toHaveAttribute('type', 'text');
});
it('should default to text input when input_type is undefined', () => {
const field = {
id: 'email',
type: 'string',
label: 'Email',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Email')).toHaveAttribute('type', 'text');
});
});
describe('Description priority', () => {
it('should prioritize help_text over description', () => {
const field = {
id: 'test',
type: 'string',
label: 'Test',
help_text: 'Help text',
description: 'Description text',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByText('Help text')).toBeInTheDocument();
expect(screen.queryByText('Description text')).not.toBeInTheDocument();
});
it('should use description when help_text is not provided', () => {
const field = {
id: 'test',
type: 'string',
label: 'Test',
description: 'Description text',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByText('Description text')).toBeInTheDocument();
});
it('should use field.value when neither help_text nor description provided', () => {
const field = {
id: 'test',
type: 'string',
label: 'Test',
value: 'Value text',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByText('Value text')).toBeInTheDocument();
});
it('should not show description when all are undefined', () => {
const field = {
id: 'test',
type: 'string',
label: 'Test',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Test')).toBeInTheDocument();
// No description should be present
});
});
describe('Placeholder handling', () => {
it('should render placeholder for TextInput', () => {
const field = {
id: 'name',
type: 'string',
label: 'Name',
placeholder: 'Enter your name',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Name')).toHaveAttribute(
'placeholder',
'Enter your name'
);
});
it('should render placeholder for NumberInput', () => {
const field = {
id: 'age',
type: 'number',
label: 'Age',
placeholder: 'Enter your age',
default: 0,
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Age')).toHaveAttribute(
'placeholder',
'Enter your age'
);
});
it('should render placeholder for Select', () => {
const field = {
id: 'country',
type: 'select',
label: 'Country',
placeholder: 'Select a country',
default: '',
options: [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
],
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Country')).toHaveAttribute(
'placeholder',
'Select a country'
);
});
it('should render placeholder for Textarea', () => {
const field = {
id: 'bio',
type: 'text',
label: 'Biography',
placeholder: 'Tell us about yourself',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Biography')).toHaveAttribute(
'placeholder',
'Tell us about yourself'
);
});
});
describe('Edge cases', () => {
it('should handle empty string default value', () => {
const field = {
id: 'test',
type: 'string',
label: 'Test',
default: '',
};
render(<Field field={field} value={null} onChange={mockOnChange} />);
expect(screen.getByLabelText('Test')).toHaveValue('');
});
it('should handle 0 as valid number value', () => {
const field = {
id: 'count',
type: 'number',
label: 'Count',
default: 10,
};
render(<Field field={field} value={0} onChange={mockOnChange} />);
expect(screen.getByLabelText('Count')).toHaveValue(0);
});
it('should handle false as valid boolean value', () => {
const field = {
id: 'enabled',
type: 'boolean',
label: 'Enabled',
default: true,
};
render(<Field field={field} value={false} onChange={mockOnChange} />);
expect(screen.getByLabelText('Enabled')).not.toBeChecked();
});
it('should handle empty string as valid select value', () => {
const field = {
id: 'status',
type: 'select',
label: 'Status',
default: 'active',
options: [
{ value: '', label: 'None' },
{ value: 'active', label: 'Active' },
],
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Status')).toHaveValue('');
});
});
describe('Default fallback', () => {
it('should render TextInput for unknown type', () => {
const field = {
id: 'custom',
type: 'unknown',
label: 'Custom Field',
default: '',
};
render(<Field field={field} value="" onChange={mockOnChange} />);
expect(screen.getByLabelText('Custom Field')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,477 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import FloatingVideo from '../FloatingVideo';
import useVideoStore from '../../store/useVideoStore';
// Mock the video store
vi.mock('../../store/useVideoStore');
// Mock mpegts.js
vi.mock('mpegts.js', () => ({
default: {
createPlayer: vi.fn(),
getFeatureList: vi.fn(),
Events: {
LOADING_COMPLETE: 'loading_complete',
METADATA_ARRIVED: 'metadata_arrived',
ERROR: 'error',
MEDIA_INFO: 'media_info',
},
},
}));
// Import the mocked module after mocking
const mpegts = (await import('mpegts.js')).default;
// Mock react-draggable
vi.mock('react-draggable', () => ({
default: ({ children, nodeRef }) => <div ref={nodeRef}>{children}</div>,
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
CloseButton: ({ onClick, onTouchEnd }) => (
<button
data-testid="close-button"
onClick={onClick}
onTouchEnd={onTouchEnd}
>
Close
</button>
),
Flex: ({ children, ...props }) => <div {...props}>{children}</div>,
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Loader: () => <div data-testid="loader">Loading...</div>,
Text: ({ children, ...props }) => <div {...props}>{children}</div>,
};
});
describe('FloatingVideo', () => {
const mockHideVideo = vi.fn();
let mockPlayer;
beforeEach(async () => {
vi.clearAllMocks();
// Mock HTMLVideoElement methods
HTMLVideoElement.prototype.load = vi.fn();
HTMLVideoElement.prototype.play = vi.fn(() => Promise.resolve());
HTMLVideoElement.prototype.pause = vi.fn();
mockPlayer = {
attachMediaElement: vi.fn(),
load: vi.fn(),
play: vi.fn(() => Promise.resolve()),
pause: vi.fn(),
destroy: vi.fn(),
on: vi.fn(),
};
mpegts.createPlayer.mockReturnValue(mockPlayer);
mpegts.getFeatureList.mockReturnValue({ mseLivePlayback: true });
useVideoStore.mockImplementation((selector) => {
const state = {
isVisible: false,
streamUrl: null,
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
});
});
describe('Visibility', () => {
it('should not render when isVisible is false', () => {
const { container } = render(<FloatingVideo />);
expect(container.firstChild).toBeNull();
});
it('should not render when streamUrl is null', () => {
useVideoStore.mockImplementation((selector) => {
{
const state = {
isVisible: true,
streamUrl: null,
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}
});
const { container } = render(<FloatingVideo />);
expect(container.firstChild).toBeNull();
});
it('should render when isVisible is true and streamUrl is provided', () => {
useVideoStore.mockImplementation((selector) => {
{
const state = {
isVisible: true,
streamUrl: 'http://example.com/stream',
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}
});
render(<FloatingVideo />);
expect(screen.getByTestId('close-button')).toBeInTheDocument();
});
});
describe('Live Stream Player', () => {
beforeEach(() => {
useVideoStore.mockImplementation((selector) => {
{
const state = {
isVisible: true,
streamUrl: 'http://example.com/stream.ts',
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}
});
});
it('should initialize mpegts player for live streams', () => {
render(<FloatingVideo />);
expect(mpegts.createPlayer).toHaveBeenCalledWith(
expect.objectContaining({
type: 'mpegts',
url: 'http://example.com/stream.ts',
isLive: true,
})
);
});
it('should show loading state initially', () => {
render(<FloatingVideo />);
expect(screen.getByTestId('loader')).toBeInTheDocument();
expect(screen.getByText('Loading stream...')).toBeInTheDocument();
});
it('should attach player to video element', () => {
render(<FloatingVideo />);
expect(mockPlayer.attachMediaElement).toHaveBeenCalled();
});
it('should handle player errors', async () => {
render(<FloatingVideo />);
const errorCallback = mockPlayer.on.mock.calls.find(
(call) => call[0] === mpegts.Events.ERROR
)?.[1];
errorCallback('MediaError', 'AC3 codec not supported');
await screen.findByText(/Audio codec not supported/i);
});
it('should handle unsupported browser', () => {
mpegts.getFeatureList.mockReturnValue({
mseLivePlayback: false,
});
render(<FloatingVideo />);
expect(
screen.getByText(/browser doesn't support live video streaming/i)
).toBeInTheDocument();
});
it('should play video on MEDIA_INFO event', async () => {
render(<FloatingVideo />);
const mediaInfoCallback = mockPlayer.on.mock.calls.find(
(call) => call[0] === mpegts.Events.MEDIA_INFO
)?.[1];
await mediaInfoCallback();
expect(mockPlayer.play).toHaveBeenCalled();
});
});
describe('VOD Player', () => {
beforeEach(() => {
useVideoStore.mockImplementation((selector) => {
{
const state = {
isVisible: true,
streamUrl: 'http://example.com/video.mp4',
contentType: 'vod',
metadata: {
name: 'Test Movie',
year: '2024',
logo: { url: 'http://example.com/poster.jpg' },
},
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}
});
});
it('should use native video player for VOD', () => {
render(<FloatingVideo />);
expect(mpegts.createPlayer).not.toHaveBeenCalled();
});
it('should set video source for VOD', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
expect(video).toBeInTheDocument();
expect(video.src).toBe('http://example.com/video.mp4');
expect(video.poster).toBe('http://example.com/poster.jpg');
});
it('should show metadata overlay', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
// Simulate video loaded event to clear loading state
fireEvent.loadedData(video);
expect(screen.getAllByText('Test Movie').length).toBeGreaterThanOrEqual(
1
);
expect(screen.getByText('2024')).toBeInTheDocument();
});
it('should hide overlay after 4 seconds', () => {
vi.useFakeTimers();
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
fireEvent.loadedData(video);
fireEvent.canPlay(video);
expect(screen.getAllByText('Test Movie').length).toBeGreaterThanOrEqual(
1
);
vi.advanceTimersByTime(4000);
waitFor(() => {
// After overlay hides, only the header title remains
expect(screen.getAllByText('Test Movie').length).toBe(1);
});
vi.useRealTimers();
});
it('should show overlay on mouse enter', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
fireEvent.loadedData(video);
fireEvent.canPlay(video);
const videoContainer = video.parentElement;
fireEvent.mouseEnter(videoContainer);
expect(screen.getAllByText('Test Movie').length).toBeGreaterThanOrEqual(
1
);
});
it('should hide overlay on mouse leave', () => {
vi.useFakeTimers();
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
fireEvent.loadedData(video);
fireEvent.canPlay(video);
const videoContainer = video.parentElement;
fireEvent.mouseEnter(videoContainer);
fireEvent.mouseLeave(videoContainer);
vi.advanceTimersByTime(4000);
waitFor(() => {
// After overlay hides, only the header title remains
expect(screen.getAllByText('Test Movie').length).toBe(1);
});
vi.useRealTimers();
});
});
describe('Close functionality', () => {
beforeEach(() => {
useVideoStore.mockImplementation((selector) => {
{
const state = {
isVisible: true,
streamUrl: 'http://example.com/stream.ts',
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}
});
});
it('should call hideVideo when close button is clicked', () => {
vi.useFakeTimers();
render(<FloatingVideo />);
fireEvent.click(screen.getByTestId('close-button'));
vi.advanceTimersByTime(50);
waitFor(() => {
expect(mockHideVideo).toHaveBeenCalled();
expect(mockPlayer.destroy).toHaveBeenCalled();
});
vi.useRealTimers();
});
});
describe('Error handling', () => {
beforeEach(() => {
useVideoStore.mockImplementation((selector) => {
{
const state = {
isVisible: true,
streamUrl: 'http://example.com/video.mp4',
contentType: 'vod',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}
});
});
it('should display video error messages', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
Object.defineProperty(video, 'error', {
value: { code: 3, message: 'MEDIA_ERR_DECODE' },
writable: true,
});
fireEvent.error(video);
expect(screen.getByText(/MEDIA_ERR_DECODE/i)).toBeInTheDocument();
});
it('should handle network errors', () => {
const { container } = render(<FloatingVideo />);
const video = container.querySelector('video');
Object.defineProperty(video, 'error', {
value: { code: 2, message: 'MEDIA_ERR_NETWORK' },
writable: true,
});
fireEvent.error(video);
expect(screen.getByText(/MEDIA_ERR_NETWORK/i)).toBeInTheDocument();
});
});
describe('Player cleanup', () => {
it('should cleanup player on unmount', () => {
useVideoStore.mockImplementation((selector) => {
{
const state = {
isVisible: true,
streamUrl: 'http://example.com/stream.ts',
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}
});
const { unmount } = render(<FloatingVideo />);
unmount();
expect(mockPlayer.destroy).toHaveBeenCalled();
});
it('should cleanup player when streamUrl changes', () => {
useVideoStore.mockImplementation((selector) => {
{
const state = {
isVisible: true,
streamUrl: 'http://example.com/stream1.ts',
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}
});
const { rerender } = render(<FloatingVideo />);
useVideoStore.mockImplementation((selector) => {
{
const state = {
isVisible: true,
streamUrl: 'http://example.com/stream2.ts',
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}
});
rerender(<FloatingVideo />);
expect(mockPlayer.destroy).toHaveBeenCalled();
});
});
describe('Resize functionality', () => {
beforeEach(() => {
useVideoStore.mockImplementation((selector) => {
{
const state = {
isVisible: true,
streamUrl: 'http://example.com/stream.ts',
contentType: 'live',
metadata: null,
hideVideo: mockHideVideo,
};
return selector ? selector(state) : state;
}
});
});
it('should render resize handles', () => {
const { container } = render(<FloatingVideo />);
const handles = container.querySelectorAll(
'[class*="floating-video-no-drag"]'
);
// Should have 4 resize handles plus video element
expect(handles.length).toBeGreaterThanOrEqual(4);
});
});
});

View file

@ -0,0 +1,715 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import GuideRow from '../GuideRow';
import {
CHANNEL_WIDTH,
EXPANDED_PROGRAM_HEIGHT,
HOUR_WIDTH,
PROGRAM_HEIGHT,
} from '../../pages/guideUtils';
// Mock logo import
vi.mock('../../images/logo.png', () => ({
default: 'mocked-logo.png',
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
Play: (props) => <div data-testid="play-icon" {...props} />,
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Flex: ({ children, ...props }) => <div {...props}>{children}</div>,
Text: ({ children, ...props }) => <div {...props}>{children}</div>,
};
});
// Helper function to create programs at specific times
const createProgramAtTime = (id, startHour, durationMinutes) => {
const timelineStart = new Date('2024-01-01T00:00:00Z');
const startMs = timelineStart.getTime() + startHour * 60 * 60 * 1000;
return {
id,
title: `Program ${id}`,
startMs,
endMs: startMs + durationMinutes * 60 * 1000,
};
};
describe('GuideRow', () => {
const mockChannel = {
id: 'channel-1',
name: 'Test Channel',
channel_number: '101',
logo_id: 'logo-1',
};
const mockProgram = {
id: 'program-1',
title: 'Test Program',
start_time: '2024-01-01T10:00:00Z',
end_time: '2024-01-01T11:00:00Z',
};
const mockLogos = {
'logo-1': {
cache_url: 'https://example.com/logo.png',
},
};
const mockData = {
filteredChannels: [mockChannel],
programsByChannelId: new Map([[mockChannel.id, [mockProgram]]]),
expandedProgramId: null,
rowHeights: {},
logos: mockLogos,
hoveredChannelId: null,
setHoveredChannelId: vi.fn(),
renderProgram: vi.fn((program) => (
<div key={program.id} data-testid={`program-${program.id}`}>
{program.title}
</div>
)),
handleLogoClick: vi.fn(),
contentWidth: 1920,
guideScrollLeftRef: { current: 0 },
viewportWidth: 1920,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
const mockStyle = {
position: 'absolute',
left: 0,
top: 0,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render channel row with channel information', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
expect(screen.getByTestId('guide-row')).toBeInTheDocument();
expect(screen.getByAltText('Test Channel')).toBeInTheDocument();
expect(screen.getByText('101')).toBeInTheDocument();
});
it('should return null when channel does not exist', () => {
const data = { ...mockData, filteredChannels: [] };
const { container } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
expect(container.firstChild).toBeNull();
});
it('should use default logo when channel logo is not available', () => {
const channelWithoutLogo = { ...mockChannel, logo_id: 'missing-logo' };
const data = {
...mockData,
filteredChannels: [channelWithoutLogo],
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const img = screen.getByAltText('Test Channel');
expect(img).toHaveAttribute('src', 'mocked-logo.png');
});
it('should display channel number or dash if missing', () => {
const channelWithoutNumber = { ...mockChannel, channel_number: null };
const data = {
...mockData,
filteredChannels: [channelWithoutNumber],
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByText('-')).toBeInTheDocument();
});
});
describe('Row Height Calculation', () => {
it('should use default PROGRAM_HEIGHT when no expanded program', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
const row = screen.getByTestId('guide-row');
expect(row).toHaveStyle({ height: `${PROGRAM_HEIGHT}px` });
});
it('should use EXPANDED_PROGRAM_HEIGHT when program is expanded', () => {
const data = {
...mockData,
expandedProgramId: mockProgram.id,
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const row = screen.getByTestId('guide-row');
expect(row).toHaveStyle({ height: `${EXPANDED_PROGRAM_HEIGHT}px` });
});
it('should use pre-calculated row height from rowHeights array', () => {
const customHeight = 150;
const data = {
...mockData,
rowHeights: { 0: customHeight },
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const row = screen.getByTestId('guide-row');
expect(row).toHaveStyle({ height: `${customHeight}px` });
});
});
describe('Programs Rendering', () => {
it('should render programs when channel has programs', () => {
// Create program at hour 0 (definitely within viewport at scrollLeft 0)
const visibleProgram = createProgramAtTime('program-1', 0, 60);
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, [visibleProgram]]]),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByTestId('program-program-1')).toBeInTheDocument();
expect(mockData.renderProgram).toHaveBeenCalledWith(visibleProgram, undefined, mockChannel);
});
it('should render multiple programs', () => {
const programs = [
createProgramAtTime('prog-1', 0, 60),
createProgramAtTime('prog-2', 1, 30),
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByTestId('program-prog-1')).toBeInTheDocument();
expect(screen.getByTestId('program-prog-2')).toBeInTheDocument();
});
it('should render placeholder when channel has no programs', () => {
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, []]]),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const placeholders = screen.getAllByText('No program data');
expect(placeholders.length).toBeGreaterThan(0);
});
it('should render placeholder when programsByChannelId does not contain channel', () => {
const data = {
...mockData,
programsByChannelId: new Map(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const placeholders = screen.getAllByText('No program data');
expect(placeholders.length).toBeGreaterThan(0);
});
it('should position placeholder programs correctly', () => {
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, []]]),
};
const { container } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const placeholders = container.querySelectorAll('[pos*="absolute"]');
const filteredPlaceholders = Array.from(placeholders).filter(el =>
el.textContent.includes('No program data')
);
filteredPlaceholders.forEach((placeholder, index) => {
expect(placeholder).toHaveAttribute('left', `${index * (HOUR_WIDTH * 2)}`);
expect(placeholder).toHaveAttribute('w', `${HOUR_WIDTH * 2}`);
});
});
});
describe('Channel Logo Interactions', () => {
it('should call handleLogoClick when logo is clicked', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
fireEvent.click(logo);
expect(mockData.handleLogoClick).toHaveBeenCalledWith(
mockChannel,
expect.any(Object)
);
});
it('should show play icon on hover', () => {
render(<GuideRow index={0} style={mockStyle} data={mockData} />);
const logo = screen.getByAltText('Test Channel').closest('.channel-logo');
fireEvent.mouseEnter(logo);
expect(screen.getByTestId('play-icon')).toBeInTheDocument();
});
it('should not show play icon when not hovering', () => {
render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
});
});
describe('Layout and Styling', () => {
it('should set correct channel logo width', () => {
const { container } = render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
const logoContainer = container.querySelector('.channel-logo');
expect(logoContainer).toHaveAttribute('w', `${CHANNEL_WIDTH}`);
expect(logoContainer).toHaveAttribute('miw', `${CHANNEL_WIDTH}`);
});
it('should apply content width to row', () => {
const customWidth = 2400;
const data = {
...mockData,
contentWidth: customWidth,
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const row = screen.getByTestId('guide-row');
expect(row).toHaveStyle({ width: `${customWidth}px` });
});
it('should adjust logo image container height based on row height', () => {
const customHeight = 200;
const data = {
...mockData,
rowHeights: { 0: customHeight },
};
const { container } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const imageContainer = container.querySelector('img').parentElement;
expect(imageContainer).toHaveAttribute('h', `${customHeight - 32}px`);
});
});
describe('Horizontal Viewport Culling', () => {
it('should only render programs visible in viewport', () => {
const programs = [
createProgramAtTime('prog-1', 0, 60), // Hour 0
createProgramAtTime('prog-2', 6, 60), // Hour 6
createProgramAtTime('prog-3', 12, 60), // Hour 12
createProgramAtTime('prog-4', 18, 60), // Hour 18
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 6 }, // Scroll to hour 6
viewportWidth: HOUR_WIDTH * 4, // Show 4 hours
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Program at hour 6 should be visible
expect(screen.getByTestId('program-prog-2')).toBeInTheDocument();
// Programs outside viewport + buffer should not be rendered
expect(mockData.renderProgram).toHaveBeenCalledTimes(1);
});
it('should render programs within buffer zone', () => {
const programs = [
createProgramAtTime('prog-1', 0, 60),
createProgramAtTime('prog-2', 1, 60),
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 2 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Programs within H_BUFFER (600px) should be rendered
const renderedPrograms = mockData.renderProgram.mock.calls.map(
call => call[0]
);
expect(renderedPrograms.length).toBeGreaterThan(0);
});
it('should not render programs completely outside viewport and buffer', () => {
const programs = [
createProgramAtTime('prog-far-left', 0, 60),
createProgramAtTime('prog-visible', 10, 60),
createProgramAtTime('prog-far-right', 22, 60),
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 10 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
const renderedPrograms = mockData.renderProgram.mock.calls.map(
call => call[0].id
);
// Only visible program should be rendered
expect(renderedPrograms).toContain('prog-visible');
expect(renderedPrograms).not.toContain('prog-far-left');
expect(renderedPrograms).not.toContain('prog-far-right');
});
it('should handle edge case where program spans viewport boundary', () => {
const programs = [
createProgramAtTime('prog-spanning', 5, 180), // 3-hour program
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 6 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Program spanning viewport should be visible
expect(screen.getByTestId('program-prog-spanning')).toBeInTheDocument();
});
it('should update visible programs when scroll position changes', () => {
const programs = [
createProgramAtTime('prog-1', 0, 60),
createProgramAtTime('prog-2', 12, 60),
];
const scrollRef = { current: 0 };
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: scrollRef,
viewportWidth: HOUR_WIDTH * 4,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
const { rerender } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const initialCalls = mockData.renderProgram.mock.calls.length;
// Scroll to different position
scrollRef.current = HOUR_WIDTH * 12;
const newData = { ...data, guideScrollLeftRef: scrollRef };
rerender(<GuideRow index={0} style={mockStyle} data={newData} />);
// Different programs should be rendered
expect(mockData.renderProgram.mock.calls.length).toBeGreaterThan(initialCalls);
});
});
describe('Placeholder Culling', () => {
it('should only render placeholders visible in viewport', () => {
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, []]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 10 },
viewportWidth: HOUR_WIDTH * 4,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
const { container } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const visiblePlaceholders = screen.getAllByText('No program data');
// Should render fewer than total placeholders due to culling
expect(visiblePlaceholders.length).toBeLessThan(Math.ceil(24 / 2));
expect(visiblePlaceholders.length).toBeGreaterThan(0);
});
it('should not render placeholders outside viewport', () => {
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, []]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 20 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
const { container } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const visiblePlaceholders = screen.getAllByText('No program data');
// Near the end of timeline, should show fewer placeholders
expect(visiblePlaceholders.length).toBeGreaterThan(0);
expect(visiblePlaceholders.length).toBeLessThanOrEqual(3);
});
});
describe('Hover State Management', () => {
it('should show play icon on mouse enter', () => {
render(<GuideRow index={0} style={mockStyle} data={mockData} />);
const logoContainer = screen.getByAltText('Test Channel').closest('.channel-logo');
expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
fireEvent.mouseEnter(logoContainer);
expect(screen.getByTestId('play-icon')).toBeInTheDocument();
});
it('should hide play icon on mouse leave', () => {
render(<GuideRow index={0} style={mockStyle} data={mockData} />);
const logoContainer = screen.getByAltText('Test Channel').closest('.channel-logo');
fireEvent.mouseEnter(logoContainer);
expect(screen.getByTestId('play-icon')).toBeInTheDocument();
fireEvent.mouseLeave(logoContainer);
expect(screen.queryByTestId('play-icon')).not.toBeInTheDocument();
});
it('should maintain hover state independently per row', () => {
const data = {
...mockData,
filteredChannels: [mockChannel, { ...mockChannel, id: 'channel-2', name: 'Channel 2' }],
};
// Render both rows separately
const { container: container1 } = render(
<GuideRow index={0} style={mockStyle} data={data} />
);
const { container: container2 } = render(
<GuideRow index={1} style={{ ...mockStyle, top: PROGRAM_HEIGHT }} data={data} />
);
// Hover over first row
const logo1 = container1.querySelector('.channel-logo');
fireEvent.mouseEnter(logo1);
// First row should show play icon
expect(container1.querySelector('[data-testid="play-icon"]')).toBeInTheDocument();
// Second row should not show play icon
expect(container2.querySelector('[data-testid="play-icon"]')).not.toBeInTheDocument();
});
});
describe('Program Time Positioning', () => {
const createTimedProgram = (startHour, durationMinutes) => {
const timelineStart = new Date('2024-01-01T00:00:00Z');
const startMs = timelineStart.getTime() + startHour * 60 * 60 * 1000;
return {
id: `program-${startHour}`,
title: `Program at ${startHour}h`,
startMs,
endMs: startMs + durationMinutes * 60 * 1000,
};
};
it('should calculate correct viewport boundaries', () => {
const programs = [
createTimedProgram(6, 60),
createTimedProgram(12, 60),
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 8 },
viewportWidth: HOUR_WIDTH * 4,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Both programs should be rendered since they're within viewport range
expect(mockData.renderProgram).toHaveBeenCalled();
});
it('should handle programs at timeline boundaries', () => {
const programs = [
createTimedProgram(0, 60), // Start of timeline
createTimedProgram(23, 60), // End of timeline
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: 0 },
viewportWidth: HOUR_WIDTH * 24,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByText('Program at 0h')).toBeInTheDocument();
expect(screen.getByText('Program at 23h')).toBeInTheDocument();
});
it('should handle very short programs', () => {
const programs = [
createTimedProgram(12, 5), // 5-minute program
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 12 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByTestId('program-program-12')).toBeInTheDocument();
});
it('should handle very long programs', () => {
const programs = [
createTimedProgram(6, 360), // 6-hour program
];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: HOUR_WIDTH * 8 },
viewportWidth: HOUR_WIDTH * 2,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Long program spanning viewport should be visible
expect(screen.getByTestId('program-program-6')).toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('should handle empty programsByChannelId gracefully', () => {
const data = {
...mockData,
programsByChannelId: new Map(),
guideScrollLeftRef: { current: 0 },
viewportWidth: HOUR_WIDTH * 4,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getAllByText('No program data').length).toBeGreaterThan(0);
});
it('should handle zero viewport width', () => {
const programs = [mockProgram];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: 0 },
viewportWidth: 0,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
// Should still render due to buffer
expect(screen.getByTestId('guide-row')).toBeInTheDocument();
});
it('should handle negative scroll position', () => {
const programs = [createProgramAtTime('prog-1', 0, 60)];
const data = {
...mockData,
programsByChannelId: new Map([[mockChannel.id, programs]]),
guideScrollLeftRef: { current: -100 },
viewportWidth: HOUR_WIDTH * 4,
timelineStartMs: new Date('2024-01-01T00:00:00Z').getTime(),
};
render(<GuideRow index={0} style={mockStyle} data={data} />);
expect(screen.getByTestId('guide-row')).toBeInTheDocument();
});
it('should handle row index out of bounds', () => {
const data = {
...mockData,
filteredChannels: [mockChannel],
};
const { container } = render(
<GuideRow index={999} style={mockStyle} data={data} />
);
expect(container.firstChild).toBeNull();
});
it('should memoize component to prevent unnecessary re-renders', () => {
const { rerender } = render(
<GuideRow index={0} style={mockStyle} data={mockData} />
);
const renderCount = mockData.renderProgram.mock.calls.length;
// Re-render with same props
rerender(<GuideRow index={0} style={mockStyle} data={mockData} />);
// Should not cause additional renders due to React.memo
expect(mockData.renderProgram.mock.calls.length).toBe(renderCount);
});
});
});

View file

@ -0,0 +1,318 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import HourTimeline from '../HourTimeline';
import { format } from '../../utils/dateTimeUtils';
import { HOUR_WIDTH } from '../../pages/guideUtils';
// Mock date utilities
vi.mock('../../utils/dateTimeUtils', () => ({
format: vi.fn((date, formatStr) => {
if (!formatStr) return date.toISOString();
return formatStr === 'h:mm a' ? '12:00 PM' : 'Jan 1';
}),
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Text: ({ children, ...props }) => <span {...props}>{children}</span>,
};
});
describe('HourTimeline', () => {
const mockTime1 = new Date('2024-01-01T10:00:00Z');
const mockTime2 = new Date('2024-01-01T11:00:00Z');
const mockTime3 = new Date('2024-01-02T00:00:00Z');
const mockHourTimeline = [
{ time: mockTime1, isNewDay: false },
{ time: mockTime2, isNewDay: false },
];
const mockTimeFormat = 'h:mm a';
const mockFormatDayLabel = vi.fn((time) => 'Mon');
const mockHandleTimeClick = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
describe('Rendering', () => {
it('should render all hour blocks', () => {
render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
expect(mockFormatDayLabel).toHaveBeenCalledTimes(2);
expect(format).toHaveBeenCalledWith(mockTime1, mockTimeFormat);
expect(format).toHaveBeenCalledWith(mockTime2, mockTimeFormat);
});
it('should render empty when hourTimeline is empty', () => {
const { container } = render(
<HourTimeline
hourTimeline={[]}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
expect(container.firstChild).toBeNull();
});
it('should render formatted time labels', () => {
render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const timeLabels = screen.getAllByText('12:00 PM');
expect(timeLabels.length).toBe(2);
});
it('should render day labels', () => {
render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const dayLabels = screen.getAllByText('Mon');
expect(dayLabels.length).toBe(2);
});
});
describe('HourBlock Styling', () => {
it('should apply correct width and height to hour blocks', () => {
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
hourBlocks.forEach((block) => {
expect(block).toHaveAttribute('w', `${HOUR_WIDTH}`);
expect(block).toHaveAttribute('h', '40px');
expect(block).toHaveAttribute('pos', 'relative');
});
});
it('should apply default styling for non-new-day blocks', () => {
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
expect(hourBlocks[0]).toHaveStyle({
backgroundColor: '#1B2421',
});
});
it('should apply special styling for new day blocks', () => {
const newDayTimeline = [
{ time: mockTime3, isNewDay: true },
];
const { container } = render(
<HourTimeline
hourTimeline={newDayTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const hourBlock = container.querySelector('[style*="cursor: pointer"]');
expect(hourBlock).toHaveStyle({
borderLeft: '2px solid #3BA882',
backgroundColor: '#1E2A27',
});
});
it('should apply bold font weight to day label on new day', () => {
const newDayTimeline = [
{ time: mockTime3, isNewDay: true },
];
const { container } = render(
<HourTimeline
hourTimeline={newDayTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const dayLabel = screen.getByText('Mon');
expect(dayLabel).toHaveAttribute('fw', '600');
expect(dayLabel).toHaveAttribute('c', '#3BA882');
});
it('should apply normal font weight to day label on regular day', () => {
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const dayLabels = screen.getAllByText('Mon');
expect(dayLabels[0]).toHaveAttribute('fw', '400');
});
});
describe('Quarter Hour Markers', () => {
it('should render quarter hour markers', () => {
const { container } = render(
<HourTimeline
hourTimeline={[mockHourTimeline[0]]}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const markers = container.querySelectorAll('[style*="background-color: rgb(113, 128, 150);"]');
expect(markers.length).toBe(3); // 15, 30, 45 minute markers
});
it('should position quarter hour markers correctly', () => {
const { container } = render(
<HourTimeline
hourTimeline={[mockHourTimeline[0]]}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const markers = container.querySelectorAll('[style*="backgroundColor: #718096"]');
const positions = ['25%', '50%', '75%'];
markers.forEach((marker, index) => {
expect(marker).toHaveStyle({
left: positions[index],
width: '1px',
height: '8px',
position: 'absolute',
bottom: '0px',
});
});
});
});
describe('Click Interactions', () => {
it('should call handleTimeClick when hour block is clicked', () => {
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
fireEvent.click(hourBlocks[0]);
expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime1, expect.any(Object));
});
it('should call handleTimeClick with correct time for each block', () => {
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const hourBlocks = container.querySelectorAll('[style*="cursor: pointer"]');
fireEvent.click(hourBlocks[0]);
expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime1, expect.any(Object));
fireEvent.click(hourBlocks[1]);
expect(mockHandleTimeClick).toHaveBeenCalledWith(mockTime2, expect.any(Object));
});
});
describe('Component Keys', () => {
it('should use formatted time as key for each hour block', () => {
format.mockImplementation((date) => date.toISOString());
const { container } = render(
<HourTimeline
hourTimeline={mockHourTimeline}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
expect(format).toHaveBeenCalledWith(mockTime1);
expect(format).toHaveBeenCalledWith(mockTime2);
});
});
describe('Time Label Positioning', () => {
it('should position time label correctly', () => {
const { container } = render(
<HourTimeline
hourTimeline={[mockHourTimeline[0]]}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const timeLabel = container.querySelector('[pos*="absolute"][top*="8"]');
expect(timeLabel).toHaveAttribute('left', '4');
});
});
describe('Visual Separators', () => {
it('should render left separator box', () => {
const { container } = render(
<HourTimeline
hourTimeline={[mockHourTimeline[0]]}
timeFormat={mockTimeFormat}
formatDayLabel={mockFormatDayLabel}
handleTimeClick={mockHandleTimeClick}
/>
);
const separator = container.querySelector('[w*="1px"][left*="0"]');
expect(separator).toHaveAttribute('pos', 'absolute');
expect(separator).toHaveAttribute('top', '0');
expect(separator).toHaveAttribute('bottom', '0');
});
});
});

View file

@ -0,0 +1,398 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import LazyLogo from '../LazyLogo';
import useLogosStore from '../../store/logos';
// Mock the logos store
vi.mock('../../store/logos', () => ({
default: vi.fn(),
}));
// Mock the logo import
vi.mock('../../images/logo.png', () => ({
default: 'mocked-default-logo.png',
}));
// Mock Mantine Skeleton component
vi.mock('@mantine/core', async () => {
return {
Skeleton: ({ height, width, style, ...props }) => {
return <div data-testid="skeleton" style={{ height, width, ...style }} {...props} />;
},
};
});
describe('LazyLogo', () => {
let mockFetchLogosByIds;
let mockStore;
beforeEach(() => {
vi.clearAllMocks();
vi.clearAllTimers();
vi.useFakeTimers();
mockFetchLogosByIds = vi.fn().mockResolvedValue(undefined);
mockStore = {
logos: {},
fetchLogosByIds: mockFetchLogosByIds,
allowLogoRendering: true,
};
useLogosStore.mockImplementation((selector) => selector(mockStore));
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
describe('Rendering', () => {
it('should render image with logo data', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
render(<LazyLogo logoId="logo-1" alt="Test Logo" />);
const img = screen.getByAltText('Test Logo');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', 'https://example.com/logo.png');
});
it('should render with default style', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
render(<LazyLogo logoId="logo-1" />);
const img = screen.getByAltText('logo');
expect(img).toHaveStyle({
maxHeight: '18px',
maxWidth: '55px',
});
});
it('should render with custom style', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
const customStyle = { maxHeight: 30, maxWidth: 100 };
render(<LazyLogo logoId="logo-1" style={customStyle} />);
const img = screen.getByAltText('logo');
expect(img).toHaveStyle({
maxHeight: '30px',
maxWidth: '100px',
});
});
it('should render fallback logo when no logoId provided', () => {
render(<LazyLogo />);
const img = screen.getByAltText('logo');
expect(img).toHaveAttribute('src', 'mocked-default-logo.png');
});
it('should pass through additional props to img element', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
render(
<LazyLogo logoId="logo-1" className="test-class" data-testid="custom-logo" />
);
const img = screen.getByTestId('custom-logo');
expect(img).toHaveClass('test-class');
});
});
describe('Skeleton Loading State', () => {
it('should show skeleton when logo rendering is not allowed', () => {
mockStore.allowLogoRendering = false;
render(<LazyLogo logoId="logo-1" />);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
expect(screen.queryByAltText('logo')).not.toBeInTheDocument();
});
it('should show skeleton when logo data is not available', () => {
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
});
it('should render skeleton with default dimensions', () => {
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
const skeleton = screen.getByTestId('skeleton');
expect(skeleton).toHaveStyle({
height: '18px',
width: '55px',
});
});
it('should render skeleton with custom dimensions', () => {
mockStore.logos = {};
const customStyle = { maxHeight: 40, maxWidth: 120 };
render(<LazyLogo logoId="logo-1" style={customStyle} />);
const skeleton = screen.getByTestId('skeleton');
expect(skeleton).toHaveStyle({
height: '40px',
width: '120px',
});
});
it('should apply border radius to skeleton', () => {
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
const skeleton = screen.getByTestId('skeleton');
expect(skeleton).toHaveStyle({
borderRadius: '4px',
});
});
});
describe('Logo Fetching', () => {
it('should fetch logo when logoId is provided and logo data is missing', async () => {
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo-1']);
});
it('should not fetch logo when logo data already exists', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).not.toHaveBeenCalled();
});
it('should not fetch logo when allowLogoRendering is false', () => {
mockStore.allowLogoRendering = false;
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).not.toHaveBeenCalled();
});
it('should not fetch logo when no logoId is provided', () => {
render(<LazyLogo />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).not.toHaveBeenCalled();
});
it('should batch multiple logo requests', async () => {
mockStore.logos = {};
render(
<>
<LazyLogo logoId="logo-1" />
<LazyLogo logoId="logo-2" />
<LazyLogo logoId="logo-3" />
</>
);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
expect(mockFetchLogosByIds).toHaveBeenCalledWith(
expect.arrayContaining(['logo-1', 'logo-2', 'logo-3'])
);
});
it('should debounce fetch requests with 100ms delay', async () => {
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(50);
expect(mockFetchLogosByIds).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
expect(mockFetchLogosByIds).toHaveBeenCalled();
});
it('should handle fetch errors gracefully', async () => {
mockFetchLogosByIds.mockRejectedValueOnce(new Error('Fetch failed'));
mockStore.logos = {};
render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
});
it('should not fetch same logo twice', async () => {
mockStore.logos = {};
const { rerender } = render(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
rerender(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalledTimes(1);
});
});
describe('Error Handling', () => {
it('should fallback to default logo on image load error', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/invalid-logo.png' },
};
render(<LazyLogo logoId="logo-1" />);
const img = screen.getByAltText('logo');
// Simulate image load error
img.dispatchEvent(new Event('error'));
expect(img).toHaveAttribute('src', 'mocked-default-logo.png');
});
it('should use custom fallback source on error', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/invalid-logo.png' },
};
const customFallback = 'custom-fallback.png';
render(<LazyLogo logoId="logo-1" fallbackSrc={customFallback} />);
const img = screen.getByAltText('logo');
img.dispatchEvent(new Event('error'));
expect(img).toHaveAttribute('src', customFallback);
});
it('should only set fallback once to prevent infinite error loop', () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/invalid-logo.png' },
};
render(<LazyLogo logoId="logo-1" />);
const img = screen.getByAltText('logo');
// First error - should set fallback
img.dispatchEvent(new Event('error'));
expect(img).toHaveAttribute('src', 'mocked-default-logo.png');
// Reset src to test second error
img.src = 'https://example.com/another-invalid.png';
// Second error - should not change src again
img.dispatchEvent(new Event('error'));
expect(img).toHaveAttribute('src', 'mocked-default-logo.png');
});
it('should reset error state when logoId changes', async () => {
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo1.png' },
'logo-2': { cache_url: 'https://example.com/logo2.png' },
};
const { rerender } = render(<LazyLogo logoId="logo-1" />);
const img = screen.getByAltText('logo');
img.dispatchEvent(new Event('error'));
rerender(<LazyLogo logoId="logo-2" />);
const newImg = screen.getByAltText('logo');
expect(newImg).toHaveAttribute('src', 'https://example.com/logo2.png');
});
});
describe('Component Lifecycle', () => {
it('should cleanup on unmount', () => {
const { unmount } = render(<LazyLogo logoId="logo-1" />);
unmount();
// Should not throw errors after unmount
vi.advanceTimersByTime(100);
});
it('should handle rapid logoId changes', async () => {
mockStore.logos = {};
const { rerender } = render(<LazyLogo logoId="logo-1" />);
rerender(<LazyLogo logoId="logo-2" />);
rerender(<LazyLogo logoId="logo-3" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalled();
});
});
describe('Store Integration', () => {
it('should react to store updates', async () => {
mockStore.logos = {};
const { rerender } = render(<LazyLogo logoId="logo-1" />);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
// Update store with logo data
mockStore.logos = {
'logo-1': { cache_url: 'https://example.com/logo.png' },
};
rerender(<LazyLogo logoId="logo-1" />);
expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
expect(screen.getByAltText('logo')).toBeInTheDocument();
});
it('should handle allowLogoRendering becoming true', async () => {
mockStore.allowLogoRendering = false;
mockStore.logos = {};
const { rerender } = render(<LazyLogo logoId="logo-1" />);
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
expect(mockFetchLogosByIds).not.toHaveBeenCalled();
mockStore.allowLogoRendering = true;
rerender(<LazyLogo logoId="logo-1" />);
vi.advanceTimersByTime(100);
expect(mockFetchLogosByIds).toHaveBeenCalledWith(['logo-1']);
});
});
});

View file

@ -0,0 +1,716 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { BrowserRouter } from 'react-router-dom';
import M3URefreshNotification from '../M3URefreshNotification';
import usePlaylistsStore from '../../store/playlists';
import useStreamsStore from '../../store/streams';
import useChannelsStore from '../../store/channels';
import useEPGsStore from '../../store/epgs';
import useVODStore from '../../store/useVODStore';
import API from '../../api';
import { showNotification } from '../../utils/notificationUtils';
// Mock all stores
vi.mock('../../store/playlists', () => ({
default: vi.fn(),
}));
vi.mock('../../store/streams', () => ({
default: vi.fn(),
}));
vi.mock('../../store/channels', () => ({
default: vi.fn(),
}));
vi.mock('../../store/epgs', () => ({
default: vi.fn(),
}));
vi.mock('../../store/useVODStore', () => ({
default: vi.fn(),
}));
// Mock API
vi.mock('../../api', () => ({
default: {
refreshPlaylist: vi.fn(),
requeryChannels: vi.fn(),
},
}));
// Mock notification utility
vi.mock('../../utils/notificationUtils', () => ({
showNotification: vi.fn(),
}));
vi.mock('@mantine/core', async () => {
return {
Stack: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick }) => (
<button onClick={onClick}>{children}</button>
),
};
});
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
CircleCheck: () => <div data-testid="circle-check-icon" />,
}));
const renderWithProviders = (component) => {
return render(<BrowserRouter>{component}</BrowserRouter>);
};
describe('M3URefreshNotification', () => {
let mockPlaylistsStore;
let mockStreamsStore;
let mockChannelsStore;
let mockEPGsStore;
let mockVODStore;
const mockPlaylist = {
id: 1,
name: 'Test Playlist',
url: 'https://example.com/playlist.m3u',
};
beforeEach(() => {
vi.clearAllMocks();
// Setup default store mocks
mockPlaylistsStore = {
playlists: [mockPlaylist],
refreshProgress: {},
fetchPlaylists: vi.fn(),
setEditPlaylistId: vi.fn(),
};
mockStreamsStore = {
fetchStreams: vi.fn(),
};
mockChannelsStore = {
fetchChannelGroups: vi.fn(),
fetchChannelIds: vi.fn(),
};
mockEPGsStore = {
fetchEPGData: vi.fn(),
};
mockVODStore = {
fetchCategories: vi.fn(),
};
usePlaylistsStore.mockImplementation((selector) =>
selector(mockPlaylistsStore)
);
useStreamsStore.mockImplementation((selector) =>
selector(mockStreamsStore)
);
useChannelsStore.mockImplementation((selector) =>
selector(mockChannelsStore)
);
useEPGsStore.mockImplementation((selector) => selector(mockEPGsStore));
useVODStore.mockImplementation((selector) => selector(mockVODStore));
});
describe('Rendering', () => {
it('should render without crashing', () => {
const { container } = renderWithProviders(<M3URefreshNotification />);
expect(container).toBeInTheDocument();
});
it('should render empty fragment', () => {
const { container } = renderWithProviders(<M3URefreshNotification />);
expect(container.firstChild).toBeNull();
});
});
describe('Download Progress Notifications', () => {
it('should show notification when download starts', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'downloading',
progress: 0,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'Downloading starting...',
loading: true,
autoClose: 2000,
icon: null,
});
});
});
it('should show notification when download completes', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'downloading',
progress: 100,
status: 'completed',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'Downloading complete!',
loading: false,
autoClose: 2000,
icon: expect.anything(),
});
});
});
it('should not show notification for intermediate progress', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'downloading',
progress: 50,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
describe('Parsing Progress Notifications', () => {
it('should show notification when parsing starts', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'Stream parsing starting...',
loading: true,
autoClose: 2000,
icon: null,
});
});
});
it('should show notification and trigger fetches when parsing completes', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 100,
status: 'completed',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalled();
expect(mockStreamsStore.fetchStreams).toHaveBeenCalled();
expect(API.requeryChannels).toHaveBeenCalled();
expect(mockChannelsStore.fetchChannelIds).toHaveBeenCalled();
});
});
});
describe('Group Processing Notifications', () => {
it('should show notification when processing groups starts', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'processing_groups',
progress: 0,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'Group parsing starting...',
loading: true,
autoClose: 2000,
icon: null,
});
});
});
it('should trigger multiple fetches when processing groups completes', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'processing_groups',
progress: 100,
status: 'completed',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(mockStreamsStore.fetchStreams).toHaveBeenCalled();
expect(mockChannelsStore.fetchChannelGroups).toHaveBeenCalled();
expect(mockEPGsStore.fetchEPGData).toHaveBeenCalled();
expect(mockPlaylistsStore.fetchPlaylists).toHaveBeenCalled();
});
});
});
describe('VOD Refresh Notifications', () => {
it('should show notification when VOD refresh starts', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'vod_refresh',
progress: 0,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'VOD content refresh starting...',
loading: true,
autoClose: 2000,
icon: null,
});
});
});
it('should trigger VOD-specific fetches when VOD refresh completes', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'vod_refresh',
progress: 100,
status: 'completed',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(mockPlaylistsStore.fetchPlaylists).toHaveBeenCalled();
expect(mockVODStore.fetchCategories).toHaveBeenCalled();
});
});
});
describe('Pending Setup Status', () => {
it('should show setup notification and trigger fetches for pending_setup status', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
status: 'pending_setup',
message: 'Test setup message',
progress: 100,
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Setup: Test Playlist',
message: expect.anything(),
color: 'orange.5',
autoClose: 5000,
});
expect(mockChannelsStore.fetchChannelGroups).toHaveBeenCalled();
expect(mockPlaylistsStore.fetchPlaylists).toHaveBeenCalled();
});
});
it('should use default message when no message provided in pending_setup', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
status: 'pending_setup',
progress: 100,
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalled();
});
});
});
describe('Error Handling', () => {
it('should show error notification when status is error and progress is 100', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
status: 'error',
progress: 100,
error: 'Connection timeout',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'parsing failed: Connection timeout',
color: 'red',
autoClose: 5000,
});
});
});
it('should not show error notification when progress is not 100', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
status: 'error',
progress: 50,
error: 'Connection timeout',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
it('should use default error message when error field is missing', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'downloading',
status: 'error',
progress: 100,
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'downloading failed: Unknown error',
color: 'red',
autoClose: 5000,
});
});
});
it('should use default action when action field is missing in error', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
status: 'error',
progress: 100,
error: 'Test error',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith({
title: 'M3U Processing: Test Playlist',
message: 'Processing failed: Test error',
color: 'red',
autoClose: 5000,
});
});
});
it('should not show further notifications after error status', async () => {
// First update with error
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
status: 'error',
progress: 100,
error: 'Test error',
},
};
const { rerender } = renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledTimes(1);
});
vi.clearAllMocks();
// Second update with success
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
status: 'completed',
progress: 100,
},
};
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
// Should not show notification due to previous error
expect(showNotification).not.toHaveBeenCalled();
});
});
describe('Playlist Validation', () => {
it('should not show notification if playlist not found', async () => {
mockPlaylistsStore.playlists = [];
mockPlaylistsStore.refreshProgress = {
999: {
account: 999,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
it('should handle multiple playlists correctly', async () => {
const secondPlaylist = { id: 2, name: 'Second Playlist' };
mockPlaylistsStore.playlists = [mockPlaylist, secondPlaylist];
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
2: {
account: 2,
action: 'downloading',
progress: 100,
status: 'completed',
},
};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledTimes(2);
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'M3U Processing: Test Playlist',
})
);
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({
title: 'M3U Processing: Second Playlist',
})
);
});
});
});
describe('Notification Deduplication', () => {
it('should not show duplicate notification for same status', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
const { rerender } = renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledTimes(1);
});
vi.clearAllMocks();
// Re-render with same data
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
expect(showNotification).not.toHaveBeenCalled();
});
it('should show notification when status changes', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
const { rerender } = renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledTimes(1);
});
vi.clearAllMocks();
// Update with different progress
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 100,
status: 'completed',
},
};
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
await waitFor(() => {
expect(showNotification).toHaveBeenCalledTimes(1);
});
});
});
describe('State Cleanup', () => {
it('should reset notification status when playlists change', async () => {
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
const { rerender } = renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).toHaveBeenCalled();
});
vi.clearAllMocks();
// Change playlists - remove existing playlist
mockPlaylistsStore.playlists = [];
mockPlaylistsStore.refreshProgress = {
2: {
account: 2,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
// Should not show notification because playlist doesn't exist
expect(showNotification).not.toHaveBeenCalled();
});
it('should handle empty playlists array', async () => {
mockPlaylistsStore.playlists = [];
mockPlaylistsStore.refreshProgress = {};
renderWithProviders(<M3URefreshNotification />);
await waitFor(() => {
expect(showNotification).not.toHaveBeenCalled();
});
});
});
describe('Effect Dependencies', () => {
it('should re-run effect when refreshProgress changes', async () => {
mockPlaylistsStore.refreshProgress = {};
const { rerender } = renderWithProviders(<M3URefreshNotification />);
expect(showNotification).not.toHaveBeenCalled();
mockPlaylistsStore.refreshProgress = {
1: {
account: 1,
action: 'parsing',
progress: 0,
status: 'in_progress',
},
};
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
await waitFor(() => {
expect(showNotification).toHaveBeenCalled();
});
});
it('should re-run effect when playlists change', async () => {
const { rerender } = renderWithProviders(<M3URefreshNotification />);
const newPlaylist = { id: 2, name: 'New Playlist' };
mockPlaylistsStore.playlists = [mockPlaylist, newPlaylist];
rerender(
<BrowserRouter>
<M3URefreshNotification />
</BrowserRouter>
);
});
});
});

View file

@ -0,0 +1,662 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BrowserRouter } from 'react-router-dom';
import NotificationCenter from '../NotificationCenter';
import useNotificationsStore from '../../store/notifications';
import * as NotificationUtils from '../../utils/components/NotificationCenterUtils';
// Mock the notifications store
vi.mock('../../store/notifications');
// Mock the notification utils
vi.mock('../../utils/components/NotificationCenterUtils', () => ({
getNotifications: vi.fn(),
dismissNotification: vi.fn(),
dismissAllNotifications: vi.fn(),
}));
// Mock react-router-dom
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Popover: ({ children, opened }) => (
<div data-testid="popover" data-opened={opened}>
{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"
data-label={label}
data-disabled={disabled}
data-processing={processing}
>
{children}
</div>
),
ActionIcon: ({ children, onClick, 'aria-label': 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>,
Button: ({ children, onClick, ...props }) => (
<button onClick={onClick} data-testid="button" {...props}>
{children}
</button>
),
Divider: () => <hr data-testid="divider" />,
Tooltip: ({ children, label }) => (
<div data-testid="tooltip" title={label}>
{children}
</div>
),
useMantineTheme: () => ({
colors: {
red: ['', '', '', '', '', '#fa5252', '', '', '', '#c92a2a'],
orange: ['', '', '', '', '', '#fd7e14'],
blue: ['', '', '', '', '', '#228be6'],
gray: ['', '', '', '', '', '#adb5bd'],
green: ['', '', '', '', '', '#51cf66'],
violet: ['', '', '', '', '', '#7950f2'],
},
}),
};
});
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
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>,
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>,
Megaphone: () => <span data-testid="megaphone-icon">Megaphone</span>,
X: () => <span data-testid="x-icon">X</span>,
Eye: () => <span data-testid="eye-icon">Eye</span>,
EyeOff: () => <span data-testid="eyeoff-icon">EyeOff</span>,
ArrowRight: () => <span data-testid="arrow-right-icon">ArrowRight</span>,
}));
const mockNotifications = [
{
id: 1,
title: 'Version Update Available',
message: 'Version 2.0 is available',
notification_type: 'version_update',
priority: 'high',
is_dismissed: false,
created_at: '2024-01-01T10:00:00Z',
action_data: {
release_url: 'https://github.com/releases/v2.0',
},
},
{
id: 2,
title: 'Setting Recommendation',
message: 'Enable dark mode for better experience',
notification_type: 'setting_recommendation',
priority: 'normal',
is_dismissed: false,
created_at: '2024-01-02T10:00:00Z',
action_data: {},
},
{
id: 3,
title: 'System Announcement',
message: 'Maintenance scheduled for tomorrow',
notification_type: 'announcement',
priority: 'normal',
is_dismissed: true,
created_at: '2024-01-03T10:00:00Z',
action_data: {},
},
{
id: 4,
title: 'Critical Warning',
message: 'System issue detected',
notification_type: 'warning',
priority: 'critical',
is_dismissed: false,
created_at: '2024-01-04T10:00:00Z',
action_data: {},
},
];
describe('NotificationCenter', () => {
beforeEach(() => {
vi.clearAllMocks();
// vi.useFakeTimers();
// Default store mock
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: mockNotifications,
unreadCount: 3,
getUnreadNotifications: vi.fn(() =>
mockNotifications.filter((n) => !n.is_dismissed)
),
};
return selector ? selector(state) : state;
});
NotificationUtils.getNotifications.mockResolvedValue();
NotificationUtils.dismissNotification.mockResolvedValue();
NotificationUtils.dismissAllNotifications.mockResolvedValue();
});
afterEach(() => {
vi.clearAllTimers();
});
const renderComponent = (props = {}) => {
return render(
<BrowserRouter>
<NotificationCenter {...props} />
</BrowserRouter>
);
};
describe('Bell Icon and Indicator', () => {
it('should render bell icon', () => {
renderComponent();
expect(screen.getByLabelText('Notifications')).toBeInTheDocument();
expect(screen.getByTestId('bell-icon')).toBeInTheDocument();
});
it('should show unread count indicator when there are unread notifications', () => {
renderComponent();
const indicator = screen.getByTestId('indicator');
expect(indicator).toHaveAttribute('data-label', '3');
expect(indicator).toHaveAttribute('data-disabled', 'false');
expect(indicator).toHaveAttribute('data-processing', 'true');
});
it('should not show indicator when unread count is zero', () => {
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: [],
unreadCount: 0,
getUnreadNotifications: vi.fn(() => []),
};
return selector ? selector(state) : state;
});
renderComponent();
const indicator = screen.getByTestId('indicator');
expect(indicator).toHaveAttribute('data-disabled', 'true');
});
it('should show "9+" when unread count exceeds 9', () => {
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: mockNotifications,
unreadCount: 15,
getUnreadNotifications: vi.fn(() => mockNotifications),
};
return selector ? selector(state) : state;
});
renderComponent();
const indicator = screen.getByTestId('indicator');
expect(indicator).toHaveAttribute('data-label', '9+');
});
});
describe('Popover Toggle', () => {
it('should open popover when bell icon is clicked', () => {
renderComponent();
const bellButton = screen.getByLabelText('Notifications');
fireEvent.click(bellButton);
const popover = screen.getByTestId('popover');
expect(popover).toHaveAttribute('data-opened', 'true');
});
it('should close popover when bell icon is clicked again', () => {
renderComponent();
const bellButton = screen.getByLabelText('Notifications');
fireEvent.click(bellButton);
fireEvent.click(bellButton);
const popover = screen.getByTestId('popover');
expect(popover).toHaveAttribute('data-opened', 'false');
});
it('should display notification header with count', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByText('Notifications')).toBeInTheDocument();
expect(screen.getByText('3 new')).toBeInTheDocument();
});
});
describe('API Calls', () => {
it('should fetch notifications on mount', async () => {
renderComponent();
await waitFor(() => {
expect(NotificationUtils.getNotifications).toHaveBeenCalledWith(false);
});
});
it('should fetch notifications every 5 minutes', () => {
vi.useFakeTimers();
renderComponent();
expect(NotificationUtils.getNotifications).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(5 * 60 * 1000);
expect(NotificationUtils.getNotifications).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
it('should handle API errors gracefully', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
NotificationUtils.getNotifications.mockRejectedValue(new Error('Network error'));
renderComponent();
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
'Failed to fetch notifications:',
expect.any(Error)
);
});
consoleError.mockRestore();
});
});
describe('Notification Display', () => {
it('should display unread notifications by default', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByText('Version Update Available')).toBeInTheDocument();
expect(screen.getByText('Setting Recommendation')).toBeInTheDocument();
expect(screen.getByText('Critical Warning')).toBeInTheDocument();
expect(screen.queryByText('System Announcement')).not.toBeInTheDocument();
});
it('should display all notifications when show dismissed is toggled', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const eyeButtons = screen.getAllByTestId(/action-icon-/);
const toggleButton = eyeButtons.find(btn =>
btn.querySelector('[data-testid="eye-icon"]')
);
fireEvent.click(toggleButton);
expect(screen.getByText('System Announcement')).toBeInTheDocument();
});
it('should show empty state when no unread notifications', () => {
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: [],
unreadCount: 0,
getUnreadNotifications: vi.fn(() => []),
};
return selector ? selector(state) : state;
});
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByText('All caught up!')).toBeInTheDocument();
expect(screen.getByText('No new notifications')).toBeInTheDocument();
});
it('should show dismissed count in footer', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByText('1 dismissed notification')).toBeInTheDocument();
});
it('should show correct dismissed count with multiple dismissed notifications', () => {
const notifications = [
...mockNotifications,
{ ...mockNotifications[2], id: 5, is_dismissed: true },
];
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications,
unreadCount: 3,
getUnreadNotifications: vi.fn(() =>
notifications.filter((n) => !n.is_dismissed)
),
};
return selector ? selector(state) : state;
});
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByText('2 dismissed notifications')).toBeInTheDocument();
});
});
describe('Notification Actions', () => {
it('should dismiss notification when X button is clicked', async () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const xIcons = screen.getAllByTestId('x-icon');
fireEvent.click(xIcons[0].closest('button'));
await waitFor(() => {
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(1, 'dismissed');
});
});
it('should dismiss all notifications when CheckCheck button is clicked', async () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const checkCheckIcon = screen.getByTestId('checkcheck-icon');
fireEvent.click(checkCheckIcon.closest('button'));
await waitFor(() => {
expect(NotificationUtils.dismissAllNotifications).toHaveBeenCalled();
});
});
it('should open release URL for version update notification', () => {
const windowOpen = vi.spyOn(window, 'open').mockImplementation(() => {});
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const viewReleaseButton = screen.getByText('View Release');
fireEvent.click(viewReleaseButton);
expect(windowOpen).toHaveBeenCalledWith(
'https://github.com/releases/v2.0',
'_blank'
);
windowOpen.mockRestore();
});
it('should navigate for notifications with action_url', () => {
const notificationWithUrl = {
...mockNotifications[0],
action_data: {
action_url: '/settings',
action_text: 'Go to Settings',
},
};
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: [notificationWithUrl],
unreadCount: 1,
getUnreadNotifications: vi.fn(() => [notificationWithUrl]),
};
return selector ? selector(state) : state;
});
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const actionButton = screen.getByText('Go to Settings');
fireEvent.click(actionButton);
expect(mockNavigate).toHaveBeenCalledWith('/settings');
});
it('should call onSettingAction when applying setting recommendation', async () => {
const onSettingAction = vi.fn();
renderComponent({ onSettingAction });
fireEvent.click(screen.getByLabelText('Notifications'));
const applyButton = screen.getByText('Apply');
fireEvent.click(applyButton);
await waitFor(() => {
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(2, 'applied');
expect(onSettingAction).toHaveBeenCalledWith(mockNotifications[1]);
});
});
it('should dismiss setting recommendation when Ignore is clicked', async () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const ignoreButton = screen.getByText('Ignore');
fireEvent.click(ignoreButton);
await waitFor(() => {
expect(NotificationUtils.dismissNotification).toHaveBeenCalledWith(2, 'dismissed');
});
});
it('should close popover when navigating with action_url', () => {
const notificationWithUrl = {
...mockNotifications[0],
action_data: {
action_url: '/settings',
action_text: 'Go to Settings',
},
};
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: [notificationWithUrl],
unreadCount: 1,
getUnreadNotifications: vi.fn(() => [notificationWithUrl]),
};
return selector ? selector(state) : state;
});
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const actionButton = screen.getByText('Go to Settings');
fireEvent.click(actionButton);
const popover = screen.getByTestId('popover');
expect(popover).toHaveAttribute('data-opened', 'false');
});
});
describe('Notification Types and Icons', () => {
it('should render correct icon for version_update', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByTestId('download-icon')).toBeInTheDocument();
});
it('should render correct icon for setting_recommendation', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByTestId('settings-icon')).toBeInTheDocument();
});
it('should render correct icon for announcement', () => {
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: [mockNotifications[2]],
unreadCount: 0,
getUnreadNotifications: vi.fn(() => []),
};
return selector ? selector(state) : state;
});
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const eyeButtons = screen.getAllByTestId(/action-icon-/);
const toggleButton = eyeButtons.find(btn =>
btn.querySelector('[data-testid="eye-icon"]')
);
fireEvent.click(toggleButton);
expect(screen.getByTestId('megaphone-icon')).toBeInTheDocument();
});
it('should render correct icon for warning', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByTestId('alert-triangle-icon')).toBeInTheDocument();
});
it('should render info icon for unknown type', () => {
const unknownTypeNotification = {
...mockNotifications[0],
notification_type: 'unknown',
is_dismissed: false,
};
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: [unknownTypeNotification],
unreadCount: 1,
getUnreadNotifications: vi.fn(() => [unknownTypeNotification]),
};
return selector ? selector(state) : state;
});
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByTestId('info-icon')).toBeInTheDocument();
});
});
describe('Priority Badges', () => {
it('should show priority badge for high priority notifications', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByText('high')).toBeInTheDocument();
});
it('should show priority badge for critical priority notifications', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.getByText('critical')).toBeInTheDocument();
});
it('should not show priority badge for normal priority', () => {
const normalNotification = {
...mockNotifications[1],
priority: 'normal',
};
useNotificationsStore.mockImplementation((selector) => {
const state = {
notifications: [normalNotification],
unreadCount: 1,
getUnreadNotifications: vi.fn(() => [normalNotification]),
};
return selector ? selector(state) : state;
});
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
expect(screen.queryByText('normal')).not.toBeInTheDocument();
});
it('should show dismissed badge for dismissed notifications', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const eyeButtons = screen.getAllByTestId(/action-icon-/);
const toggleButton = eyeButtons.find(btn =>
btn.querySelector('[data-testid="eye-icon"]')
);
fireEvent.click(toggleButton);
expect(screen.getByText('Dismissed')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('should handle dismiss notification errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
NotificationUtils.dismissNotification.mockRejectedValue(new Error('API error'));
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const xIcons = screen.getAllByTestId('x-icon');
fireEvent.click(xIcons[0].closest('button'));
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
'Failed to dismiss notification:',
expect.any(Error)
);
});
consoleError.mockRestore();
});
it('should handle dismiss all notifications errors', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
NotificationUtils.dismissAllNotifications.mockRejectedValue(new Error('API error'));
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const checkCheckIcon = screen.getByTestId('checkcheck-icon');
fireEvent.click(checkCheckIcon.closest('button'));
await waitFor(() => {
expect(consoleError).toHaveBeenCalledWith(
'Failed to dismiss all notifications:',
expect.any(Error)
);
});
consoleError.mockRestore();
});
});
describe('Date Display', () => {
it('should display formatted date for notifications', () => {
renderComponent();
fireEvent.click(screen.getByLabelText('Notifications'));
const expectedDate = new Date('2024-01-01T10:00:00Z').toLocaleDateString();
expect(screen.getByText(expectedDate)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,169 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import RecordingSynopsis from '../RecordingSynopsis';
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Text: ({ children, size, c, lineClamp, onClick, title, style, ...props }) => {
return (
<div
data-testid="text"
data-size={size}
data-color={c}
data-line-clamp={lineClamp}
title={title}
onClick={onClick}
style={style}
{...props}
>
{children}
</div>
);
},
};
});
describe('RecordingSynopsis', () => {
let mockOnOpen;
beforeEach(() => {
vi.clearAllMocks();
mockOnOpen = vi.fn();
});
describe('Rendering', () => {
it('should render with short description', () => {
const shortDescription = 'This is a short description.';
render(
<RecordingSynopsis description={shortDescription} onOpen={mockOnOpen} />
);
const text = screen.getByText(shortDescription);
expect(text).toBeInTheDocument();
});
it('should return null when description is undefined', () => {
const { container } = render(
<RecordingSynopsis description={undefined} onOpen={mockOnOpen} />
);
expect(container.firstChild).toBeNull();
});
it('should return null when description is null', () => {
const { container } = render(
<RecordingSynopsis description={null} onOpen={mockOnOpen} />
);
expect(container.firstChild).toBeNull();
});
it('should return null when description is empty string', () => {
const { container } = render(
<RecordingSynopsis description="" onOpen={mockOnOpen} />
);
expect(container.firstChild).toBeNull();
});
it('should render without onOpen callback', () => {
const description = 'Test description';
render(<RecordingSynopsis description={description} />);
expect(screen.getByText(description)).toBeInTheDocument();
});
});
describe('Text Truncation', () => {
it('should not truncate description with exactly 140 characters', () => {
const exactLength = 'A'.repeat(140);
render(
<RecordingSynopsis description={exactLength} onOpen={mockOnOpen} />
);
expect(screen.getByText(exactLength)).toBeInTheDocument();
expect(screen.queryByText(/\.\.\./)).not.toBeInTheDocument();
});
it('should truncate description with 141 characters', () => {
const overLength = 'A'.repeat(141);
render(
<RecordingSynopsis description={overLength} onOpen={mockOnOpen} />
);
const expectedPreview = `${overLength.slice(0, 140).trim()}...`;
expect(screen.getByText(expectedPreview)).toBeInTheDocument();
});
it('should trim whitespace before adding ellipsis', () => {
const description = 'A'.repeat(135) + ' B'.repeat(10);
render(
<RecordingSynopsis description={description} onOpen={mockOnOpen} />
);
const preview = screen.getByTestId('text').textContent;
expect(preview).toMatch(/\.\.\./);
expect(preview).not.toMatch(/\s+\.\.\./);
});
});
describe('Click Interactions', () => {
it('should call onOpen when clicked', () => {
const description = 'Test description';
render(
<RecordingSynopsis description={description} onOpen={mockOnOpen} />
);
const text = screen.getByText(description);
fireEvent.click(text);
expect(mockOnOpen).toHaveBeenCalledTimes(1);
});
it('should not throw error when clicked without onOpen callback', () => {
const description = 'Test description';
render(<RecordingSynopsis description={description} />);
const text = screen.getByText(description);
expect(() => fireEvent.click(text)).not.toThrow();
});
it('should handle multiple clicks', () => {
const description = 'Test description';
render(
<RecordingSynopsis description={description} onOpen={mockOnOpen} />
);
const text = screen.getByText(description);
fireEvent.click(text);
fireEvent.click(text);
fireEvent.click(text);
expect(mockOnOpen).toHaveBeenCalledTimes(3);
});
it('should be clickable with truncated text', () => {
const longDescription = 'A'.repeat(200);
render(
<RecordingSynopsis description={longDescription} onOpen={mockOnOpen} />
);
const text = screen.getByTestId('text');
fireEvent.click(text);
expect(mockOnOpen).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -0,0 +1,864 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import SeriesModal from '../SeriesModal';
import useVODStore from '../../store/useVODStore';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
import { copyToClipboard } from '../../utils';
// Mock stores
vi.mock('../../store/useVODStore', () => ({
default: vi.fn(),
}));
vi.mock('../../store/useVideoStore', () => ({
default: vi.fn(),
}));
vi.mock('../../store/settings', () => ({
default: vi.fn(),
}));
// Mock utils
vi.mock('../../utils', () => ({
copyToClipboard: vi.fn(),
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
Play: () => <div data-testid="play-icon" />,
Copy: () => <div data-testid="copy-icon" />,
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
const actual = await vi.importActual('@mantine/core');
return {
...actual,
Modal: ({ opened, onClose, title, children, size, ...props }) => {
if (!opened) return null;
return (
<div data-testid="modal" data-title={title} data-size={size}>
<button onClick={onClose} data-testid="modal-close">Close</button>
<div>{children}</div>
</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}>
{children}
</button>
),
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>
),
Select: ({ value, onChange, data, label, placeholder, disabled, ...props }) => (
<div data-testid="select" data-label={label}>
<select
value={value || ''}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
{...props}
>
<option value="">{placeholder}</option>
{data?.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</div>
),
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>,
ActionIcon: ({ children, onClick, disabled, ...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);
}}>
{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>
),
TabsPanel: ({ children, value }) => (
<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>,
TableTr: ({ children, onClick, ...props }) => (
<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>,
Divider: (props) => <hr data-testid="divider" {...props} />,
};
});
describe('SeriesModal', () => {
let mockVODStore;
let mockVideoStore;
let mockSettingsStore;
const mockSeries = {
id: 1,
name: 'Test Series',
series_image: 'https://example.com/cover.jpg',
genre: 'Drama, Action',
cast: 'Actor 1, Actor 2',
director: 'Director Name',
m3u_account: { name: 'Test Account' },
rating: '8.5',
release_date: '2020-01-01',
youtube_trailer: 'dQw4w9WgXcQ',
};
const mockEpisode = {
id: 1,
uuid: 'episode-uuid-1',
series_id: 1,
season_number: 1,
episode_number: 1,
name: 'Pilot',
duration_secs: 3600,
rating: '8.0',
container_extension: 'mkv',
added: '2024-01-01T00:00:00Z',
};
const mockDetailedSeries = {
...mockSeries,
episodesList: [mockEpisode],
tmdb_id: '12345',
imdb_id: 'tt1234567',
};
const mockProviders = [
{
id: 1,
stream_id: 100,
account_id: 5,
m3u_account: { name: 'Provider 1' },
stream_name: 'Test Series 1080p',
quality_info: { quality: '1080p' },
},
{
id: 2,
stream_id: 101,
account_id: 5,
m3u_account: { name: 'Provider 2' },
stream_name: 'Test Series 720p',
quality_info: null,
}
];
beforeEach(() => {
vi.clearAllMocks();
mockVODStore = {
fetchSeriesInfo: vi.fn().mockResolvedValue(mockDetailedSeries),
fetchSeriesProviders: vi.fn().mockResolvedValue(mockProviders),
};
mockVideoStore = {
showVideo: vi.fn(),
};
mockSettingsStore = {
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);
copyToClipboard.mockResolvedValue(undefined);
});
describe('Rendering', () => {
it('should render nothing when series is null', () => {
const { container } = render(
<SeriesModal series={null} opened={true} onClose={vi.fn()} />
);
expect(container.firstChild).toBeNull();
});
it('should render nothing when modal is closed', () => {
render(
<SeriesModal series={mockSeries} opened={false} onClose={vi.fn()} />
);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('should render modal when opened with series', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
});
it('should display series name as title', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText('Test Series')).toBeInTheDocument();
});
});
it('should display cover image', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const image = screen.getByAltText('Test Series');
expect(image).toHaveAttribute('src', 'https://example.com/cover.jpg');
});
});
it('should show loader while fetching details', () => {
mockVODStore.fetchSeriesInfo = vi.fn(() => new Promise(() => {}));
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
//expect multiple loader instances since we have one for details and one for providers
const loaders = screen.getAllByTestId('loader');
expect(loaders.length).toBeGreaterThan(0);
});
});
describe('Data Fetching', () => {
it('should fetch series info when opened', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalledWith(1);
});
});
it('should fetch series providers when opened', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(mockVODStore.fetchSeriesProviders).toHaveBeenCalledWith(1);
});
});
it('should not fetch data when modal is closed', () => {
render(
<SeriesModal series={mockSeries} opened={false} onClose={vi.fn()} />
);
expect(mockVODStore.fetchSeriesInfo).not.toHaveBeenCalled();
expect(mockVODStore.fetchSeriesProviders).not.toHaveBeenCalled();
});
it('should reset state when modal closes', async () => {
const { rerender } = render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalled();
});
rerender(
<SeriesModal series={mockSeries} opened={false} onClose={vi.fn()} />
);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
});
describe('Series Information Display', () => {
it('should display genre', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Drama, Action/)).toBeInTheDocument();
});
});
it('should display rating', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/8\.5/)).toBeInTheDocument();
});
});
it('should display IMDB link when imdb_id exists', async () => {
render(
<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');
});
});
it('should display TMDB link when tmdb_id exists', async () => {
render(
<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');
});
});
});
describe('Provider Selection', () => {
it('should display provider select with fetched providers', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const select = screen.getByTestId('select');
expect(select).toBeInTheDocument();
});
});
it('should format provider label correctly with quality info', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument();
});
});
it('should handle provider selection', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
let select;
await waitFor(() => {
select = screen.getByTestId('select').querySelector('select');
fireEvent.change(select, { target: { value: '1' } });
});
await waitFor(() => {
expect(select.value).toBe('1');
});
});
it('should show loader while fetching providers', () => {
mockVODStore.fetchSeriesProviders = vi.fn(() => new Promise(() => {}));
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
// Should show loading text and loader
expect(screen.getByText('Stream Selection')).toBeInTheDocument();
const loaders = screen.getAllByTestId('loader');
expect(loaders.length).toBeGreaterThan(0);
});
});
describe('Episodes Display', () => {
it('should display episodes grouped by season', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Season 1/i)).toBeInTheDocument();
});
});
it('should display episode information', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Pilot/)).toBeInTheDocument();
});
});
it('should format episode duration correctly', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/1h 0m/)).toBeInTheDocument();
});
});
it('should handle episodes with no duration', async () => {
const episodeNoDuration = { ...mockEpisode, duration_secs: null };
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episodeNoDuration],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Pilot/)).toBeInTheDocument();
});
});
it('should sort episodes by episode number', async () => {
const episode2 = { ...mockEpisode, id: 2, episode_number: 2, name: 'Second Episode' };
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episode2, mockEpisode],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const episodes = screen.getAllByTestId('table-tr');
expect(episodes[1]).toHaveTextContent('Pilot');
expect(episodes[2]).toHaveTextContent('Second Episode');
});
});
});
describe('Episode Actions', () => {
it('should play episode when play button clicked', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const playButtons = screen.getAllByTestId('action-icon');
fireEvent.click(playButtons[0]);
});
expect(mockVideoStore.showVideo).toHaveBeenCalledWith(
expect.stringContaining('/proxy/vod/episode/episode-uuid-1'),
'vod',
mockEpisode
);
});
it('should include provider stream_id in URL when provider selected', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const select = screen.getByTestId('select').querySelector('select');
fireEvent.change(select, { target: { value: '1' } });
});
await waitFor(() => {
const playButtons = screen.getAllByTestId('action-icon');
fireEvent.click(playButtons[0]);
});
expect(mockVideoStore.showVideo).toHaveBeenCalledWith(
expect.stringContaining('stream_id=100'),
'vod',
mockEpisode
);
});
it('should use dev mode URL in development', async () => {
mockSettingsStore.environment.env_mode = 'dev';
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const playButtons = screen.getAllByTestId('action-icon');
fireEvent.click(playButtons[0]);
});
expect(mockVideoStore.showVideo).toHaveBeenCalledWith(
expect.stringContaining('localhost:5656'),
'vod',
mockEpisode
);
});
it('should copy episode link when copy button clicked', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const copyButtons = screen.getAllByTestId('action-icon');
fireEvent.click(copyButtons[1]);
});
expect(copyToClipboard).toHaveBeenCalledWith(
expect.stringContaining('/proxy/vod/episode/episode-uuid-1'),
expect.objectContaining({
successTitle: 'Link Copied!',
successMessage: 'Episode link copied to clipboard',
})
);
});
it('should expand episode details when row clicked', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const rows = screen.getAllByTestId('table-tr');
fireEvent.click(rows[1]);
});
await waitFor(() => {
expect(screen.getByText(/8\.0/)).toBeInTheDocument();
});
});
it('should collapse episode details when clicked again', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const rows = screen.getAllByTestId('table-tr');
fireEvent.click(rows[1]);
});
await waitFor(() => {
expect(screen.getByText(/8\.0/)).toBeInTheDocument();
});
await waitFor(() => {
const rows = screen.getAllByTestId('table-tr');
fireEvent.click(rows[1]);
});
await waitFor(() => {
expect(screen.queryByText(/8\.0/)).not.toBeInTheDocument();
});
});
});
it('should show trailer button when youtube_trailer exists', async () => {
render(<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText('Watch Trailer')).toBeInTheDocument();
});
});
describe('Season Tabs', () => {
it('should create tabs for each season', async () => {
const season2Episode = { ...mockEpisode, id: 2, season_number: 2, episode_num: 1 };
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [mockEpisode, season2Episode],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Season 1/i)).toBeInTheDocument();
expect(screen.getByText(/Season 2/i)).toBeInTheDocument();
});
});
it('should sort seasons in ascending order', async () => {
const season3Episode = { ...mockEpisode, id: 3, season_number: 3 };
const season2Episode = { ...mockEpisode, id: 2, season_number: 2 };
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [season3Episode, mockEpisode, season2Episode],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const tabs = screen.getAllByTestId('tabs-tab');
expect(tabs[0]).toHaveTextContent('Season 1');
expect(tabs[1]).toHaveTextContent('Season 2');
expect(tabs[2]).toHaveTextContent('Season 3');
});
});
it('should activate first season tab by default', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
const tabs = screen.getByTestId('tabs');
expect(tabs).toHaveAttribute('data-value', 'season-1');
});
});
});
describe('Quality Info Extraction', () => {
it('should extract quality from quality_info field', async () => {
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument();
});
});
it('should extract quality from custom_properties.detailed_info.video', async () => {
const customProviders = [
{
...mockProviders[0],
quality_info: null,
custom_properties: {
detailed_info: {
video: { width: 1920, height: 1080 },
},
},
},
mockProviders[1],
];
mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders);
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 1080p/)).toBeInTheDocument();
});
});
it('should extract quality from stream_name as fallback', async () => {
const customProviders = [
{
...mockProviders[0],
quality_info: null,
stream_name: 'Test Series 720p',
},
mockProviders[1],
];
mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders);
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 720p/)).toBeInTheDocument();
});
});
it('should detect 4K quality', async () => {
const customProviders = [
{
...mockProviders[0],
quality_info: null,
custom_properties: {
detailed_info: {
video: { width: 3840, height: 2160 },
},
},
},
mockProviders[1],
];
mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders);
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Provider 1 - 4K/)).toBeInTheDocument();
});
});
it('should show resolution when standard quality not detected', async () => {
const customProviders = [
{
...mockProviders[0],
quality_info: null,
custom_properties: {
detailed_info: {
video: { width: 720, height: 480 },
},
},
},
mockProviders[1],
];
mockVODStore.fetchSeriesProviders.mockResolvedValue(customProviders);
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/720x480/)).toBeInTheDocument();
});
});
});
describe('Edge Cases', () => {
it('should handle series with no episodes', async () => {
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.queryByTestId('table')).not.toBeInTheDocument();
});
});
it('should handle fetch errors gracefully', async () => {
mockVODStore.fetchSeriesInfo.mockRejectedValue(new Error('Fetch failed'));
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(mockVODStore.fetchSeriesInfo).toHaveBeenCalled();
});
});
it('should handle episodes with null season_number', async () => {
const episodeNoSeason = { ...mockEpisode, season_number: null };
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episodeNoSeason],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText(/Season 1/i)).toBeInTheDocument();
});
});
it('should handle empty provider list', async () => {
mockVODStore.fetchSeriesProviders.mockResolvedValue([]);
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
await waitFor(() => {
expect(screen.getByText('Test Account')).toBeInTheDocument()
});
});
});
describe('Modal Close', () => {
it('should call onClose when close button clicked', async () => {
const onClose = vi.fn();
render(
<SeriesModal series={mockSeries} opened={true} onClose={onClose} />
);
await waitFor(() => {
const closeButton = screen.getByTestId('modal-close');
fireEvent.click(closeButton);
});
expect(onClose).toHaveBeenCalled();
});
});
describe('Helper Functions', () => {
it('should format duration with hours and minutes', () => {
const duration = 7265; // 2h 1m 5s
// This tests the formatDuration function indirectly through episode display
const episode = {
...mockEpisode,
info: { ...mockEpisode.info, duration },
};
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episode],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
waitFor(() => {
expect(screen.getByText(/2h 1m/)).toBeInTheDocument();
});
});
it('should format duration with minutes and seconds when under an hour', () => {
const duration = 125; // 2m 5s
const episode = {
...mockEpisode,
info: { ...mockEpisode.info, duration },
};
mockVODStore.fetchSeriesInfo.mockResolvedValue({
...mockDetailedSeries,
episodesList: [episode],
});
render(
<SeriesModal series={mockSeries} opened={true} onClose={vi.fn()} />
);
waitFor(() => {
expect(screen.getByText(/2m 5s/)).toBeInTheDocument();
});
});
});
});

View file

@ -0,0 +1,628 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Sidebar from '../Sidebar';
import useChannelsStore from '../../store/channels';
import useSettingsStore from '../../store/settings';
import useAuthStore from '../../store/auth';
import { copyToClipboard } from '../../utils';
import { USER_LEVELS } from '../../constants';
// Mock stores
vi.mock('../../store/channels');
vi.mock('../../store/settings');
vi.mock('../../store/auth');
vi.mock('../../utils', () => ({
copyToClipboard: vi.fn(),
}));
vi.mock('../NotificationCenter', () => ({
default: () => <div data-testid="notification-center">Notification Center</div>,
}));
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
ListOrdered: ({ onClick }) => <div data-testid="list-ordered-icon" onClick={onClick} />,
Play: ({ onClick }) => <div data-testid="play-icon" onClick={onClick} />,
Database: ({ onClick }) => <div data-testid="database-icon" onClick={onClick} />,
LayoutGrid: ({ onClick }) => <div data-testid="layout-grid-icon" onClick={onClick} />,
Settings: ({ onClick }) => <div data-testid="settings-icon" onClick={onClick} />,
Copy: ({ onClick }) => <div data-testid="copy-icon" onClick={onClick} />,
ChartLine: ({ onClick }) => <div data-testid="chart-line-icon" onClick={onClick} />,
Video: ({ onClick }) => <div data-testid="video-icon" onClick={onClick} />,
PlugZap: ({ onClick }) => <div data-testid="plug-zap-icon" onClick={onClick} />,
LogOut: ({ onClick }) => <div data-testid="logout-icon" onClick={onClick} />,
User: ({ onClick }) => <div data-testid="user-icon" onClick={onClick} />,
FileImage: ({ onClick }) => <div data-testid="file-image-icon" onClick={onClick} />,
Webhook: () => <div data-testid="webhook-icon" />,
Logs: () => <div data-testid="logs-icon" />,
ChevronDown: () => <div data-testid="chevron-down-icon" />,
ChevronRight: () => <div data-testid="chevron-right-icon" />,
MonitorCog: () => <div data-testid="monitor-cog-icon" />,
Blocks: () => <div data-testid="blocks-icon" />,
}));
// Mock UserForm component
vi.mock('../forms/User', () => ({
default: ({ isOpen, onClose, user }) => (
isOpen ? (
<div data-testid="user-form">
User Form for {user?.username}
<button onClick={onClose}>Close</button>
</div>
) : null
),
}));
vi.mock('@mantine/core', async () => {
return {
Avatar: ({ children }) => <div>{children}</div>,
Group: ({ children, onClick, ...props }) => (
<div onClick={onClick} {...props}>{children}</div>
),
Stack: ({ children }) => <div>{children}</div>,
Box: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <div>{children}</div>,
UnstyledButton: ({ children, onClick, component, to, className }) => {
const Component = component || 'button';
return (
<Component onClick={onClick} to={to} className={className}>
{children}
</Component>
);
},
TextInput: ({ value, onChange, leftSection, rightSection, label }) => (
<div>
{label && <label>{label}</label>}
{leftSection}
<input value={value} onChange={onChange} />
{rightSection}
</div>
),
ActionIcon: ({ children, onClick, ...props }) => (
<button onClick={onClick} {...props}>
{children}
</button>
),
AppShellNavbar: ({ children, style, width, ...props }) => (
<nav
style={{
...style,
width: typeof width?.base === 'number' ? `${width.base}px` : width?.base
}}
{...props}
>
{children}
</nav>
),
ScrollArea: ({ children }) => <div>{children}</div>,
};
});
const mockChannels = [ 'channel-1', 'channel-2', 'channel-3' ];
const mockEnvironment = {
public_ip: '192.168.1.1',
country_code: 'US',
country_name: 'United States',
};
const mockVersion = {
version: '1.2.3',
timestamp: '20240115',
};
const mockAdminUser = {
id: 1,
username: 'admin',
first_name: 'Admin',
user_level: USER_LEVELS.ADMIN,
};
const mockRegularUser = {
id: 2,
username: 'user',
first_name: 'John',
user_level: USER_LEVELS.USER,
};
const renderSidebar = (props = {}) => {
const defaultProps = {
collapsed: false,
toggleDrawer: vi.fn(),
drawerWidth: 250,
miniDrawerWidth: 80,
};
return render(
<BrowserRouter>
<Sidebar {...defaultProps} {...props} />
</BrowserRouter>
);
};
describe('Sidebar', () => {
beforeEach(() => {
vi.clearAllMocks();
useChannelsStore.mockReturnValue(mockChannels);
useSettingsStore.mockImplementation((selector) => {
const state = {
environment: mockEnvironment,
version: mockVersion,
};
return selector(state);
});
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: true,
user: mockAdminUser,
logout: vi.fn(),
};
return selector(state);
});
});
describe('Brand Section', () => {
it('should render logo and brand name when expanded', () => {
const { container } = renderSidebar();
expect(screen.getByText('Dispatcharr')).toBeInTheDocument();
const logo = container.querySelectorAll('img[src="/src/images/logo.png"]');
expect(logo).toHaveLength(1);
});
it('should hide brand name when collapsed', () => {
renderSidebar({ collapsed: true });
expect(screen.queryByText('Dispatcharr')).not.toBeInTheDocument();
});
it('should toggle drawer when brand is clicked', () => {
const toggleDrawer = vi.fn();
renderSidebar({ toggleDrawer });
const brand = screen.getByText('Dispatcharr').closest('div');
fireEvent.click(brand);
expect(toggleDrawer).toHaveBeenCalledTimes(1);
});
});
describe('Navigation Links - Admin User', () => {
it('should render all admin navigation items', async () => {
renderSidebar();
expect(screen.getByText('Channels')).toBeInTheDocument();
expect(screen.getByText('VODs')).toBeInTheDocument();
expect(screen.getByText('M3U & EPG Manager')).toBeInTheDocument();
expect(screen.getByText('TV Guide')).toBeInTheDocument();
expect(screen.getByText('DVR')).toBeInTheDocument();
expect(screen.getByText('Stats')).toBeInTheDocument();
expect(screen.getByText('Plugins')).toBeInTheDocument();
// Expand System group to access Users
const systemButton = screen.getByText('System');
fireEvent.click(systemButton);
await waitFor(() => {
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Logo Manager')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});
});
it('should display channel count badge', () => {
renderSidebar();
expect(screen.getByText('(3)')).toBeInTheDocument();
});
it('should hide labels and badges when collapsed', () => {
renderSidebar({ collapsed: true });
expect(screen.queryByText('Channels')).not.toBeInTheDocument();
expect(screen.queryByText('(3)')).not.toBeInTheDocument();
});
});
describe('Navigation Links - Regular User', () => {
beforeEach(() => {
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: true,
user: mockRegularUser,
logout: vi.fn(),
};
return selector(state);
});
});
it('should render limited navigation items for regular user', () => {
renderSidebar();
expect(screen.getByText('Channels')).toBeInTheDocument();
expect(screen.getByText('TV Guide')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
expect(screen.queryByText('VODs')).not.toBeInTheDocument();
expect(screen.queryByText('M3U & EPG Manager')).not.toBeInTheDocument();
expect(screen.queryByText('DVR')).not.toBeInTheDocument();
expect(screen.queryByText('Stats')).not.toBeInTheDocument();
expect(screen.queryByText('Plugins')).not.toBeInTheDocument();
expect(screen.queryByText('Users')).not.toBeInTheDocument();
expect(screen.queryByText('Logo Manager')).not.toBeInTheDocument();
});
});
describe('Profile Section - Authenticated', () => {
it('should render public IP with country flag', () => {
renderSidebar();
const ipInput = screen.getByDisplayValue('192.168.1.1');
expect(ipInput).toBeInTheDocument();
const flag = screen.getByAltText('United States');
expect(flag).toHaveAttribute('src', 'https://flagcdn.com/16x12/us.png');
});
it('should copy public IP to clipboard when copy button is clicked', async () => {
copyToClipboard.mockResolvedValue();
renderSidebar();
const copyButton = screen.getByTestId('copy-icon').closest('button');
fireEvent.click(copyButton);
await waitFor(() => {
expect(copyToClipboard).toHaveBeenCalledWith('192.168.1.1', {
successTitle: 'Success',
successMessage: 'Public IP copied to clipboard',
});
});
});
it('should render user avatar and name', () => {
renderSidebar();
expect(screen.getByText('Admin')).toBeInTheDocument();
});
it('should fallback to username if first_name is not set', () => {
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: true,
user: { ...mockAdminUser, first_name: null },
logout: vi.fn(),
};
return selector(state);
});
renderSidebar();
expect(screen.getByText('admin')).toBeInTheDocument();
});
it('should open user form when username is clicked', () => {
renderSidebar();
const nameButton = screen.getByText('Admin');
fireEvent.click(nameButton);
expect(screen.getByTestId('user-form')).toBeInTheDocument();
expect(screen.getByText('User Form for admin')).toBeInTheDocument();
});
it('should close user form when close button is clicked', () => {
renderSidebar();
const nameButton = screen.getByText('Admin');
fireEvent.click(nameButton);
expect(screen.getByTestId('user-form')).toBeInTheDocument();
const closeButton = screen.getByText('Close');
fireEvent.click(closeButton);
expect(screen.queryByTestId('user-form')).not.toBeInTheDocument();
});
it('should logout when logout button is clicked', () => {
const mockLogout = vi.fn();
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: true,
user: mockAdminUser,
logout: mockLogout,
};
return selector(state);
});
renderSidebar();
const logoutIcon = screen.getByTestId('logout-icon');
fireEvent.click(logoutIcon);
expect(mockLogout).toHaveBeenCalledTimes(1);
});
it('should hide profile details when collapsed', () => {
renderSidebar({ collapsed: true });
expect(screen.queryByDisplayValue('192.168.1.1')).not.toBeInTheDocument();
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
});
});
describe('Profile Section - Not Authenticated', () => {
beforeEach(() => {
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: false,
user: null,
logout: vi.fn(),
};
return selector(state);
});
});
it('should not render profile section when not authenticated', () => {
renderSidebar();
expect(screen.queryByDisplayValue('192.168.1.1')).not.toBeInTheDocument();
expect(screen.queryByText('Admin')).not.toBeInTheDocument();
});
});
describe('Version Display', () => {
it('should render version when expanded', () => {
renderSidebar();
expect(screen.getByText('v1.2.3-20240115')).toBeInTheDocument();
});
it('should render version without timestamp if not available', () => {
useSettingsStore.mockImplementation((selector) => {
const state = {
environment: mockEnvironment,
version: { version: '1.2.3' },
};
return selector(state);
});
renderSidebar();
expect(screen.getByText('v1.2.3')).toBeInTheDocument();
});
it('should render default version if not loaded', () => {
useSettingsStore.mockImplementation((selector) => {
const state = {
environment: mockEnvironment,
version: null,
};
return selector(state);
});
renderSidebar();
expect(screen.getByText('v0.0.0')).toBeInTheDocument();
});
it('should hide version when collapsed', () => {
renderSidebar({ collapsed: true });
expect(screen.queryByText(/v1.2.3-20240115/)).not.toBeInTheDocument();
});
});
describe('Collapsed State', () => {
it('should apply collapsed class to navigation links', () => {
renderSidebar({ collapsed: true });
const links = screen.getAllByRole('link');
links.forEach(link => {
expect(link).toHaveClass('navlink-collapsed');
});
});
it('should adjust width based on collapsed state', () => {
const { rerender } = renderSidebar({ collapsed: false, drawerWidth: 250 });
const navbar = screen.getByRole('navigation');
expect(navbar).toHaveStyle({ width: '250px' });
rerender(
<BrowserRouter>
<Sidebar collapsed={true} drawerWidth={250} miniDrawerWidth={80} toggleDrawer={vi.fn()} />
</BrowserRouter>
);
expect(navbar).toHaveStyle({ width: '80px' });
});
});
describe('Environment Edge Cases', () => {
it('should handle missing country code gracefully', () => {
useSettingsStore.mockImplementation((selector) => {
const state = {
environment: { public_ip: '192.168.1.1' },
version: mockVersion,
};
return selector(state);
});
renderSidebar();
expect(screen.getByDisplayValue('192.168.1.1')).toBeInTheDocument();
expect(screen.queryByRole('img', { name: /flag/i })).not.toBeInTheDocument();
});
it('should use country code as alt text if country name is missing', () => {
useSettingsStore.mockImplementation((selector) => {
const state = {
environment: {
public_ip: '192.168.1.1',
country_code: 'US',
},
version: mockVersion,
};
return selector(state);
});
renderSidebar();
const flag = screen.getByAltText('US');
expect(flag).toBeInTheDocument();
});
});
describe('NavGroup Component', () => {
it('should render Integrations group with children collapsed by default', () => {
renderSidebar();
expect(screen.getByText('Integrations')).toBeInTheDocument();
expect(screen.queryByText('Connections')).not.toBeInTheDocument();
expect(screen.queryByText('Logs')).not.toBeInTheDocument();
});
it('should expand Integrations group when clicked', async () => {
renderSidebar();
const integrationsGroup = screen.getByText('Integrations').closest('button');
fireEvent.click(integrationsGroup);
await waitFor(() => {
expect(screen.getByText('Connections')).toBeInTheDocument();
expect(screen.getByText('Logs')).toBeInTheDocument();
});
});
it('should collapse Integrations group when clicked again', async () => {
renderSidebar();
const integrationsGroup = screen.getByText('Integrations').closest('button');
// Expand
fireEvent.click(integrationsGroup);
await waitFor(() => {
expect(screen.getByText('Connections')).toBeInTheDocument();
});
// Collapse
fireEvent.click(integrationsGroup);
await waitFor(() => {
expect(screen.queryByText('Connections')).not.toBeInTheDocument();
expect(screen.queryByText('Logs')).not.toBeInTheDocument();
});
});
it('should render System group with children collapsed by default', () => {
renderSidebar();
expect(screen.getByText('System')).toBeInTheDocument();
expect(screen.queryByText('Users')).not.toBeInTheDocument();
expect(screen.queryByText('Logo Manager')).not.toBeInTheDocument();
});
it('should expand System group when clicked', async () => {
renderSidebar();
const systemGroup = screen.getByText('System').closest('button');
fireEvent.click(systemGroup);
await waitFor(() => {
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Logo Manager')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
});
});
it('should hide group label when collapsed sidebar', () => {
renderSidebar({ collapsed: true });
expect(screen.queryByText('Integrations')).not.toBeInTheDocument();
expect(screen.queryByText('System')).not.toBeInTheDocument();
});
it('should not show multiple groups collapsed when both expanded', async () => {
renderSidebar();
const integrationsGroup = screen.getByText('Integrations').closest('button');
const systemGroup = screen.getByText('System').closest('button');
// Expand Integrations
fireEvent.click(integrationsGroup);
await waitFor(() => {
expect(screen.getByText('Connections')).toBeInTheDocument();
});
// Expand System (Integrations should remain expanded)
fireEvent.click(systemGroup);
await waitFor(() => {
expect(screen.getByText('Users')).toBeInTheDocument();
expect(screen.getByText('Connections')).toBeInTheDocument();
});
});
});
describe('NotificationCenter Integration', () => {
it('should render NotificationCenter when authenticated and expanded', () => {
renderSidebar();
expect(screen.getByTestId('notification-center')).toBeInTheDocument();
});
it('should render NotificationCenter when authenticated and collapsed', () => {
renderSidebar({ collapsed: true });
expect(screen.getByTestId('notification-center')).toBeInTheDocument();
});
it('should not render NotificationCenter when not authenticated', () => {
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: false,
user: null,
logout: vi.fn(),
};
return selector(state);
});
renderSidebar();
expect(screen.queryByTestId('notification-center')).not.toBeInTheDocument();
});
it('should not render NotificationCenter when not authenticated and collapsed', () => {
useAuthStore.mockImplementation((selector) => {
const state = {
isAuthenticated: false,
user: null,
logout: vi.fn(),
};
return selector(state);
});
renderSidebar({ collapsed: true });
expect(screen.queryByTestId('notification-center')).not.toBeInTheDocument();
});
});
describe('Channel Count Badge', () => {
it('should display 0 when no channels exist', () => {
useChannelsStore.mockReturnValue({});
renderSidebar();
expect(screen.getByText('(0)')).toBeInTheDocument();
});
it('should handle null channelIds gracefully', () => {
useChannelsStore.mockReturnValue(null);
renderSidebar();
expect(screen.getByText('(0)')).toBeInTheDocument();
});
it('should handle array of channel IDs', () => {
useChannelsStore.mockReturnValue(['channel-1', 'channel-2', 'channel-3']);
renderSidebar();
expect(screen.getByText('(3)')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,251 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import SystemEvents from '../SystemEvents';
import API from '../../api';
import useLocalStorage from "../../hooks/useLocalStorage";
// Mock the API module
vi.mock('../../api', () => ({
default: {
getSystemEvents: vi.fn(),
},
}));
// Mock the useLocalStorage hook
vi.mock('../../hooks/useLocalStorage', () => ({
default: vi.fn((key, defaultValue) => {
const mockSetters = {
'events-refresh-interval': vi.fn(),
'events-limit': vi.fn(),
'date-format': vi.fn(),
};
return [defaultValue, mockSetters[key] || vi.fn()];
}),
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
ActionIcon: ({ children, onClick }) => (
<button onClick={onClick}>{children}</button>
),
Box: ({ children }) => <div>{children}</div>,
Button: ({ children, onClick }) => (
<button onClick={onClick}>{children}</button>
),
Card: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
NumberInput: ({ value, onChange, label }) => (
<input
type="number"
value={value}
aria-label={label}
onChange={(e) => onChange(Number(e.target.value))}
/>
),
Pagination: ({ page, onChange, total }) => (
<div>
{Array.from({ length: Math.ceil(total / 100) }, (_, i) => (
<button key={i} onClick={() => onChange(i + 1)}>
{i + 1}
</button>
))}
</div>
),
Select: ({ value, onChange, data }) => (
<select value={value} onChange={(e) => onChange(e.target.value)}>
{data.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
),
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children }) => <div>{children}</div>,
Title: ({ children }) => <h1>{children}</h1>,
};
});
// Mock the dateTimeUtils
vi.mock('../../utils/dateTimeUtils.js', () => ({
format: vi.fn((timestamp, format) => '01/15 10:30:45'),
}));
const mockEventsResponse = {
events: [
{
id: 1,
event_type: 'channel_start',
event_type_display: 'Channel Started',
channel_name: 'Test Channel',
timestamp: '2024-01-15T10:30:45Z',
details: { bitrate: '5000kbps' },
},
{
id: 2,
event_type: 'login_success',
event_type_display: 'Login Successful',
timestamp: '2024-01-15T10:25:30Z',
details: {},
},
],
total: 2,
};
describe('SystemEvents', () => {
beforeEach(() => {
vi.clearAllMocks();
API.getSystemEvents.mockResolvedValue(mockEventsResponse);
});
afterEach(() => {
vi.clearAllTimers();
});
it('should render component with title', async () => {
render(<SystemEvents />);
await waitFor(() => {
expect(screen.getByText('System Events')).toBeInTheDocument();
});
});
it('should fetch and display events on mount', async () => {
render(<SystemEvents />);
await waitFor(() => {
expect(API.getSystemEvents).toHaveBeenCalledWith(100, 0);
});
});
it('should expand and show events when chevron is clicked', async () => {
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText('Channel Started')).toBeInTheDocument();
expect(screen.getByText('Login Successful')).toBeInTheDocument();
});
});
it('should display "No events recorded yet" when events array is empty', async () => {
API.getSystemEvents.mockResolvedValue({ events: [], total: 0 });
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText('No events recorded yet')).toBeInTheDocument();
});
});
it('should call fetchEvents when refresh button is clicked', async () => {
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText('Refresh')).toBeInTheDocument();
});
const refreshButton = screen.getByText('Refresh');
fireEvent.click(refreshButton);
await waitFor(() => {
expect(API.getSystemEvents).toHaveBeenCalledTimes(3)
});
});
it('should update events limit when changed', async () => {
const mockSetEventsLimit = vi.fn();
// Update the mock to return the setter for this test
useLocalStorage.mockImplementation((key, defaultValue) => {
if (key === 'events-limit') {
return [100, mockSetEventsLimit];
}
return [defaultValue, vi.fn()];
});
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
const input = screen.getByLabelText('Events Per Page');
fireEvent.change(input, { target: { value: '50' } });
});
expect(mockSetEventsLimit).toHaveBeenCalled();
});
it('should show pagination when total events exceed limit', async () => {
API.getSystemEvents.mockResolvedValue({
events: mockEventsResponse.events,
total: 150,
});
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText(/Showing 1-100 of 150/)).toBeInTheDocument();
});
});
it('should handle API errors gracefully', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
API.getSystemEvents.mockRejectedValue(new Error('API Error'));
render(<SystemEvents />);
await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalledWith(
'Error fetching system events:',
expect.any(Error)
);
});
consoleErrorSpy.mockRestore();
});
it('should display event details correctly', async () => {
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText('Test Channel')).toBeInTheDocument();
expect(screen.getByText('bitrate: 5000kbps')).toBeInTheDocument();
});
});
it('should change page when pagination is clicked', async () => {
API.getSystemEvents.mockResolvedValue({
events: mockEventsResponse.events,
total: 250,
});
render(<SystemEvents />);
const expandButton = screen.getByRole('button', { name: '' });
fireEvent.click(expandButton);
await waitFor(() => {
expect(screen.getByText(/Showing 1-100 of 250/)).toBeInTheDocument();
});
// Note: Pagination interaction would require more specific selectors
// based on Mantine's Pagination component implementation
});
});

View file

@ -0,0 +1,439 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import VODModal from '../VODModal';
import useVODStore from '../../store/useVODStore';
import useVideoStore from '../../store/useVideoStore';
import useSettingsStore from '../../store/settings';
// Mock stores
vi.mock('../../store/useVODStore');
vi.mock('../../store/useVideoStore');
vi.mock('../../store/settings');
// Mock utils
vi.mock('../../utils', () => ({
copyToClipboard: vi.fn(() => Promise.resolve()),
}));
vi.mock('../../utils/components/SeriesModalUtils.js', () => ({
formatStreamLabel: vi.fn((provider) => `${provider.m3u_account.name} - Stream ${provider.stream_id}`),
imdbUrl: vi.fn((id) => `https://www.imdb.com/title/${id}`),
tmdbUrl: vi.fn((id, type) => `https://www.themoviedb.org/${type}/${id}`),
formatDuration: vi.fn((secs) => `${Math.floor(secs / 60)} min`),
getYouTubeEmbedUrl: vi.fn((url) => `https://www.youtube.com/embed/${url}`),
}));
// Mock Mantine components
vi.mock('@mantine/core', async () => {
return {
Modal: ({ children, opened, onClose, title }) =>
opened ? (
<div data-testid="modal">
<div data-testid="modal-title">{title}</div>
<button data-testid="modal-close" onClick={onClose}>Close</button>
{children}
</div>
) : null,
Box: ({ children, ...props }) => <div {...props}>{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
Group: ({ children }) => <div>{children}</div>,
Flex: ({ children }) => <div>{children}</div>,
Image: ({ src, alt }) => <img src={src} alt={alt} />,
Text: ({ children, ...props }) => <span {...props}>{children}</span>,
Title: ({ children }) => <h3>{children}</h3>,
Button: ({ children, onClick, disabled, leftSection }) => (
<button onClick={onClick} disabled={disabled}>
{leftSection}
{children}
</button>
),
Badge: ({ children, component, href, ...props }) =>
component === 'a' ? (
<a href={href} {...props}>
{children}
</a>
) : (
<span {...props}>{children}</span>
),
Select: ({ data, value, onChange, placeholder, disabled }) => (
<select data-testid="provider-select" value={value}
onChange={(e) => onChange(e.target.value)} disabled={disabled}>
<option value="">{placeholder}</option>
{data.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
),
Loader: () => <div data-testid="loader">Loading...</div>,
};
});
// Mock lucide-react icons
vi.mock('lucide-react', () => ({
Play: () => <span>Play Icon</span>,
Copy: () => <span>Copy Icon</span>,
}));
describe('VODModal', () => {
const mockShowVideo = vi.fn();
const mockFetchMovieDetailsFromProvider = vi.fn();
const mockFetchMovieProviders = vi.fn();
const mockOnClose = vi.fn();
const mockVOD = {
id: 1,
uuid: 'test-uuid',
name: 'Test Movie',
o_name: 'Original Test Movie',
year: 2023,
duration_secs: 7200,
rating: '8.5',
age: 'PG-13',
imdb_id: 'tt1234567',
tmdb_id: '12345',
release_date: '2023-01-15',
genre: 'Action, Sci-Fi',
director: 'Test Director',
actors: 'Actor 1, Actor 2',
country: 'USA',
description: 'A test movie description',
youtube_trailer: 'test-trailer-id',
movie_image: 'https://example.com/poster.jpg',
backdrop_path: ['https://example.com/backdrop.jpg'],
bitrate: 5000,
m3u_account: { name: 'Test Account', id: 1 },
};
const mockProvider = {
id: 1,
stream_id: 'stream-123',
m3u_account: { name: 'Test Provider', id: 1 },
bitrate: 6000,
};
beforeEach(() => {
vi.clearAllMocks();
useVideoStore.mockImplementation((selector) => {
const state = { showVideo: mockShowVideo };
return selector ? selector(state) : state;
});
useVODStore.mockImplementation((selector) => {
const state = {
fetchMovieDetailsFromProvider: mockFetchMovieDetailsFromProvider,
fetchMovieProviders: mockFetchMovieProviders,
};
return selector ? selector(state) : state;
});
useSettingsStore.mockImplementation((selector) => {
const state = { environment: { env_mode: 'prod' } };
return selector ? selector(state) : state;
});
mockFetchMovieDetailsFromProvider.mockResolvedValue(mockVOD);
mockFetchMovieProviders.mockResolvedValue([mockProvider]);
});
it('should not render when vod is null', () => {
render(<VODModal vod={null} opened={true} onClose={mockOnClose} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('should render modal when opened with vod', () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie');
});
it('should not render when closed', () => {
render(<VODModal vod={mockVOD} opened={false} onClose={mockOnClose} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('should display movie details correctly', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Original: Original Test Movie')).toBeInTheDocument();
});
expect(screen.getByText('2023')).toBeInTheDocument();
expect(screen.getByText('8.5')).toBeInTheDocument();
expect(screen.getByText('PG-13')).toBeInTheDocument();
expect(screen.getByText('Action, Sci-Fi')).toBeInTheDocument();
expect(screen.getByText(/Test Director/)).toBeInTheDocument();
expect(screen.getByText(/Actor 1, Actor 2/)).toBeInTheDocument();
expect(screen.getByText(/A test movie description/)).toBeInTheDocument();
});
it('should fetch movie details on mount', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockFetchMovieDetailsFromProvider).toHaveBeenCalledWith(mockVOD.id);
});
});
it('should fetch movie providers on mount', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockFetchMovieProviders).toHaveBeenCalledWith(mockVOD.id);
});
});
it('should show loading state while fetching details', () => {
mockFetchMovieDetailsFromProvider.mockImplementation(
() => new Promise(() => {})
);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
expect(screen.getByText('Loading additional details...')).toBeInTheDocument();
});
it('should handle play button click', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockFetchMovieProviders).toHaveBeenCalled();
});
const playButton = screen.getByText('Play Movie');
fireEvent.click(playButton);
expect(mockShowVideo).toHaveBeenCalled();
});
it('should disable play button when multiple providers and none selected', async () => {
mockFetchMovieProviders.mockResolvedValue([mockProvider, { ...mockProvider, id: 2 }]);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Play Movie')).toBeInTheDocument();
});
// Note: Testing for disabled state would require checking the button's disabled attribute
});
it('should handle provider selection', async () => {
const providers = [
mockProvider,
{ ...mockProvider, id: 2, stream_id: 'stream-456' },
];
mockFetchMovieProviders.mockResolvedValue(providers);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const select = screen.getByTestId('provider-select');
expect(select).toBeInTheDocument();
});
const select = screen.getByTestId('provider-select');
fireEvent.change(select, { target: { value: '2' } });
await waitFor(() => {
const select = screen.getByTestId('provider-select');
expect(select).toHaveValue('2');
});
});
it('should display single provider as badge', async () => {
mockFetchMovieProviders.mockResolvedValue([mockProvider]);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText('Test Provider')).toBeInTheDocument();
});
});
it('should handle fetch details error gracefully', async () => {
mockFetchMovieDetailsFromProvider.mockRejectedValue(new Error('Fetch failed'));
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockFetchMovieDetailsFromProvider).toHaveBeenCalled();
});
// Should still display basic VOD info
expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie');
});
it('should handle fetch providers error gracefully', async () => {
mockFetchMovieProviders.mockRejectedValue(new Error('Fetch failed'));
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(mockFetchMovieProviders).toHaveBeenCalled();
});
// Should still render without providers
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('should display technical details when available', async () => {
const vodWithTech = {
...mockVOD,
bitrate: 5000,
video: {
codec_name: 'h264',
width: 1920,
height: 1080,
},
audio: {
codec_name: 'aac',
channels: 2,
},
};
mockFetchMovieDetailsFromProvider.mockResolvedValue(vodWithTech);
render(<VODModal vod={vodWithTech} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByText(/Technical Details:/)).toBeInTheDocument();
});
});
it('should render IMDb and TMDb badges with correct links', () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
const imdbLink = screen.getByText('IMDb');
const tmdbLink = screen.getByText('TMDb');
expect(imdbLink).toHaveAttribute('href', 'https://www.imdb.com/title/tt1234567');
expect(tmdbLink).toHaveAttribute('href', 'https://www.themoviedb.org/movie/12345');
});
describe('Copy Link Functionality', () => {
it('should copy movie link when copy button clicked', async () => {
const { copyToClipboard } = await import('../../utils');
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const copyButton = screen.getByText('Copy Link');
fireEvent.click(copyButton);
});
expect(copyToClipboard).toHaveBeenCalledWith(
expect.stringContaining('/proxy/vod/movie/test-uuid'),
expect.objectContaining({
successTitle: expect.any(String),
successMessage: expect.any(String),
})
);
});
it('should include provider stream_id in copied URL when provider selected', async () => {
const { copyToClipboard } = await import('../../utils');
mockFetchMovieProviders.mockResolvedValue([
{ ...mockProvider, id: 1 },
{ ...mockProvider, id: 2, stream_id: 'stream-456' },
]);
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const select = screen.getByTestId('provider-select');
fireEvent.change(select, { target: { value: '2' } });
});
await waitFor(() => {
const copyButton = screen.getByText('Copy Link')
fireEvent.click(copyButton);
});
expect(copyToClipboard).toHaveBeenCalledWith(
expect.stringContaining('stream_id=stream-456'),
expect.any(Object)
);
});
});
describe('URL Generation', () => {
it('should use dev mode URL in development', async () => {
useSettingsStore.mockImplementation((selector) => {
const state = { environment: { env_mode: 'dev' } };
return selector ? selector(state) : state;
});
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const playButton = screen.getByText('Play Movie');
fireEvent.click(playButton);
});
expect(mockShowVideo).toHaveBeenCalledWith(
expect.stringContaining('localhost:5656'),
'vod',
expect.any(Object)
);
});
it('should use production URL in production', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const playButton = screen.getByText('Play Movie');
fireEvent.click(playButton);
});
expect(mockShowVideo).toHaveBeenCalledWith(
expect.not.stringContaining('localhost:5656'),
'vod',
expect.any(Object)
);
});
});
describe('Modal Interaction', () => {
it('should call onClose when close button clicked', async () => {
render(<VODModal vod={mockVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
const closeButton = screen.getByTestId('modal-close');
fireEvent.click(closeButton);
});
expect(mockOnClose).toHaveBeenCalled();
});
});
describe('Edge Cases', () => {
it('should handle VOD with no image', async () => {
const vodNoImage = { ...mockVOD, movie_image: null };
render(<VODModal vod={vodNoImage} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
});
it('should handle VOD with missing optional fields', async () => {
const minimalVOD = {
id: 1,
uuid: 'test-uuid',
name: 'Test Movie',
m3u_account: { name: 'Test Account', id: 1 },
};
render(<VODModal vod={minimalVOD} opened={true} onClose={mockOnClose} />);
await waitFor(() => {
expect(screen.getByTestId('modal-title')).toHaveTextContent('Test Movie');
});
});
});
});

View file

@ -0,0 +1,29 @@
import { Box, Modal } from '@mantine/core';
import React from 'react';
export const YouTubeTrailerModal = ({ opened, onClose, trailerUrl }) => {
return (
<Modal opened={opened} onClose={onClose} title="Trailer" size="xl" centered>
<Box pos="relative" pb={'56.25%'} h={0}>
{trailerUrl && (
<iframe
src={trailerUrl}
title="YouTube Trailer"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
borderRadius: 8,
}}
/>
)}
</Box>
</Modal>
);
};

View file

@ -0,0 +1,109 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { YouTubeTrailerModal } from '../YouTubeTrailerModal';
// Mock Mantine components
vi.mock('@mantine/core', () => ({
Modal: ({ opened, onClose, title, children, size, centered }) => {
if (!opened) return null;
return (
<div
data-testid="modal"
data-title={title}
data-size={size}
data-centered={centered}
>
<button onClick={onClose} data-testid="modal-close">Close</button>
<div>{children}</div>
</div>
);
},
Box: ({ children, ...props }) => (
<div data-testid="box" {...props}>{children}</div>
),
}));
describe('YouTubeTrailerModal', () => {
const mockTrailerUrl = 'https://www.youtube.com/embed/dQw4w9WgXcQ';
const mockOnClose = vi.fn();
it('should not render when opened is false', () => {
render(
<YouTubeTrailerModal
opened={false}
onClose={mockOnClose}
trailerUrl={mockTrailerUrl}
/>
);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('should render modal when opened is true', () => {
render(
<YouTubeTrailerModal
opened={true}
onClose={mockOnClose}
trailerUrl={mockTrailerUrl}
/>
);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('should display correct modal title', () => {
render(
<YouTubeTrailerModal
opened={true}
onClose={mockOnClose}
trailerUrl={mockTrailerUrl}
/>
);
const modal = screen.getByTestId('modal');
expect(modal).toHaveAttribute('data-title', 'Trailer');
});
it('should render iframe with correct trailerUrl', () => {
render(
<YouTubeTrailerModal
opened={true}
onClose={mockOnClose}
trailerUrl={mockTrailerUrl}
/>
);
const iframe = screen.getByTitle('YouTube Trailer');
expect(iframe).toBeInTheDocument();
expect(iframe).toHaveAttribute('src', mockTrailerUrl);
});
it('should not render iframe when trailerUrl is null', () => {
render(
<YouTubeTrailerModal
opened={true}
onClose={mockOnClose}
trailerUrl={null}
/>
);
expect(screen.queryByTitle('YouTube Trailer')).not.toBeInTheDocument();
});
it('should call onClose when close button clicked', () => {
const mockClose = vi.fn();
render(
<YouTubeTrailerModal
opened={true}
onClose={mockClose}
trailerUrl={mockTrailerUrl}
/>
);
const closeButton = screen.getByTestId('modal-close');
closeButton.click();
expect(mockClose).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,124 @@
export const getLivePlayerErrorMessage = (errorType, errorDetail) => {
if (errorType !== 'MediaError') {
return errorDetail
? `Error: ${errorType} - ${errorDetail}`
: `Error: ${errorType}`;
}
const errorString = errorDetail?.toLowerCase() || '';
if (
errorString.includes('audio') ||
errorString.includes('ac3') ||
errorString.includes('ac-3')
) {
return 'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.';
}
if (
errorString.includes('video') ||
errorString.includes('h264') ||
errorString.includes('h.264')
) {
return 'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.';
}
if (errorString.includes('mse')) {
return "Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility.";
}
return 'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.';
};
export const getVODPlayerErrorMessage = (error) => {
if (!error) return 'Video playback error';
switch (error.code) {
case error.MEDIA_ERR_ABORTED:
return 'Video playback was aborted';
case error.MEDIA_ERR_NETWORK:
return 'Network error while loading video';
case error.MEDIA_ERR_DECODE:
return 'Video codec not supported by your browser';
case error.MEDIA_ERR_SRC_NOT_SUPPORTED:
return 'Video format not supported by your browser';
default:
return error.message || 'Unknown video error';
}
};
export const getClientCoordinates = (event) => ({
clientX: event.touches?.[0]?.clientX ?? event.clientX,
clientY: event.touches?.[0]?.clientY ?? event.clientY,
});
export const calculateNewDimensions = (
deltaX,
deltaY,
startWidth,
startHeight,
handle,
ratio
) => {
const widthDelta = deltaX * handle.xDir;
const heightDelta = deltaY * handle.yDir;
let width = startWidth + widthDelta;
let height = width / ratio;
// Use vertical-driven resize if user drags mostly vertically
if (Math.abs(deltaY) > Math.abs(deltaX)) {
height = startHeight + heightDelta;
width = height * ratio;
}
return { width, height };
};
export const applyConstraints = (
width,
height,
ratio,
startPos,
handle,
minWidth,
minHeight,
visibleMargin
) => {
// Apply minimum constraints
if (width < minWidth) {
width = minWidth;
height = width / ratio;
}
if (height < minHeight) {
height = minHeight;
width = height * ratio;
}
// Apply viewport constraints
const posX = startPos?.x ?? 0;
const posY = startPos?.y ?? 0;
const maxWidth = !handle.isLeft
? Math.max(minWidth, window.innerWidth - posX - visibleMargin)
: null;
const maxHeight = !handle.isTop
? Math.max(minHeight, window.innerHeight - posY - visibleMargin)
: null;
if (maxWidth && width > maxWidth) {
width = maxWidth;
height = width / ratio;
}
if (maxHeight && height > maxHeight) {
height = maxHeight;
width = height * ratio;
}
// Final adjustment to maintain aspect ratio
if (maxWidth && width > maxWidth) {
width = maxWidth;
height = width / ratio;
}
return { width, height };
};

View file

@ -0,0 +1,11 @@
import API from '../../api.js';
export const getNotifications = (showDismissed) => {
return API.getNotifications(showDismissed);
};
export const dismissNotification = (notificationId, actionTaken) => {
return API.dismissNotification(notificationId, actionTaken);
};
export const dismissAllNotifications = () => {
return API.dismissAllNotifications();
};

View file

@ -0,0 +1,166 @@
export const imdbUrl = (imdb_id) =>
imdb_id ? `https://www.imdb.com/title/${imdb_id}` : '';
export const tmdbUrl = (tmdb_id, type = 'movie') =>
tmdb_id ? `https://www.themoviedb.org/${type}/${tmdb_id}` : '';
export const formatDuration = (seconds) => {
if (!seconds) return '';
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`;
};
export const formatStreamLabel = (relation) => {
const provider = relation.m3u_account.name;
const streamId = relation.stream_id;
const quality = extractQuality(relation);
return `${provider}${quality ?? ''}${streamId ? ` (Stream ${streamId})` : ''}`;
};
const extractQuality = (relation) => {
// 1. Primary: Backend quality_info field
const fromQualityInfo = getQualityFromBackend(relation.quality_info);
if (fromQualityInfo) return fromQualityInfo;
// 2. Secondary: Custom properties detailed_info
if (relation.custom_properties?.detailed_info) {
const fromDetailedInfo = getQualityFromDetailedInfo(relation.custom_properties.detailed_info);
if (fromDetailedInfo) return fromDetailedInfo;
}
// 3. Fallback: Stream name
if (relation.stream_name) {
return getQualityFromStreamName(relation.stream_name);
}
return '';
};
const getQualityFromBackend = (qualityInfo) => {
if (!qualityInfo) return '';
if (qualityInfo.quality) {
return ` - ${qualityInfo.quality}`;
} else if (qualityInfo.resolution) {
return ` - ${qualityInfo.resolution}`;
} else if (qualityInfo.bitrate) {
return ` - ${qualityInfo.bitrate}`;
}
return '';
};
const getQualityFromDetailedInfo = (detailedInfo) => {
// Check video dimensions first
if (detailedInfo.video?.width && detailedInfo.video?.height) {
return getQualityInfoFromDimensions(detailedInfo.video.width, detailedInfo.video.height);
}
// Check name field
if (detailedInfo.name) {
return parseQualityFromText(detailedInfo.name);
}
return '';
};
const getQualityFromStreamName = (streamName) => {
return parseQualityFromText(streamName);
};
const parseQualityFromText = (text) => {
if (text.includes('4K') || text.includes('2160p')) return ' - 4K';
if (text.includes('1080p') || text.includes('FHD')) return ' - 1080p';
if (text.includes('720p') || text.includes('HD')) return ' - 720p';
if (text.includes('480p')) return ' - 480p';
return '';
};
const getQualityInfoFromDimensions = (width, height) => {
// Prioritize width for quality detection (handles ultrawide/cinematic aspect ratios)
if (width >= 3840) {
return ' - 4K';
} else if (width >= 1920) {
return ' - 1080p';
} else if (width >= 1280) {
return ' - 720p';
} else if (width >= 854) {
return ' - 480p';
} else {
return ` - ${width}x${height}`;
}
}
export const sortEpisodesList = (episodesList) => {
return episodesList.sort((a, b) => {
if (a.season_number !== b.season_number) {
return (a.season_number || 0) - (b.season_number || 0);
}
return (a.episode_number || 0) - (b.episode_number || 0);
});
};
export const groupEpisodesBySeason = (seriesEpisodes) => {
const grouped = {};
seriesEpisodes.forEach((episode) => {
const season = episode.season_number || 1;
if (!grouped[season]) {
grouped[season] = [];
}
grouped[season].push(episode);
});
return grouped;
};
export const sortBySeasonNumber = (episodesBySeason) => {
return Object.keys(episodesBySeason)
.map(Number)
.sort((a, b) => a - b);
};
export const getEpisodeStreamUrl = (episode, selectedProvider, env_mode) => {
let streamUrl = `/proxy/vod/episode/${episode.uuid}`;
// Add selected provider as query parameter if available
if (selectedProvider) {
// Use stream_id for most specific selection, fallback to account_id
if (selectedProvider.stream_id) {
streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
} else {
streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
}
}
if (env_mode === 'dev') {
streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
} else {
streamUrl = `${window.location.origin}${streamUrl}`;
}
return streamUrl;
};
// Helper to get embeddable YouTube URL
export const getYouTubeEmbedUrl = (url) => {
if (!url) return '';
// Accepts full YouTube URLs or just IDs
const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/);
const videoId = match ? match[1] : url;
return `https://www.youtube.com/embed/${videoId}`;
};
export const getEpisodeAirdate = (episode) => {
return episode.air_date
? new Date(episode.air_date).toLocaleDateString()
: 'N/A';
};
export const getTmdbUrlLink = (displaySeries, episode) => {
return (
tmdbUrl(displaySeries.tmdb_id, 'tv') +
(episode.season_number && episode.episode_number
? `/season/${episode.season_number}/episode/${episode.episode_number}`
: '')
);
};

View file

@ -0,0 +1,124 @@
const hasValidTechnicalDetails = (obj) => {
return obj?.bitrate || obj?.video || obj?.audio;
};
const extractFromDetailedInfo = (customProperties) => {
const detailedInfo = customProperties?.detailed_info;
if (!detailedInfo) return null;
return {
bitrate: detailedInfo.bitrate || null,
video: detailedInfo.video || null,
audio: detailedInfo.audio || null,
};
};
export const getTechnicalDetails = (selectedProvider, defaultVOD) => {
if (!selectedProvider) {
return {
bitrate: defaultVOD?.bitrate,
video: defaultVOD?.video,
audio: defaultVOD?.audio,
};
}
// Try movie/episode content first
const content = selectedProvider.movie || selectedProvider.episode;
if (content && hasValidTechnicalDetails(content)) {
return {
bitrate: content.bitrate,
video: content.video,
audio: content.audio,
};
}
// Try provider object directly
if (hasValidTechnicalDetails(selectedProvider)) {
return {
bitrate: selectedProvider.bitrate,
video: selectedProvider.video,
audio: selectedProvider.audio,
};
}
// Try custom_properties.detailed_info
const detailedInfo = extractFromDetailedInfo(
selectedProvider.custom_properties
);
if (detailedInfo && hasValidTechnicalDetails(detailedInfo)) {
return detailedInfo;
}
// Fallback to defaultVOD
return {
bitrate: defaultVOD?.bitrate,
video: defaultVOD?.video,
audio: defaultVOD?.audio,
};
};
export const getMovieStreamUrl = (vod, selectedProvider, env_mode) => {
let streamUrl = `/proxy/vod/movie/${vod.uuid}`;
// Add selected provider as query parameter if available
if (selectedProvider) {
// Use stream_id for most specific selection, fallback to account_id
if (selectedProvider.stream_id) {
streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`;
} else {
streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`;
}
}
if (env_mode === 'dev') {
streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`;
} else {
streamUrl = `${window.location.origin}${streamUrl}`;
}
return streamUrl;
};
export const formatVideoDetails = (video) => {
const parts = [];
const codec =
video.codec_long_name && video.codec_long_name !== 'unknown'
? video.codec_long_name
: video.codec_name;
parts.push(codec);
if (video.profile) parts.push(`(${video.profile})`);
if (video.width && video.height) parts.push(`${video.width}x${video.height}`);
if (video.display_aspect_ratio)
parts.push(`Aspect Ratio: ${video.display_aspect_ratio}`);
if (video.bit_rate)
parts.push(`Bitrate: ${Math.round(Number(video.bit_rate) / 1000)} kbps`);
if (video.r_frame_rate) parts.push(`Frame Rate: ${video.r_frame_rate} fps`);
if (video.tags?.encoder) parts.push(`Encoder: ${video.tags.encoder}`);
return parts.join(', ');
};
export const formatAudioDetails = (audio) => {
const parts = [];
const codec =
audio.codec_long_name && audio.codec_long_name !== 'unknown'
? audio.codec_long_name
: audio.codec_name;
parts.push(codec);
if (audio.profile) parts.push(`(${audio.profile})`);
const channels =
audio.channel_layout || (audio.channels ? `${audio.channels}` : null);
if (channels) parts.push(`Channels: ${channels}`);
if (audio.sample_rate) parts.push(`Sample Rate: ${audio.sample_rate} Hz`);
if (audio.bit_rate)
parts.push(`Bitrate: ${Math.round(Number(audio.bit_rate) / 1000)} kbps`);
if (audio.tags?.handler_name)
parts.push(`Handler: ${audio.tags.handler_name}`);
return parts.join(', ');
};

View file

@ -0,0 +1,235 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
getLivePlayerErrorMessage,
getVODPlayerErrorMessage,
getClientCoordinates,
calculateNewDimensions,
applyConstraints,
} from '../FloatingVideoUtils';
describe('FloatingVideoUtils', () => {
describe('getLivePlayerErrorMessage', () => {
it('should return formatted error for non-MediaError types', () => {
expect(getLivePlayerErrorMessage('NetworkError', 'Connection failed')).toBe(
'Error: NetworkError - Connection failed'
);
});
it('should return error type only when no detail provided', () => {
expect(getLivePlayerErrorMessage('NetworkError')).toBe('Error: NetworkError');
});
it('should return audio codec message for audio-related errors', () => {
const result = getLivePlayerErrorMessage('MediaError', 'audio codec not supported');
expect(result).toBe(
'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.'
);
});
it('should return audio codec message for AC3 errors', () => {
const result = getLivePlayerErrorMessage('MediaError', 'AC3 codec issue');
expect(result).toContain('Audio codec not supported');
});
it('should return video codec message for video-related errors', () => {
const result = getLivePlayerErrorMessage('MediaError', 'video codec h264 failed');
expect(result).toBe(
'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.'
);
});
it('should return MSE message for MSE-related errors', () => {
const result = getLivePlayerErrorMessage('MediaError', 'MSE not supported');
expect(result).toBe(
"Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility."
);
});
it('should return generic codec message for other MediaError cases', () => {
const result = getLivePlayerErrorMessage('MediaError', 'unknown codec issue');
expect(result).toBe(
'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.'
);
});
it('should handle null errorDetail for MediaError', () => {
const result = getLivePlayerErrorMessage('MediaError', null);
expect(result).toBe(
'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.'
);
});
});
describe('getVODPlayerErrorMessage', () => {
it('should return default message when error is null', () => {
expect(getVODPlayerErrorMessage(null)).toBe('Video playback error');
});
it('should return aborted message for MEDIA_ERR_ABORTED', () => {
const error = { code: 1, MEDIA_ERR_ABORTED: 1 };
expect(getVODPlayerErrorMessage(error)).toBe('Video playback was aborted');
});
it('should return network message for MEDIA_ERR_NETWORK', () => {
const error = { code: 2, MEDIA_ERR_NETWORK: 2 };
expect(getVODPlayerErrorMessage(error)).toBe('Network error while loading video');
});
it('should return codec message for MEDIA_ERR_DECODE', () => {
const error = { code: 3, MEDIA_ERR_DECODE: 3 };
expect(getVODPlayerErrorMessage(error)).toBe('Video codec not supported by your browser');
});
it('should return format message for MEDIA_ERR_SRC_NOT_SUPPORTED', () => {
const error = { code: 4, MEDIA_ERR_SRC_NOT_SUPPORTED: 4 };
expect(getVODPlayerErrorMessage(error)).toBe('Video format not supported by your browser');
});
it('should return error message for unknown error codes', () => {
const error = { code: 99, message: 'Custom error message' };
expect(getVODPlayerErrorMessage(error)).toBe('Custom error message');
});
it('should return default message for unknown error without message', () => {
const error = { code: 99 };
expect(getVODPlayerErrorMessage(error)).toBe('Unknown video error');
});
});
describe('getClientCoordinates', () => {
it('should extract coordinates from mouse event', () => {
const event = { clientX: 100, clientY: 200 };
expect(getClientCoordinates(event)).toEqual({ clientX: 100, clientY: 200 });
});
it('should extract coordinates from touch event', () => {
const event = { touches: [{ clientX: 150, clientY: 250 }] };
expect(getClientCoordinates(event)).toEqual({ clientX: 150, clientY: 250 });
});
it('should prioritize touch coordinates over mouse coordinates', () => {
const event = {
touches: [{ clientX: 150, clientY: 250 }],
clientX: 100,
clientY: 200,
};
expect(getClientCoordinates(event)).toEqual({ clientX: 150, clientY: 250 });
});
it('should handle undefined coordinates', () => {
const event = {};
expect(getClientCoordinates(event)).toEqual({ clientX: undefined, clientY: undefined });
});
});
describe('calculateNewDimensions', () => {
const ratio = 16 / 9;
it('should calculate dimensions based on horizontal drag', () => {
const handle = { xDir: 1, yDir: 0, isLeft: false, isTop: false };
const result = calculateNewDimensions(100, 10, 400, 225, handle, ratio);
expect(result.width).toBe(500);
expect(result.height).toBeCloseTo(281.25, 1);
});
it('should calculate dimensions based on vertical drag', () => {
const handle = { xDir: 0, yDir: 1, isLeft: false, isTop: false };
const result = calculateNewDimensions(10, 100, 400, 225, handle, ratio);
expect(result.height).toBe(325);
expect(result.width).toBeCloseTo(577.78, 1);
});
it('should use vertical-driven resize when vertical delta is larger', () => {
const handle = { xDir: 1, yDir: 1, isLeft: false, isTop: false };
const result = calculateNewDimensions(20, 100, 400, 225, handle, ratio);
expect(result.height).toBe(325);
expect(result.width).toBeCloseTo(577.78, 1);
});
it('should handle negative deltas', () => {
const handle = { xDir: -1, yDir: 0, isLeft: true, isTop: false };
const result = calculateNewDimensions(-50, 0, 400, 225, handle, ratio);
expect(result.width).toBe(450);
expect(result.height).toBeCloseTo(253.13, 1);
});
});
describe('applyConstraints', () => {
const ratio = 16 / 9;
const minWidth = 200;
const minHeight = 112.5;
const visibleMargin = 50;
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', { writable: true, value: 1920 });
Object.defineProperty(window, 'innerHeight', { writable: true, value: 1080 });
});
it('should apply minimum width constraint', () => {
const handle = { isLeft: false, isTop: false };
const result = applyConstraints(100, 50, ratio, { x: 0, y: 0 }, handle, minWidth, minHeight, visibleMargin);
expect(result.width).toBe(minWidth);
expect(result.height).toBeCloseTo(minWidth / ratio, 1);
});
it('should apply minimum height constraint', () => {
const handle = { isLeft: false, isTop: false };
const result = applyConstraints(300, 100, ratio, { x: 0, y: 0 }, handle, minWidth, minHeight, visibleMargin);
expect(result.height).toBe(minHeight);
expect(result.width).toBeCloseTo(minHeight * ratio, 1);
});
it('should apply maximum width constraint based on viewport', () => {
const handle = { isLeft: false, isTop: false };
const startPos = { x: 1200, y: 100 };
const result = applyConstraints(800, 450, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
const maxWidth = 1920 - 1200 - 50; // 670
expect(result.width).toBe(maxWidth);
expect(result.height).toBeCloseTo(maxWidth / ratio, 1);
});
it('should apply maximum height constraint based on viewport', () => {
const handle = { isLeft: false, isTop: false };
const startPos = { x: 100, y: 700 };
const result = applyConstraints(500, 400, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
const maxHeight = 1080 - 700 - 50; // 330
expect(result.height).toBe(maxHeight);
expect(result.width).toBeCloseTo(maxHeight * ratio, 1);
});
it('should not apply max width constraint for left handle', () => {
const handle = { isLeft: true, isTop: false };
const startPos = { x: 1800, y: 100 };
const result = applyConstraints(500, 281.25, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
expect(result.width).toBe(500);
expect(result.height).toBeCloseTo(281.25, 1);
});
it('should not apply max height constraint for top handle', () => {
const handle = { isLeft: false, isTop: true };
const startPos = { x: 100, y: 1000 };
const result = applyConstraints(500, 400, ratio, startPos, handle, minWidth, minHeight, visibleMargin);
expect(result.width).toBe(500);
expect(result.height).toBe(400);
});
it('should handle null startPos', () => {
const handle = { isLeft: false, isTop: false };
const result = applyConstraints(300, 168.75, ratio, null, handle, minWidth, minHeight, visibleMargin);
expect(result.width).toBe(300);
expect(result.height).toBeCloseTo(168.75, 1);
});
});
});

View file

@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as NotificationUtils from '../NotificationCenterUtils';
import API from '../../../api';
vi.mock('../../../api');
describe('NotificationCenterUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getNotifications', () => {
it('should call API.getNotifications with showDismissed parameter', async () => {
const mockNotifications = [
{ id: 1, message: 'Test notification' }
];
API.getNotifications.mockResolvedValue(mockNotifications);
const result = await NotificationUtils.getNotifications(false);
expect(API.getNotifications).toHaveBeenCalledWith(false);
expect(result).toEqual(mockNotifications);
});
it('should call API.getNotifications with showDismissed=true', async () => {
const mockNotifications = [
{ id: 2, message: 'Dismissed notification', is_dismissed: true }
];
API.getNotifications.mockResolvedValue(mockNotifications);
const result = await NotificationUtils.getNotifications(true);
expect(API.getNotifications).toHaveBeenCalledWith(true);
expect(result).toEqual(mockNotifications);
});
it('should handle API errors', async () => {
const error = new Error('API error');
API.getNotifications.mockRejectedValue(error);
await expect(NotificationUtils.getNotifications(false)).rejects.toThrow('API error');
expect(API.getNotifications).toHaveBeenCalledWith(false);
});
});
describe('dismissNotification', () => {
it('should call API.dismissNotification with notificationId and actionTaken', async () => {
API.dismissNotification.mockResolvedValue({ success: true });
const result = await NotificationUtils.dismissNotification(1, 'dismissed');
expect(API.dismissNotification).toHaveBeenCalledWith(1, 'dismissed');
expect(result).toEqual({ success: true });
});
it('should handle API errors when dismissing', async () => {
const error = new Error('Dismiss failed');
API.dismissNotification.mockRejectedValue(error);
await expect(NotificationUtils.dismissNotification(1, 'dismissed')).rejects.toThrow('Dismiss failed');
expect(API.dismissNotification).toHaveBeenCalledWith(1, 'dismissed');
});
});
describe('dismissAllNotifications', () => {
it('should call API.dismissAllNotifications', async () => {
API.dismissAllNotifications.mockResolvedValue({ success: true, count: 5 });
const result = await NotificationUtils.dismissAllNotifications();
expect(API.dismissAllNotifications).toHaveBeenCalled();
expect(result).toEqual({ success: true, count: 5 });
});
it('should handle API errors when dismissing all', async () => {
const error = new Error('Dismiss all failed');
API.dismissAllNotifications.mockRejectedValue(error);
await expect(NotificationUtils.dismissAllNotifications()).rejects.toThrow('Dismiss all failed');
expect(API.dismissAllNotifications).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,397 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
imdbUrl,
tmdbUrl,
formatDuration,
formatStreamLabel,
sortEpisodesList,
groupEpisodesBySeason,
sortBySeasonNumber,
getEpisodeStreamUrl,
getYouTubeEmbedUrl,
getEpisodeAirdate,
getTmdbUrlLink
} from '../SeriesModalUtils';
describe('SeriesModalUtils', () => {
describe('imdbUrl', () => {
it('should return IMDb URL with valid ID', () => {
expect(imdbUrl('tt1234567')).toBe('https://www.imdb.com/title/tt1234567');
});
it('should return empty string when ID is null', () => {
expect(imdbUrl(null)).toBe('');
});
it('should return empty string when ID is undefined', () => {
expect(imdbUrl(undefined)).toBe('');
});
});
describe('tmdbUrl', () => {
it('should return TMDB movie URL by default', () => {
expect(tmdbUrl('12345')).toBe('https://www.themoviedb.org/movie/12345');
});
it('should return TMDB TV URL when type is tv', () => {
expect(tmdbUrl('12345', 'tv')).toBe('https://www.themoviedb.org/tv/12345');
});
it('should return empty string when ID is null', () => {
expect(tmdbUrl(null)).toBe('');
});
});
describe('formatDuration', () => {
it('should format hours and minutes', () => {
expect(formatDuration(7200)).toBe('2h 0m');
});
it('should format hours, minutes and seconds', () => {
expect(formatDuration(3665)).toBe('1h 1m');
});
it('should format minutes and seconds when under an hour', () => {
expect(formatDuration(125)).toBe('2m 5s');
});
it('should return empty string for 0 seconds', () => {
expect(formatDuration(0)).toBe('');
});
it('should return empty string for null', () => {
expect(formatDuration(null)).toBe('');
});
});
describe('formatStreamLabel', () => {
const baseRelation = {
m3u_account: { name: 'Provider 1' },
stream_id: 100
};
it('should format label with quality from quality_info', () => {
const relation = {
...baseRelation,
quality_info: { quality: '1080p' }
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)');
});
it('should format label with quality from resolution', () => {
const relation = {
...baseRelation,
quality_info: { resolution: '4K' }
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
});
it('should format label with quality from bitrate', () => {
const relation = {
...baseRelation,
quality_info: { bitrate: '8000kbps' }
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 8000kbps (Stream 100)');
});
it('should detect 4K from video dimensions', () => {
const relation = {
...baseRelation,
custom_properties: {
detailed_info: {
video: { width: 3840, height: 2160 }
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
});
it('should detect 1080p from video dimensions', () => {
const relation = {
...baseRelation,
custom_properties: {
detailed_info: {
video: { width: 1920, height: 1080 }
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)');
});
it('should detect 720p from video dimensions', () => {
const relation = {
...baseRelation,
custom_properties: {
detailed_info: {
video: { width: 1280, height: 720 }
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 720p (Stream 100)');
});
it('should show custom dimensions for non-standard resolutions', () => {
const relation = {
...baseRelation,
custom_properties: {
detailed_info: {
video: { width: 640, height: 480 }
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 640x480 (Stream 100)');
});
it('should detect quality from detailed_info name field', () => {
const relation = {
...baseRelation,
custom_properties: {
detailed_info: {
name: 'Stream 4K HDR'
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
});
it('should detect quality from stream_name', () => {
const relation = {
...baseRelation,
stream_name: 'Channel 1080p FHD'
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)');
});
it('should detect 2160p as 4K', () => {
const relation = {
...baseRelation,
stream_name: 'Stream 2160p'
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
});
it('should detect FHD as 1080p', () => {
const relation = {
...baseRelation,
stream_name: 'Stream FHD'
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p (Stream 100)');
});
it('should detect HD as 720p', () => {
const relation = {
...baseRelation,
stream_name: 'Stream HD'
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 720p (Stream 100)');
});
it('should format without stream ID when not provided', () => {
const relation = {
m3u_account: { name: 'Provider 1' },
quality_info: { quality: '1080p' }
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 1080p');
});
it('should format without quality when not detected', () => {
const relation = {
m3u_account: { name: 'Provider 1' },
stream_id: 100
};
expect(formatStreamLabel(relation)).toBe('Provider 1 (Stream 100)');
});
it('should prioritize quality_info over detailed_info', () => {
const relation = {
...baseRelation,
quality_info: { quality: '4K' },
custom_properties: {
detailed_info: {
video: { width: 1920, height: 1080 }
}
}
};
expect(formatStreamLabel(relation)).toBe('Provider 1 - 4K (Stream 100)');
});
});
describe('sortEpisodesList', () => {
it('should sort episodes by season then episode number', () => {
const episodes = [
{ season_number: 2, episode_number: 1 },
{ season_number: 1, episode_number: 2 },
{ season_number: 1, episode_number: 1 }
];
const sorted = sortEpisodesList(episodes);
expect(sorted).toEqual([
{ season_number: 1, episode_number: 1 },
{ season_number: 1, episode_number: 2 },
{ season_number: 2, episode_number: 1 }
]);
});
it('should handle missing season numbers as 0', () => {
const episodes = [
{ season_number: 1, episode_number: 1 },
{ episode_number: 1 }
];
const sorted = sortEpisodesList(episodes);
expect(sorted[0].season_number).toBeUndefined();
});
});
describe('groupEpisodesBySeason', () => {
it('should group episodes by season number', () => {
const episodes = [
{ season_number: 1, episode_number: 1 },
{ season_number: 1, episode_number: 2 },
{ season_number: 2, episode_number: 1 }
];
const grouped = groupEpisodesBySeason(episodes);
expect(grouped[1]).toHaveLength(2);
expect(grouped[2]).toHaveLength(1);
});
it('should default missing season numbers to 1', () => {
const episodes = [{ episode_number: 1 }];
const grouped = groupEpisodesBySeason(episodes);
expect(grouped[1]).toHaveLength(1);
});
});
describe('sortBySeasonNumber', () => {
it('should return season numbers in ascending order', () => {
const episodesBySeason = {
3: [],
1: [],
2: []
};
const sorted = sortBySeasonNumber(episodesBySeason);
expect(sorted).toEqual([1, 2, 3]);
});
});
describe('getEpisodeStreamUrl', () => {
beforeEach(() => {
delete window.location;
window.location = {
protocol: 'https:',
hostname: 'example.com',
origin: 'https://example.com'
};
});
it('should generate stream URL without provider in production', () => {
const episode = { uuid: 'episode-123' };
const url = getEpisodeStreamUrl(episode, null, 'prod');
expect(url).toBe('https://example.com/proxy/vod/episode/episode-123');
});
it('should generate stream URL with stream_id parameter', () => {
const episode = { uuid: 'episode-123' };
const provider = {
stream_id: 'stream-456',
m3u_account: { id: 1 }
};
const url = getEpisodeStreamUrl(episode, provider, 'prod');
expect(url).toBe('https://example.com/proxy/vod/episode/episode-123?stream_id=stream-456');
});
it('should generate stream URL with m3u_account_id when no stream_id', () => {
const episode = { uuid: 'episode-123' };
const provider = {
m3u_account: { id: 1 }
};
const url = getEpisodeStreamUrl(episode, provider, 'prod');
expect(url).toBe('https://example.com/proxy/vod/episode/episode-123?m3u_account_id=1');
});
it('should use dev port in dev mode', () => {
const episode = { uuid: 'episode-123' };
const url = getEpisodeStreamUrl(episode, null, 'dev');
expect(url).toBe('https://example.com:5656/proxy/vod/episode/episode-123');
});
it('should encode special characters in stream_id', () => {
const episode = { uuid: 'episode-123' };
const provider = {
stream_id: 'stream with spaces',
m3u_account: { id: 1 }
};
const url = getEpisodeStreamUrl(episode, provider, 'prod');
expect(url).toContain('stream%20with%20spaces');
});
});
describe('getYouTubeEmbedUrl', () => {
it('should convert youtube.com watch URL to embed URL', () => {
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
expect(getYouTubeEmbedUrl(url)).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ');
});
it('should convert youtu.be URL to embed URL', () => {
const url = 'https://youtu.be/dQw4w9WgXcQ';
expect(getYouTubeEmbedUrl(url)).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ');
});
it('should handle bare video ID', () => {
const videoId = 'dQw4w9WgXcQ';
expect(getYouTubeEmbedUrl(videoId)).toBe('https://www.youtube.com/embed/dQw4w9WgXcQ');
});
it('should return empty string for null', () => {
expect(getYouTubeEmbedUrl(null)).toBe('');
});
it('should return empty string for empty string', () => {
expect(getYouTubeEmbedUrl('')).toBe('');
});
});
describe('getEpisodeAirdate', () => {
it('should format valid air date', () => {
const episode = { air_date: '2024-01-15' };
const formatted = getEpisodeAirdate(episode);
expect(formatted).toMatch(/1\/1[4|5]\/2024/);
});
it('should return N/A for missing air date', () => {
const episode = {};
expect(getEpisodeAirdate(episode)).toBe('N/A');
});
it('should return N/A for null air date', () => {
const episode = { air_date: null };
expect(getEpisodeAirdate(episode)).toBe('N/A');
});
});
describe('getTmdbUrlLink', () => {
const displaySeries = { tmdb_id: '12345' };
it('should return TV show URL without episode details', () => {
const episode = {};
const url = getTmdbUrlLink(displaySeries, episode);
expect(url).toBe('https://www.themoviedb.org/tv/12345');
});
it('should return TV show URL with season and episode', () => {
const episode = { season_number: 2, episode_number: 5 };
const url = getTmdbUrlLink(displaySeries, episode);
expect(url).toBe('https://www.themoviedb.org/tv/12345/season/2/episode/5');
});
it('should not append episode details if only season is present', () => {
const episode = { season_number: 2 };
const url = getTmdbUrlLink(displaySeries, episode);
expect(url).toBe('https://www.themoviedb.org/tv/12345');
});
it('should not append episode details if only episode is present', () => {
const episode = { episode_number: 5 };
const url = getTmdbUrlLink(displaySeries, episode);
expect(url).toBe('https://www.themoviedb.org/tv/12345');
});
});
});

View file

@ -0,0 +1,344 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
getTechnicalDetails,
getMovieStreamUrl,
formatVideoDetails,
formatAudioDetails,
} from '../VODModalUtils';
describe('VODModalUtils', () => {
describe('getTechnicalDetails', () => {
const defaultVOD = {
bitrate: '5000 kbps',
video: 'H.264',
audio: 'AAC',
};
it('should return defaultVOD details when no provider selected', () => {
const result = getTechnicalDetails(null, defaultVOD);
expect(result).toEqual({
bitrate: '5000 kbps',
video: 'H.264',
audio: 'AAC',
});
});
it('should extract details from movie content', () => {
const provider = {
movie: {
bitrate: '8000 kbps',
video: 'H.265',
audio: 'AC3',
},
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result).toEqual({
bitrate: '8000 kbps',
video: 'H.265',
audio: 'AC3',
});
});
it('should extract details from episode content', () => {
const provider = {
episode: {
bitrate: '6000 kbps',
video: 'AVC',
audio: 'DTS',
},
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result).toEqual({
bitrate: '6000 kbps',
video: 'AVC',
audio: 'DTS',
});
});
it('should extract details from provider object directly', () => {
const provider = {
bitrate: '7000 kbps',
video: 'VP9',
audio: 'Opus',
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result).toEqual({
bitrate: '7000 kbps',
video: 'VP9',
audio: 'Opus',
});
});
it('should extract details from custom_properties.detailed_info', () => {
const provider = {
custom_properties: {
detailed_info: {
bitrate: '9000 kbps',
video: 'AV1',
audio: 'EAC3',
},
},
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result).toEqual({
bitrate: '9000 kbps',
video: 'AV1',
audio: 'EAC3',
});
});
it('should fallback to defaultVOD when provider has no valid details', () => {
const provider = {
custom_properties: {},
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result).toEqual({
bitrate: '5000 kbps',
video: 'H.264',
audio: 'AAC',
});
});
it('should prioritize movie content over provider object', () => {
const provider = {
movie: {
bitrate: '8000 kbps',
},
bitrate: '7000 kbps',
};
const result = getTechnicalDetails(provider, defaultVOD);
expect(result.bitrate).toBe('8000 kbps');
});
it('should handle undefined defaultVOD', () => {
const result = getTechnicalDetails(null, undefined);
expect(result).toEqual({
bitrate: undefined,
video: undefined,
audio: undefined,
});
});
});
describe('getMovieStreamUrl', () => {
const vod = { uuid: 'test-uuid-123' };
const originalLocation = window.location;
beforeEach(() => {
delete window.location;
window.location = {
protocol: 'https:',
hostname: 'example.com',
origin: 'https://example.com',
};
});
afterEach(() => {
window.location = originalLocation;
});
it('should generate basic stream URL without provider', () => {
const result = getMovieStreamUrl(vod, null, 'production');
expect(result).toBe('https://example.com/proxy/vod/movie/test-uuid-123');
});
it('should include stream_id when available', () => {
const provider = {
stream_id: 'stream-123',
m3u_account: { id: 'account-456' },
};
const result = getMovieStreamUrl(vod, provider, 'production');
expect(result).toBe(
'https://example.com/proxy/vod/movie/test-uuid-123?stream_id=stream-123'
);
});
it('should include m3u_account_id when stream_id not available', () => {
const provider = {
m3u_account: { id: 'account-456' },
};
const result = getMovieStreamUrl(vod, provider, 'production');
expect(result).toBe(
'https://example.com/proxy/vod/movie/test-uuid-123?m3u_account_id=account-456'
);
});
it('should use dev port in dev mode', () => {
const result = getMovieStreamUrl(vod, null, 'dev');
expect(result).toBe(
'https://example.com:5656/proxy/vod/movie/test-uuid-123'
);
});
it('should encode stream_id', () => {
const provider = {
stream_id: 'stream with spaces',
};
const result = getMovieStreamUrl(vod, provider, 'production');
expect(result).toContain('stream_id=stream%20with%20spaces');
});
});
describe('formatVideoDetails', () => {
it('should format complete video details', () => {
const video = {
codec_name: 'h264',
codec_long_name: 'H.264 / AVC / MPEG-4 AVC',
profile: 'High',
width: 1920,
height: 1080,
display_aspect_ratio: '16:9',
bit_rate: '8000000',
r_frame_rate: '23.98',
tags: { encoder: 'x264' },
};
const result = formatVideoDetails(video);
expect(result).toBe(
'H.264 / AVC / MPEG-4 AVC, (High), 1920x1080, Aspect Ratio: ' +
'16:9, Bitrate: 8000 kbps, Frame Rate: 23.98 fps, Encoder: x264'
);
});
it('should use codec_name when codec_long_name is unknown', () => {
const video = {
codec_name: 'h264',
codec_long_name: 'unknown',
};
const result = formatVideoDetails(video);
expect(result).toBe('h264');
});
it('should use codec_name when codec_long_name is not available', () => {
const video = {
codec_name: 'h265',
};
const result = formatVideoDetails(video);
expect(result).toBe('h265');
});
it('should handle minimal video details', () => {
const video = {
codec_name: 'vp9',
};
const result = formatVideoDetails(video);
expect(result).toBe('vp9');
});
it('should format bitrate correctly', () => {
const video = {
codec_name: 'h264',
bit_rate: '5500000',
};
const result = formatVideoDetails(video);
expect(result).toContain('Bitrate: 5500 kbps');
});
});
describe('formatAudioDetails', () => {
it('should format complete audio details', () => {
const audio = {
codec_name: 'aac',
codec_long_name: 'AAC (Advanced Audio Coding)',
profile: 'LC',
channel_layout: '5.1',
sample_rate: '48000',
bit_rate: '256000',
tags: { handler_name: 'SoundHandler' },
};
const result = formatAudioDetails(audio);
expect(result).toBe(
'AAC (Advanced Audio Coding), (LC), Channels: 5.1, ' +
'Sample Rate: 48000 Hz, Bitrate: 256 kbps, Handler: SoundHandler'
);
});
it('should use codec_name when codec_long_name is unknown', () => {
const audio = {
codec_name: 'ac3',
codec_long_name: 'unknown',
};
const result = formatAudioDetails(audio);
expect(result).toBe('ac3');
});
it('should use channels count when channel_layout not available', () => {
const audio = {
codec_name: 'aac',
channels: '2',
};
const result = formatAudioDetails(audio);
expect(result).toBe('aac, Channels: 2');
});
it('should handle minimal audio details', () => {
const audio = {
codec_name: 'opus',
};
const result = formatAudioDetails(audio);
expect(result).toBe('opus');
});
it('should format bitrate correctly', () => {
const audio = {
codec_name: 'aac',
bit_rate: '192000',
};
const result = formatAudioDetails(audio);
expect(result).toContain('Bitrate: 192 kbps');
});
it('should prefer channel_layout over channels', () => {
const audio = {
codec_name: 'dts',
channel_layout: '7.1',
channels: '8',
};
const result = formatAudioDetails(audio);
expect(result).toContain('Channels: 7.1');
});
});
});