diff options
Diffstat (limited to 'addons/mail/static/src/utils/timer')
| -rw-r--r-- | addons/mail/static/src/utils/timer/timer.js | 165 | ||||
| -rw-r--r-- | addons/mail/static/src/utils/timer/timer_tests.js | 427 |
2 files changed, 592 insertions, 0 deletions
diff --git a/addons/mail/static/src/utils/timer/timer.js b/addons/mail/static/src/utils/timer/timer.js new file mode 100644 index 00000000..56d7f58e --- /dev/null +++ b/addons/mail/static/src/utils/timer/timer.js @@ -0,0 +1,165 @@ +odoo.define('mail/static/src/utils/timer/timer.js', function (require) { +'use strict'; + +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); + +//------------------------------------------------------------------------------ +// Errors +//------------------------------------------------------------------------------ + +/** + * List of Timer errors. + */ + + /** + * Error when timer has been cleared with `.clear()` or `.reset()`. Used to + * let know caller of timer that the countdown has been aborted, which + * means the inner function will not be called. Usually caller should just + * accept it and kindly treated this error as a polite warning. + */ + class TimerClearedError extends Error { + /** + * @override + */ + constructor(timerId, ...args) { + super(...args); + this.name = 'TimerClearedError'; + this.timerId = timerId; + } +} + +//------------------------------------------------------------------------------ +// Private +//------------------------------------------------------------------------------ + +/** + * This class creates a timer which, when times out, calls a function. + * Note that the timer is not started on initialization (@see start method). + */ +class Timer { + + /** + * @param {Object} env the OWL env + * @param {function} onTimeout + * @param {integer} duration + * @param {Object} [param3={}] + * @param {boolean} [param3.silentCancelationErrors=true] if unset, caller + * of timer will observe some errors that come from current timer calls + * that has been cleared with `.clear()` or `.reset()`. + * @see TimerClearedError for when timer has been aborted from `.clear()` + * or `.reset()`. + */ + constructor(env, onTimeout, duration, { silentCancelationErrors = true } = {}) { + this.env = env; + /** + * Determine whether the timer has a pending timeout. + */ + this.isRunning = false; + /** + * Duration, in milliseconds, until timer times out and calls the + * timeout function. + */ + this._duration = duration; + /** + * Determine whether the caller of timer `.start()` and `.reset()` + * should observe cancelation errors from `.clear()` or `.reset()`. + */ + this._hasSilentCancelationErrors = silentCancelationErrors; + /** + * The function that is called when the timer times out. + */ + this._onTimeout = onTimeout; + /** + * Deferred of a currently pending invocation to inner function on + * timeout. + */ + this._timeoutDeferred = undefined; + /** + * Internal reference of `setTimeout()` that is used to invoke function + * when timer times out. Useful to clear it when timer is cleared/reset. + */ + this._timeoutId = undefined; + } + + //-------------------------------------------------------------------------- + // Public + //-------------------------------------------------------------------------- + + /** + * Clear the timer, which basically sets the state of timer as if it was + * just instantiated, without being started. This function makes sense only + * when this timer is running. + */ + clear() { + this.env.browser.clearTimeout(this._timeoutId); + this.isRunning = false; + if (!this._timeoutDeferred) { + return; + } + this._timeoutDeferred.reject(new TimerClearedError(this.id)); + } + + /** + * Reset the timer, i.e. the pending timeout is refreshed with initial + * duration. This function makes sense only when this timer is running. + */ + async reset() { + this.clear(); + await this.start(); + } + + /** + * Starts the timer, i.e. after a certain duration, it times out and calls + * a function back. This function makes sense only when this timer is not + * yet running. + * + * @throws {Error} in case the timer is already running. + */ + async start() { + if (this.isRunning) { + throw new Error("Cannot start a timer that is currently running."); + } + this.isRunning = true; + const timeoutDeferred = makeDeferred(); + this._timeoutDeferred = timeoutDeferred; + const timeoutId = this.env.browser.setTimeout( + () => { + this.isRunning = false; + timeoutDeferred.resolve(this._onTimeout()); + }, + this._duration + ); + this._timeoutId = timeoutId; + let result; + try { + result = await timeoutDeferred; + } catch (error) { + if ( + !this._hasSilentCancelationErrors || + !(error instanceof TimerClearedError) || + error.timerId !== this.id + ) { + // This branching should never happens. + // Still defined in case of programming error. + throw error; + } + } finally { + this.env.browser.clearTimeout(timeoutId); + this._timeoutDeferred = undefined; + this.isRunning = false; + } + return result; + } + +} + +/** + * Make external timer errors accessible from timer class. + */ +Object.assign(Timer, { + TimerClearedError, +}); + +return Timer; + +}); diff --git a/addons/mail/static/src/utils/timer/timer_tests.js b/addons/mail/static/src/utils/timer/timer_tests.js new file mode 100644 index 00000000..e2d33e91 --- /dev/null +++ b/addons/mail/static/src/utils/timer/timer_tests.js @@ -0,0 +1,427 @@ +odoo.define('mail/static/src/utils/timer/timer_tests.js', function (require) { +'use strict'; + +const { afterEach, beforeEach, nextTick, start } = require('mail/static/src/utils/test_utils.js'); +const Timer = require('mail/static/src/utils/timer/timer.js'); + +const { TimerClearedError } = Timer; + +QUnit.module('mail', {}, function () { +QUnit.module('utils', {}, function () { +QUnit.module('timer', {}, function () { +QUnit.module('timer_tests.js', { + beforeEach() { + beforeEach(this); + this.timers = []; + + this.start = async (params) => { + const { env, widget } = await start(Object.assign({}, params, { + data: this.data, + })); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + // Important: tests should cleanly intercept cancelation errors that + // may result from this teardown. + for (const timer of this.timers) { + timer.clear(); + } + afterEach(this); + }, +}); + +QUnit.test('timer does not timeout on initialization', async function (assert) { + assert.expect(3); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 0 + ) + ); + + assert.notOk( + hasTimedOut, + "timer should not have timed out on immediate initialization" + ); + + await this.env.testUtils.advanceTime(0); + assert.notOk( + hasTimedOut, + "timer should not have timed out from initialization after 0ms" + ); + + await this.env.testUtils.advanceTime(1000 * 1000); + assert.notOk( + hasTimedOut, + "timer should not have timed out from initialization after 1000s" + ); +}); + +QUnit.test('timer start (duration: 0ms)', async function (assert) { + assert.expect(2); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 0 + ) + ); + + this.timers[0].start(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + + await this.env.testUtils.advanceTime(0); + assert.ok( + hasTimedOut, + "timer should have timed out on start after 0ms" + ); +}); + +QUnit.test('timer start observe termination (duration: 0ms)', async function (assert) { + assert.expect(6); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => { + hasTimedOut = true; + return 'timeout_result'; + }, + 0 + ) + ); + + this.timers[0].start() + .then(result => { + assert.strictEqual( + result, + 'timeout_result', + "value returned by start should be value returned by function on timeout" + ); + assert.step('timeout'); + }); + await nextTick(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + assert.verifySteps( + [], + "timer.start() should not have yet observed timeout" + ); + + await this.env.testUtils.advanceTime(0); + assert.ok( + hasTimedOut, + "timer should have timed out on start after 0ms" + ); + assert.verifySteps( + ['timeout'], + "timer.start() should have observed timeout after 0ms" + ); +}); + +QUnit.test('timer start (duration: 1000s)', async function (assert) { + assert.expect(5); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 1000 * 1000 + ) + ); + + this.timers[0].start(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + + await this.env.testUtils.advanceTime(0); + assert.notOk( + hasTimedOut, + "timer should not have timed out on start after 0ms" + ); + + await this.env.testUtils.advanceTime(1000); + assert.notOk( + hasTimedOut, + "timer should not have timed out on start after 1000ms" + ); + + await this.env.testUtils.advanceTime(998 * 1000 + 999); + assert.notOk( + hasTimedOut, + "timer should not have timed out on start after 9999ms" + ); + + await this.env.testUtils.advanceTime(1); + assert.ok( + hasTimedOut, + "timer should have timed out on start after 10s" + ); +}); + +QUnit.test('[no cancelation intercept] timer start then immediate clear (duration: 0ms)', async function (assert) { + assert.expect(4); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 0 + ) + ); + + this.timers[0].start(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + + this.timers[0].clear(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start and clear" + ); + + await this.env.testUtils.advanceTime(0); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 0ms of clear" + ); + + await this.env.testUtils.advanceTime(1000); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 1s of clear" + ); +}); + +QUnit.test('[no cancelation intercept] timer start then clear before timeout (duration: 1000ms)', async function (assert) { + assert.expect(4); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 1000 + ) + ); + + this.timers[0].start(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + + await this.env.testUtils.advanceTime(999); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after 999ms of start" + ); + + this.timers[0].clear(); + await this.env.testUtils.advanceTime(1); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 1ms of clear that happens 999ms after start (globally 1s await)" + ); + + await this.env.testUtils.advanceTime(1000); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 1001ms after clear (timer fully cleared)" + ); +}); + +QUnit.test('[no cancelation intercept] timer start then reset before timeout (duration: 1000ms)', async function (assert) { + assert.expect(5); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 1000 + ) + ); + + this.timers[0].start(); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + + await this.env.testUtils.advanceTime(999); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 999ms of start" + ); + + this.timers[0].reset(); + await this.env.testUtils.advanceTime(1); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 1ms of reset which happens 999ms after start" + ); + + await this.env.testUtils.advanceTime(998); + assert.notOk( + hasTimedOut, + "timer should not have timed out after 999ms of reset" + ); + + await this.env.testUtils.advanceTime(1); + assert.ok( + hasTimedOut, + "timer should not have timed out after 1s of reset" + ); +}); + +QUnit.test('[with cancelation intercept] timer start then immediate clear (duration: 0ms)', async function (assert) { + assert.expect(5); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 0, + { silentCancelationErrors: false } + ) + ); + + this.timers[0].start() + .then(() => { + throw new Error("timer.start() should not be resolved (should have been canceled by clear)"); + }) + .catch(error => { + assert.ok( + error instanceof TimerClearedError, + "Should generate a Timer cleared error (from `.clear()`)" + ); + assert.step('timer_cleared'); + }); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + await nextTick(); + assert.verifySteps([], "should not have observed cleared timer (timer not yet cleared)"); + + this.timers[0].clear(); + await nextTick(); + assert.verifySteps( + ['timer_cleared'], + "timer.start() should have observed it has been cleared" + ); +}); + +QUnit.test('[with cancelation intercept] timer start then immediate reset (duration: 0ms)', async function (assert) { + assert.expect(9); + + await this.start({ + hasTimeControl: true, + }); + + let hasTimedOut = false; + this.timers.push( + new Timer( + this.env, + () => hasTimedOut = true, + 0, + { silentCancelationErrors: false } + ) + ); + + this.timers[0].start() + .then(() => { + throw new Error("timer.start() should not observe a timeout"); + }) + .catch(error => { + assert.ok(error instanceof TimerClearedError, "Should generate a Timer cleared error (from `.reset()`)"); + assert.step('timer_cleared'); + }); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after start" + ); + await nextTick(); + assert.verifySteps([], "should not have observed cleared timer (timer not yet cleared)"); + + this.timers[0].reset() + .then(() => assert.step('timer_reset_timeout')); + await nextTick(); + assert.verifySteps( + ['timer_cleared'], + "timer.start() should have observed it has been cleared" + ); + assert.notOk( + hasTimedOut, + "timer should not have timed out immediately after reset" + ); + + await this.env.testUtils.advanceTime(0); + assert.ok( + hasTimedOut, + "timer should have timed out after reset timeout" + ); + assert.verifySteps( + ['timer_reset_timeout'], + "timer.reset() should have observed it has timed out" + ); +}); + +}); +}); +}); + +}); |
