From 6021bdf741a89164e568212f97b8365824a742cd Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 25 May 2026 18:02:01 -0700 Subject: [PATCH 01/10] Extracted utils --- .../utils/forms/RecordingDetailsModalUtils.js | 21 ++ frontend/src/utils/forms/RecordingUtils.js | 181 ++++++++++++++++++ .../utils/forms/RecurringRuleModalUtils.js | 56 +++--- .../utils/forms/SeriesRuleEditorModalUtils.js | 84 ++++++++ .../src/utils/forms/StreamProfileUtils.js | 44 +++++ frontend/src/utils/forms/StreamUtils.js | 19 ++ frontend/src/utils/forms/UserAgentUtils.js | 17 ++ frontend/src/utils/forms/UserUtils.js | 150 +++++++++++++++ frontend/src/utils/guideUtils.js | 23 +-- 9 files changed, 544 insertions(+), 51 deletions(-) create mode 100644 frontend/src/utils/forms/RecordingUtils.js create mode 100644 frontend/src/utils/forms/SeriesRuleEditorModalUtils.js create mode 100644 frontend/src/utils/forms/StreamProfileUtils.js create mode 100644 frontend/src/utils/forms/StreamUtils.js create mode 100644 frontend/src/utils/forms/UserAgentUtils.js create mode 100644 frontend/src/utils/forms/UserUtils.js 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..dbcd545e --- /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]; +}; \ No newline at end of file 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..256d84bb --- /dev/null +++ b/frontend/src/utils/forms/UserUtils.js @@ -0,0 +1,150 @@ +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, + }); +} \ No newline at end of file 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) => { From 1dd0446701ffd51031f5c80acf023483d7630817 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 25 May 2026 18:02:59 -0700 Subject: [PATCH 02/10] Added tests for utils --- .../RecordingDetailsModalUtils.test.js | 104 ++++ .../forms/__tests__/RecordingUtils.test.js | 503 ++++++++++++++++++ .../__tests__/RecurringRuleModalUtils.test.js | 163 ++---- .../SeriesRuleEditorModalUtils.test.js | 425 +++++++++++++++ .../__tests__/StreamProfileUtils.test.js | 197 +++++++ .../utils/forms/__tests__/StreamUtils.test.js | 95 ++++ .../forms/__tests__/UserAgentUtils.test.js | 95 ++++ .../utils/forms/__tests__/UserUtils.test.js | 432 +++++++++++++++ 8 files changed, 1902 insertions(+), 112 deletions(-) create mode 100644 frontend/src/utils/forms/__tests__/RecordingUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/StreamUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/UserAgentUtils.test.js create mode 100644 frontend/src/utils/forms/__tests__/UserUtils.test.js diff --git a/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js index c86da2a8..303b891b 100644 --- a/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js +++ b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js @@ -1,8 +1,22 @@ 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 +638,94 @@ 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..be7cf1a2 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/RecordingUtils.test.js @@ -0,0 +1,503 @@ +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..1731e08d --- /dev/null +++ b/frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js @@ -0,0 +1,425 @@ +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..3ed9357e --- /dev/null +++ b/frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js @@ -0,0 +1,197 @@ +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({}); + }); + }); +}); \ No newline at end of file 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..4eeded36 --- /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({}); + }); + }); +}); \ No newline at end of file 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..ed6163b2 --- /dev/null +++ b/frontend/src/utils/forms/__tests__/UserAgentUtils.test.js @@ -0,0 +1,95 @@ +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({}); + }); + }); +}); \ No newline at end of file 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..3618228a --- /dev/null +++ b/frontend/src/utils/forms/__tests__/UserUtils.test.js @@ -0,0 +1,432 @@ +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'); + }); + }); + }); +}); \ No newline at end of file From 275a93b9b550ce73829549d102fc9a09ad0dd080 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 25 May 2026 18:05:07 -0700 Subject: [PATCH 03/10] Added functionality to dateTimeUtils.js --- .../src/utils/__tests__/dateTimeUtils.test.js | 77 ++++++++++++++++++- frontend/src/utils/dateTimeUtils.js | 20 ++++- 2 files changed, 95 insertions(+), 2 deletions(-) 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/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) => { From 688ca2b40552fe520ff3724d645ad5cf13daf1ef Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 25 May 2026 18:06:04 -0700 Subject: [PATCH 04/10] Updated Guide component and utils --- frontend/src/pages/Guide.jsx | 19 +- frontend/src/pages/__tests__/Guide.test.jsx | 40 +- .../src/pages/__tests__/guideUtils.test.js | 1146 +++++++---------- 3 files changed, 513 insertions(+), 692 deletions(-) diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index c445407e..aa319913 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -33,12 +33,10 @@ import { useElementSize } from '@mantine/hooks'; import { VariableSizeList } from 'react-window'; import { buildChannelIdMap, - calculateDesiredScrollPosition, calculateEarliestProgramStart, calculateEnd, calculateHourTimeline, calculateLatestProgramEnd, - calculateLeftScrollPosition, calculateNowPosition, calculateScrollPosition, calculateScrollPositionByTimeClick, @@ -47,7 +45,6 @@ import { computeRowHeights, createRecording, createSeriesRule, - evaluateSeriesRule, fetchPrograms, fetchRules, filterGuideChannels, @@ -66,6 +63,7 @@ import { PX_PER_MS, calcProgressPct, sortChannels, + evaluateSeriesRulesByTvgId, } from '../utils/guideUtils'; import API from '../api'; import { getShowVideoUrl } from '../utils/cards/RecordingCardUtils.js'; @@ -739,13 +737,22 @@ export default function TVChannelGuide({ startDate, endDate }) { return; } - await createRecording(channel, program); + await createRecording({ + channel: `${channel.id}`, + start_time: program.start_time, + end_time: program.end_time, + custom_properties: { program }, + }); showNotification({ title: 'Recording scheduled' }); }, []); const saveSeriesRule = useCallback(async (program, mode) => { - await createSeriesRule(program, mode); - await evaluateSeriesRule(program); + await createSeriesRule({ + tvg_id: program.tvg_id, + mode, + title: program.title, + }); + await evaluateSeriesRulesByTvgId(program.tvg_id); // recordings_refreshed WS event triggers the debounced fetchRecordings() showNotification({ title: mode === 'new' ? 'Record new episodes' : 'Record all episodes', diff --git a/frontend/src/pages/__tests__/Guide.test.jsx b/frontend/src/pages/__tests__/Guide.test.jsx index d66f9be0..e1ce551a 100644 --- a/frontend/src/pages/__tests__/Guide.test.jsx +++ b/frontend/src/pages/__tests__/Guide.test.jsx @@ -9,20 +9,36 @@ import useEPGsStore from '../../store/epgs'; import useSettingsStore from '../../store/settings'; import useVideoStore from '../../store/useVideoStore'; import useLocalStorage from '../../hooks/useLocalStorage'; -import { showNotification } from '../../utils/notificationUtils.js'; import * as guideUtils from '../../utils/guideUtils'; import * as recordingCardUtils from '../../utils/cards/RecordingCardUtils.js'; import * as dateTimeUtils from '../../utils/dateTimeUtils.js'; import userEvent from '@testing-library/user-event'; // Mock dependencies -vi.mock('../../store/channels'); -vi.mock('../../store/logos'); -vi.mock('../../store/epgs'); -vi.mock('../../store/settings'); -vi.mock('../../store/useVideoStore'); -vi.mock('../../hooks/useLocalStorage'); -vi.mock('../../api'); +vi.mock('../../store/channels', () => ({ + default: vi.fn(), +})); +vi.mock('../../store/logos', () => ({ + default: vi.fn(), +})); +vi.mock('../../store/epgs', () => ({ + default: vi.fn(), +})); +vi.mock('../../store/settings', () => ({ + default: vi.fn(), +})); +vi.mock('../../store/useVideoStore', () => ({ + default: vi.fn(), +})); +vi.mock('../../hooks/useLocalStorage', () => ({ + default: vi.fn(), +})); +vi.mock('../../api', () => ({ + default: { + getAllChannelIds: vi.fn(), + getChannelsSummary: vi.fn(), + }, +})); vi.mock('@mantine/hooks', () => ({ useElementSize: () => ({ @@ -235,7 +251,7 @@ vi.mock('../../components/forms/SeriesRecordingModal', () => ({ })); vi.mock('../../components/ProgramDetailModal', () => ({ __esModule: true, - default: ({ program, channel, opened, onClose, onRecord }) => + default: ({ program, opened, onClose, onRecord }) => opened ? (
{program?.title}
@@ -252,11 +268,11 @@ vi.mock('../../utils/guideUtils', async () => { fetchPrograms: vi.fn(), createRecording: vi.fn(), createSeriesRule: vi.fn(), - evaluateSeriesRule: vi.fn(), fetchRules: vi.fn(), filterGuideChannels: vi.fn(), getGroupOptions: vi.fn(), getProfileOptions: vi.fn(), + sortChannels: vi.fn((ch) => ch), }; }); vi.mock('../../utils/cards/RecordingCardUtils.js', async () => { @@ -403,7 +419,6 @@ describe('Guide', () => { ); guideUtils.createRecording.mockResolvedValue(undefined); guideUtils.createSeriesRule.mockResolvedValue(undefined); - guideUtils.evaluateSeriesRule.mockResolvedValue(undefined); guideUtils.getGroupOptions.mockReturnValue([ { value: 'all', label: 'All Groups' }, { value: 'group-1', label: 'News' }, @@ -666,8 +681,7 @@ describe('Guide', () => { await waitFor(() => { expect(guideUtils.createRecording).toHaveBeenCalledWith( - expect.objectContaining({ id: 'channel-1' }), - expect.objectContaining({ id: 'prog-1' }) + expect.objectContaining({ channel: 'channel-1' }), ); }); }); diff --git a/frontend/src/pages/__tests__/guideUtils.test.js b/frontend/src/pages/__tests__/guideUtils.test.js index 18307016..2cd321ca 100644 --- a/frontend/src/pages/__tests__/guideUtils.test.js +++ b/frontend/src/pages/__tests__/guideUtils.test.js @@ -48,6 +48,8 @@ describe('guideUtils', () => { vi.clearAllMocks(); }); + // ── buildChannelIdMap ────────────────────────────────────────────────────── + describe('buildChannelIdMap', () => { it('should create map with channel UUIDs when no EPG data', () => { const channels = [ @@ -123,850 +125,638 @@ describe('guideUtils', () => { }); }); + // ── mapProgramsByChannel ─────────────────────────────────────────────────── + describe('mapProgramsByChannel', () => { it('should return empty map when no programs', () => { - const channelIdByTvgId = new Map(); - - const result = guideUtils.mapProgramsByChannel([], channelIdByTvgId); - + const result = guideUtils.mapProgramsByChannel([], new Map([['tvg-1', [1]]])); expect(result.size).toBe(0); }); it('should return empty map when no channel mapping', () => { - const programs = [{ tvg_id: 'tvg-1' }]; - + const programs = [{ tvg_id: 'tvg-1', startMs: 1000, endMs: 2000 }]; const result = guideUtils.mapProgramsByChannel(programs, new Map()); - expect(result.size).toBe(0); }); it('should map programs to channels', () => { - const nowMs = 1000000; - dateTimeUtils.getNowMs.mockReturnValue(nowMs); - - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; + const nowMs = dayjs().valueOf(); + const startMs = nowMs - 10000; + const endMs = nowMs + 10000; + const programs = [{ tvg_id: 'tvg-1', startMs, endMs }]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + + expect(result.has(1)).toBe(true); expect(result.get(1)).toHaveLength(1); - expect(result.get(1)[0]).toMatchObject({ - id: 1, - tvg_id: 'tvg-1', - }); }); it('should precompute startMs and endMs', () => { - dateTimeUtils.getNowMs.mockReturnValue(1000000); - dateTimeUtils.convertToMs.mockImplementation((time) => - typeof time === 'number' ? time : dayjs(time).valueOf() - ); + const startTime = '2024-01-01T10:00:00Z'; + const endTime = '2024-01-01T11:00:00Z'; + const expectedStartMs = dayjs(startTime).valueOf(); + const expectedEndMs = dayjs(endTime).valueOf(); - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; + vi.mocked(dateTimeUtils.convertToMs) + .mockReturnValueOnce(expectedStartMs) + .mockReturnValueOnce(expectedEndMs); + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(expectedStartMs - 1000); + + const programs = [{ tvg_id: 'tvg-1', start_time: startTime, end_time: endTime }]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + const mapped = result.get(1)[0]; - expect(result.get(1)[0]).toHaveProperty('startMs'); - expect(result.get(1)[0]).toHaveProperty('endMs'); + expect(mapped.startMs).toBe(expectedStartMs); + expect(mapped.endMs).toBe(expectedEndMs); }); it('should mark program as live when now is between start and end', () => { + const nowMs = 5000; const startMs = 1000; - const endMs = 2000; - const nowMs = 1500; - dateTimeUtils.getNowMs.mockReturnValue(nowMs); + const endMs = 10000; - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - startMs, - endMs, - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); + + const programs = [{ tvg_id: 'tvg-1', startMs, endMs }]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); expect(result.get(1)[0].isLive).toBe(true); expect(result.get(1)[0].isPast).toBe(false); }); it('should mark program as past when now is after end', () => { + const nowMs = 20000; const startMs = 1000; - const endMs = 2000; - const nowMs = 3000; - dateTimeUtils.getNowMs.mockReturnValue(nowMs); + const endMs = 10000; - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - startMs, - endMs, - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); + + const programs = [{ tvg_id: 'tvg-1', startMs, endMs }]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); - expect(result.get(1)[0].isLive).toBe(false); expect(result.get(1)[0].isPast).toBe(true); + expect(result.get(1)[0].isLive).toBe(false); }); it('should add program to multiple channels with same tvg_id', () => { - dateTimeUtils.getNowMs.mockReturnValue(1000000); + const nowMs = 5000; + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); - const programs = [ - { - id: 1, - tvg_id: 'tvg-1', - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; - const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]); + const programs = [{ tvg_id: 'tvg-1', startMs: 1000, endMs: 10000 }]; + const channelIdByTvgId = new Map([['tvg-1', [1, 2]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); expect(result.get(1)).toHaveLength(1); expect(result.get(2)).toHaveLength(1); - expect(result.get(3)).toHaveLength(1); }); it('should sort programs by start time', () => { - dateTimeUtils.getNowMs.mockReturnValue(1000000); + const nowMs = 0; + vi.mocked(dateTimeUtils.getNowMs).mockReturnValue(nowMs); const programs = [ - { - id: 2, - tvg_id: 'tvg-1', - startMs: 2000, - endMs: 3000, - start_time: '2024-01-15T11:00:00Z', - end_time: '2024-01-15T12:00:00Z', - }, - { - id: 1, - tvg_id: 'tvg-1', - startMs: 1000, - endMs: 2000, - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, + { tvg_id: 'tvg-1', startMs: 3000, endMs: 4000 }, + { tvg_id: 'tvg-1', startMs: 1000, endMs: 2000 }, ]; const channelIdByTvgId = new Map([['tvg-1', [1]]]); - const result = guideUtils.mapProgramsByChannel( - programs, - channelIdByTvgId - ); + const result = guideUtils.mapProgramsByChannel(programs, channelIdByTvgId); + const list = result.get(1); - expect(result.get(1)[0].id).toBe(1); - expect(result.get(1)[1].id).toBe(2); + expect(list[0].startMs).toBe(1000); + expect(list[1].startMs).toBe(3000); }); }); + // ── computeRowHeights ───────────────────────────────────────────────────── + describe('computeRowHeights', () => { it('should return empty array when no channels', () => { - const result = guideUtils.computeRowHeights([]); - - expect(result).toEqual([]); + expect(guideUtils.computeRowHeights([])).toEqual([]); + expect(guideUtils.computeRowHeights(null)).toEqual([]); }); it('should return default height for all channels', () => { - const channels = [{ id: 1 }, { id: 2 }]; - + const channels = [{}, {}, {}]; const result = guideUtils.computeRowHeights(channels); - - expect(result).toEqual([ - guideUtils.PROGRAM_HEIGHT, - guideUtils.PROGRAM_HEIGHT, - ]); + expect(result).toEqual([90, 90, 90]); }); it('should use custom default height when provided', () => { - const channels = [{ id: 1 }]; - const customDefault = 100; - - const result = guideUtils.computeRowHeights(channels, customDefault); - - expect(result).toEqual([customDefault]); + const channels = [{}, {}]; + const result = guideUtils.computeRowHeights(channels, 60); + expect(result).toEqual([60, 60]); }); }); + // ── fetchPrograms ───────────────────────────────────────────────────────── + describe('fetchPrograms', () => { it('should fetch and transform programs', async () => { - const mockPrograms = [ - { - id: 1, - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }, - ]; - API.getGrid.mockResolvedValue(mockPrograms); - dateTimeUtils.convertToMs.mockReturnValue(1000); + const startTime = '2024-01-01T10:00:00Z'; + const endTime = '2024-01-01T11:00:00Z'; + const raw = [{ id: 1, start_time: startTime, end_time: endTime }]; + vi.mocked(API.getGrid).mockResolvedValue(raw); + vi.mocked(dateTimeUtils.convertToMs) + .mockReturnValueOnce(dayjs(startTime).valueOf()) + .mockReturnValueOnce(dayjs(endTime).valueOf()); const result = await guideUtils.fetchPrograms(); - expect(API.getGrid).toHaveBeenCalledTimes(1); - expect(result).toHaveLength(1); - expect(result[0]).toHaveProperty('startMs'); - expect(result[0]).toHaveProperty('endMs'); + expect(API.getGrid).toHaveBeenCalled(); + expect(result[0].startMs).toBe(dayjs(startTime).valueOf()); + expect(result[0].endMs).toBe(dayjs(endTime).valueOf()); }); }); + // ── sortChannels ────────────────────────────────────────────────────────── + describe('sortChannels', () => { it('should sort channels by channel number', () => { const channels = { - 1: { id: 1, channel_number: 3 }, - 2: { id: 2, channel_number: 1 }, - 3: { id: 3, channel_number: 2 }, + a: { id: 1, channel_number: 3 }, + b: { id: 2, channel_number: 1 }, + c: { id: 3, channel_number: 2 }, }; - const result = guideUtils.sortChannels(channels); - - expect(result[0].channel_number).toBe(1); - expect(result[1].channel_number).toBe(2); - expect(result[2].channel_number).toBe(3); + expect(result.map((c) => c.channel_number)).toEqual([1, 2, 3]); }); it('should put channels without number at end', () => { const channels = { - 1: { id: 1, channel_number: 2 }, - 2: { id: 2, channel_number: null }, - 3: { id: 3, channel_number: 1 }, + a: { id: 1, channel_number: 2 }, + b: { id: 2, channel_number: null }, + c: { id: 3, channel_number: 1 }, }; - const result = guideUtils.sortChannels(channels); - expect(result[0].channel_number).toBe(1); expect(result[1].channel_number).toBe(2); expect(result[2].channel_number).toBeNull(); }); }); + // ── filterGuideChannels ─────────────────────────────────────────────────── + describe('filterGuideChannels', () => { + const channels = [ + { id: 1, name: 'ESPN', channel_group_id: 10 }, + { id: 2, name: 'CNN', channel_group_id: 20 }, + { id: 3, name: 'ESPN2', channel_group_id: 10 }, + ]; + it('should return all channels when no filters', () => { - const channels = [ - { id: 1, name: 'Channel 1' }, - { id: 2, name: 'Channel 2' }, - ]; - - const result = guideUtils.filterGuideChannels( - channels, - '', - 'all', - 'all', - {} - ); - - expect(result).toHaveLength(2); + const result = guideUtils.filterGuideChannels(channels, '', 'all', 'all', {}); + expect(result).toHaveLength(3); }); it('should filter by search query', () => { - const channels = [ - { id: 1, name: 'ESPN' }, - { id: 2, name: 'CNN' }, - ]; - - const result = guideUtils.filterGuideChannels( - channels, - 'espn', - 'all', - 'all', - {} - ); - - expect(result).toHaveLength(1); - expect(result[0].name).toBe('ESPN'); + const result = guideUtils.filterGuideChannels(channels, 'espn', 'all', 'all', {}); + expect(result).toHaveLength(2); + expect(result.map((c) => c.name)).toEqual(['ESPN', 'ESPN2']); }); it('should filter by channel group', () => { - const channels = [ - { id: 1, name: 'Channel 1', channel_group_id: 1 }, - { id: 2, name: 'Channel 2', channel_group_id: 2 }, - ]; - - const result = guideUtils.filterGuideChannels( - channels, - '', - '1', - 'all', - {} - ); - - expect(result).toHaveLength(1); - expect(result[0].channel_group_id).toBe(1); + const result = guideUtils.filterGuideChannels(channels, '', '10', 'all', {}); + expect(result).toHaveLength(2); + expect(result.every((c) => c.channel_group_id === 10)).toBe(true); }); it('should filter by profile with array of channels', () => { - const channels = [ - { id: 1, name: 'Channel 1' }, - { id: 2, name: 'Channel 2' }, - ]; const profiles = { - profile1: { + 'p1': { channels: [ { id: 1, enabled: true }, { id: 2, enabled: false }, - ], - }, - }; - - const result = guideUtils.filterGuideChannels( - channels, - '', - 'all', - 'profile1', - profiles - ); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe(1); - }); - - it('should filter by profile with Set of channels', () => { - const channels = [ - { id: 1, name: 'Channel 1' }, - { id: 2, name: 'Channel 2' }, - ]; - const profiles = { - profile1: { - channels: new Set([1]), - }, - }; - - const result = guideUtils.filterGuideChannels( - channels, - '', - 'all', - 'profile1', - profiles - ); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe(1); - }); - - it('should apply multiple filters together', () => { - const channels = [ - { id: 1, name: 'ESPN', channel_group_id: 1 }, - { id: 2, name: 'ESPN2', channel_group_id: 2 }, - { id: 3, name: 'CNN', channel_group_id: 1 }, - ]; - const profiles = { - profile1: { - channels: [ - { id: 1, enabled: true }, { id: 3, enabled: true }, ], }, }; + const result = guideUtils.filterGuideChannels(channels, '', 'all', 'p1', profiles); + expect(result.map((c) => c.id)).toEqual([1, 3]); + }); - const result = guideUtils.filterGuideChannels( - channels, - 'espn', - '1', - 'profile1', - profiles - ); + it('should filter by profile with Set of channels', () => { + const profiles = { + 'p1': { channels: new Set([1, 3]) }, + }; + const result = guideUtils.filterGuideChannels(channels, '', 'all', 'p1', profiles); + expect(result.map((c) => c.id)).toEqual([1, 3]); + }); + it('should apply multiple filters together', () => { + const profiles = { + 'p1': { channels: [{ id: 1, enabled: true }, { id: 3, enabled: true }] }, + }; + const result = guideUtils.filterGuideChannels(channels, 'espn2', '10', 'p1', profiles); expect(result).toHaveLength(1); - expect(result[0].id).toBe(1); + expect(result[0].name).toBe('ESPN2'); }); }); + // ── calculateEarliestProgramStart ───────────────────────────────────────── + describe('calculateEarliestProgramStart', () => { it('should return default when no programs', () => { - const defaultStart = dayjs('2024-01-15T00:00:00Z'); - + const defaultStart = dayjs('2024-01-01T10:00:00Z'); const result = guideUtils.calculateEarliestProgramStart([], defaultStart); - expect(result).toBe(defaultStart); }); it('should return earliest program start', () => { - dateTimeUtils.initializeTime.mockImplementation((time) => - dayjs.utc(time) - ); - dateTimeUtils.isBefore.mockImplementation((a, b) => - dayjs(a).isBefore(dayjs(b)) - ); + vi.mocked(dateTimeUtils.initializeTime).mockImplementation((t) => dayjs(t)); + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b))); + const defaultStart = dayjs('2024-01-01T10:00:00Z'); const programs = [ - { start_time: '2024-01-15T12:00:00Z' }, - { start_time: '2024-01-15T10:00:00Z' }, - { start_time: '2024-01-15T14:00:00Z' }, + { start_time: '2024-01-01T09:00:00Z' }, + { start_time: '2024-01-01T08:00:00Z' }, + { start_time: '2024-01-01T11:00:00Z' }, ]; - const defaultStart = dayjs.utc('2024-01-16T00:00:00Z'); - const result = guideUtils.calculateEarliestProgramStart( - programs, - defaultStart - ); - - expect(result.hour()).toBe(10); + const result = guideUtils.calculateEarliestProgramStart(programs, defaultStart); + expect(dayjs(result).toISOString()).toBe(dayjs('2024-01-01T08:00:00Z').toISOString()); }); }); + // ── calculateLatestProgramEnd ───────────────────────────────────────────── + describe('calculateLatestProgramEnd', () => { it('should return default when no programs', () => { - const defaultEnd = dayjs('2024-01-16T00:00:00Z'); - + const defaultEnd = dayjs('2024-01-01T22:00:00Z'); const result = guideUtils.calculateLatestProgramEnd([], defaultEnd); - expect(result).toBe(defaultEnd); }); it('should return latest program end', () => { - dateTimeUtils.initializeTime.mockImplementation((time) => - dayjs.utc(time) - ); - dateTimeUtils.isAfter.mockImplementation((a, b) => - dayjs(a).isAfter(dayjs(b)) - ); + vi.mocked(dateTimeUtils.initializeTime).mockImplementation((t) => dayjs(t)); + vi.mocked(dateTimeUtils.isAfter).mockImplementation((a, b) => dayjs(a).isAfter(dayjs(b))); + const defaultEnd = dayjs('2024-01-01T22:00:00Z'); const programs = [ - { end_time: '2024-01-15T12:00:00Z' }, - { end_time: '2024-01-15T18:00:00Z' }, - { end_time: '2024-01-15T14:00:00Z' }, + { end_time: '2024-01-01T20:00:00Z' }, + { end_time: '2024-01-01T23:00:00Z' }, + { end_time: '2024-01-01T21:00:00Z' }, ]; - const defaultEnd = dayjs.utc('2024-01-15T00:00:00Z'); const result = guideUtils.calculateLatestProgramEnd(programs, defaultEnd); - - expect(result.hour()).toBe(18); + expect(dayjs(result).toISOString()).toBe(dayjs('2024-01-01T23:00:00Z').toISOString()); }); }); + // ── calculateStart ──────────────────────────────────────────────────────── + describe('calculateStart', () => { it('should return earliest when before default', () => { - const earliest = dayjs('2024-01-15T08:00:00Z'); - const defaultStart = dayjs('2024-01-15T10:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(true); + const earliest = dayjs('2024-01-01T08:00:00Z'); + const defaultStart = dayjs('2024-01-01T10:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(true); const result = guideUtils.calculateStart(earliest, defaultStart); - expect(result).toBe(earliest); }); it('should return default when earliest is after', () => { - const earliest = dayjs('2024-01-15T12:00:00Z'); - const defaultStart = dayjs('2024-01-15T10:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(false); + const earliest = dayjs('2024-01-01T11:00:00Z'); + const defaultStart = dayjs('2024-01-01T10:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(false); const result = guideUtils.calculateStart(earliest, defaultStart); - expect(result).toBe(defaultStart); }); }); + // ── calculateEnd ────────────────────────────────────────────────────────── + describe('calculateEnd', () => { it('should return latest when after default', () => { - const latest = dayjs('2024-01-16T02:00:00Z'); - const defaultEnd = dayjs('2024-01-16T00:00:00Z'); - dateTimeUtils.isAfter.mockReturnValue(true); + const latest = dayjs('2024-01-01T23:00:00Z'); + const defaultEnd = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(true); const result = guideUtils.calculateEnd(latest, defaultEnd); - expect(result).toBe(latest); }); it('should return default when latest is before', () => { - const latest = dayjs('2024-01-15T22:00:00Z'); - const defaultEnd = dayjs('2024-01-16T00:00:00Z'); - dateTimeUtils.isAfter.mockReturnValue(false); + const latest = dayjs('2024-01-01T20:00:00Z'); + const defaultEnd = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(false); const result = guideUtils.calculateEnd(latest, defaultEnd); - expect(result).toBe(defaultEnd); }); }); + // ── mapChannelsById ─────────────────────────────────────────────────────── + describe('mapChannelsById', () => { it('should create map of channels by id', () => { const channels = [ - { id: 1, name: 'Channel 1' }, - { id: 2, name: 'Channel 2' }, + { id: 1, name: 'ESPN' }, + { id: 2, name: 'CNN' }, ]; - const result = guideUtils.mapChannelsById(channels); - - expect(result.get(1).name).toBe('Channel 1'); - expect(result.get(2).name).toBe('Channel 2'); + expect(result.get(1)).toEqual({ id: 1, name: 'ESPN' }); + expect(result.get(2)).toEqual({ id: 2, name: 'CNN' }); }); }); + // ── mapRecordingsByProgramId ─────────────────────────────────────────────── + describe('mapRecordingsByProgramId', () => { it('should return empty map for null recordings', () => { const result = guideUtils.mapRecordingsByProgramId(null); - expect(result.size).toBe(0); }); it('should map recordings by program id', () => { const recordings = [ { - id: 1, - custom_properties: { - program: { id: 'program-1' }, - }, - }, - { - id: 2, - custom_properties: { - program: { id: 'program-2' }, - }, + custom_properties: { program: { id: 42 }, status: 'pending' }, }, ]; - const result = guideUtils.mapRecordingsByProgramId(recordings); - - expect(result.get('program-1').id).toBe(1); - expect(result.get('program-2').id).toBe(2); + expect(result.has(42)).toBe(true); }); it('should skip recordings without program id', () => { const recordings = [ - { - id: 1, - custom_properties: {}, - }, + { custom_properties: { status: 'pending' } }, + { custom_properties: { program: {}, status: 'pending' } }, ]; - const result = guideUtils.mapRecordingsByProgramId(recordings); - expect(result.size).toBe(0); }); it('should exclude terminal status recordings', () => { - const recordings = [ - { - id: 1, - custom_properties: { program: { id: 'p1' }, status: 'completed' }, - }, - { - id: 2, - custom_properties: { program: { id: 'p2' }, status: 'stopped' }, - }, - { - id: 3, - custom_properties: { program: { id: 'p3' }, status: 'interrupted' }, - }, - { - id: 4, - custom_properties: { program: { id: 'p4' }, status: 'failed' }, - }, - { - id: 5, - custom_properties: { program: { id: 'p5' }, status: 'recording' }, - }, - { id: 6, custom_properties: { program: { id: 'p6' } } }, - ]; - + const terminalStatuses = ['stopped', 'completed', 'interrupted', 'failed']; + const recordings = terminalStatuses.map((status, i) => ({ + custom_properties: { program: { id: i + 1 }, status }, + })); const result = guideUtils.mapRecordingsByProgramId(recordings); + expect(result.size).toBe(0); + }); + it('should include non-terminal status recordings', () => { + const recordings = [ + { custom_properties: { program: { id: 1 }, status: 'pending' } }, + { custom_properties: { program: { id: 2 }, status: 'recording' } }, + ]; + const result = guideUtils.mapRecordingsByProgramId(recordings); expect(result.size).toBe(2); - expect(result.get('p5').id).toBe(5); - expect(result.get('p6').id).toBe(6); }); }); + // ── formatTime ──────────────────────────────────────────────────────────── + describe('formatTime', () => { it('should return "Today" for today', () => { - const today = dayjs(); - dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') + const now = dayjs(); + vi.mocked(dateTimeUtils.getNow).mockReturnValue(now); + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.isSame.mockReturnValueOnce(true); - - const result = guideUtils.formatTime(today, 'MM/DD'); + const result = guideUtils.formatTime(now, 'MMM D'); expect(result).toBe('Today'); }); it('should return "Tomorrow" for tomorrow', () => { - const today = dayjs(); - const tomorrow = today.add(1, 'day'); - dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') + const now = dayjs(); + const tomorrow = now.add(1, 'day'); + vi.mocked(dateTimeUtils.getNow).mockReturnValue(now); + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.isSame.mockReturnValueOnce(false).mockReturnValueOnce(true); - - const result = guideUtils.formatTime(tomorrow, 'MM/DD'); + const result = guideUtils.formatTime(tomorrow, 'MMM D'); expect(result).toBe('Tomorrow'); }); it('should return day name within a week', () => { - const today = dayjs(); - const future = today.add(3, 'day'); - dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') + const now = dayjs(); + const inThreeDays = now.add(3, 'day'); + vi.mocked(dateTimeUtils.getNow).mockReturnValue(now); + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.isSame.mockReturnValue(false); - dateTimeUtils.isBefore.mockReturnValue(true); - dateTimeUtils.format.mockReturnValue('Wednesday'); + vi.mocked(dateTimeUtils.format).mockImplementation((t, fmt) => dayjs(t).format(fmt)); - const result = guideUtils.formatTime(future, 'MM/DD'); - - expect(result).toBe('Wednesday'); + const result = guideUtils.formatTime(inThreeDays, 'MMM D'); + expect(result).toBe(inThreeDays.format('dddd')); }); it('should return formatted date beyond a week', () => { - const today = dayjs(); - const future = today.add(10, 'day'); - dateTimeUtils.getNow.mockReturnValue(today); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') + const now = dayjs(); + const beyondWeek = now.add(10, 'day'); + vi.mocked(dateTimeUtils.getNow).mockReturnValue(now); + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => + dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.isSame.mockReturnValue(false); - dateTimeUtils.isBefore.mockReturnValue(false); - dateTimeUtils.format.mockReturnValue('01/25'); + vi.mocked(dateTimeUtils.format).mockImplementation((t, fmt) => dayjs(t).format(fmt)); - const result = guideUtils.formatTime(future, 'MM/DD'); - - expect(result).toBe('01/25'); + const result = guideUtils.formatTime(beyondWeek, 'MMM D'); + expect(result).toBe(beyondWeek.format('MMM D')); }); }); + // ── calculateHourTimeline ───────────────────────────────────────────────── + describe('calculateHourTimeline', () => { it('should generate hours between start and end', () => { - const start = dayjs('2024-01-15T10:00:00Z'); - const end = dayjs('2024-01-15T13:00:00Z'); - dateTimeUtils.isBefore.mockImplementation((a, b) => + const start = dayjs('2024-01-01T10:00:00Z'); + const end = dayjs('2024-01-01T13:00:00Z'); + + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => dayjs(t).startOf('day')); + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).isSame(dayjs(b), unit) ); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') - ); - dateTimeUtils.isSame.mockReturnValue(true); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); - const formatDayLabel = vi.fn((time) => 'Today'); - const result = guideUtils.calculateHourTimeline( - start, - end, - formatDayLabel - ); + const formatDayLabel = vi.fn((t) => dayjs(t).format('MMM D')); + const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel); expect(result).toHaveLength(3); - expect(formatDayLabel).toHaveBeenCalledTimes(3); }); it('should mark new day transitions', () => { - const start = dayjs('2024-01-15T23:00:00Z'); - const end = dayjs('2024-01-16T02:00:00Z'); - dateTimeUtils.isBefore.mockImplementation((a, b) => + const start = dayjs('2024-01-01T23:00:00Z'); + const end = dayjs('2024-01-02T02:00:00Z'); + + vi.mocked(dateTimeUtils.isBefore).mockImplementation((a, b) => dayjs(a).isBefore(dayjs(b)) ); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) + vi.mocked(dateTimeUtils.startOfDay).mockImplementation((t) => + dayjs(t).utc().startOf('day') ); - dateTimeUtils.startOfDay.mockImplementation((time) => - dayjs(time).startOf('day') - ); - dateTimeUtils.isSame.mockImplementation((a, b, unit) => - dayjs(a).isSame(dayjs(b), unit) + vi.mocked(dateTimeUtils.isSame).mockImplementation((a, b, unit) => + dayjs(a).utc().isSame(dayjs(b).utc(), unit) ); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => dayjs(t).add(n, u)); - const formatDayLabel = vi.fn((time) => 'Day'); - const result = guideUtils.calculateHourTimeline( - start, - end, - formatDayLabel - ); + const formatDayLabel = vi.fn((t) => dayjs(t).utc().format('MMM D')); + const result = guideUtils.calculateHourTimeline(start, end, formatDayLabel); - expect(result[0].isNewDay).toBe(true); + expect(result).toHaveLength(3); + expect(result[0].isNewDay).toBe(true); // 23:00 UTC — first entry, always new day + expect(result[1].isNewDay).toBe(true); // 00:00 UTC — crosses into Jan 2 + expect(result[2].isNewDay).toBe(false); // 01:00 UTC — same day as 00:00 }); }); + // ── calculateNowPosition ────────────────────────────────────────────────── + describe('calculateNowPosition', () => { it('should return -1 when now is before start', () => { - const now = dayjs('2024-01-15T09:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const end = dayjs('2024-01-15T18:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(true); + const now = dayjs('2024-01-01T09:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + const end = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(true); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(false); const result = guideUtils.calculateNowPosition(now, start, end); - expect(result).toBe(-1); }); it('should return -1 when now is after end', () => { - const now = dayjs('2024-01-15T19:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const end = dayjs('2024-01-15T18:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(false); - dateTimeUtils.isAfter.mockReturnValue(true); + const now = dayjs('2024-01-01T23:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + const end = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(false); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(true); const result = guideUtils.calculateNowPosition(now, start, end); - expect(result).toBe(-1); }); it('should calculate position when now is between start and end', () => { - const now = dayjs('2024-01-15T11:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const end = dayjs('2024-01-15T18:00:00Z'); - dateTimeUtils.isBefore.mockReturnValue(false); - dateTimeUtils.isAfter.mockReturnValue(false); - dateTimeUtils.diff.mockReturnValue(60); + const now = dayjs('2024-01-01T11:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + const end = dayjs('2024-01-01T22:00:00Z'); + vi.mocked(dateTimeUtils.isBefore).mockReturnValue(false); + vi.mocked(dateTimeUtils.isAfter).mockReturnValue(false); + vi.mocked(dateTimeUtils.diff).mockReturnValue(60); // 60 minutes const result = guideUtils.calculateNowPosition(now, start, end); - - expect(result).toBeGreaterThan(0); + // 60 minutes / 15 min increment * MINUTE_BLOCK_WIDTH(112.5) + expect(result).toBe((60 / 15) * guideUtils.MINUTE_BLOCK_WIDTH); }); }); + // ── calculateScrollPosition ─────────────────────────────────────────────── + describe('calculateScrollPosition', () => { it('should calculate scroll position for current time', () => { - const now = dayjs('2024-01-15T11:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const rounded = dayjs('2024-01-15T11:00:00Z'); - dateTimeUtils.roundToNearest.mockReturnValue(rounded); - dateTimeUtils.diff.mockReturnValue(60); + const now = dayjs('2024-01-01T11:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + vi.mocked(dateTimeUtils.roundToNearest).mockReturnValue(now); + vi.mocked(dateTimeUtils.diff).mockReturnValue(60); const result = guideUtils.calculateScrollPosition(now, start); - - expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeGreaterThan(0); }); it('should return 0 when calculated position is negative', () => { - const now = dayjs('2024-01-15T10:00:00Z'); - const start = dayjs('2024-01-15T10:00:00Z'); - const rounded = dayjs('2024-01-15T10:00:00Z'); - dateTimeUtils.roundToNearest.mockReturnValue(rounded); - dateTimeUtils.diff.mockReturnValue(0); + const now = dayjs('2024-01-01T10:00:00Z'); + const start = dayjs('2024-01-01T10:00:00Z'); + vi.mocked(dateTimeUtils.roundToNearest).mockReturnValue(now); + vi.mocked(dateTimeUtils.diff).mockReturnValue(0); const result = guideUtils.calculateScrollPosition(now, start); - expect(result).toBe(0); }); }); + // ── matchChannelByTvgId ─────────────────────────────────────────────────── + describe('matchChannelByTvgId', () => { it('should return null when no matching channel ids', () => { const channelIdByTvgId = new Map(); - const channelById = new Map(); - - const result = guideUtils.matchChannelByTvgId( - channelIdByTvgId, - channelById, - 'tvg-1' - ); + const channelById = new Map([[1, { id: 1, name: 'ESPN' }]]); + const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1'); expect(result).toBeNull(); }); it('should return first matching channel', () => { - const channel = { id: 1, name: 'Channel 1' }; - const channelIdByTvgId = new Map([['tvg-1', [1, 2, 3]]]); - const channelById = new Map([[1, channel]]); + const channelIdByTvgId = new Map([['tvg-1', [1, 2]]]); + const channelById = new Map([ + [1, { id: 1, name: 'ESPN' }], + [2, { id: 2, name: 'ESPN HD' }], + ]); - const result = guideUtils.matchChannelByTvgId( - channelIdByTvgId, - channelById, - 'tvg-1' - ); - - expect(result).toBe(channel); + const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1'); + expect(result).toEqual({ id: 1, name: 'ESPN' }); }); it('should return null when channel not in channelById map', () => { - const channelIdByTvgId = new Map([['tvg-1', [999]]]); - const channelById = new Map(); - - const result = guideUtils.matchChannelByTvgId( - channelIdByTvgId, - channelById, - 'tvg-1' - ); + const channelIdByTvgId = new Map([['tvg-1', [99]]]); + const channelById = new Map([[1, { id: 1 }]]); + const result = guideUtils.matchChannelByTvgId(channelIdByTvgId, channelById, 'tvg-1'); expect(result).toBeNull(); }); }); + // ── fetchRules ──────────────────────────────────────────────────────────── + describe('fetchRules', () => { it('should fetch series rules from API', async () => { - const mockRules = [{ id: 1, tvg_id: 'tvg-1' }]; - API.listSeriesRules.mockResolvedValue(mockRules); + const rules = [{ tvg_id: 'tvg-1', title: 'Show' }]; + vi.mocked(API.listSeriesRules).mockResolvedValue(rules); const result = await guideUtils.fetchRules(); - expect(API.listSeriesRules).toHaveBeenCalledTimes(1); - expect(result).toBe(mockRules); + expect(API.listSeriesRules).toHaveBeenCalled(); + expect(result).toEqual(rules); }); }); + // ── getRuleByProgram ────────────────────────────────────────────────────── + describe('getRuleByProgram', () => { it('should return null when no rules', () => { - const program = { tvg_id: 'tvg-1', title: 'Show' }; - - const result = guideUtils.getRuleByProgram(null, program); - + const result = guideUtils.getRuleByProgram(null, { tvg_id: 'tvg-1' }); expect(result).toBeUndefined(); }); it('should find rule by tvg_id without title', () => { - const rules = [{ tvg_id: 'tvg-1', title: null }]; - const program = { tvg_id: 'tvg-1', title: 'Show' }; - - const result = guideUtils.getRuleByProgram(rules, program); - - expect(result).toBe(rules[0]); + const rules = [{ tvg_id: 'tvg-1', title: '' }]; + const result = guideUtils.getRuleByProgram(rules, { tvg_id: 'tvg-1', title: 'Anything' }); + expect(result).toEqual(rules[0]); }); it('should find rule by tvg_id and title', () => { @@ -974,274 +764,272 @@ describe('guideUtils', () => { { tvg_id: 'tvg-1', title: 'Show A' }, { tvg_id: 'tvg-1', title: 'Show B' }, ]; - const program = { tvg_id: 'tvg-1', title: 'Show B' }; - - const result = guideUtils.getRuleByProgram(rules, program); - - expect(result).toBe(rules[1]); + const result = guideUtils.getRuleByProgram(rules, { tvg_id: 'tvg-1', title: 'Show B' }); + expect(result).toEqual(rules[1]); }); it('should handle string comparison for tvg_id', () => { - const rules = [{ tvg_id: 123, title: null }]; - const program = { tvg_id: '123', title: 'Show' }; - - const result = guideUtils.getRuleByProgram(rules, program); - - expect(result).toBe(rules[0]); + const rules = [{ tvg_id: 123, title: '' }]; + const result = guideUtils.getRuleByProgram(rules, { tvg_id: '123', title: 'Show' }); + expect(result).toEqual(rules[0]); }); }); + // ── createRecording ─────────────────────────────────────────────────────── + describe('createRecording', () => { it('should create recording via API', async () => { - const channel = { id: 1 }; - const program = { - start_time: '2024-01-15T10:00:00Z', - end_time: '2024-01-15T11:00:00Z', - }; + vi.mocked(API.createRecording).mockResolvedValue({}); + const values = { channel_id: 1, start_time: '2024-01-01T10:00:00Z' }; - await guideUtils.createRecording(channel, program); + await guideUtils.createRecording(values); - expect(API.createRecording).toHaveBeenCalledWith({ - channel: '1', - start_time: program.start_time, - end_time: program.end_time, - custom_properties: { program }, - }); + expect(API.createRecording).toHaveBeenCalledWith(values); }); }); + // ── createSeriesRule ────────────────────────────────────────────────────── + describe('createSeriesRule', () => { it('should create series rule via API', async () => { - const program = { tvg_id: 'tvg-1', title: 'Show' }; - const mode = 'all'; + vi.mocked(API.createSeriesRule).mockResolvedValue({}); + const values = { tvg_id: 'tvg-1', title: 'Show' }; - await guideUtils.createSeriesRule(program, mode); + await guideUtils.createSeriesRule(values); - expect(API.createSeriesRule).toHaveBeenCalledWith({ - tvg_id: program.tvg_id, - mode, - title: program.title, - }); + expect(API.createSeriesRule).toHaveBeenCalledWith(values); }); }); + // ── evaluateSeriesRule ──────────────────────────────────────────────────── + describe('evaluateSeriesRule', () => { it('should evaluate series rule via API', async () => { - const program = { tvg_id: 'tvg-1' }; + vi.mocked(API.evaluateSeriesRules).mockResolvedValue({}); - await guideUtils.evaluateSeriesRule(program); + await guideUtils.evaluateSeriesRulesByTvgId('tvg-1'); - expect(API.evaluateSeriesRules).toHaveBeenCalledWith(program.tvg_id); + expect(API.evaluateSeriesRules).toHaveBeenCalledWith('tvg-1'); }); }); + // ── calculateLeftScrollPosition ─────────────────────────────────────────── + describe('calculateLeftScrollPosition', () => { it('should calculate left position using startMs', () => { - const program = { - startMs: dayjs.utc('2024-01-15T11:00:00Z').valueOf(), - }; - const start = dayjs.utc('2024-01-15T10:00:00Z').valueOf(); - dateTimeUtils.convertToMs.mockImplementation((time) => { - if (typeof time === 'number') return time; - return dayjs.utc(time).valueOf(); - }); + const startMs = 60 * 60 * 1000; // 1 hour in ms + const start = '2024-01-01T00:00:00Z'; + vi.mocked(dateTimeUtils.convertToMs).mockReturnValue(0); + const program = { startMs }; const result = guideUtils.calculateLeftScrollPosition(program, start); - expect(result).toBeGreaterThanOrEqual(0); + // (60 min / 15 min increment) * MINUTE_BLOCK_WIDTH + expect(result).toBe((60 / 15) * guideUtils.MINUTE_BLOCK_WIDTH); }); it('should calculate left position from start_time when no startMs', () => { - const program = { - start_time: '2024-01-15T10:30:00Z', - }; - const start = '2024-01-15T10:00:00Z'; - dateTimeUtils.convertToMs.mockImplementation((time) => - dayjs(time).valueOf() - ); + const startTimeMs = 30 * 60 * 1000; // 30 min + vi.mocked(dateTimeUtils.convertToMs) + .mockReturnValueOnce(startTimeMs) // program start_time + .mockReturnValueOnce(0); // guide start - const result = guideUtils.calculateLeftScrollPosition(program, start); + const program = { start_time: '2024-01-01T00:30:00Z' }; + const result = guideUtils.calculateLeftScrollPosition(program, '2024-01-01T00:00:00Z'); - expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBe((30 / 15) * guideUtils.MINUTE_BLOCK_WIDTH); }); }); + // ── calculateDesiredScrollPosition ──────────────────────────────────────── + describe('calculateDesiredScrollPosition', () => { it('should subtract 20 from left position', () => { const result = guideUtils.calculateDesiredScrollPosition(100); - expect(result).toBe(80); }); it('should return 0 when result would be negative', () => { const result = guideUtils.calculateDesiredScrollPosition(10); - expect(result).toBe(0); }); }); + // ── calculateScrollPositionByTimeClick ──────────────────────────────────── + describe('calculateScrollPositionByTimeClick', () => { + const makeEvent = (clientX, rectLeft, rectWidth) => ({ + currentTarget: { + getBoundingClientRect: () => ({ left: rectLeft, width: rectWidth }), + }, + clientX, + }); + it('should calculate scroll position from time click', () => { - const event = { - currentTarget: { - getBoundingClientRect: () => ({ left: 100, width: 450 }), - }, - clientX: 325, - }; - const clickedTime = dayjs('2024-01-15T10:00:00Z'); - const start = dayjs('2024-01-15T09:00:00Z'); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) - ); - dateTimeUtils.diff.mockReturnValue(60); + vi.mocked(dateTimeUtils.diff).mockReturnValue(60); + vi.mocked(dateTimeUtils.add).mockImplementation((t, n, u) => ({ + ...dayjs(t).add(n, u), + minute: (m) => dayjs(t).add(n, u).minute(m), + })); - const result = guideUtils.calculateScrollPositionByTimeClick( - event, - clickedTime, - start - ); + const clickedTime = { minute: vi.fn().mockReturnThis() }; + const start = dayjs('2024-01-01T10:00:00Z'); + // Click at 50% of a 600px element → 30 min into hour → snaps to 30 + const event = makeEvent(350, 100, 500); + vi.mocked(dateTimeUtils.diff).mockReturnValue(90); + + const result = guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start); expect(result).toBeGreaterThanOrEqual(0); }); it('should snap to 15-minute increments', () => { - const event = { - currentTarget: { - getBoundingClientRect: () => ({ left: 0, width: 450 }), - }, - clientX: 112.5, - }; - const clickedTime = dayjs('2024-01-15T10:00:00Z'); - const start = dayjs('2024-01-15T09:00:00Z'); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) - ); - dateTimeUtils.diff.mockReturnValue(75); - - guideUtils.calculateScrollPositionByTimeClick(event, clickedTime, start); - - expect(dateTimeUtils.diff).toHaveBeenCalled(); - }); - - it('should handle click at end of hour', () => { - const event = { - currentTarget: { - getBoundingClientRect: () => ({ left: 0, width: 450 }), - }, - clientX: 450, - }; - const clickedTime = dayjs('2024-01-15T10:00:00Z'); - const start = dayjs('2024-01-15T09:00:00Z'); - dateTimeUtils.add.mockImplementation((time, amount, unit) => - dayjs(time).add(amount, unit) - ); - dateTimeUtils.diff.mockReturnValue(120); + // Click at 10% of a 600px element → 6 min → snaps to 0 + const event = makeEvent(160, 100, 600); + const clickedTime = { minute: vi.fn().mockReturnThis() }; + vi.mocked(dateTimeUtils.diff).mockReturnValue(0); const result = guideUtils.calculateScrollPositionByTimeClick( event, clickedTime, - start + dayjs() ); + expect(result).toBe(0); + }); - expect(dateTimeUtils.add).toHaveBeenCalledWith( - expect.anything(), - 1, - 'hour' + it('should handle click at end of hour (snappedMinute === 60)', () => { + // 100% across the element → 60 min → snappedMinute = 60 → use add(1, hour).minute(0) + const event = makeEvent(700, 100, 600); + const nextHour = dayjs('2024-01-01T11:00:00Z'); + const addResult = { minute: vi.fn().mockReturnValue(nextHour) }; + vi.mocked(dateTimeUtils.add).mockReturnValue(addResult); + vi.mocked(dateTimeUtils.diff).mockReturnValue(60); + + const result = guideUtils.calculateScrollPositionByTimeClick( + event, + dayjs('2024-01-01T10:00:00Z'), + dayjs('2024-01-01T10:00:00Z') ); + expect(result).toBeGreaterThanOrEqual(0); }); }); + // ── getGroupOptions ─────────────────────────────────────────────────────── + describe('getGroupOptions', () => { it('should return only "All" when no channel groups', () => { const result = guideUtils.getGroupOptions(null, []); - - expect(result).toHaveLength(1); - expect(result[0].value).toBe('all'); + expect(result).toEqual([{ value: 'all', label: 'All Channel Groups' }]); }); it('should include groups used by channels', () => { const channelGroups = { - 1: { id: 1, name: 'Sports' }, - 2: { id: 2, name: 'News' }, + 1: { id: 10, name: 'Sports' }, + 2: { id: 20, name: 'News' }, }; - const channels = [ - { id: 1, channel_group_id: 1 }, - { id: 2, channel_group_id: 1 }, + const guideChannels = [ + { id: 1, channel_group_id: 10 }, + { id: 2, channel_group_id: 20 }, ]; - const result = guideUtils.getGroupOptions(channelGroups, channels); - - expect(result).toHaveLength(2); - expect(result[1].label).toBe('Sports'); + const result = guideUtils.getGroupOptions(channelGroups, guideChannels); + expect(result).toHaveLength(3); + expect(result.map((o) => o.label)).toContain('Sports'); + expect(result.map((o) => o.label)).toContain('News'); }); it('should exclude groups not used by any channel', () => { const channelGroups = { - 1: { id: 1, name: 'Sports' }, - 2: { id: 2, name: 'News' }, + 1: { id: 10, name: 'Sports' }, + 2: { id: 99, name: 'Unused' }, }; - const channels = [{ id: 1, channel_group_id: 1 }]; + const guideChannels = [{ id: 1, channel_group_id: 10 }]; - const result = guideUtils.getGroupOptions(channelGroups, channels); - - expect(result).toHaveLength(2); - expect(result[1].label).toBe('Sports'); + const result = guideUtils.getGroupOptions(channelGroups, guideChannels); + expect(result.map((o) => o.label)).not.toContain('Unused'); }); it('should sort groups alphabetically', () => { const channelGroups = { - 1: { id: 1, name: 'Z Group' }, - 2: { id: 2, name: 'A Group' }, - 3: { id: 3, name: 'M Group' }, + 1: { id: 10, name: 'Sports' }, + 2: { id: 20, name: 'Movies' }, + 3: { id: 30, name: 'News' }, }; - const channels = [ - { id: 1, channel_group_id: 1 }, - { id: 2, channel_group_id: 2 }, - { id: 3, channel_group_id: 3 }, + const guideChannels = [ + { id: 1, channel_group_id: 10 }, + { id: 2, channel_group_id: 20 }, + { id: 3, channel_group_id: 30 }, ]; - const result = guideUtils.getGroupOptions(channelGroups, channels); - - expect(result[1].label).toBe('A Group'); - expect(result[2].label).toBe('M Group'); - expect(result[3].label).toBe('Z Group'); + const result = guideUtils.getGroupOptions(channelGroups, guideChannels); + const labels = result.slice(1).map((o) => o.label); + expect(labels).toEqual([...labels].sort()); }); }); + // ── getProfileOptions ───────────────────────────────────────────────────── + describe('getProfileOptions', () => { it('should return only "All" when no profiles', () => { const result = guideUtils.getProfileOptions(null); - - expect(result).toHaveLength(1); - expect(result[0].value).toBe('all'); + expect(result).toEqual([{ value: 'all', label: 'All Profiles' }]); }); it('should include all profiles except id 0', () => { const profiles = { - 0: { id: '0', name: 'All' }, - 1: { id: '1', name: 'Profile 1' }, - 2: { id: '2', name: 'Profile 2' }, + '0': { id: '0', name: 'Default' }, + '1': { id: '1', name: 'Kids' }, + '2': { id: '2', name: 'Sports' }, }; const result = guideUtils.getProfileOptions(profiles); - - expect(result).toHaveLength(3); - expect(result[1].label).toBe('Profile 1'); - expect(result[2].label).toBe('Profile 2'); + expect(result).toHaveLength(3); // All + Kids + Sports + expect(result.map((o) => o.label)).not.toContain('Default'); + expect(result.map((o) => o.label)).toContain('Kids'); + expect(result.map((o) => o.label)).toContain('Sports'); }); }); + // ── calcProgressPct ─────────────────────────────────────────────────────── + + describe('calcProgressPct', () => { + it('should return 0 when now is at start', () => { + const result = guideUtils.calcProgressPct(1000, 1000, 60000); + expect(result).toBeGreaterThanOrEqual(0); + }); + + it('should return 1 when now is at or past end', () => { + // nowMs >= startMs + durationMs → elapsed >= duration → clamped to 1 + const startMs = 0; + const durationMs = 60 * 60 * 1000; // 1 hour + const nowMs = startMs + durationMs + 1000; // past the end + const result = guideUtils.calcProgressPct(nowMs, startMs, durationMs); + expect(result).toBe(1); + }); + + it('should return value between 0 and 1 for midpoint', () => { + const startMs = 0; + const durationMs = 60 * 60 * 1000; // 1 hour + const nowMs = 30 * 60 * 1000; // 30 minutes in + const result = guideUtils.calcProgressPct(nowMs, startMs, durationMs); + expect(result).toBeGreaterThan(0); + expect(result).toBeLessThan(1); + }); + }); + + // ── formatSeasonEpisode ─────────────────────────────────────────────────── + describe('formatSeasonEpisode', () => { it('should format both season and episode', () => { - expect(guideUtils.formatSeasonEpisode(1, 3)).toBe('S01E03'); + expect(guideUtils.formatSeasonEpisode(1, 2)).toBe('S01E02'); }); it('should pad numbers to 2 digits', () => { - expect(guideUtils.formatSeasonEpisode(1, 1)).toBe('S01E01'); + expect(guideUtils.formatSeasonEpisode(3, 7)).toBe('S03E07'); }); it('should handle large numbers without truncation', () => { - expect(guideUtils.formatSeasonEpisode(12, 24)).toBe('S12E24'); + expect(guideUtils.formatSeasonEpisode(10, 10)).toBe('S10E10'); }); it('should handle numbers greater than 99', () => { @@ -1249,19 +1037,19 @@ describe('guideUtils', () => { }); it('should return season only when episode is null', () => { - expect(guideUtils.formatSeasonEpisode(5, null)).toBe('S05'); + expect(guideUtils.formatSeasonEpisode(2, null)).toBe('S02'); }); it('should return season only when episode is undefined', () => { - expect(guideUtils.formatSeasonEpisode(5, undefined)).toBe('S05'); + expect(guideUtils.formatSeasonEpisode(2, undefined)).toBe('S02'); }); it('should return episode only when season is null', () => { - expect(guideUtils.formatSeasonEpisode(null, 7)).toBe('E07'); + expect(guideUtils.formatSeasonEpisode(null, 5)).toBe('E05'); }); it('should return episode only when season is undefined', () => { - expect(guideUtils.formatSeasonEpisode(undefined, 7)).toBe('E07'); + expect(guideUtils.formatSeasonEpisode(undefined, 5)).toBe('E05'); }); it('should return null when both are null', () => { @@ -1277,32 +1065,44 @@ describe('guideUtils', () => { }); it('should handle season zero with episode', () => { - expect(guideUtils.formatSeasonEpisode(0, 5)).toBe('S00E05'); + expect(guideUtils.formatSeasonEpisode(0, 1)).toBe('S00E01'); }); }); + // ── deleteSeriesRuleByTvgId ─────────────────────────────────────────────── + describe('deleteSeriesRuleByTvgId', () => { it('should delete series rule via API with tvg_id and title', async () => { + vi.mocked(API.deleteSeriesRule).mockResolvedValue({}); + await guideUtils.deleteSeriesRuleByTvgId('tvg-1', 'My Show'); expect(API.deleteSeriesRule).toHaveBeenCalledWith('tvg-1', 'My Show'); }); it('should forward undefined title when not provided', async () => { + vi.mocked(API.deleteSeriesRule).mockResolvedValue({}); + await guideUtils.deleteSeriesRuleByTvgId('tvg-1'); expect(API.deleteSeriesRule).toHaveBeenCalledWith('tvg-1', undefined); }); it('should work with empty tvg_id for title-only rules', async () => { + vi.mocked(API.deleteSeriesRule).mockResolvedValue({}); + await guideUtils.deleteSeriesRuleByTvgId('', 'Title-Only Show'); expect(API.deleteSeriesRule).toHaveBeenCalledWith('', 'Title-Only Show'); }); }); + // ── evaluateSeriesRulesByTvgId ──────────────────────────────────────────── + describe('evaluateSeriesRulesByTvgId', () => { it('should evaluate series rules via API', async () => { + vi.mocked(API.evaluateSeriesRules).mockResolvedValue({}); + await guideUtils.evaluateSeriesRulesByTvgId('tvg-1'); expect(API.evaluateSeriesRules).toHaveBeenCalledWith('tvg-1'); From e766c4dd54a9414536a9da4314a243b8ea5e9810 Mon Sep 17 00:00:00 2001 From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com> Date: Mon, 25 May 2026 18:06:52 -0700 Subject: [PATCH 05/10] Refactored components --- frontend/src/components/forms/Recording.jsx | 252 ++++-------------- .../forms/RecordingDetailsModal.jsx | 70 ++--- .../components/forms/RecurringRuleModal.jsx | 91 ++----- .../src/components/forms/ScheduleInput.jsx | 12 +- .../components/forms/SeriesRecordingModal.jsx | 26 +- .../forms/SeriesRuleEditorModal.jsx | 124 +++------ frontend/src/components/forms/Stream.jsx | 21 +- .../src/components/forms/StreamProfile.jsx | 81 ++---- .../src/components/forms/SuperuserForm.jsx | 16 +- frontend/src/components/forms/User.jsx | 228 ++++------------ frontend/src/components/forms/UserAgent.jsx | 28 +- 11 files changed, 270 insertions(+), 679 deletions(-) diff --git a/frontend/src/components/forms/Recording.jsx b/frontend/src/components/forms/Recording.jsx index 23f78867..163a43a7 100644 --- a/frontend/src/components/forms/Recording.jsx +++ b/frontend/src/components/forms/Recording.jsx @@ -1,86 +1,40 @@ import React, { useEffect, useMemo, useState } from 'react'; -import dayjs from 'dayjs'; -import API from '../../api'; import { Alert, Button, + Group, + Loader, Modal, + MultiSelect, + SegmentedControl, Select, Stack, - SegmentedControl, - MultiSelect, - Group, TextInput, - Loader, } from '@mantine/core'; -import { DateTimePicker, TimeInput, DatePickerInput } from '@mantine/dates'; +import { DatePickerInput, DateTimePicker, TimeInput } from '@mantine/dates'; import { CircleAlert } from 'lucide-react'; -import { isNotEmpty, useForm } from '@mantine/form'; +import { useForm } from '@mantine/form'; import useChannelsStore from '../../store/channels'; -import { notifications } from '@mantine/notifications'; - -const DAY_OPTIONS = [ - { value: '6', label: 'Sun' }, - { value: '0', label: 'Mon' }, - { value: '1', label: 'Tue' }, - { value: '2', label: 'Wed' }, - { value: '3', label: 'Thu' }, - { value: '4', label: 'Fri' }, - { value: '5', label: 'Sat' }, -]; - -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; -}; - -const toIsoIfDate = (value) => { - const dt = asDate(value); - return dt ? dt.toISOString() : value; -}; - -// Accepts "h:mm A"/"hh:mm A"/"HH:mm"/Date, returns "HH:mm" -const toTimeString = (value) => { - if (!value) return '00:00'; - if (typeof value === 'string') { - const parsed = dayjs( - value, - ['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'], - true - ); - if (parsed.isValid()) return parsed.format('HH:mm'); - return value; - } - const dt = asDate(value); - if (!dt) return '00:00'; - return dayjs(dt).format('HH:mm'); -}; - -const toDateString = (value) => { - const dt = asDate(value); - if (!dt) return null; - const year = dt.getFullYear(); - const month = String(dt.getMonth() + 1).padStart(2, '0'); - const day = String(dt.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -}; - -const createRoundedDate = (minutesAhead = 0) => { - const dt = new Date(); - dt.setSeconds(0); - dt.setMilliseconds(0); - dt.setMinutes(Math.ceil(dt.getMinutes() / 30) * 30); - if (minutesAhead) dt.setMinutes(dt.getMinutes() + minutesAhead); - return dt; -}; - -// robust onChange for TimeInput (string or event) -const timeChange = (setter) => (valOrEvent) => { - if (typeof valOrEvent === 'string') setter(valOrEvent); - else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value); -}; +import { + RECURRING_DAY_OPTIONS, + toTimeString, +} from '../../utils/dateTimeUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + buildRecurringPayload, + buildSinglePayload, + createRecording, + createRecurringRule, + getChannelsSummary, + getRecurringFormDefaults, + getSingleFormDefaults, + numberedChannelLabel, + recurringFormValidators, + singleFormValidators, + sortedChannelOptions, + timeChange, + updateRecording, +} from '../../utils/forms/RecordingUtils.js'; const RecordingModal = ({ recording = null, @@ -98,117 +52,29 @@ const RecordingModal = ({ const [mode, setMode] = useState('single'); const [submitting, setSubmitting] = useState(false); - const defaultStart = createRoundedDate(); - const defaultEnd = createRoundedDate(60); - const defaultDate = new Date(); - - // One-time form const singleForm = useForm({ mode: 'controlled', - initialValues: { - 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, - }, - validate: { - 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; - }, - }, + initialValues: getSingleFormDefaults(recording, channel), + validate: singleFormValidators, }); - // Recurring form stores times as "HH:mm" strings for stable editing const recurringForm = useForm({ mode: 'controlled', validateInputOnChange: false, validateInputOnBlur: true, - initialValues: { - channel_id: channel ? `${channel.id}` : '', - days_of_week: [], - start_time: dayjs(defaultStart).format('HH:mm'), - end_time: dayjs(defaultEnd).format('HH:mm'), - rule_name: '', - start_date: defaultDate, - end_date: defaultDate, - }, - validate: { - channel_id: isNotEmpty('Select a channel'), - days_of_week: (value) => - 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 = dayjs( - values.start_time, - ['HH:mm', 'hh:mm A', 'h:mm A'], - true - ); - const end = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true); - if ( - start.isValid() && - end.isValid() && - end.diff(start, '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; - }, - }, + initialValues: getRecurringFormDefaults(channel), + validate: recurringFormValidators, }); useEffect(() => { if (!isOpen) return; - const freshStart = createRoundedDate(); - const freshEnd = createRoundedDate(60); - const freshDate = new Date(); - - if (recording && recording.id) { + if (recording?.id) { setMode('single'); - singleForm.setValues({ - channel_id: `${recording.channel}`, - start_time: asDate(recording.start_time) || defaultStart, - end_time: asDate(recording.end_time) || defaultEnd, - }); + singleForm.setValues(getSingleFormDefaults(recording, channel)); } else { - // Reset forms for fresh open - singleForm.setValues({ - channel_id: channel ? `${channel.id}` : '', - start_time: freshStart, - end_time: freshEnd, - }); - - const startStr = dayjs(freshStart).format('HH:mm'); - recurringForm.setValues({ - channel_id: channel ? `${channel.id}` : '', - days_of_week: [], - start_time: startStr, - end_time: dayjs(freshEnd).format('HH:mm'), - rule_name: channel?.name || '', - start_date: freshDate, - end_date: freshDate, - }); + singleForm.setValues(getSingleFormDefaults(null, channel)); + recurringForm.setValues(getRecurringFormDefaults(channel)); setMode('single'); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -221,7 +87,7 @@ const RecordingModal = ({ if (!isOpen) return; try { setIsChannelsLoading(true); - const chans = await API.getChannelsSummary(); + const chans = await getChannelsSummary(); if (cancelled) return; setAllChannels(Array.isArray(chans) ? chans : []); } catch (e) { @@ -238,19 +104,7 @@ const RecordingModal = ({ }, [isOpen]); const channelOptions = useMemo(() => { - const list = Array.isArray(allChannels) ? [...allChannels] : []; - 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; - }); - return list.map((item) => ({ - value: `${item.id}`, - label: item.channel_number - ? `${item.channel_number} - ${item.name || `Channel ${item.id}`}` - : item.name || `Channel ${item.id}`, - })); + return sortedChannelOptions(allChannels, numberedChannelLabel); }, [allChannels]); const resetForms = () => { @@ -267,25 +121,18 @@ const RecordingModal = ({ const handleSingleSubmit = async (values) => { try { setSubmitting(true); + const payload = buildSinglePayload(values); if (recording && recording.id) { - await API.updateRecording(recording.id, { - channel: values.channel_id, - start_time: toIsoIfDate(values.start_time), - end_time: toIsoIfDate(values.end_time), - }); - notifications.show({ + await updateRecording(recording.id, payload); + showNotification({ title: 'Recording updated', message: 'Recording schedule updated successfully', color: 'green', autoClose: 2500, }); } else { - await API.createRecording({ - channel: values.channel_id, - start_time: toIsoIfDate(values.start_time), - end_time: toIsoIfDate(values.end_time), - }); - notifications.show({ + await createRecording(payload); + showNotification({ title: 'Recording scheduled', message: 'One-time recording added to DVR queue', color: 'green', @@ -304,18 +151,10 @@ const RecordingModal = ({ const handleRecurringSubmit = async (values) => { try { setSubmitting(true); - await API.createRecurringRule({ - 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: toDateString(values.start_date), - end_date: toDateString(values.end_date), - name: values.rule_name?.trim() || '', - }); + await createRecurringRule(buildRecurringPayload(values)); await Promise.all([fetchRecurringRules(), fetchRecordings()]); - notifications.show({ + showNotification({ title: 'Recurring rule saved', message: 'Future slots will be scheduled automatically', color: 'green', @@ -427,7 +266,10 @@ const RecordingModal = ({ key={recurringForm.key('days_of_week')} label="Every" placeholder="Select days" - data={DAY_OPTIONS} + data={RECURRING_DAY_OPTIONS.map((opt) => ({ + value: String(opt.value), + label: opt.label, + }))} searchable clearable nothingFoundMessage="No match" diff --git a/frontend/src/components/forms/RecordingDetailsModal.jsx b/frontend/src/components/forms/RecordingDetailsModal.jsx index bc8b245f..43351765 100644 --- a/frontend/src/components/forms/RecordingDetailsModal.jsx +++ b/frontend/src/components/forms/RecordingDetailsModal.jsx @@ -1,11 +1,13 @@ import useChannelsStore from '../../store/channels.jsx'; -import API from '../../api'; import { + format, + isAfter, + isBefore, useDateTimeFormat, useTimeHelpers, } from '../../utils/dateTimeUtils.js'; -import React from 'react'; -import { Pencil, RefreshCcw, Check, X } from 'lucide-react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Check, Pencil, RefreshCcw, X } from 'lucide-react'; import { ActionIcon, Badge, @@ -21,7 +23,6 @@ import { TextInput, } from '@mantine/core'; import useVideoStore from '../../store/useVideoStore.jsx'; -import { notifications } from '@mantine/notifications'; import defaultLogo from '../../images/logo.png'; import { deleteRecordingById, @@ -33,10 +34,14 @@ import { runComSkip, } from '../../utils/cards/RecordingCardUtils.js'; import { + getChannel, getRating, getStatRows, getUpcomingEpisodes, + refreshArtwork, + updateRecordingMetadata, } from '../../utils/forms/RecordingDetailsModalUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; const RecordingDetailsModal = ({ opened, @@ -51,21 +56,21 @@ const RecordingDetailsModal = ({ }) => { const allRecordings = useChannelsStore((s) => s.recordings); // Local channel cache to avoid the global channels map - const [channelsById, setChannelsById] = React.useState({}); + const [channelsById, setChannelsById] = useState({}); const { toUserTime, userNow } = useTimeHelpers(); - const [childOpen, setChildOpen] = React.useState(false); - const [childRec, setChildRec] = React.useState(null); + const [childOpen, setChildOpen] = useState(false); + const [childRec, setChildRec] = useState(null); const { timeFormat: timeformat, dateFormat: dateformat } = useDateTimeFormat(); - const [editing, setEditing] = React.useState(false); + const [editing, setEditing] = useState(false); // Prefer the store version of the recording for live updates // (e.g., after artwork refresh or metadata edit via WebSocket). // Preserve _group_count from the categorized prop — the store version // doesn't carry this client-side field, so without merging it back // isSeriesGroup would always be false and the episode list hidden. - const safeRecording = React.useMemo(() => { + const safeRecording = useMemo(() => { if (recording?.id && Array.isArray(allRecordings)) { const found = allRecordings.find((r) => r.id === recording.id); if (found) { @@ -81,7 +86,7 @@ const RecordingDetailsModal = ({ const program = customProps.program || {}; // Derive poster URL from live store data instead of the stale prop snapshot. - const livePosterUrl = React.useMemo( + const livePosterUrl = useMemo( () => getPosterUrl( customProps.poster_logo_id, @@ -93,17 +98,17 @@ const RecordingDetailsModal = ({ // Optimistic overrides — show saved values immediately without waiting // for the WebSocket round-trip to refresh the store. - const [savedTitle, setSavedTitle] = React.useState(null); - const [savedDescription, setSavedDescription] = React.useState(null); + const [savedTitle, setSavedTitle] = useState(null); + const [savedDescription, setSavedDescription] = useState(null); const recordingName = savedTitle ?? (program.title || 'Custom Recording'); const description = savedDescription ?? (program.description || customProps.description || ''); - const [editTitle, setEditTitle] = React.useState(''); - const [editDescription, setEditDescription] = React.useState(''); + const [editTitle, setEditTitle] = useState(''); + const [editDescription, setEditDescription] = useState(''); // Reset optimistic state when the recording changes - React.useEffect(() => { + useEffect(() => { setSavedTitle(null); setSavedDescription(null); setEditing(false); @@ -129,7 +134,7 @@ const RecordingDetailsModal = ({ const isSeriesGroup = Boolean( safeRecording._group_count && safeRecording._group_count > 1 ); - const upcomingEpisodes = React.useMemo(() => { + const upcomingEpisodes = useMemo(() => { return getUpcomingEpisodes( isSeriesGroup, allRecordings, @@ -147,7 +152,7 @@ const RecordingDetailsModal = ({ ]); // Ensure channel is available for a given id - const loadChannel = React.useCallback( + const loadChannel = useCallback( async (id) => { if (!id) { return null; @@ -159,7 +164,7 @@ const RecordingDetailsModal = ({ } try { - const ch = await API.getChannel(id); + const ch = await getChannel(id); if (ch && ch.id === id) { setChannelsById((prev) => ({ ...prev, [id]: ch })); return ch; @@ -177,7 +182,7 @@ const RecordingDetailsModal = ({ ); // When opening a child episode, fetch that episode's channel - React.useEffect(() => { + useEffect(() => { if (!childOpen || !childRec) return; loadChannel(childRec.channel); }, [childOpen, childRec, loadChannel]); @@ -188,7 +193,7 @@ const RecordingDetailsModal = ({ const s = toUserTime(rec.start_time); const e = toUserTime(rec.end_time); - if (now.isAfter(s) && now.isBefore(e)) { + if (isAfter(now, s) && isBefore(now, e)) { const ch = channelsById[rec.channel] || (rec.channel === recording?.channel ? channel : null); @@ -228,14 +233,11 @@ const RecordingDetailsModal = ({ const saveMetadata = async () => { try { - await API.updateRecordingMetadata(recording.id, { - title: editTitle || 'Custom Recording', - description: editDescription, - }); + await updateRecordingMetadata(recording, editTitle, editDescription); setSavedTitle(editTitle || 'Custom Recording'); setSavedDescription(editDescription); setEditing(false); - notifications.show({ + showNotification({ title: 'Saved', message: 'Recording metadata updated', color: 'green', @@ -249,8 +251,8 @@ const RecordingDetailsModal = ({ const handleRefreshArtwork = async (e) => { e.stopPropagation?.(); try { - await API.refreshArtwork(recording.id); - notifications.show({ + await refreshArtwork(recording.id); + showNotification({ title: 'Refreshing artwork', message: 'Poster resolution started', color: 'blue.5', @@ -265,7 +267,7 @@ const RecordingDetailsModal = ({ e.stopPropagation?.(); try { await runComSkip(recording); - notifications.show({ + showNotification({ title: 'Removing commercials', message: 'Queued comskip for this recording', color: 'blue.5', @@ -344,8 +346,8 @@ const RecordingDetailsModal = ({ )} - {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} - {end.format(timeformat)} + {format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '} + {format(end, timeformat)} @@ -468,7 +470,7 @@ const RecordingDetailsModal = ({ {onWatchLive && } {onWatchRecording && } - {onEdit && start.isAfter(userNow()) && } + {onEdit && isAfter(start, userNow()) && } {(customProps.status === 'completed' || customProps.status === 'stopped' || customProps.status === 'interrupted') && @@ -486,8 +488,8 @@ const RecordingDetailsModal = ({ - {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} - {end.format(timeformat)} + {format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '} + {format(end, timeformat)} {rating && ( @@ -599,7 +601,7 @@ const RecordingDetailsModal = ({ title: { color: 'white' }, }} > - {isSeriesGroup ? Series() : Movie()} + {isSeriesGroup ? : } ); }; diff --git a/frontend/src/components/forms/RecurringRuleModal.jsx b/frontend/src/components/forms/RecurringRuleModal.jsx index 9fe49220..d9007d34 100644 --- a/frontend/src/components/forms/RecurringRuleModal.jsx +++ b/frontend/src/components/forms/RecurringRuleModal.jsx @@ -1,16 +1,15 @@ import useChannelsStore from '../../store/channels.jsx'; -import API from '../../api.js'; import { - parseDate, + format, + getNow, RECURRING_DAY_OPTIONS, + toDate, toTimeString, useDateTimeFormat, useTimeHelpers, } from '../../utils/dateTimeUtils.js'; import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from '@mantine/form'; -import dayjs from 'dayjs'; -import { notifications } from '@mantine/notifications'; import { Badge, Button, @@ -28,11 +27,18 @@ import { DatePickerInput, TimeInput } from '@mantine/dates'; import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js'; import { deleteRecurringRuleById, - getChannelOptions, + getFormDefaults, getUpcomingOccurrences, updateRecurringRule, updateRecurringRuleEnabled, } from '../../utils/forms/RecurringRuleModalUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + getChannelsSummary, + getRecurringFormDefaults, + recurringFormValidators, + sortedChannelOptions, +} from '../../utils/forms/RecordingUtils.js'; const RecurringRuleModal = ({ opened, @@ -56,66 +62,18 @@ const RecurringRuleModal = ({ const rule = recurringRules.find((r) => r.id === ruleId); const channelOptions = useMemo(() => { - return getChannelOptions(allChannels); + return sortedChannelOptions(allChannels); }, [allChannels]); const form = useForm({ mode: 'controlled', - initialValues: { - channel_id: '', - days_of_week: [], - rule_name: '', - start_time: dayjs().startOf('hour').format('HH:mm'), - end_time: dayjs().startOf('hour').add(1, 'hour').format('HH:mm'), - start_date: dayjs().toDate(), - end_date: dayjs().toDate(), - enabled: true, - }, - validate: { - channel_id: (value) => (value ? null : 'Select a channel'), - days_of_week: (value) => - value && value.length ? null : 'Pick at least one day', - end_time: (value, values) => { - if (!value) return 'Select an end time'; - const startValue = dayjs( - values.start_time, - ['HH:mm', 'hh:mm A', 'h:mm A'], - true - ); - const endValue = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true); - if ( - startValue.isValid() && - endValue.isValid() && - endValue.diff(startValue, 'minute') === 0 - ) { - return 'End time must differ from start time'; - } - return null; - }, - end_date: (value, values) => { - const endDate = dayjs(value); - const startDate = dayjs(values.start_date); - if (!value) return 'Select an end date'; - if (startDate.isValid() && endDate.isBefore(startDate, 'day')) { - return 'End date cannot be before start date'; - } - return null; - }, - }, + initialValues: { ...getRecurringFormDefaults(), enabled: true }, + validate: recurringFormValidators, }); useEffect(() => { if (opened && rule) { - form.setValues({ - 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) || dayjs().toDate(), - end_date: parseDate(rule.end_date), - enabled: Boolean(rule.enabled), - }); + form.setValues(getFormDefaults(rule)); } else { form.reset(); } @@ -127,7 +85,7 @@ const RecurringRuleModal = ({ let cancelled = false; (async () => { try { - const chans = await API.getChannelsSummary(); + const chans = await getChannelsSummary(); if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []); } catch (e) { console.warn('Failed to load channels for recurring rule modal', e); @@ -149,7 +107,7 @@ const RecurringRuleModal = ({ try { await updateRecurringRule(ruleId, values); await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update - notifications.show({ + showNotification({ title: 'Recurring rule updated', message: 'Schedule adjustments saved', color: 'green', @@ -169,7 +127,7 @@ const RecurringRuleModal = ({ try { await deleteRecurringRuleById(ruleId); await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update - notifications.show({ + showNotification({ title: 'Recurring rule removed', message: 'All future occurrences were cancelled', color: 'red', @@ -189,7 +147,7 @@ const RecurringRuleModal = ({ try { await updateRecurringRuleEnabled(ruleId, checked); await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update - notifications.show({ + showNotification({ title: checked ? 'Recurring rule enabled' : 'Recurring rule paused', message: checked ? 'Future occurrences will resume' @@ -210,7 +168,7 @@ const RecurringRuleModal = ({ try { await deleteRecordingById(occurrence.id); // recording_cancelled WS event handles recording list update - notifications.show({ + showNotification({ title: 'Occurrence cancelled', message: 'The selected airing was removed', color: 'yellow', @@ -246,7 +204,7 @@ const RecurringRuleModal = ({ setDeleting(true); try { await deleteRecordingById(sourceRecording.id); - notifications.show({ + showNotification({ title: 'Recording deleted', color: 'green', autoClose: 2500, @@ -275,7 +233,7 @@ const RecurringRuleModal = ({ }; const handleStartDateChange = (value) => { - form.setFieldValue('start_date', value || dayjs().toDate()); + form.setFieldValue('start_date', value || toDate(getNow())); }; const handleEndDateChange = (value) => { @@ -302,10 +260,11 @@ const RecurringRuleModal = ({ - {occStart.format(`${dateformat}, YYYY`)} + {format(occStart, `${dateformat}, YYYY`)} - {occStart.format(timeformat)} – {occEnd.format(timeformat)} + {format(occStart, timeformat)} –{' '} + {format(occEnd, timeformat)} diff --git a/frontend/src/components/forms/ScheduleInput.jsx b/frontend/src/components/forms/ScheduleInput.jsx index eb4aa70d..a0cbb854 100644 --- a/frontend/src/components/forms/ScheduleInput.jsx +++ b/frontend/src/components/forms/ScheduleInput.jsx @@ -38,8 +38,8 @@ import { Popover, ActionIcon, Group, - Divider, - SimpleGrid, + PopoverTarget, + PopoverDropdown, } from '@mantine/core'; import { Info } from 'lucide-react'; import { validateCronExpression } from '../../utils/cronUtils'; @@ -118,12 +118,12 @@ export default function ScheduleInput({ Cron Expression - + - - + + COMMON EXAMPLES @@ -169,7 +169,7 @@ export default function ScheduleInput({ 30 14 1 * * - + } diff --git a/frontend/src/components/forms/SeriesRecordingModal.jsx b/frontend/src/components/forms/SeriesRecordingModal.jsx index 67aaced5..6ec6cea4 100644 --- a/frontend/src/components/forms/SeriesRecordingModal.jsx +++ b/frontend/src/components/forms/SeriesRecordingModal.jsx @@ -16,6 +16,19 @@ const TITLE_MODE_LABEL = { regex: 'Title regex', }; +const renderRuleSummary = (r) => { + const titleMode = (r.title_mode || 'exact').toLowerCase(); + const parts = []; + parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode'); + if (r.title) { + parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`); + } + if (r.description) { + parts.push(`Description: "${r.description}"`); + } + return parts.join(' | '); +}; + export default function SeriesRecordingModal({ opened, onClose, @@ -59,19 +72,6 @@ export default function SeriesRecordingModal({ onRulesUpdate(updated); }; - const renderRuleSummary = (r) => { - const titleMode = (r.title_mode || 'exact').toLowerCase(); - const parts = []; - parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode'); - if (r.title) { - parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`); - } - if (r.description) { - parts.push(`Description: "${r.description}"`); - } - return parts.join(' | '); - }; - return ( <> ${e.toLocaleString()}`; - } catch { - return `${start} - ${end}`; - } -} +import { getChannelsSummary } from '../../utils/forms/RecordingUtils.js'; +import { + createSeriesRule, + evaluateSeriesRulesByTvgId, +} from '../../utils/guideUtils.js'; +import { + DESCRIPTION_MODES, + EPISODE_MODES, + formatRange, + getChannelOptions, + getTvgOptions, + previewSeriesRule, + TITLE_MODES, +} from '../../utils/forms/SeriesRuleEditorModalUtils.js'; export default function SeriesRuleEditorModal({ opened, @@ -106,7 +79,7 @@ export default function SeriesRuleEditorModal({ useEffect(() => { if (!opened) return; let cancelled = false; - API.getChannelsSummary() + getChannelsSummary() .then((chans) => { if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []); }) @@ -152,7 +125,7 @@ export default function SeriesRuleEditorModal({ setPreviewLoading(true); setPreviewError(null); - API.previewSeriesRule(debouncedPreviewKey, { signal: controller.signal }) + previewSeriesRule(debouncedPreviewKey, controller) .then((resp) => { if (controller.signal.aborted) return; setPreview(resp || { matches: [], total: 0 }); @@ -173,44 +146,11 @@ export default function SeriesRuleEditorModal({ // EPG channel options for the tvg_id selector. Deduplicate by tvg_id value // since the same channel can appear across multiple EPG sources. const tvgOptions = useMemo(() => { - 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)); + return getTvgOptions(tvgs); }, [tvgs]); - // Channel select options: prefer channels matching the selected tvg_id. const channelOptions = useMemo(() => { - const sorted = [...allChannels].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 || ''); - }); - const matching = []; - const others = []; - for (const c of sorted) { - const item = { - value: String(c.id), - label: c.channel_number - ? `${c.channel_number} - ${c.name || `Channel ${c.id}`}` - : c.name || `Channel ${c.id}`, - }; - const cTvg = c.epg_data_id ? tvgsById?.[c.epg_data_id]?.tvg_id : null; - if (tvgId && cTvg && String(cTvg) === String(tvgId)) { - matching.push(item); - } else { - others.push(item); - } - } - return [...matching, ...others]; + return getChannelOptions(allChannels, tvgsById, tvgId); }, [allChannels, tvgsById, tvgId]); const canSave = !!(payload.title || payload.description); @@ -218,10 +158,10 @@ export default function SeriesRuleEditorModal({ const handleSave = async () => { setSaving(true); try { - await API.createSeriesRule(payload); + await createSeriesRule(payload); // Trigger evaluation so matching upcoming programs get scheduled. try { - await API.evaluateSeriesRules(payload.tvg_id); + await evaluateSeriesRulesByTvgId(payload.tvg_id); await useChannelsStore.getState().fetchRecordings(); } catch (e) { console.warn('Failed to evaluate after save', e); @@ -229,6 +169,8 @@ export default function SeriesRuleEditorModal({ showNotification({ title: 'Series rule saved' }); if (onSaved) await onSaved(); onClose(); + } catch (e) { + console.error('Failed to save series rule', e); } finally { setSaving(false); } @@ -370,7 +312,7 @@ export default function SeriesRuleEditorModal({ )} - + {(preview.matches || []).map((p) => ( @@ -407,7 +349,7 @@ export default function SeriesRuleEditorModal({ )} - +
+ ), + SegmentedControl: ({ value, onChange, data, disabled }) => ( +
+ {data.map((item) => ( + + ))} +
+ ), + Select: ({ label, disabled, rightSection, data, ...props }) => ( +
+ + {rightSection} + +
+ ), + Stack: ({ children }) =>
{children}
, + TextInput: ({ label, placeholder, ...props }) => ( +
+ + +
+ ), +})); + +// ── @mantine/dates ───────────────────────────────────────────────────────────── +vi.mock('@mantine/dates', () => ({ + DatePickerInput: ({ label, value, onChange }) => ( +
+ + onChange(e.target.value ? new Date(e.target.value) : null)} + /> +
+ ), + DateTimePicker: ({ label, ...props }) => ( +
+ + +
+ ), + TimeInput: ({ label, value, onChange, onBlur }) => ( +
+ + +
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + CircleAlert: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useChannelsStore from '../../../store/channels'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import * as RecordingUtils from '../../../utils/forms/RecordingUtils.js'; + +const setupStoreMock = () => { + const mockFetchRecordings = vi.fn().mockResolvedValue(undefined); + const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined); + + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ fetchRecordings: mockFetchRecordings, fetchRecurringRules: mockFetchRecurringRules }) + ); + + return { mockFetchRecordings, mockFetchRecurringRules }; +}; + +const makeRecording = (overrides = {}) => ({ + id: 'rec-1', + start_time: '2024-06-01T10:00:00Z', + end_time: '2024-06-01T11:00:00Z', + custom_properties: { program: { title: 'Test Show' } }, + ...overrides, +}); + +const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 }); + +describe('RecordingModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([ + { id: 'ch-1', name: 'HBO', channel_number: 501 }, + ]); + vi.mocked(RecordingUtils.createRecording).mockResolvedValue(undefined); + vi.mocked(RecordingUtils.updateRecording).mockResolvedValue(undefined); + vi.mocked(RecordingUtils.createRecurringRule).mockResolvedValue(undefined); + setupStoreMock(); + }); + + // ── Visibility ───────────────────────────────────────────────────────────── + + describe('visibility', () => { + it('renders the modal when isOpen is true', () => { + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render when isOpen is false', () => { + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + // ── Alert ────────────────────────────────────────────────────────────────── + + describe('scheduling conflict alert', () => { + it('renders the scheduling conflicts alert', () => { + render(); + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByTestId('alert-title')).toHaveTextContent('Scheduling Conflicts'); + }); + }); + + // ── Mode switching ───────────────────────────────────────────────────────── + + describe('mode switching', () => { + it('defaults to "single" mode', () => { + render(); + expect(screen.getByTestId('mode-single')).toHaveAttribute('data-active', 'true'); + }); + + it('switches to recurring mode when Recurring button clicked', () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + expect(screen.getByTestId('mode-recurring')).toHaveAttribute('data-active', 'true'); + }); + + it('shows DateTimePicker fields in single mode', () => { + render(); + expect(screen.getByTestId('datetimepicker-Start')).toBeInTheDocument(); + expect(screen.getByTestId('datetimepicker-End')).toBeInTheDocument(); + }); + + it('shows recurring fields when in recurring mode', () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + expect(screen.getByTestId('textinput-Rule name')).toBeInTheDocument(); + expect(screen.getByTestId('timeinput-Start time')).toBeInTheDocument(); + expect(screen.getByTestId('timeinput-End time')).toBeInTheDocument(); + }); + + it('disables mode toggle when editing an existing recording', () => { + render(); + expect(screen.getByTestId('mode-single')).toBeDisabled(); + expect(screen.getByTestId('mode-recurring')).toBeDisabled(); + }); + + it('shows "Schedule Recording" submit button in single mode', () => { + render(); + expect(screen.getByText('Schedule Recording')).toBeInTheDocument(); + }); + + it('shows "Save Rule" submit button in recurring mode', () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + expect(screen.getByText('Save Rule')).toBeInTheDocument(); + }); + }); + + // ── Channel loading ──────────────────────────────────────────────────────── + + describe('channel loading', () => { + it('calls getChannelsSummary when modal opens', async () => { + render(); + await waitFor(() => { + expect(RecordingUtils.getChannelsSummary).toHaveBeenCalled(); + }); + }); + + it('calls sortedChannelOptions with loaded channels', async () => { + const channels = [{ id: 'ch-1', name: 'HBO', channel_number: 501 }]; + vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue(channels); + render(); + await waitFor(() => { + expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith( + channels, + RecordingUtils.numberedChannelLabel + ); + }); + }); + + it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => { + vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(new Error('fail')); + render(); + await waitFor(() => { + expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith( + [], + RecordingUtils.numberedChannelLabel + ); + }); + }); + + it('does not load channels when modal is closed', () => { + render(); + expect(RecordingUtils.getChannelsSummary).not.toHaveBeenCalled(); + }); + }); + + // ── Single form submit (create) ──────────────────────────────────────────── + + describe('single mode – create recording', () => { + it('calls buildSinglePayload with form values on submit', async () => { + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.buildSinglePayload).toHaveBeenCalled(); + }); + }); + + it('calls createRecording when no existing recording', async () => { + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.createRecording).toHaveBeenCalled(); + }); + }); + + it('shows "Recording scheduled" notification after successful create', async () => { + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Recording scheduled', color: 'green' }) + ); + }); + }); + + it('calls fetchRecordings after successful create', async () => { + const { mockFetchRecordings } = setupStoreMock(); + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(mockFetchRecordings).toHaveBeenCalled(); + }); + }); + + it('calls onClose after successful create', async () => { + const onClose = vi.fn(); + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('does not call showNotification when createRecording throws', async () => { + vi.mocked(RecordingUtils.createRecording).mockRejectedValue(new Error('fail')); + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(showNotification).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Single form submit (update) ──────────────────────────────────────────── + + describe('single mode – update recording', () => { + it('calls updateRecording when editing an existing recording', async () => { + render( + + ); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.updateRecording).toHaveBeenCalledWith( + 'rec-1', + expect.anything() + ); + }); + }); + + it('does not call createRecording when updating', async () => { + render( + + ); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.createRecording).not.toHaveBeenCalled(); + }); + }); + + it('shows "Recording updated" notification after successful update', async () => { + render( + + ); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Recording updated', color: 'green' }) + ); + }); + }); + }); + + // ── Recurring form submit ────────────────────────────────────────────────── + + describe('recurring mode – create rule', () => { + it('calls buildRecurringPayload with form values on submit', async () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(RecordingUtils.buildRecurringPayload).toHaveBeenCalled(); + }); + }); + + it('calls createRecurringRule on submit', async () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(RecordingUtils.createRecurringRule).toHaveBeenCalled(); + }); + }); + + it('calls fetchRecurringRules and fetchRecordings on success', async () => { + const { mockFetchRecurringRules, mockFetchRecordings } = setupStoreMock(); + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(mockFetchRecurringRules).toHaveBeenCalled(); + expect(mockFetchRecordings).toHaveBeenCalled(); + }); + }); + + it('shows "Recurring rule saved" notification on success', async () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Recurring rule saved', color: 'green' }) + ); + }); + }); + + it('calls onClose after successful recurring submit', async () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('does not show notification when createRecurringRule throws', async () => { + vi.mocked(RecordingUtils.createRecurringRule).mockRejectedValue(new Error('fail')); + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(showNotification).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Form initialization ──────────────────────────────────────────────────── + + describe('form initialization', () => { + it('calls getSingleFormDefaults with recording and channel when opening with existing recording', () => { + const recording = makeRecording(); + const channel = makeChannel(); + render(); + expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(recording, channel); + }); + + it('calls getSingleFormDefaults with null when opening for new recording', () => { + render(); + expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(null, null); + }); + + it('calls getRecurringFormDefaults with channel on open', () => { + const channel = makeChannel(); + render(); + expect(RecordingUtils.getRecurringFormDefaults).toHaveBeenCalledWith(channel); + }); + }); + + // ── Close / reset ────────────────────────────────────────────────────────── + + describe('close and reset', () => { + it('calls onClose when modal close button is clicked', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx b/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx new file mode 100644 index 00000000..91216ac1 --- /dev/null +++ b/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx @@ -0,0 +1,770 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVideoStore.jsx', () => ({ + default: Object.assign(vi.fn(), { + getState: vi.fn(() => ({ showVideo: vi.fn() })), + }), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + format: vi.fn(), + isAfter: vi.fn(), + isBefore: vi.fn(), + useDateTimeFormat: vi.fn(), + useTimeHelpers: vi.fn(), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({ + deleteRecordingById: vi.fn(), + getChannelLogoUrl: vi.fn(), + getPosterUrl: vi.fn(), + getRecordingUrl: vi.fn(), + getSeasonLabel: vi.fn(), + getShowVideoUrl: vi.fn(), + runComSkip: vi.fn(), +})); + +vi.mock('../../../utils/forms/RecordingDetailsModalUtils.js', () => ({ + getChannel: vi.fn(), + getRating: vi.fn(), + getStatRows: vi.fn(), + getUpcomingEpisodes: vi.fn(), + refreshArtwork: vi.fn(), + updateRecordingMetadata: vi.fn(), +})); + +vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' })); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Check: () => , + Pencil: () => , + RefreshCcw: () => , + X: () => , +})); + +// ── @mantine/core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled }) => ( + + ), + Badge: ({ children, color }) => ( + {children} + ), + Button: ({ children, onClick, disabled, loading, size, variant, color }) => ( + + ), + Card: ({ children, onClick, style }) => ( +
{children}
+ ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Image: ({ src, alt, fallbackSrc }) => ( + {alt} + ), + Modal: ({ children, opened, onClose, title, size }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Stack: ({ children }) =>
{children}
, + Text: ({ children, size, c, fw, style }) => ( + {children} + ), + Textarea: ({ label, value, onChange, placeholder, ...props }) => ( +
+ +