[IMP] mail: introduce Throttle model

Task-2831082

closes odoo/odoo#91502

Signed-off-by: Sébastien Theys (seb) <seb@odoo.com>
This commit is contained in:
Alexandre Kühn
2022-06-01 15:22:59 +02:00
parent 557a3e28ed
commit 669dccddd9
7 changed files with 247 additions and 726 deletions
+42 -57
View File
@@ -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
+80
View File
@@ -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,
}),
},
});
+12
View File
@@ -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.
-379
View File
@@ -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;
@@ -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,
@@ -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();
},
});
@@ -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)"
);
});