diff --git a/addons/mail/static/src/models/thread.js b/addons/mail/static/src/models/thread.js index 757358fc3f8..a4cae1caae1 100644 --- a/addons/mail/static/src/models/thread.js +++ b/addons/mail/static/src/models/thread.js @@ -4,7 +4,6 @@ import { registerModel } from '@mail/model/model_core'; import { attr, many, one } from '@mail/model/model_field'; import { clear, insert, insertAndReplace, insertAndUnlink, link, replace, unlink } from '@mail/model/model_field_command'; import { OnChange } from '@mail/model/model_onchange'; -import throttle from '@mail/utils/throttle'; import { cleanSearchTerm } from '@mail/utils/utils'; import * as mailUtils from '@mail/js/utils'; @@ -26,7 +25,6 @@ registerModel({ identifyingFields: ['model', 'id'], lifecycleHooks: { _willDelete() { - this.throttleNotifyCurrentPartnerTypingStatus.clear(); if (this.isTemporary) { for (const message of this.messages) { message.delete(); @@ -914,15 +912,17 @@ registerModel({ }); // Manage typing member relation. const currentPartner = this.messaging.currentPartner; - const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds - .filter(localId => localId !== currentPartner.localId); - newOrderedTypingMemberLocalIds.push(currentPartner.localId); + const newOrderedTypingMembers = [ + ...this.orderedTypingMembers.filter(member => member !== currentPartner), + currentPartner, + ]; this.update({ - orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds, + isCurrentPartnerTyping: true, + orderedTypingMembers: replace(newOrderedTypingMembers), typingMembers: link(currentPartner), }); // Notify typing status to other members. - await this.throttleNotifyCurrentPartnerTypingStatus({ isTyping: true }); + await this.throttleNotifyCurrentPartnerTypingStatus.do(); }, /** * Called to register a new other member partner that is typing @@ -932,11 +932,12 @@ registerModel({ */ registerOtherMemberTypingMember(partner) { this.update({ otherMembersLongTypingTimers: insert({ partner: replace(partner) }) }); - const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds - .filter(localId => localId !== partner.localId); - newOrderedTypingMemberLocalIds.push(partner.localId); + const newOrderedTypingMembers = [ + ...this.orderedTypingMembers.filter(member => member !== partner), + partner, + ]; this.update({ - orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds, + orderedTypingMembers: replace(newOrderedTypingMembers), typingMembers: link(partner), }); }, @@ -1005,17 +1006,17 @@ registerModel({ }); // Manage typing member relation. const currentPartner = this.messaging.currentPartner; - const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds - .filter(localId => localId !== currentPartner.localId); + const newOrderedTypingMembers = this.orderedTypingMembers.filter(member => member !== currentPartner); this.update({ - orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds, + isCurrentPartnerTyping: false, + orderedTypingMembers: replace(newOrderedTypingMembers), typingMembers: unlink(currentPartner), }); // Notify typing status to other members. if (immediateNotify) { this.throttleNotifyCurrentPartnerTypingStatus.clear(); } - this.throttleNotifyCurrentPartnerTypingStatus({ isTyping: false }); + this.throttleNotifyCurrentPartnerTypingStatus.do(); }, /** * Called to unregister an other member partner that is no longer typing @@ -1025,10 +1026,9 @@ registerModel({ */ unregisterOtherMemberTypingMember(partner) { this.update({ otherMembersLongTypingTimers: insertAndUnlink({ partner: replace(partner) }) }); - const newOrderedTypingMemberLocalIds = this.orderedTypingMemberLocalIds - .filter(localId => localId !== partner.localId); + const newOrderedTypingMembers = this.orderedTypingMembers.filter(member => member !== partner); this.update({ - orderedTypingMemberLocalIds: newOrderedTypingMemberLocalIds, + orderedTypingMembers: replace(newOrderedTypingMembers), typingMembers: unlink(partner), }); }, @@ -1116,6 +1116,9 @@ registerModel({ if (!this.isPinned) { return clear(); } + if (!this.messaging.discuss) { + return clear(); + } const discussSidebarCategory = this._getDiscussSidebarCategory(); if (!discussSidebarCategory) { return clear(); @@ -1508,18 +1511,6 @@ registerModel({ member => member !== this.messaging.currentPartner )); }, - /** - * @private - * @returns {Partner[]} - */ - _computeOrderedTypingMembers() { - return [[ - 'replace', - this.orderedTypingMemberLocalIds - .map(localId => this.messaging.models['Partner'].get(localId)) - .filter(member => !!member), - ]]; - }, /** * @private * @returns {Activity[]} @@ -1542,11 +1533,9 @@ registerModel({ * @returns {Throttle} */ _computeThrottleNotifyCurrentPartnerTypingStatus() { - return throttle( - this.messaging, - ({ isTyping }) => this._notifyCurrentPartnerTypingStatus({ isTyping }), - 2.5 * 1000 - ); + return insertAndReplace({ + func: () => this._notifyCurrentPartnerTypingStatus(), + }); }, /** * @private @@ -1627,31 +1616,29 @@ registerModel({ }, /** * @private - * @param {Object} param0 - * @param {boolean} param0.isTyping */ - async _notifyCurrentPartnerTypingStatus({ isTyping }) { + async _notifyCurrentPartnerTypingStatus() { if ( this.forceNotifyNextCurrentPartnerTypingStatus || - isTyping !== this.currentPartnerLastNotifiedIsTyping + this.isCurrentPartnerTyping !== this.currentPartnerLastNotifiedIsTyping ) { if (this.model === 'mail.channel') { await this.messaging.rpc({ model: 'mail.channel', method: 'notify_typing', args: [this.id], - kwargs: { is_typing: isTyping }, + kwargs: { is_typing: this.isCurrentPartnerTyping }, }, { shadow: true }); if (!this.exists()) { return; } } - if (isTyping && this.currentPartnerLongTypingTimer) { + if (this.isCurrentPartnerTyping && this.currentPartnerLongTypingTimer) { this.update({ currentPartnerLongTypingTimer: [clear(), insertAndReplace()] }); } } this.update({ - currentPartnerLastNotifiedIsTyping: isTyping, + currentPartnerLastNotifiedIsTyping: this.isCurrentPartnerTyping, forceNotifyNextCurrentPartnerTypingStatus: false, }); }, @@ -1795,9 +1782,12 @@ registerModel({ * Immediately notify other members that he/she is still typing. */ async onCurrentPartnerLongTypingTimeout() { - this.update({ forceNotifyNextCurrentPartnerTypingStatus: true }); + this.update({ + isCurrentPartnerTyping: true, + forceNotifyNextCurrentPartnerTypingStatus: true, + }); this.throttleNotifyCurrentPartnerTypingStatus.clear(); - await this.throttleNotifyCurrentPartnerTypingStatus({ isTyping: true }); + await this.throttleNotifyCurrentPartnerTypingStatus.do(); }, }, fields: { @@ -2052,6 +2042,9 @@ registerModel({ compute: '_computeIsCurrentPartnerFollowing', default: false, }), + isCurrentPartnerTyping: attr({ + default: false, + }), /** * States whether this thread description is editable by the current user. */ @@ -2248,19 +2241,9 @@ registerModel({ compute: '_computeOrderedOtherTypingMembers', }), /** - * Ordered typing members on this thread. Lower index means this member - * is currently typing for the longest time. This list includes current - * partner as typer. + * Ordered list of typing members. */ - orderedTypingMembers: many('Partner', { - compute: '_computeOrderedTypingMembers', - }), - /** - * Technical attribute to manage ordered list of typing members. - */ - orderedTypingMemberLocalIds: attr({ - default: [], - }), + orderedTypingMembers: many('Partner'), originThreadAttachments: many('Attachment', { inverse: 'originThread', isCausal: true, @@ -2405,8 +2388,10 @@ registerModel({ * * @see _notifyCurrentPartnerTypingStatus */ - throttleNotifyCurrentPartnerTypingStatus: attr({ + throttleNotifyCurrentPartnerTypingStatus: one('Throttle', { compute: '_computeThrottleNotifyCurrentPartnerTypingStatus', + inverse: 'threadAsThrottleNotifyCurrentPartnerTypingStatus', + isCausal: true, }), /** * States the `Activity` that belongs to `this` and that are due diff --git a/addons/mail/static/src/models/throttle.js b/addons/mail/static/src/models/throttle.js new file mode 100644 index 00000000000..166e896bcfb --- /dev/null +++ b/addons/mail/static/src/models/throttle.js @@ -0,0 +1,80 @@ +/** @odoo-module **/ + +import { registerModel } from '@mail/model/model_core'; +import { attr, one } from '@mail/model/model_field'; +import { clear, insertAndReplace } from '@mail/model/model_field_command'; + +/** + * This model defines a "Throttle", which is an abstraction to throttle calls on a + * provided function. Such throttled calls can be cleared, i.e it can simply stop + * cooling down phase and clear pending func invocation. This allows having throttled + * calls most of the time, and a few priviledged exception to immediately make the call + * are re-trigger a cooldown like a fresh throttle call. + */ +registerModel({ + name: 'Throttle', + identifyingFields: [[ + 'threadAsThrottleNotifyCurrentPartnerTypingStatus', + ]], + recordMethods: { + /** + * Clear any buffered function call and immediately terminates any cooling + * down phase in progress. + */ + clear() { + this.update({ + cooldownTimer: clear(), + shouldInvoke: false, + }); + }, + async do() { + if (!this.cooldownTimer) { + this.func(); + this.update({ cooldownTimer: insertAndReplace() }); + } else { + this.update({ shouldInvoke: true }); + } + }, + onTimeout() { + if (this.shouldInvoke) { + this.func(); + } + this.update({ shouldInvoke: false }); + }, + /** + * @private + * @returns {integer|FieldCommand} + */ + _computeDuration() { + if (this.threadAsThrottleNotifyCurrentPartnerTypingStatus) { + return 2.5 * 1000; + } + return clear(); + }, + }, + fields: { + cooldownTimer: one('Timer', { + inverse: 'throttleOwner', + isCausal: true, + }), + /** + * Duration, in milliseconds, of the cool down phase. + */ + duration: attr({ + compute: '_computeDuration', + readonly: true, + required: true, + }), + /** + * Inner function to be invoked and throttled. + */ + func: attr(), + shouldInvoke: attr({ + default: false, + }), + threadAsThrottleNotifyCurrentPartnerTypingStatus: one('Thread', { + inverse: 'throttleNotifyCurrentPartnerTypingStatus', + readonly: true, + }), + }, +}); diff --git a/addons/mail/static/src/models/timer.js b/addons/mail/static/src/models/timer.js index 64c469f708a..b4bddbd191c 100644 --- a/addons/mail/static/src/models/timer.js +++ b/addons/mail/static/src/models/timer.js @@ -10,6 +10,7 @@ registerModel({ 'otherMemberLongTypingInThreadTimerOwner', 'threadAsCurrentPartnerInactiveTypingTimerOwner', 'threadAsCurrentPartnerLongTypingTimerOwner', + 'throttleOwner', ]], lifecycleHooks: { _willDelete() { @@ -31,6 +32,9 @@ registerModel({ if (this.otherMemberLongTypingInThreadTimerOwner) { return 60 * 1000; } + if (this.throttleOwner) { + return this.throttleOwner.duration; + } return clear(); }, /** @@ -71,6 +75,10 @@ registerModel({ this.otherMemberLongTypingInThreadTimerOwner.onOtherMemberLongTypingTimeout(); return; } + if (this.throttleOwner) { + this.throttleOwner.onTimeout(); + return; + } }, }, fields: { @@ -96,6 +104,10 @@ registerModel({ inverse: 'currentPartnerLongTypingTimer', readonly: true, }), + throttleOwner: one('Throttle', { + inverse: 'cooldownTimer', + readonly: true, + }), /** * Internal reference of `setTimeout()` that is used to invoke function * when timer times out. Useful to clear it when timer is cleared/reset. diff --git a/addons/mail/static/src/utils/throttle.js b/addons/mail/static/src/utils/throttle.js deleted file mode 100644 index 72554990faa..00000000000 --- a/addons/mail/static/src/utils/throttle.js +++ /dev/null @@ -1,379 +0,0 @@ -/** @odoo-module **/ - -import { makeDeferred } from '@mail/utils/deferred'; - -/** - * This module define an utility function that enables throttling calls on a - * provided function. Such throttled calls can be canceled, flushed and/or - * cleared: - * - * - cancel: Canceling a throttle function call means that if a function call is - * pending invocation, cancel removes this pending call invocation. It however - * preserves the internal timer of the cooling down phase of this throttle - * function, meaning that any following throttle function call will be pending - * and has to wait for the remaining time of the cooling down phase before - * being invoked. - * - * - flush: Flushing a throttle function call means that if a function call is - * pending invocation, flush immediately terminates the cooling down phase and - * the pending function call is immediately invoked. Flush also works without - * any pending function call: it just terminates the cooling down phase, so - * that a following function call is guaranteed to be immediately called. - * - * - clear: Clearing a throttle function combines canceling and flushing - * together. - */ - -//------------------------------------------------------------------------------ -// Errors -//------------------------------------------------------------------------------ - -/** - * List of internal and external Throttle errors. - * Internal errors are prefixed with `_`. - */ - - /** - * Error when throttle function has been canceled with `.cancel()`. Used to - * let the caller know of throttle function that the call has been canceled, - * which means the inner function will not be called. Usually caller should - * just accept it and kindly treat this error as a polite warning. - */ -class ThrottleCanceledError extends Error { - /** - * @override - */ - constructor(throttleId, ...args) { - super(...args); - this.name = 'ThrottleCanceledError'; - this.throttleId = throttleId; - } -} -/** - * Error when throttle function has been reinvoked again. Used to let know - * caller of throttle function that the call has been canceled and replaced with - * another one, which means the (potentially) following inner function will be - * in the context of another call. Same as for `ThrottleCanceledError`, usually - * caller should just accept it and kindly treat this error as a polite - * warning. - */ -class ThrottleReinvokedError extends Error { - /** - * @override - */ - constructor(throttleId, ...args) { - super(...args); - this.name = 'ThrottleReinvokedError'; - this.throttleId = throttleId; - } -} -/** - * Error when throttle function has been flushed with `.flush()`. Used - * internally to immediately invoke pending inner functions, since a flush means - * the termination of cooling down phase. - * - * @private - */ -class _ThrottleFlushedError extends Error { - /** - * @override - */ - constructor(throttleId, ...args) { - super(...args); - this.name = '_ThrottleFlushedError'; - this.throttleId = throttleId; - } -} - -//------------------------------------------------------------------------------ -// Private -//------------------------------------------------------------------------------ - -/** - * This class models the behaviour of the cancelable, flushable and clearable - * throttle version of a provided function. See definitions at the top of this - * file. - */ -class Throttle { - - /** - * @param {Messaging} messaging the messaging record. - * @param {function} func provided function for making throttled version. - * @param {integer} duration duration of the 'cool down' phase, i.e. - * the minimum duration between the most recent function call that has - * been made and the following function call (of course, assuming no flush - * in-between). - */ - constructor(messaging, func, duration) { - /** - * Reference to the messaging record. Useful to fine-tune control of - * time flow in tests. - * @see mail/static/src/utils/test_utils.js:start.hasTimeControl - */ - this.messaging = messaging; - /** - * Unique id of this throttle function. Useful for the ThrottleError - * management, in order to determine whether these errors come from - * this throttle or from another one (e.g. inner function makes use of - * another throttle). - */ - this.id = _.uniqueId('throttle_'); - /** - * Deferred of current cooling down phase in progress. Defined only when - * there is a cooling down phase in progress. Resolved when cooling down - * phase terminates from timeout, and rejected if flushed. - * - * @see _ThrottleFlushedError for rejection of this deferred. - */ - this._coolingDownDeferred = undefined; - /** - * Duration, in milliseconds, of the cool down phase. - */ - this._duration = duration; - /** - * Inner function to be invoked and throttled. - */ - this._function = func; - /** - * Determines whether the throttle function is currently in cool down - * phase. Cool down phase happens just after inner function has been - * invoked, and during this time any following function call are pending - * and will be invoked only after the end of the cool down phase (except - * if canceled). - */ - this._isCoolingDown = false; - /** - * Deferred of a currently pending invocation to inner function. Defined - * only during a cooling down phase and just after when throttle - * function has been called during this cooling down phase. It is kept - * until cooling down phase ends (either from timeout or flushed - * throttle) or until throttle is canceled (i.e. removes pending invoke - * while keeping cooling down phase live on). - */ - this._pendingInvokeDeferred = undefined; - } - - //-------------------------------------------------------------------------- - // Public - //-------------------------------------------------------------------------- - - /** - * Cancel any buffered function call while keeping the cooldown phase - * running. - */ - cancel() { - if (!this._isCoolingDown) { - return; - } - if (!this._pendingInvokeDeferred) { - return; - } - this._pendingInvokeDeferred.reject(new ThrottleCanceledError(this.id)); - } - - /** - * Clear any buffered function call and immediately terminates any cooling - * down phase in progress. - */ - clear() { - this.cancel(); - this.flush(); - } - - /** - * Called when there is a call to the function. This function is throttled, - * so the time it is called depends on whether the "cooldown stage" occurs - * or not: - * - * - no cooldown stage: function is called immediately, and it starts - * the cooldown stage when successful. - * - in cooldown stage: function is called when the cooldown stage has - * ended from timeout. - * - * Note that after the cooldown stage, only the last attempted function - * call will be considered. - * - * @param {...any} args - * @throws {ThrottleReinvokedError|ThrottleCanceledError} - * @returns {any} result of called function, if it's called. - */ - async do(...args) { - if (!this._isCoolingDown) { - return this._invokeFunction(...args); - } - if (this._pendingInvokeDeferred) { - this._pendingInvokeDeferred.reject(new ThrottleReinvokedError(this.id)); - } - try { - this._pendingInvokeDeferred = makeDeferred(); - await Promise.race([this._coolingDownDeferred, this._pendingInvokeDeferred]); - } catch (error) { - if ( - !(error instanceof _ThrottleFlushedError) || - error.throttleId !== this.id - ) { - throw error; - } - } finally { - this._pendingInvokeDeferred = undefined; - } - return this._invokeFunction(...args); - } - - /** - * Flush the internal throttle timer, so that the following function call - * is immediate. For instance, if there is a cooldown stage, it is aborted. - */ - flush() { - if (!this._isCoolingDown) { - return; - } - const coolingDownDeferred = this._coolingDownDeferred; - this._coolingDownDeferred = undefined; - this._isCoolingDown = false; - coolingDownDeferred.reject(new _ThrottleFlushedError(this.id)); - } - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * Invoke the inner function of this throttle and starts cooling down phase - * immediately after. - * - * @private - * @param {...any} args - */ - _invokeFunction(...args) { - const res = this._function(...args); - this._startCoolingDown(); - return res; - } - - /** - * Called just when the inner function is being called. Starts the cooling - * down phase, which turn any call to this throttle function as pending - * inner function calls. This will be called after the end of cooling down - * phase (except if canceled). - */ - async _startCoolingDown() { - if (this._coolingDownDeferred) { - throw new Error("Cannot start cooling down if there's already a cooling down in progress."); - } - // Keep local reference of cooling down deferred, because the one stored - // on `this` could be overwritten by another call to this throttle. - const coolingDownDeferred = makeDeferred(); - this._coolingDownDeferred = coolingDownDeferred; - this._isCoolingDown = true; - const cooldownTimeoutId = this.messaging.browser.setTimeout( - () => coolingDownDeferred.resolve(), - this._duration - ); - let unexpectedError; - try { - await coolingDownDeferred; - } catch (error) { - if ( - !(error instanceof _ThrottleFlushedError) || - error.throttleId !== this.id - ) { - // This branching should never happen. - // Still defined in case of programming error. - unexpectedError = error; - } - } finally { - this.messaging.browser.clearTimeout(cooldownTimeoutId); - this._coolingDownDeferred = undefined; - this._isCoolingDown = false; - } - if (unexpectedError) { - throw unexpectedError; - } - } - -} - -//------------------------------------------------------------------------------ -// Public -//------------------------------------------------------------------------------ - -/** - * A function that creates a cancelable, flushable and clearable throttle - * version of a provided function. See definitions at the top of this file. - * - * This throttle mechanism allows calling a function at most once during a - * certain period: - * - * - When a function call is made, it enters a 'cooldown' phase, in which any - * attempt to call the function is buffered until the cooldown phase ends. - * - At most 1 function call can be buffered during the cooldown phase, and the - * latest one in this phase will be considered at its end. - * - When a cooldown phase ends, any buffered function call will be performed - * and another cooldown phase will follow up. - * - * @param {Messaging} messaging the messaging record. - * @param {function} func the function to throttle. - * @param {integer} duration duration, in milliseconds, of the cooling down - * phase of the throttling. - * @param {Object} [param2={}] - * @param {boolean} [param2.silentCancelationErrors=true] if unset, caller - * of throttle function will observe some errors that come from current - * throttle call that has been canceled, such as when throttle function has - * been explicitly canceled with `.cancel()` or when another new throttle call - * has been registered. - * @see ThrottleCanceledError for when a call has been canceled from explicit - * call. - * @see ThrottleReinvokedError for when a call has been canceled from another - * new throttle call has been registered. - * @returns {function} the cancelable, flushable and clearable throttle version - * of the provided function. - */ -function throttle( - messaging, - func, - duration, - { silentCancelationErrors = true } = {} -) { - const throttleObj = new Throttle(messaging, func, duration); - const callable = async (...args) => { - try { - // await is important, otherwise errors are not intercepted. - return await throttleObj.do(...args); - } catch (error) { - const isSelfReinvokedError = ( - error instanceof ThrottleReinvokedError && - error.throttleId === throttleObj.id - ); - const isSelfCanceledError = ( - error instanceof ThrottleCanceledError && - error.throttleId === throttleObj.id - ); - - if (silentCancelationErrors && (isSelfReinvokedError || isSelfCanceledError)) { - // Silently ignore cancelation errors. - // Promise is indefinitely pending for async functions. - return new Promise(() => {}); - } else { - throw error; - } - } - }; - Object.assign(callable, { - cancel: () => throttleObj.cancel(), - clear: () => throttleObj.clear(), - flush: () => throttleObj.flush(), - }); - return callable; -} - -/** - * Make external throttle errors accessible from throttle function. - */ -Object.assign(throttle, { - ThrottleReinvokedError, - ThrottleCanceledError, -}); - - -export default throttle; diff --git a/addons/mail/static/tests/models/qunit_test.js b/addons/mail/static/tests/models/qunit_test.js index 290e1ee60a6..b3766ad3020 100644 --- a/addons/mail/static/tests/models/qunit_test.js +++ b/addons/mail/static/tests/models/qunit_test.js @@ -34,6 +34,14 @@ registerModel({ inverse: 'qunitTest', isCausal: true, }), + throttle1: one('Throttle', { + inverse: 'qunitTestOwner1', + isCausal: true, + }), + throttle2: one('Throttle', { + inverse: 'qunitTestOwner2', + isCausal: true, + }), timer1: one('Timer', { inverse: 'qunitTestOwner1', isCausal: true, diff --git a/addons/mail/static/tests/models/throttle_qunit_tests.js b/addons/mail/static/tests/models/throttle_qunit_tests.js new file mode 100644 index 00000000000..55784555555 --- /dev/null +++ b/addons/mail/static/tests/models/throttle_qunit_tests.js @@ -0,0 +1,34 @@ +/** @odoo-module **/ + +import { addFields, patchIdentifyingFields, patchRecordMethods } from '@mail/model/model_core'; +import { one } from '@mail/model/model_field'; +// ensure that the model definition is loaded before the patch +import '@mail/models/throttle'; + +addFields('Throttle', { + qunitTestOwner1: one('QUnitTest', { + inverse: 'throttle1', + readonly: true, + }), + qunitTestOwner2: one('QUnitTest', { + inverse: 'throttle2', + readonly: true, + }), +}); + +patchIdentifyingFields('Throttle', identifyingFields => { + identifyingFields[0].push('qunitTestOwner1'); + identifyingFields[0].push('qunitTestOwner2'); +}); + +patchRecordMethods('Throttle', { + _computeDuration() { + if (this.qunitTestOwner1) { + return 0; + } + if (this.qunitTestOwner2) { + return 1000; + } + return this._super(); + }, +}); diff --git a/addons/mail/static/tests/qunit_suite_tests/utils/throttle_tests.js b/addons/mail/static/tests/qunit_suite_tests/utils/throttle_tests.js index 991d150c509..14f8c3df787 100644 --- a/addons/mail/static/tests/qunit_suite_tests/utils/throttle_tests.js +++ b/addons/mail/static/tests/qunit_suite_tests/utils/throttle_tests.js @@ -1,45 +1,25 @@ /** @odoo-module **/ import { start } from '@mail/../tests/helpers/test_utils'; -import throttle from '@mail/utils/throttle'; +import { insertAndReplace } from '@mail/model/model_field_command'; import { nextTick } from '@mail/utils/utils'; -const { ThrottleReinvokedError, ThrottleCanceledError } = throttle; - QUnit.module('mail', {}, function () { QUnit.module('utils', {}, function () { QUnit.module('throttle', {}, function () { -QUnit.module('throttle_tests.js', { - async beforeEach() { - this.throttles = []; - }, - afterEach() { - // Important: tests should cleanly intercept cancelation errors that - // may result from this teardown. - for (const t of this.throttles) { - t.clear(); - } - }, -}); +QUnit.module('throttle_tests.js', {}); QUnit.test('single call', async function (assert) { - assert.expect(6); + assert.expect(3); const { advanceTime, messaging } = await start({ hasTimeControl: true, }); - let hasInvokedFunc = false; - const throttledFunc = throttle( - messaging, - () => { - hasInvokedFunc = true; - return 'func_result'; - }, - 0 - ); - this.throttles.push(throttledFunc); - + const throttle = messaging.models['Throttle'].insert({ + func: () => hasInvokedFunc = true, + qunitTestOwner1: insertAndReplace(), + }); assert.notOk( hasInvokedFunc, "func should not have been invoked on immediate throttle initialization" @@ -51,340 +31,141 @@ QUnit.test('single call', async function (assert) { "func should not have been invoked from throttle initialization after 0ms" ); - throttledFunc().then(res => { - assert.step('throttle_observed_invoke'); - assert.strictEqual( - res, - 'func_result', - "throttle call return should forward result of inner func" - ); - }); + throttle.do(); await nextTick(); assert.ok( hasInvokedFunc, "func should have been immediately invoked on first throttle call" ); - assert.verifySteps( - ['throttle_observed_invoke'], - "throttle should have observed invoked on first throttle call" - ); }); QUnit.test('2nd (throttled) call', async function (assert) { - assert.expect(8); + assert.expect(4); const { advanceTime, messaging } = await start({ hasTimeControl: true, }); - let funcCalledAmount = 0; - const throttledFunc = throttle( - messaging, - () => { - funcCalledAmount++; - return `func_result_${funcCalledAmount}`; - }, - 1000 - ); - this.throttles.push(throttledFunc); - - throttledFunc().then(result => { - assert.step('throttle_observed_invoke_1'); - assert.strictEqual( - result, - 'func_result_1', - "throttle call return should forward result of inner func 1" - ); + const throttle = messaging.models['Throttle'].insert({ + func: () => funcCalledAmount++, + qunitTestOwner2: insertAndReplace(), }); + throttle.do(); await nextTick(); - assert.verifySteps( - ['throttle_observed_invoke_1'], - "inner function of throttle should have been invoked on 1st call (immediate return)" + assert.strictEqual( + funcCalledAmount, + 1, + "throttle call return should forward result of inner func 1" ); - throttledFunc().then(res => { - assert.step('throttle_observed_invoke_2'); - assert.strictEqual( - res, - 'func_result_2', - "throttle call return should forward result of inner func 2" - ); - }); + throttle.do(); await nextTick(); - assert.verifySteps( - [], + assert.strictEqual( + funcCalledAmount, + 1, "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" ); await advanceTime(999); - assert.verifySteps( - [], + assert.strictEqual( + funcCalledAmount, + 1, "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)" ); await advanceTime(1); - assert.verifySteps( - ['throttle_observed_invoke_2'], - "inner function of throttle should not have been invoked after 1s of 2nd call (throttled with 1s internal clock)" + assert.strictEqual( + funcCalledAmount, + 2, + "throttle call return should forward result of inner func 2" ); }); QUnit.test('throttled call reinvocation', async function (assert) { - assert.expect(11); + assert.expect(4); const { advanceTime, messaging } = await start({ hasTimeControl: true, }); - let funcCalledAmount = 0; - const throttledFunc = throttle( - messaging, - () => { - funcCalledAmount++; - return `func_result_${funcCalledAmount}`; - }, - 1000, - { silentCancelationErrors: false } - ); - this.throttles.push(throttledFunc); - - throttledFunc().then(result => { - assert.step('throttle_observed_invoke_1'); - assert.strictEqual( - result, - 'func_result_1', - "throttle call return should forward result of inner func 1" - ); + const throttle = messaging.models['Throttle'].insert({ + func: () => funcCalledAmount++, + qunitTestOwner2: insertAndReplace(), }); + throttle.do(); await nextTick(); - assert.verifySteps( - ['throttle_observed_invoke_1'], - "inner function of throttle should have been invoked on 1st call (immediate return)" + assert.strictEqual( + funcCalledAmount, + 1, + "throttle call return should forward result of inner func 1" ); - throttledFunc() - .then(() => { - throw new Error("2nd throttle call should not be resolved (should have been canceled by reinvocation)"); - }) - .catch(error => { - assert.ok( - error instanceof ThrottleReinvokedError, - "Should generate a Throttle reinvoked error (from another throttle function call)" - ); - assert.step('throttle_reinvoked_1'); - }); + throttle.do(); await nextTick(); - assert.verifySteps( - [], + assert.strictEqual( + funcCalledAmount, + 1, "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" ); await advanceTime(999); - assert.verifySteps( - [], + assert.strictEqual( + funcCalledAmount, + 1, "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)" ); - throttledFunc() - .then(result => { - assert.step('throttle_observed_invoke_2'); - assert.strictEqual( - result, - 'func_result_2', - "throttle call return should forward result of inner func 2" - ); - }); + throttle.do(); await nextTick(); - assert.verifySteps( - ['throttle_reinvoked_1'], - "2nd throttle call should have been canceled from 3rd throttle call (reinvoked before cooling down phase has ended)" - ); - await advanceTime(1); - assert.verifySteps( - ['throttle_observed_invoke_2'], - "inner function of throttle should have been invoked after 1s of 1st call (throttled with 1s internal clock, 3rd throttle call re-use timer of 2nd throttle call)" - ); -}); - -QUnit.test('flush throttled call', async function (assert) { - assert.expect(9); - - const { advanceTime, messaging } = await start({ - hasTimeControl: true, - }); - - const throttledFunc = throttle( - messaging, - () => {}, - 1000, - ); - this.throttles.push(throttledFunc); - - throttledFunc().then(() => assert.step('throttle_observed_invoke_1')); - await nextTick(); - assert.verifySteps( - ['throttle_observed_invoke_1'], - "inner function of throttle should have been invoked on 1st call (immediate return)" - ); - - throttledFunc().then(() => assert.step('throttle_observed_invoke_2')); - await nextTick(); - assert.verifySteps( - [], - "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" - ); - - await advanceTime(10); - assert.verifySteps( - [], - "inner function of throttle should not have been invoked after 10ms of 2nd call (throttled with 1s internal clock)" - ); - - throttledFunc.flush(); - await nextTick(); - assert.verifySteps( - ['throttle_observed_invoke_2'], - "inner function of throttle should have been invoked from 2nd call after flush" - ); - - throttledFunc().then(() => assert.step('throttle_observed_invoke_3')); - await nextTick(); - await advanceTime(999); - assert.verifySteps( - [], - "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)" - ); - - await advanceTime(1); - assert.verifySteps( - ['throttle_observed_invoke_3'], - "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)" - ); -}); - -QUnit.test('cancel throttled call', async function (assert) { - assert.expect(10); - - const { advanceTime, messaging } = await start({ - hasTimeControl: true, - }); - - const throttledFunc = throttle( - messaging, - () => {}, - 1000, - { silentCancelationErrors: false } - ); - this.throttles.push(throttledFunc); - - throttledFunc().then(() => assert.step('throttle_observed_invoke_1')); - await nextTick(); - assert.verifySteps( - ['throttle_observed_invoke_1'], - "inner function of throttle should have been invoked on 1st call (immediate return)" - ); - - throttledFunc() - .then(() => { - throw new Error("2nd throttle call should not be resolved (should have been canceled)"); - }) - .catch(error => { - assert.ok( - error instanceof ThrottleCanceledError, - "Should generate a Throttle canceled error (from `.cancel()`)" - ); - assert.step('throttle_canceled'); - }); - await nextTick(); - assert.verifySteps( - [], - "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" - ); - - await advanceTime(500); - assert.verifySteps( - [], - "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)" - ); - - throttledFunc.cancel(); - await nextTick(); - assert.verifySteps( - ['throttle_canceled'], - "2nd throttle function call should have been canceled" - ); - - throttledFunc().then(() => assert.step('throttle_observed_invoke_3')); - await nextTick(); - assert.verifySteps( - [], - "3rd throttle function call should not have invoked inner function yet (cancel reuses inner clock of throttle)" - ); - - await advanceTime(500); - assert.verifySteps( - ['throttle_observed_invoke_3'], - "3rd throttle function call should have invoke inner function after 500ms (cancel reuses inner clock of throttle which was at 500ms in, throttle set at 1ms)" + assert.strictEqual( + funcCalledAmount, + 2, + "throttle call return should forward result of inner func 2" ); }); QUnit.test('clear throttled call', async function (assert) { - assert.expect(9); + assert.expect(4); const { advanceTime, messaging } = await start({ hasTimeControl: true, }); - - const throttledFunc = throttle( - messaging, - () => {}, - 1000, - { silentCancelationErrors: false } - ); - this.throttles.push(throttledFunc); - - throttledFunc().then(() => assert.step('throttle_observed_invoke_1')); + let funcCalledAmount = 0; + const throttle = messaging.models['Throttle'].insert({ + func: () => funcCalledAmount++, + qunitTestOwner2: insertAndReplace(), + }); + throttle.do(); await nextTick(); - assert.verifySteps( - ['throttle_observed_invoke_1'], + assert.strictEqual( + funcCalledAmount, + 1, "inner function of throttle should have been invoked on 1st call (immediate return)" ); - throttledFunc() - .then(() => { - throw new Error("2nd throttle call should not be resolved (should have been canceled from clear)"); - }) - .catch(error => { - assert.ok( - error instanceof ThrottleCanceledError, - "Should generate a Throttle canceled error (from `.clear()`)" - ); - assert.step('throttle_canceled'); - }); + throttle.do(); await nextTick(); - assert.verifySteps( - [], + assert.strictEqual( + funcCalledAmount, + 1, "inner function of throttle should not have been immediately invoked after 2nd call immediately after 1st call (throttled with 1s internal clock)" ); await advanceTime(500); - assert.verifySteps( - [], + assert.strictEqual( + funcCalledAmount, + 1, "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)" ); - throttledFunc.clear(); + throttle.clear(); await nextTick(); - assert.verifySteps( - ['throttle_canceled'], - "2nd throttle function call should have been canceled (from `.clear()`)" - ); - - throttledFunc().then(() => assert.step('throttle_observed_invoke_3')); + throttle.do(); await nextTick(); - assert.verifySteps( - ['throttle_observed_invoke_3'], + assert.strictEqual( + funcCalledAmount, + 2, "3rd throttle function call should have invoke inner function immediately (`.clear()` flushes throttle)" ); });