diff --git a/frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx b/frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx
new file mode 100644
index 00000000..7ad3a002
--- /dev/null
+++ b/frontend/src/components/tables/__tests__/M3uTableUtils.test.jsx
@@ -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 }) =>
{children}
,
+ Group: ({ children }) => {children}
,
+ Text: ({ children, size, name }) => (
+
+ {children}
+
+ ),
+}));
+
+vi.mock('lucide-react', () => ({
+ ArrowUpDown: (props) => (
+
+ ),
+ ArrowUpNarrowWide: (props) => (
+
+ ),
+ ArrowDownWideNarrow: (props) => (
+
+ ),
+}));
+
+// ── 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);
+ });
+ });
+});
diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js
index 5ab6ed48..43ecaaf6 100644
--- a/frontend/src/utils/__tests__/dateTimeUtils.test.js
+++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js
@@ -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'
+ );
+ });
+ });
+ });
});
diff --git a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js
index 489b781a..0f266693 100644
--- a/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js
+++ b/frontend/src/utils/cards/__tests__/VodConnectionCardUtils.test.js
@@ -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' };
diff --git a/frontend/src/utils/tables/ChannelTableStreamsUtils.js b/frontend/src/utils/tables/ChannelTableStreamsUtils.js
new file mode 100644
index 00000000..8fdbc7a7
--- /dev/null
+++ b/frontend/src/utils/tables/ChannelTableStreamsUtils.js
@@ -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);
+};
diff --git a/frontend/src/utils/tables/OutputProfilesTableUtils.js b/frontend/src/utils/tables/OutputProfilesTableUtils.js
new file mode 100644
index 00000000..dc2761bd
--- /dev/null
+++ b/frontend/src/utils/tables/OutputProfilesTableUtils.js
@@ -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);
+};
diff --git a/frontend/src/utils/tables/__tests__/ChannelTableStreamsUtils.test.js b/frontend/src/utils/tables/__tests__/ChannelTableStreamsUtils.test.js
new file mode 100644
index 00000000..f65e7f2c
--- /dev/null
+++ b/frontend/src/utils/tables/__tests__/ChannelTableStreamsUtils.test.js
@@ -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);
+ });
+ });
+});
diff --git a/frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js b/frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js
new file mode 100644
index 00000000..c4fc9e3e
--- /dev/null
+++ b/frontend/src/utils/tables/__tests__/ChannelsTableUtils.test.js
@@ -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);
+ });
+ });
+});
diff --git a/frontend/src/utils/tables/__tests__/EPGsTableUtils.test.js b/frontend/src/utils/tables/__tests__/EPGsTableUtils.test.js
new file mode 100644
index 00000000..7dd0b8dc
--- /dev/null
+++ b/frontend/src/utils/tables/__tests__/EPGsTableUtils.test.js
@@ -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([]);
+ });
+ });
+});
diff --git a/frontend/src/utils/tables/__tests__/LogosTableUtils.test.js b/frontend/src/utils/tables/__tests__/LogosTableUtils.test.js
new file mode 100644
index 00000000..332f198a
--- /dev/null
+++ b/frontend/src/utils/tables/__tests__/LogosTableUtils.test.js
@@ -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');
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/tables/__tests__/M3UsTableUtils.test.js b/frontend/src/utils/tables/__tests__/M3UsTableUtils.test.js
new file mode 100644
index 00000000..de63e0e9
--- /dev/null
+++ b/frontend/src/utils/tables/__tests__/M3UsTableUtils.test.js
@@ -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...' });
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/tables/__tests__/OutputProfilesTableUtils.test.js b/frontend/src/utils/tables/__tests__/OutputProfilesTableUtils.test.js
new file mode 100644
index 00000000..8ef7bf3b
--- /dev/null
+++ b/frontend/src/utils/tables/__tests__/OutputProfilesTableUtils.test.js
@@ -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');
+ });
+ });
+});
diff --git a/frontend/src/utils/tables/__tests__/StreamsTableUtils.test.js b/frontend/src/utils/tables/__tests__/StreamsTableUtils.test.js
new file mode 100644
index 00000000..dd6cf8ab
--- /dev/null
+++ b/frontend/src/utils/tables/__tests__/StreamsTableUtils.test.js
@@ -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();
+ });
+ });
+});