import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { NavigationEnd, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { PlaylistActions, selectActivePlaylistId, selectAllPlaylistsMeta, selectPlaylistsLoadingFlag, } from 'm3u-state'; import { Subject } from 'rxjs'; import { PlaylistMeta } from 'shared-interfaces'; import { PlaylistContextFacade, PlaylistRouteContext, } from './playlist-context.facade'; const LAST_SECTION_STORAGE_KEY = 'playlist-switcher:last-sections:v2'; function createPlaylist( overrides: Partial & { _id: string } ): PlaylistMeta { return { _id: overrides._id, title: overrides.title ?? overrides.filename ?? overrides._id, count: overrides.count ?? 0, importDate: overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(), autoRefresh: overrides.autoRefresh ?? false, ...overrides, } as PlaylistMeta; } describe('PlaylistContextFacade', () => { let facade: PlaylistContextFacade; let routerEvents: Subject; let router: { url: string; events: ReturnType['asObservable']>; navigate: jest.Mock; }; let dispatch: jest.Mock; let playlistsSignal: ReturnType>; let loadedSignal: ReturnType>; let activePlaylistIdSignal: ReturnType>; let originalElectron: unknown; const xtreamA = createPlaylist({ _id: 'xtream-a', password: 'secret', serverUrl: 'http://127.0.0.1:3211', title: 'Xtream A', username: 'user-a', }); const xtreamB = createPlaylist({ _id: 'xtream-b', password: 'secret', serverUrl: 'http://127.0.0.1:3211', title: 'Xtream B', username: 'user-b', }); const stalkerA = createPlaylist({ _id: 'stalker-a', macAddress: '00:1A:79:00:00:01', portalUrl: 'http://127.0.0.1:3210/portal.php', title: 'Stalker A', }); const stalkerB = createPlaylist({ _id: 'stalker-b', macAddress: '00:1A:79:00:00:02', portalUrl: 'http://127.0.0.1:3210/portal.php', title: 'Stalker B', }); const m3uA = createPlaylist({ _id: 'm3u-a', title: 'M3U A', url: 'http://example.test/a.m3u', }); const m3uB = createPlaylist({ _id: 'm3u-b', title: 'M3U B', url: 'http://example.test/b.m3u', }); function setElectronAvailability(enabled: boolean): void { Object.defineProperty(window, 'electron', { configurable: true, value: enabled ? {} : undefined, }); } function instantiateFacade(): PlaylistContextFacade { facade = TestBed.inject(PlaylistContextFacade); dispatch.mockClear(); router.navigate.mockClear(); return facade; } beforeEach(() => { localStorage.clear(); originalElectron = (window as Window & { electron?: unknown }).electron; routerEvents = new Subject(); router = { url: '/workspace/xtreams/xtream-a/live', events: routerEvents.asObservable(), navigate: jest.fn().mockResolvedValue(true), }; dispatch = jest.fn((action: unknown) => action); playlistsSignal = signal([ xtreamA, xtreamB, stalkerA, stalkerB, m3uA, m3uB, ]); loadedSignal = signal(true); activePlaylistIdSignal = signal(xtreamA._id); setElectronAvailability(true); TestBed.configureTestingModule({ providers: [ PlaylistContextFacade, { provide: Router, useValue: router, }, { provide: Store, useValue: { dispatch, selectSignal: jest.fn((selector: unknown) => { if (selector === selectAllPlaylistsMeta) { return playlistsSignal; } if (selector === selectPlaylistsLoadingFlag) { return loadedSignal; } if (selector === selectActivePlaylistId) { return activePlaylistIdSignal; } throw new Error( `Unexpected selector: ${String(selector)}` ); }), }, }, ], }); }); afterEach(() => { localStorage.clear(); Object.defineProperty(window, 'electron', { configurable: true, value: originalElectron, }); routerEvents.complete(); jest.restoreAllMocks(); }); it('preserves same-provider Xtream sections when switching through selectPlaylist', () => { router.url = '/workspace/xtreams/xtream-a/live'; const service = instantiateFacade(); service.selectPlaylist(xtreamB); expect(dispatch).toHaveBeenCalledWith( PlaylistActions.setActivePlaylist({ playlistId: xtreamB._id }) ); expect(router.navigate).toHaveBeenCalledWith([ 'workspace', 'xtreams', xtreamB._id, 'live', ]); }); it('preserves same-provider Stalker sections when resolving target commands', () => { const service = instantiateFacade(); expect( service.resolveTargetCommands('stalker', stalkerB._id, { inWorkspace: true, provider: 'stalker', playlistId: stalkerA._id, section: 'downloads', }) ).toEqual(['workspace', 'stalker', stalkerB._id, 'downloads']); }); it('preserves same-provider M3U sections when resolving target commands', () => { const service = instantiateFacade(); expect( service.resolveTargetCommands('playlists', m3uB._id, { inWorkspace: true, provider: 'playlists', playlistId: m3uA._id, section: 'groups', }) ).toEqual(['workspace', 'playlists', m3uB._id, 'groups']); }); it('maps Stalker ITV routes to Xtream live routes', () => { const service = instantiateFacade(); expect( service.resolveTargetCommands('xtreams', xtreamA._id, { inWorkspace: true, provider: 'stalker', playlistId: stalkerA._id, section: 'itv', }) ).toEqual(['workspace', 'xtreams', xtreamA._id, 'live']); }); it('maps Xtream live routes to Stalker ITV routes', () => { const service = instantiateFacade(); expect( service.resolveTargetCommands('stalker', stalkerA._id, { inWorkspace: true, provider: 'xtreams', playlistId: xtreamA._id, section: 'live', }) ).toEqual(['workspace', 'stalker', stalkerA._id, 'itv']); }); it('maps Xtream recently-added routes to Stalker recent routes', () => { const service = instantiateFacade(); expect( service.resolveTargetCommands('stalker', stalkerA._id, { inWorkspace: true, provider: 'xtreams', playlistId: xtreamA._id, section: 'recently-added', }) ).toEqual(['workspace', 'stalker', stalkerA._id, 'recent']); }); it('preserves recent and favorites sections across provider changes when both providers support them', () => { const service = instantiateFacade(); expect( service.resolveTargetCommands('xtreams', xtreamA._id, { inWorkspace: true, provider: 'playlists', playlistId: m3uA._id, section: 'recent', }) ).toEqual(['workspace', 'xtreams', xtreamA._id, 'recent']); expect( service.resolveTargetCommands('stalker', stalkerA._id, { inWorkspace: true, provider: 'playlists', playlistId: m3uA._id, section: 'favorites', }) ).toEqual(['workspace', 'stalker', stalkerA._id, 'favorites']); }); it('falls back to M3U all when the current section has no M3U equivalent', () => { const service = instantiateFacade(); expect( service.resolveTargetCommands('playlists', m3uA._id, { inWorkspace: true, provider: 'xtreams', playlistId: xtreamA._id, section: 'live', }) ).toEqual(['workspace', 'playlists', m3uA._id, 'all']); }); it('does not let stale M3U memory override the fallback to all for portal-only sections', () => { localStorage.setItem( LAST_SECTION_STORAGE_KEY, JSON.stringify({ providers: { playlists: 'recent', }, playlists: { [m3uA._id]: { provider: 'playlists', section: 'recent', updatedAt: 1, }, }, }) ); const service = instantiateFacade(); expect( service.resolveTargetCommands('playlists', m3uA._id, { inWorkspace: true, provider: 'xtreams', playlistId: xtreamA._id, section: 'live', }) ).toEqual(['workspace', 'playlists', m3uA._id, 'all']); }); it('uses playlist-specific section memory when the current route has no section', () => { localStorage.setItem( LAST_SECTION_STORAGE_KEY, JSON.stringify({ providers: {}, playlists: { [xtreamB._id]: { provider: 'xtreams', section: 'series', updatedAt: 1, }, }, }) ); const service = instantiateFacade(); expect( service.resolveTargetCommands('xtreams', xtreamB._id, { inWorkspace: true, provider: 'xtreams', playlistId: xtreamA._id, section: null, }) ).toEqual(['workspace', 'xtreams', xtreamB._id, 'series']); }); it('uses provider-level section memory when no route section or playlist memory exists', () => { localStorage.setItem( LAST_SECTION_STORAGE_KEY, JSON.stringify({ providers: { stalker: 'search', }, playlists: {}, }) ); const service = instantiateFacade(); expect( service.resolveTargetCommands('stalker', stalkerB._id, { inWorkspace: true, provider: 'xtreams', playlistId: xtreamA._id, section: null, }) ).toEqual(['workspace', 'stalker', stalkerB._id, 'search']); }); it('omits Xtream sections outside Electron where section navigation is unsupported', () => { setElectronAvailability(false); const service = instantiateFacade(); expect( service.resolveTargetCommands('xtreams', xtreamB._id, { inWorkspace: true, provider: 'xtreams', playlistId: xtreamA._id, section: 'live', }) ).toEqual(['workspace', 'xtreams', xtreamB._id]); }); it('returns null when switching from the workspace root before a provider route is selected', () => { const service = instantiateFacade(); expect( service.resolveTargetCommands('playlists', m3uA._id, { inWorkspace: true, provider: null, playlistId: null, section: null, }) ).toBeNull(); }); it('syncs section memory from router navigation events', () => { router.url = '/workspace/playlists/m3u-a/favorites'; const service = instantiateFacade(); router.url = '/workspace/xtreams/xtream-a/recently-added'; routerEvents.next( new NavigationEnd( 1, '/workspace/xtreams/xtream-a/recently-added', '/workspace/xtreams/xtream-a/recently-added' ) ); expect(service.routeContext()).toEqual({ inWorkspace: true, provider: 'xtreams', playlistId: xtreamA._id, section: 'recently-added', }); expect(JSON.parse(localStorage.getItem(LAST_SECTION_STORAGE_KEY) ?? '{}')) .toEqual({ playlists: { [m3uA._id]: { provider: 'playlists', section: 'favorites', updatedAt: expect.any(Number), }, [xtreamA._id]: { provider: 'xtreams', section: 'recently-added', updatedAt: expect.any(Number), }, }, providers: { playlists: 'favorites', xtreams: 'recently-added', }, }); }); });