From e2074c192116a6274249c047e84a7216c827d862 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Wed, 20 May 2026 21:52:28 -0400 Subject: [PATCH 01/35] fix: reduce intermittent 503s on stats page and API requests --- apps/proxy/live_proxy/channel_status.py | 28 +++++++++++++------------ docker/nginx.conf | 2 ++ frontend/src/pages/Stats.jsx | 6 +----- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/proxy/live_proxy/channel_status.py b/apps/proxy/live_proxy/channel_status.py index 3f75693e..2bf93ae8 100644 --- a/apps/proxy/live_proxy/channel_status.py +++ b/apps/proxy/live_proxy/channel_status.py @@ -419,7 +419,8 @@ class ChannelStatus: info['stream_name'] = stream_name # Add data throughput information to basic info - total_bytes_bytes = proxy_server.redis_client.hget(metadata_key, ChannelMetadataField.TOTAL_BYTES) + # TOTAL_BYTES is already present in the hgetall result — avoid a redundant round-trip + total_bytes_bytes = metadata.get(ChannelMetadataField.TOTAL_BYTES) if total_bytes_bytes: total_bytes = int(total_bytes_bytes) info['total_bytes'] = total_bytes @@ -460,24 +461,25 @@ class ChannelStatus: client_key = RedisKeys.client_metadata(channel_id, client_id) + # Fetch only the fields we need in one round-trip (hmget returns a list + # in the same order as the requested keys; values are None if absent) + ua, ip, connected_at, user_id = proxy_server.redis_client.hmget( + client_key, 'user_agent', 'ip_address', 'connected_at', 'user_id' + ) + client_info = { 'client_id': client_id, + 'user_agent': ua, } - user_agent_bytes = proxy_server.redis_client.hget(client_key, 'user_agent') - client_info['user_agent'] = user_agent_bytes + if ip: + client_info['ip_address'] = ip - ip_address_bytes = proxy_server.redis_client.hget(client_key, 'ip_address') - if ip_address_bytes: - client_info['ip_address'] = ip_address_bytes + if connected_at: + client_info['connected_at'] = float(connected_at) - connected_at_bytes = proxy_server.redis_client.hget(client_key, 'connected_at') - if connected_at_bytes: - client_info['connected_at'] = float(connected_at_bytes) - - user_id_bytes = proxy_server.redis_client.hget(client_key, 'user_id') - if user_id_bytes: - client_info['user_id'] = user_id_bytes + if user_id: + client_info['user_id'] = user_id output_format = proxy_server.redis_client.hget(client_key, 'output_format') client_info['output_format'] = output_format or 'mpegts' diff --git a/docker/nginx.conf b/docker/nginx.conf index c5cb429e..bacfb655 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -21,6 +21,8 @@ server { location / { include uwsgi_params; uwsgi_pass unix:/app/uwsgi.sock; + uwsgi_read_timeout 300s; + uwsgi_send_timeout 300s; } location /assets/ { diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index ec5f7042..ea4ef5f2 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -239,11 +239,7 @@ const StatsPage = () => { } }, [refreshInterval, fetchChannelStats, fetchVODStats]); - // Fetch initial stats on component mount (for immediate data when navigating to page) - useEffect(() => { - fetchChannelStats(); - fetchVODStats(); - }, [fetchChannelStats, fetchVODStats]); + // Initial fetch is handled by the polling useEffect above (it fetches immediately on mount) useEffect(() => { console.log('Processing channel stats:', channelStats); From 6787e4912fc8ff9f5a93db62e4cfb930e783ac01 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Wed, 20 May 2026 22:12:09 -0400 Subject: [PATCH 02/35] refactor: replace multiple hget calls with hmget where fields are known - channel_status.py: fold output_format and output_profile_id into the existing hmget, reducing per-client Redis calls from 3 to 1 - utils.py: collapse 2x and 3x hget per key in scan_iter loops (get_user_active_connections) to single hmget calls - views.py: replace 3x hget on channel metadata in the worker-join path of stream_ts with a single hmget --- apps/proxy/live_proxy/channel_status.py | 13 +++++++------ apps/proxy/live_proxy/views.py | 13 +++++-------- apps/proxy/utils.py | 9 ++++----- 3 files changed, 16 insertions(+), 19 deletions(-) diff --git a/apps/proxy/live_proxy/channel_status.py b/apps/proxy/live_proxy/channel_status.py index 2bf93ae8..9bd1d284 100644 --- a/apps/proxy/live_proxy/channel_status.py +++ b/apps/proxy/live_proxy/channel_status.py @@ -463,13 +463,18 @@ class ChannelStatus: # Fetch only the fields we need in one round-trip (hmget returns a list # in the same order as the requested keys; values are None if absent) - ua, ip, connected_at, user_id = proxy_server.redis_client.hmget( - client_key, 'user_agent', 'ip_address', 'connected_at', 'user_id' + ua, ip, connected_at, user_id, output_format, raw_profile_id = ( + proxy_server.redis_client.hmget( + client_key, + 'user_agent', 'ip_address', 'connected_at', 'user_id', + 'output_format', 'output_profile_id', + ) ) client_info = { 'client_id': client_id, 'user_agent': ua, + 'output_format': output_format or 'mpegts', } if ip: @@ -481,10 +486,6 @@ class ChannelStatus: if user_id: client_info['user_id'] = user_id - output_format = proxy_server.redis_client.hget(client_key, 'output_format') - client_info['output_format'] = output_format or 'mpegts' - - raw_profile_id = proxy_server.redis_client.hget(client_key, 'output_profile_id') if raw_profile_id and raw_profile_id not in ('None', '0', ''): client_info['output_profile_id'] = int(raw_profile_id) else: diff --git a/apps/proxy/live_proxy/views.py b/apps/proxy/live_proxy/views.py index 1369a364..bf39f6a9 100644 --- a/apps/proxy/live_proxy/views.py +++ b/apps/proxy/live_proxy/views.py @@ -468,14 +468,11 @@ def stream_ts(request, channel_id, user=None, force_output_format=None): if proxy_server.redis_client: metadata_key = RedisKeys.channel_metadata(channel_id) - url_bytes = proxy_server.redis_client.hget( - metadata_key, ChannelMetadataField.URL - ) - ua_bytes = proxy_server.redis_client.hget( - metadata_key, ChannelMetadataField.USER_AGENT - ) - profile_bytes = proxy_server.redis_client.hget( - metadata_key, ChannelMetadataField.STREAM_PROFILE + url_bytes, ua_bytes, profile_bytes = proxy_server.redis_client.hmget( + metadata_key, + ChannelMetadataField.URL, + ChannelMetadataField.USER_AGENT, + ChannelMetadataField.STREAM_PROFILE, ) if url_bytes: diff --git a/apps/proxy/utils.py b/apps/proxy/utils.py index db2339ed..69ee15e4 100644 --- a/apps/proxy/utils.py +++ b/apps/proxy/utils.py @@ -98,8 +98,7 @@ def get_user_active_connections(user_id): channel_id = parts[2] client_id = parts[4] - client_user_id = redis_client.hget(key, 'user_id') - connected_at = redis_client.hget(key, 'connected_at') + client_user_id, connected_at = redis_client.hmget(key, 'user_id', 'connected_at') logger.debug(f"[stream limits] user_id = {user_id}") logger.debug(f"[stream limits] channel_id = {channel_id}") @@ -124,9 +123,9 @@ def get_user_active_connections(user_id): if len(parts) >= 2: client_id = parts[1] - client_user_id = redis_client.hget(key, 'user_id') - connected_at = redis_client.hget(key, 'created_at') - content_uuid = redis_client.hget(key, 'content_uuid') + client_user_id, connected_at, content_uuid = redis_client.hmget( + key, 'user_id', 'created_at', 'content_uuid' + ) logger.debug(f"[stream limits] user_id = {user_id}") logger.debug(f"[stream limits] client_id = {client_id}") From a2c69be861d38711e40f0dad1976500b33423f12 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Wed, 20 May 2026 22:22:48 -0400 Subject: [PATCH 03/35] fix: update Stats tests to expect single mount fetch after removing duplicate useEffect Tests were asserting fetchActiveChannelStats/getVODStats were called twice on mount, encoding React strict mode's double-invoke behavior rather than testing actual application logic. Update all affected call count assertions to reflect the correct single-fetch behavior. --- frontend/src/pages/__tests__/Stats.test.jsx | 30 ++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/frontend/src/pages/__tests__/Stats.test.jsx b/frontend/src/pages/__tests__/Stats.test.jsx index d809a47a..21cfebf7 100644 --- a/frontend/src/pages/__tests__/Stats.test.jsx +++ b/frontend/src/pages/__tests__/Stats.test.jsx @@ -220,8 +220,8 @@ describe('StatsPage', () => { render(); await waitFor(() => { - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); - expect(getVODStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); }); }); @@ -283,16 +283,16 @@ describe('StatsPage', () => { render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); - expect(getVODStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); // Advance timers by 5 seconds await act(async () => { vi.advanceTimersByTime(5000); }); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3); - expect(getVODStats).toHaveBeenCalledTimes(3); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(getVODStats).toHaveBeenCalledTimes(2); vi.useRealTimers(); }); @@ -303,13 +303,13 @@ describe('StatsPage', () => { useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]); render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(0); await act(async () => { vi.advanceTimersByTime(10000); }); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(0); vi.useRealTimers(); }); @@ -319,7 +319,7 @@ describe('StatsPage', () => { const { unmount } = render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); unmount(); @@ -328,7 +328,7 @@ describe('StatsPage', () => { }); // Should not fetch again after unmount - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); @@ -338,14 +338,14 @@ describe('StatsPage', () => { it('refreshes stats when Refresh Now button is clicked', async () => { render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); const refreshButton = screen.getByText('Refresh Now'); fireEvent.click(refreshButton); await waitFor(() => { - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(3); - expect(getVODStats).toHaveBeenCalledTimes(3); + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(2); + expect(getVODStats).toHaveBeenCalledTimes(2); }); }); }); @@ -406,14 +406,14 @@ describe('StatsPage', () => { render(); await waitFor(() => { - expect(getVODStats).toHaveBeenCalledTimes(2); + expect(getVODStats).toHaveBeenCalledTimes(1); }); const stopButton = await screen.findByTestId('stop-vod-client-client-1'); fireEvent.click(stopButton); await waitFor(() => { - expect(getVODStats).toHaveBeenCalledTimes(3); + expect(getVODStats).toHaveBeenCalledTimes(2); }); }); }); From 0d1d1f27221d883fbc675edc0f78ccbbf26115e0 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Sat, 23 May 2026 14:20:13 -0400 Subject: [PATCH 04/35] fix: revert nginx timeouts; fire stats fetch on mount regardless of interval --- docker/nginx.conf | 2 -- frontend/src/pages/Stats.jsx | 16 +++++++--------- frontend/src/pages/__tests__/Stats.test.jsx | 10 +++++++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docker/nginx.conf b/docker/nginx.conf index bacfb655..c5cb429e 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -21,8 +21,6 @@ server { location / { include uwsgi_params; uwsgi_pass unix:/app/uwsgi.sock; - uwsgi_read_timeout 300s; - uwsgi_send_timeout 300s; } location /assets/ { diff --git a/frontend/src/pages/Stats.jsx b/frontend/src/pages/Stats.jsx index ea4ef5f2..86c7f1c3 100644 --- a/frontend/src/pages/Stats.jsx +++ b/frontend/src/pages/Stats.jsx @@ -212,19 +212,19 @@ const StatsPage = () => { } }, [setVodStats]); + // Always fetch once on mount, regardless of polling interval setting + useEffect(() => { + fetchChannelStats(); + fetchVODStats(); + }, [fetchChannelStats, fetchVODStats]); + // Set up polling for stats when on stats page useEffect(() => { - const location = window.location; - const isOnStatsPage = location.pathname === '/stats'; + const isOnStatsPage = window.location.pathname === '/stats'; if (isOnStatsPage && refreshInterval > 0) { setIsPollingActive(true); - // Initial fetch - fetchChannelStats(); - fetchVODStats(); - - // Set up interval const interval = setInterval(() => { fetchChannelStats(); fetchVODStats(); @@ -239,8 +239,6 @@ const StatsPage = () => { } }, [refreshInterval, fetchChannelStats, fetchVODStats]); - // Initial fetch is handled by the polling useEffect above (it fetches immediately on mount) - useEffect(() => { console.log('Processing channel stats:', channelStats); if ( diff --git a/frontend/src/pages/__tests__/Stats.test.jsx b/frontend/src/pages/__tests__/Stats.test.jsx index 21cfebf7..92cf8560 100644 --- a/frontend/src/pages/__tests__/Stats.test.jsx +++ b/frontend/src/pages/__tests__/Stats.test.jsx @@ -297,19 +297,23 @@ describe('StatsPage', () => { vi.useRealTimers(); }); - it('does not poll when interval is 0', async () => { + it('does not poll when interval is 0 but still fetches once on mount', async () => { vi.useFakeTimers(); useLocalStorage.mockReturnValue([0, mockSetRefreshInterval]); render(); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(0); + // Should still fetch once on mount even with interval = 0 + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); await act(async () => { vi.advanceTimersByTime(10000); }); - expect(fetchActiveChannelStats).toHaveBeenCalledTimes(0); + // Should not have polled — count stays at 1 + expect(fetchActiveChannelStats).toHaveBeenCalledTimes(1); + expect(getVODStats).toHaveBeenCalledTimes(1); vi.useRealTimers(); }); From 92d3068be0e964478ec2cb18f41183cf916a490a Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 23 May 2026 21:25:44 -0500 Subject: [PATCH 05/35] fix: ensure migrations run automatically after restoring backups to prevent missing schema issues --- CHANGELOG.md | 4 ++++ apps/backups/tasks.py | 3 +++ frontend/src/components/backups/BackupManager.jsx | 6 +++--- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e998abd..3464dd31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Restoring a backup from an older version left the database with missing schema.** The restore task ran `pg_restore` which replaced the entire database (including the `django_migrations` table) but did not run migrations afterward. If the backup predated a schema migration, the restored database was missing tables and columns added by those migrations, causing 500 errors on every API call. `migrate --noinput` now runs automatically after every restore. The success notification also now recommends a restart to clear stale service state. + ## [0.25.1] - 2026-05-23 ### Fixed diff --git a/apps/backups/tasks.py b/apps/backups/tasks.py index f531fef8..a169b7db 100644 --- a/apps/backups/tasks.py +++ b/apps/backups/tasks.py @@ -1,6 +1,7 @@ import logging import traceback from celery import shared_task +from django.core.management import call_command from . import services @@ -61,6 +62,8 @@ def restore_backup_task(self, filename: str): backup_file = backup_dir / filename logger.info(f"[RESTORE] Backup file path: {backup_file}") services.restore_backup(backup_file) + logger.info(f"[RESTORE] Running migrations after restore...") + call_command('migrate', '--noinput', verbosity=1) logger.info(f"[RESTORE] Task {self.request.id} completed successfully") return { "status": "completed", diff --git a/frontend/src/components/backups/BackupManager.jsx b/frontend/src/components/backups/BackupManager.jsx index 02b7d15d..c58755b2 100644 --- a/frontend/src/components/backups/BackupManager.jsx +++ b/frontend/src/components/backups/BackupManager.jsx @@ -439,12 +439,12 @@ export default function BackupManager() { try { await API.restoreBackup(selectedBackup.name); notifications.show({ - title: 'Success', + title: 'Restore Complete', message: - 'Backup restored successfully. You may need to refresh the page.', + 'Backup restored successfully. A restart is recommended to ensure all services are running against the restored data.', color: 'green', }); - setTimeout(() => window.location.reload(), 2000); + setTimeout(() => window.location.reload(), 4000); } catch (error) { notifications.show({ title: 'Error', From fb260356aad4ff8b44035fe88cc769742df361a7 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 23 May 2026 21:45:49 -0500 Subject: [PATCH 06/35] changelog: Update changelog for stats PR. --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3464dd31..7cef64db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Performance + +- **Reduced Redis round-trips on the Stats page channel status endpoint.** `get_basic_channel_info` was making up to 6 individual `HGET` calls per connected client plus a redundant `HGET` for `TOTAL_BYTES` (already present in the preceding `HGETALL` result). Client metadata is now fetched with a single `HMGET` per client, and `TOTAL_BYTES` is read from the already-fetched hash. Under load with many active streams this significantly reduces the time each uWSGI worker holds the GIL servicing the stats endpoint, reducing the chance of concurrent requests from other pages timing out with a 503. The same `HGET`-to-`HMGET` consolidation was applied to `stream_ts` and `get_user_active_connections`. The Stats page frontend was also fixed to fire the initial fetch only once on mount (previously two `useEffect` hooks both triggered an immediate fetch on load). — Thanks [@JCBird1012](https://github.com/JCBird1012) + ### Fixed - **Restoring a backup from an older version left the database with missing schema.** The restore task ran `pg_restore` which replaced the entire database (including the `django_migrations` table) but did not run migrations afterward. If the backup predated a schema migration, the restored database was missing tables and columns added by those migrations, causing 500 errors on every API call. `migrate --noinput` now runs automatically after every restore. The success notification also now recommends a restart to clear stale service state. From 0db557384a7219d7d14426eba7ee2b053e5550a0 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Sun, 24 May 2026 10:59:53 -0400 Subject: [PATCH 07/35] configurable per-page, sticky pagination footer, fix update sort order and disabled button badge color --- .../components/theme/SizedInstallButton.jsx | 2 +- frontend/src/pages/PluginBrowse.jsx | 122 ++++++++++++------ 2 files changed, 87 insertions(+), 37 deletions(-) diff --git a/frontend/src/components/theme/SizedInstallButton.jsx b/frontend/src/components/theme/SizedInstallButton.jsx index 780aedb5..7bcb1819 100644 --- a/frontend/src/components/theme/SizedInstallButton.jsx +++ b/frontend/src/components/theme/SizedInstallButton.jsx @@ -71,7 +71,7 @@ const SizedInstallButton = ({ : 'var(--mantine-primary-color-filled-hover)' : colorVar, filter: isDisabled - ? 'brightness(0.65) saturate(0.7)' + ? 'grayscale(1) brightness(0.55)' : hovered ? 'brightness(0.86)' : 'brightness(0.82)', diff --git a/frontend/src/pages/PluginBrowse.jsx b/frontend/src/pages/PluginBrowse.jsx index ccc81b28..dd4a3a6d 100644 --- a/frontend/src/pages/PluginBrowse.jsx +++ b/frontend/src/pages/PluginBrowse.jsx @@ -8,6 +8,7 @@ import { Group, Loader, Modal, + NativeSelect, NumberInput, Pagination, Select, @@ -68,6 +69,7 @@ export default function PluginBrowsePage() { const saveIntervalTimer = useRef(null); const recentlyInstalledSlugs = useRef(new Set()); + const recentlyUpdatedSlugs = useRef(new Set()); const recentlyUninstalledSlugs = useRef(new Set()); const [searchQuery, setSearchQuery] = useState(''); @@ -75,7 +77,14 @@ export default function PluginBrowsePage() { const [filterRepo, setFilterRepo] = useState('all'); const [filterStatus, setFilterStatus] = useState('all'); const [page, setPage] = useState(1); - const perPage = 9; + const [perPage, setPerPage] = useState(() => { + const stored = localStorage.getItem('pluginBrowsePerPage'); + return stored && !isNaN(Number(stored)) ? Number(stored) : 9; + }); + const handlePerPageChange = (value) => { + setPerPage(Number(value)); + localStorage.setItem('pluginBrowsePerPage', value); + }; const hasFetched = useRef(false); @@ -294,6 +303,8 @@ export default function PluginBrowsePage() { // Pre-sort weights: deprecated → installed → incompatible sink to bottom (in that order) // Recently installed plugins are exempt so they don't jump away after install const weight = (p) => { + if (p.install_status === 'update_available') return -1; + if (recentlyUpdatedSlugs.current.has(p.slug)) return -1; if (recentlyInstalledSlugs.current.has(p.slug)) return 0; if (recentlyUninstalledSlugs.current.has(p.slug)) return 2; const meetsMin = @@ -335,10 +346,10 @@ export default function PluginBrowsePage() { appVersion, ]); - // Reset to page 1 when filters/search change + // Reset to page 1 when filters/search/page-size change React.useEffect(() => { setPage(1); - }, [searchQuery, filterRepo, filterStatus, sortBy]); + }, [searchQuery, filterRepo, filterStatus, sortBy, perPage]); const totalPages = Math.ceil(filteredPlugins.length / perPage); const paginatedPlugins = filteredPlugins.slice( @@ -347,7 +358,15 @@ export default function PluginBrowsePage() { ); return ( - + + @@ -467,38 +486,69 @@ export default function PluginBrowsePage() { )} {!loading && filteredPlugins.length > 0 && ( - <> - - {paginatedPlugins.map((p) => ( - 1} - onBeforeInstall={(slug) => { - if (slug) recentlyInstalledSlugs.current.add(slug); - }} - onInstalled={(slug) => { - if (slug) recentlyInstalledSlugs.current.add(slug); - fetchAvailablePlugins(); - }} - onUninstalled={(slug) => { - if (slug) recentlyUninstalledSlugs.current.add(slug); - }} - /> - ))} - - {totalPages > 1 && ( - - - - )} - + + {paginatedPlugins.map((p) => ( + 1} + onBeforeInstall={(slug) => { + if (slug) { + if (p.install_status === 'update_available') { + recentlyUpdatedSlugs.current.add(slug); + } else { + recentlyInstalledSlugs.current.add(slug); + } + } + }} + onInstalled={(slug) => { + if (slug) recentlyInstalledSlugs.current.add(slug); + fetchAvailablePlugins(); + }} + onUninstalled={(slug) => { + if (slug) recentlyUninstalledSlugs.current.add(slug); + }} + /> + ))} + + )} + + + + {!loading && filteredPlugins.length > 0 && ( + + + Page Size + handlePerPageChange(e.target.value)} + styles={{ input: { textAlignLast: 'center' } }} + /> + + + {`${(page - 1) * perPage + 1} to ${Math.min(page * perPage, filteredPlugins.length)} of ${filteredPlugins.length}`} + + + )} {/* Manage Repos Modal */} From 2070f9e281f762c62d2a37289740ac059a184745 Mon Sep 17 00:00:00 2001 From: Kanishk Sachdev Date: Mon, 25 May 2026 04:31:42 -0400 Subject: [PATCH 08/35] docker: split base image into builder and runtime stages Keeps compilers in the builder; runtime stage copies only the venv and NumPy wheel. --- docker/DispatcharrBase | 62 +++++++++++++++++++++++++++++++++--------- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/docker/DispatcharrBase b/docker/DispatcharrBase index 3040e189..61c460ce 100644 --- a/docker/DispatcharrBase +++ b/docker/DispatcharrBase @@ -1,4 +1,10 @@ -FROM lscr.io/linuxserver/ffmpeg:latest +# ============================================================================== +# Stage 1: builder +# Installs the Python toolchain + compilers, creates the virtual environment and +# builds the legacy (CPU-baseline=none) NumPy wheel. None of these compilers end +# up in the final image — only the artifacts below are copied forward. +# ============================================================================== +FROM lscr.io/linuxserver/ffmpeg:latest AS builder ENV DEBIAN_FRONTEND=noninteractive ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy @@ -15,10 +21,9 @@ RUN apt-get update && apt-get install --no-install-recommends -y \ && apt-get update \ && apt-get install --no-install-recommends -y \ python3.13 python3.13-dev python3.13-venv libpython3.13 \ - libpcre3 libpcre3-dev libpq-dev procps pciutils \ - nginx comskip \ - vlc-bin vlc-plugin-base \ - build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build + libpcre3 libpcre3-dev libpq-dev \ + build-essential gcc g++ gfortran libopenblas-dev libopenblas0 ninja-build \ + && rm -rf /var/lib/apt/lists/* # --- Install UV --- COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ @@ -28,11 +33,12 @@ WORKDIR /tmp/build COPY pyproject.toml /tmp/build/ COPY version.py /tmp/build/ COPY README.md /tmp/build/ -RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev && \ - rm -rf /tmp/build +RUN uv sync --python 3.13 --no-cache --no-install-project --no-dev WORKDIR / # --- Build legacy NumPy wheel for old hardware (store for runtime switching) --- +# build/pip are installed into the venv only long enough to produce the wheel, +# then uninstalled so they are not carried into the final image's venv. RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build pip && \ cd /tmp && \ $UV_PROJECT_ENVIRONMENT/bin/python -m pip download --no-binary numpy --no-deps numpy && \ @@ -40,14 +46,44 @@ RUN uv pip install --python $UV_PROJECT_ENVIRONMENT/bin/python --no-cache build cd numpy-*/ && \ $UV_PROJECT_ENVIRONMENT/bin/python -m build --wheel -Csetup-args=-Dcpu-baseline="none" -Csetup-args=-Dcpu-dispatch="none" && \ mv dist/*.whl /opt/ && \ - cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz && \ + cd / && rm -rf /tmp/numpy-* /tmp/*.tar.gz /tmp/build && \ uv pip uninstall --python $UV_PROJECT_ENVIRONMENT/bin/python build pip -# --- Clean up build dependencies to reduce image size --- -RUN apt-get remove -y build-essential gcc g++ gfortran libopenblas-dev libpcre3-dev python3.13-dev ninja-build && \ - apt-get autoremove -y --purge && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /root/.cache /tmp/* +# ============================================================================== +# Stage 2: final runtime image +# Same ffmpeg base, but installs only the runtime system libraries (no compilers) +# and copies the prebuilt virtual environment + NumPy wheel from the builder. +# ============================================================================== +FROM lscr.io/linuxserver/ffmpeg:latest AS final + +ENV DEBIAN_FRONTEND=noninteractive +ENV UV_PROJECT_ENVIRONMENT=/dispatcharrpy +ENV VIRTUAL_ENV=/dispatcharrpy +ENV PATH="$VIRTUAL_ENV/bin:$PATH" +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy + +# --- Install runtime system dependencies (no build toolchain) --- +# python3.13 + libpython3.13 back the copied venv; libpcre3 backs uWSGI; +# libopenblas0 backs the legacy NumPy wheel; ca-certificates/gnupg2/curl/wget +# and software-properties-common are kept for TLS and the AIO runtime apt step. +RUN apt-get update && apt-get install --no-install-recommends -y \ + ca-certificates software-properties-common gnupg2 curl wget \ + && add-apt-repository ppa:deadsnakes/ppa \ + && apt-get update \ + && apt-get install --no-install-recommends -y \ + python3.13 python3.13-venv libpython3.13 \ + libpcre3 libpq-dev libopenblas0 procps pciutils \ + nginx comskip \ + vlc-bin vlc-plugin-base \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# --- Install UV (used at runtime to swap in the legacy NumPy wheel) --- +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +# --- Copy prebuilt virtual environment and legacy NumPy wheel from the builder --- +COPY --from=builder /dispatcharrpy /dispatcharrpy +COPY --from=builder /opt/numpy-*.whl /opt/ # --- Set up Redis 7.x --- RUN curl -fsSL https://packages.redis.io/gpg | gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg && \ From 94c8bd36bfffc6d361ff6f252037ccda9c1854c7 Mon Sep 17 00:00:00 2001 From: Jonathan Caicedo Date: Mon, 25 May 2026 11:12:46 -0400 Subject: [PATCH 09/35] perf: replace per-request DB connections with geventpool --- dispatcharr/settings.py | 12 ++++++++---- pyproject.toml | 1 + 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index e8818108..1b103ccf 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -111,7 +111,7 @@ XC_PROFILE_REFRESH_DELAY = float(os.environ.get('XC_PROFILE_REFRESH_DELAY', '2.5 # Database optimization settings DATABASE_STATEMENT_TIMEOUT = 300 # Seconds before timing out long-running queries -DATABASE_CONN_MAX_AGE = 0 # Close after each request; gevent makes per-greenlet connections +DATABASE_CONN_MAX_AGE = None # Managed by django-db-geventpool; None = persistent pool connections # Disable atomic requests for performance-sensitive views ATOMIC_REQUESTS = False @@ -221,13 +221,17 @@ if os.getenv("DB_ENGINE", None) == "sqlite": else: DATABASES = { "default": { - "ENGINE": "django.db.backends.postgresql", + "ENGINE": "django_db_geventpool.backends.postgresql", "NAME": os.environ.get("POSTGRES_DB", "dispatcharr"), "USER": os.environ.get("POSTGRES_USER", "dispatch"), "PASSWORD": os.environ.get("POSTGRES_PASSWORD", "secret"), "HOST": os.environ.get("POSTGRES_HOST", "localhost"), "PORT": int(os.environ.get("POSTGRES_PORT", 5432)), "CONN_MAX_AGE": DATABASE_CONN_MAX_AGE, + "OPTIONS": { + "MAX_CONNS": 20, # Per-worker pool size; 4 workers × 20 = 80 total < pg max_connections=100 + "REUSE_CONNS": 10, # Connections to keep warm between requests + }, } } @@ -238,9 +242,9 @@ else: ("POSTGRES_SSL_KEY", POSTGRES_SSL_KEY), ], "PostgreSQL") - DATABASES["default"]["OPTIONS"] = { + DATABASES["default"]["OPTIONS"].update({ "sslmode": POSTGRES_SSL_MODE, - } + }) if POSTGRES_SSL_CA_CERT: DATABASES["default"]["OPTIONS"]["sslrootcert"] = POSTGRES_SSL_CA_CERT if POSTGRES_SSL_CERT: diff --git a/pyproject.toml b/pyproject.toml index db7efc78..fe7112f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "yt-dlp", "gevent==26.4.0", "psycogreen", + "django-db-geventpool", "daphne", "uwsgi", "django-cors-headers", 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 10/35] 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 11/35] 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 12/35] 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 13/35] 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 14/35] Refactored components --- frontend/src/components/forms/Recording.jsx | 252 ++++-------------- .../forms/RecordingDetailsModal.jsx | 70 ++--- .../components/forms/RecurringRuleModal.jsx | 91 ++----- .../src/components/forms/ScheduleInput.jsx | 12 +- .../components/forms/SeriesRecordingModal.jsx | 26 +- .../forms/SeriesRuleEditorModal.jsx | 124 +++------ frontend/src/components/forms/Stream.jsx | 21 +- .../src/components/forms/StreamProfile.jsx | 81 ++---- .../src/components/forms/SuperuserForm.jsx | 16 +- frontend/src/components/forms/User.jsx | 228 ++++------------ frontend/src/components/forms/UserAgent.jsx | 28 +- 11 files changed, 270 insertions(+), 679 deletions(-) diff --git a/frontend/src/components/forms/Recording.jsx b/frontend/src/components/forms/Recording.jsx index 23f78867..163a43a7 100644 --- a/frontend/src/components/forms/Recording.jsx +++ b/frontend/src/components/forms/Recording.jsx @@ -1,86 +1,40 @@ import React, { useEffect, useMemo, useState } from 'react'; -import dayjs from 'dayjs'; -import API from '../../api'; import { Alert, Button, + Group, + Loader, Modal, + MultiSelect, + SegmentedControl, Select, Stack, - SegmentedControl, - MultiSelect, - Group, TextInput, - Loader, } from '@mantine/core'; -import { DateTimePicker, TimeInput, DatePickerInput } from '@mantine/dates'; +import { DatePickerInput, DateTimePicker, TimeInput } from '@mantine/dates'; import { CircleAlert } from 'lucide-react'; -import { isNotEmpty, useForm } from '@mantine/form'; +import { useForm } from '@mantine/form'; import useChannelsStore from '../../store/channels'; -import { notifications } from '@mantine/notifications'; - -const DAY_OPTIONS = [ - { value: '6', label: 'Sun' }, - { value: '0', label: 'Mon' }, - { value: '1', label: 'Tue' }, - { value: '2', label: 'Wed' }, - { value: '3', label: 'Thu' }, - { value: '4', label: 'Fri' }, - { value: '5', label: 'Sat' }, -]; - -const asDate = (value) => { - if (!value) return null; - if (value instanceof Date) return value; - const parsed = new Date(value); - return Number.isNaN(parsed.getTime()) ? null : parsed; -}; - -const toIsoIfDate = (value) => { - const dt = asDate(value); - return dt ? dt.toISOString() : value; -}; - -// Accepts "h:mm A"/"hh:mm A"/"HH:mm"/Date, returns "HH:mm" -const toTimeString = (value) => { - if (!value) return '00:00'; - if (typeof value === 'string') { - const parsed = dayjs( - value, - ['HH:mm', 'hh:mm A', 'h:mm A', 'HH:mm:ss'], - true - ); - if (parsed.isValid()) return parsed.format('HH:mm'); - return value; - } - const dt = asDate(value); - if (!dt) return '00:00'; - return dayjs(dt).format('HH:mm'); -}; - -const toDateString = (value) => { - const dt = asDate(value); - if (!dt) return null; - const year = dt.getFullYear(); - const month = String(dt.getMonth() + 1).padStart(2, '0'); - const day = String(dt.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; -}; - -const createRoundedDate = (minutesAhead = 0) => { - const dt = new Date(); - dt.setSeconds(0); - dt.setMilliseconds(0); - dt.setMinutes(Math.ceil(dt.getMinutes() / 30) * 30); - if (minutesAhead) dt.setMinutes(dt.getMinutes() + minutesAhead); - return dt; -}; - -// robust onChange for TimeInput (string or event) -const timeChange = (setter) => (valOrEvent) => { - if (typeof valOrEvent === 'string') setter(valOrEvent); - else if (valOrEvent?.currentTarget) setter(valOrEvent.currentTarget.value); -}; +import { + RECURRING_DAY_OPTIONS, + toTimeString, +} from '../../utils/dateTimeUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + buildRecurringPayload, + buildSinglePayload, + createRecording, + createRecurringRule, + getChannelsSummary, + getRecurringFormDefaults, + getSingleFormDefaults, + numberedChannelLabel, + recurringFormValidators, + singleFormValidators, + sortedChannelOptions, + timeChange, + updateRecording, +} from '../../utils/forms/RecordingUtils.js'; const RecordingModal = ({ recording = null, @@ -98,117 +52,29 @@ const RecordingModal = ({ const [mode, setMode] = useState('single'); const [submitting, setSubmitting] = useState(false); - const defaultStart = createRoundedDate(); - const defaultEnd = createRoundedDate(60); - const defaultDate = new Date(); - - // One-time form const singleForm = useForm({ mode: 'controlled', - initialValues: { - channel_id: recording - ? `${recording.channel}` - : channel - ? `${channel.id}` - : '', - start_time: recording - ? asDate(recording.start_time) || defaultStart - : defaultStart, - end_time: recording - ? asDate(recording.end_time) || defaultEnd - : defaultEnd, - }, - validate: { - channel_id: isNotEmpty('Select a channel'), - start_time: isNotEmpty('Select a start time'), - end_time: (value, values) => { - const start = asDate(values.start_time); - const end = asDate(value); - if (!end) return 'Select an end time'; - if (start && end <= start) return 'End time must be after start time'; - return null; - }, - }, + initialValues: getSingleFormDefaults(recording, channel), + validate: singleFormValidators, }); - // Recurring form stores times as "HH:mm" strings for stable editing const recurringForm = useForm({ mode: 'controlled', validateInputOnChange: false, validateInputOnBlur: true, - initialValues: { - channel_id: channel ? `${channel.id}` : '', - days_of_week: [], - start_time: dayjs(defaultStart).format('HH:mm'), - end_time: dayjs(defaultEnd).format('HH:mm'), - rule_name: '', - start_date: defaultDate, - end_date: defaultDate, - }, - validate: { - channel_id: isNotEmpty('Select a channel'), - days_of_week: (value) => - value && value.length ? null : 'Pick at least one day', - start_time: (value) => (value ? null : 'Select a start time'), - end_time: (value, values) => { - if (!value) return 'Select an end time'; - const start = dayjs( - values.start_time, - ['HH:mm', 'hh:mm A', 'h:mm A'], - true - ); - const end = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true); - if ( - start.isValid() && - end.isValid() && - end.diff(start, 'minute') === 0 - ) { - return 'End time must differ from start time'; - } - return null; - }, - end_date: (value, values) => { - const end = asDate(value); - const start = asDate(values.start_date); - if (!end) return 'Select an end date'; - if (start && end < start) return 'End date cannot be before start date'; - return null; - }, - }, + initialValues: getRecurringFormDefaults(channel), + validate: recurringFormValidators, }); useEffect(() => { if (!isOpen) return; - const freshStart = createRoundedDate(); - const freshEnd = createRoundedDate(60); - const freshDate = new Date(); - - if (recording && recording.id) { + if (recording?.id) { setMode('single'); - singleForm.setValues({ - channel_id: `${recording.channel}`, - start_time: asDate(recording.start_time) || defaultStart, - end_time: asDate(recording.end_time) || defaultEnd, - }); + singleForm.setValues(getSingleFormDefaults(recording, channel)); } else { - // Reset forms for fresh open - singleForm.setValues({ - channel_id: channel ? `${channel.id}` : '', - start_time: freshStart, - end_time: freshEnd, - }); - - const startStr = dayjs(freshStart).format('HH:mm'); - recurringForm.setValues({ - channel_id: channel ? `${channel.id}` : '', - days_of_week: [], - start_time: startStr, - end_time: dayjs(freshEnd).format('HH:mm'), - rule_name: channel?.name || '', - start_date: freshDate, - end_date: freshDate, - }); + singleForm.setValues(getSingleFormDefaults(null, channel)); + recurringForm.setValues(getRecurringFormDefaults(channel)); setMode('single'); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -221,7 +87,7 @@ const RecordingModal = ({ if (!isOpen) return; try { setIsChannelsLoading(true); - const chans = await API.getChannelsSummary(); + const chans = await getChannelsSummary(); if (cancelled) return; setAllChannels(Array.isArray(chans) ? chans : []); } catch (e) { @@ -238,19 +104,7 @@ const RecordingModal = ({ }, [isOpen]); const channelOptions = useMemo(() => { - const list = Array.isArray(allChannels) ? [...allChannels] : []; - list.sort((a, b) => { - const aNum = Number(a.channel_number) || 0; - const bNum = Number(b.channel_number) || 0; - if (aNum === bNum) return (a.name || '').localeCompare(b.name || ''); - return aNum - bNum; - }); - return list.map((item) => ({ - value: `${item.id}`, - label: item.channel_number - ? `${item.channel_number} - ${item.name || `Channel ${item.id}`}` - : item.name || `Channel ${item.id}`, - })); + return sortedChannelOptions(allChannels, numberedChannelLabel); }, [allChannels]); const resetForms = () => { @@ -267,25 +121,18 @@ const RecordingModal = ({ const handleSingleSubmit = async (values) => { try { setSubmitting(true); + const payload = buildSinglePayload(values); if (recording && recording.id) { - await API.updateRecording(recording.id, { - channel: values.channel_id, - start_time: toIsoIfDate(values.start_time), - end_time: toIsoIfDate(values.end_time), - }); - notifications.show({ + await updateRecording(recording.id, payload); + showNotification({ title: 'Recording updated', message: 'Recording schedule updated successfully', color: 'green', autoClose: 2500, }); } else { - await API.createRecording({ - channel: values.channel_id, - start_time: toIsoIfDate(values.start_time), - end_time: toIsoIfDate(values.end_time), - }); - notifications.show({ + await createRecording(payload); + showNotification({ title: 'Recording scheduled', message: 'One-time recording added to DVR queue', color: 'green', @@ -304,18 +151,10 @@ const RecordingModal = ({ const handleRecurringSubmit = async (values) => { try { setSubmitting(true); - await API.createRecurringRule({ - channel: values.channel_id, - days_of_week: (values.days_of_week || []).map((d) => Number(d)), - start_time: toTimeString(values.start_time), - end_time: toTimeString(values.end_time), - start_date: toDateString(values.start_date), - end_date: toDateString(values.end_date), - name: values.rule_name?.trim() || '', - }); + await createRecurringRule(buildRecurringPayload(values)); await Promise.all([fetchRecurringRules(), fetchRecordings()]); - notifications.show({ + showNotification({ title: 'Recurring rule saved', message: 'Future slots will be scheduled automatically', color: 'green', @@ -427,7 +266,10 @@ const RecordingModal = ({ key={recurringForm.key('days_of_week')} label="Every" placeholder="Select days" - data={DAY_OPTIONS} + data={RECURRING_DAY_OPTIONS.map((opt) => ({ + value: String(opt.value), + label: opt.label, + }))} searchable clearable nothingFoundMessage="No match" diff --git a/frontend/src/components/forms/RecordingDetailsModal.jsx b/frontend/src/components/forms/RecordingDetailsModal.jsx index bc8b245f..43351765 100644 --- a/frontend/src/components/forms/RecordingDetailsModal.jsx +++ b/frontend/src/components/forms/RecordingDetailsModal.jsx @@ -1,11 +1,13 @@ import useChannelsStore from '../../store/channels.jsx'; -import API from '../../api'; import { + format, + isAfter, + isBefore, useDateTimeFormat, useTimeHelpers, } from '../../utils/dateTimeUtils.js'; -import React from 'react'; -import { Pencil, RefreshCcw, Check, X } from 'lucide-react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Check, Pencil, RefreshCcw, X } from 'lucide-react'; import { ActionIcon, Badge, @@ -21,7 +23,6 @@ import { TextInput, } from '@mantine/core'; import useVideoStore from '../../store/useVideoStore.jsx'; -import { notifications } from '@mantine/notifications'; import defaultLogo from '../../images/logo.png'; import { deleteRecordingById, @@ -33,10 +34,14 @@ import { runComSkip, } from '../../utils/cards/RecordingCardUtils.js'; import { + getChannel, getRating, getStatRows, getUpcomingEpisodes, + refreshArtwork, + updateRecordingMetadata, } from '../../utils/forms/RecordingDetailsModalUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; const RecordingDetailsModal = ({ opened, @@ -51,21 +56,21 @@ const RecordingDetailsModal = ({ }) => { const allRecordings = useChannelsStore((s) => s.recordings); // Local channel cache to avoid the global channels map - const [channelsById, setChannelsById] = React.useState({}); + const [channelsById, setChannelsById] = useState({}); const { toUserTime, userNow } = useTimeHelpers(); - const [childOpen, setChildOpen] = React.useState(false); - const [childRec, setChildRec] = React.useState(null); + const [childOpen, setChildOpen] = useState(false); + const [childRec, setChildRec] = useState(null); const { timeFormat: timeformat, dateFormat: dateformat } = useDateTimeFormat(); - const [editing, setEditing] = React.useState(false); + const [editing, setEditing] = useState(false); // Prefer the store version of the recording for live updates // (e.g., after artwork refresh or metadata edit via WebSocket). // Preserve _group_count from the categorized prop — the store version // doesn't carry this client-side field, so without merging it back // isSeriesGroup would always be false and the episode list hidden. - const safeRecording = React.useMemo(() => { + const safeRecording = useMemo(() => { if (recording?.id && Array.isArray(allRecordings)) { const found = allRecordings.find((r) => r.id === recording.id); if (found) { @@ -81,7 +86,7 @@ const RecordingDetailsModal = ({ const program = customProps.program || {}; // Derive poster URL from live store data instead of the stale prop snapshot. - const livePosterUrl = React.useMemo( + const livePosterUrl = useMemo( () => getPosterUrl( customProps.poster_logo_id, @@ -93,17 +98,17 @@ const RecordingDetailsModal = ({ // Optimistic overrides — show saved values immediately without waiting // for the WebSocket round-trip to refresh the store. - const [savedTitle, setSavedTitle] = React.useState(null); - const [savedDescription, setSavedDescription] = React.useState(null); + const [savedTitle, setSavedTitle] = useState(null); + const [savedDescription, setSavedDescription] = useState(null); const recordingName = savedTitle ?? (program.title || 'Custom Recording'); const description = savedDescription ?? (program.description || customProps.description || ''); - const [editTitle, setEditTitle] = React.useState(''); - const [editDescription, setEditDescription] = React.useState(''); + const [editTitle, setEditTitle] = useState(''); + const [editDescription, setEditDescription] = useState(''); // Reset optimistic state when the recording changes - React.useEffect(() => { + useEffect(() => { setSavedTitle(null); setSavedDescription(null); setEditing(false); @@ -129,7 +134,7 @@ const RecordingDetailsModal = ({ const isSeriesGroup = Boolean( safeRecording._group_count && safeRecording._group_count > 1 ); - const upcomingEpisodes = React.useMemo(() => { + const upcomingEpisodes = useMemo(() => { return getUpcomingEpisodes( isSeriesGroup, allRecordings, @@ -147,7 +152,7 @@ const RecordingDetailsModal = ({ ]); // Ensure channel is available for a given id - const loadChannel = React.useCallback( + const loadChannel = useCallback( async (id) => { if (!id) { return null; @@ -159,7 +164,7 @@ const RecordingDetailsModal = ({ } try { - const ch = await API.getChannel(id); + const ch = await getChannel(id); if (ch && ch.id === id) { setChannelsById((prev) => ({ ...prev, [id]: ch })); return ch; @@ -177,7 +182,7 @@ const RecordingDetailsModal = ({ ); // When opening a child episode, fetch that episode's channel - React.useEffect(() => { + useEffect(() => { if (!childOpen || !childRec) return; loadChannel(childRec.channel); }, [childOpen, childRec, loadChannel]); @@ -188,7 +193,7 @@ const RecordingDetailsModal = ({ const s = toUserTime(rec.start_time); const e = toUserTime(rec.end_time); - if (now.isAfter(s) && now.isBefore(e)) { + if (isAfter(now, s) && isBefore(now, e)) { const ch = channelsById[rec.channel] || (rec.channel === recording?.channel ? channel : null); @@ -228,14 +233,11 @@ const RecordingDetailsModal = ({ const saveMetadata = async () => { try { - await API.updateRecordingMetadata(recording.id, { - title: editTitle || 'Custom Recording', - description: editDescription, - }); + await updateRecordingMetadata(recording, editTitle, editDescription); setSavedTitle(editTitle || 'Custom Recording'); setSavedDescription(editDescription); setEditing(false); - notifications.show({ + showNotification({ title: 'Saved', message: 'Recording metadata updated', color: 'green', @@ -249,8 +251,8 @@ const RecordingDetailsModal = ({ const handleRefreshArtwork = async (e) => { e.stopPropagation?.(); try { - await API.refreshArtwork(recording.id); - notifications.show({ + await refreshArtwork(recording.id); + showNotification({ title: 'Refreshing artwork', message: 'Poster resolution started', color: 'blue.5', @@ -265,7 +267,7 @@ const RecordingDetailsModal = ({ e.stopPropagation?.(); try { await runComSkip(recording); - notifications.show({ + showNotification({ title: 'Removing commercials', message: 'Queued comskip for this recording', color: 'blue.5', @@ -344,8 +346,8 @@ const RecordingDetailsModal = ({ )} - {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} - {end.format(timeformat)} + {format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '} + {format(end, timeformat)} @@ -468,7 +470,7 @@ const RecordingDetailsModal = ({ {onWatchLive && } {onWatchRecording && } - {onEdit && start.isAfter(userNow()) && } + {onEdit && isAfter(start, userNow()) && } {(customProps.status === 'completed' || customProps.status === 'stopped' || customProps.status === 'interrupted') && @@ -486,8 +488,8 @@ const RecordingDetailsModal = ({ - {start.format(`${dateformat}, YYYY ${timeformat}`)} –{' '} - {end.format(timeformat)} + {format(start, `${dateformat}, YYYY ${timeformat}`)} –{' '} + {format(end, timeformat)} {rating && ( @@ -599,7 +601,7 @@ const RecordingDetailsModal = ({ title: { color: 'white' }, }} > - {isSeriesGroup ? Series() : Movie()} + {isSeriesGroup ? : } ); }; diff --git a/frontend/src/components/forms/RecurringRuleModal.jsx b/frontend/src/components/forms/RecurringRuleModal.jsx index 9fe49220..d9007d34 100644 --- a/frontend/src/components/forms/RecurringRuleModal.jsx +++ b/frontend/src/components/forms/RecurringRuleModal.jsx @@ -1,16 +1,15 @@ import useChannelsStore from '../../store/channels.jsx'; -import API from '../../api.js'; import { - parseDate, + format, + getNow, RECURRING_DAY_OPTIONS, + toDate, toTimeString, useDateTimeFormat, useTimeHelpers, } from '../../utils/dateTimeUtils.js'; import React, { useEffect, useMemo, useState } from 'react'; import { useForm } from '@mantine/form'; -import dayjs from 'dayjs'; -import { notifications } from '@mantine/notifications'; import { Badge, Button, @@ -28,11 +27,18 @@ import { DatePickerInput, TimeInput } from '@mantine/dates'; import { deleteRecordingById } from '../../utils/cards/RecordingCardUtils.js'; import { deleteRecurringRuleById, - getChannelOptions, + getFormDefaults, getUpcomingOccurrences, updateRecurringRule, updateRecurringRuleEnabled, } from '../../utils/forms/RecurringRuleModalUtils.js'; +import { showNotification } from '../../utils/notificationUtils.js'; +import { + getChannelsSummary, + getRecurringFormDefaults, + recurringFormValidators, + sortedChannelOptions, +} from '../../utils/forms/RecordingUtils.js'; const RecurringRuleModal = ({ opened, @@ -56,66 +62,18 @@ const RecurringRuleModal = ({ const rule = recurringRules.find((r) => r.id === ruleId); const channelOptions = useMemo(() => { - return getChannelOptions(allChannels); + return sortedChannelOptions(allChannels); }, [allChannels]); const form = useForm({ mode: 'controlled', - initialValues: { - channel_id: '', - days_of_week: [], - rule_name: '', - start_time: dayjs().startOf('hour').format('HH:mm'), - end_time: dayjs().startOf('hour').add(1, 'hour').format('HH:mm'), - start_date: dayjs().toDate(), - end_date: dayjs().toDate(), - enabled: true, - }, - validate: { - channel_id: (value) => (value ? null : 'Select a channel'), - days_of_week: (value) => - value && value.length ? null : 'Pick at least one day', - end_time: (value, values) => { - if (!value) return 'Select an end time'; - const startValue = dayjs( - values.start_time, - ['HH:mm', 'hh:mm A', 'h:mm A'], - true - ); - const endValue = dayjs(value, ['HH:mm', 'hh:mm A', 'h:mm A'], true); - if ( - startValue.isValid() && - endValue.isValid() && - endValue.diff(startValue, 'minute') === 0 - ) { - return 'End time must differ from start time'; - } - return null; - }, - end_date: (value, values) => { - const endDate = dayjs(value); - const startDate = dayjs(values.start_date); - if (!value) return 'Select an end date'; - if (startDate.isValid() && endDate.isBefore(startDate, 'day')) { - return 'End date cannot be before start date'; - } - return null; - }, - }, + initialValues: { ...getRecurringFormDefaults(), enabled: true }, + validate: recurringFormValidators, }); useEffect(() => { if (opened && rule) { - form.setValues({ - channel_id: `${rule.channel}`, - days_of_week: (rule.days_of_week || []).map((d) => String(d)), - rule_name: rule.name || '', - start_time: toTimeString(rule.start_time), - end_time: toTimeString(rule.end_time), - start_date: parseDate(rule.start_date) || dayjs().toDate(), - end_date: parseDate(rule.end_date), - enabled: Boolean(rule.enabled), - }); + form.setValues(getFormDefaults(rule)); } else { form.reset(); } @@ -127,7 +85,7 @@ const RecurringRuleModal = ({ let cancelled = false; (async () => { try { - const chans = await API.getChannelsSummary(); + const chans = await getChannelsSummary(); if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []); } catch (e) { console.warn('Failed to load channels for recurring rule modal', e); @@ -149,7 +107,7 @@ const RecurringRuleModal = ({ try { await updateRecurringRule(ruleId, values); await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update - notifications.show({ + showNotification({ title: 'Recurring rule updated', message: 'Schedule adjustments saved', color: 'green', @@ -169,7 +127,7 @@ const RecurringRuleModal = ({ try { await deleteRecurringRuleById(ruleId); await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update - notifications.show({ + showNotification({ title: 'Recurring rule removed', message: 'All future occurrences were cancelled', color: 'red', @@ -189,7 +147,7 @@ const RecurringRuleModal = ({ try { await updateRecurringRuleEnabled(ruleId, checked); await fetchRecurringRules(); // recordings_refreshed WS event handles recording list update - notifications.show({ + showNotification({ title: checked ? 'Recurring rule enabled' : 'Recurring rule paused', message: checked ? 'Future occurrences will resume' @@ -210,7 +168,7 @@ const RecurringRuleModal = ({ try { await deleteRecordingById(occurrence.id); // recording_cancelled WS event handles recording list update - notifications.show({ + showNotification({ title: 'Occurrence cancelled', message: 'The selected airing was removed', color: 'yellow', @@ -246,7 +204,7 @@ const RecurringRuleModal = ({ setDeleting(true); try { await deleteRecordingById(sourceRecording.id); - notifications.show({ + showNotification({ title: 'Recording deleted', color: 'green', autoClose: 2500, @@ -275,7 +233,7 @@ const RecurringRuleModal = ({ }; const handleStartDateChange = (value) => { - form.setFieldValue('start_date', value || dayjs().toDate()); + form.setFieldValue('start_date', value || toDate(getNow())); }; const handleEndDateChange = (value) => { @@ -302,10 +260,11 @@ const RecurringRuleModal = ({ - {occStart.format(`${dateformat}, YYYY`)} + {format(occStart, `${dateformat}, YYYY`)} - {occStart.format(timeformat)} – {occEnd.format(timeformat)} + {format(occStart, timeformat)} –{' '} + {format(occEnd, timeformat)} diff --git a/frontend/src/components/forms/ScheduleInput.jsx b/frontend/src/components/forms/ScheduleInput.jsx index eb4aa70d..a0cbb854 100644 --- a/frontend/src/components/forms/ScheduleInput.jsx +++ b/frontend/src/components/forms/ScheduleInput.jsx @@ -38,8 +38,8 @@ import { Popover, ActionIcon, Group, - Divider, - SimpleGrid, + PopoverTarget, + PopoverDropdown, } from '@mantine/core'; import { Info } from 'lucide-react'; import { validateCronExpression } from '../../utils/cronUtils'; @@ -118,12 +118,12 @@ export default function ScheduleInput({ Cron Expression - + - - + + COMMON EXAMPLES @@ -169,7 +169,7 @@ export default function ScheduleInput({ 30 14 1 * * - + } diff --git a/frontend/src/components/forms/SeriesRecordingModal.jsx b/frontend/src/components/forms/SeriesRecordingModal.jsx index 67aaced5..6ec6cea4 100644 --- a/frontend/src/components/forms/SeriesRecordingModal.jsx +++ b/frontend/src/components/forms/SeriesRecordingModal.jsx @@ -16,6 +16,19 @@ const TITLE_MODE_LABEL = { regex: 'Title regex', }; +const renderRuleSummary = (r) => { + const titleMode = (r.title_mode || 'exact').toLowerCase(); + const parts = []; + parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode'); + if (r.title) { + parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`); + } + if (r.description) { + parts.push(`Description: "${r.description}"`); + } + return parts.join(' | '); +}; + export default function SeriesRecordingModal({ opened, onClose, @@ -59,19 +72,6 @@ export default function SeriesRecordingModal({ onRulesUpdate(updated); }; - const renderRuleSummary = (r) => { - const titleMode = (r.title_mode || 'exact').toLowerCase(); - const parts = []; - parts.push(r.mode === 'new' ? 'New episodes' : 'Every episode'); - if (r.title) { - parts.push(`${TITLE_MODE_LABEL[titleMode] || titleMode}: "${r.title}"`); - } - if (r.description) { - parts.push(`Description: "${r.description}"`); - } - return parts.join(' | '); - }; - return ( <> ${e.toLocaleString()}`; - } catch { - return `${start} - ${end}`; - } -} +import { getChannelsSummary } from '../../utils/forms/RecordingUtils.js'; +import { + createSeriesRule, + evaluateSeriesRulesByTvgId, +} from '../../utils/guideUtils.js'; +import { + DESCRIPTION_MODES, + EPISODE_MODES, + formatRange, + getChannelOptions, + getTvgOptions, + previewSeriesRule, + TITLE_MODES, +} from '../../utils/forms/SeriesRuleEditorModalUtils.js'; export default function SeriesRuleEditorModal({ opened, @@ -106,7 +79,7 @@ export default function SeriesRuleEditorModal({ useEffect(() => { if (!opened) return; let cancelled = false; - API.getChannelsSummary() + getChannelsSummary() .then((chans) => { if (!cancelled) setAllChannels(Array.isArray(chans) ? chans : []); }) @@ -152,7 +125,7 @@ export default function SeriesRuleEditorModal({ setPreviewLoading(true); setPreviewError(null); - API.previewSeriesRule(debouncedPreviewKey, { signal: controller.signal }) + previewSeriesRule(debouncedPreviewKey, controller) .then((resp) => { if (controller.signal.aborted) return; setPreview(resp || { matches: [], total: 0 }); @@ -173,44 +146,11 @@ export default function SeriesRuleEditorModal({ // EPG channel options for the tvg_id selector. Deduplicate by tvg_id value // since the same channel can appear across multiple EPG sources. const tvgOptions = useMemo(() => { - const seen = new Set(); - const options = []; - for (const t of tvgs || []) { - if (!t.tvg_id || seen.has(t.tvg_id)) continue; - seen.add(t.tvg_id); - options.push({ - value: t.tvg_id, - label: t.name ? `${t.name} (${t.tvg_id})` : t.tvg_id, - }); - } - return options.sort((a, b) => a.label.localeCompare(b.label)); + return getTvgOptions(tvgs); }, [tvgs]); - // Channel select options: prefer channels matching the selected tvg_id. const channelOptions = useMemo(() => { - const sorted = [...allChannels].sort((a, b) => { - const aNum = Number(a.channel_number) || 0; - const bNum = Number(b.channel_number) || 0; - if (aNum !== bNum) return aNum - bNum; - return (a.name || '').localeCompare(b.name || ''); - }); - const matching = []; - const others = []; - for (const c of sorted) { - const item = { - value: String(c.id), - label: c.channel_number - ? `${c.channel_number} - ${c.name || `Channel ${c.id}`}` - : c.name || `Channel ${c.id}`, - }; - const cTvg = c.epg_data_id ? tvgsById?.[c.epg_data_id]?.tvg_id : null; - if (tvgId && cTvg && String(cTvg) === String(tvgId)) { - matching.push(item); - } else { - others.push(item); - } - } - return [...matching, ...others]; + return getChannelOptions(allChannels, tvgsById, tvgId); }, [allChannels, tvgsById, tvgId]); const canSave = !!(payload.title || payload.description); @@ -218,10 +158,10 @@ export default function SeriesRuleEditorModal({ const handleSave = async () => { setSaving(true); try { - await API.createSeriesRule(payload); + await createSeriesRule(payload); // Trigger evaluation so matching upcoming programs get scheduled. try { - await API.evaluateSeriesRules(payload.tvg_id); + await evaluateSeriesRulesByTvgId(payload.tvg_id); await useChannelsStore.getState().fetchRecordings(); } catch (e) { console.warn('Failed to evaluate after save', e); @@ -229,6 +169,8 @@ export default function SeriesRuleEditorModal({ showNotification({ title: 'Series rule saved' }); if (onSaved) await onSaved(); onClose(); + } catch (e) { + console.error('Failed to save series rule', e); } finally { setSaving(false); } @@ -370,7 +312,7 @@ export default function SeriesRuleEditorModal({ )} - + {(preview.matches || []).map((p) => ( @@ -407,7 +349,7 @@ export default function SeriesRuleEditorModal({ )} - +
+ ), + SegmentedControl: ({ value, onChange, data, disabled }) => ( +
+ {data.map((item) => ( + + ))} +
+ ), + Select: ({ label, disabled, rightSection, data, ...props }) => ( +
+ + {rightSection} + +
+ ), + Stack: ({ children }) =>
{children}
, + TextInput: ({ label, placeholder, ...props }) => ( +
+ + +
+ ), +})); + +// ── @mantine/dates ───────────────────────────────────────────────────────────── +vi.mock('@mantine/dates', () => ({ + DatePickerInput: ({ label, value, onChange }) => ( +
+ + onChange(e.target.value ? new Date(e.target.value) : null)} + /> +
+ ), + DateTimePicker: ({ label, ...props }) => ( +
+ + +
+ ), + TimeInput: ({ label, value, onChange, onBlur }) => ( +
+ + +
+ ), +})); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + CircleAlert: () => , +})); + +// ── Imports after mocks ──────────────────────────────────────────────────────── +import useChannelsStore from '../../../store/channels'; +import { showNotification } from '../../../utils/notificationUtils.js'; +import * as RecordingUtils from '../../../utils/forms/RecordingUtils.js'; + +const setupStoreMock = () => { + const mockFetchRecordings = vi.fn().mockResolvedValue(undefined); + const mockFetchRecurringRules = vi.fn().mockResolvedValue(undefined); + + vi.mocked(useChannelsStore).mockImplementation((sel) => + sel({ fetchRecordings: mockFetchRecordings, fetchRecurringRules: mockFetchRecurringRules }) + ); + + return { mockFetchRecordings, mockFetchRecurringRules }; +}; + +const makeRecording = (overrides = {}) => ({ + id: 'rec-1', + start_time: '2024-06-01T10:00:00Z', + end_time: '2024-06-01T11:00:00Z', + custom_properties: { program: { title: 'Test Show' } }, + ...overrides, +}); + +const makeChannel = () => ({ id: 'ch-1', name: 'HBO', channel_number: 501 }); + +describe('RecordingModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue([ + { id: 'ch-1', name: 'HBO', channel_number: 501 }, + ]); + vi.mocked(RecordingUtils.createRecording).mockResolvedValue(undefined); + vi.mocked(RecordingUtils.updateRecording).mockResolvedValue(undefined); + vi.mocked(RecordingUtils.createRecurringRule).mockResolvedValue(undefined); + setupStoreMock(); + }); + + // ── Visibility ───────────────────────────────────────────────────────────── + + describe('visibility', () => { + it('renders the modal when isOpen is true', () => { + render(); + expect(screen.getByTestId('modal')).toBeInTheDocument(); + }); + + it('does not render when isOpen is false', () => { + render(); + expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + }); + }); + + // ── Alert ────────────────────────────────────────────────────────────────── + + describe('scheduling conflict alert', () => { + it('renders the scheduling conflicts alert', () => { + render(); + expect(screen.getByTestId('alert')).toBeInTheDocument(); + expect(screen.getByTestId('alert-title')).toHaveTextContent('Scheduling Conflicts'); + }); + }); + + // ── Mode switching ───────────────────────────────────────────────────────── + + describe('mode switching', () => { + it('defaults to "single" mode', () => { + render(); + expect(screen.getByTestId('mode-single')).toHaveAttribute('data-active', 'true'); + }); + + it('switches to recurring mode when Recurring button clicked', () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + expect(screen.getByTestId('mode-recurring')).toHaveAttribute('data-active', 'true'); + }); + + it('shows DateTimePicker fields in single mode', () => { + render(); + expect(screen.getByTestId('datetimepicker-Start')).toBeInTheDocument(); + expect(screen.getByTestId('datetimepicker-End')).toBeInTheDocument(); + }); + + it('shows recurring fields when in recurring mode', () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + expect(screen.getByTestId('textinput-Rule name')).toBeInTheDocument(); + expect(screen.getByTestId('timeinput-Start time')).toBeInTheDocument(); + expect(screen.getByTestId('timeinput-End time')).toBeInTheDocument(); + }); + + it('disables mode toggle when editing an existing recording', () => { + render(); + expect(screen.getByTestId('mode-single')).toBeDisabled(); + expect(screen.getByTestId('mode-recurring')).toBeDisabled(); + }); + + it('shows "Schedule Recording" submit button in single mode', () => { + render(); + expect(screen.getByText('Schedule Recording')).toBeInTheDocument(); + }); + + it('shows "Save Rule" submit button in recurring mode', () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + expect(screen.getByText('Save Rule')).toBeInTheDocument(); + }); + }); + + // ── Channel loading ──────────────────────────────────────────────────────── + + describe('channel loading', () => { + it('calls getChannelsSummary when modal opens', async () => { + render(); + await waitFor(() => { + expect(RecordingUtils.getChannelsSummary).toHaveBeenCalled(); + }); + }); + + it('calls sortedChannelOptions with loaded channels', async () => { + const channels = [{ id: 'ch-1', name: 'HBO', channel_number: 501 }]; + vi.mocked(RecordingUtils.getChannelsSummary).mockResolvedValue(channels); + render(); + await waitFor(() => { + expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith( + channels, + RecordingUtils.numberedChannelLabel + ); + }); + }); + + it('calls sortedChannelOptions with [] when getChannelsSummary rejects', async () => { + vi.mocked(RecordingUtils.getChannelsSummary).mockRejectedValue(new Error('fail')); + render(); + await waitFor(() => { + expect(RecordingUtils.sortedChannelOptions).toHaveBeenCalledWith( + [], + RecordingUtils.numberedChannelLabel + ); + }); + }); + + it('does not load channels when modal is closed', () => { + render(); + expect(RecordingUtils.getChannelsSummary).not.toHaveBeenCalled(); + }); + }); + + // ── Single form submit (create) ──────────────────────────────────────────── + + describe('single mode – create recording', () => { + it('calls buildSinglePayload with form values on submit', async () => { + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.buildSinglePayload).toHaveBeenCalled(); + }); + }); + + it('calls createRecording when no existing recording', async () => { + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.createRecording).toHaveBeenCalled(); + }); + }); + + it('shows "Recording scheduled" notification after successful create', async () => { + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Recording scheduled', color: 'green' }) + ); + }); + }); + + it('calls fetchRecordings after successful create', async () => { + const { mockFetchRecordings } = setupStoreMock(); + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(mockFetchRecordings).toHaveBeenCalled(); + }); + }); + + it('calls onClose after successful create', async () => { + const onClose = vi.fn(); + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('does not call showNotification when createRecording throws', async () => { + vi.mocked(RecordingUtils.createRecording).mockRejectedValue(new Error('fail')); + render(); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(showNotification).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Single form submit (update) ──────────────────────────────────────────── + + describe('single mode – update recording', () => { + it('calls updateRecording when editing an existing recording', async () => { + render( + + ); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.updateRecording).toHaveBeenCalledWith( + 'rec-1', + expect.anything() + ); + }); + }); + + it('does not call createRecording when updating', async () => { + render( + + ); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(RecordingUtils.createRecording).not.toHaveBeenCalled(); + }); + }); + + it('shows "Recording updated" notification after successful update', async () => { + render( + + ); + fireEvent.submit(screen.getByText('Schedule Recording').closest('form')); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Recording updated', color: 'green' }) + ); + }); + }); + }); + + // ── Recurring form submit ────────────────────────────────────────────────── + + describe('recurring mode – create rule', () => { + it('calls buildRecurringPayload with form values on submit', async () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(RecordingUtils.buildRecurringPayload).toHaveBeenCalled(); + }); + }); + + it('calls createRecurringRule on submit', async () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(RecordingUtils.createRecurringRule).toHaveBeenCalled(); + }); + }); + + it('calls fetchRecurringRules and fetchRecordings on success', async () => { + const { mockFetchRecurringRules, mockFetchRecordings } = setupStoreMock(); + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(mockFetchRecurringRules).toHaveBeenCalled(); + expect(mockFetchRecordings).toHaveBeenCalled(); + }); + }); + + it('shows "Recurring rule saved" notification on success', async () => { + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(showNotification).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Recurring rule saved', color: 'green' }) + ); + }); + }); + + it('calls onClose after successful recurring submit', async () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + + it('does not show notification when createRecurringRule throws', async () => { + vi.mocked(RecordingUtils.createRecurringRule).mockRejectedValue(new Error('fail')); + render(); + fireEvent.click(screen.getByTestId('mode-recurring')); + fireEvent.submit(screen.getByText('Save Rule').closest('form')); + await waitFor(() => { + expect(showNotification).not.toHaveBeenCalled(); + }); + }); + }); + + // ── Form initialization ──────────────────────────────────────────────────── + + describe('form initialization', () => { + it('calls getSingleFormDefaults with recording and channel when opening with existing recording', () => { + const recording = makeRecording(); + const channel = makeChannel(); + render(); + expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(recording, channel); + }); + + it('calls getSingleFormDefaults with null when opening for new recording', () => { + render(); + expect(RecordingUtils.getSingleFormDefaults).toHaveBeenCalledWith(null, null); + }); + + it('calls getRecurringFormDefaults with channel on open', () => { + const channel = makeChannel(); + render(); + expect(RecordingUtils.getRecurringFormDefaults).toHaveBeenCalledWith(channel); + }); + }); + + // ── Close / reset ────────────────────────────────────────────────────────── + + describe('close and reset', () => { + it('calls onClose when modal close button is clicked', () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('modal-close')); + expect(onClose).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx b/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx new file mode 100644 index 00000000..91216ac1 --- /dev/null +++ b/frontend/src/components/forms/__tests__/RecordingDetailsModal.test.jsx @@ -0,0 +1,770 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Store mocks ──────────────────────────────────────────────────────────────── +vi.mock('../../../store/channels.jsx', () => ({ default: vi.fn() })); +vi.mock('../../../store/useVideoStore.jsx', () => ({ + default: Object.assign(vi.fn(), { + getState: vi.fn(() => ({ showVideo: vi.fn() })), + }), +})); + +// ── Utility mocks ────────────────────────────────────────────────────────────── +vi.mock('../../../utils/dateTimeUtils.js', () => ({ + format: vi.fn(), + isAfter: vi.fn(), + isBefore: vi.fn(), + useDateTimeFormat: vi.fn(), + useTimeHelpers: vi.fn(), +})); + +vi.mock('../../../utils/notificationUtils.js', () => ({ + showNotification: vi.fn(), +})); + +vi.mock('../../../utils/cards/RecordingCardUtils.js', () => ({ + deleteRecordingById: vi.fn(), + getChannelLogoUrl: vi.fn(), + getPosterUrl: vi.fn(), + getRecordingUrl: vi.fn(), + getSeasonLabel: vi.fn(), + getShowVideoUrl: vi.fn(), + runComSkip: vi.fn(), +})); + +vi.mock('../../../utils/forms/RecordingDetailsModalUtils.js', () => ({ + getChannel: vi.fn(), + getRating: vi.fn(), + getStatRows: vi.fn(), + getUpcomingEpisodes: vi.fn(), + refreshArtwork: vi.fn(), + updateRecordingMetadata: vi.fn(), +})); + +vi.mock('../../../images/logo.png', () => ({ default: 'default-logo.png' })); + +// ── lucide-react ─────────────────────────────────────────────────────────────── +vi.mock('lucide-react', () => ({ + Check: () => , + Pencil: () => , + RefreshCcw: () => , + X: () => , +})); + +// ── @mantine/core ────────────────────────────────────────────────────────────── +vi.mock('@mantine/core', () => ({ + ActionIcon: ({ children, onClick, disabled }) => ( + + ), + Badge: ({ children, color }) => ( + {children} + ), + Button: ({ children, onClick, disabled, loading, size, variant, color }) => ( + + ), + Card: ({ children, onClick, style }) => ( +
{children}
+ ), + Flex: ({ children }) =>
{children}
, + Group: ({ children }) =>
{children}
, + Image: ({ src, alt, fallbackSrc }) => ( + {alt} + ), + Modal: ({ children, opened, onClose, title, size }) => + opened ? ( +
+
{title}
+ + {children} +
+ ) : null, + Stack: ({ children }) =>
{children}
, + Text: ({ children, size, c, fw, style }) => ( + {children} + ), + Textarea: ({ label, value, onChange, placeholder, ...props }) => ( +
+ +