diff --git a/test/unit/mocks/MediaPlayerModelMock.js b/test/unit/mocks/MediaPlayerModelMock.js index 4270f75522..dc142bbe48 100644 --- a/test/unit/mocks/MediaPlayerModelMock.js +++ b/test/unit/mocks/MediaPlayerModelMock.js @@ -141,6 +141,14 @@ class MediaPlayerModelMock { return { min: -0.5, max: 0.5 }; } + getCatchupModeEnabled() { + return false; + } + + getCatchupMaxDrift() { + return 10; + } + getBufferTimeDefault() { return this.bufferTimeDefault > -1 ? this.bufferTimeDefault : this.fastSwitchEnabled ? DEFAULT_MIN_BUFFER_TIME_FAST_SWITCH : DEFAULT_MIN_BUFFER_TIME; } diff --git a/test/unit/mocks/PlaybackControllerMock.js b/test/unit/mocks/PlaybackControllerMock.js index d66793d881..7c20afbd56 100644 --- a/test/unit/mocks/PlaybackControllerMock.js +++ b/test/unit/mocks/PlaybackControllerMock.js @@ -137,6 +137,22 @@ class PlaybackControllerMock { return 15; } + seekToCurrentLive() { + // Mock implementation + } + + getBufferLevel() { + return 5; + } + + getPlaybackStalled() { + return false; + } + + getStreamEndTime() { + return 100; + } + } diff --git a/test/unit/mocks/StreamControllerMock.js b/test/unit/mocks/StreamControllerMock.js index 2b1844e8c0..f5d8304e51 100644 --- a/test/unit/mocks/StreamControllerMock.js +++ b/test/unit/mocks/StreamControllerMock.js @@ -95,6 +95,18 @@ class StreamControllerMock { return true; } + getIsStreamSwitchInProgress() { + return false; + } + + getHasMediaOrInitialisationError() { + return false; + } + + getStreamForTime() { + return null; + } + } export default StreamControllerMock; diff --git a/test/unit/mocks/ThroughputControllerMock.js b/test/unit/mocks/ThroughputControllerMock.js index 82f0962d09..8122d61e3d 100644 --- a/test/unit/mocks/ThroughputControllerMock.js +++ b/test/unit/mocks/ThroughputControllerMock.js @@ -16,6 +16,25 @@ class ThroughputControllerMock { return this.averageThroughput; } + getArithmeticMean(values) { + if (!values || values.length === 0) { + return 0; + } + + let sum = 0; + values.forEach((entry) => { + if (entry && typeof entry.value === 'number') { + sum += entry.value; + } + }); + + if (sum === 0) { + return 0; + } + + return sum / values.length; + } + setConfig() { } diff --git a/test/unit/test/dash/dash.controllers.ContentSteeringController.js b/test/unit/test/dash/dash.controllers.ContentSteeringController.js new file mode 100644 index 0000000000..473cc63c0a --- /dev/null +++ b/test/unit/test/dash/dash.controllers.ContentSteeringController.js @@ -0,0 +1,701 @@ +import ContentSteeringController from '../../../../src/dash/controllers/ContentSteeringController.js'; +import EventBus from '../../../../src/core/EventBus.js'; +import MediaPlayerEvents from '../../../../src/streaming/MediaPlayerEvents.js'; +import DashConstants from '../../../../src/dash/constants/DashConstants.js'; +import SchemeLoaderFactory from '../../../../src/streaming/net/SchemeLoaderFactory.js'; + +import AdapterMock from '../../mocks/AdapterMock.js'; +import DashMetricsMock from '../../mocks/DashMetricsMock.js'; +import ErrorHandlerMock from '../../mocks/ErrorHandlerMock.js'; +import MediaPlayerModelMock from '../../mocks/MediaPlayerModelMock.js'; +import ManifestModelMock from '../../mocks/ManifestModelMock.js'; +import ServiceDescriptionControllerMock from '../../mocks/ServiceDescriptionControllerMock.js'; +import ThroughputControllerMock from '../../mocks/ThroughputControllerMock.js'; + +import chai from 'chai'; +import sinon from 'sinon'; + +const expect = chai.expect; + +const context = {}; +const eventBus = EventBus(context).getInstance(); + +describe('ContentSteeringController', function () { + let contentSteeringController; + let adapterMock; + let dashMetricsMock; + let errorHandlerMock; + let mediaPlayerModelMock; + let manifestModelMock; + let serviceDescriptionControllerMock; + let throughputControllerMock; + + beforeEach(function () { + adapterMock = new AdapterMock(); + dashMetricsMock = new DashMetricsMock(); + errorHandlerMock = new ErrorHandlerMock(); + mediaPlayerModelMock = new MediaPlayerModelMock(); + manifestModelMock = new ManifestModelMock(); + serviceDescriptionControllerMock = new ServiceDescriptionControllerMock(); + throughputControllerMock = new ThroughputControllerMock(); + + contentSteeringController = ContentSteeringController(context).getInstance(); + contentSteeringController.setConfig({ + adapter: adapterMock, + errHandler: errorHandlerMock, + dashMetrics: dashMetricsMock, + mediaPlayerModel: mediaPlayerModelMock, + manifestModel: manifestModelMock, + serviceDescriptionController: serviceDescriptionControllerMock, + throughputController: throughputControllerMock, + eventBus: eventBus + }); + }); + + afterEach(function () { + contentSteeringController.reset(); + contentSteeringController = null; + }); + + describe('Method initialize', function () { + it('should initialize the controller', function () { + contentSteeringController.initialize(); + }); + }); + + describe('Method setConfig', function () { + it('should update config with provided settings', function () { + const newAdapter = new AdapterMock(); + contentSteeringController.setConfig({ + adapter: newAdapter + }); + }); + + it('should handle null config', function () { + contentSteeringController.setConfig(null); + }); + + it('should handle empty config', function () { + contentSteeringController.setConfig({}); + }); + }); + + describe('Method reset', function () { + it('should reset the controller', function () { + contentSteeringController.initialize(); + contentSteeringController.reset(); + }); + }); + + describe('Method getSteeringDataFromManifest', function () { + it('should return steering data from adapter', function () { + const steeringData = { + serverUrl: 'https://steering.example.com', + queryBeforeStart: true + }; + manifestModelMock.getValue = sinon.stub().returns({}); + adapterMock.getContentSteering = sinon.stub().returns(steeringData); + + const result = contentSteeringController.getSteeringDataFromManifest(); + + expect(result).to.deep.equal(steeringData); + }); + + it('should return steering data from service description when adapter returns null', function () { + const steeringData = { + serverUrl: 'https://steering.example.com', + queryBeforeStart: false + }; + manifestModelMock.getValue = sinon.stub().returns({}); + adapterMock.getContentSteering = sinon.stub().returns(null); + serviceDescriptionControllerMock.getServiceDescriptionSettings = sinon.stub().returns({ + contentSteering: steeringData + }); + + const result = contentSteeringController.getSteeringDataFromManifest(); + + expect(result).to.deep.equal(steeringData); + }); + }); + + describe('Method shouldQueryBeforeStart', function () { + it('should return true when queryBeforeStart is true', function () { + const steeringData = { + serverUrl: 'https://steering.example.com', + queryBeforeStart: true + }; + manifestModelMock.getValue = sinon.stub().returns({}); + adapterMock.getContentSteering = sinon.stub().returns(steeringData); + + const result = contentSteeringController.shouldQueryBeforeStart(); + + expect(result).to.be.true; + }); + + it('should return false when queryBeforeStart is false', function () { + const steeringData = { + serverUrl: 'https://steering.example.com', + queryBeforeStart: false + }; + manifestModelMock.getValue = sinon.stub().returns({}); + adapterMock.getContentSteering = sinon.stub().returns(steeringData); + + const result = contentSteeringController.shouldQueryBeforeStart(); + + expect(result).to.be.false; + }); + + it('should return false when no steering data exists', function () { + manifestModelMock.getValue = sinon.stub().returns({}); + adapterMock.getContentSteering = sinon.stub().returns(null); + serviceDescriptionControllerMock.getServiceDescriptionSettings = sinon.stub().returns({}); + + const result = contentSteeringController.shouldQueryBeforeStart(); + + expect(result).to.be.false; + }); + }); + + describe('Method getCurrentSteeringResponseData', function () { + it('should return null initially', function () { + const result = contentSteeringController.getCurrentSteeringResponseData(); + + expect(result).to.be.null; + }); + }); + + describe('Method stopSteeringRequestTimer', function () { + it('should stop the steering request timer', function () { + contentSteeringController.stopSteeringRequestTimer(); + }); + }); + + describe('Method getSynthesizedBaseUrlElements', function () { + it('should return empty array when no reference elements', function () { + const result = contentSteeringController.getSynthesizedBaseUrlElements([]); + + expect(result).to.be.an('array').that.is.empty; + }); + + it('should return empty array when no steering response data', function () { + const referenceElements = [ + { url: 'https://example.com/path', serviceLocation: 'cdn1' } + ]; + + const result = contentSteeringController.getSynthesizedBaseUrlElements(referenceElements); + + expect(result).to.be.an('array').that.is.empty; + }); + + it('should return synthesized elements when steering response data is present', async function () { + const referenceElements = [ + { + url: 'https://example.com/path', + serviceLocation: 'cdn1', + dvbPriority: 1, + dvbWeight: 2, + availabilityTimeOffset: 0, + availabilityTimeComplete: true + } + ]; + + const mockLoaderInstance = { + load: ({ success, complete }) => { + const responseData = {}; + responseData[DashConstants.CONTENT_STEERING_RESPONSE.VERSION] = '1'; + responseData[DashConstants.CONTENT_STEERING_RESPONSE.PATHWAY_CLONES] = [ + { + [DashConstants.CONTENT_STEERING_RESPONSE.BASE_ID]: 'cdn1', + [DashConstants.CONTENT_STEERING_RESPONSE.ID]: 'clone1', + [DashConstants.CONTENT_STEERING_RESPONSE.URI_REPLACEMENT]: { + [DashConstants.CONTENT_STEERING_RESPONSE.HOST]: 'clone.example.com', + [DashConstants.CONTENT_STEERING_RESPONSE.PARAMS]: { foo: 'bar' } + } + } + ]; + + success(responseData); + if (typeof complete === 'function') { + complete(); + } + }, + abort: () => {}, + reset: () => {}, + resetInitialSettings: () => {} + }; + + function MockLoader() { + return { + create: () => mockLoaderInstance + }; + } + + const schemeLoaderFactory = SchemeLoaderFactory(context).getInstance(); + + schemeLoaderFactory.registerLoader('https://', MockLoader); + + manifestModelMock.getValue = sinon.stub().returns({}); + adapterMock.getContentSteering = sinon.stub().returns({ + serverUrl: 'https://steering.example.com', + queryBeforeStart: true + }); + + contentSteeringController.initialize(); + + try { + await contentSteeringController.loadSteeringData(); + const result = contentSteeringController.getSynthesizedBaseUrlElements(referenceElements); + + expect(result).to.be.an('array').that.is.not.empty; + const synthesized = result[0]; + + expect(synthesized.url).to.equal('https://clone.example.com/path'); + expect(synthesized.serviceLocation).to.equal('clone1'); + expect(synthesized.queryParams).to.deep.equal({ foo: 'bar' }); + expect(synthesized.dvbPriority).to.equal(1); + expect(synthesized.dvbWeight).to.equal(2); + expect(synthesized.availabilityTimeOffset).to.equal(0); + expect(synthesized.availabilityTimeComplete).to.be.true; + } finally { + schemeLoaderFactory.unregisterLoader('https://'); + } + }); + }); + + describe('Method getSynthesizedLocationElements', function () { + it('should return empty array when no reference elements', function () { + const result = contentSteeringController.getSynthesizedLocationElements([]); + + expect(result).to.be.an('array').that.is.empty; + }); + + it('should return empty array when no steering response data', function () { + const referenceElements = [ + { url: 'https://example.com/manifest.mpd', serviceLocation: 'cdn1' } + ]; + + const result = contentSteeringController.getSynthesizedLocationElements(referenceElements); + + expect(result).to.be.an('array').that.is.empty; + }); + + it('should return synthesized location elements when steering response data is present', async function () { + const referenceElements = [ + { + url: 'https://example.com/manifest.mpd', + serviceLocation: 'cdn1' + } + ]; + + const mockLoaderInstance = { + load: ({ success, complete }) => { + const responseData = {}; + responseData[DashConstants.CONTENT_STEERING_RESPONSE.VERSION] = '1'; + responseData[DashConstants.CONTENT_STEERING_RESPONSE.PATHWAY_CLONES] = [ + { + [DashConstants.CONTENT_STEERING_RESPONSE.BASE_ID]: 'cdn1', + [DashConstants.CONTENT_STEERING_RESPONSE.ID]: 'clone1', + [DashConstants.CONTENT_STEERING_RESPONSE.URI_REPLACEMENT]: { + [DashConstants.CONTENT_STEERING_RESPONSE.HOST]: 'clone.example.com', + [DashConstants.CONTENT_STEERING_RESPONSE.PARAMS]: { foo: 'bar' } + } + } + ]; + + success(responseData); + if (typeof complete === 'function') { + complete(); + } + }, + abort: () => {}, + reset: () => {}, + resetInitialSettings: () => {} + }; + + function MockLoader() { + return { + create: () => mockLoaderInstance + }; + } + + const schemeLoaderFactory = SchemeLoaderFactory(context).getInstance(); + + schemeLoaderFactory.registerLoader('https://', MockLoader); + + manifestModelMock.getValue = sinon.stub().returns({}); + adapterMock.getContentSteering = sinon.stub().returns({ + serverUrl: 'https://steering.example.com', + queryBeforeStart: true + }); + + contentSteeringController.initialize(); + + try { + await contentSteeringController.loadSteeringData(); + const result = contentSteeringController.getSynthesizedLocationElements(referenceElements); + + expect(result).to.be.an('array').that.is.not.empty; + const synthesized = result[0]; + + expect(synthesized.url).to.equal('https://clone.example.com/manifest.mpd'); + expect(synthesized.serviceLocation).to.equal('clone1'); + expect(synthesized.queryParams).to.deep.equal({ foo: 'bar' }); + } finally { + schemeLoaderFactory.unregisterLoader('https://'); + } + }); + }); + + describe('Event handling', function () { + beforeEach(function () { + contentSteeringController.initialize(); + }); + + it('should handle FRAGMENT_LOADING_STARTED event', function () { + eventBus.trigger(MediaPlayerEvents.FRAGMENT_LOADING_STARTED, { + request: { + serviceLocation: 'cdn1' + } + }); + }); + + it('should handle MANIFEST_LOADING_STARTED event', function () { + eventBus.trigger(MediaPlayerEvents.MANIFEST_LOADING_STARTED, { + request: { + serviceLocation: 'cdn1' + } + }); + }); + + it('should handle THROUGHPUT_MEASUREMENT_STORED event', function () { + eventBus.trigger(MediaPlayerEvents.THROUGHPUT_MEASUREMENT_STORED, { + throughputValues: { + serviceLocation: 'cdn1', + value: 5000 + } + }); + }); + + it('should ignore THROUGHPUT_MEASUREMENT_STORED event without serviceLocation', function () { + eventBus.trigger(MediaPlayerEvents.THROUGHPUT_MEASUREMENT_STORED, { + throughputValues: { + value: 5000 + } + }); + }); + + it('should ignore THROUGHPUT_MEASUREMENT_STORED event without throughputValues', function () { + eventBus.trigger(MediaPlayerEvents.THROUGHPUT_MEASUREMENT_STORED, {}); + }); + + it('should handle FRAGMENT_LOADING_STARTED event without request', function () { + eventBus.trigger(MediaPlayerEvents.FRAGMENT_LOADING_STARTED, {}); + }); + + it('should handle MANIFEST_LOADING_STARTED event without request', function () { + eventBus.trigger(MediaPlayerEvents.MANIFEST_LOADING_STARTED, {}); + }); + }); + + describe('Method loadSteeringData', function () { + it('should resolve immediately when no steering data in manifest', async function () { + manifestModelMock.getValue = sinon.stub().returns({}); + adapterMock.getContentSteering = sinon.stub().returns(null); + serviceDescriptionControllerMock.getServiceDescriptionSettings = sinon.stub().returns({}); + + contentSteeringController.initialize(); + await contentSteeringController.loadSteeringData(); + }); + + it('should resolve immediately when no server URL', async function () { + manifestModelMock.getValue = sinon.stub().returns({}); + adapterMock.getContentSteering = sinon.stub().returns({ + queryBeforeStart: true + }); + + contentSteeringController.initialize(); + await contentSteeringController.loadSteeringData(); + }); + }); + + describe('Service location tracking', function () { + beforeEach(function () { + contentSteeringController.initialize(); + }); + + it('should track service locations from fragment loading', async function () { + let capturedUrl; + + const mockLoaderInstance = { + load: ({ request, success, complete }) => { + capturedUrl = request.url; + + const responseData = {}; + responseData[DashConstants.CONTENT_STEERING_RESPONSE.VERSION] = '1'; + + success(responseData); + if (typeof complete === 'function') { + complete(); + } + }, + abort: () => {}, + reset: () => {}, + resetInitialSettings: () => {} + }; + + function MockLoader() { + return { + create: () => mockLoaderInstance + }; + } + + const schemeLoaderFactory = SchemeLoaderFactory(context).getInstance(); + + schemeLoaderFactory.registerLoader('https://', MockLoader); + + manifestModelMock.getValue = sinon.stub().returns({}); + adapterMock.getContentSteering = sinon.stub().returns({ + serverUrl: 'https://steering.example.com', + queryBeforeStart: true + }); + + eventBus.trigger(MediaPlayerEvents.FRAGMENT_LOADING_STARTED, { + request: { + serviceLocation: 'cdn1' + } + }); + + eventBus.trigger(MediaPlayerEvents.FRAGMENT_LOADING_STARTED, { + request: { + serviceLocation: 'cdn2' + } + }); + + try { + await contentSteeringController.loadSteeringData(); + + expect(capturedUrl).to.be.a('string'); + const queryString = capturedUrl.split('?')[1]; + const params = new URLSearchParams(queryString); + const pathway = params.get('_DASH_pathway'); + + expect(pathway).to.equal('"cdn1,cdn2"'); + } finally { + schemeLoaderFactory.unregisterLoader('https://'); + } + }); + + it('should track service locations from manifest loading', async function () { + let capturedUrl; + + const mockLoaderInstance = { + load: ({ request, success, complete }) => { + capturedUrl = request.url; + + const responseData = {}; + responseData[DashConstants.CONTENT_STEERING_RESPONSE.VERSION] = '1'; + + success(responseData); + if (typeof complete === 'function') { + complete(); + } + }, + abort: () => {}, + reset: () => {}, + resetInitialSettings: () => {} + }; + + function MockLoader() { + return { + create: () => mockLoaderInstance + }; + } + + const schemeLoaderFactory = SchemeLoaderFactory(context).getInstance(); + + schemeLoaderFactory.registerLoader('https://', MockLoader); + + manifestModelMock.getValue = sinon.stub().returns({}); + adapterMock.getContentSteering = sinon.stub().returns({ + serverUrl: 'https://steering.example.com', + queryBeforeStart: true + }); + + eventBus.trigger(MediaPlayerEvents.MANIFEST_LOADING_STARTED, { + request: { + serviceLocation: 'cdn1' + } + }); + + eventBus.trigger(MediaPlayerEvents.MANIFEST_LOADING_STARTED, { + request: { + serviceLocation: 'cdn2' + } + }); + + try { + await contentSteeringController.loadSteeringData(); + + expect(capturedUrl).to.be.a('string'); + const queryString = capturedUrl.split('?')[1]; + const params = new URLSearchParams(queryString); + const pathway = params.get('_DASH_pathway'); + + expect(pathway).to.equal('"cdn1,cdn2"'); + } finally { + schemeLoaderFactory.unregisterLoader('https://'); + } + }); + + it('should not duplicate service locations', async function () { + let capturedUrl; + + const mockLoaderInstance = { + load: ({ request, success, complete }) => { + capturedUrl = request.url; + + const responseData = {}; + responseData[DashConstants.CONTENT_STEERING_RESPONSE.VERSION] = '1'; + + success(responseData); + if (typeof complete === 'function') { + complete(); + } + }, + abort: () => {}, + reset: () => {}, + resetInitialSettings: () => {} + }; + + function MockLoader() { + return { + create: () => mockLoaderInstance + }; + } + + const schemeLoaderFactory = SchemeLoaderFactory(context).getInstance(); + + schemeLoaderFactory.registerLoader('https://', MockLoader); + + manifestModelMock.getValue = sinon.stub().returns({}); + adapterMock.getContentSteering = sinon.stub().returns({ + serverUrl: 'https://steering.example.com', + queryBeforeStart: true + }); + + eventBus.trigger(MediaPlayerEvents.FRAGMENT_LOADING_STARTED, { + request: { + serviceLocation: 'cdn1' + } + }); + + eventBus.trigger(MediaPlayerEvents.FRAGMENT_LOADING_STARTED, { + request: { + serviceLocation: 'cdn1' + } + }); + + try { + await contentSteeringController.loadSteeringData(); + + expect(capturedUrl).to.be.a('string'); + const queryString = capturedUrl.split('?')[1]; + const params = new URLSearchParams(queryString); + const pathway = params.get('_DASH_pathway'); + + expect(pathway).to.equal('"cdn1"'); + } finally { + schemeLoaderFactory.unregisterLoader('https://'); + } + }); + }); + + describe('Throughput tracking', function () { + beforeEach(function () { + contentSteeringController.initialize(); + }); + + it('should store throughput measurements for service locations', async function () { + let capturedUrl; + + const mockLoaderInstance = { + load: ({ request, success, complete }) => { + capturedUrl = request.url; + + const responseData = {}; + responseData[DashConstants.CONTENT_STEERING_RESPONSE.VERSION] = '1'; + + success(responseData); + if (typeof complete === 'function') { + complete(); + } + }, + abort: () => {}, + reset: () => {}, + resetInitialSettings: () => {} + }; + + function MockLoader() { + return { + create: () => mockLoaderInstance + }; + } + + const schemeLoaderFactory = SchemeLoaderFactory(context).getInstance(); + + schemeLoaderFactory.registerLoader('https://', MockLoader); + + manifestModelMock.getValue = sinon.stub().returns({}); + adapterMock.getContentSteering = sinon.stub().returns({ + serverUrl: 'https://steering.example.com', + queryBeforeStart: true + }); + + // Add service location so throughput is included in the request URL + eventBus.trigger(MediaPlayerEvents.FRAGMENT_LOADING_STARTED, { + request: { + serviceLocation: 'cdn1' + } + }); + + eventBus.trigger(MediaPlayerEvents.THROUGHPUT_MEASUREMENT_STORED, { + throughputValues: { + serviceLocation: 'cdn1', + value: 5000 + } + }); + + eventBus.trigger(MediaPlayerEvents.THROUGHPUT_MEASUREMENT_STORED, { + throughputValues: { + serviceLocation: 'cdn1', + value: 6000 + } + }); + + try { + await contentSteeringController.loadSteeringData(); + + expect(capturedUrl).to.be.a('string'); + expect(capturedUrl).to.contain('_DASH_throughput=5500000'); + } finally { + schemeLoaderFactory.unregisterLoader('https://'); + } + }); + + it('should store throughput for multiple service locations', function () { + eventBus.trigger(MediaPlayerEvents.THROUGHPUT_MEASUREMENT_STORED, { + throughputValues: { + serviceLocation: 'cdn1', + value: 5000 + } + }); + + eventBus.trigger(MediaPlayerEvents.THROUGHPUT_MEASUREMENT_STORED, { + throughputValues: { + serviceLocation: 'cdn2', + value: 7000 + } + }); + }); + }); +}); diff --git a/test/unit/test/streaming/streaming.controllers.CatchupController.js b/test/unit/test/streaming/streaming.controllers.CatchupController.js new file mode 100644 index 0000000000..1b4d9fe88a --- /dev/null +++ b/test/unit/test/streaming/streaming.controllers.CatchupController.js @@ -0,0 +1,377 @@ +import CatchupController from '../../../../src/streaming/controllers/CatchupController.js'; +import EventBus from '../../../../src/core/EventBus.js'; +import Events from '../../../../src/core/events/Events.js'; +import MediaPlayerEvents from '../../../../src/streaming/MediaPlayerEvents.js'; +import Settings from '../../../../src/core/Settings.js'; +import MetricsConstants from '../../../../src/streaming/constants/MetricsConstants.js'; +import Constants from '../../../../src/streaming/constants/Constants.js'; + +import PlaybackControllerMock from '../../mocks/PlaybackControllerMock.js'; +import StreamControllerMock from '../../mocks/StreamControllerMock.js'; +import VideoModelMock from '../../mocks/VideoModelMock.js'; +import MediaPlayerModelMock from '../../mocks/MediaPlayerModelMock.js'; + +import chai from 'chai'; +import sinon from 'sinon'; + +const expect = chai.expect; + +const context = {}; +const eventBus = EventBus(context).getInstance(); + +describe('CatchupController', function () { + let catchupController; + let settings; + let playbackControllerMock; + let streamControllerMock; + let videoModelMock; + let mediaPlayerModelMock; + + beforeEach(function () { + settings = Settings(context).getInstance(); + playbackControllerMock = new PlaybackControllerMock(); + streamControllerMock = new StreamControllerMock(); + videoModelMock = new VideoModelMock(); + mediaPlayerModelMock = new MediaPlayerModelMock(); + + catchupController = CatchupController(context).getInstance(); + catchupController.setConfig({ + settings: settings, + playbackController: playbackControllerMock, + streamController: streamControllerMock, + videoModel: videoModelMock, + mediaPlayerModel: mediaPlayerModelMock + }); + }); + + afterEach(function () { + catchupController.reset(); + catchupController = null; + settings.reset(); + }); + + describe('Method initialize', function () { + it('should initialize the controller', function () { + catchupController.initialize(); + }); + }); + + describe('Method setConfig', function () { + it('should update config with provided settings', function () { + const newSettings = Settings(context).getInstance(); + catchupController.setConfig({ + settings: newSettings + }); + }); + + it('should handle null config', function () { + catchupController.setConfig(null); + }); + + it('should handle empty config', function () { + catchupController.setConfig({}); + }); + }); + + describe('Method reset', function () { + it('should reset the controller and set playback rate to 1.0', function () { + const setPlaybackRateSpy = sinon.spy(videoModelMock, 'setPlaybackRate'); + catchupController.initialize(); + catchupController.reset(); + + expect(setPlaybackRateSpy.calledWith(1.0, true)).to.be.true; + }); + }); + + describe('Event handling', function () { + beforeEach(function () { + catchupController.initialize(); + }); + + it('should handle BUFFER_LEVEL_UPDATED event', function () { + const streamInfo = { id: 'stream-1' }; + streamControllerMock.getActiveStreamInfo = sinon.stub().returns(streamInfo); + playbackControllerMock.getLiveDelay = sinon.stub().returns(4); + playbackControllerMock.getBufferLevel = sinon.stub().returns(3); + + eventBus.trigger(MediaPlayerEvents.BUFFER_LEVEL_UPDATED, { + streamId: 'stream-1', + mediaType: 'video' + }); + }); + + it('should handle BUFFER_LEVEL_STATE_CHANGED event', function () { + const streamInfo = { id: 'stream-1' }; + streamControllerMock.getActiveStreamInfo = sinon.stub().returns(streamInfo); + + eventBus.trigger(MediaPlayerEvents.BUFFER_LEVEL_STATE_CHANGED, { + streamId: 'stream-1', + state: MetricsConstants.BUFFER_EMPTY + }); + }); + + it('should handle PLAYBACK_PROGRESS event', function () { + playbackControllerMock.getIsDynamic = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupModeEnabled = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupPlaybackRates = sinon.stub().returns({ min: -0.5, max: 0.5 }); + playbackControllerMock.isPaused = sinon.stub().returns(false); + playbackControllerMock.isSeeking = sinon.stub().returns(false); + playbackControllerMock.getTime = sinon.stub().returns(10); + playbackControllerMock.getCurrentLiveLatency = sinon.stub().returns(5); + playbackControllerMock.getLiveDelay = sinon.stub().returns(3); + playbackControllerMock.getBufferLevel = sinon.stub().returns(2); + playbackControllerMock.getPlaybackStalled = sinon.stub().returns(false); + videoModelMock.getPlaybackRate = sinon.stub().returns(1.0); + const setPlaybackRateSpy = sinon.spy(videoModelMock, 'setPlaybackRate'); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PROGRESS); + + expect(setPlaybackRateSpy.called).to.be.true; + }); + + it('should handle PLAYBACK_TIME_UPDATED event', function () { + playbackControllerMock.getIsDynamic = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupModeEnabled = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupPlaybackRates = sinon.stub().returns({ min: -0.5, max: 0.5 }); + playbackControllerMock.isPaused = sinon.stub().returns(false); + playbackControllerMock.isSeeking = sinon.stub().returns(false); + playbackControllerMock.getTime = sinon.stub().returns(10); + playbackControllerMock.getCurrentLiveLatency = sinon.stub().returns(5); + playbackControllerMock.getLiveDelay = sinon.stub().returns(3); + playbackControllerMock.getBufferLevel = sinon.stub().returns(2); + playbackControllerMock.getPlaybackStalled = sinon.stub().returns(false); + videoModelMock.getPlaybackRate = sinon.stub().returns(1.0); + const setPlaybackRateSpy = sinon.spy(videoModelMock, 'setPlaybackRate'); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_TIME_UPDATED); + + expect(setPlaybackRateSpy.called).to.be.true; + }); + + it('should handle PLAYBACK_SEEKED event', function () { + eventBus.trigger(MediaPlayerEvents.PLAYBACK_SEEKED); + }); + + it('should handle SETTING_UPDATED_CATCHUP_ENABLED event', function () { + mediaPlayerModelMock.getCatchupModeEnabled = sinon.stub().returns(false); + const setPlaybackRateSpy = sinon.spy(videoModelMock, 'setPlaybackRate'); + + eventBus.trigger(Events.SETTING_UPDATED_CATCHUP_ENABLED); + + expect(setPlaybackRateSpy.calledWith(1.0)).to.be.true; + }); + + it('should handle SETTING_UPDATED_PLAYBACK_RATE_MIN event', function () { + mediaPlayerModelMock.getCatchupPlaybackRates = sinon.stub().returns({ min: -0.5, max: 0.5 }); + eventBus.trigger(Events.SETTING_UPDATED_PLAYBACK_RATE_MIN); + }); + + it('should handle SETTING_UPDATED_PLAYBACK_RATE_MAX event', function () { + mediaPlayerModelMock.getCatchupPlaybackRates = sinon.stub().returns({ min: -0.5, max: 0.5 }); + eventBus.trigger(Events.SETTING_UPDATED_PLAYBACK_RATE_MAX); + }); + + it('should handle STREAM_INITIALIZED event', function () { + mediaPlayerModelMock.getCatchupPlaybackRates = sinon.stub().returns({ min: -0.5, max: 0.5 }); + eventBus.trigger(MediaPlayerEvents.STREAM_INITIALIZED); + }); + }); + + describe('Catchup mode behavior', function () { + beforeEach(function () { + catchupController.initialize(); + settings.update({ + streaming: { + liveCatchup: { + mode: Constants.LIVE_CATCHUP_MODE_DEFAULT, + playbackBufferMin: 0.5 + } + } + }); + }); + + it('should not apply catchup when not in dynamic mode', function () { + playbackControllerMock.getIsDynamic = sinon.stub().returns(false); + mediaPlayerModelMock.getCatchupModeEnabled = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupPlaybackRates = sinon.stub().returns({ min: -0.5, max: 0.5 }); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PROGRESS); + }); + + it('should not apply catchup when catchup mode is disabled', function () { + playbackControllerMock.getIsDynamic = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupModeEnabled = sinon.stub().returns(false); + mediaPlayerModelMock.getCatchupPlaybackRates = sinon.stub().returns({ min: -0.5, max: 0.5 }); + playbackControllerMock.isPaused = sinon.stub().returns(false); + playbackControllerMock.isSeeking = sinon.stub().returns(false); + playbackControllerMock.getTime = sinon.stub().returns(10); + + const setPlaybackRateSpy = sinon.spy(videoModelMock, 'setPlaybackRate'); + const seekToCurrentLiveSpy = sinon.spy(playbackControllerMock, 'seekToCurrentLive'); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PROGRESS); + + expect(setPlaybackRateSpy.notCalled).to.be.true; + expect(seekToCurrentLiveSpy.notCalled).to.be.true; + }); + + it('should not apply catchup when paused', function () { + playbackControllerMock.getIsDynamic = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupModeEnabled = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupPlaybackRates = sinon.stub().returns({ min: -0.5, max: 0.5 }); + playbackControllerMock.isPaused = sinon.stub().returns(true); + playbackControllerMock.isSeeking = sinon.stub().returns(false); + playbackControllerMock.getTime = sinon.stub().returns(10); + + const setPlaybackRateSpy = sinon.spy(videoModelMock, 'setPlaybackRate'); + const seekToCurrentLiveSpy = sinon.spy(playbackControllerMock, 'seekToCurrentLive'); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PROGRESS); + + expect(setPlaybackRateSpy.notCalled).to.be.true; + expect(seekToCurrentLiveSpy.notCalled).to.be.true; + }); + + it('should not apply catchup when seeking', function () { + playbackControllerMock.getIsDynamic = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupModeEnabled = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupPlaybackRates = sinon.stub().returns({ min: -0.5, max: 0.5 }); + playbackControllerMock.isPaused = sinon.stub().returns(false); + playbackControllerMock.isSeeking = sinon.stub().returns(true); + playbackControllerMock.getTime = sinon.stub().returns(10); + + const setPlaybackRateSpy = sinon.spy(videoModelMock, 'setPlaybackRate'); + const seekToCurrentLiveSpy = sinon.spy(playbackControllerMock, 'seekToCurrentLive'); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PROGRESS); + + expect(setPlaybackRateSpy.notCalled).to.be.true; + expect(seekToCurrentLiveSpy.notCalled).to.be.true; + }); + + it('should apply catchup in default mode when latency drift exists', function () { + playbackControllerMock.getIsDynamic = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupModeEnabled = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupPlaybackRates = sinon.stub().returns({ min: -0.5, max: 0.5 }); + playbackControllerMock.isPaused = sinon.stub().returns(false); + playbackControllerMock.isSeeking = sinon.stub().returns(false); + playbackControllerMock.getTime = sinon.stub().returns(10); + playbackControllerMock.getCurrentLiveLatency = sinon.stub().returns(5); + playbackControllerMock.getLiveDelay = sinon.stub().returns(3); + playbackControllerMock.getBufferLevel = sinon.stub().returns(2); + playbackControllerMock.getPlaybackStalled = sinon.stub().returns(false); + videoModelMock.getPlaybackRate = sinon.stub().returns(1.0); + const setPlaybackRateSpy = sinon.spy(videoModelMock, 'setPlaybackRate'); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PROGRESS); + + expect(setPlaybackRateSpy.called).to.be.true; + }); + + it('should seek to live when max drift is exceeded', function () { + playbackControllerMock.getIsDynamic = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupModeEnabled = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupPlaybackRates = sinon.stub().returns({ min: -0.5, max: 0.5 }); + mediaPlayerModelMock.getCatchupMaxDrift = sinon.stub().returns(2); + playbackControllerMock.isPaused = sinon.stub().returns(false); + playbackControllerMock.isSeeking = sinon.stub().returns(false); + playbackControllerMock.getTime = sinon.stub().returns(10); + playbackControllerMock.getCurrentLiveLatency = sinon.stub().returns(10); + playbackControllerMock.getLiveDelay = sinon.stub().returns(3); + const seekToCurrentLiveSpy = sinon.spy(playbackControllerMock, 'seekToCurrentLive'); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PROGRESS); + + expect(seekToCurrentLiveSpy.called).to.be.true; + }); + }); + + describe('LoL+ catchup mode', function () { + beforeEach(function () { + catchupController.initialize(); + settings.update({ + streaming: { + liveCatchup: { + mode: Constants.LIVE_CATCHUP_MODE_LOLP, + playbackBufferMin: 1.0 + } + } + }); + }); + + it('should use buffer-based catchup when buffer is low', function () { + playbackControllerMock.getIsDynamic = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupModeEnabled = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupPlaybackRates = sinon.stub().returns({ min: -0.5, max: 0.5 }); + playbackControllerMock.isPaused = sinon.stub().returns(false); + playbackControllerMock.isSeeking = sinon.stub().returns(false); + playbackControllerMock.getTime = sinon.stub().returns(10); + playbackControllerMock.getCurrentLiveLatency = sinon.stub().returns(5); + playbackControllerMock.getLiveDelay = sinon.stub().returns(3); + playbackControllerMock.getBufferLevel = sinon.stub().returns(0.5); + videoModelMock.getPlaybackRate = sinon.stub().returns(1.0); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PROGRESS); + }); + + it('should use latency-based catchup when buffer is sufficient', function () { + playbackControllerMock.getIsDynamic = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupModeEnabled = sinon.stub().returns(true); + mediaPlayerModelMock.getCatchupPlaybackRates = sinon.stub().returns({ min: -0.5, max: 0.5 }); + playbackControllerMock.isPaused = sinon.stub().returns(false); + playbackControllerMock.isSeeking = sinon.stub().returns(false); + playbackControllerMock.getTime = sinon.stub().returns(10); + playbackControllerMock.getCurrentLiveLatency = sinon.stub().returns(5); + playbackControllerMock.getLiveDelay = sinon.stub().returns(3); + playbackControllerMock.getBufferLevel = sinon.stub().returns(2.0); + videoModelMock.getPlaybackRate = sinon.stub().returns(1.0); + + eventBus.trigger(MediaPlayerEvents.PLAYBACK_PROGRESS); + }); + }); + + describe('Buffer state management', function () { + beforeEach(function () { + catchupController.initialize(); + }); + + it('should update stalled state when buffer becomes empty', function () { + const streamInfo = { id: 'stream-1' }; + streamControllerMock.getActiveStreamInfo = sinon.stub().returns(streamInfo); + + eventBus.trigger(MediaPlayerEvents.BUFFER_LEVEL_STATE_CHANGED, { + streamId: 'stream-1', + state: MetricsConstants.BUFFER_EMPTY + }); + }); + + it('should ignore buffer state changes from inactive streams', function () { + const streamInfo = { id: 'stream-1' }; + streamControllerMock.getActiveStreamInfo = sinon.stub().returns(streamInfo); + + eventBus.trigger(MediaPlayerEvents.BUFFER_LEVEL_STATE_CHANGED, { + streamId: 'stream-2', + state: MetricsConstants.BUFFER_EMPTY + }); + }); + + it('should remove stalled state when buffer level increases', function () { + const streamInfo = { id: 'stream-1' }; + streamControllerMock.getActiveStreamInfo = sinon.stub().returns(streamInfo); + playbackControllerMock.getLiveDelay = sinon.stub().returns(4); + playbackControllerMock.getBufferLevel = sinon.stub().returns(3); + + // First set stalled state + eventBus.trigger(MediaPlayerEvents.BUFFER_LEVEL_STATE_CHANGED, { + streamId: 'stream-1', + state: MetricsConstants.BUFFER_EMPTY + }); + + // Then trigger buffer level update + eventBus.trigger(MediaPlayerEvents.BUFFER_LEVEL_UPDATED, { + streamId: 'stream-1', + mediaType: 'video' + }); + }); + }); +}); diff --git a/test/unit/test/streaming/streaming.controllers.GapController.js b/test/unit/test/streaming/streaming.controllers.GapController.js new file mode 100644 index 0000000000..ac4dbb3f4a --- /dev/null +++ b/test/unit/test/streaming/streaming.controllers.GapController.js @@ -0,0 +1,288 @@ +import GapController from '../../../../src/streaming/controllers/GapController.js'; +import EventBus from '../../../../src/core/EventBus.js'; +import Events from '../../../../src/core/events/Events.js'; +import Settings from '../../../../src/core/Settings.js'; + +import PlaybackControllerMock from '../../mocks/PlaybackControllerMock.js'; +import StreamControllerMock from '../../mocks/StreamControllerMock.js'; +import VideoModelMock from '../../mocks/VideoModelMock.js'; + +import chai from 'chai'; +import sinon from 'sinon'; + +const expect = chai.expect; + +const context = {}; +const eventBus = EventBus(context).getInstance(); + +describe('GapController', function () { + let gapController; + let settings; + let playbackControllerMock; + let streamControllerMock; + let videoModelMock; + + beforeEach(function () { + settings = Settings(context).getInstance(); + playbackControllerMock = new PlaybackControllerMock(); + streamControllerMock = new StreamControllerMock(); + videoModelMock = new VideoModelMock(); + + gapController = GapController(context).getInstance(); + gapController.setConfig({ + settings: settings, + playbackController: playbackControllerMock, + streamController: streamControllerMock, + videoModel: videoModelMock + }); + }); + + afterEach(function () { + gapController.reset(); + gapController = null; + settings.reset(); + }); + + describe('Method initialize', function () { + it('should initialize the controller', function () { + gapController.initialize(); + }); + }); + + describe('Method setConfig', function () { + it('should update config with provided settings', function () { + const newSettings = Settings(context).getInstance(); + gapController.setConfig({ + settings: newSettings + }); + }); + + it('should handle null config', function () { + gapController.setConfig(null); + }); + + it('should handle empty config', function () { + gapController.setConfig({}); + }); + }); + + describe('Method reset', function () { + it('should reset the controller', function () { + gapController.initialize(); + gapController.reset(); + }); + }); + + describe('Event handling', function () { + beforeEach(function () { + gapController.initialize(); + }); + + it('should handle WALLCLOCK_TIME_UPDATED event', function () { + playbackControllerMock.getTime = sinon.stub().returns(10); + streamControllerMock.getActiveStream = sinon.stub().returns({ + getStartTime: () => 0, + getDuration: () => 100 + }); + streamControllerMock.getActiveStreamProcessors = sinon.stub().returns([{}]); + playbackControllerMock.isSeeking = sinon.stub().returns(false); + playbackControllerMock.isPaused = sinon.stub().returns(false); + streamControllerMock.getIsStreamSwitchInProgress = sinon.stub().returns(false); + streamControllerMock.getHasMediaOrInitialisationError = sinon.stub().returns(false); + + eventBus.trigger(Events.WALLCLOCK_TIME_UPDATED); + }); + + it('should handle INITIAL_STREAM_SWITCH event', function () { + eventBus.trigger(Events.INITIAL_STREAM_SWITCH); + }); + + it('should handle PLAYBACK_SEEKING event', function () { + eventBus.trigger(Events.PLAYBACK_SEEKING); + }); + + it('should handle BUFFER_REPLACEMENT_STARTED event', function () { + const streamInfo = { id: 'stream-1' }; + streamControllerMock.getActiveStreamInfo = sinon.stub().returns(streamInfo); + + eventBus.trigger(Events.BUFFER_REPLACEMENT_STARTED, { + streamId: 'stream-1', + mediaType: 'video' + }); + }); + + it('should handle TRACK_CHANGE_RENDERED event', function () { + eventBus.trigger(Events.TRACK_CHANGE_RENDERED, { + mediaType: 'video' + }); + }); + + it('should ignore BUFFER_REPLACEMENT_STARTED for different stream', function () { + const streamInfo = { id: 'stream-1' }; + streamControllerMock.getActiveStreamInfo = sinon.stub().returns(streamInfo); + + eventBus.trigger(Events.BUFFER_REPLACEMENT_STARTED, { + streamId: 'stream-2', + mediaType: 'video' + }); + }); + + it('should handle TRACK_CHANGE_RENDERED with null event', function () { + eventBus.trigger(Events.TRACK_CHANGE_RENDERED, null); + }); + + it('should handle TRACK_CHANGE_RENDERED without mediaType', function () { + eventBus.trigger(Events.TRACK_CHANGE_RENDERED, {}); + }); + }); + + describe('Gap detection and jumping', function () { + beforeEach(function () { + gapController.initialize(); + settings.update({ + streaming: { + gaps: { + jumpGaps: true, + smallGapLimit: 1.5, + threshold: 0.3, + enableStallFix: true, + stallSeek: 0.1, + jumpLargeGaps: false, + enableSeekFix: true + } + } + }); + }); + + it('should not check for gaps when no active stream', function () { + streamControllerMock.getActiveStream = sinon.stub().returns(null); + const getTimeSpy = sinon.spy(playbackControllerMock, 'getTime'); + + // Trigger enough wallclock updates that, if gap checks were enabled, + // we would reach the THRESHOLD_TO_STALLS branch and call getTime(). + for (let i = 0; i < 15; i++) { + eventBus.trigger(Events.WALLCLOCK_TIME_UPDATED); + } + + expect(getTimeSpy.notCalled).to.be.true; + }); + + it('should not check for gaps when paused', function () { + streamControllerMock.getActiveStream = sinon.stub().returns({ + getStartTime: () => 0, + getDuration: () => 100 + }); + streamControllerMock.getActiveStreamProcessors = sinon.stub().returns([{}]); + playbackControllerMock.isPaused = sinon.stub().returns(true); + const seekSpy = sinon.spy(playbackControllerMock, 'seek'); + + for (let i = 0; i < 15; i++) { + eventBus.trigger(Events.WALLCLOCK_TIME_UPDATED); + } + + expect(seekSpy.notCalled).to.be.true; + }); + + it('should not check for gaps when seeking', function () { + streamControllerMock.getActiveStream = sinon.stub().returns({ + getStartTime: () => 0, + getDuration: () => 100 + }); + streamControllerMock.getActiveStreamProcessors = sinon.stub().returns([{}]); + playbackControllerMock.isSeeking = sinon.stub().returns(true); + playbackControllerMock.isPaused = sinon.stub().returns(false); + const seekSpy = sinon.spy(playbackControllerMock, 'seek'); + + for (let i = 0; i < 15; i++) { + eventBus.trigger(Events.WALLCLOCK_TIME_UPDATED); + } + + expect(seekSpy.notCalled).to.be.true; + }); + + it('should not check for gaps during stream switch', function () { + streamControllerMock.getActiveStream = sinon.stub().returns({ + getStartTime: () => 0, + getDuration: () => 100 + }); + streamControllerMock.getActiveStreamProcessors = sinon.stub().returns([{}]); + streamControllerMock.getIsStreamSwitchInProgress = sinon.stub().returns(true); + playbackControllerMock.isSeeking = sinon.stub().returns(false); + playbackControllerMock.isPaused = sinon.stub().returns(false); + const seekSpy = sinon.spy(playbackControllerMock, 'seek'); + + for (let i = 0; i < 15; i++) { + eventBus.trigger(Events.WALLCLOCK_TIME_UPDATED); + } + + expect(seekSpy.notCalled).to.be.true; + }); + + it('should not check for gaps when media error occurred', function () { + streamControllerMock.getActiveStream = sinon.stub().returns({ + getStartTime: () => 0, + getDuration: () => 100 + }); + streamControllerMock.getActiveStreamProcessors = sinon.stub().returns([{}]); + streamControllerMock.getHasMediaOrInitialisationError = sinon.stub().returns(true); + playbackControllerMock.isSeeking = sinon.stub().returns(false); + playbackControllerMock.isPaused = sinon.stub().returns(false); + const seekSpy = sinon.spy(playbackControllerMock, 'seek'); + + for (let i = 0; i < 15; i++) { + eventBus.trigger(Events.WALLCLOCK_TIME_UPDATED); + } + + expect(seekSpy.notCalled).to.be.true; + }); + + it('should not check for gaps when jumpGaps is disabled', function () { + settings.update({ + streaming: { + gaps: { + jumpGaps: false + } + } + }); + + streamControllerMock.getActiveStream = sinon.stub().returns({ + getStartTime: () => 0, + getDuration: () => 100 + }); + streamControllerMock.getActiveStreamProcessors = sinon.stub().returns([{}]); + playbackControllerMock.isSeeking = sinon.stub().returns(false); + playbackControllerMock.isPaused = sinon.stub().returns(false); + const seekSpy = sinon.spy(playbackControllerMock, 'seek'); + + for (let i = 0; i < 15; i++) { + eventBus.trigger(Events.WALLCLOCK_TIME_UPDATED); + } + + expect(seekSpy.notCalled).to.be.true; + }); + }); + + describe('Configuration', function () { + it('should respect gap settings', function () { + const customSettings = { + streaming: { + gaps: { + jumpGaps: false, + smallGapLimit: 2.0, + threshold: 0.5, + enableStallFix: false, + stallSeek: 0.2, + jumpLargeGaps: true, + enableSeekFix: false + } + } + }; + + settings.update(customSettings); + const currentSettings = settings.get(); + expect(currentSettings.streaming.gaps.jumpGaps).to.equal(false); + expect(currentSettings.streaming.gaps.smallGapLimit).to.equal(2.0); + expect(currentSettings.streaming.gaps.threshold).to.equal(0.5); + }); + }); +});