diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index c445407e..aa319913 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -33,12 +33,10 @@ import { useElementSize } from '@mantine/hooks'; import { VariableSizeList } from 'react-window'; import { buildChannelIdMap, - calculateDesiredScrollPosition, calculateEarliestProgramStart, calculateEnd, calculateHourTimeline, calculateLatestProgramEnd, - calculateLeftScrollPosition, calculateNowPosition, calculateScrollPosition, calculateScrollPositionByTimeClick, @@ -47,7 +45,6 @@ import { computeRowHeights, createRecording, createSeriesRule, - evaluateSeriesRule, fetchPrograms, fetchRules, filterGuideChannels, @@ -66,6 +63,7 @@ import { PX_PER_MS, calcProgressPct, sortChannels, + evaluateSeriesRulesByTvgId, } from '../utils/guideUtils'; import API from '../api'; import { getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js'; @@ -739,13 +737,22 @@ export default function TVChannelGuide({ startDate, endDate }) { return; } - await createRecording(channel, program); + await createRecording({ + channel: `${channel.id}`, + start_time: program.start_time, + end_time: program.end_time, + custom_properties: { program }, + }); showNotification({ title: 'Recording scheduled' }); }, []); const saveSeriesRule = useCallback(async (program, mode) => { - await createSeriesRule(program, mode); - await evaluateSeriesRule(program); + await createSeriesRule({ + tvg_id: program.tvg_id, + mode, + title: program.title, + }); + await evaluateSeriesRulesByTvgId(program.tvg_id); // recordings_refreshed WS event triggers the debounced fetchRecordings() showNotification({ title: mode === 'new' ? 'Record new episodes' : 'Record all episodes', diff --git a/frontend/src/pages/__tests__/Guide.test.jsx b/frontend/src/pages/__tests__/Guide.test.jsx index d66f9be0..e1ce551a 100644 --- a/frontend/src/pages/__tests__/Guide.test.jsx +++ b/frontend/src/pages/__tests__/Guide.test.jsx @@ -9,20 +9,36 @@ import useEPGsStore from '../../store/epgs'; import useSettingsStore from '../../store/settings'; import useVideoStore from '../../store/useVideoStore'; import useLocalStorage from '../../hooks/useLocalStorage'; -import { showNotification } from '../../utils/notificationUtils.js'; import * as guideUtils from '../../utils/guideUtils'; import * as recordingCardUtils from '../../utils/cards/RecordingCardUtils.js'; import * as dateTimeUtils from '../../utils/dateTimeUtils.js'; import userEvent from '@testing-library/user-event'; // Mock dependencies -vi.mock('../../store/channels'); -vi.mock('../../store/logos'); -vi.mock('../../store/epgs'); -vi.mock('../../store/settings'); -vi.mock('../../store/useVideoStore'); -vi.mock('../../hooks/useLocalStorage'); -vi.mock('../../api'); +vi.mock('../../store/channels', () => ({ + default: vi.fn(), +})); +vi.mock('../../store/logos', () => ({ + default: vi.fn(), +})); +vi.mock('../../store/epgs', () => ({ + default: vi.fn(), +})); +vi.mock('../../store/settings', () => ({ + default: vi.fn(), +})); +vi.mock('../../store/useVideoStore', () => ({ + default: vi.fn(), +})); +vi.mock('../../hooks/useLocalStorage', () => ({ + default: vi.fn(), +})); +vi.mock('../../api', () => ({ + default: { + getAllChannelIds: vi.fn(), + getChannelsSummary: vi.fn(), + }, +})); vi.mock('@mantine/hooks', () => ({ useElementSize: () => ({ @@ -235,7 +251,7 @@ vi.mock('../../components/forms/SeriesRecordingModal', () => ({ })); vi.mock('../../components/ProgramDetailModal', () => ({ __esModule: true, - default: ({ program, channel, opened, onClose, onRecord }) => + default: ({ program, opened, onClose, onRecord }) => opened ? (
{program?.title}
@@ -252,11 +268,11 @@ vi.mock('../../utils/guideUtils', async () => { fetchPrograms: vi.fn(), createRecording: vi.fn(), createSeriesRule: vi.fn(), - evaluateSeriesRule: vi.fn(), fetchRules: vi.fn(), filterGuideChannels: vi.fn(), getGroupOptions: vi.fn(), getProfileOptions: vi.fn(), + sortChannels: vi.fn((ch) => ch), }; }); vi.mock('../../utils/cards/RecordingCardUtils.js', async () => { @@ -403,7 +419,6 @@ describe('Guide', () => { ); guideUtils.createRecording.mockResolvedValue(undefined); guideUtils.createSeriesRule.mockResolvedValue(undefined); - guideUtils.evaluateSeriesRule.mockResolvedValue(undefined); guideUtils.getGroupOptions.mockReturnValue([ { value: 'all', label: 'All Groups' }, { value: 'group-1', label: 'News' }, @@ -666,8 +681,7 @@ describe('Guide', () => { await waitFor(() => { expect(guideUtils.createRecording).toHaveBeenCalledWith( - expect.objectContaining({ id: 'channel-1' }), - expect.objectContaining({ id: 'prog-1' }) + expect.objectContaining({ channel: 'channel-1' }), ); }); }); diff --git a/frontend/src/pages/__tests__/guideUtils.test.js b/frontend/src/pages/__tests__/guideUtils.test.js index 18307016..2cd321ca 100644 --- a/frontend/src/pages/__tests__/guideUtils.test.js +++ b/frontend/src/pages/__tests__/guideUtils.test.js @@ -48,6 +48,8 @@ describe('guideUtils', () => { vi.clearAllMocks(); }); + // ── buildChannelIdMap ────────────────────────────────────────────────────── + describe('buildChannelIdMap', () => { it('should create map with channel UUIDs when no EPG data', () => { const channels = [ @@ -123,850 +125,638 @@ describe('guideUtils', () => { }); }); + // ── mapProgramsByChannel ─────────────────────────────────────────────────── + describe('mapProgramsByChannel', () => { it('should return empty map when no programs', () => { - const channelIdByTvgId = new Map(); - - const result = guideUtils.mapProgramsByChannel([], channelIdByTvgId); - + const result = guideUtils.mapProgramsByChannel([], new Map([['tvg-1', [1]]])); expect(result.size).toBe(0); }); it('should return empty map when no channel mapping', () => { - const programs = [{ tvg_id: 'tvg-1' }]; - + const programs = [{ tvg_id: 'tvg-1', startMs: 1000, endMs: 2000 }]; const result = guideUtils.mapProgramsByChannel(programs, new Map()); - expect(result.size).toBe(0); }); it('should map programs to channels', () => { - const nowMs = 1000000; - dateTimeUtils.getNowMs.mockReturnValue(nowMs); - - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; + const nowMs = dayjs().valueOf(); + const startMs = nowMs - 10000; + const endMs = nowMs + 10000; + const programs = [{ tvg_id: 'tvg-1', startMs, endMs }]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + + expect(result.has(1)).toBe(true); expect(result.get(1)).toHaveLength(1); - expect(result.get(1)[0]).toMatchObject({ - id: 1, - tvg_id: 'tvg-1', - }); }); it('should precompute startMs and endMs', () => { - dateTimeUtils.getNowMs.mockReturnValue(1000000); - dateTimeUtils.convertToMs.mockImplementation((time) => - typeof time === 'number' ? time : dayjs(time).valueOf() - ); + const startTime = '2024-01-01T10:00:00Z'; + const endTime = '2024-01-01T11:00:00Z'; + const expectedStartMs = dayjs(startTime).valueOf(); + const expectedEndMs = dayjs(endTime).valueOf(); - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; + vi.mocked(dateTimeUtils.convertToMs) + .mockReturnValueOnce(expectedStartMs) + .mockReturnValueOnce(expectedEndMs); + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(expectedStartMs - 1000); + + const programs = [{ tvg_id: 'tvg-1', start_time: startTime, end_time: endTime }]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + const mapped = result.get(1)[0]; - expect(result.get(1)[0]).toHaveProperty('startMs'); - expect(result.get(1)[0]).toHaveProperty('endMs'); + expect(mapped.startMs).toBe(expectedStartMs); + expect(mapped.endMs).toBe(expectedEndMs); }); it('should mark program as live when now is between start and end', () => { + const nowMs = 5000; const startMs = 1000; - const endMs = 2000; - const nowMs = 1500; - dateTimeUtils.getNowMs.mockReturnValue(nowMs); + const endMs = 10000; - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - startMs, - endMs, - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); + + const programs = [{ tvg_id: 'tvg-1', startMs, endMs }]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); expect(result.get(1)[0].isLive).toBe(true); expect(result.get(1)[0].isPast).toBe(false); }); it('should mark program as past when now is after end', () => { + const nowMs = 20000; const startMs = 1000; - const endMs = 2000; - const nowMs = 3000; - dateTimeUtils.getNowMs.mockReturnValue(nowMs); + const endMs = 10000; - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - startMs, - endMs, - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); + + const programs = [{ tvg_id: 'tvg-1', startMs, endMs }]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); - expect(result.get(1)[0].isLive).toBe(false); expect(result.get(1)[0].isPast).toBe(true); + expect(result.get(1)[0].isLive).toBe(false); }); it('should add program to multiple channels with same tvg_id', () => { - dateTimeUtils.getNowMs.mockReturnValue(1000000); + const nowMs = 5000; + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; - const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]); + const programs = [{ tvg_id: 'tvg-1', startMs: 1000, endMs: 10000 }]; + const channelIdByTvgId = new Map([['tvg-1', [1, 2]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); expect(result.get(1)).toHaveLength(1); expect(result.get(2)).toHaveLength(1); - expect(result.get(3)).toHaveLength(1); }); it('should sort programs by start time', () => { - dateTimeUtils.getNowMs.mockReturnValue(1000000); + const nowMs = 0; + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); const programs = [ - { - id: 2, - tvg_id: 'tvg-1', - startMs: 2000, - endMs: 3000, - start_time: '2024-01-15T11:00:00Z', - end_time: '2024-01-15T12:00:00Z', - }, - { - id: 1, - tvg_id: 'tvg-1', - startMs: 1000, - endMs: 2000, - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, + { tvg_id: 'tvg-1', startMs: 3000, endMs: 4000 }, + { tvg_id: 'tvg-1', startMs: 1000, endMs: 2000 }, ]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + const list = result.get(1); - expect(result.get(1)[0].id).toBe(1); - expect(result.get(1)[1].id).toBe(2); + expect(list[0].startMs).toBe(1000); + expect(list[1].startMs).toBe(3000); }); }); + // ── computeRowHeights ───────────────────────────────────────────────────── + describe('computeRowHeights', () => { it('should return empty array when no channels', () => { - const result = guideUtils.computeRowHeights([]); - - expect(result).toEqual([]); + expect(guideUtils.computeRowHeights([])).toEqual([]); + expect(guideUtils.computeRowHeights(null)).toEqual([]); }); it('should return default height for all channels', () => { - const channels = [{ id: 1 }, { id: 2 }]; - + const channels = [{}, {}, {}]; const result = guideUtils.computeRowHeights(channels); - - expect(result).toEqual([ - guideUtils.PROGRAM_HEIGHT, - guideUtils.PROGRAM_HEIGHT, - ]); + expect(result).toEqual([90, 90, 90]); }); it('should use custom default height when provided', () => { - const channels = [{ id: 1 }]; - const customDefault = 100; - - const result = guideUtils.computeRowHeights(channels, customDefault); - - expect(result).toEqual([customDefault]); + const channels = [{}, {}]; + const result = guideUtils.computeRowHeights(channels, 60); + expect(result).toEqual([60, 60]); }); }); + // ── fetchPrograms ───────────────────────────────────────────────────────── + describe('fetchPrograms', () => { it('should fetch and transform programs', async () => { - const mockPrograms = [ - { - id: 1, - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; - API.getGrid.mockResolvedValue(mockPrograms); - dateTimeUtils.convertToMs.mockReturnValue(1000); + const startTime = '2024-01-01T10:00:00Z'; + const endTime = '2024-01-01T11:00:00Z'; + const raw = [{ id: 1, start_time: startTime, end_time: endTime }]; + vi.mocked(API.getGrid).mockResolvedValue(raw); + vi.mocked(dateTimeUtils.convertToMs) + .mockReturnValueOnce(dayjs(startTime).valueOf()) + .mockReturnValueOnce(dayjs(endTime).valueOf()); const result = await guideUtils.fetchPrograms(); - expect(API.getGrid).toHaveBeenCalledTimes(1); - expect(result).toHaveLength(1); - expect(result[0]).toHaveProperty('startMs'); - expect(result[0]).toHaveProperty('endMs'); + expect(API.getGrid).toHaveBeenCalled(); + expect(result[0].startMs).toBe(dayjs(startTime).valueOf()); + expect(result[0].endMs).toBe(dayjs(endTime).valueOf()); }); }); + // ── sortChannels ────────────────────────────────────────────────────────── + describe('sortChannels', () => { it('should sort channels by channel number', () => { const channels = { - 1: { id: 1, channel_number: 3 }, - 2: { id: 2, channel_number: 1 }, - 3: { id: 3, channel_number: 2 }, + a: { id: 1, channel_number: 3 }, + b: { id: 2, channel_number: 1 }, + c: { id: 3, channel_number: 2 }, }; - const result = guideUtils.sortChannels(channels); - - expect(result[0].channel_number).toBe(1); - expect(result[1].channel_number).toBe(2); - expect(result[2].channel_number).toBe(3); + expect(result.map((c) => c.channel_number)).toEqual([1, 2, 3]); }); it('should put channels without number at end', () => { const channels = { - 1: { id: 1, channel_number: 2 }, - 2: { id: 2, channel_number: null }, - 3: { id: 3, channel_number: 1 }, + a: { id: 1, channel_number: 2 }, + b: { id: 2, channel_number: null }, + c: { id: 3, channel_number: 1 }, }; - const result = guideUtils.sortChannels(channels); - expect(result[0].channel_number).toBe(1); expect(result[1].channel_number).toBe(2); expect(result[2].channel_number).toBeNull(); }); }); + // ── filterGuideChannels ─────────────────────────────────────────────────── + describe('filterGuideChannels', () => { + const channels = [ + { id: 1, name: 'ESPN', channel_group_id: 10 }, + { id: 2, name: 'CNN', channel_group_id: 20 }, + { id: 3, name: 'ESPN2', channel_group_id: 10 }, + ]; + it('should return all channels when no filters', () => { - const channels = [ - { id: 1, name: 'Channel 1' }, - { id: 2, name: 'Channel 2' }, - ]; - - const result = guideUtils.filterGuideChannels( - channels, - '', - 'all', - 'all', - {} - ); - - expect(result).toHaveLength(2); + const result = guideUtils.filterGuideChannels(channels, '', 'all', 'all', {}); + expect(result).toHaveLength(3); }); it('should filter by search query', () => { - const channels = [ - { id: 1, name: 'ESPN' }, - { id: 2, name: 'CNN' }, - ]; - - const result = guideUtils.filterGuideChannels( - channels, - 'espn', - 'all', - 'all', - {} - ); - - expect(result).toHaveLength(1); - expect(result[0].name).toBe('ESPN'); + const result = guideUtils.filterGuideChannels(channels, 'espn', 'all', 'all', {}); + expect(result).toHaveLength(2); + expect(result.map((c) => c.name)).toEqual(['ESPN', 'ESPN2']); }); it('should filter by channel group', () => { - const channels = [ - { id: 1, name: 'Channel 1', channel_group_id: 1 }, - { id: 2, name: 'Channel 2', channel_group_id: 2 }, - ]; - - const result = guideUtils.filterGuideChannels( - channels, - '', - '1', - 'all', - {} - ); - - expect(result).toHaveLength(1); - expect(result[0].channel_group_id).toBe(1); + const result = guideUtils.filterGuideChannels(channels, '', '10', 'all', {}); + expect(result).toHaveLength(2); + expect(result.every((c) => c.channel_group_id === 10)).toBe(true); }); it('should filter by profile with array of channels', () => { - const channels = [ - { id: 1, name: 'Channel 1' }, - { id: 2, name: 'Channel 2' }, - ]; const profiles = { - profile1: { + 'p1': { channels: [ { id: 1, enabled: true }, { id: 2, enabled: false }, - ], - }, - }; - - const result = guideUtils.filterGuideChannels( - channels, - '', - 'all', - 'profile1', - profiles - ); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe(1); - }); - - it('should filter by profile with Set of channels', () => { - const channels = [ - { id: 1, name: 'Channel 1' }, - { id: 2, name: 'Channel 2' }, - ]; - const profiles = { - profile1: { - channels: new Set([1]), - }, - }; - - const result = guideUtils.filterGuideChannels( - channels, - '', - 'all', - 'profile1', - profiles - ); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe(1); - }); - - it('should apply multiple filters together', () => { - const channels = [ - { id: 1, name: 'ESPN', channel_group_id: 1 }, - { id: 2, name: 'ESPN2', channel_group_id: 2 }, - { id: 3, name: 'CNN', channel_group_id: 1 }, - ]; - const profiles = { - profile1: { - channels: [ - { id: 1, enabled: true }, { id: 3, enabled: true }, ], }, }; + const result = guideUtils.filterGuideChannels(channels, '', 'all', 'p1', profiles); + expect(result.map((c) => c.id)).toEqual([1, 3]); + }); - const result = guideUtils.filterGuideChannels( - channels, - 'espn', - '1', - 'profile1', - profiles - ); + it('should filter by profile with Set of channels', () => { + const profiles = { + 'p1': { channels: new Set([1, 3]) }, + }; + const result = guideUtils.filterGuideChannels(channels, '', 'all', 'p1', profiles); + expect(result.map((c) => c.id)).toEqual([1, 3]); + }); + it('should apply multiple filters together', () => { + const profiles = { + 'p1': { channels: [{ id: 1, enabled: true }, { id: 3, enabled: true }] }, + }; + const result = guideUtils.filterGuideChannels(channels, 'espn2', '10', 'p1', profiles); expect(result).toHaveLength(1); - expect(result[0].id).toBe(1); + expect(result[0].name).toBe('ESPN2'); }); }); + // ── calculateEarliestProgramStart ───────────────────────────────────────── + describe('calculateEarliestProgramStart', () => { it('should return default when no programs', () => { - const defaultStart = dayjs('2024-01-15T00:00:00Z'); - + const defaultStart = dayjs('2024-01-01T10:00:00Z'); const result = guideUtils.calculateEarliestProgramStart([], defaultStart); - expect(result).toBe(defaultStart); }); it('should return earliest program start', () => { - dateTimeUtils.initializeTime.mockImplementation((time) => - dayjs.utc(time) - ); - dateTimeUtils.isBefore.mockImplementation((a, b) => - dayjs(a).isBefore(dayjs(b)) - ); + vi.mocked(dateTimeUtils.initializeTime).mockImplementation((t) => dayjs(t)); + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b))); + const defaultStart = dayjs('2024-01-01T10:00:00Z'); const programs = [ - { start_time: '2024-01-15T12:00:00Z' }, - { start_time: '2024-01-15T10:00:00Z' }, - { start_time: '2024-01-15T14:00:00Z' }, + { start_time: '2024-01-01T09:00:00Z' }, + { start_time: '2024-01-01T08:00:00Z' }, + { start_time: '2024-01-01T11:00:00Z' }, ]; - const defaultStart = dayjs.utc('2024-01-16T00:00:00Z'); - const result = guideUtils.calculateEarliestProgramStart( - programs, - defaultStart - ); - - expect(result.hour()).toBe(10); + const result = guideUtils.calculateEarliestProgramStart(programs, defaultStart); + expect(dayjs(result).toISOString()).toBe(dayjs('2024-01-01T08:00:00Z').toISOString()); }); }); + // ── calculateLatestProgramEnd ───────────────────────────────────────────── + describe('calculateLatestProgramEnd', () => { it('should return default when no programs', () => { - const defaultEnd = dayjs('2024-01-16T00:00:00Z'); - + const defaultEnd = dayjs('2024-01-01T22:00:00Z'); const result = guideUtils.calculateLatestProgramEnd([], defaultEnd); - expect(result).toBe(defaultEnd); }); it('should return latest program end', () => { - dateTimeUtils.initializeTime.mockImplementation((time) => - dayjs.utc(time) - ); - dateTimeUtils.isAfter.mockImplementation((a, b) => - dayjs(a).isAfter(dayjs(b)) - ); + vi.mocked(dateTimeUtils.initializeTime).mockImplementation((t) => dayjs(t)); + vi.mocked(dateTimeUtils.isAfter).mockImplementation((a, b) => dayjs(a).isAfter(dayjs(b))); + const defaultEnd = dayjs('2024-01-01T22:00:00Z'); const programs = [ - { end_time: '2024-01-15T12:00:00Z' }, - { end_time: '2024-01-15T18:00:00Z' }, - { end_time: '2024-01-15T14:00:00Z' }, + { end_time: '2024-01-01T20:00:00Z' }, + { end_time: '2024-01-01T23:00:00Z' }, + { end_time: '2024-01-01T21:00:00Z' }, ]; - const defaultEnd = dayjs.utc('2024-01-15T00:00:00Z'); const result = guideUtils.calculateLatestProgramEnd(programs, defaultEnd); - - expect(result.hour()).toBe(18); + expect(dayjs(result).toISOString()).toBe(dayjs('2024-01-01T23:00:00Z').toISOString()); }); }); + // ── calculateStart ──────────────────────────────────────────────────────── + describe('calculateStart', () => { it('should return earliest when before default', () => { - const earliest = dayjs('2024-01-15T08:00:00Z'); - const defaultStart = dayjs('2024-01-15T10:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(true); + const earliest = dayjs('2024-01-01T08:00:00Z'); + const defaultStart = dayjs('2024-01-01T10:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(true); const result = guideUtils.calculateStart(earliest, defaultStart); - expect(result).toBe(earliest); }); it('should return default when earliest is after', () => { - const earliest = dayjs('2024-01-15T12:00:00Z'); - const defaultStart = dayjs('2024-01-15T10:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(false); + const earliest = dayjs('2024-01-01T11:00:00Z'); + const defaultStart = dayjs('2024-01-01T10:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(false); const result = guideUtils.calculateStart(earliest, defaultStart); - expect(result).toBe(defaultStart); }); }); + // ── calculateEnd ────────────────────────────────────────────────────────── + describe('calculateEnd', () => { it('should return latest when after default', () => { - const latest = dayjs('2024-01-16T02:00:00Z'); - const defaultEnd = dayjs('2024-01-16T00:00:00Z'); - dateTimeUtils.isAfter.mockReturnValue(true); + const latest = dayjs('2024-01-01T23:00:00Z'); + const defaultEnd = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(true); const result = guideUtils.calculateEnd(latest, defaultEnd); - expect(result).toBe(latest); }); it('should return default when latest is before', () => { - const latest = dayjs('2024-01-15T22:00:00Z'); - const defaultEnd = dayjs('2024-01-16T00:00:00Z'); - dateTimeUtils.isAfter.mockReturnValue(false); + const latest = dayjs('2024-01-01T20:00:00Z'); + const defaultEnd = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(false); const result = guideUtils.calculateEnd(latest, defaultEnd); - expect(result).toBe(defaultEnd); }); }); + // ── mapChannelsById ─────────────────────────────────────────────────────── + describe('mapChannelsById', () => { it('should create map of channels by id', () => { const channels = [ - { id: 1, name: 'Channel 1' }, - { id: 2, name: 'Channel 2' }, + { id: 1, name: 'ESPN' }, + { id: 2, name: 'CNN' }, ]; - const result = guideUtils.mapChannelsById(channels); - - expect(result.get(1).name).toBe('Channel 1'); - expect(result.get(2).name).toBe('Channel 2'); + expect(result.get(1)).toEqual({ id: 1, name: 'ESPN' }); + expect(result.get(2)).toEqual({ id: 2, name: 'CNN' }); }); }); + // ── mapRecordingsByProgramId ─────────────────────────────────────────────── + describe('mapRecordingsByProgramId', () => { it('should return empty map for null recordings', () => { const result = guideUtils.mapRecordingsByProgramId(null); - expect(result.size).toBe(0); }); it('should map recordings by program id', () => { const recordings = [ { - id: 1, - custom_properties: { - program: { id: 'program-1' }, - }, - }, - { - id: 2, - custom_properties: { - program: { id: 'program-2' }, - }, + custom_properties: { program: { id: 42 }, status: 'pending' }, }, ]; - const result = guideUtils.mapRecordingsByProgramId(recordings); - - expect(result.get('program-1').id).toBe(1); - expect(result.get('program-2').id).toBe(2); + expect(result.has(42)).toBe(true); }); it('should skip recordings without program id', () => { const recordings = [ - { - id: 1, - custom_properties: {}, - }, + { custom_properties: { status: 'pending' } }, + { custom_properties: { program: {}, status: 'pending' } }, ]; - const result = guideUtils.mapRecordingsByProgramId(recordings); - expect(result.size).toBe(0); }); it('should exclude terminal status recordings', () => { - const recordings = [ - { - id: 1, - custom_properties: { program: { id: 'p1' }, status: 'completed' }, - }, - { - id: 2, - custom_properties: { program: { id: 'p2' }, status: 'stopped' }, - }, - { - id: 3, - custom_properties: { program: { id: 'p3' }, status: 'interrupted' }, - }, - { - id: 4, - custom_properties: { program: { id: 'p4' }, status: 'failed' }, - }, - { - id: 5, - custom_properties: { program: { id: 'p5' }, status: 'recording' }, - }, - { id: 6, custom_properties: { program: { id: 'p6' } } }, - ]; - + const terminalStatuses = ['stopped', 'completed', 'interrupted', 'failed']; + const recordings = terminalStatuses.map((status, i) => ({ + custom_properties: { program: { id: i + 1 }, status }, + })); const result = guideUtils.mapRecordingsByProgramId(recordings); + expect(result.size).toBe(0); + }); + it('should include non-terminal status recordings', () => { + const recordings = [ + { custom_properties: { program: { id: 1 }, status: 'pending' } }, + { custom_properties: { program: { id: 2 }, status: 'recording' } }, + ]; + const result = guideUtils.mapRecordingsByProgramId(recordings); expect(result.size).toBe(2); - expect(result.get('p5').id).toBe(5); - expect(result.get('p6').id).toBe(6); }); }); + // ── formatTime ──────────────────────────────────────────────────────────── + describe('formatTime', () => { it('should return "Today" for today', () => { - const today = dayjs(); - dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') + const now = dayjs(); + vi.mocked(dateTimeUtils.getNow).mockReturnValue(now); + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.isSame.mockReturnValueOnce(true); - - const result = guideUtils.formatTime(today, 'MM/DD'); + const result = guideUtils.formatTime(now, 'MMM D'); expect(result).toBe('Today'); }); it('should return "Tomorrow" for tomorrow', () => { - const today = dayjs(); - const tomorrow = today.add(1, 'day'); - dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') + const now = dayjs(); + const tomorrow = now.add(1, 'day'); + vi.mocked(dateTimeUtils.getNow).mockReturnValue(now); + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.isSame.mockReturnValueOnce(false).mockReturnValueOnce(true); - - const result = guideUtils.formatTime(tomorrow, 'MM/DD'); + const result = guideUtils.formatTime(tomorrow, 'MMM D'); expect(result).toBe('Tomorrow'); }); it('should return day name within a week', () => { - const today = dayjs(); - const future = today.add(3, 'day'); - dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') + const now = dayjs(); + const inThreeDays = now.add(3, 'day'); + vi.mocked(dateTimeUtils.getNow).mockReturnValue(now); + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.isSame.mockReturnValue(false); - dateTimeUtils.isBefore.mockReturnValue(true); - dateTimeUtils.format.mockReturnValue('Wednesday'); + vi.mocked(dateTimeUtils.format).mockImplementation((t, fmt) => dayjs(t).format(fmt)); - const result = guideUtils.formatTime(future, 'MM/DD'); - - expect(result).toBe('Wednesday'); + const result = guideUtils.formatTime(inThreeDays, 'MMM D'); + expect(result).toBe(inThreeDays.format('dddd')); }); it('should return formatted date beyond a week', () => { - const today = dayjs(); - const future = today.add(10, 'day'); - dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') + const now = dayjs(); + const beyondWeek = now.add(10, 'day'); + vi.mocked(dateTimeUtils.getNow).mockReturnValue(now); + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.isSame.mockReturnValue(false); - dateTimeUtils.isBefore.mockReturnValue(false); - dateTimeUtils.format.mockReturnValue('01/25'); + vi.mocked(dateTimeUtils.format).mockImplementation((t, fmt) => dayjs(t).format(fmt)); - const result = guideUtils.formatTime(future, 'MM/DD'); - - expect(result).toBe('01/25'); + const result = guideUtils.formatTime(beyondWeek, 'MMM D'); + expect(result).toBe(beyondWeek.format('MMM D')); }); }); + // ── calculateHourTimeline ───────────────────────────────────────────────── + describe('calculateHourTimeline', () => { it('should generate hours between start and end', () => { - const start = dayjs('2024-01-15T10:00:00Z'); - const end = dayjs('2024-01-15T13:00:00Z'); - dateTimeUtils.isBefore.mockImplementation((a, b) => + const start = dayjs('2024-01-01T10:00:00Z'); + const end = dayjs('2024-01-01T13:00:00Z'); + + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') - ); - dateTimeUtils.isSame.mockReturnValue(true); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); - const formatDayLabel = vi.fn((time) => 'Today'); - const result = guideUtils.calculateHourTimeline( - start, - end, - formatDayLabel - ); + const formatDayLabel = vi.fn((t) => dayjs(t).format('MMM D')); + const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel); expect(result).toHaveLength(3); - expect(formatDayLabel).toHaveBeenCalledTimes(3); }); it('should mark new day transitions', () => { - const start = dayjs('2024-01-15T23:00:00Z'); - const end = dayjs('2024-01-16T02:00:00Z'); - dateTimeUtils.isBefore.mockImplementation((a, b) => + const start = dayjs('2024-01-01T23:00:00Z'); + const end = dayjs('2024-01-02T02:00:00Z'); + + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => + dayjs(t).utc().startOf('day') ); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') - ); - dateTimeUtils.isSame.mockImplementation((a, b, unit) => - dayjs(a).isSame(dayjs(b), unit) + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).utc().isSame(dayjs(b).utc(), unit) ); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); - const formatDayLabel = vi.fn((time) => 'Day'); - const result = guideUtils.calculateHourTimeline( - start, - end, - formatDayLabel - ); + const formatDayLabel = vi.fn((t) => dayjs(t).utc().format('MMM D')); + const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel); - expect(result[0].isNewDay).toBe(true); + expect(result).toHaveLength(3); + expect(result[0].isNewDay).toBe(true); // 23:00 UTC — first entry, always new day + expect(result[1].isNewDay).toBe(true); // 00:00 UTC — crosses into Jan 2 + expect(result[2].isNewDay).toBe(false); // 01:00 UTC — same day as 00:00 }); }); + // ── calculateNowPosition ────────────────────────────────────────────────── + describe('calculateNowPosition', () => { it('should return -1 when now is before start', () => { - const now = dayjs('2024-01-15T09:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const end = dayjs('2024-01-15T18:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(true); + const now = dayjs('2024-01-01T09:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + const end = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(true); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(false); const result = guideUtils.calculateNowPosition(now, start, end); - expect(result).toBe(-1); }); it('should return -1 when now is after end', () => { - const now = dayjs('2024-01-15T19:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const end = dayjs('2024-01-15T18:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(false); - dateTimeUtils.isAfter.mockReturnValue(true); + const now = dayjs('2024-01-01T23:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + const end = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(false); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(true); const result = guideUtils.calculateNowPosition(now, start, end); - expect(result).toBe(-1); }); it('should calculate position when now is between start and end', () => { - const now = dayjs('2024-01-15T11:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const end = dayjs('2024-01-15T18:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(false); - dateTimeUtils.isAfter.mockReturnValue(false); - dateTimeUtils.diff.mockReturnValue(60); + const now = dayjs('2024-01-01T11:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + const end = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(false); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(false); + vi.mocked(dateTimeUtils.diff).mockReturnValue(60); // 60 minutes const result = guideUtils.calculateNowPosition(now, start, end); - - expect(result).toBeGreaterThan(0); + // 60 minutes / 15 min increment * MINUTE_BLOCK_WIDTH(112.5) + expect(result).toBe((60 / 15) * guideUtils.MINUTE_BLOCK_WIDTH); }); }); + // ── calculateScrollPosition ─────────────────────────────────────────────── + describe('calculateScrollPosition', () => { it('should calculate scroll position for current time', () => { - const now = dayjs('2024-01-15T11:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const rounded = dayjs('2024-01-15T11:00:00Z'); - dateTimeUtils.roundToNearest.mockReturnValue(rounded); - dateTimeUtils.diff.mockReturnValue(60); + const now = dayjs('2024-01-01T11:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + vi.mocked(dateTimeUtils.roundToNearest).mockReturnValue(now); + vi.mocked(dateTimeUtils.diff).mockReturnValue(60); const result = guideUtils.calculateScrollPosition(now, start); - - expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeGreaterThan(0); }); it('should return 0 when calculated position is negative', () => { - const now = dayjs('2024-01-15T10:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const rounded = dayjs('2024-01-15T10:00:00Z'); - dateTimeUtils.roundToNearest.mockReturnValue(rounded); - dateTimeUtils.diff.mockReturnValue(0); + const now = dayjs('2024-01-01T10:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + vi.mocked(dateTimeUtils.roundToNearest).mockReturnValue(now); + vi.mocked(dateTimeUtils.diff).mockReturnValue(0); const result = guideUtils.calculateScrollPosition(now, start); - expect(result).toBe(0); }); }); + // ── matchChannelByTvgId ─────────────────────────────────────────────────── + describe('matchChannelByTvgId', () => { it('should return null when no matching channel ids', () => { const channelIdByTvgId = new Map(); - const channelById = new Map(); - - const result = guideUtils.matchChannelByTvgId( - channelIdByTvgId, - channelById, - 'tvg-1' - ); + const channelById = new Map([[1, { id: 1, name: 'ESPN' }]]); + const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1'); expect(result).toBeNull(); }); it('should return first matching channel', () => { - const channel = { id: 1, name: 'Channel 1' }; - const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]); - const channelById = new Map([[1, channel]]); + const channelIdByTvgId = new Map([['tvg-1', [1, 2]]]); + const channelById = new Map([ + [1, { id: 1, name: 'ESPN' }], + [2, { id: 2, name: 'ESPN HD' }], + ]); - const result = guideUtils.matchChannelByTvgId( - channelIdByTvgId, - channelById, - 'tvg-1' - ); - - expect(result).toBe(channel); + const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1'); + expect(result).toEqual({ id: 1, name: 'ESPN' }); }); it('should return null when channel not in channelById map', () => { - const channelIdByTvgId = new Map([['tvg-1', [999]]]); - const channelById = new Map(); - - const result = guideUtils.matchChannelByTvgId( - channelIdByTvgId, - channelById, - 'tvg-1' - ); + const channelIdByTvgId = new Map([['tvg-1', [99]]]); + const channelById = new Map([[1, { id: 1 }]]); + const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1'); expect(result).toBeNull(); }); }); + // ── fetchRules ──────────────────────────────────────────────────────────── + describe('fetchRules', () => { it('should fetch series rules from API', async () => { - const mockRules = [{ id: 1, tvg_id: 'tvg-1' }]; - API.listSeriesRules.mockResolvedValue(mockRules); + const rules = [{ tvg_id: 'tvg-1', title: 'Show' }]; + vi.mocked(API.listSeriesRules).mockResolvedValue(rules); const result = await guideUtils.fetchRules(); - expect(API.listSeriesRules).toHaveBeenCalledTimes(1); - expect(result).toBe(mockRules); + expect(API.listSeriesRules).toHaveBeenCalled(); + expect(result).toEqual(rules); }); }); + // ── getRuleByProgram ────────────────────────────────────────────────────── + describe('getRuleByProgram', () => { it('should return null when no rules', () => { - const program = { tvg_id: 'tvg-1', title: 'Show' }; - - const result = guideUtils.getRuleByProgram(null, program); - + const result = guideUtils.getRuleByProgram(null, { tvg_id: 'tvg-1' }); expect(result).toBeUndefined(); }); it('should find rule by tvg_id without title', () => { - const rules = [{ tvg_id: 'tvg-1', title: null }]; - const program = { tvg_id: 'tvg-1', title: 'Show' }; - - const result = guideUtils.getRuleByProgram(rules, program); - - expect(result).toBe(rules[0]); + const rules = [{ tvg_id: 'tvg-1', title: '' }]; + const result = guideUtils.getRuleByProgram(rules, { tvg_id: 'tvg-1', title: 'Anything' }); + expect(result).toEqual(rules[0]); }); it('should find rule by tvg_id and title', () => { @@ -974,274 +764,272 @@ describe('guideUtils', () => { { tvg_id: 'tvg-1', title: 'Show A' }, { tvg_id: 'tvg-1', title: 'Show B' }, ]; - const program = { tvg_id: 'tvg-1', title: 'Show B' }; - - const result = guideUtils.getRuleByProgram(rules, program); - - expect(result).toBe(rules[1]); + const result = guideUtils.getRuleByProgram(rules, { tvg_id: 'tvg-1', title: 'Show B' }); + expect(result).toEqual(rules[1]); }); it('should handle string comparison for tvg_id', () => { - const rules = [{ tvg_id: 123, title: null }]; - const program = { tvg_id: '123', title: 'Show' }; - - const result = guideUtils.getRuleByProgram(rules, program); - - expect(result).toBe(rules[0]); + const rules = [{ tvg_id: 123, title: '' }]; + const result = guideUtils.getRuleByProgram(rules, { tvg_id: '123', title: 'Show' }); + expect(result).toEqual(rules[0]); }); }); + // ── createRecording ─────────────────────────────────────────────────────── + describe('createRecording', () => { it('should create recording via API', async () => { - const channel = { id: 1 }; - const program = { - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }; + vi.mocked(API.createRecording).mockResolvedValue({}); + const values = { channel_id: 1, start_time: '2024-01-01T10:00:00Z' }; - await guideUtils.createRecording(channel, program); + await guideUtils.createRecording(values); - expect(API.createRecording).toHaveBeenCalledWith({ - channel: '1', - start_time: program.start_time, - end_time: program.end_time, - custom_properties: { program }, - }); + expect(API.createRecording).toHaveBeenCalledWith(values); }); }); + // ── createSeriesRule ────────────────────────────────────────────────────── + describe('createSeriesRule', () => { it('should create series rule via API', async () => { - const program = { tvg_id: 'tvg-1', title: 'Show' }; - const mode = 'all'; + vi.mocked(API.createSeriesRule).mockResolvedValue({}); + const values = { tvg_id: 'tvg-1', title: 'Show' }; - await guideUtils.createSeriesRule(program, mode); + await guideUtils.createSeriesRule(values); - expect(API.createSeriesRule).toHaveBeenCalledWith({ - tvg_id: program.tvg_id, - mode, - title: program.title, - }); + expect(API.createSeriesRule).toHaveBeenCalledWith(values); }); }); + // ── evaluateSeriesRule ──────────────────────────────────────────────────── + describe('evaluateSeriesRule', () => { it('should evaluate series rule via API', async () => { - const program = { tvg_id: 'tvg-1' }; + vi.mocked(API.evaluateSeriesRules).mockResolvedValue({}); - await guideUtils.evaluateSeriesRule(program); + await guideUtils.evaluateSeriesRulesByTvgId('tvg-1'); - expect(API.evaluateSeriesRules).toHaveBeenCalledWith(program.tvg_id); + expect(API.evaluateSeriesRules).toHaveBeenCalledWith('tvg-1'); }); }); + // ── calculateLeftScrollPosition ─────────────────────────────────────────── + describe('calculateLeftScrollPosition', () => { it('should calculate left position using startMs', () => { - const program = { - startMs: dayjs.utc('2024-01-15T11:00:00Z').valueOf(), - }; - const start = dayjs.utc('2024-01-15T10:00:00Z').valueOf(); - dateTimeUtils.convertToMs.mockImplementation((time) => { - if (typeof time === 'number') return time; - return dayjs.utc(time).valueOf(); - }); + const startMs = 60 * 60 * 1000; // 1 hour in ms + const start = '2024-01-01T00:00:00Z'; + vi.mocked(dateTimeUtils.convertToMs).mockReturnValue(0); + const program = { startMs }; const result = guideUtils.calculateLeftScrollPosition(program, start); - expect(result).toBeGreaterThanOrEqual(0); + // (60 min / 15 min increment) * MINUTE_BLOCK_WIDTH + expect(result).toBe((60 / 15) * guideUtils.MINUTE_BLOCK_WIDTH); }); it('should calculate left position from start_time when no startMs', () => { - const program = { - start_time: '2024-01-15T10:30:00Z', - }; - const start = '2024-01-15T10:00:00Z'; - dateTimeUtils.convertToMs.mockImplementation((time) => - dayjs(time).valueOf() - ); + const startTimeMs = 30 * 60 * 1000; // 30 min + vi.mocked(dateTimeUtils.convertToMs) + .mockReturnValueOnce(startTimeMs) // program start_time + .mockReturnValueOnce(0); // guide start - const result = guideUtils.calculateLeftScrollPosition(program, start); + const program = { start_time: '2024-01-01T00:30:00Z' }; + const result = guideUtils.calculateLeftScrollPosition(program, '2024-01-01T00:00:00Z'); - expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBe((30 / 15) * guideUtils.MINUTE_BLOCK_WIDTH); }); }); + // ── calculateDesiredScrollPosition ──────────────────────────────────────── + describe('calculateDesiredScrollPosition', () => { it('should subtract 20 from left position', () => { const result = guideUtils.calculateDesiredScrollPosition(100); - expect(result).toBe(80); }); it('should return 0 when result would be negative', () => { const result = guideUtils.calculateDesiredScrollPosition(10); - expect(result).toBe(0); }); }); + // ── calculateScrollPositionByTimeClick ──────────────────────────────────── + describe('calculateScrollPositionByTimeClick', () => { + const makeEvent = (clientX, rectLeft, rectWidth) => ({ + currentTarget: { + getBoundingClientRect: () => ({ left: rectLeft, width: rectWidth }), + }, + clientX, + }); + it('should calculate scroll position from time click', () => { - const event = { - currentTarget: { - getBoundingClientRect: () => ({ left: 100, width: 450 }), - }, - clientX: 325, - }; - const clickedTime = dayjs('2024-01-15T10:00:00Z'); - const start = dayjs('2024-01-15T09:00:00Z'); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) - ); - dateTimeUtils.diff.mockReturnValue(60); + vi.mocked(dateTimeUtils.diff).mockReturnValue(60); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => ({ + ...dayjs(t).add(n, u), + minute: (m) => dayjs(t).add(n, u).minute(m), + })); - const result = guideUtils.calculateScrollPositionByTimeClick( - event, - clickedTime, - start - ); + const clickedTime = { minute: vi.fn().mockReturnThis() }; + const start = dayjs('2024-01-01T10:00:00Z'); + // Click at 50% of a 600px element → 30 min into hour → snaps to 30 + const event = makeEvent(350, 100, 500); + vi.mocked(dateTimeUtils.diff).mockReturnValue(90); + + const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start); expect(result).toBeGreaterThanOrEqual(0); }); it('should snap to 15-minute increments', () => { - const event = { - currentTarget: { - getBoundingClientRect: () => ({ left: 0, width: 450 }), - }, - clientX: 112.5, - }; - const clickedTime = dayjs('2024-01-15T10:00:00Z'); - const start = dayjs('2024-01-15T09:00:00Z'); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) - ); - dateTimeUtils.diff.mockReturnValue(75); - - guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start); - - expect(dateTimeUtils.diff).toHaveBeenCalled(); - }); - - it('should handle click at end of hour', () => { - const event = { - currentTarget: { - getBoundingClientRect: () => ({ left: 0, width: 450 }), - }, - clientX: 450, - }; - const clickedTime = dayjs('2024-01-15T10:00:00Z'); - const start = dayjs('2024-01-15T09:00:00Z'); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) - ); - dateTimeUtils.diff.mockReturnValue(120); + // Click at 10% of a 600px element → 6 min → snaps to 0 + const event = makeEvent(160, 100, 600); + const clickedTime = { minute: vi.fn().mockReturnThis() }; + vi.mocked(dateTimeUtils.diff).mockReturnValue(0); const result = guideUtils.calculateScrollPositionByTimeClick( event, clickedTime, - start + dayjs() ); + expect(result).toBe(0); + }); - expect(dateTimeUtils.add).toHaveBeenCalledWith( - expect.anything(), - 1, - 'hour' + it('should handle click at end of hour (snappedMinute === 60)', () => { + // 100% across the element → 60 min → snappedMinute = 60 → use add(1, hour).minute(0) + const event = makeEvent(700, 100, 600); + const nextHour = dayjs('2024-01-01T11:00:00Z'); + const addResult = { minute: vi.fn().mockReturnValue(nextHour) }; + vi.mocked(dateTimeUtils.add).mockReturnValue(addResult); + vi.mocked(dateTimeUtils.diff).mockReturnValue(60); + + const result = guideUtils.calculateScrollPositionByTimeClick( + event, + dayjs('2024-01-01T10:00:00Z'), + dayjs('2024-01-01T10:00:00Z') ); + expect(result).toBeGreaterThanOrEqual(0); }); }); + // ── getGroupOptions ─────────────────────────────────────────────────────── + describe('getGroupOptions', () => { it('should return only "All" when no channel groups', () => { const result = guideUtils.getGroupOptions(null, []); - - expect(result).toHaveLength(1); - expect(result[0].value).toBe('all'); + expect(result).toEqual([{ value: 'all', label: 'All Channel Groups' }]); }); it('should include groups used by channels', () => { const channelGroups = { - 1: { id: 1, name: 'Sports' }, - 2: { id: 2, name: 'News' }, + 1: { id: 10, name: 'Sports' }, + 2: { id: 20, name: 'News' }, }; - const channels = [ - { id: 1, channel_group_id: 1 }, - { id: 2, channel_group_id: 1 }, + const guideChannels = [ + { id: 1, channel_group_id: 10 }, + { id: 2, channel_group_id: 20 }, ]; - const result = guideUtils.getGroupOptions(channelGroups, channels); - - expect(result).toHaveLength(2); - expect(result[1].label).toBe('Sports'); + const result = guideUtils.getGroupOptions(channelGroups, guideChannels); + expect(result).toHaveLength(3); + expect(result.map((o) => o.label)).toContain('Sports'); + expect(result.map((o) => o.label)).toContain('News'); }); it('should exclude groups not used by any channel', () => { const channelGroups = { - 1: { id: 1, name: 'Sports' }, - 2: { id: 2, name: 'News' }, + 1: { id: 10, name: 'Sports' }, + 2: { id: 99, name: 'Unused' }, }; - const channels = [{ id: 1, channel_group_id: 1 }]; + const guideChannels = [{ id: 1, channel_group_id: 10 }]; - const result = guideUtils.getGroupOptions(channelGroups, channels); - - expect(result).toHaveLength(2); - expect(result[1].label).toBe('Sports'); + const result = guideUtils.getGroupOptions(channelGroups, guideChannels); + expect(result.map((o) => o.label)).not.toContain('Unused'); }); it('should sort groups alphabetically', () => { const channelGroups = { - 1: { id: 1, name: 'Z Group' }, - 2: { id: 2, name: 'A Group' }, - 3: { id: 3, name: 'M Group' }, + 1: { id: 10, name: 'Sports' }, + 2: { id: 20, name: 'Movies' }, + 3: { id: 30, name: 'News' }, }; - const channels = [ - { id: 1, channel_group_id: 1 }, - { id: 2, channel_group_id: 2 }, - { id: 3, channel_group_id: 3 }, + const guideChannels = [ + { id: 1, channel_group_id: 10 }, + { id: 2, channel_group_id: 20 }, + { id: 3, channel_group_id: 30 }, ]; - const result = guideUtils.getGroupOptions(channelGroups, channels); - - expect(result[1].label).toBe('A Group'); - expect(result[2].label).toBe('M Group'); - expect(result[3].label).toBe('Z Group'); + const result = guideUtils.getGroupOptions(channelGroups, guideChannels); + const labels = result.slice(1).map((o) => o.label); + expect(labels).toEqual([...labels].sort()); }); }); + // ── getProfileOptions ───────────────────────────────────────────────────── + describe('getProfileOptions', () => { it('should return only "All" when no profiles', () => { const result = guideUtils.getProfileOptions(null); - - expect(result).toHaveLength(1); - expect(result[0].value).toBe('all'); + expect(result).toEqual([{ value: 'all', label: 'All Profiles' }]); }); it('should include all profiles except id 0', () => { const profiles = { - 0: { id: '0', name: 'All' }, - 1: { id: '1', name: 'Profile 1' }, - 2: { id: '2', name: 'Profile 2' }, + '0': { id: '0', name: 'Default' }, + '1': { id: '1', name: 'Kids' }, + '2': { id: '2', name: 'Sports' }, }; const result = guideUtils.getProfileOptions(profiles); - - expect(result).toHaveLength(3); - expect(result[1].label).toBe('Profile 1'); - expect(result[2].label).toBe('Profile 2'); + expect(result).toHaveLength(3); // All + Kids + Sports + expect(result.map((o) => o.label)).not.toContain('Default'); + expect(result.map((o) => o.label)).toContain('Kids'); + expect(result.map((o) => o.label)).toContain('Sports'); }); }); + // ── calcProgressPct ─────────────────────────────────────────────────────── + + describe('calcProgressPct', () => { + it('should return 0 when now is at start', () => { + const result = guideUtils.calcProgressPct(1000, 1000, 60000); + expect(result).toBeGreaterThanOrEqual(0); + }); + + it('should return 1 when now is at or past end', () => { + // nowMs >= startMs + durationMs → elapsed >= duration → clamped to 1 + const startMs = 0; + const durationMs = 60 * 60 * 1000; // 1 hour + const nowMs = startMs + durationMs + 1000; // past the end + const result = guideUtils.calcProgressPct(nowMs, startMs, durationMs); + expect(result).toBe(1); + }); + + it('should return value between 0 and 1 for midpoint', () => { + const startMs = 0; + const durationMs = 60 * 60 * 1000; // 1 hour + const nowMs = 30 * 60 * 1000; // 30 minutes in + const result = guideUtils.calcProgressPct(nowMs, startMs, durationMs); + expect(result).toBeGreaterThan(0); + expect(result).toBeLessThan(1); + }); + }); + + // ── formatSeasonEpisode ─────────────────────────────────────────────────── + describe('formatSeasonEpisode', () => { it('should format both season and episode', () => { - expect(guideUtils.formatSeasonEpisode(1, 3)).toBe('S01E03'); + expect(guideUtils.formatSeasonEpisode(1, 2)).toBe('S01E02'); }); it('should pad numbers to 2 digits', () => { - expect(guideUtils.formatSeasonEpisode(1, 1)).toBe('S01E01'); + expect(guideUtils.formatSeasonEpisode(3, 7)).toBe('S03E07'); }); it('should handle large numbers without truncation', () => { - expect(guideUtils.formatSeasonEpisode(12, 24)).toBe('S12E24'); + expect(guideUtils.formatSeasonEpisode(10, 10)).toBe('S10E10'); }); it('should handle numbers greater than 99', () => { @@ -1249,19 +1037,19 @@ describe('guideUtils', () => { }); it('should return season only when episode is null', () => { - expect(guideUtils.formatSeasonEpisode(5, null)).toBe('S05'); + expect(guideUtils.formatSeasonEpisode(2, null)).toBe('S02'); }); it('should return season only when episode is undefined', () => { - expect(guideUtils.formatSeasonEpisode(5, undefined)).toBe('S05'); + expect(guideUtils.formatSeasonEpisode(2, undefined)).toBe('S02'); }); it('should return episode only when season is null', () => { - expect(guideUtils.formatSeasonEpisode(null, 7)).toBe('E07'); + expect(guideUtils.formatSeasonEpisode(null, 5)).toBe('E05'); }); it('should return episode only when season is undefined', () => { - expect(guideUtils.formatSeasonEpisode(undefined, 7)).toBe('E07'); + expect(guideUtils.formatSeasonEpisode(undefined, 5)).toBe('E05'); }); it('should return null when both are null', () => { @@ -1277,32 +1065,44 @@ describe('guideUtils', () => { }); it('should handle season zero with episode', () => { - expect(guideUtils.formatSeasonEpisode(0, 5)).toBe('S00E05'); + expect(guideUtils.formatSeasonEpisode(0, 1)).toBe('S00E01'); }); }); + // ── deleteSeriesRuleByTvgId ─────────────────────────────────────────────── + describe('deleteSeriesRuleByTvgId', () => { it('should delete series rule via API with tvg_id and title', async () => { + vi.mocked(API.deleteSeriesRule).mockResolvedValue({}); + await guideUtils.deleteSeriesRuleByTvgId('tvg-1', 'My Show'); expect(API.deleteSeriesRule).toHaveBeenCalledWith('tvg-1', 'My Show'); }); it('should forward undefined title when not provided', async () => { + vi.mocked(API.deleteSeriesRule).mockResolvedValue({}); + await guideUtils.deleteSeriesRuleByTvgId('tvg-1'); expect(API.deleteSeriesRule).toHaveBeenCalledWith('tvg-1', undefined); }); it('should work with empty tvg_id for title-only rules', async () => { + vi.mocked(API.deleteSeriesRule).mockResolvedValue({}); + await guideUtils.deleteSeriesRuleByTvgId('', 'Title-Only Show'); expect(API.deleteSeriesRule).toHaveBeenCalledWith('', 'Title-Only Show'); }); }); + // ── evaluateSeriesRulesByTvgId ──────────────────────────────────────────── + describe('evaluateSeriesRulesByTvgId', () => { it('should evaluate series rules via API', async () => { + vi.mocked(API.evaluateSeriesRules).mockResolvedValue({}); + await guideUtils.evaluateSeriesRulesByTvgId('tvg-1'); expect(API.evaluateSeriesRules).toHaveBeenCalledWith('tvg-1');