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');