diff --git a/src/util/timer.js b/src/util/timer.js index a5183ad11..fba3e94e9 100644 --- a/src/util/timer.js +++ b/src/util/timer.js @@ -95,9 +95,19 @@ class Timer { * Call a handler function after a specified amount of time has elapsed. * @param {function} handler - function to call after the timeout * @param {number} timeout - number of milliseconds to delay before calling the handler + * @returns {number} - the ID of the new timeout */ setTimeout (handler, timeout) { - global.setTimeout(handler, timeout); + return global.setTimeout(handler, timeout); + } + + /** + * Clear a timeout from the pending timeout pool. + * @param {number} timeoutId - the ID returned by `setTimeout()` + * @memberof Timer + */ + clearTimeout (timeoutId) { + global.clearTimeout(timeoutId); } } diff --git a/test/fixtures/mock-timer.js b/test/fixtures/mock-timer.js index 3c9a3c77d..4bcf53057 100644 --- a/test/fixtures/mock-timer.js +++ b/test/fixtures/mock-timer.js @@ -28,10 +28,18 @@ class MockTimer { this.startTime = 0; /** - * Array of pending timeout callbacks - * @type {Array.} + * The ID to use the next time `setTimeout` is called. + * @type {number} */ - this._timeouts = []; + this._nextTimeoutId = 1; + + /** + * Map of timeout ID to pending timeout callback info. + * @type {Map.} + * @property {number} time - the time at/after which this handler should run + * @property {Function} handler - the handler to call when the time comes + */ + this._timeouts = new Map(); } /** @@ -85,14 +93,35 @@ class MockTimer { * Guaranteed to happen in between "ticks" of JavaScript. * @param {function} handler - function to call after the timeout * @param {number} timeout - number of milliseconds to delay before calling the handler + * @returns {number} - the ID of the new timeout. * @memberof MockTimer */ setTimeout (handler, timeout) { - this._timeouts.push({ + const timeoutId = this._nextTimeoutId++; + this._timeouts.set(timeoutId, { time: this._mockTime + timeout, handler }); this._runTimeouts(); + return timeoutId; + } + + /** + * Clear a particular timeout from the pending timeout pool. + * @param {number} timeoutId - the value returned from `setTimeout()` + * @memberof MockTimer + */ + clearTimeout (timeoutId) { + this._timeouts.delete(timeoutId); + } + + /** + * WARNING: this method has no equivalent in `Timer`. Do not use this method outside of tests! + * @returns {boolean} - true if there are any pending timeouts, false otherwise. + * @memberof MockTimer + */ + hasTimeouts () { + return this._timeouts.size > 0; } /** @@ -101,15 +130,17 @@ class MockTimer { */ _runTimeouts () { const ready = []; - const waiting = []; - // partition timeout records by whether or not they're ready to call - this._timeouts.forEach(o => { - const isReady = o.time <= this._mockTime; - (isReady ? ready : waiting).push(o); + this._timeouts.forEach((timeoutRecord, timeoutId) => { + const isReady = timeoutRecord.time <= this._mockTime; + if (isReady) { + ready.push(timeoutRecord); + this._timeouts.delete(timeoutId); + } }); - this._timeouts = waiting; + // sort so that earlier timeouts run before later timeouts + ready.sort((a, b) => a.time < b.time); // next tick, call everything that's ready global.setTimeout(() => { diff --git a/test/unit/mock-timer.js b/test/unit/mock-timer.js new file mode 100644 index 000000000..e6815117a --- /dev/null +++ b/test/unit/mock-timer.js @@ -0,0 +1,89 @@ +const test = require('tap').test; +const MockTimer = require('../fixtures/mock-timer'); + +test('spec', t => { + const timer = new MockTimer(); + + t.type(MockTimer, 'function'); + t.type(timer, 'object'); + + // Most members of MockTimer mimic members of Timer. + t.type(timer.startTime, 'number'); + t.type(timer.time, 'function'); + t.type(timer.start, 'function'); + t.type(timer.timeElapsed, 'function'); + t.type(timer.setTimeout, 'function'); + t.type(timer.clearTimeout, 'function'); + + // A few members of MockTimer have no Timer equivalent and should only be used in tests. + t.type(timer.hasTimeouts, 'function'); + + t.end(); +}); + +test('time', t => { + const timer = new MockTimer(); + const delta = 1; + + const time1 = timer.time(); + const time2 = timer.time(); + timer.advanceMockTime(delta); + const time3 = timer.time(); + + t.equal(time1, time2); + t.equal(time2 + delta, time3); + t.end(); +}); + +test('start / timeElapsed', t => new Promise(resolve => { + const timer = new MockTimer(); + const halfDelay = 1; + const fullDelay = halfDelay + halfDelay; + + timer.start(); + + let timeoutCalled = 0; + + // Wait and measure timer + timer.setTimeout(() => { + t.equal(timeoutCalled, 0); + ++timeoutCalled; + + const timeElapsed = timer.timeElapsed(); + t.equal(timeElapsed, fullDelay); + t.end(); + + resolve(); + }, fullDelay); + + // this should not trigger the callback + timer.advanceMockTime(halfDelay); + + // give the mock timer a chance to run tasks + global.setTimeout(() => { + // we've only mock-waited for half the delay so it should not have run yet + t.equal(timeoutCalled, 0); + + // this should trigger the callback + timer.advanceMockTime(halfDelay); + }, 0); +})); + +test('clearTimeout / hasTimeouts', t => new Promise((resolve, reject) => { + const timer = new MockTimer(); + + const timeoutId = timer.setTimeout(() => { + reject(new Error('Canceled task ran')); + }, 1); + + timer.setTimeout(() => { + resolve('Non-canceled task ran'); + t.end(); + }, 2); + + timer.clearTimeout(timeoutId); + + while (timer.hasTimeouts()) { + timer.advanceMockTime(1); + } +})); diff --git a/test/unit/util_mock-timer.js b/test/unit/util_mock-timer.js deleted file mode 100644 index e050bf116..000000000 --- a/test/unit/util_mock-timer.js +++ /dev/null @@ -1,58 +0,0 @@ -const test = require('tap').test; -const MockTimer = require('../fixtures/mock-timer'); - -test('spec', t => { - const timer = new MockTimer(); - - t.type(MockTimer, 'function'); - t.type(timer, 'object'); - - t.type(timer.startTime, 'number'); - t.type(timer.time, 'function'); - t.type(timer.start, 'function'); - t.type(timer.timeElapsed, 'function'); - - t.end(); -}); - -test('time', t => { - const timer = new MockTimer(); - - const time1 = timer.time(); - const time2 = timer.time(); - timer.advanceMockTime(1); - const time3 = timer.time(); - - t.ok(time1 === time2); - t.ok(time2 < time3); - t.end(); -}); - -test('start / timeElapsed', t => { - const timer = new MockTimer(); - const halfDelay = 50; - const fullDelay = halfDelay + halfDelay; - - timer.start(); - - let timeoutCalled = 0; - - // Wait and measure timer - timer.setTimeout(() => { - t.ok(timeoutCalled === 0); - ++timeoutCalled; - - const timeElapsed = timer.timeElapsed(); - t.ok(timeElapsed === fullDelay); - - t.end(); - }, fullDelay); - - // this should not call the callback - timer.advanceMockTime(halfDelay); - - t.ok(timeoutCalled === 0); - - // this should call the callback - timer.advanceMockTime(halfDelay); -}); diff --git a/test/unit/util_timer.js b/test/unit/util_timer.js index d58ca0152..a6665fadd 100644 --- a/test/unit/util_timer.js +++ b/test/unit/util_timer.js @@ -11,6 +11,8 @@ test('spec', t => { t.type(timer.time, 'function'); t.type(timer.start, 'function'); t.type(timer.timeElapsed, 'function'); + t.type(timer.setTimeout, 'function'); + t.type(timer.clearTimeout, 'function'); t.end(); }); @@ -40,3 +42,15 @@ test('start / timeElapsed', t => { t.end(); }, delay); }); + +test('setTimeout / clearTimeout', t => new Promise((resolve, reject) => { + const timer = new Timer(); + const cancelId = timer.setTimeout(() => { + reject(new Error('Canceled task ran')); + }, 1); + timer.setTimeout(() => { + resolve('Non-canceled task ran'); + t.end(); + }, 2); + timer.clearTimeout(cancelId); +}));