fix(stalker): series favorites handling

Entire-Checkpoint: c6e522b4276c
This commit is contained in:
4gray committed 2026-04-25 22:16:33 +02:00
1 parent 6eb602d332
commit 74c8adc12e
8 files changed
+931 -24

No files matched your search

@@ -59,6 +59,37 @@ describe('stalker-vod.utils regressions', () => {
expect(state.vodDetailsItem).toBeNull();
});
it('builds a playable VOD details item for regular movies', () => {
const state = createStalkerDetailViewState(
{
id: '42',
cmd: '/media/file_42.mpg',
info: {
name: 'Regular Movie',
movie_image: 'poster.jpg',
description: '',
actors: '',
director: '',
releasedate: '',
genre: '',
rating_imdb: '',
rating_kinopoisk: '',
},
},
'playlist-1'
);
expect(state.itemDetails?.id).toBe('42');
expect(state.vodDetailsItem).toEqual(
expect.objectContaining({
type: 'stalker',
playlistId: 'playlist-1',
cmd: '/media/file_42.mpg',
})
);
expect(state.vodDetailsItem?.data.id).toBe('42');
});
it('favorite toggle uses completion callback path without delayed state update', () => {
const addToFavorites = jest.fn(
(_item: Record<string, unknown>, onDone?: () => void) => {
@@ -0,0 +1,254 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ContentHeroComponent } from 'components';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from 'services';
import { Playlist } from 'shared-interfaces';
import { of } from 'rxjs';
import { StalkerCollectionDetailComponent } from './stalker-collection-detail.component';
import { StalkerInlineDetailComponent } from './stalker-inline-detail/stalker-inline-detail.component';
@Component({
selector: 'app-content-hero',
standalone: true,
template: '',
})
class StubContentHeroComponent {
readonly isLoading = input(false);
}
@Component({
selector: 'app-stalker-inline-detail',
standalone: true,
template: '',
})
class StubStalkerInlineDetailComponent {
readonly categoryId = input<'vod' | 'series' | null>(null);
readonly seriesItem = input<unknown>(null);
readonly isSeries = input(false);
readonly vodDetailsItem = input<unknown>(null);
readonly isFavorite = input(false);
readonly backClicked = output<void>();
readonly playClicked = output<unknown>();
readonly favoriteToggled = output<unknown>();
}
describe('StalkerCollectionDetailComponent', () => {
let fixture: ComponentFixture<StalkerCollectionDetailComponent>;
let currentPlaylist: ReturnType<typeof signal<Playlist | undefined>>;
let selectedContentType: ReturnType<
typeof signal<'vod' | 'itv' | 'series'>
>;
let selectedCategoryId: ReturnType<typeof signal<string | null>>;
let selectedItem: ReturnType<typeof signal<unknown>>;
let stalkerStore: {
currentPlaylist: typeof currentPlaylist;
selectedContentType: typeof selectedContentType;
selectedCategoryId: typeof selectedCategoryId;
selectedItem: typeof selectedItem;
setCurrentPlaylist: jest.Mock;
setSelectedContentType: jest.Mock;
setSelectedCategory: jest.Mock;
setSelectedItem: jest.Mock;
addToFavorites: jest.Mock;
removeFromFavorites: jest.Mock;
createLinkToPlayVod: jest.Mock;
};
const playlist = {
_id: 'stalker-1',
title: 'Stalker Portal',
portalUrl: 'http://portal.example/portal.php',
macAddress: '00:1A:79:00:00:01',
favorites: [],
} as unknown as Playlist;
beforeEach(async () => {
currentPlaylist = signal<Playlist | undefined>(undefined);
selectedContentType = signal<'vod' | 'itv' | 'series'>('vod');
selectedCategoryId = signal<string | null>(null);
selectedItem = signal<unknown>(null);
stalkerStore = {
currentPlaylist,
selectedContentType,
selectedCategoryId,
selectedItem,
setCurrentPlaylist: jest.fn(async (value: Playlist | undefined) => {
currentPlaylist.set(value);
}),
setSelectedContentType: jest.fn(
(value: 'vod' | 'itv' | 'series') => {
selectedContentType.set(value);
}
),
setSelectedCategory: jest.fn((value: string | null) => {
selectedCategoryId.set(value);
}),
setSelectedItem: jest.fn((value: unknown) => {
selectedItem.set(value);
}),
addToFavorites: jest.fn(),
removeFromFavorites: jest.fn(),
createLinkToPlayVod: jest.fn(),
};
await TestBed.configureTestingModule({
imports: [StalkerCollectionDetailComponent],
providers: [
{
provide: StalkerStore,
useValue: stalkerStore,
},
{
provide: PlaylistsService,
useValue: {
getPlaylistById: jest.fn(() => of(playlist)),
getPortalFavorites: jest.fn(() => of([])),
},
},
],
})
.overrideComponent(StalkerCollectionDetailComponent, {
remove: {
imports: [ContentHeroComponent, StalkerInlineDetailComponent],
},
add: {
imports: [
StubContentHeroComponent,
StubStalkerInlineDetailComponent,
],
},
})
.compileComponents();
fixture = TestBed.createComponent(StalkerCollectionDetailComponent);
});
afterEach(() => {
fixture?.destroy();
});
it('opens legacy VOD is_series favorites through the lazy VOD-series flow', async () => {
fixture.componentRef.setInput(
'item',
buildCollectionItem({
contentType: 'series',
categoryId: 'series',
stalkerItem: {
id: '1507',
title: 'Flagged Series',
category_id: 'series',
cmd: '/media/file_1507.mpg',
is_series: '1',
},
})
);
await settleDetail(fixture);
expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith(
'vod'
);
expect(stalkerStore.setSelectedCategory).toHaveBeenLastCalledWith('vod');
expect(stalkerStore.setSelectedItem).toHaveBeenLastCalledWith(
expect.objectContaining({
id: '1507',
is_series: true,
})
);
expect(fixture.componentInstance.inlineDetail()).toEqual(
expect.objectContaining({
categoryId: 'vod',
isSeries: true,
vodDetailsItem: null,
})
);
});
it('keeps regular Stalker series favorites in regular series mode', async () => {
fixture.componentRef.setInput(
'item',
buildCollectionItem({
contentType: 'series',
categoryId: 'series',
stalkerItem: {
id: '30001',
series_id: '30001',
title: 'Regular Series',
category_id: 'series',
},
})
);
await settleDetail(fixture);
expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith(
'series'
);
expect(fixture.componentInstance.inlineDetail()).toEqual(
expect.objectContaining({
categoryId: 'series',
isSeries: false,
vodDetailsItem: null,
})
);
});
it('keeps embedded VOD series favorites in the embedded VOD-series path', async () => {
fixture.componentRef.setInput(
'item',
buildCollectionItem({
contentType: 'series',
categoryId: 'series',
stalkerItem: {
id: '20001',
title: 'Embedded Series',
category_id: 'series',
cmd: '/media/file_20001.mpg',
series: [1, 2],
},
})
);
await settleDetail(fixture);
expect(stalkerStore.setSelectedContentType).toHaveBeenLastCalledWith(
'vod'
);
expect(fixture.componentInstance.inlineDetail()).toEqual(
expect.objectContaining({
categoryId: 'vod',
isSeries: false,
vodDetailsItem: null,
})
);
expect(
fixture.componentInstance.inlineDetail().seriesItem?.series
).toEqual([1, 2]);
});
});
function buildCollectionItem(
overrides: Partial<UnifiedCollectionItem>
): UnifiedCollectionItem {
return {
uid: 'stalker::stalker-1::item-1',
name: 'Item',
contentType: 'movie',
sourceType: 'stalker',
playlistId: 'stalker-1',
playlistName: 'Stalker Portal',
stalkerId: 'item-1',
...overrides,
};
}
async function settleDetail(
fixture: ComponentFixture<StalkerCollectionDetailComponent>
): Promise<void> {
fixture.detectChanges();
await fixture.whenStable();
await Promise.resolve();
fixture.detectChanges();
}
@@ -40,6 +40,15 @@ interface StalkerCollectionStateSnapshot {
selectedItem: unknown;
}
type StalkerDetailCategory = 'vod' | 'series';
interface StalkerCollectionDetailMode {
category: StalkerDetailCategory;
selectedContentType: StalkerDetailCategory;
hasEmbeddedSeries: boolean;
needsSeriesFetch: boolean;
}
@Component({
selector: 'app-stalker-collection-detail',
imports: [ContentHeroComponent, StalkerInlineDetailComponent],
@@ -83,11 +92,14 @@ export class StalkerCollectionDetailComponent {
readonly itemDetails = signal<StalkerSelectedVodItem | null>(null);
readonly vodDetailsItem = signal<VodDetailsItem | null>(null);
readonly isSelectedVodFavorite = signal(false);
readonly detailCategoryOverride = signal<StalkerDetailCategory | null>(
null
);
readonly inlineDetail = computed(() =>
createStalkerInlineDetailState(
this.itemDetails(),
this.vodDetailsItem(),
this.item()?.contentType === 'series' ? 'series' : 'vod'
this.detailCategoryOverride()
)
);
@@ -183,35 +195,24 @@ export class StalkerCollectionDetailComponent {
}
const stalkerItem = this.resolveStalkerItem(item);
const hasEmbeddedSeries = Array.isArray(
(stalkerItem as { series?: unknown[] }).series
)
? (stalkerItem as { series?: unknown[] }).series!.length > 0
: false;
const needsSeriesFetch =
item.contentType === 'movie' &&
!hasEmbeddedSeries &&
isStalkerSeriesFlag(
(stalkerItem as { is_series?: unknown }).is_series
);
const detailMode = this.resolveDetailMode(item, stalkerItem);
const itemDetails = buildStalkerSelectedVodItem(
stalkerItem as never,
needsSeriesFetch
detailMode.needsSeriesFetch
);
this.stalkerStore.setSelectedContentType(
item.contentType === 'series' ? 'series' : 'vod'
);
this.detailCategoryOverride.set(detailMode.category);
this.stalkerStore.setSelectedContentType(detailMode.selectedContentType);
this.stalkerStore.setSelectedCategory(
item.categoryId ?? toStalkerCategoryId(item.contentType)
this.resolveSelectedCategory(item, stalkerItem, detailMode)
);
this.stalkerStore.setSelectedItem(itemDetails);
this.itemDetails.set(itemDetails);
if (
item.contentType === 'movie' &&
!hasEmbeddedSeries &&
!needsSeriesFetch
detailMode.selectedContentType === 'vod' &&
!detailMode.hasEmbeddedSeries &&
!detailMode.needsSeriesFetch
) {
const detailViewState = createStalkerDetailViewState(
itemDetails,
@@ -267,6 +268,54 @@ export class StalkerCollectionDetailComponent {
) as StalkerPortalItem;
}
private resolveDetailMode(
item: UnifiedCollectionItem,
stalkerItem: StalkerPortalItem
): StalkerCollectionDetailMode {
const hasEmbeddedSeries = Array.isArray(
(stalkerItem as { series?: unknown[] }).series
)
? (stalkerItem as { series?: unknown[] }).series!.length > 0
: false;
const isVodSeries = isStalkerSeriesFlag(
(stalkerItem as { is_series?: unknown }).is_series
);
const isRegularSeries =
item.contentType === 'series' && !hasEmbeddedSeries && !isVodSeries;
const selectedContentType: StalkerDetailCategory = isRegularSeries
? 'series'
: 'vod';
return {
category: selectedContentType,
selectedContentType,
hasEmbeddedSeries,
needsSeriesFetch:
selectedContentType === 'vod' &&
!hasEmbeddedSeries &&
isVodSeries,
};
}
private resolveSelectedCategory(
item: UnifiedCollectionItem,
stalkerItem: StalkerPortalItem,
detailMode: StalkerCollectionDetailMode
): string | number {
const categoryId =
item.categoryId ??
(stalkerItem as { category_id?: string | number }).category_id;
if (
detailMode.selectedContentType === 'vod' &&
String(categoryId ?? '').toLowerCase() === 'series'
) {
return 'vod';
}
return categoryId ?? toStalkerCategoryId(detailMode.selectedContentType);
}
private syncSelectedVodFavorite(): void {
this.isSelectedVodFavorite.set(
isSelectedStalkerVodFavorite(
@@ -280,6 +329,7 @@ export class StalkerCollectionDetailComponent {
const cleared = clearStalkerDetailViewState();
this.itemDetails.set(cleared.itemDetails);
this.vodDetailsItem.set(cleared.vodDetailsItem);
this.detailCategoryOverride.set(null);
this.isSelectedVodFavorite.set(false);
}
}
@@ -0,0 +1,138 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { TranslateModule } from '@ngx-translate/core';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { PlaylistsService } from 'services';
import { of } from 'rxjs';
import { FavoritesButtonComponent } from './stalker-favorites-button.component';
describe('FavoritesButtonComponent', () => {
let fixture: ComponentFixture<FavoritesButtonComponent>;
let selectedContentType: ReturnType<typeof signal<'vod' | 'series'>>;
let addToFavorites: jest.Mock;
beforeEach(async () => {
selectedContentType = signal<'vod' | 'series'>('series');
addToFavorites = jest.fn((_item: unknown, onDone?: () => void) => {
onDone?.();
});
await TestBed.configureTestingModule({
imports: [
FavoritesButtonComponent,
NoopAnimationsModule,
TranslateModule.forRoot(),
],
providers: [
{
provide: StalkerStore,
useValue: {
currentPlaylist: signal({ _id: 'stalker-1' }),
selectedContentType,
addToFavorites,
removeFromFavorites: jest.fn(),
},
},
{
provide: PlaylistsService,
useValue: {
getPortalFavorites: jest.fn(() => of([])),
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(FavoritesButtonComponent);
});
afterEach(() => {
fixture?.destroy();
});
it('preserves VOD-series favorite payload shape from the VOD series view', () => {
selectedContentType.set('vod');
fixture.componentRef.setInput('itemId', '1507');
fixture.componentRef.setInput('item', {
id: '1507',
cmd: '/media/file_1507.mpg',
category_id: '2001',
is_series: '1',
info: {
name: 'VOD Flagged Series',
movie_image: 'poster.jpg',
},
});
fixture.detectChanges();
fixture.componentInstance.addToFavorites();
expect(addToFavorites).toHaveBeenCalledWith(
expect.objectContaining({
id: '1507',
category_id: '2001',
is_series: true,
title: 'VOD Flagged Series',
cover: 'poster.jpg',
}),
expect.any(Function)
);
expect(addToFavorites.mock.calls[0][0]).not.toHaveProperty('series_id');
});
it('preserves embedded VOD series arrays in favorite payloads', () => {
selectedContentType.set('vod');
fixture.componentRef.setInput('itemId', '20001');
fixture.componentRef.setInput('item', {
id: '20001',
cmd: '/media/file_20001.mpg',
category_id: '2001',
series: [1, 2],
info: {
name: 'Embedded Series',
movie_image: 'embedded.jpg',
},
});
fixture.detectChanges();
fixture.componentInstance.addToFavorites();
expect(addToFavorites).toHaveBeenCalledWith(
expect.objectContaining({
id: '20001',
category_id: '2001',
series: [1, 2],
title: 'Embedded Series',
cover: 'embedded.jpg',
}),
expect.any(Function)
);
expect(addToFavorites.mock.calls[0][0]).not.toHaveProperty('series_id');
});
it('writes regular Stalker series favorites with series_id', () => {
selectedContentType.set('series');
fixture.componentRef.setInput('itemId', '30001');
fixture.componentRef.setInput('item', {
id: '30001',
cmd: '/media/file_30001.mpg',
info: {
name: 'Regular Series',
movie_image: 'series.jpg',
},
});
fixture.detectChanges();
fixture.componentInstance.addToFavorites();
expect(addToFavorites).toHaveBeenCalledWith(
expect.objectContaining({
series_id: '30001',
category_id: 'series',
title: 'Regular Series',
cover: 'series.jpg',
}),
expect.any(Function)
);
});
});
@@ -7,6 +7,7 @@ import { PlaylistsService } from 'services';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
matchesFavoriteById,
normalizeStalkerSeriesFlag,
StalkerSelectedVodItem,
} from '@iptvnator/portal/stalker/data-access';
@@ -52,13 +53,33 @@ export class FavoritesButtonComponent {
addToFavorites() {
const item = this.item();
const favoriteBase = {
...item,
title: item.info?.name ?? item.name ?? item.o_name,
cover: item.info?.movie_image ?? item.cover,
added_at: new Date().toISOString(),
};
if (this.stalkerStore.selectedContentType() === 'vod') {
this.stalkerStore.addToFavorites(
{
...favoriteBase,
id: item.id,
category_id: item.category_id ?? 'vod',
series: item.series,
is_series: normalizeStalkerSeriesFlag(item.is_series),
},
() => {
this.favoritesChanged$.next();
}
);
return;
}
this.stalkerStore.addToFavorites(
{
...item,
title: item.info?.name ?? item.name ?? item.o_name,
cover: item.info?.movie_image ?? item.cover,
...favoriteBase,
series_id: item.id,
added_at: new Date().toISOString(),
category_id: 'series',
},
() => {
@@ -0,0 +1,269 @@
import { Component, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { Location } from '@angular/common';
import { MatIcon } from '@angular/material/icon';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import {
ContentHeroComponent,
SeasonContainerComponent,
} from 'components';
import {
PORTAL_EXTERNAL_PLAYBACK,
PORTAL_PLAYBACK_POSITIONS,
PORTAL_PLAYER,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PortalInlinePlayerComponent } from '@iptvnator/ui/playback';
import { of } from 'rxjs';
import { SerialDetailsComponent } from './serial-details.component';
@Component({
selector: 'app-content-hero',
standalone: true,
template: '<ng-content />',
})
class StubContentHeroComponent {
readonly title = input<string | undefined>(undefined);
readonly description = input<string | undefined>(undefined);
readonly posterUrl = input<string | undefined>(undefined);
readonly backdropUrl = input<string | undefined>(undefined);
readonly isLoading = input(false);
readonly errorMessage = input<string | null>(null);
readonly backClicked = output<void>();
}
@Component({
selector: 'app-season-container',
standalone: true,
template: '<div data-testid="season-container"></div>',
})
class StubSeasonContainerComponent {
readonly seasons = input<unknown>(null);
readonly seriesId = input<number | string | null>(null);
readonly playlistId = input('');
readonly seriesTitle = input<string | undefined>(undefined);
readonly playbackPositions = input<unknown>(null);
readonly xtreamDownloadContext = input<unknown>(null);
readonly openingEpisodeId = input<number | null>(null);
readonly activeEpisodeId = input<number | null>(null);
readonly episodeClicked = output<unknown>();
readonly playbackToggleRequested = output<unknown>();
}
@Component({
selector: 'app-portal-inline-player',
standalone: true,
template: '',
})
class StubPortalInlinePlayerComponent {
readonly playback = input<unknown>(null);
readonly timeUpdate = output<unknown>();
readonly closed = output<void>();
readonly streamUrlCopied = output<void>();
}
@Component({
selector: 'mat-icon',
standalone: true,
template: '<ng-content />',
})
class StubMatIconComponent {}
describe('SerialDetailsComponent', () => {
let fixture: ComponentFixture<SerialDetailsComponent>;
const selectedItem = signal<unknown>(null);
const selectedContentType = signal<'series'>('series');
const isFavorite = signal(false);
const isLoadingDetails = signal(false);
const detailsError = signal<string | null>(null);
const currentPlaylist = signal({
id: 'xtream-1',
serverUrl: 'http://xtream.example',
username: 'user',
password: 'pass',
});
const fetchSerialDetailsWithMetadata = jest.fn();
const checkFavoriteStatus = jest.fn();
const getSeriesPlaybackPositions = jest.fn().mockResolvedValue([]);
beforeEach(async () => {
selectedItem.set({
series_id: 103,
info: {
name: 'Series One',
plot: 'Series plot',
cover: 'cover.jpg',
backdrop_path: [],
genre: 'Drama',
},
episodes: {
'1': [
{
id: '1001',
episode_num: 1,
title: 'Episode 1',
season: 1,
},
],
},
});
isFavorite.set(false);
isLoadingDetails.set(false);
detailsError.set(null);
fetchSerialDetailsWithMetadata.mockClear();
checkFavoriteStatus.mockClear();
getSeriesPlaybackPositions.mockClear();
await TestBed.configureTestingModule({
imports: [SerialDetailsComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
snapshot: {
params: {
categoryId: '3',
serialId: '103',
},
},
},
},
{
provide: XtreamStore,
useValue: {
selectedItem,
selectedContentType,
isFavorite,
isLoadingDetails,
detailsError,
currentPlaylist,
fetchSerialDetailsWithMetadata,
checkFavoriteStatus,
setSelectedItem: jest.fn((value: unknown) =>
selectedItem.set(value)
),
toggleFavorite: jest.fn(),
constructEpisodeStreamUrl: jest.fn(),
addRecentItem: jest.fn(),
backfillContentBackdrop: jest.fn(),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: {
activeSession: signal(null),
},
},
{
provide: PORTAL_PLAYBACK_POSITIONS,
useValue: {
getSeriesPlaybackPositions,
savePlaybackPosition: jest.fn(),
clearPlaybackPosition: jest.fn(),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
openResolvedPlayback: jest.fn(),
},
},
{
provide: MatSnackBar,
useValue: {
open: jest.fn(),
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
},
},
{
provide: Location,
useValue: {
back: jest.fn(),
},
},
],
})
.overrideComponent(SerialDetailsComponent, {
remove: {
imports: [
ContentHeroComponent,
MatIcon,
PortalInlinePlayerComponent,
SeasonContainerComponent,
TranslatePipe,
],
},
add: {
imports: [
StubContentHeroComponent,
StubMatIconComponent,
StubPortalInlinePlayerComponent,
StubSeasonContainerComponent,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(SerialDetailsComponent);
});
afterEach(() => {
fixture?.destroy();
});
it('initializes series metadata and renders the season container', async () => {
fixture.detectChanges();
await fixture.whenStable();
expect(fetchSerialDetailsWithMetadata).toHaveBeenCalledWith({
serialId: '103',
categoryId: 3,
});
expect(checkFavoriteStatus).toHaveBeenCalledWith(
103,
'xtream-1',
'series'
);
expect(getSeriesPlaybackPositions).toHaveBeenCalledWith(
'xtream-1',
103
);
const seasonContainer = fixture.debugElement.query(
By.directive(StubSeasonContainerComponent)
)?.componentInstance as StubSeasonContainerComponent | undefined;
expect(seasonContainer).toBeDefined();
expect(seasonContainer?.seriesId()).toBe(103);
expect(seasonContainer?.playlistId()).toBe('xtream-1');
expect(seasonContainer?.seasons()).toEqual({
'1': [
{
id: '1001',
episode_num: 1,
title: 'Episode 1',
season: 1,
},
],
});
});
});
@@ -0,0 +1,142 @@
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute } from '@angular/router';
import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util';
import {
XtreamPlaylistData,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { PlaylistsService } from 'services';
import { Playlist } from 'shared-interfaces';
import { of } from 'rxjs';
import { SerialDetailsComponent } from './serial-details/serial-details.component';
import { XtreamCollectionDetailComponent } from './xtream-collection-detail.component';
describe('XtreamCollectionDetailComponent', () => {
let fixture: ComponentFixture<XtreamCollectionDetailComponent>;
let playlistId: ReturnType<typeof signal<string>>;
let currentPlaylist: ReturnType<
typeof signal<XtreamPlaylistData | null>
>;
let selectedContentType: ReturnType<
typeof signal<'live' | 'vod' | 'series'>
>;
let selectedCategoryId: ReturnType<typeof signal<number | null>>;
let selectedItem: ReturnType<typeof signal<unknown>>;
let isLoadingDetails: ReturnType<typeof signal<boolean>>;
let detailsError: ReturnType<typeof signal<string | null>>;
beforeEach(async () => {
playlistId = signal('');
currentPlaylist = signal<XtreamPlaylistData | null>(null);
selectedContentType = signal<'live' | 'vod' | 'series'>('vod');
selectedCategoryId = signal<number | null>(null);
selectedItem = signal<unknown>(null);
isLoadingDetails = signal(false);
detailsError = signal<string | null>(null);
await TestBed.configureTestingModule({
imports: [XtreamCollectionDetailComponent],
providers: [
{
provide: XtreamStore,
useValue: {
playlistId,
currentPlaylist,
selectedContentType,
selectedCategoryId,
selectedItem,
isLoadingDetails,
detailsError,
setPlaylistId: jest.fn((value: string) =>
playlistId.set(value)
),
setCurrentPlaylist: jest.fn(
(value: XtreamPlaylistData | null) =>
currentPlaylist.set(value)
),
setSelectedContentType: jest.fn(
(value: 'live' | 'vod' | 'series') =>
selectedContentType.set(value)
),
setSelectedCategory: jest.fn(
(value: number | null) =>
selectedCategoryId.set(value)
),
setSelectedItem: jest.fn((value: unknown) =>
selectedItem.set(value)
),
setIsLoadingDetails: jest.fn((value: boolean) =>
isLoadingDetails.set(value)
),
setDetailsError: jest.fn((value: string | null) =>
detailsError.set(value)
),
},
},
{
provide: PlaylistsService,
useValue: {
getPlaylistById: jest.fn(() =>
of({
_id: 'xtream-1',
title: 'Xtream Portal',
serverUrl: 'http://xtream.example',
username: 'user',
password: 'pass',
} as Playlist)
),
},
},
],
})
.overrideComponent(XtreamCollectionDetailComponent, {
set: {
template: '',
},
})
.compileComponents();
fixture = TestBed.createComponent(XtreamCollectionDetailComponent);
});
afterEach(() => {
fixture?.destroy();
});
it('opens Xtream series favorites with the serial detail route context', async () => {
fixture.componentRef.setInput(
'item',
{
uid: 'xtream::xtream-1::series:103',
name: 'Series One',
contentType: 'series',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream Portal',
xtreamId: 103,
categoryId: 3,
} satisfies UnifiedCollectionItem
);
fixture.detectChanges();
await fixture.whenStable();
await Promise.resolve();
fixture.detectChanges();
expect(selectedContentType()).toBe('series');
expect(selectedCategoryId()).toBe(3);
expect(fixture.componentInstance.detailComponent()).toBe(
SerialDetailsComponent
);
expect(
fixture.componentInstance
.detailInjector()
?.get(ActivatedRoute)
.snapshot.params
).toEqual({
categoryId: '3',
serialId: '103',
});
});
});
@@ -21,6 +21,8 @@ export interface StalkerPortalItem {
category_id?: string | number;
stream_type?: string;
is_series?: boolean | number | string;
/** Embedded VOD-series episode numbers preserved for VOD favorites. */
series?: unknown[];
added_at?: string | number;
cmd?: string;
}