From bd7f46eacc1a300ab860c1e16cde3b2fa166f044 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 13 Oct 2026 08:15:01 +0200 Subject: [PATCH] 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 --- .../xtream-portal-compatibility.md | 4 +- .../serial-details-menu.service.spec.ts | 111 ++++++++- .../serial-details-playback.service.spec.ts | 228 +++++++++++++++++- 3 files changed, 326 insertions(+), 17 deletions(-) diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index bf3fb37da..4a227c72e 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -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`. diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.spec.ts index 6009e08b9..5ed6fd93b 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.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(null); + const seasonContainer = signal( + 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(); + }); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.spec.ts index 42fd27837..648f40743 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-playback.service.spec.ts @@ -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(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( + (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((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); + }); +});