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({
)}
-
+
diff --git a/frontend/src/components/forms/Stream.jsx b/frontend/src/components/forms/Stream.jsx
index 785ce4b1..50ca1572 100644
--- a/frontend/src/components/forms/Stream.jsx
+++ b/frontend/src/components/forms/Stream.jsx
@@ -1,17 +1,14 @@
// Modal.js
import React, { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
-import { yupResolver } from '@hookform/resolvers/yup';
-import * as Yup from 'yup';
-import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
-import { Modal, TextInput, Select, Button, Flex } from '@mantine/core';
+import { Button, Flex, Modal, Select, TextInput } from '@mantine/core';
import useChannelsStore from '../../store/channels';
-
-const schema = Yup.object({
- name: Yup.string().required('Name is required'),
- url: Yup.string().required('URL is required').min(0),
-});
+import {
+ addStream,
+ getResolver,
+ updateStream,
+} from '../../utils/forms/StreamUtils.js';
const Stream = ({ stream = null, isOpen, onClose }) => {
const streamProfiles = useStreamProfilesStore((state) => state.profiles);
@@ -40,7 +37,7 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
watch,
} = useForm({
defaultValues,
- resolver: yupResolver(schema),
+ resolver: getResolver(),
});
const onSubmit = async (values) => {
@@ -58,9 +55,9 @@ const Stream = ({ stream = null, isOpen, onClose }) => {
};
if (stream?.id) {
- await API.updateStream({ id: stream.id, ...payload });
+ await updateStream(stream.id, payload);
} else {
- await API.addStream(payload);
+ await addStream(payload);
}
reset();
diff --git a/frontend/src/components/forms/StreamProfile.jsx b/frontend/src/components/forms/StreamProfile.jsx
index 137bc2ea..2e7fc096 100644
--- a/frontend/src/components/forms/StreamProfile.jsx
+++ b/frontend/src/components/forms/StreamProfile.jsx
@@ -1,50 +1,25 @@
// StreamProfile form
import React, { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
-import { yupResolver } from '@hookform/resolvers/yup';
-import * as Yup from 'yup';
-import API from '../../api';
import useUserAgentsStore from '../../store/userAgents';
import {
- Modal,
- TextInput,
- Textarea,
- Select,
Button,
- Flex,
- Stack,
Checkbox,
+ Flex,
+ Modal,
+ Select,
+ Stack,
+ Textarea,
+ TextInput,
} from '@mantine/core';
-
-// Built-in commands supported by Dispatcharr out of the box.
-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.
-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.
-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(),
-});
+import {
+ addStreamProfile,
+ BUILT_IN_COMMANDS,
+ COMMAND_EXAMPLES,
+ getResolver,
+ toCommandSelection,
+ updateStreamProfile,
+} from '../../utils/forms/StreamProfileUtils.js';
const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const userAgents = useUserAgentsStore((state) => state.userAgents);
@@ -73,7 +48,7 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
watch,
} = useForm({
defaultValues,
- resolver: yupResolver(schema),
+ resolver: getResolver(),
});
// Sync form + dropdown selection whenever the target profile or modal state changes
@@ -84,9 +59,9 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const onSubmit = async (values) => {
if (profile?.id) {
- await API.updateStreamProfile({ id: profile.id, ...values });
+ await updateStreamProfile(profile.id, values);
} else {
- await API.addStreamProfile(values);
+ await addStreamProfile(values);
}
reset();
@@ -102,6 +77,17 @@ const StreamProfile = ({ profile = null, isOpen, onClose }) => {
const userAgentValue = watch('user_agent');
const isActiveValue = watch('is_active');
+ const handleOnChangeCommand = (val) => {
+ setCommandSelection(val);
+ // For built-in selections, write the real command value immediately
+ if (val !== '__custom__') {
+ setValue('command', val, { shouldValidate: true });
+ } else {
+ // Clear so the user enters their own value
+ setValue('command', '', { shouldValidate: false });
+ }
+ };
+
return (
-
+
-
+
{
)}
-
+
diff --git a/frontend/src/components/forms/UserAgent.jsx b/frontend/src/components/forms/UserAgent.jsx
index 97544b57..b6c237b1 100644
--- a/frontend/src/components/forms/UserAgent.jsx
+++ b/frontend/src/components/forms/UserAgent.jsx
@@ -1,26 +1,8 @@
// Modal.js
import React, { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
-import { yupResolver } from '@hookform/resolvers/yup';
-import * as Yup from 'yup';
-import API from '../../api';
-import {
- LoadingOverlay,
- TextInput,
- Button,
- Checkbox,
- Modal,
- Flex,
- NativeSelect,
- FileInput,
- Space,
-} from '@mantine/core';
-import { NETWORK_ACCESS_OPTIONS } from '../../constants';
-
-const schema = Yup.object({
- name: Yup.string().required('Name is required'),
- user_agent: Yup.string().required('User-Agent is required'),
-});
+import { Button, Checkbox, Flex, Modal, Space, TextInput, } from '@mantine/core';
+import { addUserAgent, getResolver, updateUserAgent } from '../../utils/forms/UserAgentUtils.js';
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
const defaultValues = useMemo(
@@ -42,14 +24,14 @@ const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
watch,
} = useForm({
defaultValues,
- resolver: yupResolver(schema),
+ resolver: getResolver(),
});
const onSubmit = async (values) => {
if (userAgent?.id) {
- await API.updateUserAgent({ id: userAgent.id, ...values });
+ await updateUserAgent(userAgent.id, values);
} else {
- await API.addUserAgent(values);
+ await addUserAgent(values);
}
reset();
From fee8913824ede27f3037e85bcc9f8e489cdb97f0 Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Mon, 25 May 2026 18:07:15 -0700
Subject: [PATCH 06/10] Added tests for components
---
.../__tests__/ProgramRecordingModal.test.jsx | 285 ++++++
.../forms/__tests__/Recording.test.jsx | 507 ++++++++++
.../__tests__/RecordingDetailsModal.test.jsx | 770 ++++++++++++++
.../__tests__/RecurringRuleModal.test.jsx | 937 ++++++++++++++++++
.../forms/__tests__/ScheduleInput.test.jsx | 373 +++++++
.../__tests__/SeriesRecordingModal.test.jsx | 417 ++++++++
.../__tests__/SeriesRuleEditorModal.test.jsx | 682 +++++++++++++
.../forms/__tests__/Stream.test.jsx | 566 +++++++++++
.../forms/__tests__/StreamProfile.test.jsx | 607 ++++++++++++
.../forms/__tests__/SuperuserForm.test.jsx | 332 +++++++
.../components/forms/__tests__/User.test.jsx | 686 +++++++++++++
.../forms/__tests__/UserAgent.test.jsx | 239 +++++
.../__tests__/VODCategoryFilter.test.jsx | 362 +++++++
13 files changed, 6763 insertions(+)
create mode 100644 frontend/src/components/forms/__tests__/ProgramRecordingModal.test.jsx
create mode 100644 frontend/src/components/forms/__tests__/Recording.test.jsx
create mode 100644 frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx
create mode 100644 frontend/src/components/forms/__tests__/RecurringRuleModal.test.jsx
create mode 100644 frontend/src/components/forms/__tests__/ScheduleInput.test.jsx
create mode 100644 frontend/src/components/forms/__tests__/SeriesRecordingModal.test.jsx
create mode 100644 frontend/src/components/forms/__tests__/SeriesRuleEditorModal.test.jsx
create mode 100644 frontend/src/components/forms/__tests__/Stream.test.jsx
create mode 100644 frontend/src/components/forms/__tests__/StreamProfile.test.jsx
create mode 100644 frontend/src/components/forms/__tests__/SuperuserForm.test.jsx
create mode 100644 frontend/src/components/forms/__tests__/User.test.jsx
create mode 100644 frontend/src/components/forms/__tests__/UserAgent.test.jsx
create mode 100644 frontend/src/components/forms/__tests__/VODCategoryFilter.test.jsx
diff --git a/frontend/src/components/forms/__tests__/ProgramRecordingModal.test.jsx b/frontend/src/components/forms/__tests__/ProgramRecordingModal.test.jsx
new file mode 100644
index 00000000..e47b15fd
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/ProgramRecordingModal.test.jsx
@@ -0,0 +1,285 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Dependency mocks ───────────────────────────────────────────────────────────
+vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
+ deleteRecordingById: vi.fn(),
+ deleteSeriesAndRule: vi.fn(),
+}));
+
+vi.mock('../../../utils/guideUtils.js', () => ({
+ deleteSeriesRuleByTvgId: vi.fn(),
+}));
+
+vi.mock('../SeriesRuleEditorModal.jsx', () => ({
+ default: ({ opened, onClose }) =>
+ opened ? (
+
+
+ Close Editor
+
+
+ ) : null,
+}));
+
+vi.mock('@mantine/core', async () => ({
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
×
+ {children}
+
+ ) : null,
+ Flex: ({ children }) => {children}
,
+ Button: ({ children, onClick, disabled, loading, color, variant }) => (
+
+ {children}
+
+ ),
+ Anchor: ({ children, onClick }) => (
+ {children}
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import ProgramRecordingModal from '../ProgramRecordingModal.jsx';
+import { deleteRecordingById, deleteSeriesAndRule } from '../../../utils/cards/RecordingCardUtils.js';
+import { deleteSeriesRuleByTvgId } from '../../../utils/guideUtils.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makeProgram = (overrides = {}) => ({
+ tvg_id: 'tvg-1',
+ title: 'Test Show',
+ ...overrides,
+});
+
+const makeRecording = (overrides = {}) => ({
+ id: 'rec-1',
+ ...overrides,
+});
+
+const defaultProps = (overrides = {}) => ({
+ opened: true,
+ onClose: vi.fn(),
+ program: makeProgram(),
+ recording: null,
+ existingRuleMode: null,
+ existingRule: null,
+ onRecordOne: vi.fn(),
+ onRecordSeriesAll: vi.fn(),
+ onRecordSeriesNew: vi.fn(),
+ onExistingRuleModeChange: vi.fn(),
+ ...overrides,
+});
+
+// ─── ProgramRecordingModal ─────────────────────────────────────────────────────
+
+describe('ProgramRecordingModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(deleteRecordingById).mockResolvedValue(undefined);
+ vi.mocked(deleteSeriesAndRule).mockResolvedValue(undefined);
+ vi.mocked(deleteSeriesRuleByTvgId).mockResolvedValue(undefined);
+ });
+
+ // ── Visibility ─────────────────────────────────────────────────────────────
+
+ describe('visibility', () => {
+ it('renders the modal when opened is true', () => {
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render the modal when opened is false', () => {
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('calls onClose when the modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── handleRemoveRecording ──────────────────────────────────────────────────
+
+ describe('handleRemoveRecording', () => {
+ it('calls deleteRecordingById with the recording id', async () => {
+ const recording = makeRecording();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Remove this recording'));
+ await waitFor(() => {
+ expect(deleteRecordingById).toHaveBeenCalledWith('rec-1');
+ });
+ });
+
+ it('calls onClose after deleting recording', async () => {
+ const onClose = vi.fn();
+ const recording = makeRecording();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Remove this recording'));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('still calls onClose when deleteRecordingById throws', async () => {
+ vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
+ const onClose = vi.fn();
+ const recording = makeRecording();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Remove this recording'));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── handleRemoveSeries ─────────────────────────────────────────────────────
+
+ describe('handleRemoveSeries', () => {
+ it('calls deleteSeriesAndRule with tvg_id and title', async () => {
+ const program = makeProgram({ tvg_id: 'tvg-2', title: 'My Series' });
+ const recording = makeRecording();
+ render(
+
+ );
+ fireEvent.click(screen.getByText(/Remove this series/i));
+ await waitFor(() => {
+ expect(deleteSeriesAndRule).toHaveBeenCalledWith({
+ tvg_id: 'tvg-2',
+ title: 'My Series',
+ });
+ });
+ });
+
+ it('calls onClose after removing series', async () => {
+ const onClose = vi.fn();
+ const recording = makeRecording();
+ render(
+
+ );
+ fireEvent.click(screen.getByText(/Remove this series/i));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── handleRemoveSeriesRule ─────────────────────────────────────────────────
+
+ describe('handleRemoveSeriesRule', () => {
+ it('calls deleteSeriesRuleByTvgId with tvg_id and title', async () => {
+ const program = makeProgram({ tvg_id: 'tvg-3', title: 'Rule Show' });
+ render(
+
+ );
+ fireEvent.click(screen.getByText(/Remove series rule/i));
+ await waitFor(() => {
+ expect(deleteSeriesRuleByTvgId).toHaveBeenCalledWith('tvg-3', 'Rule Show');
+ });
+ });
+
+ it('calls onExistingRuleModeChange(null) after removing rule', async () => {
+ const onExistingRuleModeChange = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText(/Remove series rule/i));
+ await waitFor(() => {
+ expect(onExistingRuleModeChange).toHaveBeenCalledWith(null);
+ });
+ });
+
+ it('calls onClose after removing rule', async () => {
+ const onClose = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText(/Remove series rule/i));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Record actions ─────────────────────────────────────────────────────────
+
+ describe('record actions', () => {
+ it('calls onRecordOne when "Record Once" is clicked', () => {
+ const onRecordOne = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText('Just this one'));
+ expect(onRecordOne).toHaveBeenCalled();
+ });
+
+ it('calls onRecordSeriesAll when "Record All" is clicked', () => {
+ const onRecordSeriesAll = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText('Every episode'));
+ expect(onRecordSeriesAll).toHaveBeenCalled();
+ });
+
+ it('calls onRecordSeriesNew when "Record New" is clicked', () => {
+ const onRecordSeriesNew = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText('New episodes only'));
+ expect(onRecordSeriesNew).toHaveBeenCalled();
+ });
+ });
+
+ // ── SeriesRuleEditorModal ──────────────────────────────────────────────────
+
+ describe('SeriesRuleEditorModal', () => {
+ it('opens SeriesRuleEditorModal when "Edit Rule" is clicked', () => {
+ render(
+
+ );
+ fireEvent.click(screen.getByText(/Customize rule/i));
+ expect(screen.getByTestId('series-rule-editor-modal')).toBeInTheDocument();
+ });
+
+ it('closes SeriesRuleEditorModal when its onClose is called', () => {
+ render(
+
+ );
+ fireEvent.click(screen.getByText(/Customize rule/i));
+ fireEvent.click(screen.getByTestId('series-rule-editor-close'));
+ expect(screen.queryByTestId('series-rule-editor-modal')).not.toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/__tests__/Recording.test.jsx b/frontend/src/components/forms/__tests__/Recording.test.jsx
new file mode 100644
index 00000000..9c3771bc
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/Recording.test.jsx
@@ -0,0 +1,507 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import RecordingModal from '../Recording';
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/dateTimeUtils.js', () => ({
+ RECURRING_DAY_OPTIONS: [
+ { value: 'mon', label: 'Monday' },
+ { value: 'tue', label: 'Tuesday' },
+ ],
+ toTimeString: vi.fn((val) => val),
+}));
+
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
+ buildRecurringPayload: vi.fn((v) => v),
+ buildSinglePayload: vi.fn((v) => v),
+ createRecording: vi.fn(),
+ createRecurringRule: vi.fn(),
+ sortedChannelOptions: vi.fn(() => [{ value: 'ch-1', label: '501 - HBO' }]),
+ numberedChannelLabel: vi.fn((item) =>
+ item.channel_number ? `${item.channel_number} - ${item.name}` : item.name
+ ),
+ getChannelsSummary: vi.fn(),
+ getRecurringFormDefaults: vi.fn(() => ({
+ channel_id: '',
+ rule_name: '',
+ days_of_week: [],
+ start_date: new Date('2024-01-01'),
+ end_date: null,
+ start_time: '08:00',
+ end_time: '09:00',
+ })),
+ getSingleFormDefaults: vi.fn(() => ({
+ channel_id: 'ch-1',
+ start_time: new Date('2024-06-01T10:00:00'),
+ end_time: new Date('2024-06-01T11:00:00'),
+ })),
+ recurringFormValidators: {},
+ singleFormValidators: {},
+ timeChange: vi.fn((fn) => (e) => fn(e.target.value)),
+ updateRecording: vi.fn(),
+}));
+
+// ── @mantine/form ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/form', () => ({
+ useForm: vi.fn(({ initialValues }) => {
+ const values = { ...initialValues };
+ return {
+ values,
+ key: vi.fn((k) => k),
+ getInputProps: vi.fn((k) => ({ name: k, value: values[k] ?? '', onChange: vi.fn() })),
+ onSubmit: vi.fn((handler) => (e) => { e?.preventDefault?.(); return handler(values); }),
+ reset: vi.fn(),
+ setValues: vi.fn((newVals) => Object.assign(values, newVals)),
+ setFieldValue: vi.fn((k, v) => { values[k] = v; }),
+ validateField: vi.fn(),
+ };
+ }),
+}));
+
+// ── @mantine/core ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Alert: ({ children, title }) => (
+
+ {title}
+ {children}
+
+ ),
+ Button: ({ children, onClick, loading, type }) => (
+
+ {children}
+
+ ),
+ Group: ({ children }) => {children}
,
+ Loader: ({ size, color }) => ,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
×
+ {children}
+
+ ) : null,
+ MultiSelect: ({ label, placeholder, ...props }) => (
+
+ {label}
+
+
+ ),
+ SegmentedControl: ({ value, onChange, data, disabled }) => (
+
+ {data.map((item) => (
+ onChange(item.value)}
+ >
+ {item.label}
+
+ ))}
+
+ ),
+ Select: ({ label, disabled, rightSection, data, ...props }) => (
+
+ {label}
+ {rightSection}
+
+ {(data ?? []).map((opt) => (
+ {opt.label}
+ ))}
+
+
+ ),
+ Stack: ({ children }) => {children}
,
+ TextInput: ({ label, placeholder, ...props }) => (
+
+ {label}
+
+
+ ),
+}));
+
+// ── @mantine/dates ─────────────────────────────────────────────────────────────
+vi.mock('@mantine/dates', () => ({
+ DatePickerInput: ({ label, value, onChange }) => (
+
+ {label}
+ onChange(e.target.value ? new Date(e.target.value) : null)}
+ />
+
+ ),
+ DateTimePicker: ({ label, ...props }) => (
+
+ {label}
+
+
+ ),
+ TimeInput: ({ label, value, onChange, onBlur }) => (
+
+ {label}
+
+
+ ),
+}));
+
+// ── 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 }) => (
+
+ {children}
+
+ ),
+ Badge: ({ children, color }) => (
+ {children}
+ ),
+ Button: ({ children, onClick, disabled, loading, size, variant, color }) => (
+
+ {children}
+
+ ),
+ Card: ({ children, onClick, style }) => (
+ {children}
+ ),
+ Flex: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ Image: ({ src, alt, fallbackSrc }) => (
+
+ ),
+ 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 }) => (
+
+ {label}
+
+
+ ),
+ TextInput: ({ label, value, onChange, placeholder, ...props }) => (
+
+
+
+
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import RecordingDetailsModal from '../RecordingDetailsModal';
+import useChannelsStore from '../../../store/channels.jsx';
+import useVideoStore from '../../../store/useVideoStore.jsx';
+import {
+ format,
+ isAfter,
+ isBefore,
+ useDateTimeFormat,
+ useTimeHelpers,
+} from '../../../utils/dateTimeUtils.js';
+import { showNotification } from '../../../utils/notificationUtils.js';
+import * as RecordingCardUtils from '../../../utils/cards/RecordingCardUtils.js';
+import * as RecordingDetailsModalUtils from '../../../utils/forms/RecordingDetailsModalUtils.js';
+import dayjs from 'dayjs';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const PAST = '2020-01-01T10:00:00Z';
+const FUTURE = '2099-01-01T10:00:00Z';
+const NOW = '2024-06-01T12:00:00Z';
+
+const makeMoment = (isoString) => {
+ const d = dayjs(isoString);
+ return {
+ isAfter: (other) => d.isAfter(other?._d ?? other),
+ isBefore: (other) => d.isBefore(other?._d ?? other),
+ format: vi.fn((fmt) => d.format(fmt)),
+ _d: d.toDate(),
+ };
+};
+
+const makeRecording = (overrides = {}) => ({
+ id: 'rec-1',
+ channel: 'ch-1',
+ start_time: PAST,
+ end_time: PAST,
+ _group_count: 1,
+ custom_properties: {
+ status: 'completed',
+ file_url: '/recordings/test.ts',
+ program: {
+ title: 'Test Show',
+ description: 'A test description',
+ sub_title: 'Pilot',
+ },
+ ...overrides.custom_properties,
+ },
+ ...overrides,
+});
+
+const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
+
+const mockShowVideo = vi.fn();
+
+const setupMocks = ({ recording = makeRecording(), now = NOW } = {}) => {
+ const nowMoment = makeMoment(now);
+ const startMoment = makeMoment(recording.start_time);
+ const endMoment = makeMoment(recording.end_time);
+
+ vi.mocked(useTimeHelpers).mockReturnValue({
+ toUserTime: (iso) => {
+ if (iso === recording.start_time) return startMoment;
+ if (iso === recording.end_time) return endMoment;
+ return makeMoment(iso);
+ },
+ userNow: () => nowMoment,
+ });
+
+ vi.mocked(useDateTimeFormat).mockReturnValue({
+ timeFormat: 'HH:mm',
+ dateFormat: 'MM/DD',
+ });
+
+ vi.mocked(format).mockImplementation((moment, fmt) => moment.format(fmt));
+ vi.mocked(isAfter).mockImplementation((a, b) => a.isAfter(b));
+ vi.mocked(isBefore).mockImplementation((a, b) => a.isBefore(b));
+
+ vi.mocked(useChannelsStore).mockImplementation((sel) =>
+ sel({ recordings: [recording] })
+ );
+
+ mockShowVideo.mockReset();
+ vi.mocked(useVideoStore).mockImplementation((sel) =>
+ sel({ showVideo: mockShowVideo })
+ );
+ useVideoStore.getState = vi.fn(() => ({ showVideo: mockShowVideo }));
+
+ vi.mocked(RecordingCardUtils.getPosterUrl).mockReturnValue('/poster.jpg');
+ vi.mocked(RecordingCardUtils.getChannelLogoUrl).mockReturnValue('/logo.png');
+ vi.mocked(RecordingCardUtils.getRecordingUrl).mockReturnValue('/recordings/test.ts');
+ vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('');
+ vi.mocked(RecordingCardUtils.getShowVideoUrl).mockReturnValue('/live/ch-1');
+
+ vi.mocked(RecordingDetailsModalUtils.getRating).mockReturnValue(null);
+ vi.mocked(RecordingDetailsModalUtils.getStatRows).mockReturnValue([]);
+ vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue([]);
+ vi.mocked(RecordingDetailsModalUtils.getChannel).mockResolvedValue(null);
+};
+
+const defaultProps = () => ({
+ opened: true,
+ onClose: vi.fn(),
+ recording: makeRecording(),
+ channel: makeChannel(),
+ posterUrl: '/poster.jpg',
+ onWatchLive: vi.fn(),
+ onWatchRecording: vi.fn(),
+ env_mode: 'production',
+ onEdit: vi.fn(),
+});
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+describe('RecordingDetailsModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupMocks();
+ vi.mocked(RecordingDetailsModalUtils.updateRecordingMetadata).mockResolvedValue(undefined);
+ vi.mocked(RecordingDetailsModalUtils.refreshArtwork).mockResolvedValue(undefined);
+ vi.mocked(RecordingCardUtils.runComSkip).mockResolvedValue(undefined);
+ vi.mocked(RecordingCardUtils.deleteRecordingById).mockResolvedValue(undefined);
+ });
+
+ // ── Visibility ───────────────────────────────────────────────────────────────
+
+ describe('visibility', () => {
+ it('renders the modal when opened is true', () => {
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render when opened is false', () => {
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('returns null when recording is not provided', () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('calls onClose when modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Content rendering ────────────────────────────────────────────────────────
+
+ describe('content rendering', () => {
+ it('renders the recording title', () => {
+ render( );
+ expect(screen.getByText(/Test Show/i)).toBeInTheDocument();
+ });
+
+ it('renders "Custom Recording" when no program title', () => {
+ const recording = makeRecording({
+ custom_properties: { status: 'completed', program: {} },
+ });
+ setupMocks({ recording });
+ render(
+
+ );
+ expect(screen.getByText('Custom Recording')).toBeInTheDocument();
+ });
+
+ it('renders the description', () => {
+ render( );
+ expect(screen.getByText('A test description')).toBeInTheDocument();
+ });
+
+ it('renders the poster image', () => {
+ render( );
+ expect(screen.getByAltText('Test Show')).toHaveAttribute('src', '/poster.jpg');
+ });
+
+ it('renders the season/episode label when present', () => {
+ const seriesRecording = makeRecording({ _group_count: 2 });
+ const mockEpisode = makeRecording({ id: 'ep-1', _group_count: 1 });
+
+ vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue([mockEpisode]);
+ vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('S01E02');
+
+ render(
+
+ );
+ expect(screen.getByText('S01E02')).toBeInTheDocument();
+ });
+
+ it('renders rating when present', () => {
+ vi.mocked(RecordingDetailsModalUtils.getRating).mockReturnValue('TV-MA');
+ render( );
+ expect(screen.getByText('TV-MA')).toBeInTheDocument();
+ });
+
+ it('renders stat rows when available', () => {
+ vi.mocked(RecordingDetailsModalUtils.getStatRows).mockReturnValue([
+ ['Video Codec', 'H264'],
+ ['Audio Codec', 'AAC'],
+ ]);
+ render( );
+ expect(screen.getByText('Stream Stats')).toBeInTheDocument();
+ expect(screen.getByText('Video Codec')).toBeInTheDocument();
+ expect(screen.getByText('H264')).toBeInTheDocument();
+ });
+ });
+
+ // ── Watch buttons ────────────────────────────────────────────────────────────
+
+ describe('Watch button', () => {
+ it('renders Watch button for completed recording', () => {
+ render( );
+ expect(screen.getByText('Watch')).toBeInTheDocument();
+ });
+
+ it('calls onWatchRecording when Watch is clicked', () => {
+ const onWatchRecording = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Watch'));
+ expect(onWatchRecording).toHaveBeenCalled();
+ });
+
+ it('Watch button is disabled when no file_url', () => {
+ const recording = makeRecording({
+ custom_properties: {
+ status: 'completed',
+ program: { title: 'Test Show' },
+ },
+ });
+ setupMocks({ recording });
+ render(
+
+ );
+ expect(screen.getByText('Watch')).toBeDisabled();
+ });
+ });
+
+ describe('Watch Live button', () => {
+ it('renders Watch Live button for in-progress recording', () => {
+ const recording = makeRecording({
+ start_time: PAST,
+ end_time: FUTURE,
+ custom_properties: {
+ status: 'recording',
+ file_url: '/f.ts',
+ program: { title: 'Live Show' },
+ },
+ });
+ setupMocks({ recording });
+ render(
+
+ );
+ expect(screen.getByText('Watch Live')).toBeInTheDocument();
+ });
+
+ it('calls onWatchLive when Watch Live is clicked', () => {
+ const recording = makeRecording({
+ start_time: PAST,
+ end_time: FUTURE,
+ custom_properties: {
+ status: 'recording',
+ file_url: '/f.ts',
+ program: { title: 'Live Show' },
+ },
+ });
+ setupMocks({ recording });
+ const onWatchLive = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Watch Live'));
+ expect(onWatchLive).toHaveBeenCalled();
+ });
+ });
+
+ // ── Edit button ──────────────────────────────────────────────────────────────
+
+ describe('Edit button', () => {
+ it('renders the Edit button', () => {
+ const futureRecording = makeRecording({ start_time: FUTURE, end_time: FUTURE });
+ setupMocks({ recording: futureRecording });
+ render(
+
+ );
+ expect(screen.getByText('Edit')).toBeInTheDocument();
+ });
+
+ it('calls onEdit when Edit is clicked', () => {
+ const futureRecording = makeRecording({ start_time: FUTURE, end_time: FUTURE });
+ setupMocks({ recording: futureRecording });
+ const onEdit = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Edit'));
+ expect(onEdit).toHaveBeenCalledWith(futureRecording);
+ });
+ });
+
+ // ── Inline editing ───────────────────────────────────────────────────────────
+
+ describe('inline metadata editing', () => {
+ it('shows edit inputs when pencil icon is clicked', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ expect(screen.getByTestId('textinput-Recording title')).toBeInTheDocument();
+ });
+
+ it('pre-fills title input with current title', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ expect(screen.getByTestId('textinput-Recording title')).toHaveValue('Test Show');
+ });
+
+ it('pre-fills description input with current description', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ expect(screen.getByTestId('textarea-Description (optional)')).toHaveValue('A test description');
+ });
+
+ it('cancels editing when X icon is clicked', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ expect(screen.getByTestId('textinput-Recording title')).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId('icon-x').closest('button'));
+ expect(screen.queryByTestId('textinput-Recording title')).not.toBeInTheDocument();
+ });
+
+ it('calls updateRecordingMetadata with new title and description on save', async () => {
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+
+ fireEvent.change(screen.getByTestId('textinput-Recording title'), {
+ target: { value: 'New Title' },
+ });
+ fireEvent.change(screen.getByTestId('textarea-Description (optional)'), {
+ target: { value: 'New Description' },
+ });
+ fireEvent.click(screen.getByTestId('icon-check').closest('button'));
+
+ await waitFor(() => {
+ expect(
+ RecordingDetailsModalUtils.updateRecordingMetadata
+ ).toHaveBeenCalledWith(makeRecording(), 'New Title', 'New Description');
+ });
+ });
+
+ it('shows saved title optimistically after save', async () => {
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ fireEvent.change(screen.getByTestId('textinput-Recording title'), {
+ target: { value: 'Updated Title' },
+ });
+ fireEvent.click(screen.getByTestId('icon-check').closest('button'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Updated Title')).toBeInTheDocument();
+ });
+ });
+
+ it('shows success notification after save', async () => {
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ fireEvent.click(screen.getByTestId('icon-check').closest('button'));
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ color: 'green' })
+ );
+ });
+ });
+
+ it('does not show notification when updateRecordingMetadata throws', async () => {
+ vi.mocked(
+ RecordingDetailsModalUtils.updateRecordingMetadata
+ ).mockRejectedValue(new Error('fail'));
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ fireEvent.click(screen.getByTestId('icon-check').closest('button'));
+
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+
+ it('uses "Custom Recording" when title is cleared on save', async () => {
+ render( );
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ fireEvent.change(screen.getByTestId('textinput-Recording title'), {
+ target: { value: '' },
+ });
+ fireEvent.click(screen.getByTestId('icon-check').closest('button'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Custom Recording')).toBeInTheDocument();
+ });
+ });
+ });
+
+ // ── Refresh artwork ──────────────────────────────────────────────────────────
+
+ describe('refresh artwork', () => {
+ it('calls refreshArtwork with recording id on click', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Refresh artwork'));
+
+ await waitFor(() => {
+ expect(RecordingDetailsModalUtils.refreshArtwork).toHaveBeenCalledWith(
+ 'rec-1'
+ );
+ });
+ });
+
+ it('shows success notification after refreshArtwork', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Refresh artwork'));
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ color: 'blue.5' })
+ );
+ });
+ });
+
+ it('does not show notification when refreshArtwork throws', async () => {
+ vi.mocked(RecordingDetailsModalUtils.refreshArtwork).mockRejectedValue(
+ new Error('fail')
+ );
+ render( );
+ fireEvent.click(screen.getByText('Refresh artwork'));
+
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── ComSkip ──────────────────────────────────────────────────────────────────
+
+ describe('run comskip', () => {
+ it('calls runComSkip with the recording', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Remove commercials'));
+
+ await waitFor(() => {
+ expect(RecordingCardUtils.runComSkip).toHaveBeenCalledWith(
+ makeRecording()
+ );
+ });
+ });
+
+ it('shows success notification after runComSkip', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Remove commercials'));
+
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ color: expect.any(String) })
+ );
+ });
+ });
+
+ it('does not show notification when runComSkip throws', async () => {
+ vi.mocked(RecordingCardUtils.runComSkip).mockRejectedValue(
+ new Error('fail')
+ );
+ render( );
+ fireEvent.click(screen.getByText('Remove commercials'));
+
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+
+ it('does not show Remove commercials when comskip is completed', () => {
+ const recording = makeRecording({
+ custom_properties: {
+ status: 'completed',
+ file_url: '/f.ts',
+ comskip: { status: 'completed' },
+ program: { title: 'Test Show' },
+ },
+ });
+ setupMocks({ recording });
+ render(
+
+ );
+ expect(screen.queryByText('Remove commercials')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Series / episodes ────────────────────────────────────────────────────────
+
+ describe('series group', () => {
+ const makeSeriesRecording = () =>
+ makeRecording({
+ _group_count: 3,
+ custom_properties: {
+ status: 'scheduled',
+ program: { title: 'Series Show', tvg_id: 'series-1' },
+ },
+ start_time: FUTURE,
+ end_time: FUTURE,
+ });
+
+ const makeEpisode = (id = 'ep-1') => ({
+ id,
+ channel: 'ch-1',
+ start_time: FUTURE,
+ end_time: FUTURE,
+ custom_properties: {
+ status: 'scheduled',
+ program: { title: 'Episode', sub_title: `Sub ${id}` },
+ },
+ });
+
+ it('renders upcoming episodes list when isSeriesGroup is true', () => {
+ const recording = makeSeriesRecording();
+ const episode = makeEpisode();
+ setupMocks({ recording });
+ vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
+ [episode]
+ );
+ render(
+
+ );
+ expect(screen.getByText('Sub ep-1')).toBeInTheDocument();
+ });
+
+ it('shows "No upcoming episodes" when episode list is empty for series', () => {
+ const recording = makeSeriesRecording();
+ setupMocks({ recording });
+ vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
+ []
+ );
+ render(
+
+ );
+ expect(screen.getByText(/no upcoming episodes/i)).toBeInTheDocument();
+ });
+
+ it('opens child modal when an episode card is clicked', async () => {
+ const recording = makeSeriesRecording();
+ const episode = makeEpisode();
+ setupMocks({ recording });
+ vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
+ [episode]
+ );
+ render(
+
+ );
+ const episodeCards = screen.getAllByTestId('card');
+ fireEvent.click(episodeCards[episodeCards.length - 1]);
+ await waitFor(() => {
+ expect(screen.getAllByTestId('modal').length).toBeGreaterThanOrEqual(1);
+ });
+ });
+
+ it('calls deleteRecordingById when episode remove is clicked', async () => {
+ const recording = makeSeriesRecording();
+ const episode = makeEpisode();
+ setupMocks({ recording });
+ vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
+ [episode]
+ );
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Remove'));
+ await waitFor(() => {
+ expect(RecordingCardUtils.deleteRecordingById).toHaveBeenCalledWith(
+ 'ep-1'
+ );
+ });
+ });
+ });
+
+ // ── safeRecording merging ────────────────────────────────────────────────────
+
+ describe('safeRecording store merge', () => {
+ it('picks updated recording from store while preserving _group_count', () => {
+ const original = makeRecording({ _group_count: 3 });
+ const storeVersion = {
+ ...makeRecording(),
+ id: 'rec-1',
+ custom_properties: {
+ ...makeRecording().custom_properties,
+ program: { title: 'Updated Title' },
+ },
+ };
+ vi.mocked(useChannelsStore).mockImplementation((sel) =>
+ sel({ recordings: [storeVersion] })
+ );
+ render(
+
+ );
+ expect(screen.getByText(/Updated Title/i)).toBeInTheDocument();
+ });
+ });
+
+ // ── Optimistic state reset ───────────────────────────────────────────────────
+
+ describe('optimistic state reset on recording change', () => {
+ it('resets saved title when recording id changes', async () => {
+ const { rerender } = render(
+
+ );
+ // Save a new title
+ fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
+ fireEvent.change(screen.getByTestId('textinput-Recording title'), {
+ target: { value: 'Custom' },
+ });
+ fireEvent.click(screen.getByTestId('icon-check').closest('button'));
+ await waitFor(() => expect(screen.getByText('Custom')).toBeInTheDocument());
+
+ // Switch to a different recording
+ const newRecording = makeRecording({ id: 'rec-2' });
+ setupMocks({ recording: newRecording });
+ rerender(
+
+ );
+ expect(screen.getByText(/Test Show/i)).toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/__tests__/RecurringRuleModal.test.jsx b/frontend/src/components/forms/__tests__/RecurringRuleModal.test.jsx
new file mode 100644
index 00000000..6bfc8927
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/RecurringRuleModal.test.jsx
@@ -0,0 +1,937 @@
+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() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/dateTimeUtils.js', () => ({
+ format: vi.fn((moment, fmt) => `formatted-${fmt}`),
+ getNow: vi.fn(() => '2024-06-01T12:00:00Z'),
+ RECURRING_DAY_OPTIONS: [
+ { value: 'mon', label: 'Monday' },
+ { value: 'tue', label: 'Tuesday' },
+ { value: 'wed', label: 'Wednesday' },
+ ],
+ toDate: vi.fn((val) => new Date(val)),
+ toTimeString: vi.fn((val) => val),
+ useDateTimeFormat: vi.fn(),
+ useTimeHelpers: vi.fn(),
+}));
+
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
+ deleteRecordingById: vi.fn(),
+}));
+
+vi.mock('../../../utils/forms/RecurringRuleModalUtils.js', () => ({
+ deleteRecurringRuleById: vi.fn(),
+ getFormDefaults: vi.fn(),
+ getUpcomingOccurrences: vi.fn(),
+ updateRecurringRule: vi.fn(),
+ updateRecurringRuleEnabled: vi.fn(),
+}));
+
+vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
+ getChannelsSummary: vi.fn(),
+ getRecurringFormDefaults: vi.fn(),
+ recurringFormValidators: {},
+ sortedChannelOptions: vi.fn(() => [{ value: 'ch-1', label: 'HBO' }]),
+}));
+
+// ── @mantine/form ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/form', async () => {
+ const React = await import('react');
+ return {
+ useForm: vi.fn(({ initialValues }) => {
+ const [values, setValuesState] = React.useState({ ...initialValues });
+ return {
+ values,
+ key: vi.fn((k) => k),
+ getInputProps: vi.fn((k) => ({
+ name: k,
+ value: values[k] ?? '',
+ onChange: vi.fn((e) => {
+ const v = e?.currentTarget?.value ?? e?.target?.value ?? e;
+ setValuesState((prev) => ({ ...prev, [k]: v }));
+ }),
+ })),
+ onSubmit: vi.fn(
+ (handler) => (e) => {
+ e?.preventDefault?.();
+ return handler(values);
+ }
+ ),
+ reset: vi.fn(() => setValuesState({ ...initialValues })),
+ setValues: vi.fn((newVals) =>
+ setValuesState((prev) => ({ ...prev, ...newVals }))
+ ),
+ setFieldValue: vi.fn((k, v) =>
+ setValuesState((prev) => ({ ...prev, [k]: v }))
+ ),
+ };
+ }),
+ };
+});
+
+// ── @mantine/core ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Badge: ({ children, color }) => (
+
+ {children}
+
+ ),
+ Button: ({ children, onClick, loading, type, variant, color, size, disabled }) => (
+
+ {children}
+
+ ),
+ Card: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ ×
+
+ {children}
+
+ ) : null,
+ MultiSelect: ({ label, data, ...props }) => (
+
+ {label}
+
+ {(data ?? []).map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ ),
+ Select: ({ label, data, ...props }) => (
+
+ {label}
+
+ {(data ?? []).map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ ),
+ Stack: ({ children }) => {children}
,
+ Switch: ({ checked, onChange, label, disabled }) => (
+
+ {
+ // Mirror the event shape the component expects: event.currentTarget.checked
+ onChange({ currentTarget: { checked: e.target.checked } });
+ }}
+ />
+ {label}
+
+ ),
+ Text: ({ children, size, c, fw }) => (
+
+ {children}
+
+ ),
+ TextInput: ({ label, placeholder, ...props }) => (
+
+ {label}
+
+
+ ),
+}));
+
+// ── @mantine/dates ─────────────────────────────────────────────────────────────
+vi.mock('@mantine/dates', () => ({
+ DatePickerInput: ({ label, value, onChange }) => (
+
+ {label}
+ onChange(e.target.value ? new Date(e.target.value) : null)}
+ />
+
+ ),
+ TimeInput: ({ label, value, onChange }) => (
+
+ {label}
+ onChange(e.target.value)}
+ />
+
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import useChannelsStore from '../../../store/channels.jsx';
+import {
+ format,
+ toTimeString,
+ useDateTimeFormat,
+ useTimeHelpers,
+} from '../../../utils/dateTimeUtils.js';
+import { showNotification } from '../../../utils/notificationUtils.js';
+import { deleteRecordingById } from '../../../utils/cards/RecordingCardUtils.js';
+import * as RecurringRuleModalUtils from '../../../utils/forms/RecurringRuleModalUtils.js';
+import * as RecordingUtils from '../../../utils/forms/RecordingUtils.js';
+import { useForm } from '@mantine/form';
+import RecurringRuleModal from '../RecurringRuleModal';
+import dayjs from 'dayjs';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const FUTURE = '2099-01-01T10:00:00Z';
+const NOW = '2024-06-01T12:00:00Z';
+
+const makeMoment = (isoString) => {
+ const d = dayjs(isoString);
+ return {
+ isAfter: (other) => d.isAfter(other?._d ?? other),
+ isBefore: (other) => d.isBefore(other?._d ?? other),
+ format: vi.fn((fmt) => `formatted-${fmt}`),
+ _d: d.toDate(),
+ };
+};
+
+const makeRule = (overrides = {}) => ({
+ id: 'rule-1',
+ name: 'Morning News',
+ channel: 'ch-1',
+ channel_id: 'ch-1',
+ days_of_week: ['mon', 'tue'],
+ start_date: new Date('2024-01-01'),
+ end_date: null,
+ start_time: '08:00',
+ end_time: '09:00',
+ enabled: true,
+ ...overrides,
+});
+
+const makeOccurrence = (id = 'occ-1') => ({
+ id,
+ start_time: FUTURE,
+ end_time: FUTURE,
+ custom_properties: { program: { title: 'Episode' } },
+});
+
+const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 });
+
+const makeRecording = (overrides = {}) => ({
+ id: 'rec-1',
+ start_time: FUTURE,
+ end_time: FUTURE,
+ custom_properties: { program: { title: 'Morning News' } },
+ ...overrides,
+});
+
+const setupMocks = ({ rule = makeRule(), occurrences = [] } = {}) => {
+ const nowMoment = makeMoment(NOW);
+ const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined);
+
+ vi.mocked(useChannelsStore).mockImplementation((sel) =>
+ sel({
+ recurringRules: rule ? [rule] : [],
+ fetchRecurringRules: mockFetchRecurringRules,
+ recordings: [],
+ })
+ );
+
+ vi.mocked(useTimeHelpers).mockReturnValue({
+ toUserTime: (iso) => makeMoment(iso),
+ userNow: () => nowMoment,
+ });
+
+ vi.mocked(useDateTimeFormat).mockReturnValue({
+ timeFormat: 'HH:mm',
+ dateFormat: 'MM/DD',
+ });
+
+ vi.mocked(format).mockImplementation((moment, fmt) => `formatted-${fmt}`);
+
+ vi.mocked(RecordingUtils.getRecurringFormDefaults).mockReturnValue({
+ channel_id: '',
+ rule_name: '',
+ days_of_week: [],
+ start_date: new Date('2024-01-01'),
+ end_date: null,
+ start_time: '08:00',
+ end_time: '09:00',
+ enabled: rule?.enabled ?? true,
+ });
+
+ vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([makeChannel()]);
+
+ vi.mocked(RecurringRuleModalUtils.getFormDefaults).mockReturnValue({
+ channel_id: rule?.channel_id ?? '',
+ rule_name: rule?.name ?? '',
+ days_of_week: rule?.days_of_week ?? [],
+ start_date: rule?.start_date ?? new Date('2024-01-01'),
+ end_date: rule?.end_date ?? null,
+ start_time: rule?.start_time ?? '08:00',
+ end_time: rule?.end_time ?? '09:00',
+ enabled: rule?.enabled ?? true,
+ });
+
+ vi.mocked(RecurringRuleModalUtils.getUpcomingOccurrences).mockReturnValue(occurrences);
+ vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockResolvedValue(undefined);
+ vi.mocked(RecurringRuleModalUtils.updateRecurringRuleEnabled).mockResolvedValue(undefined);
+ vi.mocked(RecurringRuleModalUtils.deleteRecurringRuleById).mockResolvedValue(undefined);
+ vi.mocked(deleteRecordingById).mockResolvedValue(undefined);
+
+ return { mockFetchRecurringRules };
+};
+
+const defaultProps = (overrides = {}) => ({
+ opened: true,
+ onClose: vi.fn(),
+ ruleId: 'rule-1',
+ recording: null,
+ onEditOccurrence: vi.fn(),
+ ...overrides,
+});
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+describe('RecurringRuleModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupMocks();
+ });
+
+ // ── Visibility ───────────────────────────────────────────────────────────────
+
+ describe('visibility', () => {
+ it('renders the modal when opened is true', () => {
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render when opened is false', () => {
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('uses rule name as modal title', () => {
+ render( );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Morning News');
+ });
+
+ it('falls back to "Recurring Rule" when rule has no name', () => {
+ setupMocks({ rule: makeRule({ name: '' }) });
+ render( );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Recurring Rule');
+ });
+
+ it('calls onClose when modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Missing rule fallback ────────────────────────────────────────────────────
+
+ describe('missing rule fallback', () => {
+ beforeEach(() => {
+ setupMocks({ rule: null });
+ });
+
+ it('renders fallback message when rule does not exist', () => {
+ render( );
+ expect(
+ screen.getByText('The recurring rule for this recording no longer exists.')
+ ).toBeInTheDocument();
+ });
+
+ it('shows Delete Recording button when sourceRecording is provided', () => {
+ render(
+
+ );
+ expect(screen.getByText('Delete Recording')).toBeInTheDocument();
+ });
+
+ it('does not show Delete Recording button when sourceRecording is null', () => {
+ render( );
+ expect(screen.queryByText('Delete Recording')).not.toBeInTheDocument();
+ });
+
+ it('calls deleteRecordingById when Delete Recording is confirmed', async () => {
+ const onClose = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Delete Recording'));
+ await waitFor(() => {
+ expect(deleteRecordingById).toHaveBeenCalledWith('rec-1');
+ });
+ });
+
+ it('shows "Recording deleted" notification after deleting orphaned recording', async () => {
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Delete Recording'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Recording deleted', color: 'green' })
+ );
+ });
+ });
+
+ it('calls onClose after deleting orphaned recording', async () => {
+ const onClose = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Delete Recording'));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose when Cancel is clicked in fallback', () => {
+ const onClose = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('does not show notification when deleteRecordingById throws', async () => {
+ vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Delete Recording'));
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Form fields ──────────────────────────────────────────────────────────────
+
+ describe('form fields', () => {
+ it('renders the Channel select', () => {
+ render( );
+ expect(screen.getByTestId('select-Channel')).toBeInTheDocument();
+ });
+
+ it('renders the Rule name input', () => {
+ render( );
+ expect(screen.getByTestId('textinput-Rule name')).toBeInTheDocument();
+ });
+
+ it('renders the Every (days of week) multiselect', () => {
+ render( );
+ expect(screen.getByTestId('multiselect-Every')).toBeInTheDocument();
+ });
+
+ it('renders Start date and End date pickers', () => {
+ render( );
+ expect(screen.getByTestId('datepicker-Start date')).toBeInTheDocument();
+ expect(screen.getByTestId('datepicker-End date')).toBeInTheDocument();
+ });
+
+ it('renders Start time and End time inputs', () => {
+ render( );
+ expect(screen.getByTestId('timeinput-Start time')).toBeInTheDocument();
+ expect(screen.getByTestId('timeinput-End time')).toBeInTheDocument();
+ });
+
+ it('renders the enabled/paused Switch', () => {
+ render( );
+ expect(screen.getByTestId('switch')).toBeInTheDocument();
+ });
+
+ it('switch reflects rule enabled state', () => {
+ render( );
+ expect(screen.getByTestId('switch')).toBeChecked();
+ });
+
+ it('switch is unchecked when rule is paused', () => {
+ setupMocks({ rule: makeRule({ enabled: false }) });
+ render( );
+ expect(screen.getByTestId('switch')).not.toBeChecked();
+ });
+
+ it('renders channel name in header', () => {
+ render( );
+ expect(screen.getByText('HBO')).toBeInTheDocument();
+ });
+
+ it('renders channel number fallback when channel not in allChannels', async () => {
+ vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([]);
+ render( );
+ await waitFor(() => {
+ expect(screen.getByText('Channel ch-1')).toBeInTheDocument();
+ });
+ });
+ });
+
+ // ── Channel loading ──────────────────────────────────────────────────────────
+
+ describe('channel loading', () => {
+ it('calls getChannelsSummary when opened', async () => {
+ render( );
+ await waitFor(() => {
+ expect(RecordingUtils.getChannelsSummary).toHaveBeenCalled();
+ });
+ });
+
+ it('does not call getChannelsSummary when closed', () => {
+ render( );
+ expect(RecordingUtils.getChannelsSummary).not.toHaveBeenCalled();
+ });
+
+ it('calls sortedChannelOptions with loaded channels', async () => {
+ render( );
+ await waitFor(() => {
+ expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith([
+ makeChannel(),
+ ]);
+ });
+ });
+
+ it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => {
+ vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(
+ new Error('fail')
+ );
+ render( );
+ await waitFor(() => {
+ expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith([]);
+ });
+ });
+ });
+
+ // ── Form initialization from rule ─────────────────────────────────────────────
+
+ describe('form initialization', () => {
+ it('calls getFormDefaults with rule when opened', () => {
+ render( );
+ expect(RecurringRuleModalUtils.getFormDefaults).toHaveBeenCalledWith(
+ makeRule()
+ );
+ });
+
+ it('calls form.setValues with getFormDefaults result', () => {
+ const formMock = vi.mocked(useForm).mock.results[0]?.value;
+ render( );
+ expect(formMock?.setValues ?? vi.fn()).toBeDefined();
+ });
+ });
+
+ // ── Save changes ─────────────────────────────────────────────────────────────
+
+ describe('save changes', () => {
+ it('calls updateRecurringRule on form submit', async () => {
+ render( );
+ fireEvent.submit(screen.getByText('Save changes').closest('form'));
+ await waitFor(() => {
+ expect(RecurringRuleModalUtils.updateRecurringRule).toHaveBeenCalledWith(
+ 'rule-1',
+ expect.any(Object)
+ );
+ });
+ });
+
+ it('calls fetchRecurringRules after save', async () => {
+ const { mockFetchRecurringRules } = setupMocks();
+ render( );
+ fireEvent.submit(screen.getByText('Save changes').closest('form'));
+ await waitFor(() => {
+ expect(mockFetchRecurringRules).toHaveBeenCalled();
+ });
+ });
+
+ it('shows "Recurring rule updated" notification on success', async () => {
+ render( );
+ fireEvent.submit(screen.getByText('Save changes').closest('form'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Recurring rule updated',
+ color: 'green',
+ })
+ );
+ });
+ });
+
+ it('calls onClose after successful save', async () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.submit(screen.getByText('Save changes').closest('form'));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('does not show notification when updateRecurringRule throws', async () => {
+ vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockRejectedValue(
+ new Error('fail')
+ );
+ render( );
+ fireEvent.submit(screen.getByText('Save changes').closest('form'));
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+
+ it('Save changes button shows loading state while saving', async () => {
+ let resolve;
+ vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockReturnValue(
+ new Promise((r) => { resolve = r; })
+ );
+ render( );
+ fireEvent.submit(screen.getByText('Save changes').closest('form'));
+ await waitFor(() => {
+ expect(screen.getByText('Save changes')).toHaveAttribute('data-loading', 'true');
+ });
+ resolve();
+ });
+ });
+
+ // ── Delete rule ──────────────────────────────────────────────────────────────
+
+ describe('delete rule', () => {
+ it('renders the Delete rule button', () => {
+ render( );
+ expect(screen.getByText('Delete rule')).toBeInTheDocument();
+ });
+
+ it('calls deleteRecurringRuleById when Delete rule is clicked', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Delete rule'));
+ await waitFor(() => {
+ expect(RecurringRuleModalUtils.deleteRecurringRuleById).toHaveBeenCalledWith(
+ 'rule-1'
+ );
+ });
+ });
+
+ it('calls fetchRecurringRules after delete', async () => {
+ const { mockFetchRecurringRules } = setupMocks();
+ render( );
+ fireEvent.click(screen.getByText('Delete rule'));
+ await waitFor(() => {
+ expect(mockFetchRecurringRules).toHaveBeenCalled();
+ });
+ });
+
+ it('shows "Recurring rule removed" notification on success', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Delete rule'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Recurring rule removed',
+ color: 'red',
+ })
+ );
+ });
+ });
+
+ it('calls onClose after successful delete', async () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText('Delete rule'));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('does not show notification when deleteRecurringRuleById throws', async () => {
+ vi.mocked(RecurringRuleModalUtils.deleteRecurringRuleById).mockRejectedValue(
+ new Error('fail')
+ );
+ render( );
+ fireEvent.click(screen.getByText('Delete rule'));
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Toggle enabled ───────────────────────────────────────────────────────────
+
+ describe('toggle enabled', () => {
+ it('calls updateRecurringRuleEnabled with true when switch is toggled on', async () => {
+ setupMocks({ rule: makeRule({ enabled: false }) });
+ render( );
+ const sw = screen.getByTestId('switch');
+ expect(sw).not.toBeChecked();
+ fireEvent.click(sw);
+ await waitFor(() => {
+ expect(
+ RecurringRuleModalUtils.updateRecurringRuleEnabled
+ ).toHaveBeenCalledWith('rule-1', true);
+ });
+ });
+
+ it('calls updateRecurringRuleEnabled with false when switch is toggled off', async () => {
+ render( );
+ const sw = screen.getByTestId('switch');
+ expect(sw).toBeChecked();
+ fireEvent.click(sw);
+ await waitFor(() => {
+ expect(
+ RecurringRuleModalUtils.updateRecurringRuleEnabled
+ ).toHaveBeenCalledWith('rule-1', false);
+ });
+ });
+
+ it('shows "Recurring rule enabled" notification when enabling', async () => {
+ setupMocks({ rule: makeRule({ enabled: false }) });
+ render( );
+ fireEvent.click(screen.getByTestId('switch'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Recurring rule enabled',
+ color: 'green',
+ })
+ );
+ });
+ });
+
+ it('shows "Recurring rule paused" notification when disabling', async () => {
+ render( );
+ fireEvent.click(screen.getByTestId('switch'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Recurring rule paused',
+ color: 'yellow',
+ })
+ );
+ });
+ });
+
+ it('calls fetchRecurringRules after toggle', async () => {
+ const { mockFetchRecurringRules } = setupMocks();
+ render( );
+ fireEvent.click(screen.getByTestId('switch'));
+ await waitFor(() => {
+ expect(mockFetchRecurringRules).toHaveBeenCalled();
+ });
+ });
+
+ it('does not show notification when updateRecurringRuleEnabled throws', async () => {
+ vi.mocked(
+ RecurringRuleModalUtils.updateRecurringRuleEnabled
+ ).mockRejectedValue(new Error('fail'));
+ render( );
+ fireEvent.click(screen.getByTestId('switch'));
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Upcoming occurrences ─────────────────────────────────────────────────────
+
+ describe('upcoming occurrences', () => {
+ it('renders the occurrence count badge', () => {
+ setupMocks({ occurrences: [makeOccurrence(), makeOccurrence('occ-2')] });
+ render( );
+ expect(screen.getByTestId('badge')).toHaveTextContent('2');
+ });
+
+ it('shows "No future airings" when no upcoming occurrences', () => {
+ setupMocks({ occurrences: [] });
+ render( );
+ expect(
+ screen.getByText('No future airings currently scheduled.')
+ ).toBeInTheDocument();
+ });
+
+ it('renders occurrence cards when occurrences exist', () => {
+ setupMocks({
+ occurrences: [makeOccurrence(), makeOccurrence('occ-2')],
+ });
+ render( );
+ expect(screen.getAllByTestId('occurrence-card')).toHaveLength(2);
+ });
+
+ it('renders Edit and Cancel buttons for each occurrence', () => {
+ setupMocks({ occurrences: [makeOccurrence()] });
+ render( );
+ expect(screen.getByText('Edit')).toBeInTheDocument();
+ expect(screen.getByText('Cancel')).toBeInTheDocument();
+ });
+
+ it('calls getUpcomingOccurrences with recordings, userNow, ruleId, toUserTime', () => {
+ render( );
+ expect(RecurringRuleModalUtils.getUpcomingOccurrences).toHaveBeenCalledWith(
+ expect.any(Array),
+ expect.any(Function),
+ 'rule-1',
+ expect.any(Function)
+ );
+ });
+ });
+
+ // ── Edit occurrence ──────────────────────────────────────────────────────────
+
+ describe('edit occurrence', () => {
+ it('calls onClose and onEditOccurrence when Edit is clicked', () => {
+ const occ = makeOccurrence();
+ setupMocks({ occurrences: [occ] });
+ const onClose = vi.fn();
+ const onEditOccurrence = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Edit'));
+ expect(onClose).toHaveBeenCalled();
+ expect(onEditOccurrence).toHaveBeenCalledWith(occ);
+ });
+
+ it('does not throw when onEditOccurrence is not provided', () => {
+ const occ = makeOccurrence();
+ setupMocks({ occurrences: [occ] });
+ render(
+
+ );
+ expect(() => fireEvent.click(screen.getByText('Edit'))).not.toThrow();
+ });
+ });
+
+ // ── Cancel occurrence ────────────────────────────────────────────────────────
+
+ describe('cancel occurrence', () => {
+ it('calls deleteRecordingById when Cancel is clicked', async () => {
+ const occ = makeOccurrence();
+ setupMocks({ occurrences: [occ] });
+ render( );
+ fireEvent.click(screen.getByText('Cancel'));
+ await waitFor(() => {
+ expect(deleteRecordingById).toHaveBeenCalledWith('occ-1');
+ });
+ });
+
+ it('shows "Occurrence cancelled" notification on success', async () => {
+ const occ = makeOccurrence();
+ setupMocks({ occurrences: [occ] });
+ render( );
+ fireEvent.click(screen.getByText('Cancel'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({
+ title: 'Occurrence cancelled',
+ color: 'yellow',
+ })
+ );
+ });
+ });
+
+ it('does not show notification when deleteRecordingById throws', async () => {
+ vi.mocked(deleteRecordingById).mockRejectedValue(new Error('fail'));
+ setupMocks({ occurrences: [makeOccurrence()] });
+ render( );
+ fireEvent.click(screen.getByText('Cancel'));
+ await waitFor(() => {
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+ });
+
+ it('shows loading state on Cancel button while deleting occurrence', async () => {
+ let resolve;
+ vi.mocked(deleteRecordingById).mockReturnValue(
+ new Promise((r) => { resolve = r; })
+ );
+ setupMocks({ occurrences: [makeOccurrence()] });
+ render( );
+ fireEvent.click(screen.getByText('Cancel'));
+ await waitFor(() => {
+ expect(screen.getByText('Cancel')).toHaveAttribute('data-loading', 'true');
+ });
+ resolve();
+ });
+ });
+
+ // ── Time/date field handlers ─────────────────────────────────────────────────
+
+ describe('field change handlers', () => {
+ it('calls toTimeString when start time changes', () => {
+ render( );
+ fireEvent.change(screen.getByTestId('timeinput-Start time'), {
+ target: { value: '09:00' },
+ });
+ expect(toTimeString).toHaveBeenCalledWith('09:00');
+ });
+
+ it('calls toTimeString when end time changes', () => {
+ render( );
+ fireEvent.change(screen.getByTestId('timeinput-End time'), {
+ target: { value: '10:00' },
+ });
+ expect(toTimeString).toHaveBeenCalledWith('10:00');
+ });
+
+ it('updates end_date field when end date picker changes', () => {
+ render( );
+ const picker = screen.getByTestId('datepicker-End date');
+ fireEvent.change(picker, { target: { value: '2024-12-31' } });
+ // No throw — handler wired correctly
+ });
+
+ it('updates start_date field when start date picker changes', () => {
+ render( );
+ const picker = screen.getByTestId('datepicker-Start date');
+ fireEvent.change(picker, { target: { value: '2024-07-01' } });
+ // No throw — handler wired correctly
+ });
+ });
+});
+
diff --git a/frontend/src/components/forms/__tests__/ScheduleInput.test.jsx b/frontend/src/components/forms/__tests__/ScheduleInput.test.jsx
new file mode 100644
index 00000000..85dc55d9
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/ScheduleInput.test.jsx
@@ -0,0 +1,373 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import ScheduleInput from '../ScheduleInput';
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ TextInput: ({ label, placeholder, description, value, onChange, error, disabled }) => (
+
+ {typeof label === 'string' ? label : 'Cron Expression'}
+
+ {description && {description} }
+ {error && {error} }
+
+ ),
+ NumberInput: ({ label, description, value, onChange, min, disabled }) => (
+
+ {label}
+ onChange(Number(e.target.value))}
+ min={min}
+ disabled={disabled}
+ />
+ {description && {description} }
+
+ ),
+ Anchor: ({ children, onClick }) => (
+
+ {children}
+
+ ),
+ Stack: ({ children }) => {children}
,
+ Text: ({ children }) => {children} ,
+ Code: ({ children }) => {children},
+ Group: ({ children }) => {children}
,
+ Popover: ({ children }) => {children}
,
+ PopoverTarget: ({ children }) => {children}
,
+ PopoverDropdown: ({ children }) => {children}
,
+ ActionIcon: ({ children, onClick }) => (
+ {children}
+ ),
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ Info: () => ,
+}));
+
+// ── cronUtils ─────────────────────────────────────────────────────────────────
+vi.mock('../../../utils/cronUtils', () => ({
+ validateCronExpression: vi.fn(),
+}));
+
+// ── CronBuilder ────────────────────────────────────────────────────────────────
+vi.mock('../CronBuilder', () => ({
+ default: ({ opened, onClose, onApply, currentValue }) =>
+ opened ? (
+
+ {currentValue}
+ onApply('0 3 * * *')}>
+ Apply
+
+
+ Close
+
+
+ ) : null,
+}));
+
+// ──────────────────────────────────────────────────────────────────────────────
+import { validateCronExpression } from '../../../utils/cronUtils';
+
+const defaultIntervalProps = () => ({
+ scheduleType: 'interval',
+ onScheduleTypeChange: vi.fn(),
+ intervalValue: 6,
+ onIntervalChange: vi.fn(),
+ cronValue: '',
+ onCronChange: vi.fn(),
+});
+
+const defaultCronProps = () => ({
+ scheduleType: 'cron',
+ onScheduleTypeChange: vi.fn(),
+ cronValue: '0 3 * * *',
+ onCronChange: vi.fn(),
+ intervalValue: 0,
+ onIntervalChange: vi.fn(),
+});
+
+describe('ScheduleInput', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
+ });
+
+ // ── Interval mode ────────────────────────────────────────────────────────
+
+ describe('interval mode', () => {
+ it('renders NumberInput in interval mode', () => {
+ render( );
+ expect(screen.getByTestId('numberinput-interval')).toBeInTheDocument();
+ });
+
+ it('displays the intervalValue', () => {
+ render( );
+ expect(screen.getByTestId('numberinput-interval')).toHaveValue(6);
+ });
+
+ it('renders default interval label', () => {
+ render( );
+ expect(screen.getByText('Refresh Interval (hours)')).toBeInTheDocument();
+ });
+
+ it('renders custom intervalLabel', () => {
+ render( );
+ expect(screen.getByText('My Custom Label')).toBeInTheDocument();
+ });
+
+ it('renders default interval description', () => {
+ render( );
+ expect(screen.getByTestId('interval-description')).toHaveTextContent(
+ 'How often to refresh (0 to disable)'
+ );
+ });
+
+ it('calls onIntervalChange when value changes', () => {
+ const props = defaultIntervalProps();
+ render( );
+ fireEvent.change(screen.getByTestId('numberinput-interval'), {
+ target: { value: '12' },
+ });
+ expect(props.onIntervalChange).toHaveBeenCalledWith(12);
+ });
+
+ it('shows "Use cron schedule" link in interval mode', () => {
+ render( );
+ expect(screen.getByTestId('anchor-use-cron-schedule')).toBeInTheDocument();
+ });
+
+ it('calls onScheduleTypeChange("cron") when "Use cron schedule" is clicked', () => {
+ const props = defaultIntervalProps();
+ render( );
+ fireEvent.click(screen.getByTestId('anchor-use-cron-schedule'));
+ expect(props.onScheduleTypeChange).toHaveBeenCalledWith('cron');
+ });
+
+ it('does not show cron switch link when disabled', () => {
+ render( );
+ expect(screen.queryByTestId('anchor-use-cron-schedule')).not.toBeInTheDocument();
+ });
+
+ it('disables the NumberInput when disabled prop is true', () => {
+ render( );
+ expect(screen.getByTestId('numberinput-interval')).toBeDisabled();
+ });
+
+ it('renders custom switchToCronLabel', () => {
+ render(
+
+ );
+ expect(screen.getByText('Use custom cron schedule')).toBeInTheDocument();
+ });
+ });
+
+ // ── Children (simple) mode ────────────────────────────────────────────────
+
+ describe('children (simple) mode', () => {
+ it('renders children instead of NumberInput', () => {
+ render(
+
+ Custom content
+
+ );
+ expect(screen.getByTestId('custom-child')).toBeInTheDocument();
+ expect(screen.queryByTestId('numberinput-interval')).not.toBeInTheDocument();
+ });
+
+ it('shows cron toggle link below children', () => {
+ render(
+
+ Child
+
+ );
+ expect(screen.getByTestId('anchor-use-cron-schedule')).toBeInTheDocument();
+ });
+
+ it('does not show cron link when disabled with children', () => {
+ render(
+
+ Child
+
+ );
+ expect(screen.queryByTestId('anchor-use-cron-schedule')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Cron mode ─────────────────────────────────────────────────────────────
+
+ describe('cron mode', () => {
+ it('renders TextInput in cron mode', () => {
+ render( );
+ expect(screen.getByTestId('textinput-cron')).toBeInTheDocument();
+ });
+
+ it('displays the cronValue', () => {
+ render( );
+ expect(screen.getByTestId('textinput-cron')).toHaveValue('0 3 * * *');
+ });
+
+ it('shows "Use interval schedule" link in cron mode', () => {
+ render( );
+ expect(screen.getByTestId('anchor-use-interval-schedule')).toBeInTheDocument();
+ });
+
+ it('shows "Open Cron Builder" link', () => {
+ render( );
+ expect(screen.getByTestId('anchor-open-cron-builder')).toBeInTheDocument();
+ });
+
+ it('calls onScheduleTypeChange("interval") and onCronChange("") when switching to interval', () => {
+ const props = defaultCronProps();
+ render( );
+ fireEvent.click(screen.getByTestId('anchor-use-interval-schedule'));
+ expect(props.onScheduleTypeChange).toHaveBeenCalledWith('interval');
+ expect(props.onCronChange).toHaveBeenCalledWith('');
+ });
+
+ it('calls onCronChange when cron input changes', () => {
+ const props = defaultCronProps();
+ render( );
+ fireEvent.change(screen.getByTestId('textinput-cron'), {
+ target: { value: '0 5 * * *' },
+ });
+ expect(props.onCronChange).toHaveBeenCalledWith('0 5 * * *');
+ });
+
+ it('disables the TextInput when disabled prop is true', () => {
+ render( );
+ expect(screen.getByTestId('textinput-cron')).toBeDisabled();
+ });
+
+ it('does not show cron links when disabled', () => {
+ render( );
+ expect(screen.queryByTestId('anchor-use-interval-schedule')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('anchor-open-cron-builder')).not.toBeInTheDocument();
+ });
+
+ it('renders custom switchToIntervalLabel', () => {
+ render(
+
+ );
+ expect(screen.getByText('Use simple schedule')).toBeInTheDocument();
+ });
+ });
+
+ // ── Cron validation ───────────────────────────────────────────────────────
+
+ describe('cron validation', () => {
+ it('shows no error when cron is valid', () => {
+ vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
+ render( );
+ expect(screen.queryByTestId('cron-error')).not.toBeInTheDocument();
+ });
+
+ it('shows error message when cron is invalid', async () => {
+ vi.mocked(validateCronExpression).mockReturnValue({
+ valid: false,
+ error: 'Invalid cron expression',
+ });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByTestId('cron-error')).toHaveTextContent('Invalid cron expression');
+ });
+ });
+
+ it('clears error when cron input is cleared', async () => {
+ vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
+ const props = { ...defaultCronProps(), cronValue: '' };
+ render( );
+ await waitFor(() => {
+ expect(screen.queryByTestId('cron-error')).not.toBeInTheDocument();
+ });
+ });
+
+ it('calls validateCronExpression on cron value change', () => {
+ vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
+ const props = defaultCronProps();
+ render( );
+ fireEvent.change(screen.getByTestId('textinput-cron'), {
+ target: { value: '0 6 * * 1' },
+ });
+ expect(validateCronExpression).toHaveBeenCalledWith('0 6 * * 1');
+ });
+
+ it('does not call validateCronExpression in interval mode', () => {
+ render( );
+ expect(validateCronExpression).not.toHaveBeenCalled();
+ });
+ });
+
+ // ── Cron Builder ──────────────────────────────────────────────────────────
+
+ describe('CronBuilder', () => {
+ it('CronBuilder is not visible initially', () => {
+ render( );
+ expect(screen.queryByTestId('cron-builder')).not.toBeInTheDocument();
+ });
+
+ it('opens CronBuilder when "Open Cron Builder" is clicked', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
+ expect(screen.getByTestId('cron-builder')).toBeInTheDocument();
+ });
+
+ it('passes current cronValue to CronBuilder', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
+ expect(screen.getByTestId('cron-builder-value')).toHaveTextContent('0 4 * * *');
+ });
+
+ it('closes CronBuilder when onClose is triggered', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
+ fireEvent.click(screen.getByTestId('cron-builder-close'));
+ expect(screen.queryByTestId('cron-builder')).not.toBeInTheDocument();
+ });
+
+ it('calls onCronChange with applied value from CronBuilder', () => {
+ vi.mocked(validateCronExpression).mockReturnValue({ valid: true });
+ const props = defaultCronProps();
+ render( );
+ fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
+ fireEvent.click(screen.getByTestId('cron-builder-apply'));
+ expect(props.onCronChange).toHaveBeenCalledWith('0 3 * * *');
+ });
+ });
+
+ // ── Default props ─────────────────────────────────────────────────────────
+
+ describe('default props', () => {
+ it('defaults to interval mode when scheduleType is not provided', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('numberinput-interval')).toBeInTheDocument();
+ });
+
+ it('defaults intervalValue to 0', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('numberinput-interval')).toHaveValue(0);
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/__tests__/SeriesRecordingModal.test.jsx b/frontend/src/components/forms/__tests__/SeriesRecordingModal.test.jsx
new file mode 100644
index 00000000..5e7f3c09
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/SeriesRecordingModal.test.jsx
@@ -0,0 +1,417 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import SeriesRecordingModal from '../SeriesRecordingModal';
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ ×
+
+ {children}
+
+ ) : null,
+ Stack: ({ children }) => {children}
,
+ Text: ({ children, size, c, fw }) => (
+
+ {children}
+
+ ),
+ Flex: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ Button: ({ children, onClick, variant, color, disabled }) => (
+
+ {children}
+
+ ),
+ Badge: ({ children, color }) => (
+
+ {children}
+
+ ),
+}));
+
+// ── Store ──────────────────────────────────────────────────────────────────────
+vi.mock('../../../store/channels.jsx', () => ({
+ default: { getState: vi.fn() },
+}));
+
+// ── Utils ──────────────────────────────────────────────────────────────────────
+vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({
+ deleteSeriesAndRule: vi.fn(),
+}));
+
+vi.mock('../../../utils/guideUtils.js', () => ({
+ evaluateSeriesRulesByTvgId: vi.fn(),
+ fetchRules: vi.fn(),
+}));
+
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+// ── SeriesRuleEditorModal ──────────────────────────────────────────────────────
+vi.mock('../SeriesRuleEditorModal', () => ({
+ default: ({ opened, onClose, initialRule, onSaved }) =>
+ opened ? (
+
+ {initialRule ? initialRule.title : 'new'}
+ Close
+ Save
+
+ ) : null,
+}));
+
+// ──────────────────────────────────────────────────────────────────────────────
+import useChannelsStore from '../../../store/channels.jsx';
+import { deleteSeriesAndRule } from '../../../utils/cards/RecordingCardUtils.js';
+import { evaluateSeriesRulesByTvgId, fetchRules } from '../../../utils/guideUtils.js';
+import { showNotification } from '../../../utils/notificationUtils.js';
+
+const makeRule = (overrides = {}) => ({
+ tvg_id: 'tvg-1',
+ title: 'Test Show',
+ mode: 'new',
+ title_mode: 'exact',
+ description: '',
+ channel_id: null,
+ ...overrides,
+});
+
+const defaultProps = (overrides = {}) => ({
+ opened: true,
+ onClose: vi.fn(),
+ rules: [makeRule()],
+ onRulesUpdate: vi.fn(),
+ ...overrides,
+});
+
+const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
+
+const setupMocks = () => {
+ vi.mocked(useChannelsStore.getState).mockReturnValue({
+ fetchRecordings: mockFetchRecordings,
+ });
+ vi.mocked(fetchRules).mockResolvedValue([makeRule()]);
+ vi.mocked(evaluateSeriesRulesByTvgId).mockResolvedValue(undefined);
+ vi.mocked(deleteSeriesAndRule).mockResolvedValue(undefined);
+};
+
+describe('SeriesRecordingModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupMocks();
+ });
+
+ // ── Visibility ─────────────────────────────────────────────────────────────
+
+ describe('visibility', () => {
+ it('renders when opened is true', () => {
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render when opened is false', () => {
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('renders modal title "Series Recording Rules"', () => {
+ render( );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Series Recording Rules');
+ });
+
+ it('calls onClose when modal close button is clicked', () => {
+ const props = defaultProps();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(props.onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Empty state ────────────────────────────────────────────────────────────
+
+ describe('empty state', () => {
+ it('shows "No series rules configured" when rules is empty', () => {
+ render( );
+ expect(screen.getByText('No series rules configured')).toBeInTheDocument();
+ });
+
+ it('shows "No series rules configured" when rules is null', () => {
+ render( );
+ expect(screen.getByText('No series rules configured')).toBeInTheDocument();
+ });
+
+ it('does not show "No series rules configured" when rules are present', () => {
+ render( );
+ expect(screen.queryByText('No series rules configured')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Rule rendering ─────────────────────────────────────────────────────────
+
+ describe('rule rendering', () => {
+ it('renders rule title', () => {
+ render( );
+ expect(screen.getByText('Test Show')).toBeInTheDocument();
+ });
+
+ it('falls back to tvg_id when rule has no title', () => {
+ render(
+
+ );
+ expect(screen.getByText('tvg-fallback')).toBeInTheDocument();
+ });
+
+ it('renders "Pinned channel" badge when channel_id is set', () => {
+ render(
+
+ );
+ expect(screen.getByText('Pinned channel')).toBeInTheDocument();
+ });
+
+ it('does not render "Pinned channel" badge when channel_id is null', () => {
+ render( );
+ expect(screen.queryByText('Pinned channel')).not.toBeInTheDocument();
+ });
+
+ it('renders multiple rules', () => {
+ const rules = [
+ makeRule({ tvg_id: 'tvg-1', title: 'Show One' }),
+ makeRule({ tvg_id: 'tvg-2', title: 'Show Two' }),
+ ];
+ render( );
+ expect(screen.getByText('Show One')).toBeInTheDocument();
+ expect(screen.getByText('Show Two')).toBeInTheDocument();
+ });
+
+ it('renders Edit, Evaluate Now, and Remove buttons for each rule', () => {
+ render( );
+ expect(screen.getByText('Edit')).toBeInTheDocument();
+ expect(screen.getByText('Evaluate Now')).toBeInTheDocument();
+ expect(screen.getByText('Remove')).toBeInTheDocument();
+ });
+
+ it('renders "Add rule" button', () => {
+ render( );
+ expect(screen.getByText('Add rule')).toBeInTheDocument();
+ });
+ });
+
+ // ── Rule summary ───────────────────────────────────────────────────────────
+
+ describe('rule summary', () => {
+ it('shows "New episodes" for mode new', () => {
+ render( );
+ expect(screen.getByText(/New episodes/)).toBeInTheDocument();
+ });
+
+ it('shows "Every episode" for mode all', () => {
+ render( );
+ expect(screen.getByText(/Every episode/)).toBeInTheDocument();
+ });
+
+ it('shows exact title label in summary', () => {
+ render(
+
+ );
+ expect(screen.getByText(/Exact title: "Test Show"/)).toBeInTheDocument();
+ });
+
+ it('shows contains label in summary', () => {
+ render(
+
+ );
+ expect(screen.getByText(/Title contains: "Test"/)).toBeInTheDocument();
+ });
+
+ it('shows regex label in summary', () => {
+ render(
+
+ );
+ expect(screen.getByText(/Title regex: "\^Test"/)).toBeInTheDocument();
+ });
+
+ it('shows description in summary when present', () => {
+ render(
+
+ );
+ expect(screen.getByText(/Description: "A drama"/)).toBeInTheDocument();
+ });
+
+ it('does not show description in summary when empty', () => {
+ render( );
+ expect(screen.queryByText(/Description:/)).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Evaluate Now ───────────────────────────────────────────────────────────
+
+ describe('Evaluate Now', () => {
+ it('calls evaluateSeriesRulesByTvgId with rule tvg_id', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Evaluate Now'));
+ await waitFor(() => {
+ expect(evaluateSeriesRulesByTvgId).toHaveBeenCalledWith('tvg-1');
+ });
+ });
+
+ it('calls fetchRecordings after evaluation', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Evaluate Now'));
+ await waitFor(() => {
+ expect(mockFetchRecordings).toHaveBeenCalled();
+ });
+ });
+
+ it('shows "Evaluated" notification after evaluation', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Evaluate Now'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Evaluated', message: 'Checked for episodes' })
+ );
+ });
+ });
+
+ it('still shows notification when fetchRecordings rejects', async () => {
+ mockFetchRecordings.mockRejectedValueOnce(new Error('network'));
+ render( );
+ fireEvent.click(screen.getByText('Evaluate Now'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Evaluated' })
+ );
+ });
+ });
+ });
+
+ // ── Remove series ──────────────────────────────────────────────────────────
+
+ describe('Remove series', () => {
+ it('calls deleteSeriesAndRule with tvg_id and title', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Remove'));
+ await waitFor(() => {
+ expect(deleteSeriesAndRule).toHaveBeenCalledWith({
+ tvg_id: 'tvg-1',
+ title: 'Test Show',
+ });
+ });
+ });
+
+ it('calls fetchRecordings after removal', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Remove'));
+ await waitFor(() => {
+ expect(mockFetchRecordings).toHaveBeenCalled();
+ });
+ });
+
+ it('calls fetchRules after removal', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Remove'));
+ await waitFor(() => {
+ expect(fetchRules).toHaveBeenCalled();
+ });
+ });
+
+ it('calls onRulesUpdate with fetched rules after removal', async () => {
+ const updated = [makeRule({ title: 'Updated Show' })];
+ vi.mocked(fetchRules).mockResolvedValue(updated);
+ const props = defaultProps();
+ render( );
+ fireEvent.click(screen.getByText('Remove'));
+ await waitFor(() => {
+ expect(props.onRulesUpdate).toHaveBeenCalledWith(updated);
+ });
+ });
+
+ it('still calls onRulesUpdate when fetchRecordings rejects', async () => {
+ mockFetchRecordings.mockRejectedValueOnce(new Error('network'));
+ const props = defaultProps();
+ render( );
+ fireEvent.click(screen.getByText('Remove'));
+ await waitFor(() => {
+ expect(props.onRulesUpdate).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Add rule / Editor ──────────────────────────────────────────────────────
+
+ describe('Add rule', () => {
+ it('opens SeriesRuleEditorModal when "Add rule" is clicked', () => {
+ render( );
+ fireEvent.click(screen.getByText('Add rule'));
+ expect(screen.getByTestId('series-rule-editor')).toBeInTheDocument();
+ });
+
+ it('passes null initialRule when opening via "Add rule"', () => {
+ render( );
+ fireEvent.click(screen.getByText('Add rule'));
+ expect(screen.getByTestId('editor-rule')).toHaveTextContent('new');
+ });
+
+ it('closes SeriesRuleEditorModal when editor onClose fires', () => {
+ render( );
+ fireEvent.click(screen.getByText('Add rule'));
+ fireEvent.click(screen.getByTestId('editor-close'));
+ expect(screen.queryByTestId('series-rule-editor')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Edit rule ──────────────────────────────────────────────────────────────
+
+ describe('Edit rule', () => {
+ it('opens SeriesRuleEditorModal with the selected rule when Edit is clicked', () => {
+ render( );
+ fireEvent.click(screen.getByText('Edit'));
+ expect(screen.getByTestId('series-rule-editor')).toBeInTheDocument();
+ expect(screen.getByTestId('editor-rule')).toHaveTextContent('Test Show');
+ });
+
+ it('calls fetchRules and onRulesUpdate when editor onSaved fires', async () => {
+ const updated = [makeRule({ title: 'Saved Show' })];
+ vi.mocked(fetchRules).mockResolvedValue(updated);
+ const props = defaultProps();
+ render( );
+ fireEvent.click(screen.getByText('Edit'));
+ fireEvent.click(screen.getByTestId('editor-save'));
+ await waitFor(() => {
+ expect(fetchRules).toHaveBeenCalled();
+ expect(props.onRulesUpdate).toHaveBeenCalledWith(updated);
+ });
+ });
+
+ it('closes editor after save', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Edit'));
+ fireEvent.click(screen.getByTestId('editor-save'));
+ await waitFor(() => {
+ // editor is still open (onSaved doesn't auto-close) — just verify no error
+ expect(fetchRules).toHaveBeenCalled();
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/__tests__/SeriesRuleEditorModal.test.jsx b/frontend/src/components/forms/__tests__/SeriesRuleEditorModal.test.jsx
new file mode 100644
index 00000000..59a98bd9
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/SeriesRuleEditorModal.test.jsx
@@ -0,0 +1,682 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+
+// ── Store mocks ────────────────────────────────────────────────────────────
+vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
+vi.mock('../../../store/epgs.jsx', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────
+vi.mock('../../../utils.js', () => ({
+ useDebounce: vi.fn((val) => val),
+}));
+
+vi.mock('../../../utils/notificationUtils.js', () => ({
+ showNotification: vi.fn(),
+}));
+
+vi.mock('../../../utils/forms/RecordingUtils.js', () => ({
+ getChannelsSummary: vi.fn(),
+}));
+
+vi.mock('../../../utils/guideUtils.js', () => ({
+ createSeriesRule: vi.fn(),
+ evaluateSeriesRulesByTvgId: vi.fn(),
+}));
+
+vi.mock('../../../utils/forms/SeriesRuleEditorModalUtils.js', () => ({
+ TITLE_MODES: [
+ { label: 'Exact', value: 'exact' },
+ { label: 'Contains', value: 'contains' },
+ ],
+ DESCRIPTION_MODES: [
+ { label: 'Contains', value: 'contains' },
+ { label: 'Exact', value: 'exact' },
+ ],
+ EPISODE_MODES: [
+ { label: 'All', value: 'all' },
+ { label: 'New only', value: 'new' },
+ ],
+ formatRange: vi.fn((start, end) => `${start} - ${end}`),
+ getChannelOptions: vi.fn(() => [{ value: '10', label: '501 - HBO' }]),
+ getTvgOptions: vi.fn(),
+ previewSeriesRule: vi.fn(),
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Alert: ({ children, color }) => (
+
+ {children}
+
+ ),
+ Badge: ({ children, color }) => (
+
+ {children}
+
+ ),
+ Button: ({ children, onClick, disabled, loading, variant }) => (
+
+ {children}
+
+ ),
+ Divider: () => ,
+ Group: ({ children }) => {children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ ×
+
+ {children}
+
+ ) : null,
+ ScrollAreaAutosize: ({ children }) => {children}
,
+ SegmentedControl: ({ data, value, onChange, size }) => (
+
+ {data.map((item) => (
+ onChange(item.value)}
+ >
+ {item.label}
+
+ ))}
+
+ ),
+ Select: ({
+ label,
+ placeholder,
+ data,
+ value,
+ onChange,
+ description,
+ }) => (
+
+ {label}
+ {description && (
+ {description}
+ )}
+ onChange(e.target.value || null)}
+ >
+ {placeholder}
+ {(data || []).map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+
+ ),
+ Stack: ({ children }) => {children}
,
+ Text: ({ children, size, c, fw }) => (
+
+ {children}
+
+ ),
+ Textarea: ({ label, placeholder, value, onChange, description }) => (
+
+ {label}
+ {description && {description} }
+
+
+ ),
+ TextInput: ({ label, placeholder, value, onChange, description }) => (
+
+ {label}
+ {description && {description} }
+
+
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────
+import SeriesRuleEditorModal from '../SeriesRuleEditorModal';
+import useChannelsStore from '../../../store/channels.jsx';
+import useEPGsStore from '../../../store/epgs.jsx';
+import { useDebounce } from '../../../utils.js';
+import { showNotification } from '../../../utils/notificationUtils.js';
+import { getChannelsSummary } from '../../../utils/forms/RecordingUtils.js';
+import { createSeriesRule, evaluateSeriesRulesByTvgId } from '../../../utils/guideUtils.js';
+import {
+ getTvgOptions,
+ previewSeriesRule,
+ formatRange,
+} from '../../../utils/forms/SeriesRuleEditorModalUtils.js';
+
+// ── Shared test data ───────────────────────────────────────────────────────
+const mockTvgs = [
+ { id: 1, tvg_id: 'tvg-1', name: 'Channel One' },
+ { id: 2, tvg_id: 'tvg-2', name: 'Channel Two' },
+];
+
+const mockTvgsById = {
+ 1: { tvg_id: 'tvg-1', name: 'Channel One' },
+ 2: { tvg_id: 'tvg-2', name: 'Channel Two' },
+};
+
+const mockChannels = [
+ { id: 10, name: 'HBO', channel_number: 501, epg_data_id: 1 },
+ { id: 20, name: 'CNN', channel_number: 102, epg_data_id: 2 },
+];
+
+const mockTvgOptions = [
+ { value: 'tvg-1', label: 'Channel One (tvg-1)' },
+ { value: 'tvg-2', label: 'Channel Two (tvg-2)' },
+];
+
+const mockPreviewResponse = {
+ matches: [
+ {
+ id: 'p1',
+ title: 'Match Show',
+ sub_title: 'Episode 1',
+ description: 'A great show',
+ start_time: '2024-01-01T10:00:00Z',
+ end_time: '2024-01-01T11:00:00Z',
+ tvg_id: 'tvg-1',
+ is_new: true,
+ },
+ ],
+ total: 1,
+ epg_found: true,
+};
+
+// ── Setup helper ───────────────────────────────────────────────────────────
+const setupMocks = () => {
+ const mockFetchRecordings = vi.fn().mockResolvedValue(undefined);
+
+ vi.mocked(useEPGsStore).mockImplementation((selector) =>
+ selector({ tvgs: mockTvgs, tvgsById: mockTvgsById })
+ );
+
+ // Mock getState for the imperative call in handleSave
+ useChannelsStore.getState = vi.fn().mockReturnValue({
+ fetchRecordings: mockFetchRecordings,
+ });
+
+ vi.mocked(getTvgOptions).mockReturnValue(mockTvgOptions);
+ vi.mocked(getChannelsSummary).mockResolvedValue(mockChannels);
+ vi.mocked(previewSeriesRule).mockResolvedValue({ matches: [], total: 0, epg_found: true });
+ vi.mocked(createSeriesRule).mockResolvedValue({ id: 'rule-1' });
+ vi.mocked(evaluateSeriesRulesByTvgId).mockResolvedValue(undefined);
+ vi.mocked(useDebounce).mockImplementation((val) => val);
+ vi.mocked(formatRange).mockImplementation((s, e) => `${s} - ${e}`);
+
+ return { mockFetchRecordings };
+};
+
+// ──────────────────────────────────────────────────────────────────────────
+describe('SeriesRuleEditorModal', () => {
+ let defaultProps;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupMocks();
+
+ defaultProps = {
+ opened: true,
+ onClose: vi.fn(),
+ initialRule: null,
+ onSaved: vi.fn(),
+ };
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ── Rendering ────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the modal when opened is true', () => {
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render the modal when opened is false', () => {
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('shows "New Series Rule" title when initialRule is null', () => {
+ render( );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('New Series Rule');
+ });
+
+ it('shows "Edit Series Rule" title when initialRule is provided', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Edit Series Rule');
+ });
+
+ it('renders the title input', () => {
+ render( );
+ expect(screen.getByTestId('input-title')).toBeInTheDocument();
+ });
+
+ it('renders the description textarea', () => {
+ render( );
+ expect(screen.getByTestId('textarea-description')).toBeInTheDocument();
+ });
+
+ it('renders Cancel and Save rule buttons', () => {
+ render( );
+ expect(screen.getByText('Cancel')).toBeInTheDocument();
+ expect(screen.getByText('Save rule')).toBeInTheDocument();
+ });
+
+ it('Save rule button is disabled when no title or description', () => {
+ render( );
+ expect(screen.getByText('Save rule')).toBeDisabled();
+ });
+
+ it('Save rule button is enabled when title is provided', async () => {
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'My Show' },
+ });
+ expect(screen.getByText('Save rule')).not.toBeDisabled();
+ });
+
+ it('Save rule button is enabled when description is provided', async () => {
+ render( );
+ fireEvent.change(screen.getByTestId('textarea-description'), {
+ target: { value: 'some description' },
+ });
+ expect(screen.getByText('Save rule')).not.toBeDisabled();
+ });
+ });
+
+ // ── State hydration from initialRule ─────────────────────────────────────
+
+ describe('state hydration', () => {
+ const initialRule = {
+ tvg_id: 'tvg-1',
+ mode: 'new',
+ title: 'Pre-filled Title',
+ title_mode: 'contains',
+ description: 'Pre-filled description',
+ description_mode: 'exact',
+ channel_id: 10,
+ };
+
+ it('pre-fills title from initialRule', () => {
+ render( );
+ expect(screen.getByTestId('input-title')).toHaveValue('Pre-filled Title');
+ });
+
+ it('pre-fills description from initialRule', () => {
+ render( );
+ expect(screen.getByTestId('textarea-description')).toHaveValue('Pre-filled description');
+ });
+
+ it('uses default mode "all" when initialRule has no mode', () => {
+ render( );
+ // "All" segmented button should be active (data-active=true)
+ const allBtn = screen.getAllByText('All').find(
+ (el) => el.closest('[data-testid="segmented-control"]')
+ );
+ expect(allBtn).toBeTruthy();
+ });
+
+ it('resets fields when modal is reopened without initialRule', async () => {
+ const { rerender } = render(
+
+ );
+ rerender(
+
+ );
+ rerender(
+
+ );
+ expect(screen.getByTestId('input-title')).toHaveValue('');
+ expect(screen.getByTestId('textarea-description')).toHaveValue('');
+ });
+ });
+
+ // ── Channel loading ───────────────────────────────────────────────────────
+
+ describe('channel loading', () => {
+ it('calls getChannelsSummary when modal opens', async () => {
+ render( );
+ await waitFor(() => {
+ expect(getChannelsSummary).toHaveBeenCalled();
+ });
+ });
+
+ it('does not call getChannelsSummary when modal is closed', () => {
+ render( );
+ expect(getChannelsSummary).not.toHaveBeenCalled();
+ });
+
+ it('handles getChannelsSummary rejection gracefully', async () => {
+ vi.mocked(getChannelsSummary).mockRejectedValue(new Error('Network error'));
+ render( );
+ // Should not throw; channel options just empty
+ await waitFor(() => {
+ expect(getChannelsSummary).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── TVG options ───────────────────────────────────────────────────────────
+
+ describe('EPG channel options', () => {
+ it('calls getTvgOptions with tvgs from store', () => {
+ render( );
+ expect(getTvgOptions).toHaveBeenCalledWith(mockTvgs);
+ });
+ });
+
+ // ── Preview ───────────────────────────────────────────────────────────────
+
+ describe('preview', () => {
+ it('does not call previewSeriesRule when title, description, and tvg_id are all empty', async () => {
+ render( );
+ await waitFor(() => expect(getChannelsSummary).toHaveBeenCalled());
+ expect(previewSeriesRule).not.toHaveBeenCalled();
+ });
+
+ it('calls previewSeriesRule when title is entered', async () => {
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'My Show' },
+ });
+ await waitFor(() => {
+ expect(previewSeriesRule).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'My Show' }),
+ expect.any(Object)
+ );
+ });
+ });
+
+ it('calls previewSeriesRule when description is entered', async () => {
+ render( );
+ fireEvent.change(screen.getByTestId('textarea-description'), {
+ target: { value: 'some words' },
+ });
+ await waitFor(() => {
+ expect(previewSeriesRule).toHaveBeenCalledWith(
+ expect.objectContaining({ description: 'some words' }),
+ expect.any(Object)
+ );
+ });
+ });
+
+ it('renders preview match results', async () => {
+ vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'Match Show' },
+ });
+ await waitFor(() => {
+ expect(screen.getByText(/Match Show/)).toBeInTheDocument();
+ });
+ });
+
+ it('renders preview badge with match count', async () => {
+ vi.mocked(previewSeriesRule).mockResolvedValue({ matches: [{ id: 'p1', title: 'X' }], total: 5, epg_found: true });
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'X' },
+ });
+ await waitFor(() => {
+ expect(screen.getByTestId('badge')).toHaveTextContent('1 of 5');
+ });
+ });
+
+ it('shows preview error alert when previewSeriesRule rejects', async () => {
+ vi.mocked(previewSeriesRule).mockRejectedValue(new Error('Preview failed'));
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'Boom' },
+ });
+ await waitFor(() => {
+ expect(screen.getByTestId('alert')).toBeInTheDocument();
+ expect(screen.getByTestId('alert')).toHaveTextContent('Preview failed');
+ });
+ });
+
+ it('shows "No EPG channel matches" warning when epg_found is false and tvg_id is set', async () => {
+ vi.mocked(previewSeriesRule).mockResolvedValue({
+ matches: [],
+ total: 0,
+ epg_found: false,
+ });
+ render( );
+ await waitFor(() => {
+ expect(screen.getByText(/No EPG channel matches this tvg_id/)).toBeInTheDocument();
+ });
+ });
+
+ it('shows warn alert when preview.warn is true', async () => {
+ vi.mocked(previewSeriesRule).mockResolvedValue({
+ matches: Array(50).fill({ id: 'x', title: 'X' }),
+ total: 50,
+ epg_found: true,
+ warn: true,
+ });
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'X' },
+ });
+ await waitFor(() => {
+ expect(screen.getByText(/This rule matches many programs/)).toBeInTheDocument();
+ });
+ });
+
+ it('shows "No matching upcoming programs" when matches empty and filter set', async () => {
+ vi.mocked(previewSeriesRule).mockResolvedValue({
+ matches: [],
+ total: 0,
+ epg_found: true,
+ });
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'Unknown Show' },
+ });
+ await waitFor(() => {
+ expect(
+ screen.getByText(/No matching upcoming programs/)
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('renders sub_title in preview match', async () => {
+ vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'Match Show' },
+ });
+ await waitFor(() => {
+ expect(screen.getByText(/Episode 1/)).toBeInTheDocument();
+ });
+ });
+
+ it('renders "(NEW)" marker for new episodes in preview', async () => {
+ vi.mocked(previewSeriesRule).mockResolvedValue(mockPreviewResponse);
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'Match Show' },
+ });
+ await waitFor(() => {
+ expect(screen.getByText(/(NEW)/)).toBeInTheDocument();
+ });
+ });
+ });
+
+ // ── handleSave ────────────────────────────────────────────────────────────
+
+ describe('handleSave', () => {
+ const renderWithTitle = () => {
+ render( );
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'My Show' },
+ });
+ };
+
+ it('calls createSeriesRule with correct payload', async () => {
+ renderWithTitle();
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ expect(createSeriesRule).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'My Show', mode: 'all' })
+ );
+ });
+ });
+
+ it('calls evaluateSeriesRulesByTvgId after createSeriesRule', async () => {
+ renderWithTitle();
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ expect(evaluateSeriesRulesByTvgId).toHaveBeenCalled();
+ });
+ });
+
+ it('calls fetchRecordings after save', async () => {
+ const { mockFetchRecordings } = setupMocks();
+ renderWithTitle();
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ expect(mockFetchRecordings).toHaveBeenCalled();
+ });
+ });
+
+ it('calls showNotification on success', async () => {
+ renderWithTitle();
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ expect(showNotification).toHaveBeenCalledWith(
+ expect.objectContaining({ title: 'Series rule saved' })
+ );
+ });
+ });
+
+ it('calls onSaved callback on success', async () => {
+ renderWithTitle();
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ expect(defaultProps.onSaved).toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose after save', async () => {
+ renderWithTitle();
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ expect(defaultProps.onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('does not call showNotification when createSeriesRule throws', async () => {
+ vi.mocked(createSeriesRule).mockRejectedValue(new Error('Server error'));
+ renderWithTitle();
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ expect(createSeriesRule).toHaveBeenCalled();
+ });
+ expect(showNotification).not.toHaveBeenCalled();
+ });
+
+ it('does not crash when evaluateSeriesRulesByTvgId throws', async () => {
+ vi.mocked(evaluateSeriesRulesByTvgId).mockRejectedValue(new Error('eval fail'));
+ renderWithTitle();
+ await expect(
+ waitFor(() => {
+ fireEvent.click(screen.getByText('Save rule'));
+ })
+ ).resolves.not.toThrow();
+ });
+
+ it('does not crash when fetchRecordings throws', async () => {
+ useChannelsStore.getState = vi.fn().mockReturnValue({
+ fetchRecordings: vi.fn().mockRejectedValue(new Error('fetch fail')),
+ });
+ renderWithTitle();
+ await expect(
+ waitFor(() => {
+ fireEvent.click(screen.getByText('Save rule'));
+ })
+ ).resolves.not.toThrow();
+ });
+
+ it('includes channel_id in payload when channelId is set', async () => {
+ vi.mocked(getChannelsSummary).mockResolvedValue(mockChannels);
+ render( );
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ expect(createSeriesRule).toHaveBeenCalledWith(
+ expect.objectContaining({ channel_id: 10 })
+ );
+ });
+ });
+
+ it('omits channel_id from payload when no channel selected', async () => {
+ renderWithTitle();
+ fireEvent.click(screen.getByText('Save rule'));
+ await waitFor(() => {
+ const call = vi.mocked(createSeriesRule).mock.calls[0][0];
+ expect(call).not.toHaveProperty('channel_id');
+ });
+ });
+ });
+
+ // ── Cancel button ─────────────────────────────────────────────────────────
+
+ describe('Cancel button', () => {
+ it('calls onClose when Cancel is clicked', () => {
+ render( );
+ fireEvent.click(screen.getByText('Cancel'));
+ expect(defaultProps.onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── SegmentedControl interactions ─────────────────────────────────────────
+
+ describe('SegmentedControl', () => {
+ it('changes title mode when SegmentedControl is clicked', () => {
+ render( );
+ fireEvent.click(screen.getAllByText('Contains').find((el) => el.closest('[data-testid="segmented-control"]')));
+ // No error thrown; state updated without crash
+ });
+
+ it('changes episode mode to "New only"', () => {
+ render( );
+ fireEvent.click(screen.getByText('New only'));
+ // Payload mode becomes 'new'; verify via save
+ fireEvent.change(screen.getByTestId('input-title'), {
+ target: { value: 'Test' },
+ });
+ fireEvent.click(screen.getByText('Save rule'));
+ waitFor(() => {
+ expect(createSeriesRule).toHaveBeenCalledWith(
+ expect.objectContaining({ mode: 'new' })
+ );
+ });
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/__tests__/Stream.test.jsx b/frontend/src/components/forms/__tests__/Stream.test.jsx
new file mode 100644
index 00000000..18fe3b75
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/Stream.test.jsx
@@ -0,0 +1,566 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Module-level form state (survives re-renders, accessible in beforeEach) ───
+const __form = { values: {}, resetSpy: null };
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/streamProfiles.jsx', () => ({ default: vi.fn() }));
+vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/forms/StreamUtils.js', () => ({
+ addStream: vi.fn(),
+ getResolver: vi.fn(() => undefined),
+ updateStream: vi.fn(),
+}));
+
+// ── react-hook-form ────────────────────────────────────────────────────────────
+vi.mock('react-hook-form', async () => {
+ const React = await import('react');
+ return {
+ useForm: vi.fn(({ defaultValues } = {}) => {
+ const [formValues, setFormValues] = React.useState(() => {
+ const vals = defaultValues || {};
+ Object.assign(__form.values, vals);
+ return vals;
+ });
+
+ const updateField = (name, value) => {
+ __form.values[name] = value;
+ setFormValues((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const register = (name) => ({
+ name,
+ value: __form.values[name] ?? '',
+ onChange: (e) => updateField(name, e.target.value),
+ onBlur: () => {},
+ });
+
+ const setValue = (name, value) => updateField(name, value);
+
+ const watch = (name) => formValues[name];
+
+ const handleSubmit = (onSubmit) => (e) => {
+ e?.preventDefault?.();
+ return onSubmit({ ...__form.values });
+ };
+
+ // Keep reset stable across re-renders so useEffect([defaultValues, reset])
+ // in Stream.jsx does not fire on every render and cause an infinite loop.
+ const resetImpl = React.useCallback((newValues) => {
+ const vals = newValues || defaultValues || {};
+ Object.assign(__form.values, vals);
+ setFormValues({ ...vals });
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const resetRef = React.useRef(null);
+ if (!resetRef.current) {
+ resetRef.current = vi.fn((...args) => resetImpl(...args));
+ __form.resetSpy = resetRef.current;
+ }
+ const reset = resetRef.current;
+
+ return {
+ register,
+ handleSubmit,
+ formState: { errors: {}, isSubmitting: false },
+ reset,
+ setValue,
+ watch,
+ };
+ }),
+ };
+});
+
+// ── @mantine/core ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Button: ({ children, type, disabled }) => (
+
+ {children}
+
+ ),
+ Flex: ({ children }) => {children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ ×
+
+ {children}
+
+ ) : null,
+ Select: ({ label, value, onChange, data, placeholder, error }) => (
+
+ {label}
+ onChange(e.target.value || null)}
+ >
+ {placeholder ?? `-- ${label} --`}
+ {(data ?? []).map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+ {error && {error} }
+
+ ),
+ TextInput: ({ label, name, value, onChange, error, ...rest }) => (
+
+ {label}
+
+ {error && {error} }
+
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import Stream from '../Stream';
+import useStreamProfilesStore from '../../../store/streamProfiles.jsx';
+import useChannelsStore from '../../../store/channels.jsx';
+import * as StreamUtils from '../../../utils/forms/StreamUtils.js';
+
+// ── Shared test data ───────────────────────────────────────────────────────────
+const mockProfiles = [
+ { id: 1, name: 'Default' },
+ { id: 2, name: 'HD Profile' },
+];
+
+const mockChannelGroups = {
+ 10: { id: 10, name: 'Sports' },
+ 20: { id: 20, name: 'News' },
+};
+
+const makeStream = (overrides = {}) => ({
+ id: 'stream-1',
+ name: 'Test Stream',
+ url: 'http://example.com/stream',
+ channel_group: 10,
+ stream_profile_id: 1,
+ ...overrides,
+});
+
+const setupMocks = () => {
+ vi.mocked(useStreamProfilesStore).mockImplementation((sel) =>
+ sel({ profiles: mockProfiles })
+ );
+ vi.mocked(useChannelsStore).mockImplementation((sel) =>
+ sel({ channelGroups: mockChannelGroups })
+ );
+ vi.mocked(StreamUtils.addStream).mockResolvedValue(undefined);
+ vi.mocked(StreamUtils.updateStream).mockResolvedValue(undefined);
+};
+
+const defaultProps = (overrides = {}) => ({
+ stream: null,
+ isOpen: true,
+ onClose: vi.fn(),
+ ...overrides,
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+
+describe('Stream', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Reset module-level form state before each test
+ __form.values = {};
+ setupMocks();
+ });
+
+ // ── 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();
+ });
+
+ it('renders "Stream" as the modal title', () => {
+ render( );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Stream');
+ });
+
+ it('calls onClose when modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Form fields ────────────────────────────────────────────────────────────
+
+ describe('form fields', () => {
+ it('renders Stream Name input', () => {
+ render( );
+ expect(screen.getByTestId('input-stream-name')).toBeInTheDocument();
+ });
+
+ it('renders Stream URL input', () => {
+ render( );
+ expect(screen.getByTestId('input-stream-url')).toBeInTheDocument();
+ });
+
+ it('renders Group select', () => {
+ render( );
+ expect(screen.getByTestId('select-group')).toBeInTheDocument();
+ });
+
+ it('renders Stream Profile select', () => {
+ render( );
+ expect(screen.getByTestId('select-stream-profile')).toBeInTheDocument();
+ });
+
+ it('renders Submit button', () => {
+ render( );
+ expect(screen.getByText('Submit')).toBeInTheDocument();
+ });
+
+ it('populates Group select with channel groups', () => {
+ render( );
+ expect(screen.getByText('Sports')).toBeInTheDocument();
+ expect(screen.getByText('News')).toBeInTheDocument();
+ });
+
+ it('populates Stream Profile select with profiles', () => {
+ render( );
+ expect(screen.getByText('Default')).toBeInTheDocument();
+ expect(screen.getByText('HD Profile')).toBeInTheDocument();
+ });
+
+ it('shows "Optional" placeholder for Stream Profile', () => {
+ render( );
+ expect(screen.getByText('Optional')).toBeInTheDocument();
+ });
+ });
+
+ // ── Default values ─────────────────────────────────────────────────────────
+
+ describe('default values', () => {
+ it('pre-fills name from stream prop', () => {
+ render( );
+ expect(screen.getByTestId('input-stream-name')).toHaveValue('Test Stream');
+ });
+
+ it('pre-fills url from stream prop', () => {
+ render( );
+ expect(screen.getByTestId('input-stream-url')).toHaveValue(
+ 'http://example.com/stream'
+ );
+ });
+
+ it('pre-selects group from stream prop', () => {
+ render( );
+ expect(screen.getByTestId('select-group')).toHaveValue('10');
+ });
+
+ it('pre-selects stream profile from stream prop', () => {
+ render( );
+ expect(screen.getByTestId('select-stream-profile')).toHaveValue('1');
+ });
+
+ it('renders empty name when no stream provided', () => {
+ render( );
+ expect(screen.getByTestId('input-stream-name')).toHaveValue('');
+ });
+
+ it('renders empty url when no stream provided', () => {
+ render( );
+ expect(screen.getByTestId('input-stream-url')).toHaveValue('');
+ });
+
+ it('renders empty group when stream has no channel_group', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('select-group')).toHaveValue('');
+ });
+
+ it('renders empty profile when stream has no stream_profile_id', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('select-stream-profile')).toHaveValue('');
+ });
+ });
+
+ // ── Create stream ──────────────────────────────────────────────────────────
+
+ describe('create stream (no existing stream)', () => {
+ it('calls addStream on submit when no stream id', async () => {
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-stream-name'), {
+ target: { value: 'New Stream' },
+ });
+ fireEvent.change(screen.getByTestId('input-stream-url'), {
+ target: { value: 'http://new.stream/live' },
+ });
+
+ fireEvent.submit(screen.getByRole('button', { name: 'Submit' }).closest('form'));
+
+ await waitFor(() => {
+ expect(StreamUtils.addStream).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 'New Stream',
+ url: 'http://new.stream/live',
+ })
+ );
+ });
+ });
+
+ it('does not call updateStream when creating', async () => {
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-stream-name'), {
+ target: { value: 'New Stream' },
+ });
+ fireEvent.change(screen.getByTestId('input-stream-url'), {
+ target: { value: 'http://new.stream' },
+ });
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(StreamUtils.updateStream).not.toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose after successful create', async () => {
+ const onClose = vi.fn();
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-stream-name'), {
+ target: { value: 'New Stream' },
+ });
+ fireEvent.change(screen.getByTestId('input-stream-url'), {
+ target: { value: 'http://new.stream' },
+ });
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('converts channel_group string to integer in payload', async () => {
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-stream-name'), {
+ target: { value: 'S' },
+ });
+ fireEvent.change(screen.getByTestId('input-stream-url'), {
+ target: { value: 'http://x.com' },
+ });
+ fireEvent.change(screen.getByTestId('select-group'), {
+ target: { value: '10' },
+ });
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(StreamUtils.addStream).toHaveBeenCalledWith(
+ expect.objectContaining({ channel_group: 10 })
+ );
+ });
+ });
+
+ it('sets channel_group to null when no group selected', async () => {
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-stream-name'), {
+ target: { value: 'S' },
+ });
+ fireEvent.change(screen.getByTestId('input-stream-url'), {
+ target: { value: 'http://x.com' },
+ });
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ const call = vi.mocked(StreamUtils.addStream).mock.calls[0][0];
+ expect(call.channel_group).toBeNull();
+ });
+ });
+
+ it('converts stream_profile_id string to integer in payload', async () => {
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-stream-name'), {
+ target: { value: 'S' },
+ });
+ fireEvent.change(screen.getByTestId('input-stream-url'), {
+ target: { value: 'http://x.com' },
+ });
+ fireEvent.change(screen.getByTestId('select-stream-profile'), {
+ target: { value: '2' },
+ });
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(StreamUtils.addStream).toHaveBeenCalledWith(
+ expect.objectContaining({ stream_profile_id: 2 })
+ );
+ });
+ });
+
+ it('sets stream_profile_id to null when no profile selected', async () => {
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-stream-name'), {
+ target: { value: 'S' },
+ });
+ fireEvent.change(screen.getByTestId('input-stream-url'), {
+ target: { value: 'http://x.com' },
+ });
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ const call = vi.mocked(StreamUtils.addStream).mock.calls[0][0];
+ expect(call.stream_profile_id).toBeNull();
+ });
+ });
+ });
+
+ // ── Update stream ──────────────────────────────────────────────────────────
+
+ describe('update stream (existing stream)', () => {
+ it('calls updateStream with the stream id on submit', async () => {
+ render( );
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(StreamUtils.updateStream).toHaveBeenCalledWith(
+ 'stream-1',
+ expect.objectContaining({
+ name: 'Test Stream',
+ url: 'http://example.com/stream',
+ })
+ );
+ });
+ });
+
+ it('does not call addStream when updating', async () => {
+ render( );
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(StreamUtils.addStream).not.toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose after successful update', async () => {
+ const onClose = vi.fn();
+ render( );
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('converts channel_group back to integer for update payload', async () => {
+ render( );
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(StreamUtils.updateStream).toHaveBeenCalledWith(
+ 'stream-1',
+ expect.objectContaining({ channel_group: 10 })
+ );
+ });
+ });
+
+ it('converts stream_profile_id back to integer for update payload', async () => {
+ render( );
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(StreamUtils.updateStream).toHaveBeenCalledWith(
+ 'stream-1',
+ expect.objectContaining({ stream_profile_id: 1 })
+ );
+ });
+ });
+ });
+
+ // ── getResolver ────────────────────────────────────────────────────────────
+
+ describe('getResolver', () => {
+ it('calls getResolver on mount', () => {
+ render( );
+ expect(StreamUtils.getResolver).toHaveBeenCalled();
+ });
+ });
+
+ // ── Form reset on stream change ────────────────────────────────────────────
+
+ describe('form reset', () => {
+ it('resets the form when stream prop changes', () => {
+ const { rerender } = render( );
+
+ rerender( );
+
+ expect(__form.resetSpy).toHaveBeenCalled();
+ });
+ });
+
+ // ── Submit button state ────────────────────────────────────────────────────
+
+ describe('submit button', () => {
+ it('Submit button is not disabled by default', () => {
+ render( );
+ expect(screen.getByText('Submit')).not.toBeDisabled();
+ });
+ });
+});
diff --git a/frontend/src/components/forms/__tests__/StreamProfile.test.jsx b/frontend/src/components/forms/__tests__/StreamProfile.test.jsx
new file mode 100644
index 00000000..50237950
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/StreamProfile.test.jsx
@@ -0,0 +1,607 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Module-level form state ────────────────────────────────────────────────────
+const __form = { values: {}, resetSpy: null };
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/userAgents', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/forms/StreamProfileUtils.js', () => ({
+ addStreamProfile: vi.fn(),
+ updateStreamProfile: vi.fn(),
+ getResolver: vi.fn(() => undefined),
+ toCommandSelection: vi.fn((cmd) => {
+ const builtins = ['ffmpeg', 'streamlink', 'cvlc', 'yt-dlp'];
+ return builtins.includes(cmd) ? cmd : '__custom__';
+ }),
+ 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…' },
+ ],
+ COMMAND_EXAMPLES: {
+ ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
+ streamlink: '{streamUrl} --http-header User-Agent={userAgent} best --stdout',
+ },
+}));
+
+// ── react-hook-form ────────────────────────────────────────────────────────────
+vi.mock('react-hook-form', async () => {
+ const React = await import('react');
+ return {
+ useForm: vi.fn(({ defaultValues } = {}) => {
+ const [formValues, setFormValues] = React.useState(() => {
+ const vals = defaultValues || {};
+ Object.assign(__form.values, vals);
+ return vals;
+ });
+
+ const updateField = (name, value) => {
+ __form.values[name] = value;
+ setFormValues((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const register = (name) => ({
+ name,
+ value: __form.values[name] ?? '',
+ onChange: (e) => updateField(name, e.target.value),
+ onBlur: () => {},
+ });
+
+ const setValue = (name, value) => updateField(name, value);
+ const watch = (name) => formValues[name];
+
+ const handleSubmit = (onSubmit) => (e) => {
+ e?.preventDefault?.();
+ return onSubmit({ ...__form.values });
+ };
+
+ const resetImpl = React.useCallback((newValues) => {
+ const vals = newValues || defaultValues || {};
+ Object.assign(__form.values, vals);
+ setFormValues({ ...vals });
+ }, []); // eslint-disable-line react-hooks/exhaustive-deps
+
+ const resetRef = React.useRef(null);
+ if (!resetRef.current) {
+ resetRef.current = vi.fn((...args) => resetImpl(...args));
+ __form.resetSpy = resetRef.current;
+ }
+
+ return {
+ register,
+ handleSubmit,
+ formState: { errors: {}, isSubmitting: false },
+ reset: resetRef.current,
+ setValue,
+ watch,
+ };
+ }),
+ };
+});
+
+// ── @mantine/core ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Button: ({ children, type, disabled }) => (
+
+ {children}
+
+ ),
+ Checkbox: ({ label, checked, onChange }) => (
+
+ {label}
+ onChange({ currentTarget: { checked: e.target.checked } })}
+ />
+
+ ),
+ Flex: ({ children }) => {children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
+ ×
+
+ {children}
+
+ ) : null,
+ Select: ({ label, value, onChange, data, placeholder, error, clearable, disabled, description }) => (
+
+
{label}
+ {description &&
{description}
}
+
onChange(e.target.value || (clearable ? null : ''))}
+ >
+ {placeholder ?? `-- ${label} --`}
+ {(data ?? []).map((opt) => (
+
+ {opt.label}
+
+ ))}
+
+ {error &&
{error} }
+
+ ),
+ Stack: ({ children }) => {children}
,
+ Textarea: ({ label, name, value, onChange, error, placeholder, description, disabled, ...rest }) => (
+
+
{label}
+ {description &&
{description}
}
+
+ {error &&
{error} }
+
+ ),
+ TextInput: ({ label, name, value, onChange, error, disabled, ...rest }) => (
+
+ {label}
+
+ {error && {error} }
+
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import StreamProfile from '../StreamProfile';
+import useUserAgentsStore from '../../../store/userAgents';
+import * as StreamProfileUtils from '../../../utils/forms/StreamProfileUtils.js';
+
+// ── Shared test data ───────────────────────────────────────────────────────────
+const mockUserAgents = [
+ { id: 1, name: 'Chrome' },
+ { id: 2, name: 'Firefox' },
+];
+
+const makeProfile = (overrides = {}) => ({
+ id: 'sp-1',
+ name: 'My Profile',
+ command: 'ffmpeg',
+ parameters: '-i {streamUrl}',
+ is_active: true,
+ user_agent: '1',
+ locked: false,
+ ...overrides,
+});
+
+const setupMocks = () => {
+ vi.mocked(useUserAgentsStore).mockImplementation((sel) =>
+ sel({ userAgents: mockUserAgents })
+ );
+ vi.mocked(StreamProfileUtils.addStreamProfile).mockResolvedValue(undefined);
+ vi.mocked(StreamProfileUtils.updateStreamProfile).mockResolvedValue(undefined);
+ vi.mocked(StreamProfileUtils.getResolver).mockReturnValue(undefined);
+ vi.mocked(StreamProfileUtils.toCommandSelection).mockImplementation((cmd) => {
+ const builtins = ['ffmpeg', 'streamlink', 'cvlc', 'yt-dlp'];
+ return builtins.includes(cmd) ? cmd : '__custom__';
+ });
+};
+
+const defaultProps = (overrides = {}) => ({
+ profile: null,
+ isOpen: true,
+ onClose: vi.fn(),
+ ...overrides,
+});
+
+describe('StreamProfile', () => {
+ beforeEach(() => {
+ vi.resetAllMocks();
+ __form.values = {};
+ __form.resetSpy = null;
+ setupMocks();
+ });
+
+ // ── Visibility ──────────────────────────────────────────────────────────────
+
+ describe('visibility', () => {
+ it('renders the modal when isOpen is true', () => {
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('does not render the modal when isOpen is false', () => {
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('renders "Stream Profile" as the modal title', () => {
+ render( );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('Stream Profile');
+ });
+
+ it('calls onClose when modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Form fields ─────────────────────────────────────────────────────────────
+
+ describe('form fields', () => {
+ it('renders Name input', () => {
+ render( );
+ expect(screen.getByTestId('input-name')).toBeInTheDocument();
+ });
+
+ it('renders Command select', () => {
+ render( );
+ expect(screen.getByTestId('select-command')).toBeInTheDocument();
+ });
+
+ it('renders Parameters textarea', () => {
+ render( );
+ expect(screen.getByTestId('textarea-parameters')).toBeInTheDocument();
+ });
+
+ it('renders User-Agent select', () => {
+ render( );
+ expect(screen.getByTestId('select-user-agent')).toBeInTheDocument();
+ });
+
+ it('renders Is Active checkbox', () => {
+ render( );
+ expect(screen.getByTestId('checkbox-is-active')).toBeInTheDocument();
+ });
+
+ it('renders Save button', () => {
+ render( );
+ expect(screen.getByText('Save')).toBeInTheDocument();
+ });
+
+ it('populates Command select with built-in options', () => {
+ render( );
+ expect(screen.getByText('FFmpeg')).toBeInTheDocument();
+ expect(screen.getByText('Streamlink')).toBeInTheDocument();
+ expect(screen.getAllByText('Custom…').length).toBeGreaterThan(0);
+ });
+
+ it('populates User-Agent select with user agents', () => {
+ render( );
+ expect(screen.getByText('Chrome')).toBeInTheDocument();
+ expect(screen.getByText('Firefox')).toBeInTheDocument();
+ });
+ });
+
+ // ── Default values ──────────────────────────────────────────────────────────
+
+ describe('default values', () => {
+ it('pre-fills name from profile prop', () => {
+ render( );
+ expect(screen.getByTestId('input-name')).toHaveValue('My Profile');
+ });
+
+ it('pre-fills parameters from profile prop', () => {
+ render( );
+ expect(screen.getByTestId('textarea-parameters')).toHaveValue('-i {streamUrl}');
+ });
+
+ it('pre-selects command from profile prop', () => {
+ render( );
+ expect(screen.getByTestId('select-command')).toHaveValue('ffmpeg');
+ });
+
+ it('pre-selects user-agent from profile prop', () => {
+ render( );
+ expect(screen.getByTestId('select-user-agent')).toHaveValue('1');
+ });
+
+ it('checkbox is checked when is_active is true', () => {
+ render( );
+ expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
+ });
+
+ it('checkbox is unchecked when is_active is false', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
+ });
+
+ it('defaults name to empty string when no profile', () => {
+ render( );
+ expect(screen.getByTestId('input-name')).toHaveValue('');
+ });
+
+ it('defaults parameters to empty string when no profile', () => {
+ render( );
+ expect(screen.getByTestId('textarea-parameters')).toHaveValue('');
+ });
+ });
+
+ // ── Command selection ───────────────────────────────────────────────────────
+
+ describe('command selection', () => {
+ it('does not show Custom Command input when a built-in is selected', () => {
+ render( );
+ expect(screen.queryByTestId('input-custom-command')).not.toBeInTheDocument();
+ });
+
+ it('shows Custom Command input when Custom… is selected', () => {
+ render( );
+ fireEvent.change(screen.getByTestId('select-command'), {
+ target: { value: '__custom__' },
+ });
+ expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
+ });
+
+ it('hides Custom Command input when switching from custom back to built-in', () => {
+ render( );
+ fireEvent.change(screen.getByTestId('select-command'), {
+ target: { value: '__custom__' },
+ });
+ fireEvent.change(screen.getByTestId('select-command'), {
+ target: { value: 'ffmpeg' },
+ });
+ expect(screen.queryByTestId('input-custom-command')).not.toBeInTheDocument();
+ });
+
+ it('shows Custom Command input when profile has a custom command', () => {
+ vi.mocked(StreamProfileUtils.toCommandSelection).mockReturnValue('__custom__');
+ render(
+
+ );
+ expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
+ });
+
+ it('sets command form value when built-in is selected', () => {
+ render( );
+ fireEvent.change(screen.getByTestId('select-command'), {
+ target: { value: 'streamlink' },
+ });
+ expect(__form.values.command).toBe('streamlink');
+ });
+
+ it('clears command form value when Custom… is selected', () => {
+ render( );
+ fireEvent.change(screen.getByTestId('select-command'), {
+ target: { value: '__custom__' },
+ });
+ expect(__form.values.command).toBe('');
+ });
+
+ it('shows parameters example hint for ffmpeg', () => {
+ const { container } = render(
+
+ );
+ expect(container.textContent).toMatch(/-user_agent \{userAgent\}/);
+ });
+ });
+
+ // ── Is Active checkbox ──────────────────────────────────────────────────────
+
+ describe('Is Active checkbox', () => {
+ it('toggles is_active to false when unchecked', () => {
+ render( );
+ fireEvent.click(screen.getByTestId('checkbox-is-active'));
+ expect(__form.values.is_active).toBe(false);
+ });
+
+ it('toggles is_active to true when checked', () => {
+ render(
+
+ );
+ fireEvent.click(screen.getByTestId('checkbox-is-active'));
+ expect(__form.values.is_active).toBe(true);
+ });
+ });
+
+ // ── User-Agent select ───────────────────────────────────────────────────────
+
+ describe('User-Agent select', () => {
+ it('updates user_agent form value when changed', () => {
+ render( );
+ fireEvent.change(screen.getByTestId('select-user-agent'), {
+ target: { value: '2' },
+ });
+ expect(__form.values.user_agent).toBe('2');
+ });
+
+ it('sets user_agent to empty string when cleared', () => {
+ render( );
+ fireEvent.change(screen.getByTestId('select-user-agent'), {
+ target: { value: '' },
+ });
+ expect(__form.values.user_agent).toBe('');
+ });
+ });
+
+ // ── Locked profile ──────────────────────────────────────────────────────────
+
+ describe('locked profile', () => {
+ it('disables Name input when profile is locked', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('input-name')).toBeDisabled();
+ });
+
+ it('disables Command select when profile is locked', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('select-command')).toBeDisabled();
+ });
+
+ it('disables Parameters textarea when profile is locked', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('textarea-parameters')).toBeDisabled();
+ });
+
+ it('does not disable inputs when profile is not locked', () => {
+ render( );
+ expect(screen.getByTestId('input-name')).not.toBeDisabled();
+ });
+
+ it('does not disable inputs when no profile is provided', () => {
+ render( );
+ expect(screen.getByTestId('input-name')).not.toBeDisabled();
+ });
+ });
+
+ // ── Create profile ──────────────────────────────────────────────────────────
+
+ describe('create profile (no existing profile)', () => {
+ it('calls addStreamProfile on submit when no profile id', async () => {
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-name'), {
+ target: { value: 'New Profile' },
+ });
+ fireEvent.change(screen.getByTestId('select-command'), {
+ target: { value: 'ffmpeg' },
+ });
+
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+
+ await waitFor(() => {
+ expect(StreamProfileUtils.addStreamProfile).toHaveBeenCalledWith(
+ expect.objectContaining({ name: 'New Profile' })
+ );
+ });
+ });
+
+ it('does not call updateStreamProfile when creating', async () => {
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-name'), {
+ target: { value: 'New Profile' },
+ });
+
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+
+ await waitFor(() => {
+ expect(StreamProfileUtils.updateStreamProfile).not.toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose after successful create', async () => {
+ const onClose = vi.fn();
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-name'), {
+ target: { value: 'New Profile' },
+ });
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Update profile ──────────────────────────────────────────────────────────
+
+ describe('update profile (existing profile)', () => {
+ it('calls updateStreamProfile with the profile id on submit', async () => {
+ render( );
+
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+
+ await waitFor(() => {
+ expect(StreamProfileUtils.updateStreamProfile).toHaveBeenCalledWith(
+ 'sp-1',
+ expect.objectContaining({ name: 'My Profile' })
+ );
+ });
+ });
+
+ it('does not call addStreamProfile when updating', async () => {
+ render( );
+
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+
+ await waitFor(() => {
+ expect(StreamProfileUtils.addStreamProfile).not.toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose after successful update', async () => {
+ const onClose = vi.fn();
+ render(
+
+ );
+
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Form reset ──────────────────────────────────────────────────────────────
+
+ describe('form reset', () => {
+ it('resets the form when profile prop changes', () => {
+ const { rerender } = render( );
+
+ rerender( );
+
+ expect(__form.resetSpy).toHaveBeenCalled();
+ });
+
+ it('calls reset after successful submit', async () => {
+ render( );
+
+ fireEvent.submit(screen.getByText('Save').closest('form'));
+
+ await waitFor(() => {
+ expect(__form.resetSpy).toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── getResolver ─────────────────────────────────────────────────────────────
+
+ describe('getResolver', () => {
+ it('calls getResolver on mount', () => {
+ render( );
+ expect(StreamProfileUtils.getResolver).toHaveBeenCalled();
+ });
+ });
+});
+
diff --git a/frontend/src/components/forms/__tests__/SuperuserForm.test.jsx b/frontend/src/components/forms/__tests__/SuperuserForm.test.jsx
new file mode 100644
index 00000000..b9b0dcbb
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/SuperuserForm.test.jsx
@@ -0,0 +1,332 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Asset mock ─────────────────────────────────────────────────────────────────
+vi.mock('../../../assets/logo.png', () => ({ default: 'logo.png' }));
+
+// ── API mock ───────────────────────────────────────────────────────────────────
+vi.mock('../../../api', () => ({
+ default: {
+ createSuperUser: vi.fn(),
+ },
+}));
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/auth', () => ({ default: vi.fn() }));
+vi.mock('../../../store/settings', () => ({ default: vi.fn() }));
+
+// ── @mantine/core ──────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Button: ({ children, type, fullWidth }) => (
+
+ {children}
+
+ ),
+ Center: ({ children, style }) => {children}
,
+ Divider: ({ style }) => ,
+ Image: ({ src, alt }) => ,
+ Paper: ({ children, style }) => {children}
,
+ Stack: ({ children }) => {children}
,
+ Text: ({ children, size, color, align }) => (
+
+ {children}
+
+ ),
+ TextInput: ({ label, name, value, onChange, required, type }) => (
+
+ {label}
+
+
+ ),
+ Title: ({ children, order, align }) => {
+ const Tag = `h${order ?? 1}`;
+ return {children} ;
+ },
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import SuperuserForm from '../SuperuserForm';
+import API from '../../../api';
+import useAuthStore from '../../../store/auth';
+import useSettingsStore from '../../../store/settings';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const setupMocks = ({
+ version = {},
+ fetchVersion = vi.fn(),
+ setSuperuserExists = vi.fn(),
+} = {}) => {
+ vi.mocked(useAuthStore).mockImplementation((sel) =>
+ sel({ setSuperuserExists })
+ );
+ vi.mocked(useSettingsStore).mockImplementation((sel) =>
+ sel({ fetchVersion, version })
+ );
+ return { fetchVersion, setSuperuserExists };
+};
+
+describe('SuperuserForm', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ // ── Rendering ──────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders the Dispatcharr title', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByText('Dispatcharr')).toBeInTheDocument();
+ });
+
+ it('renders the welcome message', () => {
+ setupMocks();
+ render( );
+ expect(
+ screen.getByText(
+ 'Welcome! Create your Super User Account to get started.'
+ )
+ ).toBeInTheDocument();
+ });
+
+ it('renders the logo image', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByAltText('Dispatcharr Logo')).toBeInTheDocument();
+ });
+
+ it('renders Username input', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('input-username')).toBeInTheDocument();
+ });
+
+ it('renders Password input with type="password"', () => {
+ setupMocks();
+ render( );
+ const input = screen.getByTestId('input-password');
+ expect(input).toBeInTheDocument();
+ expect(input).toHaveAttribute('type', 'password');
+ });
+
+ it('renders Email input with type="email"', () => {
+ setupMocks();
+ render( );
+ const input = screen.getByTestId('input-email');
+ expect(input).toBeInTheDocument();
+ expect(input).toHaveAttribute('type', 'email');
+ });
+
+ it('renders the Create Account button', () => {
+ setupMocks();
+ render( );
+ expect(
+ screen.getByRole('button', { name: 'Create Account' })
+ ).toBeInTheDocument();
+ });
+
+ it('does not render version text when version is not loaded', () => {
+ setupMocks({ version: {} });
+ render( );
+ expect(screen.queryByText(/^v/)).not.toBeInTheDocument();
+ });
+
+ it('renders version text when version is loaded', () => {
+ setupMocks({ version: { version: '1.2.3' } });
+ render( );
+ expect(screen.getByText('v1.2.3')).toBeInTheDocument();
+ });
+ });
+
+ // ── useEffect ─────────────────────────────────────────────────────────────
+
+ describe('useEffect', () => {
+ it('calls fetchVersion on mount', () => {
+ const { fetchVersion } = setupMocks();
+ render( );
+ expect(fetchVersion).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not call fetchVersion again on re-render with same fetchVersion ref', () => {
+ const { fetchVersion } = setupMocks();
+ const { rerender } = render( );
+ rerender( );
+ // fetchVersion is stable, so useEffect should only fire once
+ expect(fetchVersion).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ // ── Form field interactions ────────────────────────────────────────────────
+
+ describe('form field interactions', () => {
+ it('updates username field when typed', () => {
+ setupMocks();
+ render( );
+ fireEvent.change(screen.getByTestId('input-username'), {
+ target: { name: 'username', value: 'admin' },
+ });
+ expect(screen.getByTestId('input-username')).toHaveValue('admin');
+ });
+
+ it('updates password field when typed', () => {
+ setupMocks();
+ render( );
+ fireEvent.change(screen.getByTestId('input-password'), {
+ target: { name: 'password', value: 'secret123' },
+ });
+ expect(screen.getByTestId('input-password')).toHaveValue('secret123');
+ });
+
+ it('updates email field when typed', () => {
+ setupMocks();
+ render( );
+ fireEvent.change(screen.getByTestId('input-email'), {
+ target: { name: 'email', value: 'admin@example.com' },
+ });
+ expect(screen.getByTestId('input-email')).toHaveValue('admin@example.com');
+ });
+
+ it('initializes all fields as empty strings', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('input-username')).toHaveValue('');
+ expect(screen.getByTestId('input-password')).toHaveValue('');
+ expect(screen.getByTestId('input-email')).toHaveValue('');
+ });
+ });
+
+ // ── Form submission ────────────────────────────────────────────────────────
+
+ describe('form submission', () => {
+ it('calls API.createSuperUser with form values on submit', async () => {
+ setupMocks();
+ vi.mocked(API.createSuperUser).mockResolvedValue({
+ superuser_exists: false,
+ });
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-username'), {
+ target: { name: 'username', value: 'admin' },
+ });
+ fireEvent.change(screen.getByTestId('input-password'), {
+ target: { name: 'password', value: 'secret' },
+ });
+ fireEvent.change(screen.getByTestId('input-email'), {
+ target: { name: 'email', value: 'admin@test.com' },
+ });
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Create Account' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(API.createSuperUser).toHaveBeenCalledWith({
+ username: 'admin',
+ password: 'secret',
+ email: 'admin@test.com',
+ });
+ });
+ });
+
+ it('calls setSuperuserExists(true) when response.superuser_exists is true', async () => {
+ const { setSuperuserExists } = setupMocks();
+ vi.mocked(API.createSuperUser).mockResolvedValue({
+ superuser_exists: true,
+ });
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-username'), {
+ target: { name: 'username', value: 'admin' },
+ });
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Create Account' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(setSuperuserExists).toHaveBeenCalledWith(true);
+ });
+ });
+
+ it('does not call setSuperuserExists when response.superuser_exists is false', async () => {
+ const { setSuperuserExists } = setupMocks();
+ vi.mocked(API.createSuperUser).mockResolvedValue({
+ superuser_exists: false,
+ });
+ render( );
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Create Account' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(API.createSuperUser).toHaveBeenCalled();
+ });
+
+ expect(setSuperuserExists).not.toHaveBeenCalled();
+ });
+
+ it('does not throw when API.createSuperUser rejects', async () => {
+ setupMocks();
+ vi.mocked(API.createSuperUser).mockRejectedValue(new Error('Network'));
+ render( );
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Create Account' }).closest('form')
+ );
+
+ await expect(
+ waitFor(() => expect(API.createSuperUser).toHaveBeenCalled())
+ ).resolves.not.toThrow();
+ });
+
+ it('does not call setSuperuserExists when API throws', async () => {
+ const { setSuperuserExists } = setupMocks();
+ vi.mocked(API.createSuperUser).mockRejectedValue(new Error('Network'));
+ render( );
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Create Account' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(API.createSuperUser).toHaveBeenCalled();
+ });
+
+ expect(setSuperuserExists).not.toHaveBeenCalled();
+ });
+
+ it('submits with empty email when email field is left blank', async () => {
+ setupMocks();
+ vi.mocked(API.createSuperUser).mockResolvedValue({
+ superuser_exists: false,
+ });
+ render( );
+
+ fireEvent.change(screen.getByTestId('input-username'), {
+ target: { name: 'username', value: 'admin' },
+ });
+ fireEvent.change(screen.getByTestId('input-password'), {
+ target: { name: 'password', value: 'secret' },
+ });
+
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Create Account' }).closest('form')
+ );
+
+ await waitFor(() => {
+ expect(API.createSuperUser).toHaveBeenCalledWith(
+ expect.objectContaining({ email: '' })
+ );
+ });
+ });
+ });
+});
+
diff --git a/frontend/src/components/forms/__tests__/User.test.jsx b/frontend/src/components/forms/__tests__/User.test.jsx
new file mode 100644
index 00000000..f73c6a81
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/User.test.jsx
@@ -0,0 +1,686 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Constants ──────────────────────────────────────────────────────────────────
+vi.mock('../../../constants', () => ({
+ USER_LEVELS: { ADMIN: 1, STREAMER: 2, USER: 3 },
+ USER_LEVEL_LABELS: { 1: 'Admin', 2: 'Streamer', 3: 'User' },
+}));
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+vi.mock('../../../store/channels', () => ({ default: vi.fn() }));
+vi.mock('../../../store/outputProfiles', () => ({ default: vi.fn() }));
+vi.mock('../../../store/auth', () => ({ default: vi.fn() }));
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils', () => ({ copyToClipboard: vi.fn() }));
+
+vi.mock('../../../utils/forms/UserUtils.js', () => ({
+ createUser: vi.fn(),
+ updateUser: vi.fn(),
+ generateApiKey: vi.fn(),
+ revokeApiKey: vi.fn(),
+ formValuesToPayload: vi.fn(),
+ getFormInitialValues: vi.fn(() => ({})),
+ getFormValidators: vi.fn(() => ({})),
+ userToFormValues: vi.fn(() => ({})),
+}));
+
+// ── Mantine form ───────────────────────────────────────────────────────────────
+const mockForm = {
+ getInputProps: vi.fn(() => ({})),
+ key: vi.fn((k) => k),
+ setValues: vi.fn(),
+ setFieldValue: vi.fn(),
+ reset: vi.fn(),
+ getValues: vi.fn(() => ({ user_level: '3' })),
+ onSubmit: vi.fn((fn) => (e) => { e?.preventDefault?.(); return fn(); }),
+ submitting: false,
+};
+
+vi.mock('@mantine/form', () => ({
+ useForm: vi.fn(() => mockForm),
+}));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ Copy: () => ,
+ Key: () => ,
+ RotateCcwKey: () => ,
+ X: () => ,
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ ActionIcon: ({ children, onClick, disabled }) => (
+
+ {children}
+
+ ),
+ Button: ({ children, onClick, disabled, loading, type, leftSection }) => (
+
+ {leftSection}
+ {children}
+
+ ),
+ Group: ({ children }) => {children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
×
+ {children}
+
+ ) : null,
+ MultiSelect: ({ label, onChange }) => (
+
+ {label}
+ onChange?.(e.target.value ? e.target.value.split(',') : [])}
+ />
+
+ ),
+ NumberInput: ({ label }) => {label}
,
+ PasswordInput: ({ label, disabled }) => (
+ {label}
+ ),
+ Select: ({ label, disabled }) => (
+ {label}
+ ),
+ Stack: ({ children }) => {children}
,
+ Switch: ({ label }) => {label}
,
+ Tabs: ({ children }) => {children}
,
+ TabsList: ({ children }) => {children}
,
+ TabsPanel: ({ children, value }) => {children}
,
+ TabsTab: ({ children, value }) => {children} ,
+ TagsInput: ({ label }) => {label}
,
+ Text: ({ children }) => {children} ,
+ TextInput: ({ label, value, disabled, rightSection }) => (
+
+ {label}
+
+ {rightSection}
+
+ ),
+ useMantineTheme: () => ({
+ colors: { red: Array(10).fill('#ff0000') },
+ }),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import useChannelsStore from '../../../store/channels';
+import useOutputProfilesStore from '../../../store/outputProfiles';
+import useAuthStore from '../../../store/auth';
+import * as UserUtils from '../../../utils/forms/UserUtils.js';
+import { copyToClipboard } from '../../../utils';
+import User from '../User';
+
+// ── Factories ──────────────────────────────────────────────────────────────────
+const makeAdminUser = (overrides = {}) => ({
+ id: 1,
+ username: 'admin',
+ user_level: 1,
+ api_key: null,
+ ...overrides,
+});
+
+const makeRegularUser = (overrides = {}) => ({
+ id: 2,
+ username: 'user1',
+ user_level: 3,
+ api_key: null,
+ ...overrides,
+});
+
+const setupMocks = ({
+ authUser = makeAdminUser(),
+ profiles = {},
+ outputProfiles = [],
+} = {}) => {
+ const mockSetUser = vi.fn();
+
+ vi.mocked(useChannelsStore).mockImplementation((sel) => sel({ profiles }));
+ vi.mocked(useOutputProfilesStore).mockImplementation((sel) =>
+ sel({ profiles: outputProfiles })
+ );
+ vi.mocked(useAuthStore).mockImplementation((sel) =>
+ sel({ user: authUser, setUser: mockSetUser })
+ );
+
+ // Reset form state
+ mockForm.getValues.mockReturnValue({ user_level: '3' });
+ mockForm.onSubmit.mockImplementation(
+ (fn) => (e) => { e?.preventDefault?.(); return fn(); }
+ );
+
+ return { mockSetUser };
+};
+
+// ──────────────────────────────────────────────────────────────────────────────
+
+describe('User', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(UserUtils.createUser).mockResolvedValue({});
+ vi.mocked(UserUtils.updateUser).mockResolvedValue({});
+ vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'new-api-key' });
+ vi.mocked(UserUtils.revokeApiKey).mockResolvedValue({ success: true });
+ vi.mocked(UserUtils.formValuesToPayload).mockReturnValue({ user_level: 3 });
+ vi.mocked(UserUtils.getFormInitialValues).mockReturnValue({});
+ vi.mocked(UserUtils.getFormValidators).mockReturnValue({});
+ vi.mocked(UserUtils.userToFormValues).mockReturnValue({});
+ });
+
+ // ── Visibility ───────────────────────────────────────────────────────────────
+
+ describe('visibility', () => {
+ it('renders nothing when isOpen is false', () => {
+ setupMocks();
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('renders the modal when isOpen is true', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('calls onClose when the modal close button is clicked', () => {
+ setupMocks();
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Tabs ─────────────────────────────────────────────────────────────────────
+
+ describe('tabs', () => {
+ it('always renders Account, EPG, and API tabs', () => {
+ setupMocks();
+ render( );
+ expect(screen.getByTestId('tab-account')).toBeInTheDocument();
+ expect(screen.getByTestId('tab-epg')).toBeInTheDocument();
+ expect(screen.getByTestId('tab-api')).toBeInTheDocument();
+ });
+
+ it('shows Permissions tab when admin edits another user', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+ expect(screen.getByTestId('tab-permissions')).toBeInTheDocument();
+ });
+
+ it('hides Permissions tab when admin edits themselves', () => {
+ const admin = makeAdminUser();
+ setupMocks({ authUser: admin });
+ render( );
+ expect(screen.queryByTestId('tab-permissions')).not.toBeInTheDocument();
+ });
+
+ it('hides Permissions tab for non-admin user', () => {
+ setupMocks({ authUser: makeRegularUser() });
+ render(
+
+ );
+ expect(screen.queryByTestId('tab-permissions')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Admin-only fields ────────────────────────────────────────────────────────
+
+ describe('admin-only fields', () => {
+ it('shows Output Format Override for admin', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+ expect(screen.getByText('Output Format Override')).toBeInTheDocument();
+ });
+
+ it('hides Output Format Override for non-admin', () => {
+ setupMocks({ authUser: makeRegularUser() });
+ render(
+
+ );
+ expect(screen.queryByText('Output Format Override')).not.toBeInTheDocument();
+ });
+
+ it('shows Output Profile Override for admin', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+ expect(screen.getByText('Output Profile Override')).toBeInTheDocument();
+ });
+
+ it('shows Allowed IPs for admin', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+ expect(screen.getByText('Allowed IPs')).toBeInTheDocument();
+ });
+
+ it('hides Allowed IPs for non-admin', () => {
+ setupMocks({ authUser: makeRegularUser() });
+ render(
+
+ );
+ expect(screen.queryByText('Allowed IPs')).not.toBeInTheDocument();
+ });
+ });
+
+ // ── API key generation ────────────────────────────────────────────────────────
+
+ describe('API key generation', () => {
+ it('shows "Generate API Key" when no key exists', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+ expect(screen.getByText('Generate API Key')).toBeInTheDocument();
+ });
+
+ it('shows "Regenerate API Key" when user already has an api_key', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+ expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
+ });
+
+ it('calls generateApiKey and switches button to "Regenerate API Key"', async () => {
+ setupMocks({ authUser: makeAdminUser() });
+ vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'brand-new-key' });
+
+ render(
+
+ );
+
+ expect(screen.getByText('Generate API Key')).toBeInTheDocument();
+ fireEvent.click(screen.getByText('Generate API Key'));
+
+ await waitFor(() => {
+ expect(UserUtils.generateApiKey).toHaveBeenCalledWith({ user_id: 2 });
+ expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
+ });
+ });
+
+ it('also sets the key when response contains raw_key instead of key', async () => {
+ setupMocks({ authUser: makeAdminUser() });
+ vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ raw_key: 'raw-value' });
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Generate API Key'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Regenerate API Key')).toBeInTheDocument();
+ });
+ });
+
+ it('does not call generateApiKey when canGenerateKey is false', async () => {
+ // non-admin editing someone else
+ setupMocks({ authUser: makeRegularUser({ id: 5 }) });
+ render(
+
+ );
+ // No button visible since canGenerateKey is false
+ expect(screen.queryByText('Generate API Key')).not.toBeInTheDocument();
+ expect(UserUtils.generateApiKey).not.toHaveBeenCalled();
+ });
+
+ it('does not update key when generateApiKey returns a response without key/raw_key', async () => {
+ setupMocks({ authUser: makeAdminUser() });
+ vi.mocked(UserUtils.generateApiKey).mockResolvedValue({});
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Generate API Key'));
+
+ await waitFor(() => {
+ // button text should NOT change since no key was received
+ expect(screen.getByText('Generate API Key')).toBeInTheDocument();
+ });
+ });
+
+ it('shows the generated API key in a text input', async () => {
+ setupMocks({ authUser: makeAdminUser() });
+ vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'show-me' });
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Generate API Key'));
+
+ await waitFor(() => {
+ const keyInput = screen.getByTestId('textinput-API Key');
+ expect(keyInput).toHaveValue('show-me');
+ });
+ });
+ });
+
+ // ── API key revocation ───────────────────────────────────────────────────────
+
+ describe('API key revocation', () => {
+ it('shows "Revoke API Key" when a key exists', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+ expect(screen.getByText('Revoke API Key')).toBeInTheDocument();
+ });
+
+ it('hides "Revoke API Key" when no key exists', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+ expect(screen.queryByText('Revoke API Key')).not.toBeInTheDocument();
+ });
+
+ it('calls revokeApiKey with user_id and clears the key on success', async () => {
+ setupMocks({ authUser: makeAdminUser() });
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Revoke API Key'));
+
+ await waitFor(() => {
+ expect(UserUtils.revokeApiKey).toHaveBeenCalledWith({ user_id: 2 });
+ expect(screen.queryByText('Revoke API Key')).not.toBeInTheDocument();
+ expect(screen.getByText('Generate API Key')).toBeInTheDocument();
+ });
+ });
+
+ it('updates auth store when admin revokes their own key', async () => {
+ const admin = makeAdminUser({ id: 1, api_key: 'my-key' });
+ const { mockSetUser } = setupMocks({ authUser: admin });
+
+ render( );
+ fireEvent.click(screen.getByText('Revoke API Key'));
+
+ await waitFor(() => {
+ expect(mockSetUser).toHaveBeenCalledWith(
+ expect.objectContaining({ api_key: null })
+ );
+ });
+ });
+
+ it('does not clear key when revokeApiKey returns success: false', async () => {
+ setupMocks({ authUser: makeAdminUser() });
+ vi.mocked(UserUtils.revokeApiKey).mockResolvedValue({ success: false });
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Revoke API Key'));
+
+ await waitFor(() => {
+ expect(screen.getByText('Revoke API Key')).toBeInTheDocument();
+ });
+ });
+
+ it('does not update auth store when revoking another user\'s key', async () => {
+ const admin = makeAdminUser({ id: 1 });
+ const { mockSetUser } = setupMocks({ authUser: admin });
+
+ render(
+
+ );
+
+ fireEvent.click(screen.getByText('Revoke API Key'));
+
+ await waitFor(() => {
+ expect(mockSetUser).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ // ── Copy to clipboard ────────────────────────────────────────────────────────
+
+ describe('copy API key to clipboard', () => {
+ it('calls copyToClipboard with the current key', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+
+ const copyButton = screen.getByTestId('icon-copy').closest('button');
+ fireEvent.click(copyButton);
+
+ expect(copyToClipboard).toHaveBeenCalledWith(
+ 'copy-me',
+ expect.objectContaining({ successTitle: 'API Key Copied!' })
+ );
+ });
+ });
+
+ // ── Form submission ──────────────────────────────────────────────────────────
+
+ describe('form submission', () => {
+ it('calls createUser when no user prop is given', async () => {
+ setupMocks({ authUser: makeAdminUser() });
+
+ render( );
+ fireEvent.click(screen.getByText('Save'));
+
+ await waitFor(() => {
+ expect(UserUtils.createUser).toHaveBeenCalled();
+ });
+ });
+
+ it('calls updateUser when editing an existing user', async () => {
+ const admin = makeAdminUser();
+ setupMocks({ authUser: admin });
+ vi.mocked(UserUtils.updateUser).mockResolvedValue({ id: 2 });
+
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Save'));
+
+ await waitFor(() => {
+ expect(UserUtils.updateUser).toHaveBeenCalledWith(
+ 2,
+ expect.any(Object),
+ true, // isAdmin
+ admin
+ );
+ });
+ });
+
+ it('updates auth store when admin saves their own profile', async () => {
+ const admin = makeAdminUser({ id: 1 });
+ const updatedAdmin = { ...admin, email: 'new@example.com' };
+ const { mockSetUser } = setupMocks({ authUser: admin });
+ vi.mocked(UserUtils.updateUser).mockResolvedValue(updatedAdmin);
+
+ render( );
+ fireEvent.click(screen.getByText('Save'));
+
+ await waitFor(() => {
+ expect(mockSetUser).toHaveBeenCalledWith(updatedAdmin);
+ });
+ });
+
+ it('resets form and calls onClose after successful submission', async () => {
+ setupMocks({ authUser: makeAdminUser() });
+ const onClose = vi.fn();
+
+ render( );
+ fireEvent.click(screen.getByText('Save'));
+
+ await waitFor(() => {
+ expect(mockForm.reset).toHaveBeenCalled();
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('removes password from payload when updating and payload.password is falsy', async () => {
+ const admin = makeAdminUser();
+ setupMocks({ authUser: admin });
+ vi.mocked(UserUtils.formValuesToPayload).mockReturnValue({
+ user_level: 3,
+ password: '',
+ });
+ vi.mocked(UserUtils.updateUser).mockResolvedValue({ id: 2 });
+
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Save'));
+
+ await waitFor(() => {
+ const payload = vi.mocked(UserUtils.updateUser).mock.calls[0][1];
+ expect(payload).not.toHaveProperty('password');
+ });
+ });
+ });
+
+ // ── useEffect ────────────────────────────────────────────────────────────────
+
+ describe('useEffect on user prop', () => {
+ it('calls userToFormValues and sets form values when user has an id', () => {
+ const user = makeRegularUser({ id: 2 });
+ vi.mocked(UserUtils.userToFormValues).mockReturnValue({ username: 'user1' });
+ setupMocks({ authUser: makeAdminUser() });
+
+ render( );
+
+ expect(UserUtils.userToFormValues).toHaveBeenCalledWith(user);
+ expect(mockForm.setValues).toHaveBeenCalledWith({ username: 'user1' });
+ });
+
+ it('resets form when no user is provided', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render( );
+ expect(mockForm.reset).toHaveBeenCalled();
+ });
+ });
+
+ // ── XC password generation ───────────────────────────────────────────────────
+
+ describe('XC password generation', () => {
+ it('calls setValues with a generated xc_password when rotate icon is clicked', () => {
+ setupMocks({ authUser: makeAdminUser() });
+ render(
+
+ );
+
+ const rotateButton = screen
+ .getByTestId('icon-rotate-ccw-key')
+ .closest('button');
+ fireEvent.click(rotateButton);
+
+ expect(mockForm.setValues).toHaveBeenCalledWith(
+ expect.objectContaining({ xc_password: expect.any(String) })
+ );
+ });
+
+ it('disables the XC password rotate button for non-admin', () => {
+ setupMocks({ authUser: makeRegularUser() });
+ render(
+
+ );
+
+ const rotateButton = screen
+ .getByTestId('icon-rotate-ccw-key')
+ .closest('button');
+ expect(rotateButton).toBeDisabled();
+ });
+ });
+
+ // ── Channel profiles logic ───────────────────────────────────────────────────
+
+ describe('channel profiles logic', () => {
+ const profiles = { 1: { id: 1, name: 'HD' }, 2: { id: 2, name: 'SD' } };
+
+ it('excludes "All" (0) when other profiles are selected alongside it', () => {
+ setupMocks({ authUser: makeAdminUser(), profiles });
+ render(
+
+ );
+
+ const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
+ fireEvent.change(multiSelect, { target: { value: '1,2' } });
+
+ expect(mockForm.setFieldValue).toHaveBeenCalledWith(
+ 'channel_profiles',
+ expect.not.arrayContaining(['0'])
+ );
+ });
+
+ it('sets only ["0"] when "All" is newly selected', () => {
+ setupMocks({ authUser: makeAdminUser(), profiles });
+ render(
+
+ );
+
+ const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
+ fireEvent.change(multiSelect, { target: { value: '0' } });
+
+ expect(mockForm.setFieldValue).toHaveBeenCalledWith(
+ 'channel_profiles',
+ ['0']
+ );
+ });
+
+ it('allows multiple non-all profiles together', () => {
+ setupMocks({ authUser: makeAdminUser(), profiles });
+ render(
+
+ );
+
+ const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
+ fireEvent.change(multiSelect, { target: { value: '1,2' } });
+
+ expect(mockForm.setFieldValue).toHaveBeenCalledWith(
+ 'channel_profiles',
+ ['1', '2']
+ );
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/__tests__/UserAgent.test.jsx b/frontend/src/components/forms/__tests__/UserAgent.test.jsx
new file mode 100644
index 00000000..2292b8cd
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/UserAgent.test.jsx
@@ -0,0 +1,239 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Utility mocks ──────────────────────────────────────────────────────────────
+vi.mock('../../../utils/forms/UserAgentUtils.js', () => ({
+ addUserAgent: vi.fn(),
+ updateUserAgent: vi.fn(),
+ getResolver: vi.fn(() => undefined),
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Button: ({ children, disabled, type }) => (
+
+ {children}
+
+ ),
+ Checkbox: ({ label, checked, onChange }) => (
+
+
+ {label}
+
+ ),
+ Flex: ({ children }) => {children}
,
+ Modal: ({ children, opened, onClose, title }) =>
+ opened ? (
+
+
{title}
+
×
+ {children}
+
+ ) : null,
+ Space: () =>
,
+ TextInput: ({ label, error, ...rest }) => (
+
+ {label}
+
+ {error && {error} }
+
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import * as UserAgentUtils from '../../../utils/forms/UserAgentUtils.js';
+import UserAgent from '../UserAgent';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makeUserAgent = (overrides = {}) => ({
+ id: 1,
+ name: 'Chrome',
+ user_agent: 'Mozilla/5.0',
+ description: 'Chrome browser',
+ is_active: true,
+ ...overrides,
+});
+
+describe('UserAgent', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.mocked(UserAgentUtils.addUserAgent).mockResolvedValue({});
+ vi.mocked(UserAgentUtils.updateUserAgent).mockResolvedValue({});
+ vi.mocked(UserAgentUtils.getResolver).mockReturnValue(undefined);
+ });
+
+ // ── Visibility ───────────────────────────────────────────────────────────────
+
+ describe('visibility', () => {
+ it('renders nothing when isOpen is false', () => {
+ render( );
+ expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
+ });
+
+ it('renders the modal when isOpen is true', () => {
+ render( );
+ expect(screen.getByTestId('modal')).toBeInTheDocument();
+ });
+
+ it('renders modal title "User-Agent"', () => {
+ render( );
+ expect(screen.getByTestId('modal-title')).toHaveTextContent('User-Agent');
+ });
+
+ it('calls onClose when modal close button is clicked', () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByTestId('modal-close'));
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ // ── Default values ───────────────────────────────────────────────────────────
+
+ describe('default values', () => {
+ it('renders empty fields when no userAgent prop is given', () => {
+ render( );
+ expect(screen.getByTestId('input-Name')).toHaveValue('');
+ expect(screen.getByTestId('input-User-Agent')).toHaveValue('');
+ expect(screen.getByTestId('input-Description')).toHaveValue('');
+ });
+
+ it('pre-fills fields from userAgent prop', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('input-Name')).toHaveValue('Chrome');
+ expect(screen.getByTestId('input-User-Agent')).toHaveValue('Mozilla/5.0');
+ expect(screen.getByTestId('input-Description')).toHaveValue('Chrome browser');
+ });
+
+ it('checks Is Active checkbox when userAgent.is_active is true', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
+ });
+
+ it('unchecks Is Active checkbox when userAgent.is_active is false', () => {
+ render(
+
+ );
+ expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
+ });
+
+ it('defaults Is Active to checked when no userAgent prop given', () => {
+ render( );
+ expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
+ });
+ });
+
+ // ── Field interactions ───────────────────────────────────────────────────────
+
+ describe('field interactions', () => {
+ it('toggles Is Active checkbox', () => {
+ render(
+
+ );
+ const checkbox = screen.getByTestId('checkbox-is-active');
+ fireEvent.click(checkbox);
+ expect(checkbox).not.toBeChecked();
+ });
+ });
+
+ // ── Form submission ──────────────────────────────────────────────────────────
+
+ describe('form submission', () => {
+ it('calls addUserAgent when no userAgent id is given', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Submit'));
+ await waitFor(() => {
+ expect(UserAgentUtils.addUserAgent).toHaveBeenCalled();
+ });
+ });
+
+ it('calls updateUserAgent with the id when editing an existing userAgent', async () => {
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Submit'));
+ await waitFor(() => {
+ expect(UserAgentUtils.updateUserAgent).toHaveBeenCalledWith(
+ 1,
+ expect.objectContaining({ name: 'Chrome' })
+ );
+ });
+ });
+
+ it('does not call addUserAgent when updating', async () => {
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Submit'));
+ await waitFor(() => {
+ expect(UserAgentUtils.addUserAgent).not.toHaveBeenCalled();
+ });
+ });
+
+ it('does not call updateUserAgent when creating', async () => {
+ render( );
+ fireEvent.click(screen.getByText('Submit'));
+ await waitFor(() => {
+ expect(UserAgentUtils.updateUserAgent).not.toHaveBeenCalled();
+ });
+ });
+
+ it('calls onClose after successful submission', async () => {
+ const onClose = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText('Submit'));
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalled();
+ });
+ });
+
+ it('passes is_active value in the submitted payload', async () => {
+ render( );
+ const checkbox = screen.getByTestId('checkbox-is-active');
+ fireEvent.click(checkbox); // uncheck it
+ fireEvent.click(screen.getByText('Submit'));
+ await waitFor(() => {
+ expect(UserAgentUtils.addUserAgent).toHaveBeenCalledWith(
+ expect.objectContaining({ is_active: false })
+ );
+ });
+ });
+ });
+
+ // ── useEffect reset ──────────────────────────────────────────────────────────
+
+ describe('form reset on userAgent change', () => {
+ it('resets form values when userAgent prop changes', () => {
+ const { rerender } = render(
+
+ );
+ expect(screen.getByTestId('input-Name')).toHaveValue('Chrome');
+
+ rerender(
+
+ );
+ expect(screen.getByTestId('input-Name')).toHaveValue('Firefox');
+ });
+
+ it('resets to empty when userAgent prop is removed', () => {
+ const { rerender } = render(
+
+ );
+ rerender( );
+ expect(screen.getByTestId('input-Name')).toHaveValue('');
+ });
+ });
+});
\ No newline at end of file
diff --git a/frontend/src/components/forms/__tests__/VODCategoryFilter.test.jsx b/frontend/src/components/forms/__tests__/VODCategoryFilter.test.jsx
new file mode 100644
index 00000000..de87d5c0
--- /dev/null
+++ b/frontend/src/components/forms/__tests__/VODCategoryFilter.test.jsx
@@ -0,0 +1,362 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// ── Store mock ─────────────────────────────────────────────────────────────────
+vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() }));
+
+// ── lucide-react ───────────────────────────────────────────────────────────────
+vi.mock('lucide-react', () => ({
+ CircleCheck: () => ,
+ CircleX: () => ,
+}));
+
+// ── Mantine core ───────────────────────────────────────────────────────────────
+vi.mock('@mantine/core', () => ({
+ Box: ({ children }) => {children}
,
+ Button: ({ children, onClick, disabled, color, variant }) => (
+
+ {children}
+
+ ),
+ Checkbox: ({ label, checked, onChange, disabled }) => (
+
+ onChange?.({ currentTarget: { checked: e.target.checked } })}
+ disabled={disabled}
+ />
+ {label}
+
+ ),
+ Flex: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
+ SimpleGrid: ({ children }) => {children}
,
+ Stack: ({ children }) => {children}
,
+ Text: ({ children }) => {children} ,
+ TextInput: ({ label, value, onChange, placeholder }) => (
+
+ ),
+ SegmentedControl: ({ value, onChange, data }) => (
+
+ {data.map((item) => (
+ onChange(item.value ?? item)}
+ data-active={value === (item.value ?? item) ? 'true' : 'false'}
+ >
+ {item.label ?? item}
+
+ ))}
+
+ ),
+}));
+
+// ── Imports after mocks ────────────────────────────────────────────────────────
+import useVODStore from '../../../store/useVODStore';
+import VODCategoryFilter from '../VODCategoryFilter';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+const makeCategories = () => [
+ {
+ id: 1,
+ name: 'Action',
+ m3u_accounts: [{ m3u_account: 10, enabled: true }],
+ category_type: 'movies',
+ },
+ { id: 2, name: 'Comedy', m3u_accounts: [{ m3u_account: 10, enabled: false }], category_type: 'movies' },
+ { id: 3, name: 'Drama', m3u_accounts: [{ m3u_account: 10, enabled: true }], category_type: 'movies' },
+ { id: 4, name: 'News', m3u_accounts: [{ m3u_account: 10, enabled: true }], category_type: 'series' },
+];
+
+const makePlaylist = (overrides = {}) => ({ id: 10, name: 'My Playlist', ...overrides });
+
+const categoriesToDict = (arr) =>
+ arr.reduce((acc, cat) => ({ ...acc, [cat.id]: cat }), {});
+
+const setupMocks = ({ categories = makeCategories() } = {}) => {
+ const dict = Array.isArray(categories) ? categoriesToDict(categories) : categories;
+ vi.mocked(useVODStore).mockImplementation((sel) => sel({ categories: dict }));
+};
+
+const defaultProps = (overrides = {}) => {
+ return {
+ playlist: makePlaylist(),
+ categoryStates: [
+ { id: 1, name: 'Action', enabled: true },
+ { id: 2, name: 'Comedy', enabled: false },
+ { id: 3, name: 'Drama', enabled: true },
+ ],
+ setCategoryStates: vi.fn(),
+ type: 'movies',
+ autoEnableNewGroups: true,
+ setAutoEnableNewGroups: vi.fn(),
+ ...overrides,
+ };
+};
+
+// ──────────────────────────────────────────────────────────────────────────────
+
+describe('VODCategoryFilter', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ setupMocks();
+ });
+
+ // ── Rendering ─────────────────────────────────────────────────────────────
+
+ describe('rendering', () => {
+ it('renders without crashing', () => {
+ render( );
+ expect(screen.getByTestId('simple-grid')).toBeInTheDocument();
+ });
+
+ it('renders a button for each category matching the type and playlist', () => {
+ render( );
+ expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
+ });
+
+ it('does not render categories of a different type', () => {
+ render( );
+ expect(screen.queryByRole('checkbox', { name: 'News' })).not.toBeInTheDocument();
+ });
+
+ it('renders the text filter input', () => {
+ render( );
+ expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument();
+ });
+
+ it('renders the segmented status control', () => {
+ render( );
+ expect(screen.getByTestId('segmented-control')).toBeInTheDocument();
+ });
+
+ it('renders Enable All and Disable All buttons', () => {
+ render( );
+ expect(screen.getByText('Select Visible')).toBeInTheDocument();
+ expect(screen.getByText('Deselect Visible')).toBeInTheDocument();
+ });
+
+ it('renders the Auto-enable new groups checkbox', () => {
+ render( );
+ expect(screen.getByLabelText(/automatically enable new/i)).toBeInTheDocument();
+ });
+ });
+
+ // ── Text filter ───────────────────────────────────────────────────────────
+
+ describe('text filter', () => {
+ it('hides categories that do not match the filter', () => {
+ render( );
+ const input = screen.getByPlaceholderText(/filter/i);
+ fireEvent.change(input, { target: { value: 'act' } });
+ expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Comedy' })).not.toBeInTheDocument();
+ });
+
+ it('is case-insensitive', () => {
+ render( );
+ fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'COMEDY' } });
+ expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ });
+
+ it('shows all categories when filter is cleared', () => {
+ render( );
+ const input = screen.getByPlaceholderText(/filter/i);
+ fireEvent.change(input, { target: { value: 'act' } });
+ fireEvent.change(input, { target: { value: '' } });
+ expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Drama' })).toBeInTheDocument();
+ });
+
+ it('shows no categories when filter matches nothing', () => {
+ render( );
+ fireEvent.change(screen.getByPlaceholderText(/filter/i), {
+ target: { value: 'zzznomatch' },
+ });
+ expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Status filter ─────────────────────────────────────────────────────────
+
+ describe('status filter', () => {
+ it('shows only enabled categories when "Enabled" segment is selected', () => {
+ const props = defaultProps({
+ categoryStates: [ { id: 1, name: 'Action', enabled: true }, { id: 2, name: 'Comedy', enabled: false }, { id: 3, name: 'Drama', enabled: true } ],
+ });
+ render( );
+ fireEvent.click(screen.getByTestId('segment-enabled'));
+ expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Comedy' })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Drama' })).toBeInTheDocument();
+ });
+
+ it('shows only disabled categories when "Disabled" segment is selected', () => {
+ const props = defaultProps({
+ categoryStates: [ { id: 1, name: 'Action', enabled: true }, { id: 2, name: 'Comedy', enabled: false }, { id: 3, name: 'Drama', enabled: true } ],
+ });
+ render( );
+ fireEvent.click(screen.getByTestId('segment-disabled'));
+ expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
+ });
+
+ it('shows all categories when "All" segment is active', () => {
+ const props = defaultProps({
+ categoryStates: [ { id: 1, name: 'Action', enabled: true }, { id: 2, name: 'Comedy', enabled: false }, { id: 3, name: 'Drama', enabled: true } ],
+ });
+ render( );
+ fireEvent.click(screen.getByTestId('segment-disabled'));
+ fireEvent.click(screen.getByTestId('segment-all'));
+ expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
+ });
+ });
+
+ // ── Combined filters ──────────────────────────────────────────────────────
+
+ describe('combined text + status filters', () => {
+ it('applies both text and status filters simultaneously', () => {
+ const props = defaultProps();
+ render( );
+ fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'o' } });
+ fireEvent.click(screen.getByTestId('segment-disabled'));
+ // "Comedy" matches "o" AND is disabled; "Action" matches "o" but is enabled
+ expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ });
+ });
+
+ // ── Enable All / Disable All ──────────────────────────────────────────────
+
+ describe('Enable All button', () => {
+ it('calls setCategoryStates with all visible categories set to true', () => {
+ const setCategoryStates = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByText('Select Visible'));
+ const called = setCategoryStates.mock.calls.at(-1)[0];
+ expect(called.find((s) => s.id === 1).enabled).toBe(true);
+ expect(called.find((s) => s.id === 2).enabled).toBe(true);
+ expect(called.find((s) => s.id === 3).enabled).toBe(true);
+ });
+
+ it('only enables filtered categories when a text filter is active', () => {
+ const setCategoryStates = vi.fn();
+ render(
+
+ );
+ fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'act' } });
+ fireEvent.click(screen.getByText('Select Visible'));
+ const called = setCategoryStates.mock.calls.at(-1)[0];
+ expect(called.find((s) => s.id === 1).enabled).toBe(true);
+ // Comedy and Drama were filtered out — their state should be unchanged
+ expect(called.find((s) => s.id === 2).enabled).toBe(false);
+ expect(called.find((s) => s.id === 3).enabled).toBe(false);
+ });
+ });
+
+ describe('Disable All button', () => {
+ it('calls setCategoryStates with all visible categories set to false', () => {
+ const setCategoryStates = vi.fn();
+ render( );
+ fireEvent.click(screen.getByText('Deselect Visible'));
+ const called = setCategoryStates.mock.calls.at(-1)[0];
+ expect(called.find((s) => s.id === 1).enabled).toBe(false);
+ expect(called.find((s) => s.id === 2).enabled).toBe(false);
+ expect(called.find((s) => s.id === 3).enabled).toBe(false);
+ });
+
+ it('only disables filtered categories when a text filter is active', () => {
+ const setCategoryStates = vi.fn();
+ render(
+
+ );
+ fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'comedy' } });
+ fireEvent.click(screen.getByText('Deselect Visible'));
+ const called = setCategoryStates.mock.calls.at(-1)[0];
+ expect(called.find((s) => s.id === 2).enabled).toBe(false);
+ expect(called.find((s) => s.id === 1).enabled).toBe(true);
+ expect(called.find((s) => s.id === 3).enabled).toBe(true);
+ });
+ });
+
+ // ── Auto-enable new groups ────────────────────────────────────────────────
+
+ describe('autoEnableNewGroups checkbox', () => {
+ it('calls setAutoEnableNewGroups(true) when checked', () => {
+ const setAutoEnableNewGroups = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByLabelText(/automatically enable new/i));
+ expect(setAutoEnableNewGroups).toHaveBeenCalledWith(true);
+ });
+
+ it('calls setAutoEnableNewGroups(false) when unchecked', () => {
+ const setAutoEnableNewGroups = vi.fn();
+ render(
+
+ );
+ fireEvent.click(screen.getByLabelText(/automatically enable new/i));
+ expect(setAutoEnableNewGroups).toHaveBeenCalledWith(false);
+ });
+ });
+
+ // ── No playlist / empty categories ───────────────────────────────────────
+
+ describe('edge cases', () => {
+ it('renders no category buttons when categories list is empty', () => {
+ setupMocks({ categories: [] });
+ render( );
+ expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ });
+
+ it('renders no category buttons when categoryStates is empty', () => {
+ render( );
+ expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ });
+
+ it('renders categories only for the matching playlist id', () => {
+ setupMocks({
+ categories: [
+ ...makeCategories(),
+ { id: 99, name: 'Foreign', m3u_accounts: [{ m3u_account: 99, enabled: true }], category_type: 'movies' },
+ ],
+ });
+ render( );
+ expect(screen.queryByRole('button', { name: 'Foreign' })).not.toBeInTheDocument();
+ });
+ });
+});
\ No newline at end of file
From 284ba87fe7c96e85ca3b84ae2494fe6a56412224 Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Mon, 25 May 2026 18:15:09 -0700
Subject: [PATCH 07/10] Minor formatting changes
---
frontend/src/components/forms/User.jsx | 2 +-
frontend/src/components/forms/UserAgent.jsx | 8 +-
.../__tests__/ProgramRecordingModal.test.jsx | 65 +++--
.../forms/__tests__/Recording.test.jsx | 127 +++++++---
.../__tests__/RecordingDetailsModal.test.jsx | 84 +++++--
.../__tests__/RecurringRuleModal.test.jsx | 116 +++++----
.../forms/__tests__/ScheduleInput.test.jsx | 96 ++++++--
.../__tests__/SeriesRecordingModal.test.jsx | 72 ++++--
.../__tests__/SeriesRuleEditorModal.test.jsx | 110 ++++++---
.../forms/__tests__/Stream.test.jsx | 12 +-
.../forms/__tests__/StreamProfile.test.jsx | 66 ++++-
.../forms/__tests__/SuperuserForm.test.jsx | 5 +-
.../components/forms/__tests__/User.test.jsx | 228 ++++++++++++++----
.../forms/__tests__/UserAgent.test.jsx | 64 ++++-
.../__tests__/VODCategoryFilter.test.jsx | 215 +++++++++++++----
.../utils/forms/SeriesRuleEditorModalUtils.js | 4 +-
frontend/src/utils/forms/UserUtils.js | 14 +-
.../RecordingDetailsModalUtils.test.js | 29 ++-
.../forms/__tests__/RecordingUtils.test.js | 73 +++---
.../SeriesRuleEditorModalUtils.test.js | 84 ++++---
.../__tests__/StreamProfileUtils.test.js | 6 +-
.../utils/forms/__tests__/StreamUtils.test.js | 6 +-
.../forms/__tests__/UserAgentUtils.test.js | 12 +-
.../utils/forms/__tests__/UserUtils.test.js | 169 ++++++++++---
24 files changed, 1217 insertions(+), 450 deletions(-)
diff --git a/frontend/src/components/forms/User.jsx b/frontend/src/components/forms/User.jsx
index 34a43c30..3ca08e1a 100644
--- a/frontend/src/components/forms/User.jsx
+++ b/frontend/src/components/forms/User.jsx
@@ -23,7 +23,7 @@ import { Copy, Key, RotateCcwKey, X } from 'lucide-react';
import { useForm } from '@mantine/form';
import useChannelsStore from '../../store/channels';
import useOutputProfilesStore from '../../store/outputProfiles';
-import { USER_LEVEL_LABELS, USER_LEVELS, } from '../../constants';
+import { USER_LEVEL_LABELS, USER_LEVELS } from '../../constants';
import useAuthStore from '../../store/auth';
import { copyToClipboard } from '../../utils';
import {
diff --git a/frontend/src/components/forms/UserAgent.jsx b/frontend/src/components/forms/UserAgent.jsx
index b6c237b1..1742e553 100644
--- a/frontend/src/components/forms/UserAgent.jsx
+++ b/frontend/src/components/forms/UserAgent.jsx
@@ -1,8 +1,12 @@
// Modal.js
import React, { useEffect, useMemo } from 'react';
import { useForm } from 'react-hook-form';
-import { Button, Checkbox, Flex, Modal, Space, TextInput, } from '@mantine/core';
-import { addUserAgent, getResolver, updateUserAgent } from '../../utils/forms/UserAgentUtils.js';
+import { Button, Checkbox, Flex, Modal, Space, TextInput } from '@mantine/core';
+import {
+ addUserAgent,
+ getResolver,
+ updateUserAgent,
+} from '../../utils/forms/UserAgentUtils.js';
const UserAgent = ({ userAgent = null, isOpen, onClose }) => {
const defaultValues = useMemo(
diff --git a/frontend/src/components/forms/__tests__/ProgramRecordingModal.test.jsx b/frontend/src/components/forms/__tests__/ProgramRecordingModal.test.jsx
index e47b15fd..f49776a8 100644
--- a/frontend/src/components/forms/__tests__/ProgramRecordingModal.test.jsx
+++ b/frontend/src/components/forms/__tests__/ProgramRecordingModal.test.jsx
@@ -27,7 +27,9 @@ vi.mock('@mantine/core', async () => ({
opened ? (
{title}
-
×
+
+ ×
+
{children}
) : null,
@@ -43,13 +45,18 @@ vi.mock('@mantine/core', async () => ({
),
Anchor: ({ children, onClick }) => (
- {children}
+
+ {children}
+
),
}));
// ── Imports after mocks ────────────────────────────────────────────────────────
import ProgramRecordingModal from '../ProgramRecordingModal.jsx';
-import { deleteRecordingById, deleteSeriesAndRule } from '../../../utils/cards/RecordingCardUtils.js';
+import {
+ deleteRecordingById,
+ deleteSeriesAndRule,
+} from '../../../utils/cards/RecordingCardUtils.js';
import { deleteSeriesRuleByTvgId } from '../../../utils/guideUtils.js';
// ── Helpers ────────────────────────────────────────────────────────────────────
@@ -114,11 +121,7 @@ describe('ProgramRecordingModal', () => {
describe('handleRemoveRecording', () => {
it('calls deleteRecordingById with the recording id', async () => {
const recording = makeRecording();
- render(
-
- );
+ render( );
fireEvent.click(screen.getByText('Remove this recording'));
await waitFor(() => {
expect(deleteRecordingById).toHaveBeenCalledWith('rec-1');
@@ -129,9 +132,7 @@ describe('ProgramRecordingModal', () => {
const onClose = vi.fn();
const recording = makeRecording();
render(
-
+
);
fireEvent.click(screen.getByText('Remove this recording'));
await waitFor(() => {
@@ -144,9 +145,7 @@ describe('ProgramRecordingModal', () => {
const onClose = vi.fn();
const recording = makeRecording();
render(
-
+
);
fireEvent.click(screen.getByText('Remove this recording'));
await waitFor(() => {
@@ -202,7 +201,10 @@ describe('ProgramRecordingModal', () => {
);
fireEvent.click(screen.getByText(/Remove series rule/i));
await waitFor(() => {
- expect(deleteSeriesRuleByTvgId).toHaveBeenCalledWith('tvg-3', 'Rule Show');
+ expect(deleteSeriesRuleByTvgId).toHaveBeenCalledWith(
+ 'tvg-3',
+ 'Rule Show'
+ );
});
});
@@ -210,7 +212,10 @@ describe('ProgramRecordingModal', () => {
const onExistingRuleModeChange = vi.fn();
render(
);
fireEvent.click(screen.getByText(/Remove series rule/i));
@@ -245,14 +250,18 @@ describe('ProgramRecordingModal', () => {
it('calls onRecordSeriesAll when "Record All" is clicked', () => {
const onRecordSeriesAll = vi.fn();
- render( );
+ render(
+
+ );
fireEvent.click(screen.getByText('Every episode'));
expect(onRecordSeriesAll).toHaveBeenCalled();
});
it('calls onRecordSeriesNew when "Record New" is clicked', () => {
const onRecordSeriesNew = vi.fn();
- render( );
+ render(
+
+ );
fireEvent.click(screen.getByText('New episodes only'));
expect(onRecordSeriesNew).toHaveBeenCalled();
});
@@ -264,22 +273,32 @@ describe('ProgramRecordingModal', () => {
it('opens SeriesRuleEditorModal when "Edit Rule" is clicked', () => {
render(
);
fireEvent.click(screen.getByText(/Customize rule/i));
- expect(screen.getByTestId('series-rule-editor-modal')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('series-rule-editor-modal')
+ ).toBeInTheDocument();
});
it('closes SeriesRuleEditorModal when its onClose is called', () => {
render(
);
fireEvent.click(screen.getByText(/Customize rule/i));
fireEvent.click(screen.getByTestId('series-rule-editor-close'));
- expect(screen.queryByTestId('series-rule-editor-modal')).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('series-rule-editor-modal')
+ ).not.toBeInTheDocument();
});
});
-});
\ No newline at end of file
+});
diff --git a/frontend/src/components/forms/__tests__/Recording.test.jsx b/frontend/src/components/forms/__tests__/Recording.test.jsx
index 9c3771bc..ae9da25f 100644
--- a/frontend/src/components/forms/__tests__/Recording.test.jsx
+++ b/frontend/src/components/forms/__tests__/Recording.test.jsx
@@ -55,11 +55,20 @@ vi.mock('@mantine/form', () => ({
return {
values,
key: vi.fn((k) => k),
- getInputProps: vi.fn((k) => ({ name: k, value: values[k] ?? '', onChange: vi.fn() })),
- onSubmit: vi.fn((handler) => (e) => { e?.preventDefault?.(); return handler(values); }),
+ getInputProps: vi.fn((k) => ({
+ name: k,
+ value: values[k] ?? '',
+ onChange: vi.fn(),
+ })),
+ onSubmit: vi.fn((handler) => (e) => {
+ e?.preventDefault?.();
+ return handler(values);
+ }),
reset: vi.fn(),
setValues: vi.fn((newVals) => Object.assign(values, newVals)),
- setFieldValue: vi.fn((k, v) => { values[k] = v; }),
+ setFieldValue: vi.fn((k, v) => {
+ values[k] = v;
+ }),
validateField: vi.fn(),
};
}),
@@ -74,24 +83,37 @@ vi.mock('@mantine/core', () => ({
),
Button: ({ children, onClick, loading, type }) => (
-
+
{children}
),
Group: ({ children }) => {children}
,
- Loader: ({ size, color }) => ,
+ Loader: ({ size, color }) => (
+
+ ),
Modal: ({ children, opened, onClose, title }) =>
opened ? (
{title}
-
×
+
+ ×
+
{children}
) : null,
MultiSelect: ({ label, placeholder, ...props }) => (
{label}
-
+
),
SegmentedControl: ({ value, onChange, data, disabled }) => (
@@ -115,7 +137,9 @@ vi.mock('@mantine/core', () => ({
{rightSection}
{(data ?? []).map((opt) => (
- {opt.label}
+
+ {opt.label}
+
))}
@@ -124,7 +148,11 @@ vi.mock('@mantine/core', () => ({
TextInput: ({ label, placeholder, ...props }) => (
{label}
-
+
),
}));
@@ -136,8 +164,10 @@ vi.mock('@mantine/dates', () => ({
{label}
onChange(e.target.value ? new Date(e.target.value) : null)}
+ value={value ? (value.toISOString?.() ?? value) : ''}
+ onChange={(e) =>
+ onChange(e.target.value ? new Date(e.target.value) : null)
+ }
/>
),
@@ -175,7 +205,10 @@ const setupStoreMock = () => {
const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined);
vi.mocked(useChannelsStore).mockImplementation((sel) =>
- sel({ fetchRecordings: mockFetchRecordings, fetchRecurringRules: mockFetchRecurringRules })
+ sel({
+ fetchRecordings: mockFetchRecordings,
+ fetchRecurringRules: mockFetchRecurringRules,
+ })
);
return { mockFetchRecordings, mockFetchRecurringRules };
@@ -223,7 +256,9 @@ describe('RecordingModal', () => {
it('renders the scheduling conflicts alert', () => {
render( );
expect(screen.getByTestId('alert')).toBeInTheDocument();
- expect(screen.getByTestId('alert-title')).toHaveTextContent('Scheduling Conflicts');
+ expect(screen.getByTestId('alert-title')).toHaveTextContent(
+ 'Scheduling Conflicts'
+ );
});
});
@@ -232,13 +267,19 @@ describe('RecordingModal', () => {
describe('mode switching', () => {
it('defaults to "single" mode', () => {
render( );
- expect(screen.getByTestId('mode-single')).toHaveAttribute('data-active', 'true');
+ 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');
+ expect(screen.getByTestId('mode-recurring')).toHaveAttribute(
+ 'data-active',
+ 'true'
+ );
});
it('shows DateTimePicker fields in single mode', () => {
@@ -256,7 +297,9 @@ describe('RecordingModal', () => {
});
it('disables mode toggle when editing an existing recording', () => {
- render( );
+ render(
+
+ );
expect(screen.getByTestId('mode-single')).toBeDisabled();
expect(screen.getByTestId('mode-recurring')).toBeDisabled();
});
@@ -296,7 +339,9 @@ describe('RecordingModal', () => {
});
it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => {
- vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(new Error('fail'));
+ vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(
+ new Error('fail')
+ );
render( );
await waitFor(() => {
expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith(
@@ -336,7 +381,10 @@ describe('RecordingModal', () => {
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
- expect.objectContaining({ title: 'Recording scheduled', color: 'green' })
+ expect.objectContaining({
+ title: 'Recording scheduled',
+ color: 'green',
+ })
);
});
});
@@ -360,7 +408,9 @@ describe('RecordingModal', () => {
});
it('does not call showNotification when createRecording throws', async () => {
- vi.mocked(RecordingUtils.createRecording).mockRejectedValue(new Error('fail'));
+ vi.mocked(RecordingUtils.createRecording).mockRejectedValue(
+ new Error('fail')
+ );
render( );
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
@@ -402,7 +452,10 @@ describe('RecordingModal', () => {
fireEvent.submit(screen.getByText('Schedule Recording').closest('form'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
- expect.objectContaining({ title: 'Recording updated', color: 'green' })
+ expect.objectContaining({
+ title: 'Recording updated',
+ color: 'green',
+ })
);
});
});
@@ -446,7 +499,10 @@ describe('RecordingModal', () => {
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
- expect.objectContaining({ title: 'Recurring rule saved', color: 'green' })
+ expect.objectContaining({
+ title: 'Recurring rule saved',
+ color: 'green',
+ })
);
});
});
@@ -462,7 +518,9 @@ describe('RecordingModal', () => {
});
it('does not show notification when createRecurringRule throws', async () => {
- vi.mocked(RecordingUtils.createRecurringRule).mockRejectedValue(new Error('fail'));
+ vi.mocked(RecordingUtils.createRecurringRule).mockRejectedValue(
+ new Error('fail')
+ );
render( );
fireEvent.click(screen.getByTestId('mode-recurring'));
fireEvent.submit(screen.getByText('Save Rule').closest('form'));
@@ -478,19 +536,34 @@ describe('RecordingModal', () => {
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);
+ render(
+
+ );
+ expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(
+ recording,
+ channel
+ );
});
it('calls getSingleFormDefaults with null when opening for new recording', () => {
render( );
- expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(null, null);
+ expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(
+ null,
+ null
+ );
});
it('calls getRecurringFormDefaults with channel on open', () => {
const channel = makeChannel();
render( );
- expect(RecordingUtils.getRecurringFormDefaults).toHaveBeenCalledWith(channel);
+ expect(RecordingUtils.getRecurringFormDefaults).toHaveBeenCalledWith(
+ channel
+ );
});
});
@@ -504,4 +577,4 @@ describe('RecordingModal', () => {
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
index 91216ac1..9f07a5a8 100644
--- a/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx
+++ b/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx
@@ -59,7 +59,9 @@ vi.mock('@mantine/core', () => ({
),
Badge: ({ children, color }) => (
- {children}
+
+ {children}
+
),
Button: ({ children, onClick, disabled, loading, size, variant, color }) => (
({
),
Card: ({ children, onClick, style }) => (
- {children}
+
+ {children}
+
),
Flex: ({ children }) => {children}
,
Group: ({ children }) => {children}
,
Image: ({ src, alt, fallbackSrc }) => (
),
- Modal: ({ children, opened, onClose, title, size }) =>
+ Modal: ({ children, opened, onClose, title }) =>
opened ? (
{title}
-
×
+
+ ×
+
{children}
) : null,
Stack: ({ children }) => {children}
,
Text: ({ children, size, c, fw, style }) => (
- {children}
+
+ {children}
+
),
Textarea: ({ label, value, onChange, placeholder, ...props }) => (
@@ -207,7 +215,9 @@ const setupMocks = ({ recording = makeRecording(), now = NOW } = {}) => {
vi.mocked(RecordingCardUtils.getPosterUrl).mockReturnValue('/poster.jpg');
vi.mocked(RecordingCardUtils.getChannelLogoUrl).mockReturnValue('/logo.png');
- vi.mocked(RecordingCardUtils.getRecordingUrl).mockReturnValue('/recordings/test.ts');
+ vi.mocked(RecordingCardUtils.getRecordingUrl).mockReturnValue(
+ '/recordings/test.ts'
+ );
vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('');
vi.mocked(RecordingCardUtils.getShowVideoUrl).mockReturnValue('/live/ch-1');
@@ -234,10 +244,16 @@ describe('RecordingDetailsModal', () => {
beforeEach(() => {
vi.clearAllMocks();
setupMocks();
- vi.mocked(RecordingDetailsModalUtils.updateRecordingMetadata).mockResolvedValue(undefined);
- vi.mocked(RecordingDetailsModalUtils.refreshArtwork).mockResolvedValue(undefined);
+ vi.mocked(
+ RecordingDetailsModalUtils.updateRecordingMetadata
+ ).mockResolvedValue(undefined);
+ vi.mocked(RecordingDetailsModalUtils.refreshArtwork).mockResolvedValue(
+ undefined
+ );
vi.mocked(RecordingCardUtils.runComSkip).mockResolvedValue(undefined);
- vi.mocked(RecordingCardUtils.deleteRecordingById).mockResolvedValue(undefined);
+ vi.mocked(RecordingCardUtils.deleteRecordingById).mockResolvedValue(
+ undefined
+ );
});
// ── Visibility ───────────────────────────────────────────────────────────────
@@ -294,14 +310,19 @@ describe('RecordingDetailsModal', () => {
it('renders the poster image', () => {
render( );
- expect(screen.getByAltText('Test Show')).toHaveAttribute('src', '/poster.jpg');
+ expect(screen.getByAltText('Test Show')).toHaveAttribute(
+ 'src',
+ '/poster.jpg'
+ );
});
it('renders the season/episode label when present', () => {
const seriesRecording = makeRecording({ _group_count: 2 });
const mockEpisode = makeRecording({ id: 'ep-1', _group_count: 1 });
- vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue([mockEpisode]);
+ vi.mocked(RecordingDetailsModalUtils.getUpcomingEpisodes).mockReturnValue(
+ [mockEpisode]
+ );
vi.mocked(RecordingCardUtils.getSeasonLabel).mockReturnValue('S01E02');
render(
@@ -412,7 +433,10 @@ describe('RecordingDetailsModal', () => {
describe('Edit button', () => {
it('renders the Edit button', () => {
- const futureRecording = makeRecording({ start_time: FUTURE, end_time: FUTURE });
+ const futureRecording = makeRecording({
+ start_time: FUTURE,
+ end_time: FUTURE,
+ });
setupMocks({ recording: futureRecording });
render(
{
});
it('calls onEdit when Edit is clicked', () => {
- const futureRecording = makeRecording({ start_time: FUTURE, end_time: FUTURE });
+ const futureRecording = makeRecording({
+ start_time: FUTURE,
+ end_time: FUTURE,
+ });
setupMocks({ recording: futureRecording });
const onEdit = vi.fn();
render(
@@ -445,27 +472,37 @@ describe('RecordingDetailsModal', () => {
it('shows edit inputs when pencil icon is clicked', () => {
render( );
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
- expect(screen.getByTestId('textinput-Recording title')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('textinput-Recording title')
+ ).toBeInTheDocument();
});
it('pre-fills title input with current title', () => {
render( );
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
- expect(screen.getByTestId('textinput-Recording title')).toHaveValue('Test Show');
+ expect(screen.getByTestId('textinput-Recording title')).toHaveValue(
+ 'Test Show'
+ );
});
it('pre-fills description input with current description', () => {
render( );
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
- expect(screen.getByTestId('textarea-Description (optional)')).toHaveValue('A test description');
+ expect(screen.getByTestId('textarea-Description (optional)')).toHaveValue(
+ 'A test description'
+ );
});
it('cancels editing when X icon is clicked', () => {
render( );
fireEvent.click(screen.getByTestId('icon-pencil').closest('button'));
- expect(screen.getByTestId('textinput-Recording title')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('textinput-Recording title')
+ ).toBeInTheDocument();
fireEvent.click(screen.getByTestId('icon-x').closest('button'));
- expect(screen.queryByTestId('textinput-Recording title')).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('textinput-Recording title')
+ ).not.toBeInTheDocument();
});
it('calls updateRecordingMetadata with new title and description on save', async () => {
@@ -753,18 +790,17 @@ describe('RecordingDetailsModal', () => {
target: { value: 'Custom' },
});
fireEvent.click(screen.getByTestId('icon-check').closest('button'));
- await waitFor(() => expect(screen.getByText('Custom')).toBeInTheDocument());
+ await waitFor(() =>
+ expect(screen.getByText('Custom')).toBeInTheDocument()
+ );
// Switch to a different recording
const newRecording = makeRecording({ id: 'rec-2' });
setupMocks({ recording: newRecording });
rerender(
-
+
);
expect(screen.getByText(/Test Show/i)).toBeInTheDocument();
});
});
-});
\ No newline at end of file
+});
diff --git a/frontend/src/components/forms/__tests__/RecurringRuleModal.test.jsx b/frontend/src/components/forms/__tests__/RecurringRuleModal.test.jsx
index 6bfc8927..308a415b 100644
--- a/frontend/src/components/forms/__tests__/RecurringRuleModal.test.jsx
+++ b/frontend/src/components/forms/__tests__/RecurringRuleModal.test.jsx
@@ -59,12 +59,10 @@ vi.mock('@mantine/form', async () => {
setValuesState((prev) => ({ ...prev, [k]: v }));
}),
})),
- onSubmit: vi.fn(
- (handler) => (e) => {
- e?.preventDefault?.();
- return handler(values);
- }
- ),
+ onSubmit: vi.fn((handler) => (e) => {
+ e?.preventDefault?.();
+ return handler(values);
+ }),
reset: vi.fn(() => setValuesState({ ...initialValues })),
setValues: vi.fn((newVals) =>
setValuesState((prev) => ({ ...prev, ...newVals }))
@@ -84,7 +82,16 @@ vi.mock('@mantine/core', () => ({
{children}
),
- Button: ({ children, onClick, loading, type, variant, color, size, disabled }) => (
+ Button: ({
+ children,
+ onClick,
+ loading,
+ type,
+ variant,
+ color,
+ size,
+ disabled,
+ }) => (
({
{label}
onChange(e.target.value ? new Date(e.target.value) : null)}
+ value={value ? (value.toISOString?.() ?? String(value)) : ''}
+ onChange={(e) =>
+ onChange(e.target.value ? new Date(e.target.value) : null)
+ }
/>
),
@@ -286,7 +295,9 @@ const setupMocks = ({ rule = makeRule(), occurrences = [] } = {}) => {
enabled: rule?.enabled ?? true,
});
- vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([makeChannel()]);
+ vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([
+ makeChannel(),
+ ]);
vi.mocked(RecurringRuleModalUtils.getFormDefaults).mockReturnValue({
channel_id: rule?.channel_id ?? '',
@@ -299,10 +310,18 @@ const setupMocks = ({ rule = makeRule(), occurrences = [] } = {}) => {
enabled: rule?.enabled ?? true,
});
- vi.mocked(RecurringRuleModalUtils.getUpcomingOccurrences).mockReturnValue(occurrences);
- vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockResolvedValue(undefined);
- vi.mocked(RecurringRuleModalUtils.updateRecurringRuleEnabled).mockResolvedValue(undefined);
- vi.mocked(RecurringRuleModalUtils.deleteRecurringRuleById).mockResolvedValue(undefined);
+ vi.mocked(RecurringRuleModalUtils.getUpcomingOccurrences).mockReturnValue(
+ occurrences
+ );
+ vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockResolvedValue(
+ undefined
+ );
+ vi.mocked(
+ RecurringRuleModalUtils.updateRecurringRuleEnabled
+ ).mockResolvedValue(undefined);
+ vi.mocked(RecurringRuleModalUtils.deleteRecurringRuleById).mockResolvedValue(
+ undefined
+ );
vi.mocked(deleteRecordingById).mockResolvedValue(undefined);
return { mockFetchRecurringRules };
@@ -339,13 +358,17 @@ describe('RecurringRuleModal', () => {
it('uses rule name as modal title', () => {
render( );
- expect(screen.getByTestId('modal-title')).toHaveTextContent('Morning News');
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ 'Morning News'
+ );
});
it('falls back to "Recurring Rule" when rule has no name', () => {
setupMocks({ rule: makeRule({ name: '' }) });
render( );
- expect(screen.getByTestId('modal-title')).toHaveTextContent('Recurring Rule');
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ 'Recurring Rule'
+ );
});
it('calls onClose when modal close button is clicked', () => {
@@ -366,16 +389,15 @@ describe('RecurringRuleModal', () => {
it('renders fallback message when rule does not exist', () => {
render( );
expect(
- screen.getByText('The recurring rule for this recording no longer exists.')
+ screen.getByText(
+ 'The recurring rule for this recording no longer exists.'
+ )
).toBeInTheDocument();
});
it('shows Delete Recording button when sourceRecording is provided', () => {
render(
-
+
);
expect(screen.getByText('Delete Recording')).toBeInTheDocument();
});
@@ -407,7 +429,10 @@ describe('RecurringRuleModal', () => {
fireEvent.click(screen.getByText('Delete Recording'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
- expect.objectContaining({ title: 'Recording deleted', color: 'green' })
+ expect.objectContaining({
+ title: 'Recording deleted',
+ color: 'green',
+ })
);
});
});
@@ -571,10 +596,9 @@ describe('RecurringRuleModal', () => {
render( );
fireEvent.submit(screen.getByText('Save changes').closest('form'));
await waitFor(() => {
- expect(RecurringRuleModalUtils.updateRecurringRule).toHaveBeenCalledWith(
- 'rule-1',
- expect.any(Object)
- );
+ expect(
+ RecurringRuleModalUtils.updateRecurringRule
+ ).toHaveBeenCalledWith('rule-1', expect.any(Object));
});
});
@@ -623,12 +647,17 @@ describe('RecurringRuleModal', () => {
it('Save changes button shows loading state while saving', async () => {
let resolve;
vi.mocked(RecurringRuleModalUtils.updateRecurringRule).mockReturnValue(
- new Promise((r) => { resolve = r; })
+ new Promise((r) => {
+ resolve = r;
+ })
);
render( );
fireEvent.submit(screen.getByText('Save changes').closest('form'));
await waitFor(() => {
- expect(screen.getByText('Save changes')).toHaveAttribute('data-loading', 'true');
+ expect(screen.getByText('Save changes')).toHaveAttribute(
+ 'data-loading',
+ 'true'
+ );
});
resolve();
});
@@ -646,9 +675,9 @@ describe('RecurringRuleModal', () => {
render( );
fireEvent.click(screen.getByText('Delete rule'));
await waitFor(() => {
- expect(RecurringRuleModalUtils.deleteRecurringRuleById).toHaveBeenCalledWith(
- 'rule-1'
- );
+ expect(
+ RecurringRuleModalUtils.deleteRecurringRuleById
+ ).toHaveBeenCalledWith('rule-1');
});
});
@@ -684,9 +713,9 @@ describe('RecurringRuleModal', () => {
});
it('does not show notification when deleteRecurringRuleById throws', async () => {
- vi.mocked(RecurringRuleModalUtils.deleteRecurringRuleById).mockRejectedValue(
- new Error('fail')
- );
+ vi.mocked(
+ RecurringRuleModalUtils.deleteRecurringRuleById
+ ).mockRejectedValue(new Error('fail'));
render( );
fireEvent.click(screen.getByText('Delete rule'));
await waitFor(() => {
@@ -805,7 +834,9 @@ describe('RecurringRuleModal', () => {
it('calls getUpcomingOccurrences with recordings, userNow, ruleId, toUserTime', () => {
render( );
- expect(RecurringRuleModalUtils.getUpcomingOccurrences).toHaveBeenCalledWith(
+ expect(
+ RecurringRuleModalUtils.getUpcomingOccurrences
+ ).toHaveBeenCalledWith(
expect.any(Array),
expect.any(Function),
'rule-1',
@@ -838,10 +869,7 @@ describe('RecurringRuleModal', () => {
const occ = makeOccurrence();
setupMocks({ occurrences: [occ] });
render(
-
+
);
expect(() => fireEvent.click(screen.getByText('Edit'))).not.toThrow();
});
@@ -888,13 +916,18 @@ describe('RecurringRuleModal', () => {
it('shows loading state on Cancel button while deleting occurrence', async () => {
let resolve;
vi.mocked(deleteRecordingById).mockReturnValue(
- new Promise((r) => { resolve = r; })
+ new Promise((r) => {
+ resolve = r;
+ })
);
setupMocks({ occurrences: [makeOccurrence()] });
render( );
fireEvent.click(screen.getByText('Cancel'));
await waitFor(() => {
- expect(screen.getByText('Cancel')).toHaveAttribute('data-loading', 'true');
+ expect(screen.getByText('Cancel')).toHaveAttribute(
+ 'data-loading',
+ 'true'
+ );
});
resolve();
});
@@ -934,4 +967,3 @@ describe('RecurringRuleModal', () => {
});
});
});
-
diff --git a/frontend/src/components/forms/__tests__/ScheduleInput.test.jsx b/frontend/src/components/forms/__tests__/ScheduleInput.test.jsx
index 85dc55d9..1fcd3216 100644
--- a/frontend/src/components/forms/__tests__/ScheduleInput.test.jsx
+++ b/frontend/src/components/forms/__tests__/ScheduleInput.test.jsx
@@ -4,7 +4,15 @@ import ScheduleInput from '../ScheduleInput';
// ── Mantine core ───────────────────────────────────────────────────────────────
vi.mock('@mantine/core', () => ({
- TextInput: ({ label, placeholder, description, value, onChange, error, disabled }) => (
+ TextInput: ({
+ label,
+ placeholder,
+ description,
+ value,
+ onChange,
+ error,
+ disabled,
+ }) => (
{typeof label === 'string' ? label : 'Cron Expression'}
({
min={min}
disabled={disabled}
/>
- {description && {description} }
+ {description && (
+ {description}
+ )}
),
Anchor: ({ children, onClick }) => (
-
+
{children}
),
@@ -44,9 +58,13 @@ vi.mock('@mantine/core', () => ({
Group: ({ children }) => {children}
,
Popover: ({ children }) => {children}
,
PopoverTarget: ({ children }) => {children}
,
- PopoverDropdown: ({ children }) => {children}
,
+ PopoverDropdown: ({ children }) => (
+ {children}
+ ),
ActionIcon: ({ children, onClick }) => (
- {children}
+
+ {children}
+
),
}));
@@ -66,7 +84,10 @@ vi.mock('../CronBuilder', () => ({
opened ? (
{currentValue}
-
onApply('0 3 * * *')}>
+ onApply('0 3 * * *')}
+ >
Apply
@@ -122,7 +143,12 @@ describe('ScheduleInput', () => {
});
it('renders custom intervalLabel', () => {
- render( );
+ render(
+
+ );
expect(screen.getByText('My Custom Label')).toBeInTheDocument();
});
@@ -144,7 +170,9 @@ describe('ScheduleInput', () => {
it('shows "Use cron schedule" link in interval mode', () => {
render( );
- expect(screen.getByTestId('anchor-use-cron-schedule')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('anchor-use-cron-schedule')
+ ).toBeInTheDocument();
});
it('calls onScheduleTypeChange("cron") when "Use cron schedule" is clicked', () => {
@@ -156,7 +184,9 @@ describe('ScheduleInput', () => {
it('does not show cron switch link when disabled', () => {
render( );
- expect(screen.queryByTestId('anchor-use-cron-schedule')).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('anchor-use-cron-schedule')
+ ).not.toBeInTheDocument();
});
it('disables the NumberInput when disabled prop is true', () => {
@@ -166,7 +196,10 @@ describe('ScheduleInput', () => {
it('renders custom switchToCronLabel', () => {
render(
-
+
);
expect(screen.getByText('Use custom cron schedule')).toBeInTheDocument();
});
@@ -182,7 +215,9 @@ describe('ScheduleInput', () => {
);
expect(screen.getByTestId('custom-child')).toBeInTheDocument();
- expect(screen.queryByTestId('numberinput-interval')).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('numberinput-interval')
+ ).not.toBeInTheDocument();
});
it('shows cron toggle link below children', () => {
@@ -191,7 +226,9 @@ describe('ScheduleInput', () => {
Child
);
- expect(screen.getByTestId('anchor-use-cron-schedule')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('anchor-use-cron-schedule')
+ ).toBeInTheDocument();
});
it('does not show cron link when disabled with children', () => {
@@ -200,7 +237,9 @@ describe('ScheduleInput', () => {
Child
);
- expect(screen.queryByTestId('anchor-use-cron-schedule')).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('anchor-use-cron-schedule')
+ ).not.toBeInTheDocument();
});
});
@@ -219,12 +258,16 @@ describe('ScheduleInput', () => {
it('shows "Use interval schedule" link in cron mode', () => {
render( );
- expect(screen.getByTestId('anchor-use-interval-schedule')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('anchor-use-interval-schedule')
+ ).toBeInTheDocument();
});
it('shows "Open Cron Builder" link', () => {
render( );
- expect(screen.getByTestId('anchor-open-cron-builder')).toBeInTheDocument();
+ expect(
+ screen.getByTestId('anchor-open-cron-builder')
+ ).toBeInTheDocument();
});
it('calls onScheduleTypeChange("interval") and onCronChange("") when switching to interval', () => {
@@ -251,13 +294,20 @@ describe('ScheduleInput', () => {
it('does not show cron links when disabled', () => {
render( );
- expect(screen.queryByTestId('anchor-use-interval-schedule')).not.toBeInTheDocument();
- expect(screen.queryByTestId('anchor-open-cron-builder')).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('anchor-use-interval-schedule')
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('anchor-open-cron-builder')
+ ).not.toBeInTheDocument();
});
it('renders custom switchToIntervalLabel', () => {
render(
-
+
);
expect(screen.getByText('Use simple schedule')).toBeInTheDocument();
});
@@ -279,7 +329,9 @@ describe('ScheduleInput', () => {
});
render( );
await waitFor(() => {
- expect(screen.getByTestId('cron-error')).toHaveTextContent('Invalid cron expression');
+ expect(screen.getByTestId('cron-error')).toHaveTextContent(
+ 'Invalid cron expression'
+ );
});
});
@@ -325,7 +377,9 @@ describe('ScheduleInput', () => {
it('passes current cronValue to CronBuilder', () => {
render( );
fireEvent.click(screen.getByTestId('anchor-open-cron-builder'));
- expect(screen.getByTestId('cron-builder-value')).toHaveTextContent('0 4 * * *');
+ expect(screen.getByTestId('cron-builder-value')).toHaveTextContent(
+ '0 4 * * *'
+ );
});
it('closes CronBuilder when onClose is triggered', () => {
@@ -370,4 +424,4 @@ describe('ScheduleInput', () => {
expect(screen.getByTestId('numberinput-interval')).toHaveValue(0);
});
});
-});
\ No newline at end of file
+});
diff --git a/frontend/src/components/forms/__tests__/SeriesRecordingModal.test.jsx b/frontend/src/components/forms/__tests__/SeriesRecordingModal.test.jsx
index 5e7f3c09..a5ca3bb0 100644
--- a/frontend/src/components/forms/__tests__/SeriesRecordingModal.test.jsx
+++ b/frontend/src/components/forms/__tests__/SeriesRecordingModal.test.jsx
@@ -63,9 +63,15 @@ vi.mock('../SeriesRuleEditorModal', () => ({
default: ({ opened, onClose, initialRule, onSaved }) =>
opened ? (
- {initialRule ? initialRule.title : 'new'}
- Close
- Save
+
+ {initialRule ? initialRule.title : 'new'}
+
+
+ Close
+
+
+ Save
+
) : null,
}));
@@ -73,7 +79,10 @@ vi.mock('../SeriesRuleEditorModal', () => ({
// ──────────────────────────────────────────────────────────────────────────────
import useChannelsStore from '../../../store/channels.jsx';
import { deleteSeriesAndRule } from '../../../utils/cards/RecordingCardUtils.js';
-import { evaluateSeriesRulesByTvgId, fetchRules } from '../../../utils/guideUtils.js';
+import {
+ evaluateSeriesRulesByTvgId,
+ fetchRules,
+} from '../../../utils/guideUtils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
const makeRule = (overrides = {}) => ({
@@ -126,7 +135,9 @@ describe('SeriesRecordingModal', () => {
it('renders modal title "Series Recording Rules"', () => {
render( );
- expect(screen.getByTestId('modal-title')).toHaveTextContent('Series Recording Rules');
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ 'Series Recording Rules'
+ );
});
it('calls onClose when modal close button is clicked', () => {
@@ -142,17 +153,23 @@ describe('SeriesRecordingModal', () => {
describe('empty state', () => {
it('shows "No series rules configured" when rules is empty', () => {
render( );
- expect(screen.getByText('No series rules configured')).toBeInTheDocument();
+ expect(
+ screen.getByText('No series rules configured')
+ ).toBeInTheDocument();
});
it('shows "No series rules configured" when rules is null', () => {
render( );
- expect(screen.getByText('No series rules configured')).toBeInTheDocument();
+ expect(
+ screen.getByText('No series rules configured')
+ ).toBeInTheDocument();
});
it('does not show "No series rules configured" when rules are present', () => {
render( );
- expect(screen.queryByText('No series rules configured')).not.toBeInTheDocument();
+ expect(
+ screen.queryByText('No series rules configured')
+ ).not.toBeInTheDocument();
});
});
@@ -167,7 +184,9 @@ describe('SeriesRecordingModal', () => {
it('falls back to tvg_id when rule has no title', () => {
render(
);
expect(screen.getByText('tvg-fallback')).toBeInTheDocument();
@@ -214,19 +233,29 @@ describe('SeriesRecordingModal', () => {
describe('rule summary', () => {
it('shows "New episodes" for mode new', () => {
- render( );
+ render(
+
+ );
expect(screen.getByText(/New episodes/)).toBeInTheDocument();
});
it('shows "Every episode" for mode all', () => {
- render( );
+ render(
+
+ );
expect(screen.getByText(/Every episode/)).toBeInTheDocument();
});
it('shows exact title label in summary', () => {
render(
);
expect(screen.getByText(/Exact title: "Test Show"/)).toBeInTheDocument();
@@ -235,7 +264,9 @@ describe('SeriesRecordingModal', () => {
it('shows contains label in summary', () => {
render(
);
expect(screen.getByText(/Title contains: "Test"/)).toBeInTheDocument();
@@ -244,7 +275,9 @@ describe('SeriesRecordingModal', () => {
it('shows regex label in summary', () => {
render(
);
expect(screen.getByText(/Title regex: "\^Test"/)).toBeInTheDocument();
@@ -289,7 +322,10 @@ describe('SeriesRecordingModal', () => {
fireEvent.click(screen.getByText('Evaluate Now'));
await waitFor(() => {
expect(showNotification).toHaveBeenCalledWith(
- expect.objectContaining({ title: 'Evaluated', message: 'Checked for episodes' })
+ expect.objectContaining({
+ title: 'Evaluated',
+ message: 'Checked for episodes',
+ })
);
});
});
@@ -377,7 +413,9 @@ describe('SeriesRecordingModal', () => {
render( );
fireEvent.click(screen.getByText('Add rule'));
fireEvent.click(screen.getByTestId('editor-close'));
- expect(screen.queryByTestId('series-rule-editor')).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('series-rule-editor')
+ ).not.toBeInTheDocument();
});
});
@@ -414,4 +452,4 @@ describe('SeriesRecordingModal', () => {
});
});
});
-});
\ No newline at end of file
+});
diff --git a/frontend/src/components/forms/__tests__/SeriesRuleEditorModal.test.jsx b/frontend/src/components/forms/__tests__/SeriesRuleEditorModal.test.jsx
index 59a98bd9..2fffd170 100644
--- a/frontend/src/components/forms/__tests__/SeriesRuleEditorModal.test.jsx
+++ b/frontend/src/components/forms/__tests__/SeriesRuleEditorModal.test.jsx
@@ -90,14 +90,7 @@ vi.mock('@mantine/core', () => ({
))}
),
- Select: ({
- label,
- placeholder,
- data,
- value,
- onChange,
- description,
- }) => (
+ Select: ({ label, placeholder, data, value, onChange, description }) => (
{label}
{description && (
@@ -156,7 +149,10 @@ import useEPGsStore from '../../../store/epgs.jsx';
import { useDebounce } from '../../../utils.js';
import { showNotification } from '../../../utils/notificationUtils.js';
import { getChannelsSummary } from '../../../utils/forms/RecordingUtils.js';
-import { createSeriesRule, evaluateSeriesRulesByTvgId } from '../../../utils/guideUtils.js';
+import {
+ createSeriesRule,
+ evaluateSeriesRulesByTvgId,
+} from '../../../utils/guideUtils.js';
import {
getTvgOptions,
previewSeriesRule,
@@ -216,7 +212,11 @@ const setupMocks = () => {
vi.mocked(getTvgOptions).mockReturnValue(mockTvgOptions);
vi.mocked(getChannelsSummary).mockResolvedValue(mockChannels);
- vi.mocked(previewSeriesRule).mockResolvedValue({ matches: [], total: 0, epg_found: true });
+ vi.mocked(previewSeriesRule).mockResolvedValue({
+ matches: [],
+ total: 0,
+ epg_found: true,
+ });
vi.mocked(createSeriesRule).mockResolvedValue({ id: 'rule-1' });
vi.mocked(evaluateSeriesRulesByTvgId).mockResolvedValue(undefined);
vi.mocked(useDebounce).mockImplementation((val) => val);
@@ -260,7 +260,9 @@ describe('SeriesRuleEditorModal', () => {
it('shows "New Series Rule" title when initialRule is null', () => {
render( );
- expect(screen.getByTestId('modal-title')).toHaveTextContent('New Series Rule');
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ 'New Series Rule'
+ );
});
it('shows "Edit Series Rule" title when initialRule is provided', () => {
@@ -270,7 +272,9 @@ describe('SeriesRuleEditorModal', () => {
initialRule={{ tvg_id: 'tvg-1', mode: 'all', title: 'Test' }}
/>
);
- expect(screen.getByTestId('modal-title')).toHaveTextContent('Edit Series Rule');
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ 'Edit Series Rule'
+ );
});
it('renders the title input', () => {
@@ -325,21 +329,29 @@ describe('SeriesRuleEditorModal', () => {
};
it('pre-fills title from initialRule', () => {
- render( );
+ render(
+
+ );
expect(screen.getByTestId('input-title')).toHaveValue('Pre-filled Title');
});
it('pre-fills description from initialRule', () => {
- render( );
- expect(screen.getByTestId('textarea-description')).toHaveValue('Pre-filled description');
+ render(
+
+ );
+ expect(screen.getByTestId('textarea-description')).toHaveValue(
+ 'Pre-filled description'
+ );
});
it('uses default mode "all" when initialRule has no mode', () => {
- render( );
- // "All" segmented button should be active (data-active=true)
- const allBtn = screen.getAllByText('All').find(
- (el) => el.closest('[data-testid="segmented-control"]')
+ render(
+
);
+ // "All" segmented button should be active (data-active=true)
+ const allBtn = screen
+ .getAllByText('All')
+ .find((el) => el.closest('[data-testid="segmented-control"]'));
expect(allBtn).toBeTruthy();
});
@@ -348,10 +360,18 @@ describe('SeriesRuleEditorModal', () => {
);
rerender(
-
+
);
rerender(
-
+
);
expect(screen.getByTestId('input-title')).toHaveValue('');
expect(screen.getByTestId('textarea-description')).toHaveValue('');
@@ -374,7 +394,9 @@ describe('SeriesRuleEditorModal', () => {
});
it('handles getChannelsSummary rejection gracefully', async () => {
- vi.mocked(getChannelsSummary).mockRejectedValue(new Error('Network error'));
+ vi.mocked(getChannelsSummary).mockRejectedValue(
+ new Error('Network error')
+ );
render( );
// Should not throw; channel options just empty
await waitFor(() => {
@@ -439,7 +461,11 @@ describe('SeriesRuleEditorModal', () => {
});
it('renders preview badge with match count', async () => {
- vi.mocked(previewSeriesRule).mockResolvedValue({ matches: [{ id: 'p1', title: 'X' }], total: 5, epg_found: true });
+ vi.mocked(previewSeriesRule).mockResolvedValue({
+ matches: [{ id: 'p1', title: 'X' }],
+ total: 5,
+ epg_found: true,
+ });
render( );
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'X' },
@@ -450,7 +476,9 @@ describe('SeriesRuleEditorModal', () => {
});
it('shows preview error alert when previewSeriesRule rejects', async () => {
- vi.mocked(previewSeriesRule).mockRejectedValue(new Error('Preview failed'));
+ vi.mocked(previewSeriesRule).mockRejectedValue(
+ new Error('Preview failed')
+ );
render( );
fireEvent.change(screen.getByTestId('input-title'), {
target: { value: 'Boom' },
@@ -467,9 +495,16 @@ describe('SeriesRuleEditorModal', () => {
total: 0,
epg_found: false,
});
- render( );
+ render(
+
+ );
await waitFor(() => {
- expect(screen.getByText(/No EPG channel matches this tvg_id/)).toBeInTheDocument();
+ expect(
+ screen.getByText(/No EPG channel matches this tvg_id/)
+ ).toBeInTheDocument();
});
});
@@ -485,7 +520,9 @@ describe('SeriesRuleEditorModal', () => {
target: { value: 'X' },
});
await waitFor(() => {
- expect(screen.getByText(/This rule matches many programs/)).toBeInTheDocument();
+ expect(
+ screen.getByText(/This rule matches many programs/)
+ ).toBeInTheDocument();
});
});
@@ -603,7 +640,9 @@ describe('SeriesRuleEditorModal', () => {
});
it('does not crash when evaluateSeriesRulesByTvgId throws', async () => {
- vi.mocked(evaluateSeriesRulesByTvgId).mockRejectedValue(new Error('eval fail'));
+ vi.mocked(evaluateSeriesRulesByTvgId).mockRejectedValue(
+ new Error('eval fail')
+ );
renderWithTitle();
await expect(
waitFor(() => {
@@ -626,7 +665,12 @@ describe('SeriesRuleEditorModal', () => {
it('includes channel_id in payload when channelId is set', async () => {
vi.mocked(getChannelsSummary).mockResolvedValue(mockChannels);
- render( );
+ render(
+
+ );
fireEvent.click(screen.getByText('Save rule'));
await waitFor(() => {
expect(createSeriesRule).toHaveBeenCalledWith(
@@ -660,7 +704,11 @@ describe('SeriesRuleEditorModal', () => {
describe('SegmentedControl', () => {
it('changes title mode when SegmentedControl is clicked', () => {
render( );
- fireEvent.click(screen.getAllByText('Contains').find((el) => el.closest('[data-testid="segmented-control"]')));
+ fireEvent.click(
+ screen
+ .getAllByText('Contains')
+ .find((el) => el.closest('[data-testid="segmented-control"]'))
+ );
// No error thrown; state updated without crash
});
@@ -679,4 +727,4 @@ describe('SeriesRuleEditorModal', () => {
});
});
});
-});
\ No newline at end of file
+});
diff --git a/frontend/src/components/forms/__tests__/Stream.test.jsx b/frontend/src/components/forms/__tests__/Stream.test.jsx
index 18fe3b75..622fd46d 100644
--- a/frontend/src/components/forms/__tests__/Stream.test.jsx
+++ b/frontend/src/components/forms/__tests__/Stream.test.jsx
@@ -257,7 +257,9 @@ describe('Stream', () => {
describe('default values', () => {
it('pre-fills name from stream prop', () => {
render( );
- expect(screen.getByTestId('input-stream-name')).toHaveValue('Test Stream');
+ expect(screen.getByTestId('input-stream-name')).toHaveValue(
+ 'Test Stream'
+ );
});
it('pre-fills url from stream prop', () => {
@@ -289,7 +291,9 @@ describe('Stream', () => {
it('renders empty group when stream has no channel_group', () => {
render(
-
+
);
expect(screen.getByTestId('select-group')).toHaveValue('');
});
@@ -317,7 +321,9 @@ describe('Stream', () => {
target: { value: 'http://new.stream/live' },
});
- fireEvent.submit(screen.getByRole('button', { name: 'Submit' }).closest('form'));
+ fireEvent.submit(
+ screen.getByRole('button', { name: 'Submit' }).closest('form')
+ );
await waitFor(() => {
expect(StreamUtils.addStream).toHaveBeenCalledWith(
diff --git a/frontend/src/components/forms/__tests__/StreamProfile.test.jsx b/frontend/src/components/forms/__tests__/StreamProfile.test.jsx
index 50237950..922a0281 100644
--- a/frontend/src/components/forms/__tests__/StreamProfile.test.jsx
+++ b/frontend/src/components/forms/__tests__/StreamProfile.test.jsx
@@ -25,7 +25,8 @@ vi.mock('../../../utils/forms/StreamProfileUtils.js', () => ({
],
COMMAND_EXAMPLES: {
ffmpeg: '-user_agent {userAgent} -i {streamUrl} -c copy -f mpegts pipe:1',
- streamlink: '{streamUrl} --http-header User-Agent={userAgent} best --stdout',
+ streamlink:
+ '{streamUrl} --http-header User-Agent={userAgent} best --stdout',
},
}));
@@ -99,7 +100,9 @@ vi.mock('@mantine/core', () => ({
data-testid="checkbox-is-active"
type="checkbox"
checked={checked ?? false}
- onChange={(e) => onChange({ currentTarget: { checked: e.target.checked } })}
+ onChange={(e) =>
+ onChange({ currentTarget: { checked: e.target.checked } })
+ }
/>
),
@@ -114,7 +117,17 @@ vi.mock('@mantine/core', () => ({
{children}
) : null,
- Select: ({ label, value, onChange, data, placeholder, error, clearable, disabled, description }) => (
+ Select: ({
+ label,
+ value,
+ onChange,
+ data,
+ placeholder,
+ error,
+ clearable,
+ disabled,
+ description,
+ }) => (
{label}
{description &&
{description}
}
@@ -135,10 +148,24 @@ vi.mock('@mantine/core', () => ({
),
Stack: ({ children }) => {children}
,
- Textarea: ({ label, name, value, onChange, error, placeholder, description, disabled, ...rest }) => (
+ Textarea: ({
+ label,
+ name,
+ value,
+ onChange,
+ error,
+ placeholder,
+ description,
+ disabled,
+ ...rest
+ }) => (
{label}
- {description &&
{description}
}
+ {description && (
+
+ {description}
+
+ )}
{
sel({ userAgents: mockUserAgents })
);
vi.mocked(StreamProfileUtils.addStreamProfile).mockResolvedValue(undefined);
- vi.mocked(StreamProfileUtils.updateStreamProfile).mockResolvedValue(undefined);
+ vi.mocked(StreamProfileUtils.updateStreamProfile).mockResolvedValue(
+ undefined
+ );
vi.mocked(StreamProfileUtils.getResolver).mockReturnValue(undefined);
vi.mocked(StreamProfileUtils.toCommandSelection).mockImplementation((cmd) => {
const builtins = ['ffmpeg', 'streamlink', 'cvlc', 'yt-dlp'];
@@ -234,7 +263,9 @@ describe('StreamProfile', () => {
it('renders "Stream Profile" as the modal title', () => {
render( );
- expect(screen.getByTestId('modal-title')).toHaveTextContent('Stream Profile');
+ expect(screen.getByTestId('modal-title')).toHaveTextContent(
+ 'Stream Profile'
+ );
});
it('calls onClose when modal close button is clicked', () => {
@@ -302,7 +333,9 @@ describe('StreamProfile', () => {
it('pre-fills parameters from profile prop', () => {
render( );
- expect(screen.getByTestId('textarea-parameters')).toHaveValue('-i {streamUrl}');
+ expect(screen.getByTestId('textarea-parameters')).toHaveValue(
+ '-i {streamUrl}'
+ );
});
it('pre-selects command from profile prop', () => {
@@ -345,7 +378,9 @@ describe('StreamProfile', () => {
describe('command selection', () => {
it('does not show Custom Command input when a built-in is selected', () => {
render( );
- expect(screen.queryByTestId('input-custom-command')).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('input-custom-command')
+ ).not.toBeInTheDocument();
});
it('shows Custom Command input when Custom… is selected', () => {
@@ -364,14 +399,20 @@ describe('StreamProfile', () => {
fireEvent.change(screen.getByTestId('select-command'), {
target: { value: 'ffmpeg' },
});
- expect(screen.queryByTestId('input-custom-command')).not.toBeInTheDocument();
+ expect(
+ screen.queryByTestId('input-custom-command')
+ ).not.toBeInTheDocument();
});
it('shows Custom Command input when profile has a custom command', () => {
- vi.mocked(StreamProfileUtils.toCommandSelection).mockReturnValue('__custom__');
+ vi.mocked(StreamProfileUtils.toCommandSelection).mockReturnValue(
+ '__custom__'
+ );
render(
);
expect(screen.getByTestId('input-custom-command')).toBeInTheDocument();
@@ -604,4 +645,3 @@ describe('StreamProfile', () => {
});
});
});
-
diff --git a/frontend/src/components/forms/__tests__/SuperuserForm.test.jsx b/frontend/src/components/forms/__tests__/SuperuserForm.test.jsx
index b9b0dcbb..4c13a456 100644
--- a/frontend/src/components/forms/__tests__/SuperuserForm.test.jsx
+++ b/frontend/src/components/forms/__tests__/SuperuserForm.test.jsx
@@ -191,7 +191,9 @@ describe('SuperuserForm', () => {
fireEvent.change(screen.getByTestId('input-email'), {
target: { name: 'email', value: 'admin@example.com' },
});
- expect(screen.getByTestId('input-email')).toHaveValue('admin@example.com');
+ expect(screen.getByTestId('input-email')).toHaveValue(
+ 'admin@example.com'
+ );
});
it('initializes all fields as empty strings', () => {
@@ -329,4 +331,3 @@ describe('SuperuserForm', () => {
});
});
});
-
diff --git a/frontend/src/components/forms/__tests__/User.test.jsx b/frontend/src/components/forms/__tests__/User.test.jsx
index f73c6a81..69760807 100644
--- a/frontend/src/components/forms/__tests__/User.test.jsx
+++ b/frontend/src/components/forms/__tests__/User.test.jsx
@@ -34,7 +34,10 @@ const mockForm = {
setFieldValue: vi.fn(),
reset: vi.fn(),
getValues: vi.fn(() => ({ user_level: '3' })),
- onSubmit: vi.fn((fn) => (e) => { e?.preventDefault?.(); return fn(); }),
+ onSubmit: vi.fn((fn) => (e) => {
+ e?.preventDefault?.();
+ return fn();
+ }),
submitting: false,
};
@@ -73,7 +76,9 @@ vi.mock('@mantine/core', () => ({
opened ? (
{title}
-
×
+
+ ×
+
{children}
) : null,
@@ -82,29 +87,60 @@ vi.mock('@mantine/core', () => ({
{label}
onChange?.(e.target.value ? e.target.value.split(',') : [])}
+ onChange={(e) =>
+ onChange?.(e.target.value ? e.target.value.split(',') : [])
+ }
/>
),
- NumberInput: ({ label }) => {label}
,
+ NumberInput: ({ label }) => (
+
+ {label}
+
+
+ ),
PasswordInput: ({ label, disabled }) => (
- {label}
+
+ {label}
+
+
),
Select: ({ label, disabled }) => (
- {label}
+
+ {label}
+
+
),
Stack: ({ children }) => {children}
,
- Switch: ({ label }) => {label}
,
+ Switch: ({ label }) => (
+
+ {label}
+
+
+ ),
Tabs: ({ children }) => {children}
,
TabsList: ({ children }) => {children}
,
- TabsPanel: ({ children, value }) => {children}
,
- TabsTab: ({ children, value }) => {children} ,
- TagsInput: ({ label }) => {label}
,
+ TabsPanel: ({ children, value }) => (
+ {children}
+ ),
+ TabsTab: ({ children, value }) => (
+ {children}
+ ),
+ TagsInput: ({ label }) => (
+
+ {label}
+
+
+ ),
Text: ({ children }) => {children} ,
TextInput: ({ label, value, disabled, rightSection }) => (
{label}
-
+
{rightSection}
),
@@ -155,9 +191,10 @@ const setupMocks = ({
// Reset form state
mockForm.getValues.mockReturnValue({ user_level: '3' });
- mockForm.onSubmit.mockImplementation(
- (fn) => (e) => { e?.preventDefault?.(); return fn(); }
- );
+ mockForm.onSubmit.mockImplementation((fn) => (e) => {
+ e?.preventDefault?.();
+ return fn();
+ });
return { mockSetUser };
};
@@ -169,7 +206,9 @@ describe('User', () => {
vi.clearAllMocks();
vi.mocked(UserUtils.createUser).mockResolvedValue({});
vi.mocked(UserUtils.updateUser).mockResolvedValue({});
- vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'new-api-key' });
+ vi.mocked(UserUtils.generateApiKey).mockResolvedValue({
+ key: 'new-api-key',
+ });
vi.mocked(UserUtils.revokeApiKey).mockResolvedValue({ success: true });
vi.mocked(UserUtils.formValuesToPayload).mockReturnValue({ user_level: 3 });
vi.mocked(UserUtils.getFormInitialValues).mockReturnValue({});
@@ -214,9 +253,7 @@ describe('User', () => {
it('shows Permissions tab when admin edits another user', () => {
setupMocks({ authUser: makeAdminUser() });
- render(
-
- );
+ render( );
expect(screen.getByTestId('tab-permissions')).toBeInTheDocument();
});
@@ -230,7 +267,11 @@ describe('User', () => {
it('hides Permissions tab for non-admin user', () => {
setupMocks({ authUser: makeRegularUser() });
render(
-
+
);
expect(screen.queryByTestId('tab-permissions')).not.toBeInTheDocument();
});
@@ -242,7 +283,11 @@ describe('User', () => {
it('shows Output Format Override for admin', () => {
setupMocks({ authUser: makeAdminUser() });
render(
-
+
);
expect(screen.getByText('Output Format Override')).toBeInTheDocument();
});
@@ -250,15 +295,25 @@ describe('User', () => {
it('hides Output Format Override for non-admin', () => {
setupMocks({ authUser: makeRegularUser() });
render(
-
+
);
- expect(screen.queryByText('Output Format Override')).not.toBeInTheDocument();
+ expect(
+ screen.queryByText('Output Format Override')
+ ).not.toBeInTheDocument();
});
it('shows Output Profile Override for admin', () => {
setupMocks({ authUser: makeAdminUser() });
render(
-
+
);
expect(screen.getByText('Output Profile Override')).toBeInTheDocument();
});
@@ -266,7 +321,11 @@ describe('User', () => {
it('shows Allowed IPs for admin', () => {
setupMocks({ authUser: makeAdminUser() });
render(
-
+
);
expect(screen.getByText('Allowed IPs')).toBeInTheDocument();
});
@@ -274,7 +333,11 @@ describe('User', () => {
it('hides Allowed IPs for non-admin', () => {
setupMocks({ authUser: makeRegularUser() });
render(
-
+
);
expect(screen.queryByText('Allowed IPs')).not.toBeInTheDocument();
});
@@ -286,7 +349,11 @@ describe('User', () => {
it('shows "Generate API Key" when no key exists', () => {
setupMocks({ authUser: makeAdminUser() });
render(
-
+
);
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
});
@@ -305,10 +372,16 @@ describe('User', () => {
it('calls generateApiKey and switches button to "Regenerate API Key"', async () => {
setupMocks({ authUser: makeAdminUser() });
- vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'brand-new-key' });
+ vi.mocked(UserUtils.generateApiKey).mockResolvedValue({
+ key: 'brand-new-key',
+ });
render(
-
+
);
expect(screen.getByText('Generate API Key')).toBeInTheDocument();
@@ -322,10 +395,16 @@ describe('User', () => {
it('also sets the key when response contains raw_key instead of key', async () => {
setupMocks({ authUser: makeAdminUser() });
- vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ raw_key: 'raw-value' });
+ vi.mocked(UserUtils.generateApiKey).mockResolvedValue({
+ raw_key: 'raw-value',
+ });
render(
-
+
);
fireEvent.click(screen.getByText('Generate API Key'));
@@ -339,7 +418,11 @@ describe('User', () => {
// non-admin editing someone else
setupMocks({ authUser: makeRegularUser({ id: 5 }) });
render(
-
+
);
// No button visible since canGenerateKey is false
expect(screen.queryByText('Generate API Key')).not.toBeInTheDocument();
@@ -351,7 +434,11 @@ describe('User', () => {
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({});
render(
-
+
);
fireEvent.click(screen.getByText('Generate API Key'));
@@ -367,7 +454,11 @@ describe('User', () => {
vi.mocked(UserUtils.generateApiKey).mockResolvedValue({ key: 'show-me' });
render(
-
+
);
fireEvent.click(screen.getByText('Generate API Key'));
@@ -397,7 +488,11 @@ describe('User', () => {
it('hides "Revoke API Key" when no key exists', () => {
setupMocks({ authUser: makeAdminUser() });
render(
-
+
);
expect(screen.queryByText('Revoke API Key')).not.toBeInTheDocument();
});
@@ -455,7 +550,7 @@ describe('User', () => {
});
});
- it('does not update auth store when revoking another user\'s key', async () => {
+ it("does not update auth store when revoking another user's key", async () => {
const admin = makeAdminUser({ id: 1 });
const { mockSetUser } = setupMocks({ authUser: admin });
@@ -518,7 +613,11 @@ describe('User', () => {
vi.mocked(UserUtils.updateUser).mockResolvedValue({ id: 2 });
render(
-
+
);
fireEvent.click(screen.getByText('Save'));
@@ -569,7 +668,11 @@ describe('User', () => {
vi.mocked(UserUtils.updateUser).mockResolvedValue({ id: 2 });
render(
-
+
);
fireEvent.click(screen.getByText('Save'));
@@ -585,7 +688,9 @@ describe('User', () => {
describe('useEffect on user prop', () => {
it('calls userToFormValues and sets form values when user has an id', () => {
const user = makeRegularUser({ id: 2 });
- vi.mocked(UserUtils.userToFormValues).mockReturnValue({ username: 'user1' });
+ vi.mocked(UserUtils.userToFormValues).mockReturnValue({
+ username: 'user1',
+ });
setupMocks({ authUser: makeAdminUser() });
render( );
@@ -607,7 +712,11 @@ describe('User', () => {
it('calls setValues with a generated xc_password when rotate icon is clicked', () => {
setupMocks({ authUser: makeAdminUser() });
render(
-
+
);
const rotateButton = screen
@@ -623,7 +732,11 @@ describe('User', () => {
it('disables the XC password rotate button for non-admin', () => {
setupMocks({ authUser: makeRegularUser() });
render(
-
+
);
const rotateButton = screen
@@ -641,7 +754,11 @@ describe('User', () => {
it('excludes "All" (0) when other profiles are selected alongside it', () => {
setupMocks({ authUser: makeAdminUser(), profiles });
render(
-
+
);
const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
@@ -656,31 +773,38 @@ describe('User', () => {
it('sets only ["0"] when "All" is newly selected', () => {
setupMocks({ authUser: makeAdminUser(), profiles });
render(
-
+
);
const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
fireEvent.change(multiSelect, { target: { value: '0' } });
- expect(mockForm.setFieldValue).toHaveBeenCalledWith(
- 'channel_profiles',
- ['0']
- );
+ expect(mockForm.setFieldValue).toHaveBeenCalledWith('channel_profiles', [
+ '0',
+ ]);
});
it('allows multiple non-all profiles together', () => {
setupMocks({ authUser: makeAdminUser(), profiles });
render(
-
+
);
const multiSelect = screen.getByTestId('multiselect-Channel Profiles');
fireEvent.change(multiSelect, { target: { value: '1,2' } });
- expect(mockForm.setFieldValue).toHaveBeenCalledWith(
- 'channel_profiles',
- ['1', '2']
- );
+ expect(mockForm.setFieldValue).toHaveBeenCalledWith('channel_profiles', [
+ '1',
+ '2',
+ ]);
});
});
-});
\ No newline at end of file
+});
diff --git a/frontend/src/components/forms/__tests__/UserAgent.test.jsx b/frontend/src/components/forms/__tests__/UserAgent.test.jsx
index 2292b8cd..a7c50390 100644
--- a/frontend/src/components/forms/__tests__/UserAgent.test.jsx
+++ b/frontend/src/components/forms/__tests__/UserAgent.test.jsx
@@ -31,7 +31,9 @@ vi.mock('@mantine/core', () => ({
opened ? (
{title}
-
×
+
+ ×
+
{children}
) : null,
@@ -105,23 +107,37 @@ describe('UserAgent', () => {
it('pre-fills fields from userAgent prop', () => {
render(
-
+
);
expect(screen.getByTestId('input-Name')).toHaveValue('Chrome');
expect(screen.getByTestId('input-User-Agent')).toHaveValue('Mozilla/5.0');
- expect(screen.getByTestId('input-Description')).toHaveValue('Chrome browser');
+ expect(screen.getByTestId('input-Description')).toHaveValue(
+ 'Chrome browser'
+ );
});
it('checks Is Active checkbox when userAgent.is_active is true', () => {
render(
-
+
);
expect(screen.getByTestId('checkbox-is-active')).toBeChecked();
});
it('unchecks Is Active checkbox when userAgent.is_active is false', () => {
render(
-
+
);
expect(screen.getByTestId('checkbox-is-active')).not.toBeChecked();
});
@@ -137,7 +153,11 @@ describe('UserAgent', () => {
describe('field interactions', () => {
it('toggles Is Active checkbox', () => {
render(
-
+
);
const checkbox = screen.getByTestId('checkbox-is-active');
fireEvent.click(checkbox);
@@ -158,7 +178,11 @@ describe('UserAgent', () => {
it('calls updateUserAgent with the id when editing an existing userAgent', async () => {
render(
-
+
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
@@ -171,7 +195,11 @@ describe('UserAgent', () => {
it('does not call addUserAgent when updating', async () => {
render(
-
+
);
fireEvent.click(screen.getByText('Submit'));
await waitFor(() => {
@@ -214,7 +242,11 @@ describe('UserAgent', () => {
describe('form reset on userAgent change', () => {
it('resets form values when userAgent prop changes', () => {
const { rerender } = render(
-
+
);
expect(screen.getByTestId('input-Name')).toHaveValue('Chrome');
@@ -222,7 +254,11 @@ describe('UserAgent', () => {
);
expect(screen.getByTestId('input-Name')).toHaveValue('Firefox');
@@ -230,10 +266,14 @@ describe('UserAgent', () => {
it('resets to empty when userAgent prop is removed', () => {
const { rerender } = render(
-
+
);
rerender( );
expect(screen.getByTestId('input-Name')).toHaveValue('');
});
});
-});
\ No newline at end of file
+});
diff --git a/frontend/src/components/forms/__tests__/VODCategoryFilter.test.jsx b/frontend/src/components/forms/__tests__/VODCategoryFilter.test.jsx
index de87d5c0..31cd5e2b 100644
--- a/frontend/src/components/forms/__tests__/VODCategoryFilter.test.jsx
+++ b/frontend/src/components/forms/__tests__/VODCategoryFilter.test.jsx
@@ -7,14 +7,19 @@ vi.mock('../../../store/useVODStore', () => ({ default: vi.fn() }));
// ── lucide-react ───────────────────────────────────────────────────────────────
vi.mock('lucide-react', () => ({
CircleCheck: () => ,
- CircleX: () => ,
+ CircleX: () => ,
}));
// ── Mantine core ───────────────────────────────────────────────────────────────
vi.mock('@mantine/core', () => ({
- Box: ({ children }) => {children}
,
- Button: ({ children, onClick, disabled, color, variant }) => (
-
+ Box: ({ children }) => {children}
,
+ Button: ({ children, onClick, disabled, color, variant }) => (
+
{children}
),
@@ -24,17 +29,19 @@ vi.mock('@mantine/core', () => ({
type="checkbox"
aria-label={label}
checked={checked}
- onChange={(e) => onChange?.({ currentTarget: { checked: e.target.checked } })}
+ onChange={(e) =>
+ onChange?.({ currentTarget: { checked: e.target.checked } })
+ }
disabled={disabled}
/>
{label}
),
- Flex: ({ children }) => {children}
,
- Group: ({ children }) => {children}
,
+ Flex: ({ children }) => {children}
,
+ Group: ({ children }) => {children}
,
SimpleGrid: ({ children }) => {children}
,
- Stack: ({ children }) => {children}
,
- Text: ({ children }) => {children} ,
+ Stack: ({ children }) => {children}
,
+ Text: ({ children }) => {children} ,
TextInput: ({ label, value, onChange, placeholder }) => (
[
m3u_accounts: [{ m3u_account: 10, enabled: true }],
category_type: 'movies',
},
- { id: 2, name: 'Comedy', m3u_accounts: [{ m3u_account: 10, enabled: false }], category_type: 'movies' },
- { id: 3, name: 'Drama', m3u_accounts: [{ m3u_account: 10, enabled: true }], category_type: 'movies' },
- { id: 4, name: 'News', m3u_accounts: [{ m3u_account: 10, enabled: true }], category_type: 'series' },
+ {
+ id: 2,
+ name: 'Comedy',
+ m3u_accounts: [{ m3u_account: 10, enabled: false }],
+ category_type: 'movies',
+ },
+ {
+ id: 3,
+ name: 'Drama',
+ m3u_accounts: [{ m3u_account: 10, enabled: true }],
+ category_type: 'movies',
+ },
+ {
+ id: 4,
+ name: 'News',
+ m3u_accounts: [{ m3u_account: 10, enabled: true }],
+ category_type: 'series',
+ },
];
-const makePlaylist = (overrides = {}) => ({ id: 10, name: 'My Playlist', ...overrides });
+const makePlaylist = (overrides = {}) => ({
+ id: 10,
+ name: 'My Playlist',
+ ...overrides,
+});
const categoriesToDict = (arr) =>
arr.reduce((acc, cat) => ({ ...acc, [cat.id]: cat }), {});
const setupMocks = ({ categories = makeCategories() } = {}) => {
- const dict = Array.isArray(categories) ? categoriesToDict(categories) : categories;
+ const dict = Array.isArray(categories)
+ ? categoriesToDict(categories)
+ : categories;
vi.mocked(useVODStore).mockImplementation((sel) => sel({ categories: dict }));
};
@@ -120,13 +148,19 @@ describe('VODCategoryFilter', () => {
it('renders a button for each category matching the type and playlist', () => {
render( );
- expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Action' })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Comedy' })
+ ).toBeInTheDocument();
});
it('does not render categories of a different type', () => {
render( );
- expect(screen.queryByRole('checkbox', { name: 'News' })).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('checkbox', { name: 'News' })
+ ).not.toBeInTheDocument();
});
it('renders the text filter input', () => {
@@ -147,7 +181,9 @@ describe('VODCategoryFilter', () => {
it('renders the Auto-enable new groups checkbox', () => {
render( );
- expect(screen.getByLabelText(/automatically enable new/i)).toBeInTheDocument();
+ expect(
+ screen.getByLabelText(/automatically enable new/i)
+ ).toBeInTheDocument();
});
});
@@ -158,15 +194,25 @@ describe('VODCategoryFilter', () => {
render( );
const input = screen.getByPlaceholderText(/filter/i);
fireEvent.change(input, { target: { value: 'act' } });
- expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Comedy' })).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Action' })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Comedy' })
+ ).not.toBeInTheDocument();
});
it('is case-insensitive', () => {
render( );
- fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'COMEDY' } });
- expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ fireEvent.change(screen.getByPlaceholderText(/filter/i), {
+ target: { value: 'COMEDY' },
+ });
+ expect(
+ screen.getByRole('button', { name: 'Comedy' })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Action' })
+ ).not.toBeInTheDocument();
});
it('shows all categories when filter is cleared', () => {
@@ -174,8 +220,12 @@ describe('VODCategoryFilter', () => {
const input = screen.getByPlaceholderText(/filter/i);
fireEvent.change(input, { target: { value: 'act' } });
fireEvent.change(input, { target: { value: '' } });
- expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Action' })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Comedy' })
+ ).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Drama' })).toBeInTheDocument();
});
@@ -184,7 +234,9 @@ describe('VODCategoryFilter', () => {
fireEvent.change(screen.getByPlaceholderText(/filter/i), {
target: { value: 'zzznomatch' },
});
- expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Action' })
+ ).not.toBeInTheDocument();
});
});
@@ -193,34 +245,58 @@ describe('VODCategoryFilter', () => {
describe('status filter', () => {
it('shows only enabled categories when "Enabled" segment is selected', () => {
const props = defaultProps({
- categoryStates: [ { id: 1, name: 'Action', enabled: true }, { id: 2, name: 'Comedy', enabled: false }, { id: 3, name: 'Drama', enabled: true } ],
+ categoryStates: [
+ { id: 1, name: 'Action', enabled: true },
+ { id: 2, name: 'Comedy', enabled: false },
+ { id: 3, name: 'Drama', enabled: true },
+ ],
});
render( );
fireEvent.click(screen.getByTestId('segment-enabled'));
- expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Comedy' })).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Action' })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Comedy' })
+ ).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Drama' })).toBeInTheDocument();
});
it('shows only disabled categories when "Disabled" segment is selected', () => {
const props = defaultProps({
- categoryStates: [ { id: 1, name: 'Action', enabled: true }, { id: 2, name: 'Comedy', enabled: false }, { id: 3, name: 'Drama', enabled: true } ],
+ categoryStates: [
+ { id: 1, name: 'Action', enabled: true },
+ { id: 2, name: 'Comedy', enabled: false },
+ { id: 3, name: 'Drama', enabled: true },
+ ],
});
render( );
fireEvent.click(screen.getByTestId('segment-disabled'));
- expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Action' })
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Comedy' })
+ ).toBeInTheDocument();
});
it('shows all categories when "All" segment is active', () => {
const props = defaultProps({
- categoryStates: [ { id: 1, name: 'Action', enabled: true }, { id: 2, name: 'Comedy', enabled: false }, { id: 3, name: 'Drama', enabled: true } ],
+ categoryStates: [
+ { id: 1, name: 'Action', enabled: true },
+ { id: 2, name: 'Comedy', enabled: false },
+ { id: 3, name: 'Drama', enabled: true },
+ ],
});
render( );
fireEvent.click(screen.getByTestId('segment-disabled'));
fireEvent.click(screen.getByTestId('segment-all'));
- expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Action' })
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Comedy' })
+ ).toBeInTheDocument();
});
});
@@ -230,11 +306,17 @@ describe('VODCategoryFilter', () => {
it('applies both text and status filters simultaneously', () => {
const props = defaultProps();
render( );
- fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'o' } });
+ fireEvent.change(screen.getByPlaceholderText(/filter/i), {
+ target: { value: 'o' },
+ });
fireEvent.click(screen.getByTestId('segment-disabled'));
// "Comedy" matches "o" AND is disabled; "Action" matches "o" but is enabled
- expect(screen.getByRole('button', { name: 'Comedy' })).toBeInTheDocument();
- expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ expect(
+ screen.getByRole('button', { name: 'Comedy' })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Action' })
+ ).not.toBeInTheDocument();
});
});
@@ -247,7 +329,11 @@ describe('VODCategoryFilter', () => {
);
@@ -264,11 +350,17 @@ describe('VODCategoryFilter', () => {
);
- fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'act' } });
+ fireEvent.change(screen.getByPlaceholderText(/filter/i), {
+ target: { value: 'act' },
+ });
fireEvent.click(screen.getByText('Select Visible'));
const called = setCategoryStates.mock.calls.at(-1)[0];
expect(called.find((s) => s.id === 1).enabled).toBe(true);
@@ -295,11 +387,17 @@ describe('VODCategoryFilter', () => {
);
- fireEvent.change(screen.getByPlaceholderText(/filter/i), { target: { value: 'comedy' } });
+ fireEvent.change(screen.getByPlaceholderText(/filter/i), {
+ target: { value: 'comedy' },
+ });
fireEvent.click(screen.getByText('Deselect Visible'));
const called = setCategoryStates.mock.calls.at(-1)[0];
expect(called.find((s) => s.id === 2).enabled).toBe(false);
@@ -315,7 +413,10 @@ describe('VODCategoryFilter', () => {
const setAutoEnableNewGroups = vi.fn();
render(
);
fireEvent.click(screen.getByLabelText(/automatically enable new/i));
@@ -326,7 +427,10 @@ describe('VODCategoryFilter', () => {
const setAutoEnableNewGroups = vi.fn();
render(
);
fireEvent.click(screen.getByLabelText(/automatically enable new/i));
@@ -340,23 +444,34 @@ describe('VODCategoryFilter', () => {
it('renders no category buttons when categories list is empty', () => {
setupMocks({ categories: [] });
render( );
- expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Action' })
+ ).not.toBeInTheDocument();
});
it('renders no category buttons when categoryStates is empty', () => {
render( );
- expect(screen.queryByRole('button', { name: 'Action' })).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Action' })
+ ).not.toBeInTheDocument();
});
it('renders categories only for the matching playlist id', () => {
setupMocks({
categories: [
...makeCategories(),
- { id: 99, name: 'Foreign', m3u_accounts: [{ m3u_account: 99, enabled: true }], category_type: 'movies' },
+ {
+ id: 99,
+ name: 'Foreign',
+ m3u_accounts: [{ m3u_account: 99, enabled: true }],
+ category_type: 'movies',
+ },
],
});
render( );
- expect(screen.queryByRole('button', { name: 'Foreign' })).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole('button', { name: 'Foreign' })
+ ).not.toBeInTheDocument();
});
});
-});
\ No newline at end of file
+});
diff --git a/frontend/src/utils/forms/SeriesRuleEditorModalUtils.js b/frontend/src/utils/forms/SeriesRuleEditorModalUtils.js
index dbcd545e..0eddc9c1 100644
--- a/frontend/src/utils/forms/SeriesRuleEditorModalUtils.js
+++ b/frontend/src/utils/forms/SeriesRuleEditorModalUtils.js
@@ -25,7 +25,7 @@ export function formatRange(start, end) {
const s = new Date(start);
const e = new Date(end);
- if(isNaN(s) || isNaN(e)) throw new Error('Invalid date');
+ if (isNaN(s) || isNaN(e)) throw new Error('Invalid date');
const sameDay = s.toDateString() === e.toDateString();
const dateStr = s.toLocaleDateString();
@@ -81,4 +81,4 @@ export const getChannelOptions = (allChannels, tvgsById, tvgId) => {
}
}
return [...matching, ...others];
-};
\ No newline at end of file
+};
diff --git a/frontend/src/utils/forms/UserUtils.js b/frontend/src/utils/forms/UserUtils.js
index 256d84bb..3627d6ca 100644
--- a/frontend/src/utils/forms/UserUtils.js
+++ b/frontend/src/utils/forms/UserUtils.js
@@ -1,6 +1,6 @@
-import {NETWORK_ACCESS_OPTIONS, USER_LEVELS} from "../../constants.js";
+import { NETWORK_ACCESS_OPTIONS, USER_LEVELS } from '../../constants.js';
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../networkUtils.js';
-import API from "../../api.js";
+import API from '../../api.js';
const isValidNetworkEntry = (entry) =>
entry.match(IPV4_CIDR_REGEX) ||
@@ -123,14 +123,14 @@ export const getFormInitialValues = () => {
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)
+ !values.username.match(/^[a-z0-9]+$/i)
? 'Streamer username must be alphanumeric'
: null,
password:
@@ -141,10 +141,8 @@ export const getFormValidators = (user) => {
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)
- )
+ 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/forms/__tests__/RecordingDetailsModalUtils.test.js b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js
index 303b891b..7ad80f63 100644
--- a/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js
+++ b/frontend/src/utils/forms/__tests__/RecordingDetailsModalUtils.test.js
@@ -11,7 +11,6 @@ vi.mock('../../../api.js', () => ({
},
}));
-
describe('RecordingDetailsModalUtils', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -653,7 +652,9 @@ describe('RecordingDetailsModalUtils', () => {
it('should propagate API errors', async () => {
API.getChannel.mockRejectedValue(new Error('Not found'));
- await expect(RecordingDetailsModalUtils.getChannel(99)).rejects.toThrow('Not found');
+ await expect(RecordingDetailsModalUtils.getChannel(99)).rejects.toThrow(
+ 'Not found'
+ );
});
});
@@ -680,7 +681,11 @@ describe('RecordingDetailsModalUtils', () => {
API.updateRecordingMetadata.mockResolvedValue({});
const recording = { id: 5 };
- await RecordingDetailsModalUtils.updateRecordingMetadata(recording, '', 'Some description');
+ await RecordingDetailsModalUtils.updateRecordingMetadata(
+ recording,
+ '',
+ 'Some description'
+ );
expect(API.updateRecordingMetadata).toHaveBeenCalledWith(5, {
title: 'Custom Recording',
@@ -692,7 +697,11 @@ describe('RecordingDetailsModalUtils', () => {
API.updateRecordingMetadata.mockResolvedValue({});
const recording = { id: 5 };
- await RecordingDetailsModalUtils.updateRecordingMetadata(recording, null, 'desc');
+ await RecordingDetailsModalUtils.updateRecordingMetadata(
+ recording,
+ null,
+ 'desc'
+ );
expect(API.updateRecordingMetadata).toHaveBeenCalledWith(5, {
title: 'Custom Recording',
@@ -704,7 +713,11 @@ describe('RecordingDetailsModalUtils', () => {
API.updateRecordingMetadata.mockRejectedValue(new Error('Server error'));
await expect(
- RecordingDetailsModalUtils.updateRecordingMetadata({ id: 1 }, 'Title', 'Desc')
+ RecordingDetailsModalUtils.updateRecordingMetadata(
+ { id: 1 },
+ 'Title',
+ 'Desc'
+ )
).rejects.toThrow('Server error');
});
});
@@ -723,9 +736,9 @@ describe('RecordingDetailsModalUtils', () => {
it('should propagate API errors', async () => {
API.refreshArtwork.mockRejectedValue(new Error('Artwork fetch failed'));
- await expect(RecordingDetailsModalUtils.refreshArtwork(7)).rejects.toThrow(
- '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
index be7cf1a2..6e3ae901 100644
--- a/frontend/src/utils/forms/__tests__/RecordingUtils.test.js
+++ b/frontend/src/utils/forms/__tests__/RecordingUtils.test.js
@@ -85,7 +85,7 @@ describe('RecordingUtils', () => {
});
});
-// ─── toIsoIfDate ──────────────────────────────────────────────────────────────
+ // ─── toIsoIfDate ──────────────────────────────────────────────────────────────
describe('toIsoIfDate', () => {
it('returns ISO string for a valid Date', () => {
@@ -107,7 +107,7 @@ describe('RecordingUtils', () => {
});
});
-// ─── toDateString ─────────────────────────────────────────────────────────────
+ // ─── toDateString ─────────────────────────────────────────────────────────────
describe('toDateString', () => {
it('formats a Date to YYYY-MM-DD', () => {
@@ -129,7 +129,7 @@ describe('RecordingUtils', () => {
});
});
-// ─── createRoundedDate ────────────────────────────────────────────────────────
+ // ─── createRoundedDate ────────────────────────────────────────────────────────
describe('createRoundedDate', () => {
it('returns a Date', () => {
@@ -141,7 +141,7 @@ describe('RecordingUtils', () => {
});
});
-// ─── timeChange ───────────────────────────────────────────────────────────────
+ // ─── timeChange ───────────────────────────────────────────────────────────────
describe('timeChange', () => {
it('calls setter with string directly', () => {
@@ -163,7 +163,7 @@ describe('RecordingUtils', () => {
});
});
-// ─── API wrappers ─────────────────────────────────────────────────────────────
+ // ─── API wrappers ─────────────────────────────────────────────────────────────
describe('getChannelsSummary', () => {
it('calls API.getChannelsSummary', () => {
@@ -197,13 +197,13 @@ describe('RecordingUtils', () => {
});
});
-// ─── sortedChannelOptions ─────────────────────────────────────────────────────
+ // ─── 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 },
+ { 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', () => {
@@ -213,7 +213,9 @@ describe('RecordingUtils', () => {
});
it('maps each channel to { value, label }', () => {
- const result = sortedChannelOptions([{ id: 7, name: 'FOX', channel_number: 3 }]);
+ const result = sortedChannelOptions([
+ { id: 7, name: 'FOX', channel_number: 3 },
+ ]);
expect(result[0]).toEqual({ value: '7', label: 'FOX' });
});
@@ -269,22 +271,25 @@ describe('RecordingUtils', () => {
});
});
-// ─── numberedChannelLabel ─────────────────────────────────────────────────────
+ // ─── numberedChannelLabel ─────────────────────────────────────────────────────
describe('numberedChannelLabel', () => {
it('returns "number - name" when both are present', () => {
- expect(numberedChannelLabel({ id: 1, name: 'HBO', channel_number: 501 }))
- .toBe('501 - HBO');
+ 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');
+ 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');
+ expect(numberedChannelLabel({ id: 5, channel_number: 10 })).toBe(
+ '10 - Channel 5'
+ );
});
it('falls back to "Channel id" when both name and number are missing', () => {
@@ -292,7 +297,7 @@ describe('RecordingUtils', () => {
});
});
-// ─── getSingleFormDefaults ────────────────────────────────────────────────────
+ // ─── getSingleFormDefaults ────────────────────────────────────────────────────
describe('getSingleFormDefaults', () => {
it('returns defaults with no arguments', () => {
@@ -320,7 +325,7 @@ describe('RecordingUtils', () => {
});
});
-// ─── getRecurringFormDefaults ─────────────────────────────────────────────────
+ // ─── getRecurringFormDefaults ─────────────────────────────────────────────────
describe('getRecurringFormDefaults', () => {
it('returns defaults with no arguments', () => {
@@ -341,7 +346,7 @@ describe('RecordingUtils', () => {
});
});
-// ─── buildSinglePayload ───────────────────────────────────────────────────────
+ // ─── buildSinglePayload ───────────────────────────────────────────────────────
describe('buildSinglePayload', () => {
it('builds payload from values', () => {
@@ -357,7 +362,7 @@ describe('RecordingUtils', () => {
});
});
-// ─── buildRecurringPayload ────────────────────────────────────────────────────
+ // ─── buildRecurringPayload ────────────────────────────────────────────────────
describe('buildRecurringPayload', () => {
it('builds payload from values', () => {
@@ -396,7 +401,7 @@ describe('RecordingUtils', () => {
});
});
-// ─── singleFormValidators ─────────────────────────────────────────────────────
+ // ─── singleFormValidators ─────────────────────────────────────────────────────
describe('singleFormValidators', () => {
describe('end_time', () => {
@@ -424,12 +429,14 @@ describe('RecordingUtils', () => {
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();
+ expect(
+ singleFormValidators.end_time(end, { start_time: start })
+ ).toBeNull();
});
});
});
-// ─── recurringFormValidators ──────────────────────────────────────────────────
+ // ─── recurringFormValidators ──────────────────────────────────────────────────
describe('recurringFormValidators', () => {
describe('days_of_week', () => {
@@ -458,9 +465,11 @@ describe('RecordingUtils', () => {
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'
- );
+ 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)', () => {
@@ -488,15 +497,17 @@ describe('RecordingUtils', () => {
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'
- );
+ 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();
+ expect(
+ recurringFormValidators.end_date(end, { start_date: start })
+ ).toBeNull();
});
});
});
diff --git a/frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js b/frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js
index 1731e08d..0c234085 100644
--- a/frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js
+++ b/frontend/src/utils/forms/__tests__/SeriesRuleEditorModalUtils.test.js
@@ -8,11 +8,10 @@ vi.mock('../../../api.js', () => ({
}));
vi.mock('../RecordingUtils.js', () => ({
- numberedChannelLabel: vi.fn(
- (item) =>
- item.channel_number
- ? `${item.channel_number} - ${item.name || `Channel ${item.id}`}`
- : item.name || `Channel ${item.id}`
+ 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) => ({
@@ -33,7 +32,10 @@ import {
getChannelOptions,
previewSeriesRule,
} from '../SeriesRuleEditorModalUtils.js';
-import { sortedChannelOptions, numberedChannelLabel } from '../RecordingUtils.js';
+import {
+ sortedChannelOptions,
+ numberedChannelLabel,
+} from '../RecordingUtils.js';
// ─────────────────────────────────────────────────────────────────────────────
@@ -112,7 +114,7 @@ describe('SeriesRuleEditorModalUtils', () => {
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 end = '2024-06-01T11:30:00';
const result = formatRange(start, end);
// Must include a dash separating start and end times (not "->")
expect(result).toMatch(/-(?!>)/);
@@ -121,7 +123,7 @@ describe('SeriesRuleEditorModalUtils', () => {
it('formats cross-day range with "->" separator', () => {
const start = '2024-06-01T23:00:00';
- const end = '2024-06-02T01:00:00';
+ const end = '2024-06-02T01:00:00';
const result = formatRange(start, end);
expect(result).toMatch(/->/);
});
@@ -133,7 +135,7 @@ describe('SeriesRuleEditorModalUtils', () => {
it('includes a date string component in the output', () => {
const start = '2024-06-01T10:00:00';
- const end = '2024-06-01T11:00:00';
+ const end = '2024-06-01T11:00:00';
const result = formatRange(start, end);
// toLocaleDateString produces something non-empty
expect(result.length).toBeGreaterThan(5);
@@ -141,7 +143,7 @@ describe('SeriesRuleEditorModalUtils', () => {
it('handles ISO strings with timezone offsets', () => {
const start = '2024-06-01T10:00:00Z';
- const end = '2024-06-01T11:00:00Z';
+ const end = '2024-06-01T11:00:00Z';
// Should not throw
expect(() => formatRange(start, end)).not.toThrow();
});
@@ -227,15 +229,15 @@ describe('SeriesRuleEditorModalUtils', () => {
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' },
+ { 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' },
+ 'epg-2': { tvg_id: 'tvg-hbo' },
+ 'epg-3': { tvg_id: 'tvg-cnn' },
});
it('calls sortedChannelOptions with allChannels and numberedChannelLabel', () => {
@@ -262,9 +264,9 @@ describe('SeriesRuleEditorModalUtils', () => {
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' },
+ { value: '1', label: 'ESPN' },
+ { value: '2', label: 'HBO' },
+ { value: '3', label: 'CNN' },
]);
const channels = makeChannels();
@@ -273,15 +275,15 @@ describe('SeriesRuleEditorModalUtils', () => {
// 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)
+ 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' },
+ { value: '2', label: 'HBO' },
]);
const channels = makeChannels();
@@ -292,7 +294,9 @@ describe('SeriesRuleEditorModalUtils', () => {
});
it('handles channel with no epg_data_id (cTvg is null)', () => {
- const channels = [{ id: 9, name: 'NoEPG', channel_number: 1, epg_data_id: null }];
+ const channels = [
+ { id: 9, name: 'NoEPG', channel_number: 1, epg_data_id: null },
+ ];
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
{ value: '9', label: 'NoEPG' },
]);
@@ -305,7 +309,14 @@ describe('SeriesRuleEditorModalUtils', () => {
});
it('handles missing epg_data_id entry in tvgsById', () => {
- const channels = [{ id: 5, name: 'Unknown', channel_number: 1, epg_data_id: 'epg-missing' }];
+ const channels = [
+ {
+ id: 5,
+ name: 'Unknown',
+ channel_number: 1,
+ epg_data_id: 'epg-missing',
+ },
+ ];
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
{ value: '5', label: 'Unknown' },
]);
@@ -333,20 +344,20 @@ describe('SeriesRuleEditorModalUtils', () => {
it('places multiple matching channels before non-matching', () => {
vi.mocked(sortedChannelOptions).mockReturnValueOnce([
- { value: '1', label: 'ESPN' },
+ { value: '1', label: 'ESPN' },
{ value: '2', label: 'ESPN2' },
- { value: '3', label: 'CNN' },
+ { 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' },
+ { 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' },
+ 'epg-3': { tvg_id: 'tvg-cnn' },
};
const result = getChannelOptions(channels, tvgsById, 'tvg-espn');
@@ -385,9 +396,13 @@ describe('SeriesRuleEditorModalUtils', () => {
});
it('propagates rejection from API.previewSeriesRule', async () => {
- vi.mocked(API.previewSeriesRule).mockRejectedValue(new Error('Network error'));
+ vi.mocked(API.previewSeriesRule).mockRejectedValue(
+ new Error('Network error')
+ );
const controller = new AbortController();
- await expect(previewSeriesRule({}, controller)).rejects.toThrow('Network error');
+ await expect(previewSeriesRule({}, controller)).rejects.toThrow(
+ 'Network error'
+ );
});
it('propagates AbortError when signal is aborted', async () => {
@@ -397,7 +412,9 @@ describe('SeriesRuleEditorModalUtils', () => {
const controller = new AbortController();
controller.abort();
- await expect(previewSeriesRule({}, controller)).rejects.toThrow('Aborted');
+ await expect(previewSeriesRule({}, controller)).rejects.toThrow(
+ 'Aborted'
+ );
});
it('passes through all fields of the preview key', async () => {
@@ -422,4 +439,3 @@ describe('SeriesRuleEditorModalUtils', () => {
});
});
});
-
diff --git a/frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js b/frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js
index 3ed9357e..100f7817 100644
--- a/frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js
+++ b/frontend/src/utils/forms/__tests__/StreamProfileUtils.test.js
@@ -54,7 +54,9 @@ describe('StreamProfileUtils', () => {
describe('COMMAND_EXAMPLES', () => {
it('has an entry for each non-custom built-in command', () => {
- const nonCustom = BUILT_IN_COMMANDS.filter((c) => c.value !== '__custom__');
+ 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');
@@ -194,4 +196,4 @@ describe('StreamProfileUtils', () => {
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
index 4eeded36..1cb25805 100644
--- a/frontend/src/utils/forms/__tests__/StreamUtils.test.js
+++ b/frontend/src/utils/forms/__tests__/StreamUtils.test.js
@@ -32,7 +32,7 @@ describe('StreamUtils', () => {
});
});
-// ─── updateStream ─────────────────────────────────────────────────────────────
+ // ─── updateStream ─────────────────────────────────────────────────────────────
describe('updateStream', () => {
beforeEach(() => {
@@ -63,7 +63,7 @@ describe('StreamUtils', () => {
});
});
-// ─── addStream ────────────────────────────────────────────────────────────────
+ // ─── addStream ────────────────────────────────────────────────────────────────
describe('addStream', () => {
beforeEach(() => {
@@ -92,4 +92,4 @@ describe('StreamUtils', () => {
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
index ed6163b2..2e0bed4f 100644
--- a/frontend/src/utils/forms/__tests__/UserAgentUtils.test.js
+++ b/frontend/src/utils/forms/__tests__/UserAgentUtils.test.js
@@ -13,7 +13,11 @@ vi.mock('@hookform/resolvers/yup', () => ({
import API from '../../../api.js';
import { yupResolver } from '@hookform/resolvers/yup';
-import { getResolver, updateUserAgent, addUserAgent } from '../UserAgentUtils.js';
+import {
+ getResolver,
+ updateUserAgent,
+ addUserAgent,
+} from '../UserAgentUtils.js';
describe('UserAgentUtils', () => {
// ─── getResolver ──────────────────────────────────────────────────────────────
@@ -32,7 +36,7 @@ describe('UserAgentUtils', () => {
});
});
-// ─── updateUserAgent ──────────────────────────────────────────────────────────
+ // ─── updateUserAgent ──────────────────────────────────────────────────────────
describe('updateUserAgent', () => {
beforeEach(() => {
@@ -63,7 +67,7 @@ describe('UserAgentUtils', () => {
});
});
-// ─── addUserAgent ─────────────────────────────────────────────────────────────
+ // ─── addUserAgent ─────────────────────────────────────────────────────────────
describe('addUserAgent', () => {
beforeEach(() => {
@@ -92,4 +96,4 @@ describe('UserAgentUtils', () => {
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
index 3618228a..5157ac96 100644
--- a/frontend/src/utils/forms/__tests__/UserUtils.test.js
+++ b/frontend/src/utils/forms/__tests__/UserUtils.test.js
@@ -47,7 +47,10 @@ describe('UserUtils', () => {
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' });
+ expect(API.createUser).toHaveBeenCalledWith({
+ username: 'alice',
+ password: 'pass',
+ });
});
it('returns the promise from API.createUser', () => {
@@ -57,7 +60,7 @@ describe('UserUtils', () => {
});
});
-// ─── updateUser ───────────────────────────────────────────────────────────────
+ // ─── updateUser ───────────────────────────────────────────────────────────────
describe('updateUser', () => {
beforeEach(() => vi.clearAllMocks());
@@ -65,19 +68,31 @@ describe('UserUtils', () => {
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);
+ 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);
+ 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);
+ expect(API.updateUser).toHaveBeenCalledWith(
+ 5,
+ { username: 'dave' },
+ false
+ );
});
it('returns the promise from API.updateUser', () => {
@@ -87,7 +102,7 @@ describe('UserUtils', () => {
});
});
-// ─── generateApiKey ───────────────────────────────────────────────────────────
+ // ─── generateApiKey ───────────────────────────────────────────────────────────
describe('generateApiKey', () => {
beforeEach(() => vi.clearAllMocks());
@@ -105,7 +120,7 @@ describe('UserUtils', () => {
});
});
-// ─── revokeApiKey ─────────────────────────────────────────────────────────────
+ // ─── revokeApiKey ─────────────────────────────────────────────────────────────
describe('revokeApiKey', () => {
beforeEach(() => vi.clearAllMocks());
@@ -123,7 +138,7 @@ describe('UserUtils', () => {
});
});
-// ─── userToFormValues ─────────────────────────────────────────────────────────
+ // ─── userToFormValues ─────────────────────────────────────────────────────────
describe('userToFormValues', () => {
const makeUser = (overrides = {}) => ({
@@ -149,7 +164,9 @@ describe('UserUtils', () => {
});
it('defaults first_name and last_name to empty string when absent', () => {
- const result = userToFormValues(makeUser({ first_name: null, last_name: null }));
+ const result = userToFormValues(
+ makeUser({ first_name: null, last_name: null })
+ );
expect(result.first_name).toBe('');
expect(result.last_name).toBe('');
});
@@ -160,7 +177,9 @@ describe('UserUtils', () => {
});
it('uses channel_profiles when non-empty', () => {
- const result = userToFormValues(makeUser({ channel_profiles: ['3', '4'] }));
+ const result = userToFormValues(
+ makeUser({ channel_profiles: ['3', '4'] })
+ );
expect(result.channel_profiles).toEqual(['3', '4']);
});
@@ -217,7 +236,7 @@ describe('UserUtils', () => {
});
});
-// ─── formValuesToPayload ──────────────────────────────────────────────────────
+ // ─── formValuesToPayload ──────────────────────────────────────────────────────
describe('formValuesToPayload', () => {
const makeValues = (overrides = {}) => ({
@@ -253,7 +272,10 @@ describe('UserUtils', () => {
});
it('sets output_profile to null when empty string', () => {
- const result = formValuesToPayload(makeValues({ output_profile: '' }), null);
+ const result = formValuesToPayload(
+ makeValues({ output_profile: '' }),
+ null
+ );
expect(result.custom_properties.output_profile).toBeNull();
});
@@ -272,11 +294,20 @@ describe('UserUtils', () => {
});
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);
+ 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');
+ 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', () => {
@@ -285,17 +316,25 @@ describe('UserUtils', () => {
});
it('sets channel_profiles to empty array when it includes "0"', () => {
- const result = formValuesToPayload(makeValues({ channel_profiles: ['0'] }), null);
+ 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);
+ 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 existingUser = {
+ custom_properties: { some_other_prop: 'keep_me' },
+ };
const result = formValuesToPayload(makeValues(), existingUser);
expect(result.custom_properties.some_other_prop).toBe('keep_me');
});
@@ -305,7 +344,7 @@ describe('UserUtils', () => {
});
});
-// ─── getFormInitialValues ─────────────────────────────────────────────────────
+ // ─── getFormInitialValues ─────────────────────────────────────────────────────
describe('getFormInitialValues', () => {
it('returns the expected default structure', () => {
@@ -336,97 +375,151 @@ describe('UserUtils', () => {
});
});
-// ─── getFormValidators ────────────────────────────────────────────────────────
+ // ─── 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: '' });
+ 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' });
+ 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' });
+ 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' });
+ 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: '' });
+ 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: '' });
+ 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: '' });
+ 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' });
+ 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: '' });
+ 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' });
+ 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!@#' });
+ 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: [] });
+ 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'] });
+ 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'] });
+ 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'] });
+ 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'] });
+ 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 cc597189975be4f5f5c61de2cbe0c1669c7f4d4b Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Thu, 28 May 2026 18:44:20 -0700
Subject: [PATCH 08/10] Small cleanup
---
frontend/src/components/forms/User.jsx | 2 +-
frontend/src/pages/__tests__/guideUtils.test.js | 12 ------------
2 files changed, 1 insertion(+), 13 deletions(-)
diff --git a/frontend/src/components/forms/User.jsx b/frontend/src/components/forms/User.jsx
index 3ca08e1a..8c908b48 100644
--- a/frontend/src/components/forms/User.jsx
+++ b/frontend/src/components/forms/User.jsx
@@ -137,7 +137,7 @@ const User = ({ user = null, isOpen, onClose }) => {
const newKey = resp && (resp.key || resp.raw_key);
if (newKey) {
setGeneratedKey(newKey);
- console.log(setUserAPIKey(newKey));
+ setUserAPIKey(newKey);
}
} catch {
// API shows notifications
diff --git a/frontend/src/pages/__tests__/guideUtils.test.js b/frontend/src/pages/__tests__/guideUtils.test.js
index 2cd321ca..2ca149c3 100644
--- a/frontend/src/pages/__tests__/guideUtils.test.js
+++ b/frontend/src/pages/__tests__/guideUtils.test.js
@@ -801,18 +801,6 @@ describe('guideUtils', () => {
});
});
- // ── evaluateSeriesRule ────────────────────────────────────────────────────
-
- describe('evaluateSeriesRule', () => {
- it('should evaluate series rule via API', async () => {
- vi.mocked(API.evaluateSeriesRules).mockResolvedValue({});
-
- await guideUtils.evaluateSeriesRulesByTvgId('tvg-1');
-
- expect(API.evaluateSeriesRules).toHaveBeenCalledWith('tvg-1');
- });
- });
-
// ── calculateLeftScrollPosition ───────────────────────────────────────────
describe('calculateLeftScrollPosition', () => {
From 5eeca5fc94665ec77932bd8e2cbf0c57486acc6a Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Thu, 28 May 2026 18:45:32 -0700
Subject: [PATCH 09/10] Fixed test behavior
---
.../__tests__/RecurringRuleModal.test.jsx | 20 +++++++++++++++++--
1 file changed, 18 insertions(+), 2 deletions(-)
diff --git a/frontend/src/components/forms/__tests__/RecurringRuleModal.test.jsx b/frontend/src/components/forms/__tests__/RecurringRuleModal.test.jsx
index 308a415b..72bc91fd 100644
--- a/frontend/src/components/forms/__tests__/RecurringRuleModal.test.jsx
+++ b/frontend/src/components/forms/__tests__/RecurringRuleModal.test.jsx
@@ -583,9 +583,25 @@ describe('RecurringRuleModal', () => {
});
it('calls form.setValues with getFormDefaults result', () => {
- const formMock = vi.mocked(useForm).mock.results[0]?.value;
+ const expectedDefaults = {
+ channel_id: 'ch-1',
+ rule_name: 'Morning News',
+ days_of_week: ['mon', 'tue'],
+ start_date: new Date('2024-01-01'),
+ end_date: null,
+ start_time: '08:00',
+ end_time: '09:00',
+ enabled: true,
+ };
+ vi.mocked(RecurringRuleModalUtils.getFormDefaults).mockReturnValue(
+ expectedDefaults
+ );
+
render( );
- expect(formMock?.setValues ?? vi.fn()).toBeDefined();
+
+ // form instance is created during render — read after
+ const formMock = vi.mocked(useForm).mock.results[0].value;
+ expect(formMock.setValues).toHaveBeenCalledWith(expectedDefaults);
});
});
From 714f1b5cf5f4c1cc12b5dea35185220e654af8ae Mon Sep 17 00:00:00 2001
From: Nick Sandstrom <32273437+nick4810@users.noreply.github.com>
Date: Thu, 28 May 2026 18:46:40 -0700
Subject: [PATCH 10/10] Updated rendering behavior
---
.../forms/RecordingDetailsModal.jsx | 182 ++++++++++--------
1 file changed, 100 insertions(+), 82 deletions(-)
diff --git a/frontend/src/components/forms/RecordingDetailsModal.jsx b/frontend/src/components/forms/RecordingDetailsModal.jsx
index 43351765..ae2aabe1 100644
--- a/frontend/src/components/forms/RecordingDetailsModal.jsx
+++ b/frontend/src/components/forms/RecordingDetailsModal.jsx
@@ -43,6 +43,90 @@ import {
} from '../../utils/forms/RecordingDetailsModalUtils.js';
import { showNotification } from '../../utils/notificationUtils.js';
+const EpisodeRow = ({
+ rec,
+ recording,
+ channel,
+ channelsById,
+ livePosterUrl,
+ toUserTime,
+ dateformat,
+ timeformat,
+ onOpenChild,
+}) => {
+ const cp = rec.custom_properties || {};
+ const pr = cp.program || {};
+ const start = toUserTime(rec.start_time);
+ const end = toUserTime(rec.end_time);
+ const season = cp.season ?? pr?.custom_properties?.season;
+ const episode = cp.episode ?? pr?.custom_properties?.episode;
+ const onscreen =
+ cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
+ const se = getSeasonLabel(season, episode, onscreen);
+ const posterLogoId = cp.poster_logo_id;
+ const purl = getPosterUrl(posterLogoId, cp, livePosterUrl);
+ const epChannel =
+ channelsById[rec.channel] ||
+ (rec.channel === recording?.channel ? channel : null);
+
+ const onRemove = async (e) => {
+ e?.stopPropagation?.();
+ try {
+ await deleteRecordingById(rec.id);
+ } catch (error) {
+ console.error('Failed to delete upcoming recording', error);
+ }
+ };
+
+ return (
+ onOpenChild(rec)}
+ >
+
+
+
+
+
+ {pr.sub_title || pr.title}
+
+ {se && (
+
+ {se}
+
+ )}
+
+
+ {format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '}
+ {format(end, timeformat)}
+
+
+
+
+ Remove
+
+
+
+
+ );
+};
+
const RecordingDetailsModal = ({
opened,
onClose,
@@ -280,86 +364,6 @@ const RecordingDetailsModal = ({
if (!recording) return null;
- const EpisodeRow = ({ rec }) => {
- const cp = rec.custom_properties || {};
- const pr = cp.program || {};
- const start = toUserTime(rec.start_time);
- const end = toUserTime(rec.end_time);
- const season = cp.season ?? pr?.custom_properties?.season;
- const episode = cp.episode ?? pr?.custom_properties?.episode;
- const onscreen =
- cp.onscreen_episode ?? pr?.custom_properties?.onscreen_episode;
- const se = getSeasonLabel(season, episode, onscreen);
- const posterLogoId = cp.poster_logo_id;
- const purl = getPosterUrl(posterLogoId, cp, livePosterUrl);
- const epChannel =
- channelsById[rec.channel] ||
- (rec.channel === recording?.channel ? channel : null);
-
- const onRemove = async (e) => {
- e?.stopPropagation?.();
- try {
- await deleteRecordingById(rec.id);
- } catch (error) {
- console.error('Failed to delete upcoming recording', error);
- }
- // recording_cancelled WS event triggers the debounced fetchRecordings()
- };
-
- const handleOnMainCardClick = () => {
- setChildRec(rec);
- setChildOpen(true);
- };
-
- return (
-
-
-
-
-
-
- {pr.sub_title || pr.title}
-
- {se && (
-
- {se}
-
- )}
-
-
- {format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '}
- {format(end, timeformat)}
-
-
-
-
- Remove
-
-
-
-
- );
- };
-
const WatchLive = () => {
return (
)}
{upcomingEpisodes.map((ep) => (
-
+ {
+ setChildRec(rec);
+ setChildOpen(true);
+ }}
+ />
))}
{childOpen && childRec && (
- {isSeriesGroup ? : }
+ {isSeriesGroup ? Series() : Movie()}
);
};