mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
fix(stalker): series favorites handling
Entire-Checkpoint: c6e522b4276c
This commit is contained in:
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);
|
||||
}
|
||||
}
|
||||
+138
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
+25
-4
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user