{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__/Stats.test.jsx b/frontend/src/pages/__tests__/Stats.test.jsx
index d809a47a..92cf8560 100644
--- a/frontend/src/pages/__tests__/Stats.test.jsx
+++ b/frontend/src/pages/__tests__/Stats.test.jsx
@@ -220,8 +220,8 @@ describe('StatsPage', () => {
render(
);
await waitFor(() => {
- expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
- expect(getVODStats).toHaveBeenCalledTimes(2);
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
+ expect(getVODStats).toHaveBeenCalledTimes(1);
});
});
@@ -283,33 +283,37 @@ describe('StatsPage', () => {
render(
);
- expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
- expect(getVODStats).toHaveBeenCalledTimes(2);
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
+ expect(getVODStats).toHaveBeenCalledTimes(1);
// Advance timers by 5 seconds
await act(async () => {
vi.advanceTimersByTime(5000);
});
- expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3);
- expect(getVODStats).toHaveBeenCalledTimes(3);
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+ expect(getVODStats).toHaveBeenCalledTimes(2);
vi.useRealTimers();
});
- it('does not poll when interval is 0', async () => {
+ it('does not poll when interval is 0 but still fetches once on mount', async () => {
vi.useFakeTimers();
useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]);
render(
);
+ // Should still fetch once on mount even with interval = 0
expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
+ expect(getVODStats).toHaveBeenCalledTimes(1);
await act(async () => {
vi.advanceTimersByTime(10000);
});
+ // Should not have polled — count stays at 1
expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
+ expect(getVODStats).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
@@ -319,7 +323,7 @@ describe('StatsPage', () => {
const { unmount } = render(
);
- expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
unmount();
@@ -328,7 +332,7 @@ describe('StatsPage', () => {
});
// Should not fetch again after unmount
- expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
@@ -338,14 +342,14 @@ describe('StatsPage', () => {
it('refreshes stats when Refresh Now button is clicked', async () => {
render(
);
- expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1);
const refreshButton = screen.getByText('Refresh Now');
fireEvent.click(refreshButton);
await waitFor(() => {
- expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3);
- expect(getVODStats).toHaveBeenCalledTimes(3);
+ expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2);
+ expect(getVODStats).toHaveBeenCalledTimes(2);
});
});
});
@@ -406,14 +410,14 @@ describe('StatsPage', () => {
render(
);
await waitFor(() => {
- expect(getVODStats).toHaveBeenCalledTimes(2);
+ expect(getVODStats).toHaveBeenCalledTimes(1);
});
const stopButton = await screen.findByTestId('stop-vod-client-client-1');
fireEvent.click(stopButton);
await waitFor(() => {
- expect(getVODStats).toHaveBeenCalledTimes(3);
+ expect(getVODStats).toHaveBeenCalledTimes(2);
});
});
});
diff --git a/frontend/src/pages/__tests__/guideUtils.test.js b/frontend/src/pages/__tests__/guideUtils.test.js
index 18307016..2ca149c3 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,260 @@ 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);
});
});
- describe('evaluateSeriesRule', () => {
- it('should evaluate series rule via API', async () => {
- const program = { tvg_id: 'tvg-1' };
-
- await guideUtils.evaluateSeriesRule(program);
-
- expect(API.evaluateSeriesRules).toHaveBeenCalledWith(program.tvg_id);
- });
- });
+ // ── 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 +1025,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 +1053,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');
diff --git a/frontend/src/utils/__tests__/dateTimeUtils.test.js b/frontend/src/utils/__tests__/dateTimeUtils.test.js
index bfdc4fbd..5ab6ed48 100644
--- a/frontend/src/utils/__tests__/dateTimeUtils.test.js
+++ b/frontend/src/utils/__tests__/dateTimeUtils.test.js
@@ -62,6 +62,24 @@ describe('dateTimeUtils', () => {
const result = dateTimeUtils.initializeTime(date);
expect(result.format()).toBe(dayjs(date).format());
});
+
+ it('should handle custom format and locale', () => {
+ const result = dateTimeUtils.initializeTime('15/01/2024', 'DD/MM/YYYY');
+ expect(result.isValid()).toBe(true);
+ expect(result.date()).toBe(15);
+ expect(result.month()).toBe(0); // January = 0
+ expect(result.year()).toBe(2024);
+ });
+
+ it('should handle strict parsing', () => {
+ // With strict=true, a date that doesn't match the format should be invalid
+ const invalid = dateTimeUtils.initializeTime('15-01-2024', 'YYYY-MM-DD', null, true);
+ expect(invalid.isValid()).toBe(false);
+
+ // With strict=true and a matching format, it should be valid
+ const valid = dateTimeUtils.initializeTime('2024-01-15', 'YYYY-MM-DD', null, true);
+ expect(valid.isValid()).toBe(true);
+ });
});
describe('startOfDay', () => {
@@ -433,7 +451,7 @@ describe('dateTimeUtils', () => {
});
it('should return original string for unparseable format', () => {
- expect(dateTimeUtils.toTimeString('2:30 PM')).toBe('2:30 PM');
+ expect(dateTimeUtils.toTimeString('not-a-time')).toBe('not-a-time');
});
it('should return original string for invalid format', () => {
@@ -712,4 +730,61 @@ describe('dateTimeUtils', () => {
});
});
});
+
+ describe('isValid', () => {
+ it('should return true for a valid date string', () => {
+ expect(dateTimeUtils.isValid('2024-01-15T10:00:00Z')).toBe(true);
+ });
+
+ it('should return false for an invalid date string', () => {
+ expect(dateTimeUtils.isValid('not-a-date')).toBe(false);
+ });
+
+ it('should return true for a Date object', () => {
+ expect(dateTimeUtils.isValid(new Date())).toBe(true);
+ });
+
+ it('should return false for an invalid Date object', () => {
+ expect(dateTimeUtils.isValid(new Date('invalid'))).toBe(false);
+ });
+ });
+
+ describe('toDate', () => {
+ it('should convert a date string to a Date object', () => {
+ const result = dateTimeUtils.toDate('2024-01-15T10:00:00Z');
+ expect(result).toBeInstanceOf(Date);
+ });
+
+ it('should convert a dayjs object to a Date object', () => {
+ const djs = dayjs('2024-01-15T10:00:00Z');
+ const result = dateTimeUtils.toDate(djs);
+ expect(result).toBeInstanceOf(Date);
+ });
+ });
+
+ describe('setMillisecond', () => {
+ it('should set the millisecond on a date', () => {
+ const date = dayjs.utc('2024-01-15T10:00:00.000Z');
+ const result = dateTimeUtils.setMillisecond(date, 500);
+ expect(result.millisecond()).toBe(500);
+ });
+
+ it('should return 0 milliseconds when set to 0', () => {
+ const date = dayjs.utc('2024-01-15T10:00:00.999Z');
+ const result = dateTimeUtils.setMillisecond(date, 0);
+ expect(result.millisecond()).toBe(0);
+ });
+ });
+
+ describe('getMillisecond', () => {
+ it('should return the millisecond from a date', () => {
+ const date = dayjs.utc('2024-01-15T14:00:00.123Z');
+ expect(dateTimeUtils.getMillisecond(date)).toBe(123);
+ });
+
+ it('should return 0 for a date with no milliseconds', () => {
+ const date = dayjs.utc('2024-01-15T14:00:00.000Z');
+ expect(dateTimeUtils.getMillisecond(date)).toBe(0);
+ });
+ });
});
diff --git a/frontend/src/utils/cards/RecordingCardUtils.js b/frontend/src/utils/cards/RecordingCardUtils.js
index 4920c8ba..60d527bd 100644
--- a/frontend/src/utils/cards/RecordingCardUtils.js
+++ b/frontend/src/utils/cards/RecordingCardUtils.js
@@ -2,6 +2,7 @@ import API from '../../api.js';
import useChannelsStore from '../../store/channels.jsx';
import defaultLogo from '../../images/logo.png';
import { formatSeasonEpisode } from '../guideUtils.js';
+import { buildLiveStreamUrl } from '../components/FloatingVideoUtils.js';
export const removeRecording = (id) => {
// Optimistically remove immediately from UI
@@ -63,7 +64,7 @@ export const getShowVideoUrl = (channel, env_mode) => {
if (env_mode === 'dev') {
url = `${window.location.protocol}//${window.location.hostname}:5656${url}`;
}
- return url;
+ return buildLiveStreamUrl(url);
};
export const runComSkip = async (recording) => {
diff --git a/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js b/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js
index 2af98a1c..2e601120 100644
--- a/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js
+++ b/frontend/src/utils/cards/__tests__/RecordingCardUtils.test.js
@@ -19,6 +19,7 @@ vi.mock('../../../store/channels');
describe('RecordingCardUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
+ localStorage.clear();
});
describe('removeRecording', () => {
@@ -147,19 +148,32 @@ describe('RecordingCardUtils', () => {
});
describe('getShowVideoUrl', () => {
- it('returns proxy URL for channel', () => {
+ it('returns proxy URL with mpegts output format for channel', () => {
const channel = { uuid: 'channel-123' };
const result = getShowVideoUrl(channel, 'production');
- expect(result).toBe('/proxy/ts/stream/channel-123');
+ expect(result).toBe('/proxy/ts/stream/channel-123?output_format=mpegts');
});
- it('prepends dev server URL in dev mode', () => {
+ it('includes output_profile when set in player prefs', () => {
+ localStorage.setItem(
+ 'dispatcharr-player-prefs',
+ JSON.stringify({ webPlayerOutputProfileId: 5 })
+ );
+ const channel = { uuid: 'channel-123' };
+ const result = getShowVideoUrl(channel, 'production');
+
+ expect(result).toBe(
+ '/proxy/ts/stream/channel-123?output_format=mpegts&output_profile=5'
+ );
+ });
+
+ it('prepends dev server URL in dev mode with output params', () => {
const channel = { uuid: 'channel-123' };
const result = getShowVideoUrl(channel, 'dev');
expect(result).toMatch(
- /^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123$/
+ /^https?:\/\/.*:5656\/proxy\/ts\/stream\/channel-123\?output_format=mpegts$/
);
});
});
diff --git a/frontend/src/utils/dateTimeUtils.js b/frontend/src/utils/dateTimeUtils.js
index 3930188b..f973e0b0 100644
--- a/frontend/src/utils/dateTimeUtils.js
+++ b/frontend/src/utils/dateTimeUtils.js
@@ -4,6 +4,7 @@ import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
+import customParseFormat from 'dayjs/plugin/customParseFormat';
import useSettingsStore from '../store/settings';
import useLocalStorage from '../hooks/useLocalStorage';
@@ -11,12 +12,21 @@ dayjs.extend(duration);
dayjs.extend(relativeTime);
dayjs.extend(utc);
dayjs.extend(timezone);
+dayjs.extend(customParseFormat);
export const convertToMs = (dateTime) => dayjs(dateTime).valueOf();
export const convertToSec = (dateTime) => dayjs(dateTime).unix();
-export const initializeTime = (dateTime) => dayjs(dateTime);
+export const initializeTime = (dateTime, format = null, locale = null, strict = false) => {
+ if (format && locale) {
+ return dayjs(dateTime, format, locale, strict);
+ } else if (format) {
+ return dayjs(dateTime, format, strict);
+ } else {
+ return dayjs(dateTime);
+ }
+}
export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day');
@@ -43,6 +53,10 @@ export const getNow = () => dayjs();
export const toFriendlyDuration = (dateTime, unit) =>
dayjs.duration(dateTime, unit).humanize();
+export const isValid = (dateTime) => dayjs(dateTime).isValid();
+
+export const toDate = (dateTime) => dayjs(dateTime).toDate();
+
export const formatExactDuration = (seconds) => {
if (seconds < 60) return `${seconds.toFixed(1)} seconds`;
if (seconds < 3600) {
@@ -76,6 +90,8 @@ export const setMinute = (dateTime, value) => dayjs(dateTime).minute(value);
export const setSecond = (dateTime, value) => dayjs(dateTime).second(value);
+export const setMillisecond = (dateTime, value) => dayjs(dateTime).millisecond(value);
+
export const getMonth = (dateTime) => dayjs(dateTime).month();
export const getYear = (dateTime) => dayjs(dateTime).year();
@@ -88,6 +104,8 @@ export const getMinute = (dateTime) => dayjs(dateTime).minute();
export const getSecond = (dateTime) => dayjs(dateTime).second();
+export const getMillisecond = (dateTime) => dayjs(dateTime).millisecond();
+
export const getNowMs = () => Date.now();
export const roundToNearest = (dateTime, minutes) => {
diff --git a/frontend/src/utils/forms/RecordingDetailsModalUtils.js b/frontend/src/utils/forms/RecordingDetailsModalUtils.js
index c81c3b53..2d9ea0ab 100644
--- a/frontend/src/utils/forms/RecordingDetailsModalUtils.js
+++ b/frontend/src/utils/forms/RecordingDetailsModalUtils.js
@@ -1,3 +1,5 @@
+import API from '../../api.js';
+
export const getStatRows = (stats) => {
return [
['Video Codec', stats.video_codec],
@@ -86,3 +88,22 @@ export const getUpcomingEpisodes = (
(a, b) => toUserTime(a.start_time) - toUserTime(b.start_time)
);
};
+
+export const getChannel = (id) => {
+ return API.getChannel(id);
+};
+
+export const updateRecordingMetadata = (
+ recording,
+ editTitle,
+ editDescription
+) => {
+ return API.updateRecordingMetadata(recording.id, {
+ title: editTitle || 'Custom Recording',
+ description: editDescription,
+ });
+};
+
+export const refreshArtwork = (id) => {
+ return API.refreshArtwork(id);
+};
diff --git a/frontend/src/utils/forms/RecordingUtils.js b/frontend/src/utils/forms/RecordingUtils.js
new file mode 100644
index 00000000..fde3b6c5
--- /dev/null
+++ b/frontend/src/utils/forms/RecordingUtils.js
@@ -0,0 +1,181 @@
+import API from '../../api.js';
+import {
+ add,
+ diff,
+ format,
+ getNow,
+ initializeTime,
+ isValid,
+ roundToNearest,
+ setMillisecond,
+ setSecond,
+ toDate,
+ toTimeString,
+} from '../dateTimeUtils.js';
+import { isNotEmpty } from '@mantine/form';
+
+export const asDate = (value) => {
+ if (!value) return null;
+ if (value instanceof Date) return value;
+ const parsed = new Date(value);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+};
+
+export const toIsoIfDate = (value) => {
+ const dt = asDate(value);
+ return dt ? dt.toISOString() : value;
+};
+
+export const toDateString = (value) => {
+ const dt = asDate(value);
+ return dt ? format(dt, 'YYYY-MM-DD') : null;
+};
+
+export const createRoundedDate = (minutesAhead = 0) => {
+ let rounded = roundToNearest(getNow(), 30);
+ rounded = setSecond(rounded, 0);
+ rounded = setMillisecond(rounded, 0);
+ return toDate(minutesAhead ? add(rounded, minutesAhead, 'minute') : rounded);
+};
+
+// robust onChange for TimeInput (string or event)
+export const timeChange = (setter) => (valOrEvent) => {
+ if (typeof valOrEvent === 'string') setter(valOrEvent);
+ else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value);
+};
+
+export const getChannelsSummary = () => {
+ return API.getChannelsSummary();
+};
+export const updateRecording = (id, data) => {
+ return API.updateRecording(id, data);
+};
+export const createRecording = (data) => {
+ return API.createRecording(data);
+};
+export const createRecurringRule = (data) => {
+ return API.createRecurringRule(data);
+};
+
+/**
+ * Sorts a channels array or object by channel_number (then name) and maps
+ * each entry to a { value, label } select option.
+ *
+ * @param {Array|Object} channels
+ * @param {(item: object) => string} [labelFn] defaults to `name || "Channel id"`
+ */
+export const sortedChannelOptions = (channels, labelFn) => {
+ const list = Array.isArray(channels)
+ ? channels
+ : Object.values(channels || {});
+
+ const defaultLabel = (item) => item.name || `Channel ${item.id}`;
+
+ return [...list]
+ .sort((a, b) => {
+ const aNum = Number(a.channel_number) || 0;
+ const bNum = Number(b.channel_number) || 0;
+ if (aNum !== bNum) return aNum - bNum;
+ return (a.name || '').localeCompare(b.name || '');
+ })
+ .map((item) => ({
+ value: String(item.id),
+ label: (labelFn ?? defaultLabel)(item),
+ }));
+};
+
+/** Label that includes the channel number prefix used in Recording/SeriesRule UIs. */
+export const numberedChannelLabel = (item) =>
+ item.channel_number
+ ? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
+ : item.name || `Channel ${item.id}`;
+
+export const getSingleFormDefaults = (recording = null, channel = null) => {
+ const defaultStart = createRoundedDate();
+ const defaultEnd = createRoundedDate(60);
+ return {
+ channel_id: recording
+ ? `${recording.channel}`
+ : channel
+ ? `${channel.id}`
+ : '',
+ start_time: recording
+ ? asDate(recording.start_time) || defaultStart
+ : defaultStart,
+ end_time: recording ? asDate(recording.end_time) || defaultEnd : defaultEnd,
+ };
+};
+
+export const getRecurringFormDefaults = (channel = null) => {
+ const defaultStart = createRoundedDate();
+ const defaultEnd = createRoundedDate(60);
+ const defaultDate = new Date();
+ return {
+ channel_id: channel ? `${channel.id}` : '',
+ days_of_week: [],
+ start_time: format(defaultStart, 'HH:mm'),
+ end_time: format(defaultEnd, 'HH:mm'),
+ rule_name: channel?.name || '',
+ start_date: defaultDate,
+ end_date: defaultDate,
+ };
+};
+
+export const buildSinglePayload = (values) => ({
+ channel: values.channel_id,
+ start_time: toIsoIfDate(values.start_time),
+ end_time: toIsoIfDate(values.end_time),
+});
+
+export const buildRecurringPayload = (values) => ({
+ channel: values.channel_id,
+ days_of_week: (values.days_of_week || []).map(Number),
+ start_time: toTimeString(values.start_time),
+ end_time: toTimeString(values.end_time),
+ start_date: toDateString(values.start_date),
+ end_date: toDateString(values.end_date),
+ name: values.rule_name?.trim() || '',
+});
+
+export const singleFormValidators = {
+ channel_id: isNotEmpty('Select a channel'),
+ start_time: isNotEmpty('Select a start time'),
+ end_time: (value, values) => {
+ const start = asDate(values.start_time);
+ const end = asDate(value);
+ if (!end) return 'Select an end time';
+ if (start && end <= start) return 'End time must be after start time';
+ return null;
+ },
+};
+
+export const recurringFormValidators = {
+ channel_id: isNotEmpty('Select a channel'),
+ days_of_week: (value) => (value?.length ? null : 'Pick at least one day'),
+ start_time: (value) => (value ? null : 'Select a start time'),
+ end_time: (value, values) => {
+ if (!value) return 'Select an end time';
+ const start = initializeTime(
+ values.start_time,
+ ['HH:mm', 'hh:mm A', 'h:mm A'],
+ null,
+ true
+ );
+ const end = initializeTime(
+ value,
+ ['HH:mm', 'hh:mm A', 'h:mm A'],
+ null,
+ true
+ );
+ if (isValid(start) && isValid(end) && diff(start, end, 'minute') === 0)
+ return 'End time must differ from start time';
+ return null;
+ },
+ end_date: (value, values) => {
+ const end = asDate(value);
+ const start = asDate(values.start_date);
+ if (!end) return 'Select an end date';
+ if (start && end < start) return 'End date cannot be before start date';
+ return null;
+ },
+};
diff --git a/frontend/src/utils/forms/RecurringRuleModalUtils.js b/frontend/src/utils/forms/RecurringRuleModalUtils.js
index db8a5dd8..5a747bf5 100644
--- a/frontend/src/utils/forms/RecurringRuleModalUtils.js
+++ b/frontend/src/utils/forms/RecurringRuleModalUtils.js
@@ -1,25 +1,12 @@
import API from '../../api.js';
-import { toTimeString } from '../dateTimeUtils.js';
-import dayjs from 'dayjs';
-
-export const getChannelOptions = (channels) => {
- const list = Array.isArray(channels)
- ? channels
- : Object.values(channels || {});
- return list
- .sort((a, b) => {
- const aNum = Number(a.channel_number) || 0;
- const bNum = Number(b.channel_number) || 0;
- if (aNum === bNum) {
- return (a.name || '').localeCompare(b.name || '');
- }
- return aNum - bNum;
- })
- .map((item) => ({
- value: `${item.id}`,
- label: item.name || `Channel ${item.id}`,
- }));
-};
+import {
+ getNow,
+ isAfter,
+ parseDate,
+ toDate,
+ toTimeString,
+} from '../dateTimeUtils.js';
+import { buildRecurringPayload } from './RecordingUtils.js';
export const getUpcomingOccurrences = (
recordings,
@@ -35,7 +22,7 @@ export const getUpcomingOccurrences = (
.filter(
(rec) =>
rec?.custom_properties?.rule?.id === ruleId &&
- toUserTime(rec.start_time).isAfter(now)
+ isAfter(toUserTime(rec.start_time), now)
)
.sort(
(a, b) =>
@@ -45,17 +32,7 @@ export const getUpcomingOccurrences = (
export const updateRecurringRule = async (ruleId, values) => {
await API.updateRecurringRule(ruleId, {
- channel: values.channel_id,
- days_of_week: (values.days_of_week || []).map((d) => Number(d)),
- start_time: toTimeString(values.start_time),
- end_time: toTimeString(values.end_time),
- start_date: values.start_date
- ? dayjs(values.start_date).format('YYYY-MM-DD')
- : null,
- end_date: values.end_date
- ? dayjs(values.end_date).format('YYYY-MM-DD')
- : null,
- name: values.rule_name?.trim() || '',
+ ...buildRecurringPayload(values),
enabled: Boolean(values.enabled),
});
};
@@ -67,3 +44,16 @@ export const deleteRecurringRuleById = async (ruleId) => {
export const updateRecurringRuleEnabled = async (ruleId, checked) => {
await API.updateRecurringRule(ruleId, { enabled: checked });
};
+
+export const getFormDefaults = (rule) => {
+ return {
+ channel_id: `${rule.channel}`,
+ days_of_week: (rule.days_of_week || []).map((d) => String(d)),
+ rule_name: rule.name || '',
+ start_time: toTimeString(rule.start_time),
+ end_time: toTimeString(rule.end_time),
+ start_date: parseDate(rule.start_date) || toDate(getNow()),
+ end_date: parseDate(rule.end_date),
+ enabled: Boolean(rule.enabled),
+ };
+};
diff --git a/frontend/src/utils/forms/SeriesRuleEditorModalUtils.js b/frontend/src/utils/forms/SeriesRuleEditorModalUtils.js
new file mode 100644
index 00000000..0eddc9c1
--- /dev/null
+++ b/frontend/src/utils/forms/SeriesRuleEditorModalUtils.js
@@ -0,0 +1,84 @@
+import API from '../../api.js';
+import {
+ numberedChannelLabel,
+ sortedChannelOptions,
+} from './RecordingUtils.js';
+
+export const TITLE_MODES = [
+ { label: 'Exact', value: 'exact' },
+ { label: 'Contains', value: 'contains' },
+ { label: 'Whole word', value: 'search' },
+ { label: 'Regex', value: 'regex' },
+];
+export const DESCRIPTION_MODES = [
+ { label: 'Contains', value: 'contains' },
+ { label: 'Whole word', value: 'search' },
+ { label: 'Regex', value: 'regex' },
+];
+export const EPISODE_MODES = [
+ { label: 'All episodes', value: 'all' },
+ { label: 'New only', value: 'new' },
+];
+
+export function formatRange(start, end) {
+ try {
+ const s = new Date(start);
+ const e = new Date(end);
+
+ if (isNaN(s) || isNaN(e)) throw new Error('Invalid date');
+
+ const sameDay = s.toDateString() === e.toDateString();
+ const dateStr = s.toLocaleDateString();
+ const startStr = s.toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ const endStr = e.toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ });
+ return sameDay
+ ? `${dateStr} ${startStr} - ${endStr}`
+ : `${dateStr} ${startStr} -> ${e.toLocaleString()}`;
+ } catch {
+ return `${start} - ${end}`;
+ }
+}
+
+export const previewSeriesRule = (debouncedPreviewKey, controller) => {
+ return API.previewSeriesRule(debouncedPreviewKey, {
+ signal: controller.signal,
+ });
+};
+
+export const getTvgOptions = (tvgs) => {
+ const seen = new Set();
+ const options = [];
+ for (const t of tvgs || []) {
+ if (!t.tvg_id || seen.has(t.tvg_id)) continue;
+ seen.add(t.tvg_id);
+ options.push({
+ value: t.tvg_id,
+ label: t.name ? `${t.name} (${t.tvg_id})` : t.tvg_id,
+ });
+ }
+ return options.sort((a, b) => a.label.localeCompare(b.label));
+};
+
+export const getChannelOptions = (allChannels, tvgsById, tvgId) => {
+ const sorted = sortedChannelOptions(allChannels, numberedChannelLabel);
+ const matching = [];
+ const others = [];
+ for (const item of sorted) {
+ const channel = allChannels.find((c) => String(c.id) === item.value);
+ const cTvg = channel?.epg_data_id
+ ? tvgsById?.[channel.epg_data_id]?.tvg_id
+ : null;
+ if (tvgId && cTvg && String(cTvg) === String(tvgId)) {
+ matching.push(item);
+ } else {
+ others.push(item);
+ }
+ }
+ return [...matching, ...others];
+};
diff --git a/frontend/src/utils/forms/StreamProfileUtils.js b/frontend/src/utils/forms/StreamProfileUtils.js
new file mode 100644
index 00000000..edcc7ba6
--- /dev/null
+++ b/frontend/src/utils/forms/StreamProfileUtils.js
@@ -0,0 +1,44 @@
+// Built-in commands supported by Dispatcharr out of the box.
+import { yupResolver } from '@hookform/resolvers/yup';
+import API from '../../api.js';
+import * as Yup from 'yup';
+
+export const BUILT_IN_COMMANDS = [
+ { value: 'ffmpeg', label: 'FFmpeg' },
+ { value: 'streamlink', label: 'Streamlink' },
+ { value: 'cvlc', label: 'VLC' },
+ { value: 'yt-dlp', label: 'yt-dlp' },
+ { value: '__custom__', label: 'Custom…' },
+];
+
+// Default parameter examples for each built-in command.
+export const COMMAND_EXAMPLES = {
+ ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
+ streamlink: '{streamUrl} --http-header User-Agent={userAgent} best --stdout',
+ cvlc: '-vv -I dummy --no-video-title-show --http-user-agent {userAgent} {streamUrl} --sout #standard{access=file,mux=ts,dst=-}',
+ 'yt-dlp': '--hls-use-mpegts -f best -o - {streamUrl}',
+};
+
+// Returns '__custom__' when the command isn't one of the built-ins,
+// otherwise returns the command value itself.
+export const toCommandSelection = (command) =>
+ BUILT_IN_COMMANDS.find((o) => o.value === command && o.value !== '__custom__')
+ ? command
+ : '__custom__';
+
+const schema = Yup.object({
+ name: Yup.string().required('Name is required'),
+ command: Yup.string().required('Command is required'),
+ parameters: Yup.string(),
+});
+
+export const getResolver = () => {
+ return yupResolver(schema);
+};
+
+export const updateStreamProfile = (profileId, values) => {
+ return API.updateStreamProfile({ id: profileId, ...values });
+};
+export const addStreamProfile = (values) => {
+ return API.addStreamProfile(values);
+};
diff --git a/frontend/src/utils/forms/StreamUtils.js b/frontend/src/utils/forms/StreamUtils.js
new file mode 100644
index 00000000..59998233
--- /dev/null
+++ b/frontend/src/utils/forms/StreamUtils.js
@@ -0,0 +1,19 @@
+import * as Yup from 'yup';
+import { yupResolver } from '@hookform/resolvers/yup';
+import API from '../../api.js';
+
+const schema = Yup.object({
+ name: Yup.string().required('Name is required'),
+ url: Yup.string().required('URL is required').min(0),
+});
+
+export const getResolver = () => {
+ return yupResolver(schema);
+};
+
+export const updateStream = (streamId, payload) => {
+ return API.updateStream({ id: streamId, ...payload });
+};
+export const addStream = (payload) => {
+ return API.addStream(payload);
+};
diff --git a/frontend/src/utils/forms/UserAgentUtils.js b/frontend/src/utils/forms/UserAgentUtils.js
new file mode 100644
index 00000000..c05c7681
--- /dev/null
+++ b/frontend/src/utils/forms/UserAgentUtils.js
@@ -0,0 +1,17 @@
+import { yupResolver } from '@hookform/resolvers/yup';
+import API from '../../api.js';
+import * as Yup from 'yup';
+
+const schema = Yup.object({
+ name: Yup.string().required('Name is required'),
+ user_agent: Yup.string().required('User-Agent is required'),
+});
+export const getResolver = () => {
+ return yupResolver(schema);
+};
+export const updateUserAgent = (userAgentId, values) => {
+ return API.updateUserAgent({ id: userAgentId, ...values });
+};
+export const addUserAgent = (values) => {
+ return API.addUserAgent(values);
+};
diff --git a/frontend/src/utils/forms/UserUtils.js b/frontend/src/utils/forms/UserUtils.js
new file mode 100644
index 00000000..3627d6ca
--- /dev/null
+++ b/frontend/src/utils/forms/UserUtils.js
@@ -0,0 +1,148 @@
+import { NETWORK_ACCESS_OPTIONS, USER_LEVELS } from '../../constants.js';
+import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../networkUtils.js';
+import API from '../../api.js';
+
+const isValidNetworkEntry = (entry) =>
+ entry.match(IPV4_CIDR_REGEX) ||
+ entry.match(IPV6_CIDR_REGEX) ||
+ (entry + '/32').match(IPV4_CIDR_REGEX) ||
+ (entry + '/128').match(IPV6_CIDR_REGEX);
+const NETWORK_KEYS = Object.keys(NETWORK_ACCESS_OPTIONS);
+
+export const createUser = (values) => {
+ return API.createUser(values);
+};
+
+export const updateUser = (userId, values, isAdmin, authUser) => {
+ return API.updateUser(
+ userId,
+ values,
+ isAdmin ? false : authUser.id === userId
+ );
+};
+
+export const generateApiKey = (payload) => {
+ return API.generateApiKey(payload);
+};
+
+export const revokeApiKey = (payload) => {
+ return API.revokeApiKey(payload);
+};
+
+export const userToFormValues = (user) => {
+ const customProps = user.custom_properties || {};
+ const networks = customProps.allowed_networks || {};
+
+ return {
+ username: user.username,
+ first_name: user.first_name || '',
+ last_name: user.last_name || '',
+ email: user.email,
+ user_level: `${user.user_level}`,
+ stream_limit: user.stream_limit || 0,
+ channel_profiles:
+ user.channel_profiles.length > 0
+ ? user.channel_profiles.map((id) => `${id}`)
+ : ['0'],
+ xc_password: customProps.xc_password || '',
+ output_format: customProps.output_format || '',
+ output_profile: customProps.output_profile
+ ? `${customProps.output_profile}`
+ : '',
+ hide_adult_content: customProps.hide_adult_content || false,
+ epg_days: customProps.epg_days || 0,
+ epg_prev_days: customProps.epg_prev_days || 0,
+ allowed_ips: [
+ ...new Set(
+ NETWORK_KEYS.flatMap((key) =>
+ networks[key] ? networks[key].split(',').filter(Boolean) : []
+ )
+ ),
+ ],
+ };
+};
+
+export const formValuesToPayload = (values, existingUser) => {
+ const customProps = { ...(existingUser?.custom_properties || {}) };
+ const payload = { ...values };
+
+ customProps.xc_password = payload.xc_password || '';
+ delete payload.xc_password;
+
+ customProps.output_format = payload.output_format || null;
+ delete payload.output_format;
+
+ customProps.output_profile = payload.output_profile
+ ? parseInt(payload.output_profile, 10)
+ : null;
+ delete payload.output_profile;
+
+ customProps.hide_adult_content = payload.hide_adult_content || false;
+ delete payload.hide_adult_content;
+
+ customProps.epg_days = payload.epg_days || 0;
+ delete payload.epg_days;
+
+ customProps.epg_prev_days = payload.epg_prev_days || 0;
+ delete payload.epg_prev_days;
+
+ const joined = (payload.allowed_ips || []).join(',');
+ delete payload.allowed_ips;
+ const allowed_networks = {};
+ if (joined) {
+ NETWORK_KEYS.forEach((key) => {
+ allowed_networks[key] = joined;
+ });
+ }
+ customProps.allowed_networks = allowed_networks;
+
+ payload.custom_properties = customProps;
+
+ if (payload.channel_profiles?.includes('0')) {
+ payload.channel_profiles = [];
+ }
+
+ return payload;
+};
+
+export const getFormInitialValues = () => {
+ return {
+ username: '',
+ first_name: '',
+ last_name: '',
+ email: '',
+ user_level: '0',
+ stream_limit: 0,
+ password: '',
+ xc_password: '',
+ output_format: '',
+ output_profile: '',
+ channel_profiles: [],
+ hide_adult_content: false,
+ epg_days: 0,
+ epg_prev_days: 0,
+ allowed_ips: [],
+ };
+};
+
+export const getFormValidators = (user) => {
+ return (values) => ({
+ username: !values.username
+ ? 'Username is required'
+ : values.user_level == USER_LEVELS.STREAMER &&
+ !values.username.match(/^[a-z0-9]+$/i)
+ ? 'Streamer username must be alphanumeric'
+ : null,
+ password:
+ !user && !values.password && values.user_level != USER_LEVELS.STREAMER
+ ? 'Password is required'
+ : null,
+ xc_password:
+ values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i)
+ ? 'XC password must be alphanumeric'
+ : null,
+ allowed_ips: (values.allowed_ips || []).some((t) => !isValidNetworkEntry(t))
+ ? 'Invalid IP address or CIDR range'
+ : null,
+ });
+};
diff --git a/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js
index c86da2a8..7ad80f63 100644
--- a/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js
+++ b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js
@@ -1,8 +1,21 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as RecordingDetailsModalUtils from '../RecordingDetailsModalUtils';
import dayjs from 'dayjs';
+import API from '../../../api.js';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ getChannel: vi.fn(),
+ updateRecordingMetadata: vi.fn(),
+ refreshArtwork: vi.fn(),
+ },
+}));
describe('RecordingDetailsModalUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
describe('getStatRows', () => {
it('should return all stats when all values are present', () => {
const stats = {
@@ -624,4 +637,108 @@ describe('RecordingDetailsModalUtils', () => {
expect(result).toEqual([]);
});
});
+
+ describe('getChannel', () => {
+ it('should call API.getChannel with the given id', async () => {
+ const mockChannel = { id: 42, name: 'Channel 1' };
+ API.getChannel.mockResolvedValue(mockChannel);
+
+ const result = await RecordingDetailsModalUtils.getChannel(42);
+
+ expect(API.getChannel).toHaveBeenCalledWith(42);
+ expect(result).toEqual(mockChannel);
+ });
+
+ it('should propagate API errors', async () => {
+ API.getChannel.mockRejectedValue(new Error('Not found'));
+
+ await expect(RecordingDetailsModalUtils.getChannel(99)).rejects.toThrow(
+ 'Not found'
+ );
+ });
+ });
+
+ describe('updateRecordingMetadata', () => {
+ it('should call API with title and description', async () => {
+ const mockResponse = { id: 1, title: 'Updated Title' };
+ API.updateRecordingMetadata.mockResolvedValue(mockResponse);
+
+ const recording = { id: 1 };
+ const result = await RecordingDetailsModalUtils.updateRecordingMetadata(
+ recording,
+ 'Updated Title',
+ 'Updated description'
+ );
+
+ expect(API.updateRecordingMetadata).toHaveBeenCalledWith(1, {
+ title: 'Updated Title',
+ description: 'Updated description',
+ });
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should default title to "Custom Recording" when editTitle is empty', async () => {
+ API.updateRecordingMetadata.mockResolvedValue({});
+
+ const recording = { id: 5 };
+ await RecordingDetailsModalUtils.updateRecordingMetadata(
+ recording,
+ '',
+ 'Some description'
+ );
+
+ expect(API.updateRecordingMetadata).toHaveBeenCalledWith(5, {
+ title: 'Custom Recording',
+ description: 'Some description',
+ });
+ });
+
+ it('should default title to "Custom Recording" when editTitle is null', async () => {
+ API.updateRecordingMetadata.mockResolvedValue({});
+
+ const recording = { id: 5 };
+ await RecordingDetailsModalUtils.updateRecordingMetadata(
+ recording,
+ null,
+ 'desc'
+ );
+
+ expect(API.updateRecordingMetadata).toHaveBeenCalledWith(5, {
+ title: 'Custom Recording',
+ description: 'desc',
+ });
+ });
+
+ it('should propagate API errors', async () => {
+ API.updateRecordingMetadata.mockRejectedValue(new Error('Server error'));
+
+ await expect(
+ RecordingDetailsModalUtils.updateRecordingMetadata(
+ { id: 1 },
+ 'Title',
+ 'Desc'
+ )
+ ).rejects.toThrow('Server error');
+ });
+ });
+
+ describe('refreshArtwork', () => {
+ it('should call API.refreshArtwork with the given id', async () => {
+ const mockResponse = { success: true };
+ API.refreshArtwork.mockResolvedValue(mockResponse);
+
+ const result = await RecordingDetailsModalUtils.refreshArtwork(7);
+
+ expect(API.refreshArtwork).toHaveBeenCalledWith(7);
+ expect(result).toEqual(mockResponse);
+ });
+
+ it('should propagate API errors', async () => {
+ API.refreshArtwork.mockRejectedValue(new Error('Artwork fetch failed'));
+
+ await expect(
+ RecordingDetailsModalUtils.refreshArtwork(7)
+ ).rejects.toThrow('Artwork fetch failed');
+ });
+ });
});
diff --git a/frontend/src/utils/forms/__tests__/RecordingUtils.test.js b/frontend/src/utils/forms/__tests__/RecordingUtils.test.js
new file mode 100644
index 00000000..6e3ae901
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/RecordingUtils.test.js
@@ -0,0 +1,514 @@
+import { describe, it, expect, vi } from 'vitest';
+import dayjs from 'dayjs';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ getChannelsSummary: vi.fn(),
+ updateRecording: vi.fn(),
+ createRecording: vi.fn(),
+ createRecurringRule: vi.fn(),
+ },
+}));
+
+vi.mock('../../dateTimeUtils.js', () => ({
+ add: vi.fn((d, amount, unit) => dayjs(d).add(amount, unit)),
+ diff: vi.fn((a, b, unit) => dayjs(a).diff(dayjs(b), unit)),
+ format: vi.fn((d, fmt) => (d ? dayjs(d).format(fmt) : null)),
+ getNow: vi.fn(() => dayjs('2024-06-15T10:00:00')),
+ initializeTime: vi.fn((val, fmts, ref, strict) => {
+ for (const fmt of fmts) {
+ const p = dayjs(val, fmt, strict);
+ if (p.isValid()) return p;
+ }
+ return dayjs('invalid');
+ }),
+ isValid: vi.fn((d) => dayjs(d).isValid()),
+ roundToNearest: vi.fn((d) => d),
+ setMillisecond: vi.fn((d, ms) => dayjs(d).millisecond(ms)),
+ setSecond: vi.fn((d, s) => dayjs(d).second(s)),
+ toDate: vi.fn((d) => (d ? dayjs(d).toDate() : new Date())),
+ toTimeString: vi.fn((t) => {
+ if (!t) return '00:00';
+ const match = String(t).match(/(\d{2}:\d{2})/);
+ return match ? match[1] : t;
+ }),
+}));
+
+vi.mock('@mantine/form', () => ({
+ isNotEmpty: vi.fn((msg) => (value) => (value ? null : msg)),
+}));
+
+import API from '../../../api.js';
+import {
+ asDate,
+ toIsoIfDate,
+ toDateString,
+ createRoundedDate,
+ timeChange,
+ getChannelsSummary,
+ updateRecording,
+ createRecording,
+ createRecurringRule,
+ sortedChannelOptions,
+ numberedChannelLabel,
+ getSingleFormDefaults,
+ getRecurringFormDefaults,
+ buildSinglePayload,
+ buildRecurringPayload,
+ singleFormValidators,
+ recurringFormValidators,
+} from '../RecordingUtils.js';
+
+describe('RecordingUtils', () => {
+ // ─── asDate ───────────────────────────────────────────────────────────────────
+
+ describe('asDate', () => {
+ it('returns null for falsy input', () => {
+ expect(asDate(null)).toBeNull();
+ expect(asDate(undefined)).toBeNull();
+ expect(asDate('')).toBeNull();
+ });
+
+ it('returns a Date instance unchanged', () => {
+ const d = new Date('2024-06-15');
+ expect(asDate(d)).toBe(d);
+ });
+
+ it('parses a valid ISO string to a Date', () => {
+ const result = asDate('2024-06-15T10:00:00Z');
+ expect(result).toBeInstanceOf(Date);
+ expect(result.toISOString()).toContain('2024-06-15');
+ });
+
+ it('returns null for an invalid date string', () => {
+ expect(asDate('not-a-date')).toBeNull();
+ });
+ });
+
+ // ─── toIsoIfDate ──────────────────────────────────────────────────────────────
+
+ describe('toIsoIfDate', () => {
+ it('returns ISO string for a valid Date', () => {
+ const d = new Date('2024-06-15T00:00:00Z');
+ expect(toIsoIfDate(d)).toBe(d.toISOString());
+ });
+
+ it('returns ISO string for a valid date string', () => {
+ const result = toIsoIfDate('2024-06-15T10:00:00Z');
+ expect(result).toMatch(/2024-06-15/);
+ });
+
+ it('returns the original value when not a valid date', () => {
+ expect(toIsoIfDate('some-string')).toBe('some-string');
+ });
+
+ it('returns null for null input', () => {
+ expect(toIsoIfDate(null)).toBeNull();
+ });
+ });
+
+ // ─── toDateString ─────────────────────────────────────────────────────────────
+
+ describe('toDateString', () => {
+ it('formats a Date to YYYY-MM-DD', () => {
+ const d = new Date('2024-06-15T12:00:00Z');
+ const result = toDateString(d);
+ expect(result).toBe('2024-06-15');
+ });
+
+ it('formats a valid ISO string to YYYY-MM-DD', () => {
+ expect(toDateString('2024-06-15T12:00:00')).toBe('2024-06-15');
+ });
+
+ it('returns null for null input', () => {
+ expect(toDateString(null)).toBeNull();
+ });
+
+ it('returns null for an invalid string', () => {
+ expect(toDateString('not-a-date')).toBeNull();
+ });
+ });
+
+ // ─── createRoundedDate ────────────────────────────────────────────────────────
+
+ describe('createRoundedDate', () => {
+ it('returns a Date', () => {
+ expect(createRoundedDate()).toBeInstanceOf(Date);
+ });
+
+ it('returns a Date when minutesAhead is provided', () => {
+ expect(createRoundedDate(60)).toBeInstanceOf(Date);
+ });
+ });
+
+ // ─── timeChange ───────────────────────────────────────────────────────────────
+
+ describe('timeChange', () => {
+ it('calls setter with string directly', () => {
+ const setter = vi.fn();
+ timeChange(setter)('10:30');
+ expect(setter).toHaveBeenCalledWith('10:30');
+ });
+
+ it('calls setter with currentTarget.value from event', () => {
+ const setter = vi.fn();
+ timeChange(setter)({ currentTarget: { value: '09:00' } });
+ expect(setter).toHaveBeenCalledWith('09:00');
+ });
+
+ it('does not call setter for unrecognized input', () => {
+ const setter = vi.fn();
+ timeChange(setter)(42);
+ expect(setter).not.toHaveBeenCalled();
+ });
+ });
+
+ // ─── API wrappers ─────────────────────────────────────────────────────────────
+
+ describe('getChannelsSummary', () => {
+ it('calls API.getChannelsSummary', () => {
+ API.getChannelsSummary.mockResolvedValueOnce([]);
+ getChannelsSummary();
+ expect(API.getChannelsSummary).toHaveBeenCalled();
+ });
+ });
+
+ describe('updateRecording', () => {
+ it('calls API.updateRecording with id and data', () => {
+ API.updateRecording.mockResolvedValueOnce({});
+ updateRecording(1, { name: 'test' });
+ expect(API.updateRecording).toHaveBeenCalledWith(1, { name: 'test' });
+ });
+ });
+
+ describe('createRecording', () => {
+ it('calls API.createRecording with data', () => {
+ API.createRecording.mockResolvedValueOnce({});
+ createRecording({ channel: 1 });
+ expect(API.createRecording).toHaveBeenCalledWith({ channel: 1 });
+ });
+ });
+
+ describe('createRecurringRule', () => {
+ it('calls API.createRecurringRule with data', () => {
+ API.createRecurringRule.mockResolvedValueOnce({});
+ createRecurringRule({ channel: 1 });
+ expect(API.createRecurringRule).toHaveBeenCalledWith({ channel: 1 });
+ });
+ });
+
+ // ─── sortedChannelOptions ─────────────────────────────────────────────────────
+
+ describe('sortedChannelOptions', () => {
+ const channels = [
+ { id: 3, name: 'CNN', channel_number: 20 },
+ { id: 1, name: 'ESPN', channel_number: 5 },
+ { id: 2, name: 'HBO', channel_number: 10 },
+ ];
+
+ it('sorts channels by channel_number ascending', () => {
+ const result = sortedChannelOptions(channels);
+ // ESPN(5) < HBO(10) < CNN(20) → ids 1, 2, 3
+ expect(result.map((o) => o.value)).toEqual(['1', '2', '3']);
+ });
+
+ it('maps each channel to { value, label }', () => {
+ const result = sortedChannelOptions([
+ { id: 7, name: 'FOX', channel_number: 3 },
+ ]);
+ expect(result[0]).toEqual({ value: '7', label: 'FOX' });
+ });
+
+ it('uses custom labelFn when provided', () => {
+ const result = sortedChannelOptions(
+ [{ id: 1, name: 'ESPN', channel_number: 5 }],
+ (item) => `CH${item.channel_number} ${item.name}`
+ );
+ expect(result[0].label).toBe('CH5 ESPN');
+ });
+
+ it('falls back to "Channel id" when name is missing', () => {
+ const result = sortedChannelOptions([{ id: 9, channel_number: 1 }]);
+ expect(result[0].label).toBe('Channel 9');
+ });
+
+ it('accepts an object (dict) of channels', () => {
+ const dict = {
+ a: { id: 1, name: 'A', channel_number: 2 },
+ b: { id: 2, name: 'B', channel_number: 1 },
+ };
+ const result = sortedChannelOptions(dict);
+ expect(result[0].value).toBe('2');
+ expect(result[1].value).toBe('1');
+ });
+
+ it('returns empty array for null input', () => {
+ expect(sortedChannelOptions(null)).toEqual([]);
+ });
+
+ it('returns empty array for empty array input', () => {
+ expect(sortedChannelOptions([])).toEqual([]);
+ });
+
+ it('sorts by name when channel_numbers are equal', () => {
+ const input = [
+ { id: 2, name: 'Zebra', channel_number: 5 },
+ { id: 1, name: 'Apple', channel_number: 5 },
+ ];
+ const result = sortedChannelOptions(input);
+ expect(result[0].label).toBe('Apple');
+ expect(result[1].label).toBe('Zebra');
+ });
+
+ it('puts channels with no channel_number (0) first in numeric order', () => {
+ const input = [
+ { id: 1, name: 'A', channel_number: 5 },
+ { id: 2, name: 'B', channel_number: null },
+ ];
+ const result = sortedChannelOptions(input);
+ // null → 0, so it sorts before 5
+ expect(result[0].value).toBe('2');
+ });
+ });
+
+ // ─── numberedChannelLabel ─────────────────────────────────────────────────────
+
+ describe('numberedChannelLabel', () => {
+ it('returns "number - name" when both are present', () => {
+ expect(
+ numberedChannelLabel({ id: 1, name: 'HBO', channel_number: 501 })
+ ).toBe('501 - HBO');
+ });
+
+ it('returns name only when channel_number is falsy', () => {
+ expect(
+ numberedChannelLabel({ id: 1, name: 'HBO', channel_number: null })
+ ).toBe('HBO');
+ });
+
+ it('falls back to "Channel id" when name is missing and number present', () => {
+ expect(numberedChannelLabel({ id: 5, channel_number: 10 })).toBe(
+ '10 - Channel 5'
+ );
+ });
+
+ it('falls back to "Channel id" when both name and number are missing', () => {
+ expect(numberedChannelLabel({ id: 7 })).toBe('Channel 7');
+ });
+ });
+
+ // ─── getSingleFormDefaults ────────────────────────────────────────────────────
+
+ describe('getSingleFormDefaults', () => {
+ it('returns defaults with no arguments', () => {
+ const result = getSingleFormDefaults();
+ expect(result.channel_id).toBe('');
+ expect(result.start_time).toBeInstanceOf(Date);
+ expect(result.end_time).toBeInstanceOf(Date);
+ });
+
+ it('uses recording values when provided', () => {
+ const recording = {
+ channel: 3,
+ start_time: '2024-06-15T08:00:00Z',
+ end_time: '2024-06-15T09:00:00Z',
+ };
+ const result = getSingleFormDefaults(recording);
+ expect(result.channel_id).toBe('3');
+ expect(result.start_time).toBeInstanceOf(Date);
+ expect(result.end_time).toBeInstanceOf(Date);
+ });
+
+ it('uses channel id when channel is provided and recording is null', () => {
+ const result = getSingleFormDefaults(null, { id: 7 });
+ expect(result.channel_id).toBe('7');
+ });
+ });
+
+ // ─── getRecurringFormDefaults ─────────────────────────────────────────────────
+
+ describe('getRecurringFormDefaults', () => {
+ it('returns defaults with no arguments', () => {
+ const result = getRecurringFormDefaults();
+ expect(result.channel_id).toBe('');
+ expect(result.days_of_week).toEqual([]);
+ expect(result.rule_name).toBe('');
+ expect(typeof result.start_time).toBe('string');
+ expect(typeof result.end_time).toBe('string');
+ expect(result.start_date).toBeInstanceOf(Date);
+ expect(result.end_date).toBeInstanceOf(Date);
+ });
+
+ it('uses channel name and id when channel is provided', () => {
+ const result = getRecurringFormDefaults({ id: 5, name: 'ESPN' });
+ expect(result.channel_id).toBe('5');
+ expect(result.rule_name).toBe('ESPN');
+ });
+ });
+
+ // ─── buildSinglePayload ───────────────────────────────────────────────────────
+
+ describe('buildSinglePayload', () => {
+ it('builds payload from values', () => {
+ const values = {
+ channel_id: '2',
+ start_time: new Date('2024-06-15T08:00:00Z'),
+ end_time: new Date('2024-06-15T09:00:00Z'),
+ };
+ const result = buildSinglePayload(values);
+ expect(result.channel).toBe('2');
+ expect(result.start_time).toContain('2024-06-15');
+ expect(result.end_time).toContain('2024-06-15');
+ });
+ });
+
+ // ─── buildRecurringPayload ────────────────────────────────────────────────────
+
+ describe('buildRecurringPayload', () => {
+ it('builds payload from values', () => {
+ const values = {
+ channel_id: '3',
+ days_of_week: ['1', '3', '5'],
+ start_time: '08:00',
+ end_time: '09:00',
+ start_date: new Date('2024-06-15T12:00:00'),
+ end_date: new Date('2024-12-25T12:00:00'),
+ rule_name: ' Morning News ',
+ };
+ const result = buildRecurringPayload(values);
+ expect(result.channel).toBe('3');
+ expect(result.days_of_week).toEqual([1, 3, 5]);
+ expect(result.start_time).toBe('08:00');
+ expect(result.end_time).toBe('09:00');
+ expect(result.start_date).toBe('2024-06-15');
+ expect(result.end_date).toBe('2024-12-25');
+ expect(result.name).toBe('Morning News');
+ });
+
+ it('handles empty days_of_week', () => {
+ const values = {
+ channel_id: '1',
+ days_of_week: [],
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: new Date('2024-06-15T12:00:00'),
+ end_date: new Date('2024-06-15T12:00:00'),
+ rule_name: '',
+ };
+ const result = buildRecurringPayload(values);
+ expect(result.days_of_week).toEqual([]);
+ expect(result.name).toBe('');
+ });
+ });
+
+ // ─── singleFormValidators ─────────────────────────────────────────────────────
+
+ describe('singleFormValidators', () => {
+ describe('end_time', () => {
+ it('returns error when end time is missing', () => {
+ expect(singleFormValidators.end_time(null, { start_time: null })).toBe(
+ 'Select an end time'
+ );
+ });
+
+ it('returns error when end time is not after start time', () => {
+ const start = new Date('2024-06-15T10:00:00Z');
+ const end = new Date('2024-06-15T09:00:00Z');
+ expect(singleFormValidators.end_time(end, { start_time: start })).toBe(
+ 'End time must be after start time'
+ );
+ });
+
+ it('returns error when end equals start', () => {
+ const t = new Date('2024-06-15T10:00:00Z');
+ expect(singleFormValidators.end_time(t, { start_time: t })).toBe(
+ 'End time must be after start time'
+ );
+ });
+
+ it('returns null when end is after start', () => {
+ const start = new Date('2024-06-15T09:00:00Z');
+ const end = new Date('2024-06-15T10:00:00Z');
+ expect(
+ singleFormValidators.end_time(end, { start_time: start })
+ ).toBeNull();
+ });
+ });
+ });
+
+ // ─── recurringFormValidators ──────────────────────────────────────────────────
+
+ describe('recurringFormValidators', () => {
+ describe('days_of_week', () => {
+ it('returns error for empty array', () => {
+ expect(recurringFormValidators.days_of_week([])).toBe(
+ 'Pick at least one day'
+ );
+ });
+
+ it('returns null for non-empty array', () => {
+ expect(recurringFormValidators.days_of_week([1])).toBeNull();
+ });
+ });
+
+ describe('start_time', () => {
+ it('returns error for falsy value', () => {
+ expect(recurringFormValidators.start_time(null)).toBe(
+ 'Select a start time'
+ );
+ });
+
+ it('returns null for a valid time string', () => {
+ expect(recurringFormValidators.start_time('10:00')).toBeNull();
+ });
+ });
+
+ describe('end_time', () => {
+ it('returns error when value is falsy', () => {
+ expect(
+ recurringFormValidators.end_time(null, {
+ start_time: '2024-06-15T10:00:00',
+ })
+ ).toBe('Select an end time');
+ });
+
+ it('returns error when end equals start (diff === 0)', () => {
+ const result = recurringFormValidators.end_time('2024-06-15T10:00:00', {
+ start_time: '2024-06-15T10:00:00',
+ });
+ expect(result).toBe('End time must differ from start time');
+ });
+
+ it('returns null when end differs from start', () => {
+ const result = recurringFormValidators.end_time('2024-06-15T11:00:00', {
+ start_time: '2024-06-15T10:00:00',
+ });
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('end_date', () => {
+ it('returns error when end_date is missing', () => {
+ expect(
+ recurringFormValidators.end_date(null, { start_date: new Date() })
+ ).toBe('Select an end date');
+ });
+
+ it('returns error when end_date is before start_date', () => {
+ const start = new Date('2024-06-15');
+ const end = new Date('2024-06-01');
+ expect(
+ recurringFormValidators.end_date(end, { start_date: start })
+ ).toBe('End date cannot be before start date');
+ });
+
+ it('returns null when end_date is on or after start_date', () => {
+ const start = new Date('2024-06-15');
+ const end = new Date('2024-06-15');
+ expect(
+ recurringFormValidators.end_date(end, { start_date: start })
+ ).toBeNull();
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js
index 04561926..fd6d8db2 100644
--- a/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js
+++ b/frontend/src/utils/forms/__tests__/RecurringRuleModalUtils.test.js
@@ -10,113 +10,24 @@ vi.mock('../../../api.js', () => ({
},
}));
+vi.mock('../../dateTimeUtils.js', () => ({
+ toTimeString: vi.fn((t) => {
+ if (!t) return '00:00';
+ const match = String(t).match(/T(\d{2}:\d{2})/);
+ return match ? match[1] : t;
+ }),
+ parseDate: vi.fn((d) => (d ? dayjs(d).toDate() : null)),
+ toDate: vi.fn((d) => (d ? dayjs(d).toDate() : new Date())),
+ getNow: vi.fn(() => dayjs()),
+ isAfter: vi.fn((a, b) => dayjs(a).isAfter(dayjs(b))),
+ format: vi.fn((d, fmt) => (d ? dayjs(d).format(fmt) : null)),
+}));
+
describe('RecurringRuleModalUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
});
- describe('getChannelOptions', () => {
- it('should return sorted channel options by channel number', () => {
- const channels = {
- ch1: { id: 1, channel_number: '10', name: 'ABC' },
- ch2: { id: 2, channel_number: '5', name: 'NBC' },
- ch3: { id: 3, channel_number: '15', name: 'CBS' },
- };
-
- const result = RecurringRuleModalUtils.getChannelOptions(channels);
-
- expect(result).toEqual([
- { value: '2', label: 'NBC' },
- { value: '1', label: 'ABC' },
- { value: '3', label: 'CBS' },
- ]);
- });
-
- it('should sort alphabetically by name when channel numbers are equal', () => {
- const channels = {
- ch1: { id: 1, channel_number: '10', name: 'ZBC' },
- ch2: { id: 2, channel_number: '10', name: 'ABC' },
- ch3: { id: 3, channel_number: '10', name: 'MBC' },
- };
-
- const result = RecurringRuleModalUtils.getChannelOptions(channels);
-
- expect(result).toEqual([
- { value: '2', label: 'ABC' },
- { value: '3', label: 'MBC' },
- { value: '1', label: 'ZBC' },
- ]);
- });
-
- it('should handle missing channel numbers', () => {
- const channels = {
- ch1: { id: 1, name: 'ABC' },
- ch2: { id: 2, channel_number: '5', name: 'NBC' },
- };
-
- const result = RecurringRuleModalUtils.getChannelOptions(channels);
-
- expect(result).toEqual([
- { value: '1', label: 'ABC' },
- { value: '2', label: 'NBC' },
- ]);
- });
-
- it('should use fallback label when name is missing', () => {
- const channels = {
- ch1: { id: 1, channel_number: '10' },
- ch2: { id: 2, channel_number: '5', name: '' },
- };
-
- const result = RecurringRuleModalUtils.getChannelOptions(channels);
-
- expect(result).toEqual([
- { value: '2', label: 'Channel 2' },
- { value: '1', label: 'Channel 1' },
- ]);
- });
-
- it('should handle empty channels object', () => {
- const result = RecurringRuleModalUtils.getChannelOptions({});
-
- expect(result).toEqual([]);
- });
-
- it('should handle null channels', () => {
- const result = RecurringRuleModalUtils.getChannelOptions(null);
-
- expect(result).toEqual([]);
- });
-
- it('should handle undefined channels', () => {
- const result = RecurringRuleModalUtils.getChannelOptions(undefined);
-
- expect(result).toEqual([]);
- });
-
- it('should convert channel id to string value', () => {
- const channels = {
- ch1: { id: 123, channel_number: '10', name: 'ABC' },
- };
-
- const result = RecurringRuleModalUtils.getChannelOptions(channels);
-
- expect(result[0].value).toBe('123');
- expect(typeof result[0].value).toBe('string');
- });
-
- it('should handle non-numeric channel numbers', () => {
- const channels = {
- ch1: { id: 1, channel_number: 'HD1', name: 'ABC' },
- ch2: { id: 2, channel_number: '5', name: 'NBC' },
- };
-
- const result = RecurringRuleModalUtils.getChannelOptions(channels);
-
- expect(result).toHaveLength(2);
- });
- });
-
describe('getUpcomingOccurrences', () => {
let toUserTime;
let userNow;
@@ -310,11 +221,11 @@ describe('RecurringRuleModalUtils', () => {
const values = {
channel_id: '5',
days_of_week: ['1', '3', '5'],
- start_time: '14:30',
- end_time: '16:00',
- start_date: '2024-01-01',
- end_date: '2024-12-31',
- rule_name: 'My Rule',
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: dayjs('2024-06-15'),
+ end_date: dayjs('2024-12-25'),
+ rule_name: ' Test Rule ',
enabled: true,
};
@@ -323,11 +234,11 @@ describe('RecurringRuleModalUtils', () => {
expect(API.updateRecurringRule).toHaveBeenCalledWith(1, {
channel: '5',
days_of_week: [1, 3, 5],
- start_time: '14:30',
- end_time: '16:00',
- start_date: '2024-01-01',
- end_date: '2024-12-31',
- name: 'My Rule',
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: '2024-06-15',
+ end_date: '2024-12-25',
+ name: 'Test Rule',
enabled: true,
});
});
@@ -530,4 +441,32 @@ describe('RecurringRuleModalUtils', () => {
});
});
});
+
+ describe('getFormDefaults', () => {
+ it('should return formatted defaults from rule', () => {
+ const rule = {
+ channel: 5,
+ days_of_week: [1, 3, 5],
+ name: 'Test Rule',
+ start_time: '2024-06-15T10:00:00',
+ end_time: '2024-06-15T11:00:00',
+ start_date: '2024-06-15',
+ end_date: '2024-12-25',
+ enabled: true,
+ };
+
+ const result = RecurringRuleModalUtils.getFormDefaults(rule);
+
+ expect(result).toEqual({
+ channel_id: '5',
+ days_of_week: ['1', '3', '5'],
+ rule_name: 'Test Rule',
+ start_time: '10:00',
+ end_time: '11:00',
+ start_date: dayjs('2024-06-15').toDate(),
+ end_date: dayjs('2024-12-25').toDate(),
+ enabled: true,
+ });
+ });
+ });
});
diff --git a/frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js b/frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js
new file mode 100644
index 00000000..0c234085
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js
@@ -0,0 +1,441 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Dependency mocks ───────────────────────────────────────────────────────────
+vi.mock('../../../api.js', () => ({
+ default: {
+ previewSeriesRule: vi.fn(),
+ },
+}));
+
+vi.mock('../RecordingUtils.js', () => ({
+ numberedChannelLabel: vi.fn((item) =>
+ item.channel_number
+ ? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
+ : item.name || `Channel ${item.id}`
+ ),
+ sortedChannelOptions: vi.fn((channels) =>
+ (Array.isArray(channels) ? channels : []).map((c) => ({
+ value: String(c.id),
+ label: c.name || `Channel ${c.id}`,
+ }))
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import API from '../../../api.js';
+import {
+ TITLE_MODES,
+ DESCRIPTION_MODES,
+ EPISODE_MODES,
+ formatRange,
+ getTvgOptions,
+ getChannelOptions,
+ previewSeriesRule,
+} from '../SeriesRuleEditorModalUtils.js';
+import {
+ sortedChannelOptions,
+ numberedChannelLabel,
+} from '../RecordingUtils.js';
+
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('SeriesRuleEditorModalUtils', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ── Constants ──────────────────────────────────────────────────────────────
+
+ describe('TITLE_MODES', () => {
+ it('exports an array of mode objects', () => {
+ expect(Array.isArray(TITLE_MODES)).toBe(true);
+ expect(TITLE_MODES.length).toBeGreaterThan(0);
+ });
+
+ it('every entry has a label and value', () => {
+ TITLE_MODES.forEach((m) => {
+ expect(m).toHaveProperty('label');
+ expect(m).toHaveProperty('value');
+ });
+ });
+
+ it('contains exact, contains, search and regex modes', () => {
+ const values = TITLE_MODES.map((m) => m.value);
+ expect(values).toContain('exact');
+ expect(values).toContain('contains');
+ expect(values).toContain('search');
+ expect(values).toContain('regex');
+ });
+ });
+
+ describe('DESCRIPTION_MODES', () => {
+ it('exports an array of mode objects', () => {
+ expect(Array.isArray(DESCRIPTION_MODES)).toBe(true);
+ expect(DESCRIPTION_MODES.length).toBeGreaterThan(0);
+ });
+
+ it('every entry has a label and value', () => {
+ DESCRIPTION_MODES.forEach((m) => {
+ expect(m).toHaveProperty('label');
+ expect(m).toHaveProperty('value');
+ });
+ });
+
+ it('contains contains, search and regex modes', () => {
+ const values = DESCRIPTION_MODES.map((m) => m.value);
+ expect(values).toContain('contains');
+ expect(values).toContain('search');
+ expect(values).toContain('regex');
+ });
+
+ it('does not contain exact mode', () => {
+ const values = DESCRIPTION_MODES.map((m) => m.value);
+ expect(values).not.toContain('exact');
+ });
+ });
+
+ describe('EPISODE_MODES', () => {
+ it('exports an array with all and new modes', () => {
+ const values = EPISODE_MODES.map((m) => m.value);
+ expect(values).toContain('all');
+ expect(values).toContain('new');
+ });
+
+ it('every entry has a label and value', () => {
+ EPISODE_MODES.forEach((m) => {
+ expect(m).toHaveProperty('label');
+ expect(m).toHaveProperty('value');
+ });
+ });
+ });
+
+ // ── formatRange ────────────────────────────────────────────────────────────
+
+ describe('formatRange', () => {
+ it('formats same-day range as "date startTime - endTime"', () => {
+ const start = '2024-06-01T10:00:00';
+ const end = '2024-06-01T11:30:00';
+ const result = formatRange(start, end);
+ // Must include a dash separating start and end times (not "->")
+ expect(result).toMatch(/-(?!>)/);
+ expect(result).not.toMatch(/->/);
+ });
+
+ it('formats cross-day range with "->" separator', () => {
+ const start = '2024-06-01T23:00:00';
+ const end = '2024-06-02T01:00:00';
+ const result = formatRange(start, end);
+ expect(result).toMatch(/->/);
+ });
+
+ it('returns fallback string when dates are invalid', () => {
+ const result = formatRange('not-a-date', 'also-not-a-date');
+ expect(result).toBe('not-a-date - also-not-a-date');
+ });
+
+ it('includes a date string component in the output', () => {
+ const start = '2024-06-01T10:00:00';
+ const end = '2024-06-01T11:00:00';
+ const result = formatRange(start, end);
+ // toLocaleDateString produces something non-empty
+ expect(result.length).toBeGreaterThan(5);
+ });
+
+ it('handles ISO strings with timezone offsets', () => {
+ const start = '2024-06-01T10:00:00Z';
+ const end = '2024-06-01T11:00:00Z';
+ // Should not throw
+ expect(() => formatRange(start, end)).not.toThrow();
+ });
+ });
+
+ // ── getTvgOptions ──────────────────────────────────────────────────────────
+
+ describe('getTvgOptions', () => {
+ it('returns empty array for null input', () => {
+ expect(getTvgOptions(null)).toEqual([]);
+ });
+
+ it('returns empty array for empty array input', () => {
+ expect(getTvgOptions([])).toEqual([]);
+ });
+
+ it('maps tvgs to { value, label } options', () => {
+ const tvgs = [{ tvg_id: 'tvg-1', name: 'Channel One' }];
+ const result = getTvgOptions(tvgs);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual({
+ value: 'tvg-1',
+ label: 'Channel One (tvg-1)',
+ });
+ });
+
+ it('uses tvg_id as label when name is missing', () => {
+ const tvgs = [{ tvg_id: 'tvg-no-name' }];
+ const result = getTvgOptions(tvgs);
+ expect(result[0]).toEqual({
+ value: 'tvg-no-name',
+ label: 'tvg-no-name',
+ });
+ });
+
+ it('deduplicates by tvg_id', () => {
+ const tvgs = [
+ { tvg_id: 'tvg-1', name: 'A' },
+ { tvg_id: 'tvg-1', name: 'B' },
+ { tvg_id: 'tvg-2', name: 'C' },
+ ];
+ const result = getTvgOptions(tvgs);
+ expect(result).toHaveLength(2);
+ const values = result.map((o) => o.value);
+ expect(values).toContain('tvg-1');
+ expect(values).toContain('tvg-2');
+ });
+
+ it('skips entries with no tvg_id', () => {
+ const tvgs = [
+ { tvg_id: null, name: 'No ID' },
+ { tvg_id: '', name: 'Empty ID' },
+ { tvg_id: 'tvg-1', name: 'Valid' },
+ ];
+ const result = getTvgOptions(tvgs);
+ expect(result).toHaveLength(1);
+ expect(result[0].value).toBe('tvg-1');
+ });
+
+ it('sorts options alphabetically by label', () => {
+ const tvgs = [
+ { tvg_id: 'tvg-z', name: 'Zebra' },
+ { tvg_id: 'tvg-a', name: 'Apple' },
+ { tvg_id: 'tvg-m', name: 'Mango' },
+ ];
+ const result = getTvgOptions(tvgs);
+ const labels = result.map((o) => o.label);
+ expect(labels).toEqual([...labels].sort());
+ });
+
+ it('sorts by label text including the tvg_id suffix', () => {
+ const tvgs = [
+ { tvg_id: 'z-id', name: 'Same' },
+ { tvg_id: 'a-id', name: 'Same' },
+ ];
+ const result = getTvgOptions(tvgs);
+ // "Same (a-id)" < "Same (z-id)"
+ expect(result[0].value).toBe('a-id');
+ });
+ });
+
+ // ── getChannelOptions ──────────────────────────────────────────────────────
+
+ describe('getChannelOptions', () => {
+ const makeChannels = () => [
+ { id: 1, name: 'ESPN', channel_number: 5, epg_data_id: 'epg-1' },
+ { id: 2, name: 'HBO', channel_number: 10, epg_data_id: 'epg-2' },
+ { id: 3, name: 'CNN', channel_number: 20, epg_data_id: 'epg-3' },
+ ];
+
+ const makeTvgsById = () => ({
+ 'epg-1': { tvg_id: 'tvg-espn' },
+ 'epg-2': { tvg_id: 'tvg-hbo' },
+ 'epg-3': { tvg_id: 'tvg-cnn' },
+ });
+
+ it('calls sortedChannelOptions with allChannels and numberedChannelLabel', () => {
+ const channels = makeChannels();
+ getChannelOptions(channels, {}, null);
+ expect(sortedChannelOptions).toHaveBeenCalledWith(
+ channels,
+ numberedChannelLabel
+ );
+ });
+
+ it('returns all channels when tvgId is null', () => {
+ const channels = makeChannels();
+ const result = getChannelOptions(channels, makeTvgsById(), null);
+ expect(result).toHaveLength(3);
+ });
+
+ it('returns all channels when tvgId is empty string', () => {
+ const channels = makeChannels();
+ const result = getChannelOptions(channels, makeTvgsById(), '');
+ expect(result).toHaveLength(3);
+ });
+
+ it('places matching channels before non-matching when tvgId set', () => {
+ // Override sortedChannelOptions to return deterministic output
+ vi.mocked(sortedChannelOptions).mockReturnValueOnce([
+ { value: '1', label: 'ESPN' },
+ { value: '2', label: 'HBO' },
+ { value: '3', label: 'CNN' },
+ ]);
+
+ const channels = makeChannels();
+ const tvgsById = makeTvgsById();
+
+ // tvgId matches ESPN (epg-1 → tvg-espn)
+ const result = getChannelOptions(channels, tvgsById, 'tvg-espn');
+
+ expect(result[0].value).toBe('1'); // ESPN first (matching)
+ expect(result[1].value).toBe('2'); // HBO second (non-matching)
+ expect(result[2].value).toBe('3'); // CNN third (non-matching)
+ });
+
+ it('returns all in sorted order when no channel matches tvgId', () => {
+ vi.mocked(sortedChannelOptions).mockReturnValueOnce([
+ { value: '1', label: 'ESPN' },
+ { value: '2', label: 'HBO' },
+ ]);
+
+ const channels = makeChannels();
+ const result = getChannelOptions(channels, makeTvgsById(), 'tvg-unknown');
+
+ // No matches → all in others, order preserved from sortedChannelOptions
+ expect(result.map((r) => r.value)).toEqual(['1', '2']);
+ });
+
+ it('handles channel with no epg_data_id (cTvg is null)', () => {
+ const channels = [
+ { id: 9, name: 'NoEPG', channel_number: 1, epg_data_id: null },
+ ];
+ vi.mocked(sortedChannelOptions).mockReturnValueOnce([
+ { value: '9', label: 'NoEPG' },
+ ]);
+
+ const result = getChannelOptions(channels, {}, 'tvg-1');
+
+ // No epg match → goes to others
+ expect(result).toHaveLength(1);
+ expect(result[0].value).toBe('9');
+ });
+
+ it('handles missing epg_data_id entry in tvgsById', () => {
+ const channels = [
+ {
+ id: 5,
+ name: 'Unknown',
+ channel_number: 1,
+ epg_data_id: 'epg-missing',
+ },
+ ];
+ vi.mocked(sortedChannelOptions).mockReturnValueOnce([
+ { value: '5', label: 'Unknown' },
+ ]);
+
+ const result = getChannelOptions(channels, {}, 'tvg-1');
+
+ expect(result).toHaveLength(1);
+ expect(result[0].value).toBe('5');
+ });
+
+ it('handles null tvgsById gracefully', () => {
+ const channels = makeChannels();
+ vi.mocked(sortedChannelOptions).mockReturnValueOnce([
+ { value: '1', label: 'ESPN' },
+ ]);
+
+ expect(() => getChannelOptions(channels, null, 'tvg-espn')).not.toThrow();
+ });
+
+ it('returns empty array for empty channels', () => {
+ vi.mocked(sortedChannelOptions).mockReturnValueOnce([]);
+ const result = getChannelOptions([], {}, 'tvg-1');
+ expect(result).toEqual([]);
+ });
+
+ it('places multiple matching channels before non-matching', () => {
+ vi.mocked(sortedChannelOptions).mockReturnValueOnce([
+ { value: '1', label: 'ESPN' },
+ { value: '2', label: 'ESPN2' },
+ { value: '3', label: 'CNN' },
+ ]);
+
+ const channels = [
+ { id: 1, name: 'ESPN', channel_number: 5, epg_data_id: 'epg-1' },
+ { id: 2, name: 'ESPN2', channel_number: 6, epg_data_id: 'epg-4' },
+ { id: 3, name: 'CNN', channel_number: 20, epg_data_id: 'epg-3' },
+ ];
+ const tvgsById = {
+ 'epg-1': { tvg_id: 'tvg-espn' },
+ 'epg-4': { tvg_id: 'tvg-espn' }, // both ESPN channels share tvg_id
+ 'epg-3': { tvg_id: 'tvg-cnn' },
+ };
+
+ const result = getChannelOptions(channels, tvgsById, 'tvg-espn');
+
+ expect(result[0].value).toBe('1');
+ expect(result[1].value).toBe('2');
+ expect(result[2].value).toBe('3');
+ });
+ });
+
+ // ── previewSeriesRule ──────────────────────────────────────────────────────
+
+ describe('previewSeriesRule', () => {
+ it('calls API.previewSeriesRule with the key and abort signal', async () => {
+ const mockResult = { matches: [], total: 0 };
+ vi.mocked(API.previewSeriesRule).mockResolvedValue(mockResult);
+
+ const controller = new AbortController();
+ const key = { title: 'Test Show', mode: 'all' };
+
+ const result = await previewSeriesRule(key, controller);
+
+ expect(API.previewSeriesRule).toHaveBeenCalledWith(key, {
+ signal: controller.signal,
+ });
+ expect(result).toBe(mockResult);
+ });
+
+ it('passes the abort signal from the controller', async () => {
+ vi.mocked(API.previewSeriesRule).mockResolvedValue({});
+ const controller = new AbortController();
+ await previewSeriesRule({ title: 'X' }, controller);
+
+ const callArgs = vi.mocked(API.previewSeriesRule).mock.calls[0];
+ expect(callArgs[1].signal).toBe(controller.signal);
+ });
+
+ it('propagates rejection from API.previewSeriesRule', async () => {
+ vi.mocked(API.previewSeriesRule).mockRejectedValue(
+ new Error('Network error')
+ );
+ const controller = new AbortController();
+ await expect(previewSeriesRule({}, controller)).rejects.toThrow(
+ 'Network error'
+ );
+ });
+
+ it('propagates AbortError when signal is aborted', async () => {
+ const abortError = new DOMException('Aborted', 'AbortError');
+ vi.mocked(API.previewSeriesRule).mockRejectedValue(abortError);
+
+ const controller = new AbortController();
+ controller.abort();
+
+ await expect(previewSeriesRule({}, controller)).rejects.toThrow(
+ 'Aborted'
+ );
+ });
+
+ it('passes through all fields of the preview key', async () => {
+ vi.mocked(API.previewSeriesRule).mockResolvedValue({});
+ const controller = new AbortController();
+ const key = {
+ title: 'My Show',
+ title_mode: 'exact',
+ description: 'drama',
+ description_mode: 'contains',
+ mode: 'new',
+ tvg_id: 'tvg-1',
+ channel_id: 5,
+ };
+
+ await previewSeriesRule(key, controller);
+
+ expect(API.previewSeriesRule).toHaveBeenCalledWith(
+ key,
+ expect.objectContaining({ signal: controller.signal })
+ );
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js b/frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js
new file mode 100644
index 00000000..100f7817
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js
@@ -0,0 +1,199 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ updateStreamProfile: vi.fn(),
+ addStreamProfile: vi.fn(),
+ },
+}));
+
+vi.mock('@hookform/resolvers/yup', () => ({
+ yupResolver: vi.fn((schema) => ({ __schema: schema })),
+}));
+
+import API from '../../../api.js';
+import { yupResolver } from '@hookform/resolvers/yup';
+import {
+ BUILT_IN_COMMANDS,
+ COMMAND_EXAMPLES,
+ toCommandSelection,
+ getResolver,
+ updateStreamProfile,
+ addStreamProfile,
+} from '../StreamProfileUtils.js';
+
+describe('StreamProfileUtils', () => {
+ // ─── BUILT_IN_COMMANDS ────────────────────────────────────────────────────────
+
+ describe('BUILT_IN_COMMANDS', () => {
+ it('includes ffmpeg, streamlink, cvlc, yt-dlp, and __custom__', () => {
+ const values = BUILT_IN_COMMANDS.map((c) => c.value);
+ expect(values).toContain('ffmpeg');
+ expect(values).toContain('streamlink');
+ expect(values).toContain('cvlc');
+ expect(values).toContain('yt-dlp');
+ expect(values).toContain('__custom__');
+ });
+
+ it('each entry has a value and a label', () => {
+ for (const cmd of BUILT_IN_COMMANDS) {
+ expect(cmd).toHaveProperty('value');
+ expect(cmd).toHaveProperty('label');
+ expect(typeof cmd.value).toBe('string');
+ expect(typeof cmd.label).toBe('string');
+ }
+ });
+
+ it('labels the custom entry "Custom…"', () => {
+ const custom = BUILT_IN_COMMANDS.find((c) => c.value === '__custom__');
+ expect(custom?.label).toBe('Custom…');
+ });
+ });
+
+ // ─── COMMAND_EXAMPLES ─────────────────────────────────────────────────────────
+
+ describe('COMMAND_EXAMPLES', () => {
+ it('has an entry for each non-custom built-in command', () => {
+ const nonCustom = BUILT_IN_COMMANDS.filter(
+ (c) => c.value !== '__custom__'
+ );
+ for (const cmd of nonCustom) {
+ expect(COMMAND_EXAMPLES).toHaveProperty(cmd.value);
+ expect(typeof COMMAND_EXAMPLES[cmd.value]).toBe('string');
+ expect(COMMAND_EXAMPLES[cmd.value].length).toBeGreaterThan(0);
+ }
+ });
+
+ it('does not have an entry for __custom__', () => {
+ expect(COMMAND_EXAMPLES).not.toHaveProperty('__custom__');
+ });
+
+ it('ffmpeg example contains {streamUrl}', () => {
+ expect(COMMAND_EXAMPLES.ffmpeg).toContain('{streamUrl}');
+ });
+
+ it('streamlink example contains {streamUrl}', () => {
+ expect(COMMAND_EXAMPLES.streamlink).toContain('{streamUrl}');
+ });
+
+ it('cvlc example contains {streamUrl}', () => {
+ expect(COMMAND_EXAMPLES.cvlc).toContain('{streamUrl}');
+ });
+
+ it('yt-dlp example contains {streamUrl}', () => {
+ expect(COMMAND_EXAMPLES['yt-dlp']).toContain('{streamUrl}');
+ });
+ });
+
+ // ─── toCommandSelection ───────────────────────────────────────────────────────
+
+ describe('toCommandSelection', () => {
+ it('returns "ffmpeg" for "ffmpeg"', () => {
+ expect(toCommandSelection('ffmpeg')).toBe('ffmpeg');
+ });
+
+ it('returns "streamlink" for "streamlink"', () => {
+ expect(toCommandSelection('streamlink')).toBe('streamlink');
+ });
+
+ it('returns "cvlc" for "cvlc"', () => {
+ expect(toCommandSelection('cvlc')).toBe('cvlc');
+ });
+
+ it('returns "yt-dlp" for "yt-dlp"', () => {
+ expect(toCommandSelection('yt-dlp')).toBe('yt-dlp');
+ });
+
+ it('returns "__custom__" for "__custom__"', () => {
+ expect(toCommandSelection('__custom__')).toBe('__custom__');
+ });
+
+ it('returns "__custom__" for an unrecognized command', () => {
+ expect(toCommandSelection('myspecialtool')).toBe('__custom__');
+ });
+
+ it('returns "__custom__" for an empty string', () => {
+ expect(toCommandSelection('')).toBe('__custom__');
+ });
+
+ it('returns "__custom__" for null', () => {
+ expect(toCommandSelection(null)).toBe('__custom__');
+ });
+
+ it('returns "__custom__" for undefined', () => {
+ expect(toCommandSelection(undefined)).toBe('__custom__');
+ });
+ });
+
+ // ─── getResolver ──────────────────────────────────────────────────────────────
+
+ describe('getResolver', () => {
+ it('calls yupResolver and returns its result', () => {
+ const result = getResolver();
+ expect(yupResolver).toHaveBeenCalledTimes(1);
+ expect(result).toBeDefined();
+ });
+
+ it('returns the same resolver on subsequent calls', () => {
+ const r1 = getResolver();
+ const r2 = getResolver();
+ expect(r1).toEqual(r2);
+ });
+ });
+
+ // ─── updateStreamProfile ──────────────────────────────────────────────────────
+
+ describe('updateStreamProfile', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('calls API.updateStreamProfile with id merged into values', () => {
+ API.updateStreamProfile.mockResolvedValueOnce({ id: 3, name: 'HD' });
+ updateStreamProfile(3, { name: 'HD', command: 'ffmpeg' });
+ expect(API.updateStreamProfile).toHaveBeenCalledWith({
+ id: 3,
+ name: 'HD',
+ command: 'ffmpeg',
+ });
+ });
+
+ it('returns the promise from API.updateStreamProfile', () => {
+ const resolved = Promise.resolve({ id: 1 });
+ API.updateStreamProfile.mockReturnValueOnce(resolved);
+ const result = updateStreamProfile(1, {});
+ expect(result).toBe(resolved);
+ });
+ });
+
+ // ─── addStreamProfile ─────────────────────────────────────────────────────────
+
+ describe('addStreamProfile', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('calls API.addStreamProfile with the provided values', () => {
+ API.addStreamProfile.mockResolvedValueOnce({ id: 5 });
+ addStreamProfile({ name: 'New', command: 'streamlink', parameters: '' });
+ expect(API.addStreamProfile).toHaveBeenCalledWith({
+ name: 'New',
+ command: 'streamlink',
+ parameters: '',
+ });
+ });
+
+ it('returns the promise from API.addStreamProfile', () => {
+ const resolved = Promise.resolve({ id: 5 });
+ API.addStreamProfile.mockReturnValueOnce(resolved);
+ const result = addStreamProfile({});
+ expect(result).toBe(resolved);
+ });
+
+ it('passes through an empty object without error', () => {
+ API.addStreamProfile.mockResolvedValueOnce({});
+ expect(() => addStreamProfile({})).not.toThrow();
+ expect(API.addStreamProfile).toHaveBeenCalledWith({});
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/StreamUtils.test.js b/frontend/src/utils/forms/__tests__/StreamUtils.test.js
new file mode 100644
index 00000000..1cb25805
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/StreamUtils.test.js
@@ -0,0 +1,95 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ updateStream: vi.fn(),
+ addStream: vi.fn(),
+ },
+}));
+
+vi.mock('@hookform/resolvers/yup', () => ({
+ yupResolver: vi.fn((schema) => ({ __schema: schema })),
+}));
+
+import API from '../../../api.js';
+import { yupResolver } from '@hookform/resolvers/yup';
+import { getResolver, updateStream, addStream } from '../StreamUtils.js';
+
+describe('StreamUtils', () => {
+ // ─── getResolver ──────────────────────────────────────────────────────────────
+
+ describe('getResolver', () => {
+ it('calls yupResolver and returns its result', () => {
+ const result = getResolver();
+ expect(yupResolver).toHaveBeenCalledTimes(1);
+ expect(result).toBeDefined();
+ });
+
+ it('returns the same resolver on subsequent calls', () => {
+ const r1 = getResolver();
+ const r2 = getResolver();
+ expect(r1).toEqual(r2);
+ });
+ });
+
+ // ─── updateStream ─────────────────────────────────────────────────────────────
+
+ describe('updateStream', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('calls API.updateStream with id merged into payload', () => {
+ API.updateStream.mockResolvedValueOnce({ id: 1, name: 'Test Stream' });
+ updateStream(1, { name: 'Test Stream', url: 'http://example.com' });
+ expect(API.updateStream).toHaveBeenCalledWith({
+ id: 1,
+ name: 'Test Stream',
+ url: 'http://example.com',
+ });
+ });
+
+ it('returns the promise from API.updateStream', () => {
+ const resolved = Promise.resolve({ id: 1 });
+ API.updateStream.mockReturnValueOnce(resolved);
+ const result = updateStream(1, {});
+ expect(result).toBe(resolved);
+ });
+
+ it('handles an empty payload without throwing', () => {
+ API.updateStream.mockResolvedValueOnce({});
+ expect(() => updateStream(5, {})).not.toThrow();
+ expect(API.updateStream).toHaveBeenCalledWith({ id: 5 });
+ });
+ });
+
+ // ─── addStream ────────────────────────────────────────────────────────────────
+
+ describe('addStream', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('calls API.addStream with the provided payload', () => {
+ API.addStream.mockResolvedValueOnce({ id: 2 });
+ addStream({ name: 'New Stream', url: 'http://example.com/stream' });
+ expect(API.addStream).toHaveBeenCalledWith({
+ name: 'New Stream',
+ url: 'http://example.com/stream',
+ });
+ });
+
+ it('returns the promise from API.addStream', () => {
+ const resolved = Promise.resolve({ id: 2 });
+ API.addStream.mockReturnValueOnce(resolved);
+ const result = addStream({ name: 'test' });
+ expect(result).toBe(resolved);
+ });
+
+ it('passes through an empty object without error', () => {
+ API.addStream.mockResolvedValueOnce({});
+ expect(() => addStream({})).not.toThrow();
+ expect(API.addStream).toHaveBeenCalledWith({});
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/UserAgentUtils.test.js b/frontend/src/utils/forms/__tests__/UserAgentUtils.test.js
new file mode 100644
index 00000000..2e0bed4f
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/UserAgentUtils.test.js
@@ -0,0 +1,99 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ updateUserAgent: vi.fn(),
+ addUserAgent: vi.fn(),
+ },
+}));
+
+vi.mock('@hookform/resolvers/yup', () => ({
+ yupResolver: vi.fn((schema) => ({ __schema: schema })),
+}));
+
+import API from '../../../api.js';
+import { yupResolver } from '@hookform/resolvers/yup';
+import {
+ getResolver,
+ updateUserAgent,
+ addUserAgent,
+} from '../UserAgentUtils.js';
+
+describe('UserAgentUtils', () => {
+ // ─── getResolver ──────────────────────────────────────────────────────────────
+
+ describe('getResolver', () => {
+ it('calls yupResolver and returns its result', () => {
+ const result = getResolver();
+ expect(yupResolver).toHaveBeenCalledTimes(1);
+ expect(result).toBeDefined();
+ });
+
+ it('returns the same resolver on subsequent calls', () => {
+ const r1 = getResolver();
+ const r2 = getResolver();
+ expect(r1).toEqual(r2);
+ });
+ });
+
+ // ─── updateUserAgent ──────────────────────────────────────────────────────────
+
+ describe('updateUserAgent', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('calls API.updateUserAgent with id merged into values', () => {
+ API.updateUserAgent.mockResolvedValueOnce({ id: 1, name: 'Chrome' });
+ updateUserAgent(1, { name: 'Chrome', user_agent: 'Mozilla/5.0' });
+ expect(API.updateUserAgent).toHaveBeenCalledWith({
+ id: 1,
+ name: 'Chrome',
+ user_agent: 'Mozilla/5.0',
+ });
+ });
+
+ it('returns the promise from API.updateUserAgent', () => {
+ const resolved = Promise.resolve({ id: 1 });
+ API.updateUserAgent.mockReturnValueOnce(resolved);
+ const result = updateUserAgent(1, {});
+ expect(result).toBe(resolved);
+ });
+
+ it('handles an empty values object without throwing', () => {
+ API.updateUserAgent.mockResolvedValueOnce({});
+ expect(() => updateUserAgent(5, {})).not.toThrow();
+ expect(API.updateUserAgent).toHaveBeenCalledWith({ id: 5 });
+ });
+ });
+
+ // ─── addUserAgent ─────────────────────────────────────────────────────────────
+
+ describe('addUserAgent', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('calls API.addUserAgent with the provided values', () => {
+ API.addUserAgent.mockResolvedValueOnce({ id: 2 });
+ addUserAgent({ name: 'Firefox', user_agent: 'Mozilla/5.0 (Firefox)' });
+ expect(API.addUserAgent).toHaveBeenCalledWith({
+ name: 'Firefox',
+ user_agent: 'Mozilla/5.0 (Firefox)',
+ });
+ });
+
+ it('returns the promise from API.addUserAgent', () => {
+ const resolved = Promise.resolve({ id: 2 });
+ API.addUserAgent.mockReturnValueOnce(resolved);
+ const result = addUserAgent({ name: 'test' });
+ expect(result).toBe(resolved);
+ });
+
+ it('passes through an empty object without error', () => {
+ API.addUserAgent.mockResolvedValueOnce({});
+ expect(() => addUserAgent({})).not.toThrow();
+ expect(API.addUserAgent).toHaveBeenCalledWith({});
+ });
+ });
+});
diff --git a/frontend/src/utils/forms/__tests__/UserUtils.test.js b/frontend/src/utils/forms/__tests__/UserUtils.test.js
new file mode 100644
index 00000000..5157ac96
--- /dev/null
+++ b/frontend/src/utils/forms/__tests__/UserUtils.test.js
@@ -0,0 +1,525 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+vi.mock('../../../api.js', () => ({
+ default: {
+ createUser: vi.fn(),
+ updateUser: vi.fn(),
+ generateApiKey: vi.fn(),
+ revokeApiKey: vi.fn(),
+ },
+}));
+
+vi.mock('../../../constants.js', () => ({
+ USER_LEVELS: {
+ ADMIN: 0,
+ STREAMER: 2,
+ },
+ NETWORK_ACCESS_OPTIONS: {
+ m3u: 'M3U',
+ xc: 'XC',
+ mpegts: 'MPEG-TS',
+ },
+}));
+
+vi.mock('../../networkUtils.js', () => ({
+ IPV4_CIDR_REGEX: /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/,
+ IPV6_CIDR_REGEX: /^([0-9a-fA-F:]+)\/\d{1,3}$/,
+}));
+
+import API from '../../../api.js';
+import {
+ createUser,
+ updateUser,
+ generateApiKey,
+ revokeApiKey,
+ userToFormValues,
+ formValuesToPayload,
+ getFormInitialValues,
+ getFormValidators,
+} from '../UserUtils.js';
+
+describe('UserUtils', () => {
+ // ─── createUser ───────────────────────────────────────────────────────────────
+
+ describe('createUser', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it('calls API.createUser with the provided values', () => {
+ API.createUser.mockResolvedValueOnce({ id: 1 });
+ createUser({ username: 'alice', password: 'pass' });
+ expect(API.createUser).toHaveBeenCalledWith({
+ username: 'alice',
+ password: 'pass',
+ });
+ });
+
+ it('returns the promise from API.createUser', () => {
+ const resolved = Promise.resolve({ id: 1 });
+ API.createUser.mockReturnValueOnce(resolved);
+ expect(createUser({})).toBe(resolved);
+ });
+ });
+
+ // ─── updateUser ───────────────────────────────────────────────────────────────
+
+ describe('updateUser', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it('calls API.updateUser with selfEdit=false when isAdmin is true', () => {
+ API.updateUser.mockResolvedValueOnce({});
+ updateUser(1, { username: 'bob' }, true, { id: 1 });
+ expect(API.updateUser).toHaveBeenCalledWith(
+ 1,
+ { username: 'bob' },
+ false
+ );
+ });
+
+ it('calls API.updateUser with selfEdit=true when not admin and userId matches authUser', () => {
+ API.updateUser.mockResolvedValueOnce({});
+ updateUser(5, { username: 'carol' }, false, { id: 5 });
+ expect(API.updateUser).toHaveBeenCalledWith(
+ 5,
+ { username: 'carol' },
+ true
+ );
+ });
+
+ it('calls API.updateUser with selfEdit=false when not admin and userId does not match authUser', () => {
+ API.updateUser.mockResolvedValueOnce({});
+ updateUser(5, { username: 'dave' }, false, { id: 99 });
+ expect(API.updateUser).toHaveBeenCalledWith(
+ 5,
+ { username: 'dave' },
+ false
+ );
+ });
+
+ it('returns the promise from API.updateUser', () => {
+ const resolved = Promise.resolve({});
+ API.updateUser.mockReturnValueOnce(resolved);
+ expect(updateUser(1, {}, true, { id: 1 })).toBe(resolved);
+ });
+ });
+
+ // ─── generateApiKey ───────────────────────────────────────────────────────────
+
+ describe('generateApiKey', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it('calls API.generateApiKey with the payload', () => {
+ API.generateApiKey.mockResolvedValueOnce({ key: 'abc123' });
+ generateApiKey({ user_id: 1 });
+ expect(API.generateApiKey).toHaveBeenCalledWith({ user_id: 1 });
+ });
+
+ it('returns the promise from API.generateApiKey', () => {
+ const resolved = Promise.resolve({ key: 'abc' });
+ API.generateApiKey.mockReturnValueOnce(resolved);
+ expect(generateApiKey({})).toBe(resolved);
+ });
+ });
+
+ // ─── revokeApiKey ─────────────────────────────────────────────────────────────
+
+ describe('revokeApiKey', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it('calls API.revokeApiKey with the payload', () => {
+ API.revokeApiKey.mockResolvedValueOnce({});
+ revokeApiKey({ key: 'abc123' });
+ expect(API.revokeApiKey).toHaveBeenCalledWith({ key: 'abc123' });
+ });
+
+ it('returns the promise from API.revokeApiKey', () => {
+ const resolved = Promise.resolve({});
+ API.revokeApiKey.mockReturnValueOnce(resolved);
+ expect(revokeApiKey({})).toBe(resolved);
+ });
+ });
+
+ // ─── userToFormValues ─────────────────────────────────────────────────────────
+
+ describe('userToFormValues', () => {
+ const makeUser = (overrides = {}) => ({
+ username: 'alice',
+ first_name: 'Alice',
+ last_name: 'Smith',
+ email: 'alice@example.com',
+ user_level: 1,
+ stream_limit: 2,
+ channel_profiles: ['1', '2'],
+ custom_properties: {},
+ ...overrides,
+ });
+
+ it('maps basic user fields to form values', () => {
+ const result = userToFormValues(makeUser());
+ expect(result.username).toBe('alice');
+ expect(result.first_name).toBe('Alice');
+ expect(result.last_name).toBe('Smith');
+ expect(result.email).toBe('alice@example.com');
+ expect(result.user_level).toBe('1');
+ expect(result.stream_limit).toBe(2);
+ });
+
+ it('defaults first_name and last_name to empty string when absent', () => {
+ const result = userToFormValues(
+ makeUser({ first_name: null, last_name: null })
+ );
+ expect(result.first_name).toBe('');
+ expect(result.last_name).toBe('');
+ });
+
+ it('defaults stream_limit to 0 when absent', () => {
+ const result = userToFormValues(makeUser({ stream_limit: null }));
+ expect(result.stream_limit).toBe(0);
+ });
+
+ it('uses channel_profiles when non-empty', () => {
+ const result = userToFormValues(
+ makeUser({ channel_profiles: ['3', '4'] })
+ );
+ expect(result.channel_profiles).toEqual(['3', '4']);
+ });
+
+ it('maps custom_properties fields with defaults', () => {
+ const result = userToFormValues(makeUser({ custom_properties: {} }));
+ expect(result.xc_password).toBe('');
+ expect(result.output_format).toBe('');
+ expect(result.output_profile).toBe('');
+ expect(result.hide_adult_content).toBe(false);
+ expect(result.epg_days).toBe(0);
+ expect(result.epg_prev_days).toBe(0);
+ });
+
+ it('maps custom_properties when present', () => {
+ const user = makeUser({
+ custom_properties: {
+ xc_password: 'xpass',
+ output_format: 'ts',
+ output_profile: 5,
+ hide_adult_content: true,
+ epg_days: 7,
+ epg_prev_days: 2,
+ allowed_networks: {
+ m3u: '192.168.1.0/24,10.0.0.1/32',
+ xc: '192.168.1.0/24,10.0.0.1/32',
+ },
+ },
+ });
+ const result = userToFormValues(user);
+ expect(result.xc_password).toBe('xpass');
+ expect(result.output_format).toBe('ts');
+ expect(result.output_profile).toBe('5');
+ expect(result.hide_adult_content).toBe(true);
+ expect(result.epg_days).toBe(7);
+ expect(result.epg_prev_days).toBe(2);
+ });
+
+ it('deduplicates allowed_ips from allowed_networks', () => {
+ const user = makeUser({
+ custom_properties: {
+ allowed_networks: {
+ m3u: '192.168.1.0/24,10.0.0.1/32',
+ xc: '192.168.1.0/24,10.0.0.1/32',
+ },
+ },
+ });
+ const result = userToFormValues(user);
+ expect(result.allowed_ips).toEqual(['192.168.1.0/24', '10.0.0.1/32']);
+ });
+
+ it('returns empty allowed_ips when no allowed_networks', () => {
+ const result = userToFormValues(makeUser({ custom_properties: {} }));
+ expect(result.allowed_ips).toEqual([]);
+ });
+ });
+
+ // ─── formValuesToPayload ──────────────────────────────────────────────────────
+
+ describe('formValuesToPayload', () => {
+ const makeValues = (overrides = {}) => ({
+ username: 'alice',
+ email: 'alice@example.com',
+ xc_password: 'mypass',
+ output_format: 'ts',
+ output_profile: '3',
+ hide_adult_content: true,
+ epg_days: 7,
+ epg_prev_days: 2,
+ allowed_ips: ['192.168.1.0/24'],
+ channel_profiles: ['1', '2'],
+ ...overrides,
+ });
+
+ it('moves xc_password into custom_properties and removes it from payload', () => {
+ const result = formValuesToPayload(makeValues(), null);
+ expect(result.xc_password).toBeUndefined();
+ expect(result.custom_properties.xc_password).toBe('mypass');
+ });
+
+ it('moves output_format into custom_properties', () => {
+ const result = formValuesToPayload(makeValues(), null);
+ expect(result.output_format).toBeUndefined();
+ expect(result.custom_properties.output_format).toBe('ts');
+ });
+
+ it('parses output_profile as int in custom_properties', () => {
+ const result = formValuesToPayload(makeValues(), null);
+ expect(result.output_profile).toBeUndefined();
+ expect(result.custom_properties.output_profile).toBe(3);
+ });
+
+ it('sets output_profile to null when empty string', () => {
+ const result = formValuesToPayload(
+ makeValues({ output_profile: '' }),
+ null
+ );
+ expect(result.custom_properties.output_profile).toBeNull();
+ });
+
+ it('moves hide_adult_content into custom_properties', () => {
+ const result = formValuesToPayload(makeValues(), null);
+ expect(result.hide_adult_content).toBeUndefined();
+ expect(result.custom_properties.hide_adult_content).toBe(true);
+ });
+
+ it('moves epg_days and epg_prev_days into custom_properties', () => {
+ const result = formValuesToPayload(makeValues(), null);
+ expect(result.epg_days).toBeUndefined();
+ expect(result.epg_prev_days).toBeUndefined();
+ expect(result.custom_properties.epg_days).toBe(7);
+ expect(result.custom_properties.epg_prev_days).toBe(2);
+ });
+
+ it('populates allowed_networks for all NETWORK_KEYS when allowed_ips present', () => {
+ const result = formValuesToPayload(
+ makeValues({ allowed_ips: ['192.168.1.0/24', '10.0.0.1/32'] }),
+ null
+ );
+ expect(result.allowed_ips).toBeUndefined();
+ expect(result.custom_properties.allowed_networks.m3u).toBe(
+ '192.168.1.0/24,10.0.0.1/32'
+ );
+ expect(result.custom_properties.allowed_networks.xc).toBe(
+ '192.168.1.0/24,10.0.0.1/32'
+ );
+ expect(result.custom_properties.allowed_networks.mpegts).toBe(
+ '192.168.1.0/24,10.0.0.1/32'
+ );
+ });
+
+ it('sets empty allowed_networks when allowed_ips is empty', () => {
+ const result = formValuesToPayload(makeValues({ allowed_ips: [] }), null);
+ expect(result.custom_properties.allowed_networks).toEqual({});
+ });
+
+ it('sets channel_profiles to empty array when it includes "0"', () => {
+ const result = formValuesToPayload(
+ makeValues({ channel_profiles: ['0'] }),
+ null
+ );
+ expect(result.channel_profiles).toEqual([]);
+ });
+
+ it('preserves channel_profiles when it does not include "0"', () => {
+ const result = formValuesToPayload(
+ makeValues({ channel_profiles: ['1', '2'] }),
+ null
+ );
+ expect(result.channel_profiles).toEqual(['1', '2']);
+ });
+
+ it('merges existing custom_properties from existingUser', () => {
+ const existingUser = {
+ custom_properties: { some_other_prop: 'keep_me' },
+ };
+ const result = formValuesToPayload(makeValues(), existingUser);
+ expect(result.custom_properties.some_other_prop).toBe('keep_me');
+ });
+
+ it('handles null existingUser gracefully', () => {
+ expect(() => formValuesToPayload(makeValues(), null)).not.toThrow();
+ });
+ });
+
+ // ─── getFormInitialValues ─────────────────────────────────────────────────────
+
+ describe('getFormInitialValues', () => {
+ it('returns the expected default structure', () => {
+ const result = getFormInitialValues();
+ expect(result).toEqual({
+ username: '',
+ first_name: '',
+ last_name: '',
+ email: '',
+ user_level: '0',
+ stream_limit: 0,
+ password: '',
+ xc_password: '',
+ output_format: '',
+ output_profile: '',
+ channel_profiles: [],
+ hide_adult_content: false,
+ epg_days: 0,
+ epg_prev_days: 0,
+ allowed_ips: [],
+ });
+ });
+
+ it('returns a new object on each call', () => {
+ const a = getFormInitialValues();
+ const b = getFormInitialValues();
+ expect(a).not.toBe(b);
+ });
+ });
+
+ // ─── getFormValidators ────────────────────────────────────────────────────────
+
+ describe('getFormValidators', () => {
+ const validate = (user, values) => getFormValidators(user)(values);
+
+ describe('username', () => {
+ it('returns error when username is empty', () => {
+ const result = validate(null, {
+ ...getFormInitialValues(),
+ username: '',
+ });
+ expect(result.username).toBe('Username is required');
+ });
+
+ it('returns null for a valid non-streamer username', () => {
+ const result = validate(null, {
+ ...getFormInitialValues(),
+ username: 'alice',
+ user_level: '0',
+ });
+ expect(result.username).toBeNull();
+ });
+
+ it('returns null for a valid alphanumeric streamer username', () => {
+ const result = validate(null, {
+ ...getFormInitialValues(),
+ username: 'alice123',
+ user_level: '2',
+ });
+ expect(result.username).toBeNull();
+ });
+
+ it('returns error for streamer username with non-alphanumeric characters', () => {
+ const result = validate(null, {
+ ...getFormInitialValues(),
+ username: 'alice_123',
+ user_level: '2',
+ });
+ expect(result.username).toBe('Streamer username must be alphanumeric');
+ });
+ });
+
+ describe('password', () => {
+ it('returns error when creating a non-streamer user without a password', () => {
+ const result = validate(null, {
+ ...getFormInitialValues(),
+ user_level: '0',
+ password: '',
+ });
+ expect(result.password).toBe('Password is required');
+ });
+
+ it('returns null when creating a streamer user without a password', () => {
+ const result = validate(null, {
+ ...getFormInitialValues(),
+ user_level: '2',
+ password: '',
+ });
+ expect(result.password).toBeNull();
+ });
+
+ it('returns null when editing an existing user without a password', () => {
+ const result = validate(
+ { id: 1 },
+ { ...getFormInitialValues(), user_level: '0', password: '' }
+ );
+ expect(result.password).toBeNull();
+ });
+
+ it('returns null when password is provided for new user', () => {
+ const result = validate(null, {
+ ...getFormInitialValues(),
+ user_level: '0',
+ password: 'secret',
+ });
+ expect(result.password).toBeNull();
+ });
+ });
+
+ describe('xc_password', () => {
+ it('returns null when xc_password is empty', () => {
+ const result = validate(null, {
+ ...getFormInitialValues(),
+ xc_password: '',
+ });
+ expect(result.xc_password).toBeNull();
+ });
+
+ it('returns null for a valid alphanumeric xc_password', () => {
+ const result = validate(null, {
+ ...getFormInitialValues(),
+ xc_password: 'abc123',
+ });
+ expect(result.xc_password).toBeNull();
+ });
+
+ it('returns error for xc_password with non-alphanumeric characters', () => {
+ const result = validate(null, {
+ ...getFormInitialValues(),
+ xc_password: 'abc!@#',
+ });
+ expect(result.xc_password).toBe('XC password must be alphanumeric');
+ });
+ });
+
+ describe('allowed_ips', () => {
+ it('returns null for an empty allowed_ips array', () => {
+ const result = validate(null, {
+ ...getFormInitialValues(),
+ allowed_ips: [],
+ });
+ expect(result.allowed_ips).toBeNull();
+ });
+
+ it('returns null for a valid IPv4 CIDR', () => {
+ const result = validate(null, {
+ ...getFormInitialValues(),
+ allowed_ips: ['192.168.1.0/24'],
+ });
+ expect(result.allowed_ips).toBeNull();
+ });
+
+ it('returns null for a bare IPv4 address (auto-appends /32)', () => {
+ const result = validate(null, {
+ ...getFormInitialValues(),
+ allowed_ips: ['10.0.0.1'],
+ });
+ expect(result.allowed_ips).toBeNull();
+ });
+
+ it('returns error for an invalid IP entry', () => {
+ const result = validate(null, {
+ ...getFormInitialValues(),
+ allowed_ips: ['not-an-ip'],
+ });
+ expect(result.allowed_ips).toBe('Invalid IP address or CIDR range');
+ });
+
+ it('returns error when any entry in the list is invalid', () => {
+ const result = validate(null, {
+ ...getFormInitialValues(),
+ allowed_ips: ['192.168.1.0/24', 'bad-entry'],
+ });
+ expect(result.allowed_ips).toBe('Invalid IP address or CIDR range');
+ });
+ });
+ });
+});
diff --git a/frontend/src/utils/guideUtils.js b/frontend/src/utils/guideUtils.js
index 58831713..9d6ec73c 100644
--- a/frontend/src/utils/guideUtils.js
+++ b/frontend/src/utils/guideUtils.js
@@ -11,7 +11,7 @@ import {
getNow,
getNowMs,
roundToNearest,
-} from '../utils/dateTimeUtils.js';
+} from './dateTimeUtils.js';
import API from '../api.js';
export const PROGRAM_HEIGHT = 90;
@@ -309,25 +309,12 @@ export const getRuleByProgram = (rules, program) => {
);
};
-export const createRecording = async (channel, program) => {
- await API.createRecording({
- channel: `${channel.id}`,
- start_time: program.start_time,
- end_time: program.end_time,
- custom_properties: { program },
- });
+export const createRecording = async (values) => {
+ await API.createRecording(values);
};
-export const createSeriesRule = async (program, mode) => {
- await API.createSeriesRule({
- tvg_id: program.tvg_id,
- mode,
- title: program.title,
- });
-};
-
-export const evaluateSeriesRule = async (program) => {
- await API.evaluateSeriesRules(program.tvg_id);
+export const createSeriesRule = async (values) => {
+ await API.createSeriesRule(values);
};
export const calculateLeftScrollPosition = (program, start) => {
diff --git a/pyproject.toml b/pyproject.toml
index db7efc78..a003bc5c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ requires-python = ">=3.13"
dynamic = ["version"]
dependencies = [
"Django==6.0.5",
- "psycopg2-binary==2.9.12",
+ "psycopg[binary]",
"celery[redis]==5.6.3",
"djangorestframework==3.17.1",
"requests==2.33.1",
@@ -18,7 +18,7 @@ dependencies = [
"python-vlc",
"yt-dlp",
"gevent==26.4.0",
- "psycogreen",
+ "django-db-geventpool",
"daphne",
"uwsgi",
"django-cors-headers",