mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-18 00:55:50 +00:00
Added tests for utils
This commit is contained in:
parent
7a4db31460
commit
1a46ca4be4
12 changed files with 2756 additions and 75 deletions
229
frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx
Normal file
229
frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
100
frontend/src/utils/tables/ChannelTableStreamsUtils.js
Normal file
100
frontend/src/utils/tables/ChannelTableStreamsUtils.js
Normal 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);
|
||||
};
|
||||
6
frontend/src/utils/tables/OutputProfilesTableUtils.js
Normal file
6
frontend/src/utils/tables/OutputProfilesTableUtils.js
Normal 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);
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
611
frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js
Normal file
611
frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
238
frontend/src/utils/tables/__tests__/EPGsTableUtils.test.js
Normal file
238
frontend/src/utils/tables/__tests__/EPGsTableUtils.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
220
frontend/src/utils/tables/__tests__/LogosTableUtils.test.js
Normal file
220
frontend/src/utils/tables/__tests__/LogosTableUtils.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
474
frontend/src/utils/tables/__tests__/M3UsTableUtils.test.js
Normal file
474
frontend/src/utils/tables/__tests__/M3UsTableUtils.test.js
Normal 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 1–7', () => {
|
||||
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 8–30', () => {
|
||||
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...' });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
422
frontend/src/utils/tables/__tests__/StreamsTableUtils.test.js
Normal file
422
frontend/src/utils/tables/__tests__/StreamsTableUtils.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue