From f43a9a7afaffc50e3abe278a3df36203f8ca7308 Mon Sep 17 00:00:00 2001 From: jcass Date: Mon, 4 Apr 2016 05:14:45 +0200 Subject: [PATCH] Optimise progress timer callback. --- .../static/js/synced_timer.js | 124 +++--- mopidy_musicbox_webclient/static/mb.appcache | 2 +- .../vendors/media_progress_timer/timer.js | 12 +- tests/js/test_synced_timer.js | 363 +++++++++++++----- 4 files changed, 329 insertions(+), 172 deletions(-) diff --git a/mopidy_musicbox_webclient/static/js/synced_timer.js b/mopidy_musicbox_webclient/static/js/synced_timer.js index 25b2eb9..e66b0eb 100644 --- a/mopidy_musicbox_webclient/static/js/synced_timer.js +++ b/mopidy_musicbox_webclient/static/js/synced_timer.js @@ -34,8 +34,8 @@ this.positionNode = document.createTextNode('') this.durationNode = document.createTextNode('') - $('#songelapsed').append(this.positionNode) - $('#songlength').append(this.durationNode) + $('#songelapsed').empty().append(this.positionNode) + $('#songlength').empty().append(this.durationNode) this._progressTimer = new ProgressTimer({ // Make sure that the timer object's context is available. @@ -47,7 +47,12 @@ this._isConnected = false this._mopidy.on('state:online', $.proxy(function () { this._isConnected = true }), this) this._mopidy.on('state:offline', $.proxy(function () { this._isConnected = false }), this) - this.init() + this.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED + this._isSyncScheduled = false + this._scheduleID = null + this._syncAttemptsRemaining = this._maxAttempts + this._previousSyncPosition = null + this._duration = null } SyncedProgressTimer.SYNC_STATE = { @@ -58,7 +63,7 @@ SyncedProgressTimer.format = function (milliseconds) { if (milliseconds === Infinity) { - return '(n/a)' + return '' } else if (milliseconds === 0) { return '0:00' } @@ -71,57 +76,37 @@ return minutes + ':' + seconds } - SyncedProgressTimer.prototype.init = function () { - this.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED - this._syncAttemptsRemaining = this._maxAttempts - - this.positionNode.nodeValue = '' - this.durationNode.nodeValue = '' - - this._scheduledSyncTime = null - this._previousSyncPosition = null - this._duration = null - - return this - } - SyncedProgressTimer.prototype.timerCallback = function (position, duration) { this._update(position, duration) - - if (this._isConnected && this._isSyncScheduled()) { + if (this._isSyncScheduled && this._isConnected) { this._doSync(position, duration) } } SyncedProgressTimer.prototype._update = function (position, duration) { - if (!(duration === Infinity && position === 0)) { - // Timer has been properly initialized. - this.durationNode.nodeValue = SyncedProgressTimer.format(duration || Infinity) - switch (this.syncState) { - case SyncedProgressTimer.SYNC_STATE.NOT_SYNCED: - // Waiting for Mopidy to provide a target position. - this.positionNode.nodeValue = '(wait)' - break - case SyncedProgressTimer.SYNC_STATE.SYNCING: - // Busy seeking to new target position. - this.positionNode.nodeValue = '(sync)' - break - case SyncedProgressTimer.SYNC_STATE.SYNCED: - this._previousSyncPosition = position - this.positionNode.nodeValue = SyncedProgressTimer.format(position) - $('#trackslider').val(position).slider('refresh') - break - } - } else { - // Make sure that default values are displayed while the timer is being initialized. - this.positionNode.nodeValue = '' - this.durationNode.nodeValue = '' - $('#trackslider').val(0).slider('refresh') + switch (this.syncState) { + case SyncedProgressTimer.SYNC_STATE.NOT_SYNCED: + // Waiting for Mopidy to provide a target position. + this.positionNode.nodeValue = '(wait)' + break + case SyncedProgressTimer.SYNC_STATE.SYNCING: + // Busy seeking to new target position. + this.positionNode.nodeValue = '(sync)' + break + case SyncedProgressTimer.SYNC_STATE.SYNCED: + this._previousSyncPosition = position + this.positionNode.nodeValue = SyncedProgressTimer.format(position) + $('#trackslider').val(position).slider('refresh') + break } } - SyncedProgressTimer.prototype._isSyncScheduled = function () { - return this._scheduledSyncTime !== null && this._scheduledSyncTime <= new Date().getTime() + SyncedProgressTimer.prototype._scheduleSync = function (milliseconds) { + // Use an anonymous callback to set a boolean value, which should be faster to + // check in the timeout callback than doing another function call. + clearTimeout(this._scheduleID) + this._isSyncScheduled = false + this._scheduleID = setTimeout($.proxy(function () { this._isSyncScheduled = true }, this), milliseconds) } SyncedProgressTimer.prototype._doSync = function (position, duration) { @@ -130,11 +115,14 @@ // Don't try to sync if progress timer has not been initialized yet. return } + var _this = this _this._mopidy.playback.getTimePosition().then(function (targetPosition) { + if (_this.syncState === SyncedProgressTimer.SYNC_STATE.NOT_SYNCED) { + _this.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING + } if (Math.abs(targetPosition - position) <= 500) { // Less than 500ms == in sync. - _this.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING _this._syncAttemptsRemaining = Math.max(_this._syncAttemptsRemaining - 1, 0) if (_this._syncAttemptsRemaining < _this._maxAttempts - 1 && _this._previousSyncPosition !== targetPosition) { // Need at least two consecutive syncs to know that Mopidy @@ -143,14 +131,13 @@ } _this._previousSyncPosition = targetPosition // Step back exponentially while increasing number of callbacks. - _this._scheduledSyncTime = new Date().getTime() + - delay_exponential(0.25, 2, _this._maxAttempts - _this._syncAttemptsRemaining) * 1000 + _this._scheduleSync(delay_exponential(0.25, 2, _this._maxAttempts - _this._syncAttemptsRemaining) * 1000) } else { // Drift is too large, re-sync with Mopidy. - _this._syncAttemptsRemaining = _this._maxAttempts - _this._scheduledSyncTime = new Date().getTime() + 100 - _this._previousSyncPosition = null _this.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING + _this._syncAttemptsRemaining = _this._maxAttempts + _this._previousSyncPosition = null + _this._scheduleSync(1000) _this._progressTimer.set(targetPosition) } }) @@ -160,58 +147,61 @@ if (arguments.length === 0) { throw new Error('"SyncedProgressTimer.set" requires the "position" argument.') } + + this.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED + this._syncAttemptsRemaining = this._maxAttempts // Workaround for https://github.com/adamcik/media-progress-timer/issues/3 // This causes the timer to die unexpectedly if the position exceeds // the duration slightly. if (this._duration && this._duration < position) { position = this._duration - 1 } - this.init() if (arguments.length === 1) { this._progressTimer.set(position) } else { this._duration = duration this._progressTimer.set(position, duration) + this.durationNode.nodeValue = SyncedProgressTimer.format(duration) } - if (!this._isSyncScheduled()) { - // Set lapsed time and slider position directly as timer callback is not currently - // running. - this.positionNode.nodeValue = SyncedProgressTimer.format(position) - if (arguments.length === 2) { - this.durationNode.nodeValue = SyncedProgressTimer.format(duration) - } - $('#trackslider').val(position).slider('refresh') - } + this.updatePosition(position, duration) + $('#trackslider').val(position).slider('refresh') return this } SyncedProgressTimer.prototype.start = function () { this.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED - this._scheduledSyncTime = new Date().getTime() + this._scheduleSync(0) this._progressTimer.start() return this } SyncedProgressTimer.prototype.stop = function () { this._progressTimer.stop() - this._scheduledSyncTime = null - this.updatePosition(this._previousSyncPosition) + clearTimeout(this._scheduleID) + this._isSyncScheduled = false + if (this.syncState !== SyncedProgressTimer.SYNC_STATE.SYNCED && this._previousSyncPosition) { + // Timer was busy trying to sync when it was stopped, fallback to displaying the last synced position on screen. + this.positionNode.nodeValue = SyncedProgressTimer.format(this._previousSyncPosition) + } return this } SyncedProgressTimer.prototype.reset = function () { - this._progressTimer.reset() + // this._progressTimer.reset() this.stop() - this.init() this.set(0, Infinity) return this } SyncedProgressTimer.prototype.updatePosition = function (position) { - this.positionNode.nodeValue = SyncedProgressTimer.format(position) + if (!(this._duration === Infinity && position === 0)) { + this.positionNode.nodeValue = SyncedProgressTimer.format(position) + } else { + this.positionNode.nodeValue = '' + } } return SyncedProgressTimer diff --git a/mopidy_musicbox_webclient/static/mb.appcache b/mopidy_musicbox_webclient/static/mb.appcache index f21d33f..720bf20 100644 --- a/mopidy_musicbox_webclient/static/mb.appcache +++ b/mopidy_musicbox_webclient/static/mb.appcache @@ -1,6 +1,6 @@ CACHE MANIFEST -# 2016-04-03:v2 +# 2016-04-06:v1 NETWORK: * diff --git a/mopidy_musicbox_webclient/static/vendors/media_progress_timer/timer.js b/mopidy_musicbox_webclient/static/vendors/media_progress_timer/timer.js index 4592fc0..59145ca 100644 --- a/mopidy_musicbox_webclient/static/vendors/media_progress_timer/timer.js +++ b/mopidy_musicbox_webclient/static/vendors/media_progress_timer/timer.js @@ -15,9 +15,15 @@ 'use strict'; // Helper function to provide a reference time in milliseconds. - var now = typeof window.performance !== 'undefined' && - typeof window.performance.now !== 'undefined' && - window.performance.now.bind(window.performance) || Date.now || + var now = /* Sinon does not currently support faking `window.performance` + (see https://github.com/sinonjs/sinon/issues/803). + Changing this to only rely on `new Date().getTime() + in the interim in order to allow testing of the + progress timer from MMW. + + typeof window.performance !== 'undefined' && + typeof window.performance.now !== 'undefined' && + window.performance.now.bind(window.performance) || Date.now ||*/ function() { return new Date().getTime(); }; // Helper to warn library users about deprecated features etc. diff --git a/tests/js/test_synced_timer.js b/tests/js/test_synced_timer.js index 458c294..1ab85f6 100644 --- a/tests/js/test_synced_timer.js +++ b/tests/js/test_synced_timer.js @@ -14,6 +14,20 @@ describe('SyncedTimer', function () { var mopidy var playback var getTimePositionStub + var clock + + function setFakeTimers () { + clock = sinon.useFakeTimers() + syncedProgressTimer._progressTimer = new ProgressTimer({ + callback: $.proxy(syncedProgressTimer.timerCallback, syncedProgressTimer), + disableRequestAnimationFrame: true // No window available during testing - use fallback mechanism to schedule updates + }) + } + + function restoreFakeTimers () { + clock.restore() + } + before(function () { $(document.body).append( '
' + @@ -24,26 +38,40 @@ describe('SyncedTimer', function () { '
' ) $('#trackslider').slider() // Initialize slider - }) - beforeEach(function () { + $('#trackslider').on('slidestart', function () { + syncedProgressTimer.stop() + $('#trackslider').on('change', function () { syncedProgressTimer.updatePosition($(this).val()) }) + }) + + $('#trackslider').on('slidestop', function () { + $('#trackslider').off('change') + syncedProgressTimer.updatePosition($(this).val()) + // Simulate doSeekPos($(this).val()) + syncedProgressTimer.set($(this).val()) + }) + playback = { getTimePosition: function () { return $.when(1000) }, getState: function () { return $.when('stopped') } } - mopidy = new Mopidy({callingConvention: 'by-position-or-by-name'}) - mopidy.playback = playback getTimePositionStub = sinon.stub(playback, 'getTimePosition') - // Simulate Mopidy's track position advancing 10ms between each call. - for (var i = 0; i < MAX_ATTEMPTS; i++) { - getTimePositionStub.onCall(i).returns($.when(1000 + i * 10)) + // Simulate Mopidy's track position advancing 250ms between each call for 0:01 to 0:10 + for (var i = 0; i < 10000 / 250; i++) { + getTimePositionStub.onCall(i).returns($.when((i + 1) * 250)) } - mopidy = sinon.stub(mopidy) + mopidy = sinon.stub(new Mopidy({callingConvention: 'by-position-or-by-name'})) + mopidy.playback = playback + }) + + beforeEach(function () { syncedProgressTimer = new SyncedProgressTimer(MAX_ATTEMPTS, mopidy) syncedProgressTimer._isConnected = true }) + afterEach(function () { - getTimePositionStub.restore() + getTimePositionStub.reset() }) + describe('#SyncedTimer()', function () { it('should add text nodes to DOM for position and duration indicators', function () { expect($('#songelapsed')).to.have.text('') @@ -61,7 +89,7 @@ describe('SyncedTimer', function () { }) it('should handle Infinity', function () { - assert.equal(SyncedProgressTimer.format(Infinity), '(n/a)') + assert.equal(SyncedProgressTimer.format(Infinity), '') }) it('should handle zero', function () { @@ -70,44 +98,57 @@ describe('SyncedTimer', function () { }) describe('#timerCallback()', function () { - var clock beforeEach(function () { - clock = sinon.useFakeTimers() - syncedProgressTimer._progressTimer = new ProgressTimer({ - callback: $.proxy(syncedProgressTimer.timerCallback, syncedProgressTimer), - disableRequestAnimationFrame: true // No window available during testing - use fallback mechanism to schedule updates - }) + setFakeTimers() }) afterEach(function () { - clock.restore() + restoreFakeTimers() }) it('should not try to sync unless connected to mopidy', function () { + var _doSyncStub = sinon.stub(syncedProgressTimer, '_doSync') + syncedProgressTimer._isConnected = false - var _syncScheduledStub = sinon.stub(syncedProgressTimer, '_isSyncScheduled') - assert.isFalse(_syncScheduledStub.called, '_syncScheduledStub called') - _syncScheduledStub.restore() + syncedProgressTimer.set(0, 1000).start() + clock.tick(1000) + + assert.isFalse(_doSyncStub.called, '_doSync called') + syncedProgressTimer.stop() + _doSyncStub.restore() }) it('should update text nodes', function () { var updateStub = sinon.stub(syncedProgressTimer, '_update') + syncedProgressTimer.set(0, 1000).start() assert.isTrue(updateStub.called, '_update not called') + syncedProgressTimer.stop() updateStub.restore() }) - it('should check if a sync is scheduled', function () { - var scheduleStub = sinon.stub(syncedProgressTimer, '_isSyncScheduled').returns(true) - syncedProgressTimer.set(0, 1000).start() - assert.isTrue(scheduleStub.called, '_isSyncScheduled not called') - scheduleStub.restore() + it('should attempt to perform a sync as soon as timer is started', function () { + var syncStub = sinon.stub(syncedProgressTimer, '_doSync') + + syncedProgressTimer.set(0, 1000).start() // 'start' will immediately schedule a sync. + clock.tick(250) + + assert.isTrue(syncStub.called, '_doSync not called') + syncedProgressTimer.stop() + syncStub.restore() }) - it('should attempt to perform a sync when scheduled', function () { + it('should not attempt to perform a sync untill scheduled', function () { var syncStub = sinon.stub(syncedProgressTimer, '_doSync') - syncedProgressTimer.set(0, 1000).start() + + syncedProgressTimer.set(0, 5000).start() + syncedProgressTimer._scheduleSync(500) clock.tick(250) - assert.isTrue(syncStub.called, '_doSync not called') + assert.isFalse(syncStub.called, 'next _doSync should only have been called after 500ms') + + syncStub.reset() + clock.tick(500) + assert.isTrue(syncStub.called, 'next _doSync not called after 500ms') + syncedProgressTimer.stop() syncStub.restore() }) @@ -115,34 +156,27 @@ describe('SyncedTimer', function () { // Simulate runtime on a 5-second track assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED, 'Timer was initialized in incorrect state') syncedProgressTimer.set(0, 5000).start() + var wasSyncing = false - for (var i = 0; i < MAX_ATTEMPTS; i++) { - clock.tick(250) // 250ms * MAX_ATTEMPTS is only 2 seconds, but we'll be synced after only two attempts + for (var i = 0; i < 4; i++) { + clock.tick(250) wasSyncing = wasSyncing || syncedProgressTimer.syncState === SyncedProgressTimer.SYNC_STATE.SYNCING } + syncedProgressTimer.stop() assert.isTrue(wasSyncing, 'Timer never entered the "syncing" state') assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCED, 'Timer failed to sync') + syncedProgressTimer.stop() }) }) describe('#_update()', function () { - it('should clear timers and reset slider to zero while not ready', function () { - syncedProgressTimer.positionNode.nodeValue = '1:00' - syncedProgressTimer.durationNode.nodeValue = '2:00' - $('#trackslider').val(100).slider('refresh') - syncedProgressTimer._update(0, Infinity) - - assert.equal(syncedProgressTimer.positionNode.nodeValue, '') - assert.equal(syncedProgressTimer.durationNode.nodeValue, '') - assert.equal($('#trackslider').val(), 0) - }) - - it('should set duration to "(n/a)" for tracks with infinite duration (e.g. streams)', function () { + it('should set duration to "" for tracks with infinite duration (e.g. streams)', function () { syncedProgressTimer._update(1000, Infinity) - assert.equal(syncedProgressTimer.durationNode.nodeValue, '(n/a)') + assert.equal(syncedProgressTimer.durationNode.nodeValue, '') }) - it('should show "(wait)" while waiting for Mopidy to supply a position', function () { + it('should show "(wait)" while untill syncing starts', function () { + syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED syncedProgressTimer._update(1000, 2000) assert.equal(syncedProgressTimer.positionNode.nodeValue, '(wait)') }) @@ -161,84 +195,122 @@ describe('SyncedTimer', function () { }) }) - describe('#_isSyncScheduled()', function () { - var scheduleSpy - var clock - before(function () { - scheduleSpy = sinon.spy(syncedProgressTimer, '_isSyncScheduled') - clock = sinon.useFakeTimers() + describe('#scheduleSync', function () { + beforeEach(function () { + setFakeTimers() }) - after(function () { - scheduleSpy.restore() - clock.restore() + afterEach(function () { + restoreFakeTimers() }) + it('should schedule sync when scheduled time arrives', function () { - syncedProgressTimer._scheduledSyncTime = new Date().getTime() + 1000 - assert.isFalse(syncedProgressTimer._isSyncScheduled()) - clock.tick(1000) - assert.isTrue(syncedProgressTimer._isSyncScheduled()) + clock.tick(0) + syncedProgressTimer._scheduleSync(1000) + assert.isFalse(syncedProgressTimer._isSyncScheduled) + clock.tick(1001) + assert.isTrue(syncedProgressTimer._isSyncScheduled) + }) + + it('should clear schedule on each call', function () { + var clearSpy = sinon.spy(window, 'clearTimeout') + + clock.tick(0) + syncedProgressTimer._isSyncScheduled = true + syncedProgressTimer._scheduleSync(1000) + assert.isFalse(syncedProgressTimer._isSyncScheduled) + + var scheduleID = syncedProgressTimer._scheduleID + clock.tick(1001) + syncedProgressTimer._scheduleSync(1000) + assert(clearSpy.calledWith(scheduleID)) + clearSpy.restore() }) }) describe('#_doSync', function () { - var clock beforeEach(function () { - clock = sinon.useFakeTimers() + setFakeTimers() }) afterEach(function () { - clock.restore() + restoreFakeTimers() }) + it('should not try to sync until timer has been set', function () { syncedProgressTimer._doSync(0, Infinity) - assert.isFalse(getTimePositionStub.called, 'getTimePosition called even though timer has not been set') + assert.isFalse(getTimePositionStub.called, 'tried to do sync even though the timer has not been set') }) + it('should request position from Mopidy', function () { syncedProgressTimer._doSync(1000, 2000) assert.isTrue(getTimePositionStub.called, 'getTimePosition not called') }) + it('should set state to "SYNCING" as soon as the first sync attempt is made', function () { + syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED + syncedProgressTimer._doSync(100, 2000) + assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING) + }) + it('should set state to synced after two consecutive successful syncs (i.e. time drift < 500ms)', function () { assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED) - clock.tick(10) - syncedProgressTimer._doSync(1010, 2000) + clock.tick(250) + syncedProgressTimer._doSync(250, 2000) assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING) - clock.tick(10) - syncedProgressTimer._doSync(1020, 2000) + clock.tick(250) + syncedProgressTimer._doSync(500, 2000) assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCED) }) it('should re-initialize and set state to syncing if time drift is more than 500ms', function () { - syncedProgressTimer._doSync(1, 2000) + var scheduleStub = sinon.stub(syncedProgressTimer, '_scheduleSync') + + syncedProgressTimer._doSync(1000, 2000) + assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING) assert.equal(syncedProgressTimer._syncAttemptsRemaining, syncedProgressTimer._maxAttempts) + assert.isNull(syncedProgressTimer._previousSyncPosition) + assert(scheduleStub.calledWith(1000), 'Expected next sync to be scheduled 1s from now') + scheduleStub.restore() }) it('should step back exponentially while syncing', function () { + var scheduleStub = sinon.stub(syncedProgressTimer, '_scheduleSync') + for (var i = 0; i < syncedProgressTimer._maxAttempts; i++) { - syncedProgressTimer._doSync(1000 + (i + 1) * 10, 2000) - // If we don't advance the clock then '_syncAttemptsRemaining' should just contain the step-back in seconds - assert.equal(syncedProgressTimer._syncAttemptsRemaining, syncedProgressTimer._maxAttempts - i - 1, 'Incorrect sync attempts remaining') - assert.equal(syncedProgressTimer._scheduledSyncTime, (0.25 * (Math.pow(2, i)) * 1000), 'Incorrect sync time scheduled') + syncedProgressTimer._doSync(i * 250, 2000) + assert.equal(syncedProgressTimer._syncAttemptsRemaining, syncedProgressTimer._maxAttempts - i - 1, 'Incorrect number of sync attempts remaining') + assert(scheduleStub.calledWith(0.25 * (Math.pow(2, i)) * 1000), 'Incorrect sync time scheduled: ' + scheduleStub.getCall(i)) + scheduleStub.reset() } + scheduleStub.restore() }) it('should check sync every 32s once synced', function () { + var scheduleStub = sinon.stub(syncedProgressTimer, '_scheduleSync') + syncedProgressTimer._syncAttemptsRemaining = 0 - syncedProgressTimer._doSync(1000, 2000) - assert.equal(syncedProgressTimer._scheduledSyncTime, 32000) + syncedProgressTimer._doSync(250, 2000) + assert(scheduleStub.calledWith(32000)) + scheduleStub.restore() }) it('should not sync unless track playback is progressing', function () { getTimePositionStub.restore() - getTimePositionStub = sinon.stub(playback, 'getTimePosition') - getTimePositionStub.returns($.when(1000)) // Simulate playback 'stuck' at 1000ms. + assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED) - clock.tick(10) - syncedProgressTimer._doSync(1010, 2000) + clock.tick(250) + syncedProgressTimer._doSync(250, 2000) assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING) - clock.tick(10) - syncedProgressTimer._doSync(1010, 2000) + clock.tick(250) + syncedProgressTimer._doSync(250, 2000) assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.SYNCING) + + // Restore getTimePositionStub to previous state + getTimePositionStub = sinon.stub(playback, 'getTimePosition') + // Simulate Mopidy's track position advancing 250ms between each call for 0:01 to 0:10 + for (var i = 0; i < 10000 / 250; i++) { + getTimePositionStub.onCall(i).returns($.when((i + 1) * 250)) + } }) }) @@ -252,18 +324,21 @@ describe('SyncedTimer', function () { assert.equal(syncedProgressTimer._progressTimer._state.position, 1000) }) - it('should update track slider if no sync is scheduled', function () { + it('should update position and track slider immediately', function () { syncedProgressTimer.stop() syncedProgressTimer.set(1000, 2000) - expect($('#songelapsed').text()).to.endWith('0:01') + + expect($('#songelapsed').text()).to.equal('0:01') assert.equal($('#trackslider').val(), 1000) }) it('should implement workaround for https://github.com/adamcik/media-progress-timer/issues/3', function () { syncedProgressTimer.set(1000, 2000).start() + assert.equal(syncedProgressTimer._duration, 2000) syncedProgressTimer.set(3000) - assert.equal(syncedProgressTimer._progressTimer._state.position, 1999) + assert.equal(syncedProgressTimer._progressTimer._state.position, 1999, 'Expected position to be less than duration') + syncedProgressTimer.stop() }) }) @@ -272,6 +347,7 @@ describe('SyncedTimer', function () { var startStub = sinon.stub(syncedProgressTimer._progressTimer, 'start') syncedProgressTimer.start() assert(startStub.called) + syncedProgressTimer.stop() startStub.restore() }) @@ -279,16 +355,19 @@ describe('SyncedTimer', function () { syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCED syncedProgressTimer.start() assert.equal(syncedProgressTimer.syncState, SyncedProgressTimer.SYNC_STATE.NOT_SYNCED) + syncedProgressTimer.stop() }) it('should schedule a sync immediately', function () { - var clock = sinon.useFakeTimers() - syncedProgressTimer._scheduledSyncTime = new Date().getTime() + 5000 - expect(syncedProgressTimer._scheduledSyncTime).to.be.above(new Date().getTime()) + var scheduleSpy = sinon.spy(syncedProgressTimer, '_scheduleSync') + + syncedProgressTimer.set(0, 1000) + syncedProgressTimer._isSyncScheduled = false syncedProgressTimer.start() - clock.tick(1000) - expect(syncedProgressTimer._scheduledSyncTime).to.be.below(new Date().getTime()) - clock.restore() + + assert(scheduleSpy.calledWith(0)) + syncedProgressTimer.stop() + scheduleSpy.restore() }) }) @@ -296,45 +375,51 @@ describe('SyncedTimer', function () { it('should stop timer', function () { var stopStub = sinon.stub(syncedProgressTimer._progressTimer, 'stop') syncedProgressTimer.stop() + assert(stopStub.called) + syncedProgressTimer.stop() stopStub.restore() }) - it('should show position when stopped', function () { + it('should show last synced position if stopped while busy syncing', function () { + syncedProgressTimer.set(1000, 5000) syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCED - syncedProgressTimer._update(1000, 5000) + syncedProgressTimer._previousSyncPosition = 1000 syncedProgressTimer.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING syncedProgressTimer._update(2000, 5000) assert.equal(syncedProgressTimer.positionNode.nodeValue, '(sync)') syncedProgressTimer.stop() assert.equal(syncedProgressTimer.positionNode.nodeValue, '0:01') + expect($('#songelapsed').text()).to.equal('0:01') }) it('should cancel any scheduled syncs', function () { - syncedProgressTimer._scheduledSyncTime = 5000 + var cancelSpy = sinon.spy(window, 'clearTimeout') + + syncedProgressTimer._isSyncScheduled = true syncedProgressTimer.stop() - expect(syncedProgressTimer._scheduledSyncTime).to.be.null + + assert.isFalse(syncedProgressTimer._isSyncScheduled) + assert(cancelSpy.calledWith(syncedProgressTimer._scheduleID)) + cancelSpy.restore() }) }) describe('#reset()', function () { - it('should reset timer to 0:00 - (n/a) ', function () { - var resetStub = sinon.stub(syncedProgressTimer._progressTimer, 'reset') - var initStub = sinon.stub(syncedProgressTimer, 'init') + it('should reset timer to "" - "" ', function () { var stopStub = sinon.stub(syncedProgressTimer, 'stop') + var setStub = sinon.stub(syncedProgressTimer, 'set') syncedProgressTimer.reset() - assert(resetStub.called) - assert(initStub.called) assert(stopStub.called) + assert(setStub.called) - assert.equal(syncedProgressTimer.positionNode.nodeValue, '0:00') - assert.equal(syncedProgressTimer.durationNode.nodeValue, '(n/a)') + assert.equal(syncedProgressTimer.positionNode.nodeValue, '', 'Position node was not reset') + assert.equal(syncedProgressTimer.durationNode.nodeValue, '', 'Duration node was not reset') - resetStub.restore() - initStub.restore() stopStub.restore() + setStub.restore() }) }) @@ -345,8 +430,84 @@ describe('SyncedTimer', function () { syncedProgressTimer.updatePosition(1000) assert.isTrue(formatSpy.called) - expect(syncedProgressTimer.positionNode.nodeValue).to.endWith('0:01') + expect(syncedProgressTimer.positionNode.nodeValue).to.equal('0:01') formatSpy.restore() }) + + it('should set position to "" if timer has not been initialized', function () { + syncedProgressTimer.set(1000, 2000) + expect(syncedProgressTimer.positionNode.nodeValue).to.equal('0:01') + + syncedProgressTimer.updatePosition(0) + assert.equal(syncedProgressTimer.positionNode.nodeValue, '0:00', 'Position node was not reset') + + syncedProgressTimer.reset() + syncedProgressTimer.updatePosition(0) + assert.equal(syncedProgressTimer.positionNode.nodeValue, '', 'Position node was not reset') + }) + }) + + describe('integration tests', function () { + beforeEach(function () { + setFakeTimers() + }) + afterEach(function () { + restoreFakeTimers() + }) + + it('simulate 30-second test run, ', function () { + // Initialize + syncedProgressTimer.reset() + expect($('#songelapsed').text()).to.equal('') + expect($('#songlength').text()).to.equal('') + assert.equal($('#trackslider').val(), 0) + + // Set song info + syncedProgressTimer.set(0, 30000) + expect($('#songelapsed').text()).to.equal('0:00') + expect($('#songlength').text()).to.equal('0:30') + assert.equal($('#trackslider').val(), 0) + + // Start + syncedProgressTimer.start() + clock.tick(40) + expect($('#songelapsed').text()).to.equal('(wait)') + expect($('#songlength').text()).to.equal('0:30') + assert.equal($('#trackslider').val(), 0) + + // Syncing + clock.tick(250) + expect($('#songelapsed').text()).to.equal('(sync)') + expect($('#songlength').text()).to.equal('0:30') + assert.equal($('#trackslider').val(), 0) + + // Synced + clock.tick(1000) + expect($('#songelapsed').text()).to.equal('0:01') + expect($('#songlength').text()).to.equal('0:30') + assert.isAtLeast($('#trackslider').val(), 1000) + + // Move slider + $('#trackslider').trigger('slidestart') + clock.tick(250) + $('#trackslider').val(5000).slider('refresh') + $('#trackslider').trigger('change') + clock.tick(250) + $('#trackslider').trigger('slidestop') + + clock.tick(1000) // Position should remain '0:05' as the timer should not be running after a slider change + expect($('#songelapsed').text()).to.equal('0:05') + + // Start -> Sync -> Stop + syncedProgressTimer.start() + clock.tick(40) + expect($('#songelapsed').text()).to.equal('(sync)') + syncedProgressTimer._previousSyncPosition = 1000 + syncedProgressTimer.stop() + expect($('#songelapsed').text()).to.equal('0:01') + expect($('#songlength').text()).to.equal('0:30') + + syncedProgressTimer.stop() + }) }) })