feat: enhance M3U collection route component with playlist ID handling and add tests for playlist switcher and context facade

This commit is contained in:
4gray committed 2026-04-06 12:57:17 +02:00
1 parent d5f20d1df2
commit 4e74ac2546
4 files changed
+705

No files matched your search

@@ -1,9 +1,12 @@
import {
inject,
ChangeDetectionStrategy,
Component,
input,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui';
import { routeParamSignal } from '@iptvnator/portal/shared/util';
@Component({
selector: 'app-m3u-collection-route',
@@ -12,11 +15,19 @@ import { UnifiedCollectionPageComponent } from '@iptvnator/portal/shared/ui';
<app-unified-collection-page
[mode]="mode()"
[portalType]="portalType()"
[playlistId]="playlistId()"
/>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class M3uCollectionRouteComponent {
private readonly route = inject(ActivatedRoute);
readonly mode = input<'favorites' | 'recent'>('favorites');
readonly portalType = input('m3u');
readonly playlistId = routeParamSignal<string | undefined>(
this.route,
'id',
(value) => value ?? undefined
);
}
@@ -0,0 +1,268 @@
import { signal } from '@angular/core';
import {
ComponentFixture,
TestBed,
fakeAsync,
flushMicrotasks,
tick,
} from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import { PortalStatusService } from 'services';
import { PlaylistMeta } from 'shared-interfaces';
import { PlaylistSwitcherComponent } from './playlist-switcher.component';
const SEARCH_QUERY_STORAGE_KEY = 'playlist-switcher:search-query';
const PLAYLIST_TYPE_FILTER_STORAGE_KEY = 'playlist-switcher:type-filters';
function createPlaylist(
overrides: Partial<PlaylistMeta> & { _id: string }
): PlaylistMeta {
return {
_id: overrides._id,
title: overrides.title ?? overrides.filename ?? overrides._id,
filename: overrides.filename,
count: overrides.count ?? 0,
importDate:
overrides.importDate ?? new Date('2026-04-05T10:00:00.000Z').toISOString(),
autoRefresh: overrides.autoRefresh ?? false,
...overrides,
} as PlaylistMeta;
}
describe('PlaylistSwitcherComponent', () => {
let fixture: ComponentFixture<PlaylistSwitcherComponent>;
let component: PlaylistSwitcherComponent;
let playlistsSignal: ReturnType<typeof signal<PlaylistMeta[]>>;
let resolvedPlaylistIdSignal: ReturnType<typeof signal<string | null>>;
let activePlaylistSignal: ReturnType<typeof signal<PlaylistMeta | null>>;
let playlistContext: {
playlists: typeof playlistsSignal;
allPlaylistsLoaded: ReturnType<typeof signal<boolean>>;
resolvedPlaylistId: typeof resolvedPlaylistIdSignal;
activePlaylist: typeof activePlaylistSignal;
selectPlaylist: jest.Mock;
};
let portalStatusService: {
checkPortalStatus: jest.Mock;
getStatusClass: jest.Mock;
};
const m3uPlaylist = createPlaylist({
_id: 'm3u-1',
count: 24,
importDate: '2026-04-05T09:00:00.000Z',
title: 'Morning M3U',
url: 'http://example.test/m3u.m3u',
});
const stalkerPlaylist = createPlaylist({
_id: 'stalker-1',
importDate: '2026-04-05T11:00:00.000Z',
macAddress: '00:1A:79:00:00:01',
portalUrl: 'http://127.0.0.1:3210/portal.php',
title: 'Living Room Stalker',
});
const xtreamPlaylist = createPlaylist({
_id: 'xtream-1',
importDate: '2026-04-05T12:00:00.000Z',
password: 'pass1',
serverUrl: 'http://127.0.0.1:3211',
title: 'Cinema Xtream',
updateDate: new Date('2026-04-05T12:30:00.000Z').getTime(),
username: 'user1',
});
async function createComponent(): Promise<void> {
await TestBed.configureTestingModule({
imports: [
PlaylistSwitcherComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: PlaylistContextFacade,
useValue: playlistContext,
},
{
provide: PortalStatusService,
useValue: portalStatusService,
},
],
}).compileComponents();
fixture = TestBed.createComponent(PlaylistSwitcherComponent);
component = fixture.componentInstance;
fixture.componentRef.setInput('currentTitle', '');
fixture.componentRef.setInput('subtitle', '3 playlists');
fixture.detectChanges();
}
beforeEach(() => {
localStorage.clear();
playlistsSignal = signal([m3uPlaylist, stalkerPlaylist, xtreamPlaylist]);
resolvedPlaylistIdSignal = signal<string | null>(xtreamPlaylist._id);
activePlaylistSignal = signal<PlaylistMeta | null>(xtreamPlaylist);
playlistContext = {
playlists: playlistsSignal,
allPlaylistsLoaded: signal(true),
resolvedPlaylistId: resolvedPlaylistIdSignal,
activePlaylist: activePlaylistSignal,
selectPlaylist: jest.fn((playlist: PlaylistMeta) => {
resolvedPlaylistIdSignal.set(playlist._id);
activePlaylistSignal.set(playlist);
}),
};
portalStatusService = {
checkPortalStatus: jest
.fn()
.mockResolvedValueOnce('active')
.mockResolvedValueOnce('inactive'),
getStatusClass: jest.fn((status: string) => `status-${status}`),
};
});
afterEach(() => {
localStorage.clear();
jest.restoreAllMocks();
});
it('reads persisted search and type filters on startup and filters playlists accordingly', async () => {
localStorage.setItem(SEARCH_QUERY_STORAGE_KEY, 'room');
localStorage.setItem(
PLAYLIST_TYPE_FILTER_STORAGE_KEY,
JSON.stringify({
m3u: false,
stalker: true,
xtream: true,
})
);
await createComponent();
expect(component.searchQuery()).toBe('room');
expect(component.isTypeFilterSelected('m3u')).toBe(false);
expect(component.filteredPlaylists()).toEqual([stalkerPlaylist]);
});
it('sorts playlists by import date descending and prevents clearing the last enabled type filter', async () => {
await createComponent();
expect(component.filteredPlaylists().map((playlist) => playlist._id)).toEqual([
xtreamPlaylist._id,
stalkerPlaylist._id,
m3uPlaylist._id,
]);
component.togglePlaylistTypeFilter('m3u');
component.togglePlaylistTypeFilter('stalker');
expect(component.playlistTypeFilters()).toEqual({
m3u: false,
stalker: false,
xtream: true,
});
component.togglePlaylistTypeFilter('xtream');
expect(component.playlistTypeFilters()).toEqual({
m3u: false,
stalker: false,
xtream: true,
});
});
it('selects a playlist, closes the menu, and emits the selected id', async () => {
await createComponent();
const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu');
const selectedIds: string[] = [];
component.playlistSelected.subscribe((playlistId) => {
selectedIds.push(playlistId);
});
component.selectPlaylist(stalkerPlaylist);
fixture.detectChanges();
expect(closeMenuSpy).toHaveBeenCalledTimes(1);
expect(playlistContext.selectPlaylist).toHaveBeenCalledWith(
stalkerPlaylist
);
expect(selectedIds).toEqual([stalkerPlaylist._id]);
expect(component.isSelected(stalkerPlaylist)).toBe(true);
});
it('emits playlist and account info requests through the context actions', async () => {
await createComponent();
const closeMenuSpy = jest.spyOn(component.menuTrigger(), 'closeMenu');
const playlistInfoSpy = jest.fn();
const accountInfoSpy = jest.fn();
component.playlistInfoRequested.subscribe(playlistInfoSpy);
component.accountInfoRequested.subscribe(accountInfoSpy);
component.requestPlaylistInfo();
component.requestAccountInfo();
expect(closeMenuSpy).toHaveBeenCalledTimes(2);
expect(playlistInfoSpy).toHaveBeenCalledTimes(1);
expect(accountInfoSpy).toHaveBeenCalledTimes(1);
});
it('opens the menu, syncs overlay width, and checks portal statuses for Xtream playlists', fakeAsync(async () => {
await createComponent();
const triggerElement = component.triggerElement().nativeElement;
jest.spyOn(triggerElement, 'getBoundingClientRect').mockReturnValue({
bottom: 0,
height: 0,
left: 0,
right: 320,
toJSON: () => ({}),
top: 0,
width: 320,
x: 0,
y: 0,
});
component.onMenuOpened();
flushMicrotasks();
tick();
expect(component.isMenuOpen()).toBe(true);
expect(
document.documentElement.style.getPropertyValue(
'--playlist-switcher-overlay-width'
)
).toBe('320px');
expect(portalStatusService.checkPortalStatus).toHaveBeenCalledTimes(1);
expect(portalStatusService.checkPortalStatus).toHaveBeenCalledWith(
xtreamPlaylist.serverUrl,
xtreamPlaylist.username,
xtreamPlaylist.password
);
expect(component.portalStatuses().get(xtreamPlaylist._id)).toBe('active');
expect(component.getStatusClass(xtreamPlaylist._id)).toBe('status-active');
component.onMenuClosed();
expect(component.isMenuOpen()).toBe(false);
expect(
document.documentElement.style.getPropertyValue(
'--playlist-switcher-overlay-width'
)
).toBe('');
}));
it('falls back to the active playlist title when no explicit current title is provided', async () => {
await createComponent();
expect(component.displayTitle()).toBe('Cinema Xtream');
resolvedPlaylistIdSignal.set(null);
activePlaylistSignal.set(null);
fixture.detectChanges();
expect(component.displayTitle()).toBe('Select playlist');
});
});
@@ -0,0 +1,422 @@
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<PlaylistMeta> & { _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<NavigationEnd>;
let router: {
url: string;
events: ReturnType<Subject<NavigationEnd>['asObservable']>;
navigate: jest.Mock;
};
let dispatch: jest.Mock;
let playlistsSignal: ReturnType<typeof signal<PlaylistMeta[]>>;
let loadedSignal: ReturnType<typeof signal<boolean>>;
let activePlaylistIdSignal: ReturnType<typeof signal<string | null>>;
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<NavigationEnd>();
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<string | null>(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<PlaylistRouteContext>({
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',
},
});
});
});
@@ -312,6 +312,10 @@ export class PlaylistContextFacade {
return fromCurrent;
}
if (provider === 'playlists' && currentSection) {
return 'all';
}
const memory = this.readSectionMemory();
const playlistMemory = memory.playlists[targetPlaylistId];
if (playlistMemory?.provider === provider) {