Added tests for utils

This commit is contained in:
Nick Sandstrom 2026-06-27 00:22:44 -07:00
parent 7a4db31460
commit 1a46ca4be4
12 changed files with 2756 additions and 75 deletions

View file

@ -0,0 +1,229 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
makeHeaderCellRenderer,
makeSortingChangeHandler,
} from '../M3uTableUtils';
vi.mock('@mantine/core', () => ({
Center: ({ children }) => <div data-testid="center">{children}</div>,
Group: ({ children }) => <div data-testid="group">{children}</div>,
Text: ({ children, size, name }) => (
<span data-testid="text" data-size={size} data-name={name}>
{children}
</span>
),
}));
vi.mock('lucide-react', () => ({
ArrowUpDown: (props) => (
<svg data-testid="icon-arrow-up-down" onClick={props.onClick} />
),
ArrowUpNarrowWide: (props) => (
<svg data-testid="icon-arrow-up-narrow-wide" onClick={props.onClick} />
),
ArrowDownWideNarrow: (props) => (
<svg data-testid="icon-arrow-down-wide-narrow" onClick={props.onClick} />
),
}));
// Helpers
/** Build a minimal header object that matches what TanStack Table provides */
const makeHeader = ({ id = 'name', label = 'Name', sortable = true } = {}) => ({
id,
column: {
columnDef: {
header: label,
sortable,
},
},
});
// makeHeaderCellRenderer
describe('makeHeaderCellRenderer', () => {
describe('with no active sort', () => {
const sorting = [];
let onSortingChange;
let renderHeader;
beforeEach(() => {
onSortingChange = vi.fn();
renderHeader = makeHeaderCellRenderer(sorting, onSortingChange);
});
it('renders the column label text', () => {
render(renderHeader(makeHeader({ label: 'Channel' })));
expect(screen.getByTestId('text')).toHaveTextContent('Channel');
});
it('renders the neutral ArrowUpDown icon when no sort is active', () => {
render(renderHeader(makeHeader()));
expect(screen.getByTestId('icon-arrow-up-down')).toBeInTheDocument();
});
it('does not render a sort icon when column is not sortable', () => {
render(renderHeader(makeHeader({ sortable: false })));
expect(
screen.queryByTestId('icon-arrow-up-down')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('icon-arrow-up-narrow-wide')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('icon-arrow-down-wide-narrow')
).not.toBeInTheDocument();
});
it('calls onSortingChange with the header id when icon is clicked', () => {
render(renderHeader(makeHeader({ id: 'title' })));
fireEvent.click(screen.getByTestId('icon-arrow-up-down'));
expect(onSortingChange).toHaveBeenCalledTimes(1);
expect(onSortingChange).toHaveBeenCalledWith('title');
});
it('sets the data-name attribute on the Text element to the header id', () => {
render(renderHeader(makeHeader({ id: 'status' })));
expect(screen.getByTestId('text')).toHaveAttribute('data-name', 'status');
});
});
describe('when sorting asc on the current column (desc: false)', () => {
it('renders ArrowUpNarrowWide icon', () => {
const sorting = [{ id: 'name', desc: false }];
const renderHeader = makeHeaderCellRenderer(sorting, vi.fn());
render(renderHeader(makeHeader({ id: 'name' })));
expect(
screen.getByTestId('icon-arrow-up-narrow-wide')
).toBeInTheDocument();
});
it('does not render ArrowDownWideNarrow or ArrowUpDown', () => {
const sorting = [{ id: 'name', desc: false }];
const renderHeader = makeHeaderCellRenderer(sorting, vi.fn());
render(renderHeader(makeHeader({ id: 'name' })));
expect(
screen.queryByTestId('icon-arrow-down-wide-narrow')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('icon-arrow-up-down')
).not.toBeInTheDocument();
});
});
describe('when sorting desc on the current column (desc: true)', () => {
it('renders ArrowDownWideNarrow icon', () => {
const sorting = [{ id: 'name', desc: true }];
const renderHeader = makeHeaderCellRenderer(sorting, vi.fn());
render(renderHeader(makeHeader({ id: 'name' })));
expect(
screen.getByTestId('icon-arrow-down-wide-narrow')
).toBeInTheDocument();
});
it('does not render ArrowUpNarrowWide or ArrowUpDown', () => {
const sorting = [{ id: 'name', desc: true }];
const renderHeader = makeHeaderCellRenderer(sorting, vi.fn());
render(renderHeader(makeHeader({ id: 'name' })));
expect(
screen.queryByTestId('icon-arrow-up-narrow-wide')
).not.toBeInTheDocument();
expect(
screen.queryByTestId('icon-arrow-up-down')
).not.toBeInTheDocument();
});
});
describe('when a different column is sorted', () => {
it('renders the neutral ArrowUpDown icon for the unsorted column', () => {
const sorting = [{ id: 'status', desc: false }];
const renderHeader = makeHeaderCellRenderer(sorting, vi.fn());
render(renderHeader(makeHeader({ id: 'name' })));
expect(screen.getByTestId('icon-arrow-up-down')).toBeInTheDocument();
});
});
});
// makeSortingChangeHandler
describe('makeSortingChangeHandler', () => {
let setSorting;
let onDataSort;
beforeEach(() => {
setSorting = vi.fn();
onDataSort = vi.fn();
});
describe('first click on a new column', () => {
it('sets ascending sort (desc: false)', () => {
const handler = makeSortingChangeHandler([], setSorting, onDataSort);
handler('name');
expect(setSorting).toHaveBeenCalledWith([{ id: 'name', desc: false }]);
});
it('calls onDataSort with the column and desc: false', () => {
const handler = makeSortingChangeHandler([], setSorting, onDataSort);
handler('name');
expect(onDataSort).toHaveBeenCalledWith('name', false);
});
});
describe('second click on the same column (currently asc)', () => {
it('sets descending sort (desc: true)', () => {
const sorting = [{ id: 'name', desc: false }];
const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort);
handler('name');
expect(setSorting).toHaveBeenCalledWith([{ id: 'name', desc: true }]);
});
it('calls onDataSort with the column and desc: true', () => {
const sorting = [{ id: 'name', desc: false }];
const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort);
handler('name');
expect(onDataSort).toHaveBeenCalledWith('name', true);
});
});
describe('third click on the same column (currently desc) → clears sort', () => {
it('sets sorting to an empty array', () => {
const sorting = [{ id: 'name', desc: true }];
const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort);
handler('name');
expect(setSorting).toHaveBeenCalledWith([]);
});
it('does NOT call onDataSort when sorting is cleared', () => {
const sorting = [{ id: 'name', desc: true }];
const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort);
handler('name');
expect(onDataSort).not.toHaveBeenCalled();
});
});
describe('switching to a different column while another is sorted', () => {
it('resets to ascending sort on the new column', () => {
const sorting = [{ id: 'status', desc: true }];
const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort);
handler('name');
expect(setSorting).toHaveBeenCalledWith([{ id: 'name', desc: false }]);
});
it('calls onDataSort with the new column and desc: false', () => {
const sorting = [{ id: 'status', desc: true }];
const handler = makeSortingChangeHandler(sorting, setSorting, onDataSort);
handler('name');
expect(onDataSort).toHaveBeenCalledWith('name', false);
});
});
describe('always calls setSorting', () => {
it('calls setSorting exactly once per handler invocation', () => {
const handler = makeSortingChangeHandler([], setSorting, onDataSort);
handler('title');
expect(setSorting).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -787,4 +787,112 @@ describe('dateTimeUtils', () => {
expect(dateTimeUtils.getMillisecond(date)).toBe(0);
});
});
describe('formatDuration', () => {
describe('default precision (hms)', () => {
it('should return "0:00" for 0 seconds', () => {
expect(dateTimeUtils.formatDuration(0)).toBe('0:00');
});
it('should return "0:00" for falsy input', () => {
expect(dateTimeUtils.formatDuration(null)).toBe('0:00');
expect(dateTimeUtils.formatDuration(undefined)).toBe('0:00');
});
it('should return custom zeroValue when seconds is 0', () => {
expect(dateTimeUtils.formatDuration(0, { zeroValue: 'N/A' })).toBe(
'N/A'
);
});
it('should format seconds under 1 minute without hours', () => {
expect(dateTimeUtils.formatDuration(45)).toBe('0:45');
});
it('should format minutes and seconds without hours when under 1 hour', () => {
expect(dateTimeUtils.formatDuration(90)).toBe('1:30');
});
it('should format minutes with padded seconds', () => {
expect(dateTimeUtils.formatDuration(65)).toBe('1:05');
});
it('should format hours when >= 1 hour', () => {
expect(dateTimeUtils.formatDuration(3661)).toBe('01:01:01');
});
it('should pad all segments when hours are present', () => {
expect(dateTimeUtils.formatDuration(3600 + 60 + 5)).toBe('01:01:05');
});
it('should handle exactly 1 hour', () => {
expect(dateTimeUtils.formatDuration(3600)).toBe('01:00:00');
});
it('should handle negative seconds by taking absolute value', () => {
expect(dateTimeUtils.formatDuration(-90)).toBe('1:30');
});
it('should show hours when alwaysShowHours is true even under 1 hour', () => {
expect(
dateTimeUtils.formatDuration(90, { alwaysShowHours: true })
).toBe('00:01:30');
});
it('should show hours when alwaysShowHours is true for 0 minutes', () => {
expect(
dateTimeUtils.formatDuration(45, { alwaysShowHours: true })
).toBe('00:00:45');
});
});
describe('precision: hm', () => {
it('should return minutes only when under 1 hour and no alwaysShowHours', () => {
expect(dateTimeUtils.formatDuration(90, { precision: 'hm' })).toBe('1');
});
it('should return HH:MM when >= 1 hour', () => {
expect(dateTimeUtils.formatDuration(3661, { precision: 'hm' })).toBe(
'01:01'
);
});
it('should return HH:MM when alwaysShowHours is true', () => {
expect(
dateTimeUtils.formatDuration(90, {
precision: 'hm',
alwaysShowHours: true,
})
).toBe('00:01');
});
it('should return "0:00" for 0 seconds', () => {
expect(dateTimeUtils.formatDuration(0, { precision: 'hm' })).toBe(
'0:00'
);
});
});
describe('precision: m', () => {
it('should return total minutes as integer', () => {
expect(dateTimeUtils.formatDuration(90, { precision: 'm' })).toBe('1');
});
it('should return total minutes across hours', () => {
expect(dateTimeUtils.formatDuration(3661, { precision: 'm' })).toBe(
'61'
);
});
it('should truncate partial minutes', () => {
expect(dateTimeUtils.formatDuration(89, { precision: 'm' })).toBe('1');
});
it('should return "0:00" for 0 seconds', () => {
expect(dateTimeUtils.formatDuration(0, { precision: 'm' })).toBe(
'0:00'
);
});
});
});
});

View file

