mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
test(xtream): pin the series page's forced-launch bookkeeping
Cover the three behaviours of PR #1792's forced MPV/VLC launch that had no spec: a regular start made while a forced launch is pending is queued and starts once the launch settled; the launch-position marker is skipped when the page token changed before the launch settled, or while the position saved; and the menu reports a failed launch only to the visit that asked for it, while `startPending` holds the watched actions and the external row. Each test fails when its branch is removed. The playback spec releases any launch a failed test left open, since the launch chain is module state. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
2738bc28a1
commit
bd7f46eacc
3 files changed
+326
-17
No files matched your search
@@ -440,5 +440,7 @@ The series page keeps its launch state at module level in
|
||||
|
||||
Regression coverage: `serial-details-external-launch.spec.ts` (chain,
|
||||
duplicate guard, queued choice), `serial-details-playback.service.spec.ts`
|
||||
(page token) and, for the external-player and reset rows,
|
||||
(page token, the queued start, the launch-position marker),
|
||||
`serial-details-menu.service.spec.ts` (launch-failure message, the rows held
|
||||
while a launch is pending) and, for the external-player and reset rows,
|
||||
`libs/ui/components/src/lib/detail-ui/series-hero.state.spec.ts`.
|
||||
+106
-5
@@ -7,7 +7,10 @@ import type { SeriesQuickStartAction } from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
|
||||
import { VideoPlayer } from '@iptvnator/shared/interfaces';
|
||||
import { SERIES_MENU_ACTION } from '@iptvnator/ui/components';
|
||||
import {
|
||||
SERIES_MENU_ACTION,
|
||||
type SeasonContainerComponent,
|
||||
} from '@iptvnator/ui/components';
|
||||
import { SerialDetailsMenuService } from './serial-details-menu.service';
|
||||
|
||||
function quickStart(
|
||||
@@ -25,14 +28,47 @@ function quickStart(
|
||||
} as SeriesQuickStartAction;
|
||||
}
|
||||
|
||||
/** A season container whose watched rows are shown and that holds none of them back itself. */
|
||||
function watchableContainer(): SeasonContainerComponent {
|
||||
return {
|
||||
seasonWatchBatchRunning: signal(false),
|
||||
watchPresenter: {
|
||||
seasonWatchToggleVisible: signal(true),
|
||||
selectedSeasonFullyWatched: signal(false),
|
||||
seasonWatchEligibleCount: signal(2),
|
||||
seriesMenuVisible: signal(true),
|
||||
seriesFullyWatched: signal(false),
|
||||
seriesWatchEligibleCount: signal(3),
|
||||
seriesCountKnown: signal(true),
|
||||
seriesActionDisabled: signal(false),
|
||||
},
|
||||
downloadPresenter: {
|
||||
presentationVisible: signal(false),
|
||||
eligibleEpisodeCount: signal(0),
|
||||
seasonDisabled: signal(true),
|
||||
batchRunning: signal(false),
|
||||
},
|
||||
} as unknown as SeasonContainerComponent;
|
||||
}
|
||||
|
||||
describe('SerialDetailsMenuService', () => {
|
||||
const quickStartSignal = signal<SeriesQuickStartAction | null>(null);
|
||||
const seasonContainer = signal<SeasonContainerComponent | undefined>(
|
||||
undefined
|
||||
);
|
||||
const startPending = signal(false);
|
||||
const openEpisodeExternally = jest.fn().mockResolvedValue(undefined);
|
||||
const openSnackBar = jest.fn();
|
||||
let pageToken: string;
|
||||
let service: SerialDetailsMenuService;
|
||||
|
||||
beforeEach(() => {
|
||||
quickStartSignal.set(quickStart());
|
||||
seasonContainer.set(undefined);
|
||||
startPending.set(false);
|
||||
pageToken = 'xtream-1:103#1.0';
|
||||
openEpisodeExternally.mockClear();
|
||||
openSnackBar.mockClear();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
SerialDetailsMenuService,
|
||||
@@ -56,7 +92,7 @@ describe('SerialDetailsMenuService', () => {
|
||||
useValue: { player: signal(VideoPlayer.MPV) },
|
||||
},
|
||||
{ provide: Router, useValue: { navigate: jest.fn() } },
|
||||
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
||||
{ provide: MatSnackBar, useValue: { open: openSnackBar } },
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: { instant: (key: string) => key },
|
||||
@@ -71,13 +107,13 @@ describe('SerialDetailsMenuService', () => {
|
||||
episodes: {},
|
||||
} as never),
|
||||
quickStart: quickStartSignal,
|
||||
seasonContainer: signal(undefined),
|
||||
seasonContainer,
|
||||
categoryId: signal(''),
|
||||
episodePositions: signal(new Map()),
|
||||
playbackActive: signal(false),
|
||||
startPending: signal(false),
|
||||
startPending,
|
||||
resetProgress: jest.fn(),
|
||||
pageToken: () => 'xtream-1:103#1.0',
|
||||
pageToken: () => pageToken,
|
||||
openEpisodeExternally,
|
||||
});
|
||||
});
|
||||
@@ -89,6 +125,13 @@ describe('SerialDetailsMenuService', () => {
|
||||
.map((item) => item.id);
|
||||
}
|
||||
|
||||
function row(id: string) {
|
||||
return service
|
||||
.sections()
|
||||
.flatMap((section) => section.items)
|
||||
.find((item) => item.id === id);
|
||||
}
|
||||
|
||||
it('offers the next episode to an external player and to the clipboard', () => {
|
||||
expect(rowIds()).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -109,4 +152,62 @@ describe('SerialDetailsMenuService', () => {
|
||||
await service.run(SERIES_MENU_ACTION.ExternalPlayer);
|
||||
expect(openEpisodeExternally).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
SERIES_MENU_ACTION.SeasonWatched,
|
||||
SERIES_MENU_ACTION.SeriesWatched,
|
||||
SERIES_MENU_ACTION.ExternalPlayer,
|
||||
])('holds the %s row while a forced launch has not settled', (id) => {
|
||||
seasonContainer.set(watchableContainer());
|
||||
expect(row(id)?.disabled).toBe(false);
|
||||
|
||||
// The launching episode has no session yet: a bulk watched action
|
||||
// would include it, and another launch would open beside it.
|
||||
startPending.set(true);
|
||||
expect(row(id)?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
describe('a failed external launch', () => {
|
||||
let warn: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
warn = jest
|
||||
.spyOn(console, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => warn.mockRestore());
|
||||
|
||||
it('is reported on the visit that asked for it', async () => {
|
||||
openEpisodeExternally.mockRejectedValueOnce(
|
||||
new Error('spawn failed')
|
||||
);
|
||||
|
||||
await service.run(SERIES_MENU_ACTION.ExternalPlayer);
|
||||
|
||||
expect(openEpisodeExternally).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: '1002' }),
|
||||
'mpv'
|
||||
);
|
||||
expect(openSnackBar).toHaveBeenCalledWith(
|
||||
'PORTALS.PLAYBACK_ERROR',
|
||||
undefined,
|
||||
{ duration: 3000 }
|
||||
);
|
||||
});
|
||||
|
||||
it('stays quiet once the page shows another visit', async () => {
|
||||
openEpisodeExternally.mockImplementationOnce(async () => {
|
||||
// The viewer left the series and came back while the
|
||||
// launch ran: same series, a new page token.
|
||||
pageToken = 'xtream-1:103#1.2';
|
||||
throw new Error('spawn failed');
|
||||
});
|
||||
|
||||
await service.run(SERIES_MENU_ACTION.ExternalPlayer);
|
||||
|
||||
expect(openEpisodeExternally).toHaveBeenCalledTimes(1);
|
||||
expect(openSnackBar).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+217
-11
@@ -9,6 +9,10 @@ import {
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
|
||||
import type {
|
||||
ExternalPlayerSession,
|
||||
XtreamSerieEpisode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
SerialDetailsPlaybackService,
|
||||
type XtreamSerieDetailsView,
|
||||
@@ -16,11 +20,44 @@ import {
|
||||
import { XTREAM_SERIES_RESUME_TARGET } from './serial-details-resume-target.token';
|
||||
import { SerialDetailsSeasonWatchService } from './serial-details-season-watch.service';
|
||||
|
||||
const series = {
|
||||
series_id: 103,
|
||||
info: {},
|
||||
episodes: {},
|
||||
} as unknown as XtreamSerieDetailsView;
|
||||
function episode(id: number): XtreamSerieEpisode {
|
||||
return {
|
||||
id: String(id),
|
||||
season: 1,
|
||||
episode_num: id - 1000,
|
||||
title: `Episode ${id - 1000}`,
|
||||
} as XtreamSerieEpisode;
|
||||
}
|
||||
|
||||
function seriesView(seriesId: number): XtreamSerieDetailsView {
|
||||
return {
|
||||
series_id: seriesId,
|
||||
info: {},
|
||||
episodes: { '1': [episode(1001), episode(1002)] },
|
||||
} as unknown as XtreamSerieDetailsView;
|
||||
}
|
||||
|
||||
const opened = {
|
||||
id: 'mpv-1',
|
||||
player: 'mpv',
|
||||
status: 'opened',
|
||||
} as ExternalPlayerSession;
|
||||
|
||||
/** The series on screen; the component feeds it from the store. */
|
||||
const selectedItem = signal<XtreamSerieDetailsView | null>(null);
|
||||
const isEmbeddedPlayer = jest.fn();
|
||||
const openResolvedPlayback = jest.fn();
|
||||
const openExternalPlayback = jest.fn();
|
||||
const savePlaybackPosition = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
selectedItem.set(seriesView(103));
|
||||
isEmbeddedPlayer.mockReturnValue(false);
|
||||
openResolvedPlayback.mockResolvedValue(undefined);
|
||||
openExternalPlayback.mockResolvedValue(undefined);
|
||||
savePlaybackPosition.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
/** A fresh page: the component provides the service, so each visit gets its own instance. */
|
||||
function createPage(): SerialDetailsPlaybackService {
|
||||
@@ -28,13 +65,30 @@ function createPage(): SerialDetailsPlaybackService {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
SerialDetailsPlaybackService,
|
||||
{ provide: ActivatedRoute, useValue: {} },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: { snapshot: { params: { serialId: '103' } } },
|
||||
},
|
||||
{
|
||||
provide: XtreamStore,
|
||||
useValue: { currentPlaylist: signal({ id: 'xtream-1' }) },
|
||||
useValue: {
|
||||
currentPlaylist: signal({ id: 'xtream-1' }),
|
||||
constructEpisodeStreamUrl: (item: XtreamSerieEpisode) =>
|
||||
`http://xtream.example/series/${item.id}.mp4`,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYBACK_POSITIONS,
|
||||
useValue: { savePlaybackPosition },
|
||||
},
|
||||
{
|
||||
provide: PORTAL_PLAYER,
|
||||
useValue: {
|
||||
isEmbeddedPlayer,
|
||||
openResolvedPlayback,
|
||||
openExternalPlayback,
|
||||
},
|
||||
},
|
||||
{ provide: PORTAL_PLAYBACK_POSITIONS, useValue: {} },
|
||||
{ provide: PORTAL_PLAYER, useValue: {} },
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: { activeSession: signal(null) },
|
||||
@@ -43,7 +97,7 @@ function createPage(): SerialDetailsPlaybackService {
|
||||
provide: PlaybackPositionRuntimeBridgeService,
|
||||
useValue: { onPlaybackPositionUpdate: () => undefined },
|
||||
},
|
||||
{ provide: PlaybackHistoryGate, useValue: {} },
|
||||
{ provide: PlaybackHistoryGate, useValue: { defer: jest.fn() } },
|
||||
{ provide: XTREAM_SERIES_RESUME_TARGET, useValue: signal(null) },
|
||||
{
|
||||
provide: SerialDetailsSeasonWatchService,
|
||||
@@ -52,10 +106,37 @@ function createPage(): SerialDetailsPlaybackService {
|
||||
],
|
||||
});
|
||||
const service = TestBed.inject(SerialDetailsPlaybackService);
|
||||
service.bind({ selectedItem: signal(series) });
|
||||
service.bind({ selectedItem });
|
||||
return service;
|
||||
}
|
||||
|
||||
/** Lets the launch chain and the launch bookkeeping run their microtasks. */
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve));
|
||||
|
||||
/** Launches a test may have left open; see the `afterEach`. */
|
||||
const heldLaunches: Array<() => void> = [];
|
||||
|
||||
/** Keeps the next launch through `open` unsettled until the returned function is called. */
|
||||
function holdLaunch(
|
||||
open: jest.Mock
|
||||
): (session?: ExternalPlayerSession) => void {
|
||||
let settle!: (session?: ExternalPlayerSession) => void;
|
||||
open.mockReturnValueOnce(
|
||||
new Promise<ExternalPlayerSession | void>(
|
||||
(resolve) => (settle = resolve)
|
||||
)
|
||||
);
|
||||
heldLaunches.push(() => settle());
|
||||
return settle;
|
||||
}
|
||||
|
||||
// The launch chain is module state: a launch a failed test left open would
|
||||
// keep the owner pending and hold up every later test's launch.
|
||||
afterEach(async () => {
|
||||
heldLaunches.splice(0).forEach((release) => release());
|
||||
await flush();
|
||||
});
|
||||
|
||||
describe('SerialDetailsPlaybackService page token', () => {
|
||||
it('gives a recreated page of the same series a token of its own', () => {
|
||||
const first = createPage();
|
||||
@@ -78,3 +159,128 @@ describe('SerialDetailsPlaybackService page token', () => {
|
||||
expect(page.pageToken()).not.toBe(token);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SerialDetailsPlaybackService start during a forced launch', () => {
|
||||
it('queues a regular start until the forced launch settled', async () => {
|
||||
isEmbeddedPlayer.mockReturnValue(true);
|
||||
const page = createPage();
|
||||
const settleLaunch = holdLaunch(openExternalPlayback);
|
||||
const forced = page.playEpisode(episode(1001), 'mpv');
|
||||
expect(page.forcedLaunchPending()).toBe(true);
|
||||
|
||||
// Play pressed while MPV still opens: the inline player must not
|
||||
// start beside it.
|
||||
page.playEpisode(episode(1002));
|
||||
await flush();
|
||||
expect(page.inlinePlayback()).toBeNull();
|
||||
|
||||
settleLaunch(opened);
|
||||
await forced;
|
||||
await flush();
|
||||
expect(page.forcedLaunchPending()).toBe(false);
|
||||
expect(page.inlinePlayback()?.contentInfo?.contentXtreamId).toBe(1002);
|
||||
});
|
||||
|
||||
it('sends a forced start down the launch chain, not into the queue', async () => {
|
||||
isEmbeddedPlayer.mockReturnValue(true);
|
||||
const page = createPage();
|
||||
const settleLaunch = holdLaunch(openExternalPlayback);
|
||||
const first = page.playEpisode(episode(1001), 'mpv');
|
||||
const second = page.playEpisode(episode(1002), 'vlc');
|
||||
await flush();
|
||||
expect(openExternalPlayback).toHaveBeenCalledTimes(1);
|
||||
|
||||
settleLaunch(opened);
|
||||
await first;
|
||||
await second;
|
||||
await flush();
|
||||
// A queued choice replays as a regular start: it would lose its
|
||||
// player and open inline.
|
||||
expect(openExternalPlayback).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
contentInfo: expect.objectContaining({ contentXtreamId: 1002 }),
|
||||
}),
|
||||
'vlc'
|
||||
);
|
||||
expect(page.inlinePlayback()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SerialDetailsPlaybackService launch position', () => {
|
||||
it('records the launch position once a forced launch settles on the same visit', async () => {
|
||||
const page = createPage();
|
||||
openExternalPlayback.mockResolvedValue(opened);
|
||||
|
||||
await page.playEpisode(episode(1002), 'mpv');
|
||||
await flush();
|
||||
|
||||
expect(savePlaybackPosition).toHaveBeenCalledWith(
|
||||
'xtream-1',
|
||||
expect.objectContaining({
|
||||
contentXtreamId: 1002,
|
||||
contentType: 'episode',
|
||||
seriesXtreamId: 103,
|
||||
positionSeconds: 0,
|
||||
})
|
||||
);
|
||||
expect(page.episodePlaybackPositions().get(1002)?.positionSeconds).toBe(
|
||||
0
|
||||
);
|
||||
});
|
||||
|
||||
it('skips the launch position once the page moved on to another series', async () => {
|
||||
const page = createPage();
|
||||
// The configured external player launches outside the forced
|
||||
// chain: nothing but the page token notices the move.
|
||||
const settleLaunch = holdLaunch(openResolvedPlayback);
|
||||
const launch = page.playEpisode(episode(1002));
|
||||
|
||||
selectedItem.set(seriesView(104));
|
||||
page.resetForNewSeries();
|
||||
settleLaunch(opened);
|
||||
await launch;
|
||||
await flush();
|
||||
|
||||
expect(savePlaybackPosition).not.toHaveBeenCalled();
|
||||
expect(page.episodePlaybackPositions().has(1002)).toBe(false);
|
||||
});
|
||||
|
||||
it('skips the launch position of a visit the viewer left and came back to', async () => {
|
||||
const page = createPage();
|
||||
const settleLaunch = holdLaunch(openExternalPlayback);
|
||||
const launch = page.playEpisode(episode(1002), 'mpv');
|
||||
await flush();
|
||||
|
||||
// Another series and back: the chain still launches for its owner,
|
||||
// only the visit differs.
|
||||
selectedItem.set(seriesView(104));
|
||||
page.resetForNewSeries();
|
||||
selectedItem.set(seriesView(103));
|
||||
page.resetForNewSeries();
|
||||
settleLaunch(opened);
|
||||
await expect(launch).resolves.toBe(opened);
|
||||
await flush();
|
||||
|
||||
expect(savePlaybackPosition).not.toHaveBeenCalled();
|
||||
expect(page.episodePlaybackPositions().has(1002)).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves the episode unmarked when the page moved on while the position saved', async () => {
|
||||
const page = createPage();
|
||||
openExternalPlayback.mockResolvedValue(opened);
|
||||
let settleSave: () => void = () => undefined;
|
||||
savePlaybackPosition.mockImplementation(
|
||||
() => new Promise<void>((resolve) => (settleSave = resolve))
|
||||
);
|
||||
|
||||
await page.playEpisode(episode(1002), 'mpv');
|
||||
await flush();
|
||||
expect(savePlaybackPosition).toHaveBeenCalledTimes(1);
|
||||
|
||||
page.resetForNewSeries();
|
||||
settleSave();
|
||||
await flush();
|
||||
|
||||
expect(page.episodePlaybackPositions().has(1002)).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user