diff options
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 | 860 | ||||
| -rw-r--r-- | addons/mail/static/src/utils/throttle/throttle.js | 382 | ||||
| -rw-r--r-- | addons/mail/static/src/utils/throttle/throttle_tests.js | 410 | ||||
| -rw-r--r-- | addons/mail/static/src/utils/timer/timer.js | 165 | ||||
| -rw-r--r-- | addons/mail/static/src/utils/timer/timer_tests.js | 431 | ||||
| -rw-r--r-- | addons/mail/static/src/utils/utils.js | 127 |
7 files changed, 2396 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 00000000000..f96696fb992 --- /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 00000000000..e8e0c6dbc10 --- /dev/null +++ b/addons/mail/static/src/utils/test_utils.js @@ -0,0 +1,860 @@ +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 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 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(() => { + // trigger mounting of chat window manager + 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(() => { + // trigger mounting of dialog manager + 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) { + 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)); + 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 = { + initMessaging: { + channel_slots: {}, + commands: [], + is_moderator: false, + mail_failures: [], + mention_partner_suggestions: [], + menu_id: false, + moderation_counter: 0, + moderation_channel_ids: [], + needaction_inbox_counter: 0, + partner_root: { + active: false, + display_name: "OdooBot", + id: 2, + }, + public_partner: { + active: false, + display_name: "Public user", + id: 4, + }, + shortcodes: [], + starred_counter: 0, + }, + 'ir.attachment': { + fields: { + name: { type: 'char', string: "attachment name", required: true }, + res_model: { type: 'char', string: "res model" }, + res_id: { type: 'integer', string: "res id" }, + url: { type: 'char', string: 'url' }, + type: { type: 'selection', selection: [['url', "URL"], ['binary', "BINARY"]] }, + mimetype: { type: 'char', string: "mimetype" }, + }, + }, + 'mail.activity': { + fields: { + can_write: { + type: 'boolean', + }, + icon: { + type: 'string', + }, + id: { + type: 'integer', + }, + res_id: { + type: 'integer', + }, + res_model: { + type: 'string', + }, + }, + }, + 'mail.channel': { + fields: { + channel_type: { + string: "Channel Type", + type: "selection", + }, + id: { + string: "Id", + type: 'integer', + }, + message_unread_counter: { + string: "# unread messages", + type: 'integer', + }, + name: { + string: "Name", + type: "char", + required: true, + }, + }, + }, + 'mail.followers': { + fields: { + channel_id: { + type: 'integer', + }, + email: { + type: 'string', + }, + id: { + type: 'integer', + }, + is_active: { + type: 'boolean', + }, + is_editable: { + type: 'boolean', + }, + name: { + type: 'string', + }, + partner_id: { + type: 'integer', + }, + }, + }, + 'mail.message': { + fields: { + attachment_ids: { + string: "Attachments", + type: 'many2many', + relation: 'ir.attachment', + default: [], + }, + author_id: { + string: "Author", + relation: 'res.partner', + }, + body: { + string: "Contents", + type: 'html', + }, + channel_ids: { + string: "Channels", + type: 'many2many', + relation: 'mail.channel', + }, + date: { + string: "Date", + type: 'datetime', + }, + history_partner_ids: { + string: "Partners with History", + type: 'many2many', + relation: 'res.partner', + }, + id: { + string: "Id", + type: 'integer', + }, + is_discussion: { + string: "Discussion", + type: 'boolean', + }, + is_note: { + string: "Note", + type: 'boolean', + }, + is_notification: { + string: "Notification", + type: 'boolean', + }, + is_starred: { + string: "Starred", + type: 'boolean', + }, + message_type: { + string: "Type", + type: 'selection', + }, + model: { + string: "Related Document model", + type: 'char', + }, + needaction: { + string: "Need Action", + type: 'boolean', + }, + needaction_partner_ids: { + string: "Partners with Need Action", + type: 'many2many', + relation: 'res.partner', + }, + record_name: { + string: "Name", + type: 'string', + }, + res_id: { + string: "Related Document ID", + type: 'integer', + }, + starred: { + string: "Starred", + type: 'boolean', + }, + starred_partner_ids: { + string: "Favorited By", + type: 'many2many', + relation: 'res.partner', + }, + }, + }, + 'mail.notification': { + fields: { + is_read: { + string: "Is Read", + type: 'boolean', + }, + mail_message_id: { + string: "Message", + type: 'many2one', + relation: 'mail.message', + }, + res_partner_id: { + string: "Needaction Recipient", + type: 'many2one', + relation: 'res.partner', + }, + }, + }, + 'res.partner': { + fields: { + display_name: { string: "Displayed name", type: "char" }, + im_status: { + string: "status", + type: 'char', + }, + message_follower_ids: { + relation: 'follower', + string: "Followers", + type: "one2many", + }, + }, + records: [], + }, + 'res.users': { + fields: { + partner_id: { + string: "Related partners", + type: 'many2one', + relation: 'res.partner', + }, + }, + }, + }; + + const originals = { + '_.debounce': _.debounce, + '_.throttle': _.throttle, + 'window.fetch': window.fetch, + }; + + (function patch() { + // patch _.debounce and _.throttle to be fast and synchronous + _.debounce = _.identity; + _.throttle = _.identity; + let uploadedAttachmentsCount = 1; + window.fetch = async function (route, form) { + const formData = form.body; + return { + async text() { + const ufiles = formData.getAll('ufile'); + const files = ufiles.map(ufile => JSON.stringify({ + filename: ufile.name, + id: uploadedAttachmentsCount, + mimetype: ufile.type, + name: ufile.name, + })); + const callback = formData.get('callback'); + uploadedAttachmentsCount++; + return ` + <script language="javascript" type="text/javascript"> + var win = window.top.window; + win.jQuery(win).trigger('${callback}', ${files.join(', ')}); + </script>`; + } + }; + }; + })(); + + function unpatch() { + _.debounce = originals['_.debounce']; + _.throttle = originals['_.throttle']; + window.fetch = originals['window.fetch']; + } + + Object.assign(self, { data, unpatch }); + + return { + data, + unpatch, + }; +} + +function afterEach(self) { + self.unpatch(); +} + +/** + * 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.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 {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 {boolean} [param0.waitUntilMessagingInitialized=true] + * @param {...Object} [param0.kwargs] + * @returns {Object} + */ +async function start(param0 = {}) { + let callbacks = { + init: [], + mount: [], + destroy: [], + return: [], + }; + const { + hasActionManager = false, + hasChatWindow = false, + hasDialog = false, + hasDiscuss = false, + hasMessagingMenu = false, + hasTimeControl = false, + hasView = false, + waitUntilMessagingInitialized = true, + } = param0; + 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 = addMessagingToEnv(param0.env, { debug }); + 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() { + this._super(...arguments); + destroyCallbacks.forEach(callback => callback({ widget })); + legacyUnpatch(widget); + if (testEnv) { + testEnv.destroyMessaging(); + } + } + }); + } else if (hasActionManager) { + widget = await createActionManager(kwargs); + legacyPatch(widget, { + destroy() { + this._super(...arguments); + destroyCallbacks.forEach(callback => callback({ widget })); + 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') + ); + testEnv.modelManager.start(testEnv); + /** + * Create the messaging singleton record. + */ + testEnv.messaging = testEnv.models['mail.messaging'].create(); + testEnv.messaging.start().then(() => + testEnv.messagingInitializedDeferred.resolve() + ); + + const result = { env: testEnv, mockServer, widget }; + + if (waitUntilMessagingInitialized) { + // env key only accessible after MessagingService has started + await testEnv.messagingInitializedDeferred; + } + + if (mountCallbacks.length > 0) { + await afterNextRender(async () => { + await Promise.all(mountCallbacks.map(callback => callback({ selector, widget }))); + }); + } + returnCallbacks.forEach(callback => callback(result)); + 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); +} + +/** + * Set files in a file input + * + * @param {DOM.Element} el + * @param {Object[]} files must have been created beforehand + * @see testUtils.file.createFile + */ +function inputFiles(el, files) { + const dataTransfer = new window.DataTransfer(); + for (const file of files) { + dataTransfer.items.add(file); + } + el.files = dataTransfer.files; + /** + * Changing files programatically is not supposed to trigger the event but + * it does in Chrome versions before 73 (which is on runbot), so in that + * case there is no need to make a manual dispatch, because it would lead to + * the files being added twice. + */ + const versionRaw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./); + const chromeVersion = versionRaw ? parseInt(versionRaw[2], 10) : false; + if (!chromeVersion || chromeVersion >= 73) { + el.dispatchEvent(new Event('change')); + } +} + +/** + * 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, + dragenterFiles, + dropFiles, + inputFiles, + 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 00000000000..6b9ff00844a --- /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 00000000000..ecd3710d55c --- /dev/null +++ b/addons/mail/static/src/utils/throttle/throttle_tests.js @@ -0,0 +1,410 @@ +odoo.define('mail/static/src/utils/throttle/throttle_tests.js', function (require) { +'use strict'; + +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + 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() { + utilsBeforeEach(this); + this.throttles = []; + + this.start = async (...args) => { + const { env, widget } = await start(...args); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + // Important: tests should cleanly intercept cancelation errors that + // may result from this teardown. + this.widget.destroy(); + for (const t of this.throttles) { + t.clear(); + } + }, +}); + +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 00000000000..56d7f58e39e --- /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 00000000000..9b464ac9396 --- /dev/null +++ b/addons/mail/static/src/utils/timer/timer_tests.js @@ -0,0 +1,431 @@ +odoo.define('mail/static/src/utils/timer/timer_tests.js', function (require) { +'use strict'; + +const { + afterEach: utilsAfterEach, + beforeEach: utilsBeforeEach, + start, +} = require('mail/static/src/utils/test_utils.js'); +const Timer = require('mail/static/src/utils/timer/timer.js'); +const { nextTick } = require('mail/static/src/utils/test_utils.js'); + +const { TimerClearedError } = Timer; + +QUnit.module('mail', {}, function () { +QUnit.module('utils', {}, function () { +QUnit.module('timer', {}, function () { +QUnit.module('timer_tests.js', { + beforeEach() { + utilsBeforeEach(this); + this.timers = []; + + this.start = async (...args) => { + const { env, widget } = await start(...args); + this.env = env; + this.widget = widget; + }; + }, + afterEach() { + utilsAfterEach(this); + // Important: tests should cleanly intercept cancelation errors that + // may result from this teardown. + this.widget.destroy(); + for (const timer of this.timers) { + timer.clear(); + } + }, +}); + +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 00000000000..da0855437f7 --- /dev/null +++ b/addons/mail/static/src/utils/utils.js @@ -0,0 +1,127 @@ +odoo.define('mail/static/src/utils/utils.js', function (require) { +'use strict'; + +const { delay } = require('web.concurrency'); +const { + patch: webUtilsPatch, + unpatch: webUtilsUnpatch, +} = require('web.utils'); + +//------------------------------------------------------------------------------ +// Public +//------------------------------------------------------------------------------ + +const classPatchMap = new WeakMap(); + +/** + * 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 { + nextTick, + patchClassMethods, + patchInstanceMethods, + unpatchClassMethods, + unpatchInstanceMethods, +}; + +}); |
