From e19c24d5c6b618f8460be098dbc41d42e707a87f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=28=EA=B9=80=EA=B7=9C=ED=9A=8C=29?= <48755156+KimKyuHoi@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:56:06 +0900 Subject: [PATCH] test(ABR): add SimpleAbrManager integration test suite (#10126) Add integration tests for `SimpleAbrManager`. Uses the same `StreamGenerator` class already used in other integration tests to serve media segments from memory, avoiding unpredictable network conditions. Throughput is simulated deterministically by deriving each segment's download time from a target bitrate in NetworkingEngine's onDownloaded callback. Related #9918 , also adds integration tests for the dropped frame protection feature. Since real frame drops can't be reproduced in test environment, so used `getVidoePlaybackQuality` to override on the real video element to inject controlled drop ratios. --------- Co-authored-by: Claude Sonnet 4.6 --- project-words.txt | 1 + test/abr/simple_abr_manager_integration.js | 430 +++++++++++++++++++++ 2 files changed, 431 insertions(+) create mode 100644 test/abr/simple_abr_manager_integration.js diff --git a/project-words.txt b/project-words.txt index 940f76b75..46449a5b8 100644 --- a/project-words.txt +++ b/project-words.txt @@ -1,5 +1,6 @@ # events / html abrstatuschanged +abrtest haspopup menuitemradio adblocker diff --git a/test/abr/simple_abr_manager_integration.js b/test/abr/simple_abr_manager_integration.js new file mode 100644 index 000000000..3e54ababf --- /dev/null +++ b/test/abr/simple_abr_manager_integration.js @@ -0,0 +1,430 @@ +/*! @license + * Shaka Player + * Copyright 2016 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +describe('SimpleAbrManager (integration)', () => { + const ContentType = shaka.util.ManifestParserUtils.ContentType; + const Util = shaka.test.Util; + const TEST_SCHEME = 'abrtest'; + + const VARIANT_BANDWIDTHS = [200e3, 800e3, 2e6, 5e6]; + + /** @type {!HTMLVideoElement} */ + let video; + /** @type {!shaka.util.EventManager} */ + let eventManager; + /** @type {!shaka.test.Waiter} */ + let waiter; + + /** @type {!shaka.net.NetworkingEngine} */ + let netEngine; + /** @type {!shaka.media.MediaSourceEngine} */ + let mediaSourceEngine; + /** @type {!shaka.media.StreamingEngine} */ + let streamingEngine; + /** @type {!shaka.abr.SimpleAbrManager} */ + let abrManager; + /** @type {!shaka.media.MediaSourcePlayhead} */ + let playhead; + /** @type {shaka.extern.Manifest} */ + let manifest; + + /** @type {!Object} */ + let generators; + /** @type {number} */ + let currentTargetBps; + let metadata; + /** @type {function(string, number)} */ + let onDisableStream; + + beforeAll(async () => { + video = shaka.test.UiUtils.createVideoElement(); + document.body.appendChild(video); + + metadata = shaka.test.TestScheme.DATA['sintel']; + + generators = {}; + generators[ContentType.AUDIO] = new shaka.test.Mp4VodStreamGenerator( + metadata.audio.initSegmentUri, metadata.audio.mdhdOffset, + metadata.audio.segmentUri, metadata.audio.tfdtOffset, + metadata.audio.segmentDuration); + generators[ContentType.VIDEO] = new shaka.test.Mp4VodStreamGenerator( + metadata.video.initSegmentUri, metadata.video.mdhdOffset, + metadata.video.segmentUri, metadata.video.tfdtOffset, + metadata.video.segmentDuration); + await Promise.all([ + generators[ContentType.AUDIO].init(), + generators[ContentType.VIDEO].init(), + ]); + + shaka.net.NetworkingEngine.registerScheme(TEST_SCHEME, schemePlugin); + }); + + afterAll(() => { + shaka.net.NetworkingEngine.unregisterScheme(TEST_SCHEME); + document.body.removeChild(video); + }); + + beforeEach(() => { + currentTargetBps = 1e6; + onDisableStream = () => {}; + + eventManager = new shaka.util.EventManager(); + waiter = new shaka.test.Waiter(eventManager); + + abrManager = new shaka.abr.SimpleAbrManager(); + + netEngine = new shaka.net.NetworkingEngine( + (deltaTimeMs, bytes, allowSwitch, request, context) => { + // Ignore `deltaTimeMs`: background tabs clamp setTimeout to >=1s, + // so derive the time from the target throughput instead. + const simulatedMs = (bytes * 8 * 1000) / currentTargetBps; + abrManager.segmentDownloaded( + simulatedMs, bytes, allowSwitch, request, context); + }); + netEngine.configure( + shaka.util.PlayerConfiguration.createDefault().networking); + netEngine.registerResponseFilter(throughputSimulator); + + const mediaSourceConfig = + shaka.util.PlayerConfiguration.createDefault().mediaSource; + mediaSourceEngine = new shaka.media.MediaSourceEngine( + video, + new shaka.test.FakeTextDisplayer(), + { + getKeySystem: () => null, + onMetadata: () => {}, + onEmsg: () => {}, + onEvent: () => {}, + onManifestUpdate: () => {}, + }, + mediaSourceConfig); + waiter.setMediaSourceEngine(mediaSourceEngine); + }); + + afterEach(async () => { + eventManager.release(); + if (streamingEngine) { + await streamingEngine.destroy(); + } + if (mediaSourceEngine) { + await mediaSourceEngine.destroy(); + } + if (playhead) { + playhead.release(); + } + if (abrManager) { + abrManager.stop(); + abrManager.release(); + } + if (netEngine) { + await netEngine.destroy(); + } + }); + + /** + * Scheme plugin that serves segments from in-memory generators. + * URIs: + * abrtest:audio/init abrtest:video/init + * abrtest:audio/ abrtest:video/ + * + * @param {string} uri + * @param {shaka.extern.Request} request + * @param {shaka.net.NetworkingEngine.RequestType=} requestType + * @return {!shaka.extern.IAbortableOperation} + */ + function schemePlugin(uri, request, requestType) { + const re = /^abrtest:(audio|video)\/(init|\d+)$/; + const match = re.exec(uri); + if (!match) { + return shaka.util.AbortableOperation.failed(new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.NETWORK, + shaka.util.Error.Code.MALFORMED_TEST_URI)); + } + + const type = match[1]; + const ident = match[2]; + const data = ident === 'init' ? + generators[type].getInitSegment(0) : + generators[type].getSegment(Number(ident), 0); + if (!data) { + return shaka.util.AbortableOperation.failed(new shaka.util.Error( + shaka.util.Error.Severity.CRITICAL, + shaka.util.Error.Category.NETWORK, + shaka.util.Error.Code.MALFORMED_TEST_URI)); + } + + /** @type {shaka.extern.Response} */ + const response = { + uri, + originalUri: uri, + data, + headers: {}, + originalRequest: request, + }; + return shaka.util.AbortableOperation.completed(response); + } + + /** + * Response filter that sleeps so the apparent throughput matches + * `currentTargetBps`. + * + * @param {shaka.net.NetworkingEngine.RequestType} type + * @param {shaka.extern.Response} response + * @param {shaka.extern.RequestContext=} context + * @return {!Promise} + */ + async function throughputSimulator(type, response, context) { + if (!response.data || !response.data.byteLength) { + return; + } + const transferMs = + (response.data.byteLength * 8 * 1000) / currentTargetBps; + await new Promise((resolve) => setTimeout(resolve, transferMs)); + } + + /** + * Builds a multi-bitrate manifest where every variant points to the same + * in-memory media but advertises a different `bandwidth` so the ABR can + * differentiate them. + * + * @param {number} duration Presentation duration in seconds. + * @return {shaka.extern.Manifest} + */ + function createMultiBitrateManifest(duration) { + return shaka.test.ManifestGenerator.generate((m) => { + m.presentationTimeline.setDuration(duration); + m.sequenceMode = false; + + let nextStreamId = 100; + VARIANT_BANDWIDTHS.forEach((bw, i) => { + m.addVariant(i, (variant) => { + variant.bandwidth = bw; + variant.addVideo(nextStreamId++, (s) => { + s.bandwidth = bw - 64e3; + s.mimeType = metadata.video.mimeType; + s.codecs = metadata.video.codecs; + s.size(640, 360); + s.setInitSegmentReference(['abrtest:video/init'], 0, null); + s.useSegmentTemplate( + 'abrtest:video/%d', metadata.video.segmentDuration); + }); + variant.addAudio(nextStreamId++, (s) => { + s.bandwidth = 64e3; + s.mimeType = metadata.audio.mimeType; + s.codecs = metadata.audio.codecs; + s.setInitSegmentReference(['abrtest:audio/init'], 0, null); + s.useSegmentTemplate( + 'abrtest:audio/%d', metadata.audio.segmentDuration); + }); + }); + }); + }); + } + + /** + * Wires up StreamingEngine + SimpleAbrManager + Playhead for VOD playback. + * + * @param {number} defaultBandwidthEstimate + * @return {!Promise} + */ + /** + * @param {number} defaultBandwidthEstimate + */ + async function setupPlayback(defaultBandwidthEstimate) { + const presentationDuration = 60; + manifest = createMultiBitrateManifest(presentationDuration); + + const streamingConfig = + shaka.util.PlayerConfiguration.createDefault().streaming; + streamingConfig.stallEnabled = false; + + const abrConfig = shaka.util.PlayerConfiguration.createDefault().abr; + abrConfig.defaultBandwidthEstimate = defaultBandwidthEstimate; + abrConfig.useNetworkInformation = false; + abrConfig.minTimeToSwitch = 0; + abrConfig.switchInterval = 1; + abrConfig.advanced.fastHalfLife = 1; + abrConfig.advanced.slowHalfLife = 2; + + abrManager.init( + (variant, clearBuffer, safeMargin) => { + streamingEngine.switchVariant( + variant, clearBuffer || false, safeMargin || 0); + }, + (type, banDuration) => onDisableStream(type, banDuration)); + abrManager.configure(abrConfig); + abrManager.setVariants(manifest.variants, false); + + const initialVariant = abrManager.chooseVariant(); + + playhead = new shaka.media.MediaSourcePlayhead( + video, manifest, streamingConfig, + /* startTime= */ null, + () => streamingEngine.seeked(), + () => {}); + + const onError = jasmine.createSpy('onError').and.callFake(fail); + streamingEngine = new shaka.media.StreamingEngine(manifest, { + getPresentationTime: () => playhead.getTime(), + getBandwidthEstimate: () => abrManager.getBandwidthEstimate(), + getPlaybackRate: () => video.playbackRate, + video, + mediaSourceEngine, + netEngine, + onError: Util.spyFunc(onError), + onEvent: () => {}, + onSegmentAppended: () => playhead.notifyOfBufferingChange(), + onInitSegmentAppended: () => {}, + beforeAppendSegment: () => Promise.resolve(), + disableStream: () => false, + shouldPrefetchNextSegment: () => true, + getKeySystem: () => '', + }); + streamingEngine.configure(streamingConfig); + + streamingEngine.switchVariant(initialVariant); + await streamingEngine.start(); + abrManager.setMediaElement(video); + abrManager.enable(); + } + + it('settles on the lowest variant when throughput is low', async () => { + currentTargetBps = 400e3; + await setupPlayback(/* defaultBandwidthEstimate= */ 400e3); + + await video.play(); + await waiter.timeoutAfter(20).waitForMovement(video); + await Util.delay(8); + + const chosen = abrManager.chooseVariant(); + expect(chosen.bandwidth).toBe(VARIANT_BANDWIDTHS[0]); + }); + + it('settles on a high-bandwidth variant when throughput is high', + async () => { + currentTargetBps = 5.5e6; + await setupPlayback(/* defaultBandwidthEstimate= */ 5.5e6); + + await video.play(); + await waiter.timeoutAfter(20).waitForMovement(video); + await Util.delay(8); + + const chosen = abrManager.chooseVariant(); + expect(chosen.bandwidth) + .toBeGreaterThanOrEqual(VARIANT_BANDWIDTHS[2]); + }); + + it('down-switches when throughput drops', async () => { + currentTargetBps = 5.5e6; + await setupPlayback(/* defaultBandwidthEstimate= */ 5.5e6); + + await video.play(); + await waiter.timeoutAfter(20).waitForMovement(video); + await Util.delay(3); + const initialBandwidth = abrManager.chooseVariant().bandwidth; + expect(initialBandwidth).toBeGreaterThanOrEqual(VARIANT_BANDWIDTHS[2]); + + currentTargetBps = 250e3; + + await new Promise((resolve) => { + const deadline = Date.now() + 30000; + const interval = setInterval(() => { + if (abrManager.chooseVariant().bandwidth < initialBandwidth || + Date.now() >= deadline) { + clearInterval(interval); + resolve(); + } + }, 1000); + }); + + expect(abrManager.chooseVariant().bandwidth).toBeLessThan(initialBandwidth); + }); + + describe('dropped frame protection', () => { + /** + * @param {number} dropped + * @param {number} total + * @return {!VideoPlaybackQuality} + */ + function makeQuality(dropped, total) { + return /** @type {!VideoPlaybackQuality} */ ({ + droppedVideoFrames: dropped, + totalVideoFrames: total, + corruptedVideoFrames: 0, + creationTime: 0, + totalFrameDelay: 0, + }); + } + + /** + * @param {!jasmine.Spy} disableStreamSpy + * @return {!Promise} + */ + async function setupDroppedFramesPlayback(disableStreamSpy) { + onDisableStream = (type, banDuration) => { + Util.spyFunc(disableStreamSpy)(type, banDuration); + }; + currentTargetBps = 5.5e6; + await setupPlayback(/* defaultBandwidthEstimate= */ 5.5e6); + + // Override before configure so real browser frame counters don't + // contaminate the baseline. + video.getVideoPlaybackQuality = () => makeQuality(0, 0); + + const droppedFramesConfig = + shaka.util.PlayerConfiguration.createDefault().abr; + droppedFramesConfig.droppedFrames = true; + droppedFramesConfig.advanced.droppedFramesThreshold = 0.15; + droppedFramesConfig.advanced.droppedFramesInterval = 0.5; + droppedFramesConfig.advanced.droppedFramesBanDuration = 30; + abrManager.configure(droppedFramesConfig); + + await video.play(); + await waiter.timeoutAfter(20).waitForMovement(video); + } + + it('calls disableStreamCallback via timer when drop ratio exceeds' + + ' threshold', async () => { + const disableStreamSpy = jasmine.createSpy('disableStreamCallback'); + await setupDroppedFramesPlayback(disableStreamSpy); + + let droppedFrames = 0; + let totalFrames = 100; + video.getVideoPlaybackQuality = () => makeQuality(droppedFrames, + totalFrames); + + await Util.delay(0.6); // Establish baseline. + + // 20/100 new frames dropped = 20% > 15% threshold. + droppedFrames = 20; + totalFrames = 200; + await Util.delay(0.6); + + expect(disableStreamSpy).toHaveBeenCalledWith('video', 30); + }); + + it('does not call disableStreamCallback when drop ratio is below' + + ' threshold', async () => { + const disableStreamSpy = jasmine.createSpy('disableStreamCallback'); + await setupDroppedFramesPlayback(disableStreamSpy); + + let droppedFrames = 0; + let totalFrames = 100; + video.getVideoPlaybackQuality = () => makeQuality(droppedFrames, + totalFrames); + + await Util.delay(0.6); // Establish baseline. + + // 10/100 new frames dropped = 10% < 15% threshold. + droppedFrames = 10; + totalFrames = 200; + await Util.delay(0.6); + + expect(disableStreamSpy).not.toHaveBeenCalled(); + }); + }); +});