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:
4grayandClaude Fable 5.1 committed 2026-10-13 08:15:01 +02:00
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`.
@@ -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();
});
});
});
@@ -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);
});
});