@ -2,87 +2,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as VodConnectionCardUtils from '../VodConnectionCardUtils';
import * as dateTimeUtils from '../../dateTimeUtils.js';
vi.mock('../../dateTimeUtils.js');
vi.mock('../../dateTimeUtils.js', () => ({
getNowMs: vi.fn(),
format: vi.fn(),
toFriendlyDuration: vi.fn(),
}));
describe('VodConnectionCardUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('formatDuration', () => {
it('should format duration with hours and minutes when hours > 0', () => {
const result = VodConnectionCardUtils.formatDuration(3661); // 1h 1m 1s
expect(result).toBe('1h 1m');
});
it('should format duration with only minutes when less than an hour', () => {
const result = VodConnectionCardUtils.formatDuration(125); // 2m 5s
expect(result).toBe('2m');
});
it('should format duration with 0 minutes when less than 60 seconds', () => {
const result = VodConnectionCardUtils.formatDuration(45);
expect(result).toBe('0m');
});
it('should handle multiple hours correctly', () => {
const result = VodConnectionCardUtils.formatDuration(7325); // 2h 2m 5s
expect(result).toBe('2h 2m');
});
it('should return Unknown for zero seconds', () => {
const result = VodConnectionCardUtils.formatDuration(0);
expect(result).toBe('Unknown');
});
it('should return Unknown for null', () => {
const result = VodConnectionCardUtils.formatDuration(null);
expect(result).toBe('Unknown');
});
it('should return Unknown for undefined', () => {
const result = VodConnectionCardUtils.formatDuration(undefined);
expect(result).toBe('Unknown');
});
});
describe('formatTime', () => {
it('should format time with hours when hours > 0', () => {
const result = VodConnectionCardUtils.formatTime(3665); // 1:01:05
expect(result).toBe('1:01:05');
});
it('should format time without hours when less than an hour', () => {
const result = VodConnectionCardUtils.formatTime(125); // 2:05
expect(result).toBe('2:05');
});
it('should pad minutes and seconds with zeros', () => {
const result = VodConnectionCardUtils.formatTime(3605); // 1:00:05
expect(result).toBe('1:00:05');
});
it('should handle only seconds', () => {
const result = VodConnectionCardUtils.formatTime(45); // 0:45
expect(result).toBe('0:45');
});
it('should return 0:00 for zero seconds', () => {
const result = VodConnectionCardUtils.formatTime(0);
expect(result).toBe('0:00');
});
it('should return 0:00 for null', () => {
const result = VodConnectionCardUtils.formatTime(null);
expect(result).toBe('0:00');
});
it('should return 0:00 for undefined', () => {
const result = VodConnectionCardUtils.formatTime(undefined);
expect(result).toBe('0:00');
});
});
describe('getMovieDisplayTitle', () => {
it('should return content_name from vodContent', () => {
const vodContent = { content_name: 'The Matrix' };

View file

@ -0,0 +1,100 @@
import API from '../../api.js';
import { formatBytes } from '../networkUtils.js';
import { formatDuration } from '../dateTimeUtils.js';
const categoryMapping = {
basic: [
'resolution',
'video_codec',
'source_fps',
'audio_codec',
'audio_channels',
],
video: [
'video_bitrate',
'pixel_format',
'width',
'height',
'aspect_ratio',
'frame_rate',
],
audio: [
'audio_bitrate',
'sample_rate',
'audio_format',
'audio_channels_layout',
],
technical: [
'stream_type',
'container_format',
'duration',
'file_size',
'ffmpeg_output_bitrate',
'input_bitrate',
],
other: [],
};
export const categorizeStreamStats = (stats) => {
if (!stats)
return { basic: {}, video: {}, audio: {}, technical: {}, other: {} };
const categories = {
basic: {},
video: {},
audio: {},
technical: {},
other: {},
};
Object.entries(stats).forEach(([key, value]) => {
let categorized = false;
for (const [category, keys] of Object.entries(categoryMapping)) {
if (keys.includes(key)) {
categories[category][key] = value;
categorized = true;
break;
}
}
if (!categorized) {
categories.other[key] = value;
}
});
return categories;
};
export const formatStatValue = (key, value) => {
if (value === null || value === undefined) return 'N/A';
switch (key) {
case 'video_bitrate':
case 'audio_bitrate':
case 'ffmpeg_output_bitrate':
return `${value} kbps`;
case 'source_fps':
case 'frame_rate':
return `${value} fps`;
case 'sample_rate':
return `${value} Hz`;
case 'file_size':
return typeof value === 'number' ? formatBytes(value) : value;
case 'duration':
return typeof value === 'number'
? formatDuration(value, { alwaysShowHours: true })
: value;
default:
return value.toString();
}
};
export const formatStatKey = (key) => {
return key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
};
export const getChannelStreamStats = (channelId, since, ids) => {
return API.getChannelStreamStats(channelId, since, ids);
};
export const reorderChannelStreams = (channelId, streamIds) => {
return API.reorderChannelStreams(channelId, streamIds);
};

View file

@ -0,0 +1,6 @@
import API from '../../api.js';
export const updateOutputProfile = (values) => API.updateOutputProfile(values);
export const deleteOutputProfile = async (id) => {
await API.deleteOutputProfile(id);
};

View file

@ -0,0 +1,288 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as ChannelTableStreamsUtils from '../ChannelTableStreamsUtils';
// ── Dependency mocks ────────────────────────────────────────────────────────
vi.mock('../../../api.js', () => ({
default: {
getChannelStreamStats: vi.fn(),
reorderChannelStreams: vi.fn(),
},
}));
vi.mock('../../networkUtils.js', () => ({
formatBytes: vi.fn((bytes) => `${bytes} B`),
}));
vi.mock('../../dateTimeUtils.js', () => ({
formatDuration: vi.fn((seconds) => `duration-${seconds}`),
}));
import API from '../../../api.js';
import { formatBytes } from '../../networkUtils.js';
import { formatDuration } from '../../dateTimeUtils.js';
describe('ChannelTableStreamsUtils', () => {
beforeEach(() => vi.clearAllMocks());
// ── categorizeStreamStats ───────────────────────────────────────────────────
describe('categorizeStreamStats', () => {
it('returns empty categories for null input', () => {
expect(ChannelTableStreamsUtils.categorizeStreamStats(null)).toEqual({
basic: {},
video: {},
audio: {},
technical: {},
other: {},
});
});
it('returns empty categories for undefined input', () => {
expect(ChannelTableStreamsUtils.categorizeStreamStats(undefined)).toEqual(
{
basic: {},
video: {},
audio: {},
technical: {},
other: {},
}
);
});
it('categorizes basic fields correctly', () => {
const stats = {
resolution: '1920x1080',
video_codec: 'h264',
source_fps: 30,
audio_codec: 'aac',
audio_channels: 2,
};
const result = ChannelTableStreamsUtils.categorizeStreamStats(stats);
expect(result.basic).toEqual(stats);
expect(result.video).toEqual({});
expect(result.audio).toEqual({});
expect(result.technical).toEqual({});
expect(result.other).toEqual({});
});
it('categorizes video fields correctly', () => {
const stats = {
video_bitrate: 5000,
pixel_format: 'yuv420p',
width: 1920,
height: 1080,
aspect_ratio: '16:9',
frame_rate: 29.97,
};
const result = ChannelTableStreamsUtils.categorizeStreamStats(stats);
expect(result.video).toEqual(stats);
expect(result.basic).toEqual({});
});
it('categorizes audio fields correctly', () => {
const stats = {
audio_bitrate: 192,
sample_rate: 48000,
audio_format: 'flac',
audio_channels_layout: 'stereo',
};
const result = ChannelTableStreamsUtils.categorizeStreamStats(stats);
expect(result.audio).toEqual(stats);
});
it('categorizes technical fields correctly', () => {
const stats = {
stream_type: 'video',
container_format: 'mpegts',
duration: 3600,
file_size: 1024000,
ffmpeg_output_bitrate: 8000,
input_bitrate: 7500,
};
const result = ChannelTableStreamsUtils.categorizeStreamStats(stats);
expect(result.technical).toEqual(stats);
});
it('places unknown fields in other', () => {
const stats = { custom_field: 'value', another_field: 42 };
const result = ChannelTableStreamsUtils.categorizeStreamStats(stats);
expect(result.other).toEqual(stats);
});
it('handles mixed fields across categories', () => {
const stats = {
resolution: '1080p',
audio_bitrate: 192,
duration: 7200,
unknown_key: 'test',
};
const result = ChannelTableStreamsUtils.categorizeStreamStats(stats);
expect(result.basic.resolution).toBe('1080p');
expect(result.audio.audio_bitrate).toBe(192);
expect(result.technical.duration).toBe(7200);
expect(result.other.unknown_key).toBe('test');
});
it('returns empty categories for empty stats object', () => {
const result = ChannelTableStreamsUtils.categorizeStreamStats({});
expect(result).toEqual({
basic: {},
video: {},
audio: {},
technical: {},
other: {},
});
});
});
// ── formatStatValue ─────────────────────────────────────────────────────────
describe('formatStatValue', () => {
it('returns "N/A" for null value', () => {
expect(ChannelTableStreamsUtils.formatStatValue('resolution', null)).toBe(
'N/A'
);
});
it('returns "N/A" for undefined value', () => {
expect(
ChannelTableStreamsUtils.formatStatValue('resolution', undefined)
).toBe('N/A');
});
it('formats video_bitrate with kbps', () => {
expect(
ChannelTableStreamsUtils.formatStatValue('video_bitrate', 5000)
).toBe('5000 kbps');
});
it('formats audio_bitrate with kbps', () => {
expect(
ChannelTableStreamsUtils.formatStatValue('audio_bitrate', 192)
).toBe('192 kbps');
});
it('formats ffmpeg_output_bitrate with kbps', () => {
expect(
ChannelTableStreamsUtils.formatStatValue('ffmpeg_output_bitrate', 8000)
).toBe('8000 kbps');
});
it('formats source_fps with fps', () => {
expect(ChannelTableStreamsUtils.formatStatValue('source_fps', 30)).toBe(
'30 fps'
);
});
it('formats frame_rate with fps', () => {
expect(
ChannelTableStreamsUtils.formatStatValue('frame_rate', 29.97)
).toBe('29.97 fps');
});
it('formats sample_rate with Hz', () => {
expect(
ChannelTableStreamsUtils.formatStatValue('sample_rate', 48000)
).toBe('48000 Hz');
});
it('formats file_size using formatBytes when numeric', () => {
vi.mocked(formatBytes).mockReturnValue('1.0 MB');
expect(
ChannelTableStreamsUtils.formatStatValue('file_size', 1048576)
).toBe('1.0 MB');
expect(formatBytes).toHaveBeenCalledWith(1048576);
});
it('returns raw value for file_size when not numeric', () => {
expect(
ChannelTableStreamsUtils.formatStatValue('file_size', 'unknown')
).toBe('unknown');
expect(formatBytes).not.toHaveBeenCalled();
});
it('formats duration using formatDuration with alwaysShowHours when numeric', () => {
vi.mocked(formatDuration).mockReturnValue('01:00:00');
expect(ChannelTableStreamsUtils.formatStatValue('duration', 3600)).toBe(
'01:00:00'
);
expect(formatDuration).toHaveBeenCalledWith(3600, {
alwaysShowHours: true,
});
});
it('returns raw value for duration when not numeric', () => {
expect(ChannelTableStreamsUtils.formatStatValue('duration', 'live')).toBe(
'live'
);
expect(formatDuration).not.toHaveBeenCalled();
});
it('converts default values to string', () => {
expect(
ChannelTableStreamsUtils.formatStatValue('resolution', '1920x1080')
).toBe('1920x1080');
});
it('converts numeric default values to string', () => {
expect(ChannelTableStreamsUtils.formatStatValue('width', 1920)).toBe(
'1920'
);
});
});
// ── formatStatKey ───────────────────────────────────────────────────────────
describe('formatStatKey', () => {
it('replaces underscores with spaces and title-cases each word', () => {
expect(ChannelTableStreamsUtils.formatStatKey('video_bitrate')).toBe(
'Video Bitrate'
);
});
it('handles single word keys', () => {
expect(ChannelTableStreamsUtils.formatStatKey('resolution')).toBe(
'Resolution'
);
});
it('handles multi-word keys', () => {
expect(
ChannelTableStreamsUtils.formatStatKey('audio_channels_layout')
).toBe('Audio Channels Layout');
});
it('handles already-capitalized keys', () => {
expect(ChannelTableStreamsUtils.formatStatKey('FPS')).toBe('FPS');
});
});
// ── API wrappers ────────────────────────────────────────────────────────────
describe('getChannelStreamStats', () => {
it('calls API.getChannelStreamStats with correct args', () => {
const mockReturn = Promise.resolve({ data: [] });
vi.mocked(API.getChannelStreamStats).mockReturnValue(mockReturn);
const result = ChannelTableStreamsUtils.getChannelStreamStats(
'ch-1',
'2024-01-01',
[1, 2]
);
expect(API.getChannelStreamStats).toHaveBeenCalledWith(
'ch-1',
'2024-01-01',
[1, 2]
);
expect(result).toBe(mockReturn);
});
});
describe('reorderChannelStreams', () => {
it('calls API.reorderChannelStreams with correct args', () => {
const mockReturn = Promise.resolve(undefined);
vi.mocked(API.reorderChannelStreams).mockReturnValue(mockReturn);
const result = ChannelTableStreamsUtils.reorderChannelStreams(
'ch-1',
[3, 1, 2]
);
expect(API.reorderChannelStreams).toHaveBeenCalledWith('ch-1', [3, 1, 2]);
expect(result).toBe(mockReturn);
});
});
});

View file

@ -0,0 +1,611 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as ChannelsTableUtils from '../ChannelsTableUtils';
// ── Dependency mocks ────────────────────────────────────────────────────────
vi.mock('../../forms/ChannelUtils.js', () => ({
normalizeFieldValue: vi.fn((field, value) => {
if (value === '' || value === null || value === undefined) return null;
if (field === 'channel_number') return parseFloat(value);
if (['channel_group_id', 'logo_id', 'epg_data_id', 'stream_profile_id'].includes(field)) {
return parseInt(value, 10);
}
return value;
}),
OVERRIDABLE_FIELDS: [
'name',
'channel_number',
'channel_group_id',
'logo_id',
'tvg_id',
'tvc_guide_stationid',
'epg_data_id',
'stream_profile_id',
],
}));
vi.mock('../../../api.js', () => ({
default: {
reorderChannel: vi.fn(),
deleteChannel: vi.fn(),
deleteChannels: vi.fn(),
queryChannels: vi.fn(),
getAllChannelIds: vi.fn(),
updateProfileChannels: vi.fn(),
updateProfileChannel: vi.fn(),
addChannelProfile: vi.fn(),
deleteChannelProfile: vi.fn(),
},
}));
import API from '../../../api.js';
describe('ChannelsTableUtils', () => {
// ── buildInlinePatch ────────────────────────────────────────────────────────
describe('buildInlinePatch', () => {
describe('manual channel (auto_created = false)', () => {
const row = { id: 1, auto_created: false, name: 'CNN' };
it('returns direct patch for a string field', () => {
expect(ChannelsTableUtils.buildInlinePatch(row, 'name', 'BBC')).toEqual(
{
id: 1,
name: 'BBC',
}
);
});
it('normalizes empty string to null', () => {
expect(ChannelsTableUtils.buildInlinePatch(row, 'name', '')).toEqual({
id: 1,
name: null,
});
});
it('normalizes undefined to null', () => {
expect(
ChannelsTableUtils.buildInlinePatch(row, 'name', undefined)
).toEqual({
id: 1,
name: null,
});
});
it('passes through numeric values', () => {
expect(
ChannelsTableUtils.buildInlinePatch(row, 'channel_number', 5)
).toEqual({
id: 1,
channel_number: 5,
});
});
});
describe('auto-synced channel (auto_created = true)', () => {
const row = {
id: 2,
auto_created: true,
name: 'ESPN',
channel_number: 10,
epg_data_id: 99,
};
it('returns override patch when value differs from provider', () => {
const result = ChannelsTableUtils.buildInlinePatch(
row,
'name',
'ESPN HD'
);
expect(result).toEqual({
id: 2,
override: { name: 'ESPN HD' },
});
});
it('clears override when new value matches provider value', () => {
const result = ChannelsTableUtils.buildInlinePatch(row, 'name', 'ESPN');
expect(result).toEqual({
id: 2,
override: { name: null },
});
});
it('returns override patch for channel_number field', () => {
const result = ChannelsTableUtils.buildInlinePatch(
row,
'channel_number',
20
);
expect(result).toEqual({
id: 2,
override: { channel_number: 20 },
});
});
it('clears override for channel_number when value matches provider', () => {
const result = ChannelsTableUtils.buildInlinePatch(
row,
'channel_number',
10
);
expect(result).toEqual({
id: 2,
override: { channel_number: null },
});
});
it('uses direct patch for non-overridable field on auto-synced channel', () => {
const result = ChannelsTableUtils.buildInlinePatch(
row,
'some_other_field',
'value'
);
expect(result).toEqual({
id: 2,
some_other_field: 'value',
});
});
});
});
// ── getEpgOptions ───────────────────────────────────────────────────────────
describe('getEpgOptions', () => {
const epgs = {
1: { id: 1, name: 'EPG Alpha' },
2: { id: 2, name: 'EPG Beta' },
};
const tvgsById = {
10: { id: 10, tvg_id: 'cnn', name: 'CNN', epg_source: 1 },
11: { id: 11, tvg_id: 'bbc', name: 'BBC', epg_source: 2 },
12: { id: 12, tvg_id: 'espn', name: 'ESPN', epg_source: 1 },
};
it('includes "Not Assigned" as the first option', () => {
const options = ChannelsTableUtils.getEpgOptions(tvgsById, epgs);
expect(options[0]).toEqual({ value: 'null', label: 'Not Assigned' });
});
it('returns an option for each tvg entry', () => {
const options = ChannelsTableUtils.getEpgOptions(tvgsById, epgs);
expect(options).toHaveLength(4); // 1 null + 3 tvgs
});
it('formats label as "EPG Name | tvg_id | tvg name" when all present and name differs from tvg_id', () => {
const options = ChannelsTableUtils.getEpgOptions(tvgsById, epgs);
const cnn = options.find((o) => o.value === '10');
expect(cnn?.label).toBe('EPG Alpha | cnn | CNN');
});
it('omits tvg name from label when name equals tvg_id', () => {
const tvgs = {
10: { id: 10, tvg_id: 'CNN', name: 'CNN', epg_source: 1 },
};
const options = ChannelsTableUtils.getEpgOptions(tvgs, epgs);
const opt = options.find((o) => o.value === '10');
expect(opt?.label).toBe('EPG Alpha | CNN');
});
it('uses tvg name as label when no epg_source and no tvg_id', () => {
const tvgs = {
20: { id: 20, tvg_id: null, name: 'Standalone', epg_source: null },
};
const options = ChannelsTableUtils.getEpgOptions(tvgs, {});
const opt = options.find((o) => o.value === '20');
expect(opt?.label).toBe('Standalone');
});
it('falls back to "ID: {id}" when no name and no tvg_id', () => {
const tvgs = {
30: { id: 30, tvg_id: null, name: null, epg_source: null },
};
const options = ChannelsTableUtils.getEpgOptions(tvgs, {});
const opt = options.find((o) => o.value === '30');
expect(opt?.label).toBe('ID: 30');
});
it('sorts options by EPG source name then tvg_id', () => {
const options = ChannelsTableUtils.getEpgOptions(tvgsById, epgs);
// EPG Alpha entries (cnn, espn) should come before EPG Beta (bbc)
const labels = options.slice(1).map((o) => o.label);
const alphaCnn = labels.findIndex((l) => l.includes('cnn'));
const alphaEspn = labels.findIndex((l) => l.includes('espn'));
const betaBbc = labels.findIndex((l) => l.includes('bbc'));
expect(alphaCnn).toBeLessThan(betaBbc);
expect(alphaEspn).toBeLessThan(betaBbc);
});
it('returns only the null option for empty tvgsById', () => {
const options = ChannelsTableUtils.getEpgOptions({}, epgs);
expect(options).toHaveLength(1);
expect(options[0].value).toBe('null');
});
});
// ── getLogoOptions ──────────────────────────────────────────────────────────
describe('getLogoOptions', () => {
const logos = {
1: { id: 1, name: 'ABC Logo' },
2: { id: 2, name: 'NBC Logo' },
3: { id: 3, name: null },
};
it('includes "Default" as the first option', () => {
const options = ChannelsTableUtils.getLogoOptions(logos);
expect(options[0]).toEqual({
value: 'null',
label: 'Default',
logo: null,
});
});
it('returns an option for each logo', () => {
const options = ChannelsTableUtils.getLogoOptions(logos);
expect(options).toHaveLength(4); // 1 default + 3 logos
});
it('uses logo name as label', () => {
const options = ChannelsTableUtils.getLogoOptions(logos);
const abc = options.find((o) => o.value === '1');
expect(abc?.label).toBe('ABC Logo');
expect(abc?.logo).toEqual({ id: 1, name: 'ABC Logo' });
});
it('falls back to "Logo {id}" when name is null', () => {
const options = ChannelsTableUtils.getLogoOptions(logos);
const noName = options.find((o) => o.value === '3');
expect(noName?.label).toBe('Logo 3');
});
it('sorts logos by name', () => {
const options = ChannelsTableUtils.getLogoOptions(logos);
const names = options.slice(2).map((o) => o.label);
expect(names[0]).toBe('ABC Logo');
expect(names[1]).toBe('NBC Logo');
});
it('returns only the default option for empty logos', () => {
const options = ChannelsTableUtils.getLogoOptions({});
expect(options).toHaveLength(1);
});
});
// ── buildM3UUrl ─────────────────────────────────────────────────────────────
describe('buildM3UUrl', () => {
const baseUrl = 'http://localhost/output/m3u';
const defaults = {
cachedlogos: true,
direct: false,
tvg_id_source: 'channel_number',
output_format: '',
output_profile: '',
};
it('returns base URL with no params when all defaults', () => {
expect(ChannelsTableUtils.buildM3UUrl(defaults, baseUrl)).toBe(baseUrl);
});
it('appends cachedlogos=false when disabled', () => {
const result = ChannelsTableUtils.buildM3UUrl(
{ ...defaults, cachedlogos: false },
baseUrl
);
expect(result).toContain('cachedlogos=false');
});
it('appends direct=true when enabled', () => {
const result = ChannelsTableUtils.buildM3UUrl(
{ ...defaults, direct: true },
baseUrl
);
expect(result).toContain('direct=true');
});
it('appends tvg_id_source when not channel_number', () => {
const result = ChannelsTableUtils.buildM3UUrl(
{ ...defaults, tvg_id_source: 'tvg_id' },
baseUrl
);
expect(result).toContain('tvg_id_source=tvg_id');
});
it('does not append tvg_id_source when channel_number', () => {
const result = ChannelsTableUtils.buildM3UUrl(defaults, baseUrl);
expect(result).not.toContain('tvg_id_source');
});
it('appends output_format when set', () => {
const result = ChannelsTableUtils.buildM3UUrl(
{ ...defaults, output_format: 'mpegts' },
baseUrl
);
expect(result).toContain('output_format=mpegts');
});
it('appends output_profile when set', () => {
const result = ChannelsTableUtils.buildM3UUrl(
{ ...defaults, output_profile: '3' },
baseUrl
);
expect(result).toContain('output_profile=3');
});
});
// ── buildEPGUrl ─────────────────────────────────────────────────────────────
describe('buildEPGUrl', () => {
const baseUrl = 'http://localhost/output/epg';
const defaults = {
cachedlogos: true,
tvg_id_source: 'channel_number',
days: 0,
prev_days: 0,
};
it('returns base URL with no params when all defaults', () => {
expect(ChannelsTableUtils.buildEPGUrl(defaults, baseUrl)).toBe(baseUrl);
});
it('appends cachedlogos=false when disabled', () => {
const result = ChannelsTableUtils.buildEPGUrl(
{ ...defaults, cachedlogos: false },
baseUrl
);
expect(result).toContain('cachedlogos=false');
});
it('appends tvg_id_source when not channel_number', () => {
const result = ChannelsTableUtils.buildEPGUrl(
{ ...defaults, tvg_id_source: 'gracenote' },
baseUrl
);
expect(result).toContain('tvg_id_source=gracenote');
});
it('appends days when > 0', () => {
const result = ChannelsTableUtils.buildEPGUrl(
{ ...defaults, days: 7 },
baseUrl
);
expect(result).toContain('days=7');
});
it('does not append days when 0', () => {
const result = ChannelsTableUtils.buildEPGUrl(defaults, baseUrl);
expect(result).not.toContain('days');
});
it('appends prev_days when > 0', () => {
const result = ChannelsTableUtils.buildEPGUrl(
{ ...defaults, prev_days: 3 },
baseUrl
);
expect(result).toContain('prev_days=3');
});
});
// ── buildHDHRUrl ────────────────────────────────────────────────────────────
describe('buildHDHRUrl', () => {
it('returns hdhrUrl unchanged when no output profile', () => {
expect(ChannelsTableUtils.buildHDHRUrl('', 'http://localhost/hdhr')).toBe(
'http://localhost/hdhr'
);
});
it('appends output_profile segment when profile id provided', () => {
expect(
ChannelsTableUtils.buildHDHRUrl('2', 'http://localhost/hdhr')
).toBe('http://localhost/hdhr/output_profile/2');
});
it('strips trailing slash before appending', () => {
expect(
ChannelsTableUtils.buildHDHRUrl('1', 'http://localhost/hdhr/')
).toBe('http://localhost/hdhr/output_profile/1');
});
});
// ── buildFetchParams ────────────────────────────────────────────────────────
describe('buildFetchParams', () => {
const defaults = {
pagination: { pageIndex: 0, pageSize: 50 },
sorting: [],
debouncedFilters: {},
selectedProfileId: '0',
showDisabled: false,
showOnlyStreamlessChannels: false,
showOnlyStaleChannels: false,
showOnlyOverriddenChannels: false,
visibilityFilter: 'active',
};
it('always includes page, page_size, and include_streams', () => {
const params = ChannelsTableUtils.buildFetchParams(defaults);
expect(params.get('page')).toBe('1');
expect(params.get('page_size')).toBe('50');
expect(params.get('include_streams')).toBe('true');
});
it('increments page by 1 from pageIndex', () => {
const params = ChannelsTableUtils.buildFetchParams({
...defaults,
pagination: { pageIndex: 2, pageSize: 25 },
});
expect(params.get('page')).toBe('3');
expect(params.get('page_size')).toBe('25');
});
it('does not include channel_profile_id when selectedProfileId is "0"', () => {
const params = ChannelsTableUtils.buildFetchParams(defaults);
expect(params.get('channel_profile_id')).toBeNull();
});
it('includes channel_profile_id when selectedProfileId is not "0"', () => {
const params = ChannelsTableUtils.buildFetchParams({
...defaults,
selectedProfileId: '3',
});
expect(params.get('channel_profile_id')).toBe('3');
});
it('includes show_disabled when true', () => {
const params = ChannelsTableUtils.buildFetchParams({
...defaults,
showDisabled: true,
});
expect(params.get('show_disabled')).toBe('true');
});
it('includes only_streamless when true', () => {
const params = ChannelsTableUtils.buildFetchParams({
...defaults,
showOnlyStreamlessChannels: true,
});
expect(params.get('only_streamless')).toBe('true');
});
it('includes only_stale when true', () => {
const params = ChannelsTableUtils.buildFetchParams({
...defaults,
showOnlyStaleChannels: true,
});
expect(params.get('only_stale')).toBe('true');
});
it('includes only_has_overrides when true', () => {
const params = ChannelsTableUtils.buildFetchParams({
...defaults,
showOnlyOverriddenChannels: true,
});
expect(params.get('only_has_overrides')).toBe('true');
});
it('does not include visibility_filter when "active"', () => {
const params = ChannelsTableUtils.buildFetchParams(defaults);
expect(params.get('visibility_filter')).toBeNull();
});
it('includes visibility_filter when not "active"', () => {
const params = ChannelsTableUtils.buildFetchParams({
...defaults,
visibilityFilter: 'hidden',
});
expect(params.get('visibility_filter')).toBe('hidden');
});
it('includes ordering with ascending sort', () => {
const params = ChannelsTableUtils.buildFetchParams({
...defaults,
sorting: [{ id: 'name', desc: false }],
});
expect(params.get('ordering')).toBe('name');
});
it('includes ordering with descending sort', () => {
const params = ChannelsTableUtils.buildFetchParams({
...defaults,
sorting: [{ id: 'name', desc: true }],
});
expect(params.get('ordering')).toBe('-name');
});
it('maps channel_group sort field to channel_group__name', () => {
const params = ChannelsTableUtils.buildFetchParams({
...defaults,
sorting: [{ id: 'channel_group', desc: false }],
});
expect(params.get('ordering')).toBe('channel_group__name');
});
it('maps epg sort field to epg_data__name', () => {
const params = ChannelsTableUtils.buildFetchParams({
...defaults,
sorting: [{ id: 'epg', desc: false }],
});
expect(params.get('ordering')).toBe('epg_data__name');
});
it('applies string debounced filters', () => {
const params = ChannelsTableUtils.buildFetchParams({
...defaults,
debouncedFilters: { name: 'CNN' },
});
expect(params.get('name')).toBe('CNN');
});
it('applies array debounced filters joined by comma', () => {
const params = ChannelsTableUtils.buildFetchParams({
...defaults,
debouncedFilters: { channel_group: ['News', 'Sports'] },
});
expect(params.get('channel_group')).toBe('News,Sports');
});
it('converts null values in array filters to "null" string', () => {
const params = ChannelsTableUtils.buildFetchParams({
...defaults,
debouncedFilters: { epg: [null, 'SomeEPG'] },
});
expect(params.get('epg')).toBe('null,SomeEPG');
});
it('skips falsy debounced filter values', () => {
const params = ChannelsTableUtils.buildFetchParams({
...defaults,
debouncedFilters: { name: '' },
});
expect(params.get('name')).toBeNull();
});
});
// ── API wrapper functions ───────────────────────────────────────────────────
describe('API wrappers', () => {
beforeEach(() => vi.clearAllMocks());
it('reorderChannel calls API.reorderChannel', () => {
ChannelsTableUtils.reorderChannel(1, 2);
expect(API.reorderChannel).toHaveBeenCalledWith(1, 2);
});
it('deleteChannel calls API.deleteChannel', () => {
ChannelsTableUtils.deleteChannel(5);
expect(API.deleteChannel).toHaveBeenCalledWith(5);
});
it('deleteChannels calls API.deleteChannels', () => {
ChannelsTableUtils.deleteChannels([1, 2, 3]);
expect(API.deleteChannels).toHaveBeenCalledWith([1, 2, 3]);
});
it('queryChannels calls API.queryChannels', () => {
const params = new URLSearchParams();
ChannelsTableUtils.queryChannels(params);
expect(API.queryChannels).toHaveBeenCalledWith(params);
});
it('getAllChannelIds calls API.getAllChannelIds', () => {
const params = new URLSearchParams();
ChannelsTableUtils.getAllChannelIds(params);
expect(API.getAllChannelIds).toHaveBeenCalledWith(params);
});
it('updateProfileChannels calls API.updateProfileChannels', () => {
ChannelsTableUtils.updateProfileChannels([1, 2], '3', true);
expect(API.updateProfileChannels).toHaveBeenCalledWith([1, 2], '3', true);
});
it('updateProfileChannel calls API.updateProfileChannel', () => {
ChannelsTableUtils.updateProfileChannel(1, '3', false);
expect(API.updateProfileChannel).toHaveBeenCalledWith(1, '3', false);
});
it('addChannelProfile calls API.addChannelProfile', () => {
const values = { name: 'Test Profile' };
ChannelsTableUtils.addChannelProfile(values);
expect(API.addChannelProfile).toHaveBeenCalledWith(values);
});
it('deleteChannelProfile calls API.deleteChannelProfile', () => {
ChannelsTableUtils.deleteChannelProfile(4);
expect(API.deleteChannelProfile).toHaveBeenCalledWith(4);
});
});
});

View file

@ -0,0 +1,238 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as EPGsTableUtils from '../EPGsTableUtils';
// ── Dependency mocks ────────────────────────────────────────────────────────
vi.mock('../../../api.js', () => ({
default: {
updateEPG: vi.fn(),
deleteEPG: vi.fn(),
refreshEPG: vi.fn(),
},
}));
import API from '../../../api.js';
describe('EPGsTableUtils', () => {
beforeEach(() => vi.clearAllMocks());
// ── formatStatusText ────────────────────────────────────────────────────────
describe('formatStatusText', () => {
it('returns "Unknown" for null', () => {
expect(EPGsTableUtils.formatStatusText(null)).toBe('Unknown');
});
it('returns "Unknown" for undefined', () => {
expect(EPGsTableUtils.formatStatusText(undefined)).toBe('Unknown');
});
it('returns "Unknown" for empty string', () => {
expect(EPGsTableUtils.formatStatusText('')).toBe('Unknown');
});
it('capitalizes first letter and lowercases the rest', () => {
expect(EPGsTableUtils.formatStatusText('idle')).toBe('Idle');
expect(EPGsTableUtils.formatStatusText('success')).toBe('Success');
expect(EPGsTableUtils.formatStatusText('error')).toBe('Error');
});
it('handles already-uppercase input', () => {
expect(EPGsTableUtils.formatStatusText('FETCHING')).toBe('Fetching');
});
it('handles mixed case input', () => {
expect(EPGsTableUtils.formatStatusText('pArSiNg')).toBe('Parsing');
});
});
// ── updateEpg ───────────────────────────────────────────────────────────────
describe('updateEpg', () => {
it('calls API.updateEPG with merged values and id', async () => {
API.updateEPG.mockResolvedValue({ id: 1 });
const epg = { id: 1, name: 'Old Name' };
await EPGsTableUtils.updateEpg({ is_active: false }, epg, false);
expect(API.updateEPG).toHaveBeenCalledWith(
{ is_active: false, id: 1 },
false
);
});
it('passes isToggle=true to API.updateEPG', async () => {
API.updateEPG.mockResolvedValue({ id: 2 });
const epg = { id: 2 };
await EPGsTableUtils.updateEpg({ is_active: true }, epg, true);
expect(API.updateEPG).toHaveBeenCalledWith(
{ is_active: true, id: 2 },
true
);
});
it('returns the API response', async () => {
const mockResponse = { id: 3, name: 'Updated' };
API.updateEPG.mockResolvedValue(mockResponse);
const result = await EPGsTableUtils.updateEpg({}, { id: 3 }, false);
expect(result).toBe(mockResponse);
});
it('propagates API errors', async () => {
API.updateEPG.mockRejectedValue(new Error('Network error'));
await expect(
EPGsTableUtils.updateEpg({}, { id: 1 }, false)
).rejects.toThrow('Network error');
});
});
// ── deleteEpg ───────────────────────────────────────────────────────────────
describe('deleteEpg', () => {
it('calls API.deleteEPG with the correct id', () => {
const mockReturn = Promise.resolve(undefined);
API.deleteEPG.mockReturnValue(mockReturn);
const result = EPGsTableUtils.deleteEpg(5);
expect(API.deleteEPG).toHaveBeenCalledWith(5);
expect(result).toBe(mockReturn);
});
});
// ── refreshEpg ──────────────────────────────────────────────────────────────
describe('refreshEpg', () => {
it('calls API.refreshEPG with the correct id', () => {
const mockReturn = Promise.resolve(undefined);
API.refreshEPG.mockReturnValue(mockReturn);
const result = EPGsTableUtils.refreshEpg(7);
expect(API.refreshEPG).toHaveBeenCalledWith(7, expect.anything());
expect(result).toBe(mockReturn);
});
});
// ── getProgressLabel ────────────────────────────────────────────────────────
describe('getProgressLabel', () => {
it('returns "Downloading" for downloading action', () => {
expect(EPGsTableUtils.getProgressLabel('downloading')).toBe(
'Downloading'
);
});
it('returns "Extracting" for extracting action', () => {
expect(EPGsTableUtils.getProgressLabel('extracting')).toBe('Extracting');
});
it('returns "Parsing Channels" for parsing_channels action', () => {
expect(EPGsTableUtils.getProgressLabel('parsing_channels')).toBe(
'Parsing Channels'
);
});
it('returns "Parsing Programs" for parsing_programs action', () => {
expect(EPGsTableUtils.getProgressLabel('parsing_programs')).toBe(
'Parsing Programs'
);
});
it('returns null for unknown action', () => {
expect(EPGsTableUtils.getProgressLabel('unknown_action')).toBeNull();
});
it('returns null for null action', () => {
expect(EPGsTableUtils.getProgressLabel(null)).toBeNull();
});
it('returns null for undefined action', () => {
expect(EPGsTableUtils.getProgressLabel(undefined)).toBeNull();
});
});
// ── getProgressInfo ─────────────────────────────────────────────────────────
describe('getProgressInfo', () => {
it('returns message when progress.message is set', () => {
expect(
EPGsTableUtils.getProgressInfo({ message: 'Loading data...' })
).toBe('Loading data...');
});
it('returns programs/channels string when processed and channels are set', () => {
expect(
EPGsTableUtils.getProgressInfo({ processed: 1500, channels: 42 })
).toBe('1,500 programs for 42 channels');
});
it('prefers message over processed/channels', () => {
expect(
EPGsTableUtils.getProgressInfo({
message: 'Custom message',
processed: 100,
channels: 5,
})
).toBe('Custom message');
});
it('returns processed/total string when processed and total are set without channels', () => {
expect(
EPGsTableUtils.getProgressInfo({ processed: 250, total: 1000 })
).toBe('250 / 1,000');
});
it('returns null when no relevant fields are present', () => {
expect(EPGsTableUtils.getProgressInfo({})).toBeNull();
});
it('returns null when only processed is set without channels or total', () => {
expect(EPGsTableUtils.getProgressInfo({ processed: 100 })).toBeNull();
});
it('formats large numbers with locale separators', () => {
const result = EPGsTableUtils.getProgressInfo({
processed: 1000000,
total: 5000000,
});
expect(result).toBe('1,000,000 / 5,000,000');
});
});
// ── getSortedEpgs ───────────────────────────────────────────────────────────
describe('getSortedEpgs', () => {
const epgs = [
{ id: 1, name: 'Zebra EPG', is_active: true },
{ id: 2, name: 'Alpha EPG', is_active: false },
{ id: 3, name: 'Middle EPG', is_active: true },
];
it('sorts ascending when compareDesc is false', () => {
const result = EPGsTableUtils.getSortedEpgs(
[...epgs],
'is_active',
false
);
// active=false comes first in ascending (false < true)
expect(result[0].id).toBe(2);
});
it('sorts descending when compareDesc is true', () => {
const result = EPGsTableUtils.getSortedEpgs([...epgs], 'is_active', true);
// active=true comes first in descending
expect(result[0].is_active).toBe(true);
});
it('returns items in original relative order when values are equal', () => {
const items = [
{ id: 1, name: 'Same', is_active: true },
{ id: 2, name: 'Same', is_active: true },
];
const result = EPGsTableUtils.getSortedEpgs([...items], 'name', false);
expect(result[0].id).toBe(1);
expect(result[1].id).toBe(2);
});
it('sorts by name field ascending', () => {
const result = EPGsTableUtils.getSortedEpgs([...epgs], 'name', false);
expect(result[0].name).toBe('Alpha EPG');
});
it('sorts by name field descending', () => {
const result = EPGsTableUtils.getSortedEpgs([...epgs], 'name', true);
expect(result[0].name).toBe('Zebra EPG');
});
it('returns an empty array for empty input', () => {
expect(EPGsTableUtils.getSortedEpgs([], 'name', false)).toEqual([]);
});
});
});

View file

@ -0,0 +1,220 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as LogosTableUtils from '../LogosTableUtils';
// ── Dependency mocks ────────────────────────────────────────────────────────
vi.mock('../../../api.js', () => ({
default: {
deleteLogo: vi.fn(),
deleteLogos: vi.fn(),
cleanupUnusedLogos: vi.fn(),
},
}));
import API from '../../../api.js';
describe('LogosTableUtils', () => {
beforeEach(() => vi.clearAllMocks());
// ── getFilteredLogos ────────────────────────────────────────────────────────
describe('getFilteredLogos', () => {
const logos = {
1: { id: 1, name: 'ABC Logo', is_used: true },
2: { id: 2, name: 'NBC Logo', is_used: false },
3: { id: 3, name: 'CBS Logo', is_used: true },
4: { id: 4, name: 'abc sports', is_used: false },
};
it('returns all logos sorted by id when no filters applied', () => {
const result = LogosTableUtils.getFilteredLogos(logos, '', 'all');
expect(result.map((l) => l.id)).toEqual([1, 2, 3, 4]);
});
it('returns empty array for null logos', () => {
expect(LogosTableUtils.getFilteredLogos(null, '', 'all')).toEqual([]);
});
it('returns empty array for undefined logos', () => {
expect(LogosTableUtils.getFilteredLogos(undefined, '', 'all')).toEqual(
[]
);
});
it('filters by name case-insensitively', () => {
const result = LogosTableUtils.getFilteredLogos(logos, 'abc', 'all');
expect(result).toHaveLength(2);
expect(result.map((l) => l.id)).toEqual([1, 4]);
});
it('filters by exact name match', () => {
const result = LogosTableUtils.getFilteredLogos(logos, 'NBC Logo', 'all');
expect(result).toHaveLength(1);
expect(result[0].id).toBe(2);
});
it('returns empty array when name filter matches nothing', () => {
const result = LogosTableUtils.getFilteredLogos(logos, 'xyz', 'all');
expect(result).toHaveLength(0);
});
it('filters to used logos only when filtersUsed is "used"', () => {
const result = LogosTableUtils.getFilteredLogos(logos, '', 'used');
expect(result.every((l) => l.is_used)).toBe(true);
expect(result).toHaveLength(2);
});
it('filters to unused logos only when filtersUsed is "unused"', () => {
const result = LogosTableUtils.getFilteredLogos(logos, '', 'unused');
expect(result.every((l) => !l.is_used)).toBe(true);
expect(result).toHaveLength(2);
});
it('applies name filter and used filter together', () => {
const result = LogosTableUtils.getFilteredLogos(logos, 'abc', 'used');
expect(result).toHaveLength(1);
expect(result[0].id).toBe(1);
});
it('applies name filter and unused filter together', () => {
const result = LogosTableUtils.getFilteredLogos(logos, 'abc', 'unused');
expect(result).toHaveLength(1);
expect(result[0].id).toBe(4);
});
it('sorts results by id ascending', () => {
const result = LogosTableUtils.getFilteredLogos(logos, '', 'all');
expect(result).toHaveLength(4);
for (let i = 1; i < result.length; i++) {
expect(result[i].id).toBeGreaterThan(result[i - 1].id);
}
});
it('returns empty array when logos object is empty', () => {
expect(LogosTableUtils.getFilteredLogos({}, '', 'all')).toEqual([]);
});
it('does not filter when debouncedNameFilter is empty string', () => {
const result = LogosTableUtils.getFilteredLogos(logos, '', 'all');
expect(result).toHaveLength(4);
});
it('does not apply usage filter for unrecognized filtersUsed value', () => {
const result = LogosTableUtils.getFilteredLogos(logos, '', 'all');
expect(result).toHaveLength(4);
});
});
// ── deleteLogo ──────────────────────────────────────────────────────────────
describe('deleteLogo', () => {
it('calls API.deleteLogo with id and deleteFile', () => {
const mockReturn = Promise.resolve(undefined);
API.deleteLogo.mockReturnValue(mockReturn);
const result = LogosTableUtils.deleteLogo(5, true);
expect(API.deleteLogo).toHaveBeenCalledWith(5, true);
expect(result).toBe(mockReturn);
});
it('passes deleteFile=false correctly', () => {
LogosTableUtils.deleteLogo(3, false);
expect(API.deleteLogo).toHaveBeenCalledWith(3, false);
});
});
// ── deleteLogos ─────────────────────────────────────────────────────────────
describe('deleteLogos', () => {
it('calls API.deleteLogos with ids and deleteFiles', () => {
const mockReturn = Promise.resolve(undefined);
API.deleteLogos.mockReturnValue(mockReturn);
const result = LogosTableUtils.deleteLogos([1, 2, 3], true);
expect(API.deleteLogos).toHaveBeenCalledWith([1, 2, 3], true);
expect(result).toBe(mockReturn);
});
it('passes deleteFiles=false correctly', () => {
LogosTableUtils.deleteLogos([4, 5], false);
expect(API.deleteLogos).toHaveBeenCalledWith([4, 5], false);
});
});
// ── cleanupUnusedLogos ──────────────────────────────────────────────────────
describe('cleanupUnusedLogos', () => {
it('calls API.cleanupUnusedLogos with deleteFiles=true', () => {
const mockReturn = Promise.resolve(undefined);
API.cleanupUnusedLogos.mockReturnValue(mockReturn);
const result = LogosTableUtils.cleanupUnusedLogos(true);
expect(API.cleanupUnusedLogos).toHaveBeenCalledWith(true);
expect(result).toBe(mockReturn);
});
it('calls API.cleanupUnusedLogos with deleteFiles=false', () => {
LogosTableUtils.cleanupUnusedLogos(false);
expect(API.cleanupUnusedLogos).toHaveBeenCalledWith(false);
});
});
// ── generateUsageLabel ──────────────────────────────────────────────────────
describe('generateUsageLabel', () => {
describe('single type — channels only', () => {
it('returns singular "channel" for 1 channel', () => {
const names = ['Channel: HBO'];
expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 channel');
});
it('returns plural "channels" for multiple channels', () => {
const names = ['Channel: HBO', 'Channel: CNN', 'Channel: ESPN'];
expect(LogosTableUtils.generateUsageLabel(names, 3)).toBe('3 channels');
});
});
describe('single type — movies only', () => {
it('returns singular "movie" for 1 movie', () => {
const names = ['Movie: Inception'];
expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 movie');
});
it('returns plural "movies" for multiple movies', () => {
const names = ['Movie: Inception', 'Movie: Interstellar'];
expect(LogosTableUtils.generateUsageLabel(names, 2)).toBe('2 movies');
});
});
describe('single type — series only', () => {
it('returns "series" for 1 series', () => {
const names = ['Series: Breaking Bad'];
expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 series');
});
it('returns "series" for multiple series', () => {
const names = ['Series: Breaking Bad', 'Series: The Wire'];
expect(LogosTableUtils.generateUsageLabel(names, 2)).toBe('2 series');
});
});
describe('multiple types — generic items', () => {
it('returns singular "item" when channelCount is 1', () => {
const names = ['Channel: HBO', 'Movie: Inception'];
expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 item');
});
it('returns plural "items" when channelCount > 1', () => {
const names = ['Channel: HBO', 'Movie: Inception', 'Series: Lost'];
expect(LogosTableUtils.generateUsageLabel(names, 3)).toBe('3 items');
});
it('uses channelCount not array length for generic label', () => {
const names = ['Channel: HBO', 'Movie: Inception'];
expect(LogosTableUtils.generateUsageLabel(names, 5)).toBe('5 items');
});
});
describe('edge cases', () => {
it('returns "0 items" for empty names array (no types match)', () => {
expect(LogosTableUtils.generateUsageLabel([], 0)).toBe('0 items');
});
it('ignores unrecognized name prefixes', () => {
const names = ['Unknown: Foo', 'Channel: HBO'];
expect(LogosTableUtils.generateUsageLabel(names, 1)).toBe('1 channel');
});
});
});
});

View file

@ -0,0 +1,474 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as M3UsTableUtils from '../M3UsTableUtils';
// ── Dependency mocks ────────────────────────────────────────────────────────
vi.mock('../../../api.js', () => ({
default: {
refreshPlaylist: vi.fn(),
getPlaylistAutoCreatedChannelsCount: vi.fn(),
deletePlaylist: vi.fn(),
updatePlaylist: vi.fn(),
},
}));
vi.mock('../../dateTimeUtils.js', () => ({
format: vi.fn((val, fmt) => `formatted:${fmt}`),
formatDuration: vi.fn((seconds) => `duration:${seconds}`),
}));
vi.mock('../../networkUtils.js', () => ({
formatSpeed: vi.fn((speed) => `speed:${speed}`),
}));
import API from '../../../api.js';
import { format, formatDuration } from '../../dateTimeUtils.js';
import { formatSpeed } from '../../networkUtils.js';
describe('M3UsTableUtils', () => {
beforeEach(() => vi.clearAllMocks());
// ── API wrappers ────────────────────────────────────────────────────────────
describe('refreshPlaylist', () => {
it('calls API.refreshPlaylist with the correct id', () => {
const mockReturn = Promise.resolve(undefined);
API.refreshPlaylist.mockReturnValue(mockReturn);
const result = M3UsTableUtils.refreshPlaylist(3);
expect(API.refreshPlaylist).toHaveBeenCalledWith(3);
expect(result).toBe(mockReturn);
});
});
describe('getPlaylistAutoCreatedChannelsCount', () => {
it('calls API.getPlaylistAutoCreatedChannelsCount with the correct id', () => {
const mockReturn = Promise.resolve({ count: 5 });
API.getPlaylistAutoCreatedChannelsCount.mockReturnValue(mockReturn);
const result = M3UsTableUtils.getPlaylistAutoCreatedChannelsCount(7);
expect(API.getPlaylistAutoCreatedChannelsCount).toHaveBeenCalledWith(7);
expect(result).toBe(mockReturn);
});
});
describe('deletePlaylist', () => {
it('calls API.deletePlaylist with the correct id', () => {
const mockReturn = Promise.resolve(undefined);
API.deletePlaylist.mockReturnValue(mockReturn);
const result = M3UsTableUtils.deletePlaylist(2);
expect(API.deletePlaylist).toHaveBeenCalledWith(2);
expect(result).toBe(mockReturn);
});
});
describe('updatePlaylist', () => {
it('merges values with playlist id and passes isToggle', () => {
const mockReturn = Promise.resolve({});
API.updatePlaylist.mockReturnValue(mockReturn);
const result = M3UsTableUtils.updatePlaylist(
{ is_active: false },
{ id: 10, name: 'Test' },
true
);
expect(API.updatePlaylist).toHaveBeenCalledWith(
{ is_active: false, id: 10 },
true
);
expect(result).toBe(mockReturn);
});
it('defaults isToggle to false when not provided', () => {
API.updatePlaylist.mockReturnValue(Promise.resolve({}));
M3UsTableUtils.updatePlaylist({ name: 'New' }, { id: 5 });
expect(API.updatePlaylist).toHaveBeenCalledWith(
{ name: 'New', id: 5 },
false
);
});
});
// ── formatStatusText ────────────────────────────────────────────────────────
describe('formatStatusText', () => {
it.each([
['idle', 'Idle'],
['fetching', 'Fetching'],
['parsing', 'Parsing'],
['error', 'Error'],
['success', 'Success'],
['pending_setup', 'Pending Setup'],
])('returns "%s" for status "%s"', (status, expected) => {
expect(M3UsTableUtils.formatStatusText(status)).toBe(expected);
});
it('capitalizes first letter for unknown status', () => {
expect(M3UsTableUtils.formatStatusText('loading')).toBe('Loading');
});
it('returns "Unknown" for null', () => {
expect(M3UsTableUtils.formatStatusText(null)).toBe('Unknown');
});
it('returns "Unknown" for undefined', () => {
expect(M3UsTableUtils.formatStatusText(undefined)).toBe('Unknown');
});
it('returns "Unknown" for empty string', () => {
expect(M3UsTableUtils.formatStatusText('')).toBe('Unknown');
});
});
// ── getStatusColor ──────────────────────────────────────────────────────────
describe('getStatusColor', () => {
it.each([
['idle', 'gray.5'],
['fetching', 'blue.5'],
['parsing', 'indigo.5'],
['error', 'red.5'],
['success', 'green.5'],
['pending_setup', 'orange.5'],
])('returns "%s" for status "%s"', (status, expected) => {
expect(M3UsTableUtils.getStatusColor(status)).toBe(expected);
});
it('returns "gray.5" for unknown status', () => {
expect(M3UsTableUtils.getStatusColor('unknown')).toBe('gray.5');
});
it('returns "gray.5" for null', () => {
expect(M3UsTableUtils.getStatusColor(null)).toBe('gray.5');
});
});
// ── getExpirationInfo ───────────────────────────────────────────────────────
describe('getExpirationInfo', () => {
it('returns red.7 and "Expired" when daysLeft < 0', () => {
const result = M3UsTableUtils.getExpirationInfo(
-1,
'2024-01-01',
'MM/DD/YYYY'
);
expect(result).toEqual({ color: 'red.7', label: 'Expired' });
});
it('returns red.5 and "Expires today" when daysLeft === 0', () => {
const result = M3UsTableUtils.getExpirationInfo(
0,
'2024-06-01',
'MM/DD/YYYY'
);
expect(result).toEqual({ color: 'red.5', label: 'Expires today' });
});
it('returns orange.5 and "{n}d left" when daysLeft is 17', () => {
expect(M3UsTableUtils.getExpirationInfo(1, null, 'MM/DD/YYYY')).toEqual({
color: 'orange.5',
label: '1d left',
});
expect(M3UsTableUtils.getExpirationInfo(7, null, 'MM/DD/YYYY')).toEqual({
color: 'orange.5',
label: '7d left',
});
});
it('returns yellow.5 and "{n}d left" when daysLeft is 830', () => {
expect(M3UsTableUtils.getExpirationInfo(8, null, 'MM/DD/YYYY')).toEqual({
color: 'yellow.5',
label: '8d left',
});
expect(M3UsTableUtils.getExpirationInfo(30, null, 'MM/DD/YYYY')).toEqual({
color: 'yellow.5',
label: '30d left',
});
});
it('returns formatted date label with no color when daysLeft > 30', () => {
format.mockReturnValue('12/31/2024');
const result = M3UsTableUtils.getExpirationInfo(
60,
'2024-12-31',
'MM/DD/YYYY'
);
expect(format).toHaveBeenCalledWith('2024-12-31', 'MM/DD/YYYY');
expect(result.label).toBe('12/31/2024');
expect(result.color).toBeUndefined();
});
});
// ── getExpirationTooltip ────────────────────────────────────────────────────
describe('getExpirationTooltip', () => {
it('returns the fallback label when allExpirations is empty', () => {
const result = M3UsTableUtils.getExpirationTooltip(
[],
'MM/DD/YYYY HH:mm',
'7d left'
);
expect(result).toBe('7d left');
});
it('formats each expiration entry with profile name and date', () => {
format.mockImplementation(() => `2024-12-31`);
const expirations = [
{ profile_name: 'Profile A', exp_date: '2024-12-31', is_active: true },
{ profile_name: 'Profile B', exp_date: '2024-11-30', is_active: false },
];
const result = M3UsTableUtils.getExpirationTooltip(
expirations,
'MM/DD/YYYY HH:mm',
'fallback'
);
expect(result).toContain('Profile A: 2024-12-31');
expect(result).toContain('Profile B: 2024-12-31 (inactive)');
});
it('does not append "(inactive)" for active profiles', () => {
format.mockReturnValue('2024-12-31');
const expirations = [
{ profile_name: 'Active', exp_date: '2024-12-31', is_active: true },
];
const result = M3UsTableUtils.getExpirationTooltip(
expirations,
'MM/DD/YYYY',
'fallback'
);
expect(result).not.toContain('(inactive)');
});
it('joins multiple entries with newline', () => {
format.mockReturnValue('2024-12-31');
const expirations = [
{ profile_name: 'A', exp_date: '2024-12-31', is_active: true },
{ profile_name: 'B', exp_date: '2024-12-31', is_active: true },
];
const result = M3UsTableUtils.getExpirationTooltip(
expirations,
'MM/DD/YYYY',
'fallback'
);
expect(result.split('\n')).toHaveLength(2);
});
});
// ── getSortedPlaylists ──────────────────────────────────────────────────────
describe('getSortedPlaylists', () => {
const playlists = [
{ id: 1, name: 'Zebra', locked: false, max_streams: 5 },
{ id: 2, name: 'Alpha', locked: false, max_streams: 10 },
{ id: 3, name: 'Middle', locked: true, max_streams: 1 },
{ id: 4, name: 'Beta', locked: false, max_streams: 3 },
];
it('excludes locked playlists', () => {
const result = M3UsTableUtils.getSortedPlaylists(
playlists,
'name',
false
);
expect(result.find((p) => p.id === 3)).toBeUndefined();
expect(result).toHaveLength(3);
});
it('sorts by string column ascending', () => {
const result = M3UsTableUtils.getSortedPlaylists(
playlists,
'name',
false
);
expect(result.map((p) => p.name)).toEqual(['Alpha', 'Beta', 'Zebra']);
});
it('sorts by string column descending', () => {
const result = M3UsTableUtils.getSortedPlaylists(playlists, 'name', true);
expect(result.map((p) => p.name)).toEqual(['Zebra', 'Beta', 'Alpha']);
});
it('sorts by numeric column ascending', () => {
const result = M3UsTableUtils.getSortedPlaylists(
playlists,
'max_streams',
false
);
expect(result.map((p) => p.max_streams)).toEqual([3, 5, 10]);
});
it('sorts by numeric column descending', () => {
const result = M3UsTableUtils.getSortedPlaylists(
playlists,
'max_streams',
true
);
expect(result.map((p) => p.max_streams)).toEqual([10, 5, 3]);
});
it('sorts nulls to the end regardless of direction', () => {
const withNulls = [
{ id: 1, name: null, locked: false },
{ id: 2, name: 'Alpha', locked: false },
{ id: 3, name: null, locked: false },
];
const asc = M3UsTableUtils.getSortedPlaylists(withNulls, 'name', false);
expect(asc[asc.length - 1].name).toBeNull();
expect(asc[asc.length - 2].name).toBeNull();
const desc = M3UsTableUtils.getSortedPlaylists(withNulls, 'name', true);
expect(desc[desc.length - 1].name).toBeNull();
});
it('returns empty array when all playlists are locked', () => {
const locked = [{ id: 1, name: 'Locked', locked: true }];
expect(M3UsTableUtils.getSortedPlaylists(locked, 'name', false)).toEqual(
[]
);
});
});
// ── getStatusContent ────────────────────────────────────────────────────────
describe('getStatusContent', () => {
it('returns null when progress is 100', () => {
expect(
M3UsTableUtils.getStatusContent({ progress: 100, action: 'parsing' })
).toBeNull();
});
it('returns initializing type for initializing action', () => {
expect(
M3UsTableUtils.getStatusContent({
progress: 50,
action: 'initializing',
})
).toEqual({ type: 'initializing' });
});
describe('downloading', () => {
it('returns simple label when progress is 0', () => {
expect(
M3UsTableUtils.getStatusContent({
progress: 0,
action: 'downloading',
})
).toEqual({ type: 'simple', label: 'Downloading...' });
});
it('returns downloading object with formatted fields when progress > 0', () => {
formatSpeed.mockReturnValue('speed:512');
formatDuration.mockReturnValue('duration:30');
const result = M3UsTableUtils.getStatusContent({
action: 'downloading',
progress: 50,
speed: 512,
time_remaining: 30,
});
expect(result).toEqual({
type: 'downloading',
progress: 50,
speed: 'speed:512',
timeRemaining: 'duration:30',
});
expect(formatSpeed).toHaveBeenCalledWith(512);
expect(formatDuration).toHaveBeenCalledWith(30);
});
it('returns "calculating..." when time_remaining is absent', () => {
formatSpeed.mockReturnValue('speed:512');
const result = M3UsTableUtils.getStatusContent({
action: 'downloading',
progress: 25,
speed: 512,
});
expect(result.timeRemaining).toBe('calculating...');
});
});
describe('processing_groups', () => {
it('returns simple label when progress is 0', () => {
expect(
M3UsTableUtils.getStatusContent({
progress: 0,
action: 'processing_groups',
})
).toEqual({ type: 'simple', label: 'Processing groups...' });
});
it('returns groups object with formatted elapsed time', () => {
formatDuration.mockReturnValue('duration:120');
const result = M3UsTableUtils.getStatusContent({
action: 'processing_groups',
progress: 40,
elapsed_time: 120,
groups_processed: 15,
});
expect(result).toEqual({
type: 'groups',
progress: 40,
elapsedTime: 'duration:120',
groupsProcessed: 15,
});
expect(formatDuration).toHaveBeenCalledWith(120);
});
});
describe('parsing', () => {
it('returns simple label when progress is 0', () => {
expect(
M3UsTableUtils.getStatusContent({ progress: 0, action: 'parsing' })
).toEqual({ type: 'simple', label: 'Parsing...' });
});
it('returns parsing object with all fields', () => {
formatDuration.mockReturnValue('duration:60');
const result = M3UsTableUtils.getStatusContent({
action: 'parsing',
progress: 75,
elapsed_time: 60,
time_remaining: 20,
streams_processed: 1000,
});
expect(result).toEqual({
type: 'parsing',
progress: 75,
elapsedTime: 'duration:60',
timeRemaining: 'duration:60',
streamsProcessed: 1000,
});
});
it('returns "calculating..." when time_remaining is absent', () => {
formatDuration.mockReturnValue('duration:60');
const result = M3UsTableUtils.getStatusContent({
action: 'parsing',
progress: 50,
elapsed_time: 60,
});
expect(result.timeRemaining).toBe('calculating...');
});
});
describe('default / error', () => {
it('returns error type when status is error', () => {
const result = M3UsTableUtils.getStatusContent({
action: 'unknown',
progress: 50,
status: 'error',
error: 'Something went wrong',
});
expect(result).toEqual({
type: 'error',
error: 'Something went wrong',
});
});
it('returns simple label with action name for unknown non-error action', () => {
const result = M3UsTableUtils.getStatusContent({
action: 'custom_action',
progress: 50,
status: 'running',
});
expect(result).toEqual({ type: 'simple', label: 'custom_action...' });
});
it('returns "Processing..." label when action is undefined', () => {
const result = M3UsTableUtils.getStatusContent({
progress: 30,
status: 'running',
});
expect(result).toEqual({ type: 'simple', label: 'Processing...' });
});
});
});
});

View file

@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as OutputProfilesTableUtils from '../OutputProfilesTableUtils';
vi.mock('../../../api.js', () => ({
default: {
updateOutputProfile: vi.fn(),
deleteOutputProfile: vi.fn(),
},
}));
import API from '../../../api.js';
describe('OutputProfilesTableUtils', () => {
beforeEach(() => vi.clearAllMocks());
describe('updateOutputProfile', () => {
it('calls API.updateOutputProfile with the provided values', () => {
const mockReturn = Promise.resolve({ id: 1, name: 'Updated' });
API.updateOutputProfile.mockReturnValue(mockReturn);
const values = { id: 1, name: 'Updated', is_active: true };
const result = OutputProfilesTableUtils.updateOutputProfile(values);
expect(API.updateOutputProfile).toHaveBeenCalledWith(values);
expect(result).toBe(mockReturn);
});
it('passes through the return value from the API', async () => {
API.updateOutputProfile.mockResolvedValue({ id: 2, name: 'Profile' });
const result = await OutputProfilesTableUtils.updateOutputProfile({
id: 2,
});
expect(result).toEqual({ id: 2, name: 'Profile' });
});
});
describe('deleteOutputProfile', () => {
it('calls API.deleteOutputProfile with the correct id', async () => {
API.deleteOutputProfile.mockResolvedValue(undefined);
await OutputProfilesTableUtils.deleteOutputProfile(5);
expect(API.deleteOutputProfile).toHaveBeenCalledWith(5);
});
it('resolves without a return value', async () => {
API.deleteOutputProfile.mockResolvedValue(undefined);
const result = await OutputProfilesTableUtils.deleteOutputProfile(3);
expect(result).toBeUndefined();
});
it('propagates errors thrown by the API', async () => {
API.deleteOutputProfile.mockRejectedValue(new Error('Not found'));
await expect(
OutputProfilesTableUtils.deleteOutputProfile(99)
).rejects.toThrow('Not found');
});
});
});

View file

@ -0,0 +1,422 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as StreamsTableUtils from '../StreamsTableUtils';
// ── Dependency mocks ────────────────────────────────────────────────────────
vi.mock('../../../api.js', () => ({
default: {
addStreamsToChannel: vi.fn(),
queryStreamsTable: vi.fn(),
getStreams: vi.fn(),
createChannelsFromStreamsAsync: vi.fn(),
deleteStream: vi.fn(),
deleteStreams: vi.fn(),
requeryStreams: vi.fn(),
createChannelFromStream: vi.fn(),
getAllStreamIds: vi.fn(),
getStreamFilterOptions: vi.fn(),
},
}));
import API from '../../../api.js';
describe('StreamsTableUtils', () => {
beforeEach(() => vi.clearAllMocks());
// ── API wrappers ────────────────────────────────────────────────────────────
describe('API wrappers', () => {
it('addStreamsToChannel calls API with correct args', () => {
const mockReturn = Promise.resolve(undefined);
API.addStreamsToChannel.mockReturnValue(mockReturn);
const result = StreamsTableUtils.addStreamsToChannel('ch-1', [1], [2, 3]);
expect(API.addStreamsToChannel).toHaveBeenCalledWith('ch-1', [1], [2, 3]);
expect(result).toBe(mockReturn);
});
it('queryStreamsTable calls API with params', () => {
const params = new URLSearchParams({ page: '1' });
const mockReturn = Promise.resolve({ results: [] });
API.queryStreamsTable.mockReturnValue(mockReturn);
const result = StreamsTableUtils.queryStreamsTable(params);
expect(API.queryStreamsTable).toHaveBeenCalledWith(params);
expect(result).toBe(mockReturn);
});
it('getStreams calls API with streamIds', () => {
const mockReturn = Promise.resolve([]);
API.getStreams.mockReturnValue(mockReturn);
const result = StreamsTableUtils.getStreams([10, 20]);
expect(API.getStreams).toHaveBeenCalledWith([10, 20]);
expect(result).toBe(mockReturn);
});
it('createChannelsFromStreamsAsync calls API with correct args', () => {
const mockReturn = Promise.resolve(undefined);
API.createChannelsFromStreamsAsync.mockReturnValue(mockReturn);
const result = StreamsTableUtils.createChannelsFromStreamsAsync(
[1, 2],
[3],
100
);
expect(API.createChannelsFromStreamsAsync).toHaveBeenCalledWith(
[1, 2],
[3],
100
);
expect(result).toBe(mockReturn);
});
it('deleteStream calls API with id', () => {
const mockReturn = Promise.resolve(undefined);
API.deleteStream.mockReturnValue(mockReturn);
const result = StreamsTableUtils.deleteStream(5);
expect(API.deleteStream).toHaveBeenCalledWith(5);
expect(result).toBe(mockReturn);
});
it('deleteStreams calls API with ids', () => {
const mockReturn = Promise.resolve(undefined);
API.deleteStreams.mockReturnValue(mockReturn);
const result = StreamsTableUtils.deleteStreams([1, 2, 3]);
expect(API.deleteStreams).toHaveBeenCalledWith([1, 2, 3]);
expect(result).toBe(mockReturn);
});
it('requeryStreams calls API', () => {
const mockReturn = Promise.resolve(undefined);
API.requeryStreams.mockReturnValue(mockReturn);
const result = StreamsTableUtils.requeryStreams();
expect(API.requeryStreams).toHaveBeenCalled();
expect(result).toBe(mockReturn);
});
it('createChannelFromStream calls API with values', () => {
const mockReturn = Promise.resolve({ id: 1 });
API.createChannelFromStream.mockReturnValue(mockReturn);
const values = { name: 'New Channel' };
const result = StreamsTableUtils.createChannelFromStream(values);
expect(API.createChannelFromStream).toHaveBeenCalledWith(values);
expect(result).toBe(mockReturn);
});
it('getAllStreamIds calls API with params', () => {
const params = new URLSearchParams();
const mockReturn = Promise.resolve([1, 2, 3]);
API.getAllStreamIds.mockReturnValue(mockReturn);
const result = StreamsTableUtils.getAllStreamIds(params);
expect(API.getAllStreamIds).toHaveBeenCalledWith(params);
expect(result).toBe(mockReturn);
});
it('getStreamFilterOptions calls API with params', () => {
const params = new URLSearchParams();
const mockReturn = Promise.resolve({});
API.getStreamFilterOptions.mockReturnValue(mockReturn);
const result = StreamsTableUtils.getStreamFilterOptions(params);
expect(API.getStreamFilterOptions).toHaveBeenCalledWith(params);
expect(result).toBe(mockReturn);
});
});
// ── getStatsTooltip ─────────────────────────────────────────────────────────
describe('getStatsTooltip', () => {
it('returns "-" compact display for empty stats', () => {
const { compactDisplay } = StreamsTableUtils.getStatsTooltip({});
expect(compactDisplay).toBe('-');
});
it('returns "No source info available" tooltip for empty stats', () => {
const { tooltipContent } = StreamsTableUtils.getStatsTooltip({});
expect(tooltipContent).toBe('No source info available');
});
it('converts resolution "1920x1080" to "1080p" in compact display', () => {
const { compactDisplay } = StreamsTableUtils.getStatsTooltip({
resolution: '1920x1080',
});
expect(compactDisplay).toBe('1080p');
});
it('converts resolution "1280x720" to "720p" in compact display', () => {
const { compactDisplay } = StreamsTableUtils.getStatsTooltip({
resolution: '1280x720',
});
expect(compactDisplay).toBe('720p');
});
it('uppercases video_codec in compact display', () => {
const { compactDisplay } = StreamsTableUtils.getStatsTooltip({
video_codec: 'h264',
});
expect(compactDisplay).toBe('H264');
});
it('combines resolution and video_codec in compact display', () => {
const { compactDisplay } = StreamsTableUtils.getStatsTooltip({
resolution: '1920x1080',
video_codec: 'hevc',
});
expect(compactDisplay).toBe('1080p HEVC');
});
it('includes Resolution in tooltip when present', () => {
const { tooltipContent } = StreamsTableUtils.getStatsTooltip({
resolution: '1920x1080',
});
expect(tooltipContent).toContain('Resolution: 1920x1080');
});
it('includes uppercased Video Codec in tooltip', () => {
const { tooltipContent } = StreamsTableUtils.getStatsTooltip({
video_codec: 'h264',
});
expect(tooltipContent).toContain('Video Codec: H264');
});
it('includes Video Bitrate in tooltip', () => {
const { tooltipContent } = StreamsTableUtils.getStatsTooltip({
video_bitrate: 5000,
});
expect(tooltipContent).toContain('Video Bitrate: 5000 kbps');
});
it('includes Frame Rate in tooltip', () => {
const { tooltipContent } = StreamsTableUtils.getStatsTooltip({
source_fps: 30,
});
expect(tooltipContent).toContain('Frame Rate: 30 FPS');
});
it('includes uppercased Audio Codec in tooltip', () => {
const { tooltipContent } = StreamsTableUtils.getStatsTooltip({
audio_codec: 'aac',
});
expect(tooltipContent).toContain('Audio Codec: AAC');
});
it('includes Audio Channels in tooltip', () => {
const { tooltipContent } = StreamsTableUtils.getStatsTooltip({
audio_channels: 2,
});
expect(tooltipContent).toContain('Audio Channels: 2');
});
it('includes Audio Bitrate in tooltip', () => {
const { tooltipContent } = StreamsTableUtils.getStatsTooltip({
audio_bitrate: 192,
});
expect(tooltipContent).toContain('Audio Bitrate: 192 kbps');
});
it('builds multi-line tooltip joined by newlines', () => {
const { tooltipContent } = StreamsTableUtils.getStatsTooltip({
resolution: '1920x1080',
video_codec: 'h264',
audio_codec: 'aac',
});
const lines = tooltipContent.split('\n');
expect(lines).toHaveLength(3);
});
it('handles resolution with no height part gracefully', () => {
const { compactDisplay } = StreamsTableUtils.getStatsTooltip({
resolution: 'unknown',
});
// No 'x' separator — height is undefined, so no height part added
expect(compactDisplay).toBe('-');
});
});
// ── appendFetchPageParams ───────────────────────────────────────────────────
describe('appendFetchPageParams', () => {
it('appends page incremented by 1 from pageIndex', () => {
const params = new URLSearchParams();
StreamsTableUtils.appendFetchPageParams(
params,
{ pageIndex: 2, pageSize: 25 },
[]
);
expect(params.get('page')).toBe('3');
expect(params.get('page_size')).toBe('25');
});
it('does not append ordering when sorting is empty', () => {
const params = new URLSearchParams();
StreamsTableUtils.appendFetchPageParams(
params,
{ pageIndex: 0, pageSize: 50 },
[]
);
expect(params.get('ordering')).toBeNull();
});
it('appends ascending ordering for known column', () => {
const params = new URLSearchParams();
StreamsTableUtils.appendFetchPageParams(
params,
{ pageIndex: 0, pageSize: 50 },
[{ id: 'name', desc: false }]
);
expect(params.get('ordering')).toBe('name');
});
it('appends descending ordering with "-" prefix', () => {
const params = new URLSearchParams();
StreamsTableUtils.appendFetchPageParams(
params,
{ pageIndex: 0, pageSize: 50 },
[{ id: 'name', desc: true }]
);
expect(params.get('ordering')).toBe('-name');
});
it('maps "group" column to "channel_group__name"', () => {
const params = new URLSearchParams();
StreamsTableUtils.appendFetchPageParams(
params,
{ pageIndex: 0, pageSize: 50 },
[{ id: 'group', desc: false }]
);
expect(params.get('ordering')).toBe('channel_group__name');
});
it('maps "m3u" column to "m3u_account__name"', () => {
const params = new URLSearchParams();
StreamsTableUtils.appendFetchPageParams(
params,
{ pageIndex: 0, pageSize: 50 },
[{ id: 'm3u', desc: false }]
);
expect(params.get('ordering')).toBe('m3u_account__name');
});
it('maps "tvg_id" column to "tvg_id"', () => {
const params = new URLSearchParams();
StreamsTableUtils.appendFetchPageParams(
params,
{ pageIndex: 0, pageSize: 50 },
[{ id: 'tvg_id', desc: false }]
);
expect(params.get('ordering')).toBe('tvg_id');
});
it('uses column id directly for unmapped columns', () => {
const params = new URLSearchParams();
StreamsTableUtils.appendFetchPageParams(
params,
{ pageIndex: 0, pageSize: 50 },
[{ id: 'custom_field', desc: false }]
);
expect(params.get('ordering')).toBe('custom_field');
});
});
// ── getChannelProfileIds ────────────────────────────────────────────────────
describe('getChannelProfileIds', () => {
it('returns [] when profileIds includes "none"', () => {
expect(StreamsTableUtils.getChannelProfileIds(['none'], '0')).toEqual([]);
});
it('returns null when profileIds includes "all"', () => {
expect(StreamsTableUtils.getChannelProfileIds(['all'], '0')).toBeNull();
});
it('returns parsed integer array for specific profile ids', () => {
expect(
StreamsTableUtils.getChannelProfileIds(['1', '2', '3'], '0')
).toEqual([1, 2, 3]);
});
it('returns [selectedProfileId as int] when profileIds is null and selectedProfileId is not "0"', () => {
expect(StreamsTableUtils.getChannelProfileIds(null, '3')).toEqual([3]);
});
it('returns null when profileIds is null and selectedProfileId is "0"', () => {
expect(StreamsTableUtils.getChannelProfileIds(null, '0')).toBeNull();
});
it('returns null when profileIds is undefined and selectedProfileId is "0"', () => {
expect(StreamsTableUtils.getChannelProfileIds(undefined, '0')).toBeNull();
});
});
// ── getChannelNumberValue ───────────────────────────────────────────────────
describe('getChannelNumberValue', () => {
it('returns null for "provider" mode', () => {
expect(
StreamsTableUtils.getChannelNumberValue('provider', 100)
).toBeNull();
});
it('returns 0 for "auto" mode', () => {
expect(StreamsTableUtils.getChannelNumberValue('auto', 100)).toBe(0);
});
it('returns -1 for "highest" mode', () => {
expect(StreamsTableUtils.getChannelNumberValue('highest', 100)).toBe(-1);
});
it('returns startNumber as Number for any other mode', () => {
expect(StreamsTableUtils.getChannelNumberValue('manual', 42)).toBe(42);
});
it('converts string startNumber to number', () => {
expect(StreamsTableUtils.getChannelNumberValue('manual', '200')).toBe(
200
);
});
});
// ── getFilterParams ─────────────────────────────────────────────────────────
describe('getFilterParams', () => {
it('returns empty URLSearchParams for empty filters', () => {
const result = StreamsTableUtils.getFilterParams({});
expect(result.toString()).toBe('');
});
it('appends string filter values', () => {
const result = StreamsTableUtils.getFilterParams({ name: 'CNN' });
expect(result.get('name')).toBe('CNN');
});
it('appends "true" for boolean true values', () => {
const result = StreamsTableUtils.getFilterParams({ is_active: true });
expect(result.get('is_active')).toBe('true');
});
it('does not append boolean false values', () => {
const result = StreamsTableUtils.getFilterParams({ is_active: false });
expect(result.get('is_active')).toBeNull();
});
it('does not append null values', () => {
const result = StreamsTableUtils.getFilterParams({ name: null });
expect(result.get('name')).toBeNull();
});
it('does not append undefined values', () => {
const result = StreamsTableUtils.getFilterParams({ name: undefined });
expect(result.get('name')).toBeNull();
});
it('does not append empty string values', () => {
const result = StreamsTableUtils.getFilterParams({ name: '' });
expect(result.get('name')).toBeNull();
});
it('appends numeric values as strings', () => {
const result = StreamsTableUtils.getFilterParams({ page: 2 });
expect(result.get('page')).toBe('2');
});
it('handles multiple filters', () => {
const result = StreamsTableUtils.getFilterParams({
name: 'ESPN',
is_active: true,
group: '',
});
expect(result.get('name')).toBe('ESPN');
expect(result.get('is_active')).toBe('true');
expect(result.get('group')).toBeNull();
});
});
});