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(); + }); + }); +});