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 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 = {
        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',
                },
                is_minimized: {
                    // In python this belongs to mail.channel.partner. Here for simplicity.
                    string: "isMinimized",
                    type: "boolean",
                },
                is_pinned: {
                    // In python this belongs to mail.channel.partner. Here for simplicity.
                    string: "isPinned",
                    type: "boolean",
                },
                message_unread_counter: {
                    string: "# unread messages",
                    type: 'integer',
                },
                name: {
                    string: "Name",
                    type: "char",
                    required: true,
                },
                state: {
                    // In python this belongs to mail.channel.partner. Here for simplicity.
                    string: "FoldState",
                    type: "char",
                },
                uuid: {
                    string: "UUID",
                    type: "char",
                    required: true,
                },
            },
            records: [],
        },
        '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: {
                activity_ids: {
                    string: "Activities",
                    type: 'one2many',
                    relation: 'mail.activity',
                },
                description: {
                    string: 'description',
                    type: 'text',
                },
                display_name: { string: "Displayed name", type: "char" },
                im_status: {
                    string: "status",
                    type: 'char',
                },
                message_follower_ids: {
                    relation: 'mail.followers',
                    string: "Followers",
                    type: "one2many",
                },
                message_attachment_count: {
                    string: 'Attachment count',
                    type: 'integer',
                },
                message_ids: {
                    string: "Messages",
                    type: 'one2many',
                    relation: 'mail.message',
                },
                name: {
                    string: "Name",
                    type: 'char',
                },
            },
            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;
    Object.assign(testEnv, {
        modelManager: new ModelManager(testEnv),
    });

    /**
     * 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,
};

});
