diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/mail/static/src/utils | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mail/static/src/utils')
| -rw-r--r-- | addons/mail/static/src/utils/deferred/deferred.js | 21 | ||||
| -rw-r--r-- | addons/mail/static/src/utils/test_utils.js | 767 | ||||
| -rw-r--r-- | addons/mail/static/src/utils/throttle/throttle.js | 382 | ||||
| -rw-r--r-- | addons/mail/static/src/utils/throttle/throttle_tests.js | 407 | ||||
| -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 | ||||
| -rw-r--r-- | addons/mail/static/src/utils/utils.js | 193 |
7 files changed, 2362 insertions, 0 deletions
diff --git a/addons/mail/static/src/utils/deferred/deferred.js b/addons/mail/static/src/utils/deferred/deferred.js new file mode 100644 index 00000000..f96696fb --- /dev/null +++ b/addons/mail/static/src/utils/deferred/deferred.js @@ -0,0 +1,21 @@ +odoo.define('mail/static/src/utils/deferred/deferred.js', function (require) { +'use strict'; + +/** + * @returns {Deferred} + */ +function makeDeferred() { + let resolve; + let reject; + const prom = new Promise(function (res, rej) { + resolve = res.bind(this); + reject = rej.bind(this); + }); + prom.resolve = (...args) => resolve(...args); + prom.reject = (...args) => reject(...args); + return prom; +} + +return { makeDeferred }; + +}); diff --git a/addons/mail/static/src/utils/test_utils.js b/addons/mail/static/src/utils/test_utils.js new file mode 100644 index 00000000..be15afe7 --- /dev/null +++ b/addons/mail/static/src/utils/test_utils.js @@ -0,0 +1,767 @@ +odoo.define('mail/static/src/utils/test_utils.js', function (require) { +'use strict'; + +const BusService = require('bus.BusService'); + +const { + addMessagingToEnv, + addTimeControlToEnv, +} = require('mail/static/src/env/test_env.js'); +const ModelManager = require('mail/static/src/model/model_manager.js'); +const ChatWindowService = require('mail/static/src/services/chat_window_service/chat_window_service.js'); +const DialogService = require('mail/static/src/services/dialog_service/dialog_service.js'); +const { nextTick } = require('mail/static/src/utils/utils.js'); +const DiscussWidget = require('mail/static/src/widgets/discuss/discuss.js'); +const MessagingMenuWidget = require('mail/static/src/widgets/messaging_menu/messaging_menu.js'); +const MockModels = require('mail/static/tests/helpers/mock_models.js'); + +const AbstractStorageService = require('web.AbstractStorageService'); +const NotificationService = require('web.NotificationService'); +const RamStorage = require('web.RamStorage'); +const { + createActionManager, + createView, + makeTestPromise, + mock: { + addMockEnvironment, + patch: legacyPatch, + unpatch: legacyUnpatch, + }, +} = require('web.test_utils'); +const Widget = require('web.Widget'); + +const { Component } = owl; + +//------------------------------------------------------------------------------ +// Private +//------------------------------------------------------------------------------ + +/** + * Create a fake object 'dataTransfer', linked to some files, + * which is passed to drag and drop events. + * + * @param {Object[]} files + * @returns {Object} + */ +function _createFakeDataTransfer(files) { + return { + dropEffect: 'all', + effectAllowed: 'all', + files, + items: [], + types: ['Files'], + }; +} + +/** + * @private + * @param {Object} callbacks + * @param {function[]} callbacks.init + * @param {function[]} callbacks.mount + * @param {function[]} callbacks.destroy + * @param {function[]} callbacks.return + * @returns {Object} update callbacks + */ +function _useChatWindow(callbacks) { + const { + mount: prevMount, + destroy: prevDestroy, + } = callbacks; + return Object.assign({}, callbacks, { + mount: prevMount.concat(async () => { + // trigger mounting of chat window manager + await Component.env.services['chat_window']._onWebClientReady(); + }), + destroy: prevDestroy.concat(() => { + Component.env.services['chat_window'].destroy(); + }), + }); +} + +/** + * @private + * @param {Object} callbacks + * @param {function[]} callbacks.init + * @param {function[]} callbacks.mount + * @param {function[]} callbacks.destroy + * @param {function[]} callbacks.return + * @returns {Object} update callbacks + */ +function _useDialog(callbacks) { + const { + mount: prevMount, + destroy: prevDestroy, + } = callbacks; + return Object.assign({}, callbacks, { + mount: prevMount.concat(async () => { + // trigger mounting of dialog manager + await Component.env.services['dialog']._onWebClientReady(); + }), + destroy: prevDestroy.concat(() => { + Component.env.services['dialog'].destroy(); + }), + }); +} + +/** + * @private + * @param {Object} callbacks + * @param {function[]} callbacks.init + * @param {function[]} callbacks.mount + * @param {function[]} callbacks.destroy + * @param {function[]} callbacks.return + * @return {Object} update callbacks + */ +function _useDiscuss(callbacks) { + const { + init: prevInit, + mount: prevMount, + return: prevReturn, + } = callbacks; + let discussWidget; + const state = { + autoOpenDiscuss: false, + discussData: {}, + }; + return Object.assign({}, callbacks, { + init: prevInit.concat(params => { + const { + autoOpenDiscuss = state.autoOpenDiscuss, + discuss: discussData = state.discussData + } = params; + Object.assign(state, { autoOpenDiscuss, discussData }); + delete params.autoOpenDiscuss; + delete params.discuss; + }), + mount: prevMount.concat(async params => { + const { selector, widget } = params; + DiscussWidget.prototype._pushStateActionManager = () => {}; + discussWidget = new DiscussWidget(widget, state.discussData); + await discussWidget.appendTo($(selector)); + if (state.autoOpenDiscuss) { + await discussWidget.on_attach_callback(); + } + }), + return: prevReturn.concat(result => { + Object.assign(result, { discussWidget }); + }), + }); +} + +/** + * @private + * @param {Object} callbacks + * @param {function[]} callbacks.init + * @param {function[]} callbacks.mount + * @param {function[]} callbacks.destroy + * @param {function[]} callbacks.return + * @returns {Object} update callbacks + */ +function _useMessagingMenu(callbacks) { + const { + mount: prevMount, + return: prevReturn, + } = callbacks; + let messagingMenuWidget; + return Object.assign({}, callbacks, { + mount: prevMount.concat(async ({ selector, widget }) => { + messagingMenuWidget = new MessagingMenuWidget(widget, {}); + await messagingMenuWidget.appendTo($(selector)); + await messagingMenuWidget.on_attach_callback(); + }), + return: prevReturn.concat(result => { + Object.assign(result, { messagingMenuWidget }); + }), + }); +} + +//------------------------------------------------------------------------------ +// Public: rendering timers +//------------------------------------------------------------------------------ + +/** + * Returns a promise resolved at the next animation frame. + * + * @returns {Promise} + */ +function nextAnimationFrame() { + const requestAnimationFrame = owl.Component.scheduler.requestAnimationFrame; + return new Promise(function (resolve) { + setTimeout(() => requestAnimationFrame(() => resolve())); + }); +} + +/** + * Returns a promise resolved the next time OWL stops rendering. + * + * @param {function} func function which, when called, is + * expected to trigger OWL render(s). + * @param {number} [timeoutDelay=5000] in ms + * @returns {Promise} + */ +const afterNextRender = (function () { + const stop = owl.Component.scheduler.stop; + const stopPromises = []; + + owl.Component.scheduler.stop = function () { + const wasRunning = this.isRunning; + stop.call(this); + if (wasRunning) { + while (stopPromises.length) { + stopPromises.pop().resolve(); + } + } + }; + + async function afterNextRender(func, timeoutDelay = 5000) { + // Define the potential errors outside of the promise to get a proper + // trace if they happen. + const startError = new Error("Timeout: the render didn't start."); + const stopError = new Error("Timeout: the render didn't stop."); + // Set up the timeout to reject if no render happens. + let timeoutNoRender; + const timeoutProm = new Promise((resolve, reject) => { + timeoutNoRender = setTimeout(() => { + let error = startError; + if (owl.Component.scheduler.isRunning) { + error = stopError; + } + console.error(error); + reject(error); + }, timeoutDelay); + }); + // Set up the promise to resolve if a render happens. + const prom = makeTestPromise(); + stopPromises.push(prom); + // Start the function expected to trigger a render after the promise + // has been registered to not miss any potential render. + const funcRes = func(); + // Make them race (first to resolve/reject wins). + await Promise.race([prom, timeoutProm]); + clearTimeout(timeoutNoRender); + // Wait the end of the function to ensure all potential effects are + // taken into account during the following verification step. + await funcRes; + // Wait one more frame to make sure no new render has been queued. + await nextAnimationFrame(); + if (owl.Component.scheduler.isRunning) { + await afterNextRender(() => {}, timeoutDelay); + } + } + + return afterNextRender; +})(); + + +//------------------------------------------------------------------------------ +// Public: test lifecycle +//------------------------------------------------------------------------------ + +function beforeEach(self) { + const data = MockModels.generateData(); + + data.partnerRootId = 2; + data['res.partner'].records.push({ + active: false, + display_name: "OdooBot", + id: data.partnerRootId, + }); + + data.currentPartnerId = 3; + data['res.partner'].records.push({ + display_name: "Your Company, Mitchell Admin", + id: data.currentPartnerId, + name: "Mitchell Admin", + }); + data.currentUserId = 2; + data['res.users'].records.push({ + display_name: "Your Company, Mitchell Admin", + id: data.currentUserId, + name: "Mitchell Admin", + partner_id: data.currentPartnerId, + }); + + data.publicPartnerId = 4; + data['res.partner'].records.push({ + active: false, + display_name: "Public user", + id: data.publicPartnerId, + }); + data.publicUserId = 3; + data['res.users'].records.push({ + active: false, + display_name: "Public user", + id: data.publicUserId, + name: "Public user", + partner_id: data.publicPartnerId, + }); + + const originals = { + '_.debounce': _.debounce, + '_.throttle': _.throttle, + }; + + (function patch() { + // patch _.debounce and _.throttle to be fast and synchronous + _.debounce = _.identity; + _.throttle = _.identity; + })(); + + function unpatch() { + _.debounce = originals['_.debounce']; + _.throttle = originals['_.throttle']; + } + + Object.assign(self, { + components: [], + data, + unpatch, + widget: undefined + }); +} + +function afterEach(self) { + if (self.env) { + self.env.bus.off('hide_home_menu', null); + self.env.bus.off('show_home_menu', null); + self.env.bus.off('will_hide_home_menu', null); + self.env.bus.off('will_show_home_menu', null); + } + // The components must be destroyed before the widget, because the + // widget might destroy the models before destroying the components, + // and the components might still rely on messaging (or other) record(s). + while (self.components.length > 0) { + const component = self.components.pop(); + component.destroy(); + } + if (self.widget) { + self.widget.destroy(); + self.widget = undefined; + } + self.env = undefined; + self.unpatch(); +} + +/** + * Creates and returns a new root Component with the given props and mounts it + * on target. + * Assumes that self.env is set to the correct value. + * Components created this way are automatically registered for clean up after + * the test, which will happen when `afterEach` is called. + * + * @param {Object} self the current QUnit instance + * @param {Class} Component the component class to create + * @param {Object} param2 + * @param {Object} [param2.props={}] forwarded to component constructor + * @param {DOM.Element} param2.target mount target for the component + * @returns {owl.Component} the new component instance + */ +async function createRootComponent(self, Component, { props = {}, target }) { + Component.env = self.env; + const component = new Component(null, props); + delete Component.env; + self.components.push(component); + await afterNextRender(() => component.mount(target)); + return component; +} + +/** + * Main function used to make a mocked environment with mocked messaging env. + * + * @param {Object} [param0={}] + * @param {string} [param0.arch] makes only sense when `param0.hasView` is set: + * the arch to use in createView. + * @param {Object} [param0.archs] + * @param {boolean} [param0.autoOpenDiscuss=false] makes only sense when + * `param0.hasDiscuss` is set: determine whether mounted discuss should be + * open initially. + * @param {boolean} [param0.debug=false] + * @param {Object} [param0.data] makes only sense when `param0.hasView` is set: + * the data to use in createView. + * @param {Object} [param0.discuss={}] makes only sense when `param0.hasDiscuss` + * is set: provide data that is passed to discuss widget (= client action) as + * 2nd positional argument. + * @param {Object} [param0.env={}] + * @param {function} [param0.mockFetch] + * @param {function} [param0.mockRPC] + * @param {boolean} [param0.hasActionManager=false] if set, use + * createActionManager. + * @param {boolean} [param0.hasChatWindow=false] if set, mount chat window + * service. + * @param {boolean} [param0.hasDiscuss=false] if set, mount discuss app. + * @param {boolean} [param0.hasMessagingMenu=false] if set, mount messaging + * menu. + * @param {boolean} [param0.hasTimeControl=false] if set, all flow of time + * with `env.browser.setTimeout` are fully controlled by test itself. + * @see addTimeControlToEnv that adds `advanceTime` function in + * `env.testUtils`. + * @param {boolean} [param0.hasView=false] if set, use createView to create a + * view instead of a generic widget. + * @param {Deferred|Promise} [param0.messagingBeforeCreationDeferred=Promise.resolve()] + * Deferred that let tests block messaging creation and simulate resolution. + * Useful for testing working components when messaging is not yet created. + * @param {string} [param0.model] makes only sense when `param0.hasView` is set: + * the model to use in createView. + * @param {integer} [param0.res_id] makes only sense when `param0.hasView` is set: + * the res_id to use in createView. + * @param {Object} [param0.services] + * @param {Object} [param0.session] + * @param {Object} [param0.View] makes only sense when `param0.hasView` is set: + * the View class to use in createView. + * @param {Object} [param0.viewOptions] makes only sense when `param0.hasView` + * is set: the view options to use in createView. + * @param {Object} [param0.waitUntilEvent] + * @param {String} [param0.waitUntilEvent.eventName] + * @param {String} [param0.waitUntilEvent.message] + * @param {function} [param0.waitUntilEvent.predicate] + * @param {integer} [param0.waitUntilEvent.timeoutDelay] + * @param {string} [param0.waitUntilMessagingCondition='initialized'] Determines + * the condition of messaging when this function is resolved. + * Supported values: ['none', 'created', 'initialized']. + * - 'none': the function resolves regardless of whether messaging is created. + * - 'created': the function resolves when messaging is created, but + * regardless of whether messaging is initialized. + * - 'initialized' (default): the function resolves when messaging is + * initialized. + * To guarantee messaging is not created, test should pass a pending deferred + * as param of `messagingBeforeCreationDeferred`. To make sure messaging is + * not initialized, test should mock RPC `mail/init_messaging` and block its + * resolution. + * @param {...Object} [param0.kwargs] + * @throws {Error} in case some provided parameters are wrong, such as + * `waitUntilMessagingCondition`. + * @returns {Object} + */ +async function start(param0 = {}) { + let callbacks = { + init: [], + mount: [], + destroy: [], + return: [], + }; + const { + env: providedEnv, + hasActionManager = false, + hasChatWindow = false, + hasDialog = false, + hasDiscuss = false, + hasMessagingMenu = false, + hasTimeControl = false, + hasView = false, + messagingBeforeCreationDeferred = Promise.resolve(), + waitUntilEvent, + waitUntilMessagingCondition = 'initialized', + } = param0; + if (!['none', 'created', 'initialized'].includes(waitUntilMessagingCondition)) { + throw Error(`Unknown parameter value ${waitUntilMessagingCondition} for 'waitUntilMessaging'.`); + } + delete param0.env; + delete param0.hasActionManager; + delete param0.hasChatWindow; + delete param0.hasDiscuss; + delete param0.hasMessagingMenu; + delete param0.hasTimeControl; + delete param0.hasView; + if (hasChatWindow) { + callbacks = _useChatWindow(callbacks); + } + if (hasDialog) { + callbacks = _useDialog(callbacks); + } + if (hasDiscuss) { + callbacks = _useDiscuss(callbacks); + } + if (hasMessagingMenu) { + callbacks = _useMessagingMenu(callbacks); + } + const { + init: initCallbacks, + mount: mountCallbacks, + destroy: destroyCallbacks, + return: returnCallbacks, + } = callbacks; + const { debug = false } = param0; + initCallbacks.forEach(callback => callback(param0)); + + let env = Object.assign(providedEnv || {}); + env.session = Object.assign( + { + is_bound: Promise.resolve(), + url: s => s, + }, + env.session + ); + env = addMessagingToEnv(env); + if (hasTimeControl) { + env = addTimeControlToEnv(env); + } + + const services = Object.assign({}, { + bus_service: BusService.extend({ + _beep() {}, // Do nothing + _poll() {}, // Do nothing + _registerWindowUnload() {}, // Do nothing + isOdooFocused() { + return true; + }, + updateOption() {}, + }), + chat_window: ChatWindowService.extend({ + _getParentNode() { + return document.querySelector(debug ? 'body' : '#qunit-fixture'); + }, + _listenHomeMenu: () => {}, + }), + dialog: DialogService.extend({ + _getParentNode() { + return document.querySelector(debug ? 'body' : '#qunit-fixture'); + }, + _listenHomeMenu: () => {}, + }), + local_storage: AbstractStorageService.extend({ storage: new RamStorage() }), + notification: NotificationService.extend(), + }, param0.services); + + const kwargs = Object.assign({}, param0, { + archs: Object.assign({}, { + 'mail.message,false,search': '<search/>' + }, param0.archs), + debug: param0.debug || false, + services: Object.assign({}, services, param0.services), + }, { env }); + let widget; + let mockServer; // only in basic mode + let testEnv; + const selector = debug ? 'body' : '#qunit-fixture'; + if (hasView) { + widget = await createView(kwargs); + legacyPatch(widget, { + destroy() { + destroyCallbacks.forEach(callback => callback({ widget })); + this._super(...arguments); + legacyUnpatch(widget); + if (testEnv) { + testEnv.destroyMessaging(); + } + } + }); + } else if (hasActionManager) { + widget = await createActionManager(kwargs); + legacyPatch(widget, { + destroy() { + destroyCallbacks.forEach(callback => callback({ widget })); + this._super(...arguments); + legacyUnpatch(widget); + if (testEnv) { + testEnv.destroyMessaging(); + } + } + }); + } else { + const Parent = Widget.extend({ do_push_state() {} }); + const parent = new Parent(); + mockServer = await addMockEnvironment(parent, kwargs); + widget = new Widget(parent); + await widget.appendTo($(selector)); + Object.assign(widget, { + destroy() { + delete widget.destroy; + destroyCallbacks.forEach(callback => callback({ widget })); + parent.destroy(); + if (testEnv) { + testEnv.destroyMessaging(); + } + }, + }); + } + + testEnv = Component.env; + + /** + * Components cannot use web.bus, because they cannot use + * EventDispatcherMixin, and webclient cannot easily access env. + * Communication between webclient and components by core.bus + * (usable by webclient) and messagingBus (usable by components), which + * the messaging service acts as mediator since it can easily use both + * kinds of buses. + */ + testEnv.bus.on( + 'hide_home_menu', + null, + () => testEnv.messagingBus.trigger('hide_home_menu') + ); + testEnv.bus.on( + 'show_home_menu', + null, + () => testEnv.messagingBus.trigger('show_home_menu') + ); + testEnv.bus.on( + 'will_hide_home_menu', + null, + () => testEnv.messagingBus.trigger('will_hide_home_menu') + ); + testEnv.bus.on( + 'will_show_home_menu', + null, + () => testEnv.messagingBus.trigger('will_show_home_menu') + ); + + /** + * Returns a promise resolved after the expected event is received. + * + * @param {Object} param0 + * @param {string} param0.eventName event to wait + * @param {function} param0.func function which, when called, is expected to + * trigger the event + * @param {string} [param0.message] assertion message + * @param {function} [param0.predicate] predicate called with event data. + * If not provided, only the event name has to match. + * @param {number} [param0.timeoutDelay=5000] how long to wait at most in ms + * @returns {Promise} + */ + const afterEvent = (async ({ eventName, func, message, predicate, timeoutDelay = 5000 }) => { + // Set up the timeout to reject if the event is not triggered. + let timeoutNoEvent; + const timeoutProm = new Promise((resolve, reject) => { + timeoutNoEvent = setTimeout(() => { + let error = message + ? new Error(message) + : new Error(`Timeout: the event ${eventName} was not triggered.`); + console.error(error); + reject(error); + }, timeoutDelay); + }); + // Set up the promise to resolve if the event is triggered. + const eventProm = new Promise(resolve => { + testEnv.messagingBus.on(eventName, null, data => { + if (!predicate || predicate(data)) { + resolve(); + } + }); + }); + // Start the function expected to trigger the event after the + // promise has been registered to not miss any potential event. + const funcRes = func(); + // Make them race (first to resolve/reject wins). + await Promise.race([eventProm, timeoutProm]); + clearTimeout(timeoutNoEvent); + // If the event is triggered before the end of the async function, + // ensure the function finishes its job before returning. + await funcRes; + }); + + const result = { + afterEvent, + env: testEnv, + mockServer, + widget, + }; + + const start = async () => { + messagingBeforeCreationDeferred.then(async () => { + /** + * Some models require session data, like locale text direction + * (depends on fully loaded translation). + */ + await env.session.is_bound; + + testEnv.modelManager = new ModelManager(testEnv); + testEnv.modelManager.start(); + /** + * Create the messaging singleton record. + */ + testEnv.messaging = testEnv.models['mail.messaging'].create(); + testEnv.messaging.start().then(() => + testEnv.messagingInitializedDeferred.resolve() + ); + testEnv.messagingCreatedPromise.resolve(); + }); + if (waitUntilMessagingCondition === 'created') { + await testEnv.messagingCreatedPromise; + } + if (waitUntilMessagingCondition === 'initialized') { + await testEnv.messagingInitializedDeferred; + } + + if (mountCallbacks.length > 0) { + await afterNextRender(async () => { + await Promise.all(mountCallbacks.map(callback => callback({ selector, widget }))); + }); + } + returnCallbacks.forEach(callback => callback(result)); + }; + if (waitUntilEvent) { + await afterEvent(Object.assign({ func: start }, waitUntilEvent)); + } else { + await start(); + } + return result; +} + +//------------------------------------------------------------------------------ +// Public: file utilities +//------------------------------------------------------------------------------ + +/** + * Drag some files over a DOM element + * + * @param {DOM.Element} el + * @param {Object[]} file must have been create beforehand + * @see testUtils.file.createFile + */ +function dragenterFiles(el, files) { + const ev = new Event('dragenter', { bubbles: true }); + Object.defineProperty(ev, 'dataTransfer', { + value: _createFakeDataTransfer(files), + }); + el.dispatchEvent(ev); +} + +/** + * Drop some files on a DOM element + * + * @param {DOM.Element} el + * @param {Object[]} files must have been created beforehand + * @see testUtils.file.createFile + */ +function dropFiles(el, files) { + const ev = new Event('drop', { bubbles: true }); + Object.defineProperty(ev, 'dataTransfer', { + value: _createFakeDataTransfer(files), + }); + el.dispatchEvent(ev); +} + +/** + * Paste some files on a DOM element + * + * @param {DOM.Element} el + * @param {Object[]} files must have been created beforehand + * @see testUtils.file.createFile + */ +function pasteFiles(el, files) { + const ev = new Event('paste', { bubbles: true }); + Object.defineProperty(ev, 'clipboardData', { + value: _createFakeDataTransfer(files), + }); + el.dispatchEvent(ev); +} + +//------------------------------------------------------------------------------ +// Export +//------------------------------------------------------------------------------ + +return { + afterEach, + afterNextRender, + beforeEach, + createRootComponent, + dragenterFiles, + dropFiles, + nextAnimationFrame, + nextTick, + pasteFiles, + start, +}; + +}); diff --git a/addons/mail/static/src/utils/throttle/throttle.js b/addons/mail/static/src/utils/throttle/throttle.js new file mode 100644 index 00000000..6b9ff008 --- /dev/null +++ b/addons/mail/static/src/utils/throttle/throttle.js @@ -0,0 +1,382 @@ +odoo.define('mail/static/src/utils/throttle/throttle.js', function (require) { +'use strict'; + +const { makeDeferred } = require('mail/static/src/utils/deferred/deferred.js'); + +/** + * 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 {Object} env the OWL env + * @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(env, func, duration) { + /** + * Reference to the OWL envirionment. Useful to fine-tune control of + * time flow in tests. + * @see mail/static/src/utils/test_utils.js:start.hasTimeControl + */ + this.env = env; + /** + * 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.env.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.env.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 {Object} env the OWL env + * @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( + env, + func, + duration, + { silentCancelationErrors = true } = {} +) { + const throttleObj = new Throttle(env, 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, +}); + + +return throttle; + +}); diff --git a/addons/mail/static/src/utils/throttle/throttle_tests.js b/addons/mail/static/src/utils/throttle/throttle_tests.js new file mode 100644 index 00000000..d3e6ad66 --- /dev/null +++ b/addons/mail/static/src/utils/throttle/throttle_tests.js @@ -0,0 +1,407 @@ +odoo.define('mail/static/src/utils/throttle/throttle_tests.js', function (require) { +'use strict'; + +const { afterEach, beforeEach, start } = require('mail/static/src/utils/test_utils.js'); +const throttle = require('mail/static/src/utils/throttle/throttle.js'); +const { nextTick } = require('mail/static/src/utils/utils.js'); + +const { ThrottleReinvokedError, ThrottleCanceledError } = throttle; + +QUnit.module('mail', {}, function () { +QUnit.module('utils', {}, function () { +QUnit.module('throttle', {}, function () { +QUnit.module('throttle_tests.js', { + beforeEach() { + beforeEach(this); + this.throttles = []; + + 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 t of this.throttles) { + t.clear(); + } + afterEach(this); + }, +}); + +QUnit.test('single call', async function (assert) { + assert.expect(6); + + await this.start({ + hasTimeControl: true, + }); + + let hasInvokedFunc = false; + const throttledFunc = throttle( + this.env, + () => { + hasInvokedFunc = true; + return 'func_result'; + }, + 0 + ); + this.throttles.push(throttledFunc); + + assert.notOk( + hasInvokedFunc, + "func should not have been invoked on immediate throttle initialization" + ); + + await this.env.testUtils.advanceTime(0); + assert.notOk( + hasInvokedFunc, + "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" + ); + }); + 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); + + await this.start({ + hasTimeControl: true, + }); + + let funcCalledAmount = 0; + const throttledFunc = throttle( + this.env, + () => { + 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" + ); + }); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_1'], + "inner function of throttle should have been invoked on 1st call (immediate return)" + ); + + 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" + ); + }); + 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 this.env.testUtils.advanceTime(999); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 999ms of 2nd call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.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)" + ); +}); + +QUnit.test('throttled call reinvocation', async function (assert) { + assert.expect(11); + + await this.start({ + hasTimeControl: true, + }); + + let funcCalledAmount = 0; + const throttledFunc = throttle( + this.env, + () => { + 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" + ); + }); + 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 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'); + }); + 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 this.env.testUtils.advanceTime(999); + assert.verifySteps( + [], + "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" + ); + }); + 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 this.env.testUtils.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); + + await this.start({ + hasTimeControl: true, + }); + + const throttledFunc = throttle( + this.env, + () => {}, + 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 this.env.testUtils.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 this.env.testUtils.advanceTime(999); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 999ms of 3rd call (throttled with 1s internal clock)" + ); + + await this.env.testUtils.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); + + await this.start({ + hasTimeControl: true, + }); + + const throttledFunc = throttle( + this.env, + () => {}, + 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 this.env.testUtils.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 this.env.testUtils.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)" + ); +}); + +QUnit.test('clear throttled call', async function (assert) { + assert.expect(9); + + await this.start({ + hasTimeControl: true, + }); + + const throttledFunc = throttle( + this.env, + () => {}, + 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 from clear)"); + }) + .catch(error => { + assert.ok( + error instanceof ThrottleCanceledError, + "Should generate a Throttle canceled error (from `.clear()`)" + ); + 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 this.env.testUtils.advanceTime(500); + assert.verifySteps( + [], + "inner function of throttle should not have been invoked after 500ms of 2nd call (throttled with 1s internal clock)" + ); + + throttledFunc.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')); + await nextTick(); + assert.verifySteps( + ['throttle_observed_invoke_3'], + "3rd throttle function call should have invoke inner function immediately (`.clear()` flushes throttle)" + ); +}); + +}); +}); +}); + +}); 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" + ); +}); + +}); +}); +}); + +}); diff --git a/addons/mail/static/src/utils/utils.js b/addons/mail/static/src/utils/utils.js new file mode 100644 index 00000000..2cfaa531 --- /dev/null +++ b/addons/mail/static/src/utils/utils.js @@ -0,0 +1,193 @@ +odoo.define('mail/static/src/utils/utils.js', function (require) { +'use strict'; + +const { delay } = require('web.concurrency'); +const { + patch: webUtilsPatch, + unaccent, + unpatch: webUtilsUnpatch, +} = require('web.utils'); + +//------------------------------------------------------------------------------ +// Public +//------------------------------------------------------------------------------ + +const classPatchMap = new WeakMap(); +const eventHandledWeakMap = new WeakMap(); + +/** + * Returns the given string after cleaning it. The goal of the clean is to give + * more convenient results when comparing it to potential search results, on + * which the clean should also be called before comparing them. + * + * @param {string} searchTerm + * @returns {string} + */ +function cleanSearchTerm(searchTerm) { + return unaccent(searchTerm.toLowerCase()); +} + +/** + * Executes the provided functions in order, but with a potential delay between + * them if they take too much time. This is done in order to avoid blocking the + * main thread for too long. + * + * @param {function[]} functions + * @param {integer} [maxTimeFrame=100] time (in ms) until a delay is introduced + */ +async function executeGracefully(functions, maxTimeFrame = 100) { + let startDate = new Date(); + for (const func of functions) { + if (new Date() - startDate > maxTimeFrame) { + await new Promise(resolve => setTimeout(resolve)); + startDate = new Date(); + } + await func(); + } +} + +/** + * Returns whether the given event has been handled with the given markName. + * + * @param {Event} ev + * @param {string} markName + * @returns {boolean} + */ +function isEventHandled(ev, markName) { + if (!eventHandledWeakMap.get(ev)) { + return false; + } + return eventHandledWeakMap.get(ev).includes(markName); +} + +/** + * Marks the given event as handled by the given markName. Useful to allow + * handlers in the propagation chain to make a decision based on what has + * already been done. + * + * @param {Event} ev + * @param {string} markName + */ +function markEventHandled(ev, markName) { + if (!eventHandledWeakMap.get(ev)) { + eventHandledWeakMap.set(ev, []); + } + eventHandledWeakMap.get(ev).push(markName); +} + +/** + * Wait a task tick, so that anything in micro-task queue that can be processed + * is processed. + */ +async function nextTick() { + await delay(0); +} + +/** + * Inspired by web.utils:patch utility function + * + * @param {Class} Class + * @param {string} patchName + * @param {Object} patch + * @returns {function} unpatch function + */ +function patchClassMethods(Class, patchName, patch) { + let metadata = classPatchMap.get(Class); + if (!metadata) { + metadata = { + origMethods: {}, + patches: {}, + current: [] + }; + classPatchMap.set(Class, metadata); + } + if (metadata.patches[patchName]) { + throw new Error(`Patch [${patchName}] already exists`); + } + metadata.patches[patchName] = patch; + applyPatch(Class, patch); + metadata.current.push(patchName); + + function applyPatch(Class, patch) { + Object.keys(patch).forEach(function (methodName) { + const method = patch[methodName]; + if (typeof method === "function") { + const original = Class[methodName]; + if (!(methodName in metadata.origMethods)) { + metadata.origMethods[methodName] = original; + } + Class[methodName] = function (...args) { + const previousSuper = this._super; + this._super = original; + const res = method.call(this, ...args); + this._super = previousSuper; + return res; + }; + } + }); + } + + return () => unpatchClassMethods.bind(Class, patchName); +} + +/** + * @param {Class} Class + * @param {string} patchName + * @param {Object} patch + * @returns {function} unpatch function + */ +function patchInstanceMethods(Class, patchName, patch) { + return webUtilsPatch(Class, patchName, patch); +} + +/** + * Inspired by web.utils:unpatch utility function + * + * @param {Class} Class + * @param {string} patchName + */ +function unpatchClassMethods(Class, patchName) { + let metadata = classPatchMap.get(Class); + if (!metadata) { + return; + } + classPatchMap.delete(Class); + + // reset to original + for (let k in metadata.origMethods) { + Class[k] = metadata.origMethods[k]; + } + + // apply other patches + for (let name of metadata.current) { + if (name !== patchName) { + patchClassMethods(Class, name, metadata.patches[name]); + } + } +} + +/** + * @param {Class} Class + * @param {string} patchName + */ +function unpatchInstanceMethods(Class, patchName) { + return webUtilsUnpatch(Class, patchName); +} + +//------------------------------------------------------------------------------ +// Export +//------------------------------------------------------------------------------ + +return { + cleanSearchTerm, + executeGracefully, + isEventHandled, + markEventHandled, + nextTick, + patchClassMethods, + patchInstanceMethods, + unpatchClassMethods, + unpatchInstanceMethods, +}; + +}); |
