summaryrefslogtreecommitdiff
path: root/addons/web/static/tests/helpers
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/tests/helpers
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/tests/helpers')
-rw-r--r--addons/web/static/tests/helpers/mock_server.js2060
-rw-r--r--addons/web/static/tests/helpers/qunit_asserts.js244
-rw-r--r--addons/web/static/tests/helpers/qunit_config.js249
-rw-r--r--addons/web/static/tests/helpers/test_env.js88
-rw-r--r--addons/web/static/tests/helpers/test_utils.js282
-rw-r--r--addons/web/static/tests/helpers/test_utils_control_panel.js351
-rw-r--r--addons/web/static/tests/helpers/test_utils_create.js512
-rw-r--r--addons/web/static/tests/helpers/test_utils_dom.js551
-rw-r--r--addons/web/static/tests/helpers/test_utils_fields.js250
-rw-r--r--addons/web/static/tests/helpers/test_utils_file.js158
-rw-r--r--addons/web/static/tests/helpers/test_utils_form.js74
-rw-r--r--addons/web/static/tests/helpers/test_utils_graph.js28
-rw-r--r--addons/web/static/tests/helpers/test_utils_kanban.js102
-rw-r--r--addons/web/static/tests/helpers/test_utils_mock.js781
-rw-r--r--addons/web/static/tests/helpers/test_utils_modal.js26
-rw-r--r--addons/web/static/tests/helpers/test_utils_pivot.js57
-rw-r--r--addons/web/static/tests/helpers/test_utils_tests.js36
17 files changed, 5849 insertions, 0 deletions
diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js
new file mode 100644
index 00000000..9f3c4747
--- /dev/null
+++ b/addons/web/static/tests/helpers/mock_server.js
@@ -0,0 +1,2060 @@
+odoo.define('web.MockServer', function (require) {
+"use strict";
+
+var Class = require('web.Class');
+var Domain = require('web.Domain');
+var pyUtils = require('web.py_utils');
+
+var MockServer = Class.extend({
+ /**
+ * @constructor
+ * @param {Object} data
+ * @param {Object} options
+ * @param {Object[]} [options.actions=[]]
+ * @param {Object} [options.archs={}] dict of archs with keys being strings like
+ * 'model,id,viewType'
+ * @param {boolean} [options.debug=false] logs RPCs if set to true
+ * @param {string} [options.currentDate] formatted string, default to
+ * current day
+ */
+ init: function (data, options) {
+ options = options || {};
+ this.data = data;
+ for (var modelName in this.data) {
+ var model = this.data[modelName];
+ if (!('id' in model.fields)) {
+ model.fields.id = {string: "ID", type: "integer"};
+ }
+ if (!('display_name' in model.fields)) {
+ model.fields.display_name = {string: "Display Name", type: "char"};
+ }
+ if (!('__last_update' in model.fields)) {
+ model.fields.__last_update = {string: "Last Modified on", type: "datetime"};
+ }
+ if (!('name' in model.fields)) {
+ model.fields.name = {string: "Name", type: "char", default: "name"};
+ }
+ model.records = model.records || [];
+
+ for (var i = 0; i < model.records.length; i++) {
+ const values = model.records[i];
+ // add potentially missing id
+ const id = values.id === undefined
+ ? this._getUnusedID(modelName) :
+ values.id;
+ // create a clean object, initial values are passed to write
+ model.records[i] = { id };
+ // ensure initial data goes through proper conversion (x2m, ...)
+ this._applyDefaults(model, values);
+ this._writeRecord(modelName, values, id, {
+ ensureIntegrity: false,
+ });
+ }
+ }
+
+ this.debug = options.debug;
+
+ this.currentDate = options.currentDate || moment().format("YYYY-MM-DD");
+
+ this.actions = options.actions || [];
+ this.archs = options.archs || {};
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Mocks a fields_get RPC for a given model.
+ *
+ * @param {string} model
+ * @returns {Object}
+ */
+ fieldsGet: function (model) {
+ return this.data[model].fields;
+ },
+ /**
+ * helper: read a string describing an arch, and returns a simulated
+ * 'field_view_get' call to the server. Calls processViews() of data_manager
+ * to mimick the real behavior of a call to loadViews().
+ *
+ * @param {Object} params
+ * @param {string|Object} params.arch a string OR a parsed xml document
+ * @param {Number} [params.view_id] the id of the arch's view
+ * @param {string} params.model a model name (that should be in this.data)
+ * @param {Object} params.toolbar the actions possible in the toolbar
+ * @param {Object} [params.viewOptions] the view options set in the test (optional)
+ * @returns {Object} an object with 2 keys: arch and fields
+ */
+ fieldsViewGet: function (params) {
+ var model = params.model;
+ var toolbar = params.toolbar;
+ var viewId = params.view_id;
+ var viewOptions = params.viewOptions || {};
+ if (!(model in this.data)) {
+ throw new Error('Model ' + model + ' was not defined in mock server data');
+ }
+ var fields = $.extend(true, {}, this.data[model].fields);
+ var fvg = this._fieldsViewGet(params.arch, model, fields, viewOptions.context || {});
+ if (toolbar) {
+ fvg.toolbar = toolbar;
+ }
+ if (viewId) {
+ fvg.view_id = viewId;
+ }
+ return fvg;
+ },
+ /**
+ * Simulates a complete fetch call.
+ *
+ * @param {string} resource
+ * @param {Object} init
+ * @returns {any}
+ */
+ async performFetch(resource, init) {
+ if (this.debug) {
+ console.log(
+ '%c[fetch] request ' + resource, 'color: blue; font-weight: bold;',
+ JSON.parse(JSON.stringify(init))
+ );
+ }
+ const res = await this._performFetch(resource, init);
+ if (this.debug) {
+ console.log('%c[fetch] response' + resource, 'color: blue; font-weight: bold;', res);
+ }
+ return res;
+ },
+ /**
+ * Simulate a complete RPC call. This is the main method for this class.
+ *
+ * This method also log incoming and outgoing data, and stringify/parse data
+ * to simulate a barrier between the server and the client. It also simulate
+ * server errors.
+ *
+ * @param {string} route
+ * @param {Object} args
+ * @returns {Promise<any>}
+ * Resolved with the result of the RPC, stringified then parsed.
+ * If the RPC should fail, the promise will be rejected with the
+ * error object, stringified then parsed.
+ */
+ performRpc: function (route, args) {
+ var debug = this.debug;
+ args = JSON.parse(JSON.stringify(args));
+ if (debug) {
+ console.log('%c[rpc] request ' + route, 'color: blue; font-weight: bold;', args);
+ args = JSON.parse(JSON.stringify(args));
+ }
+ var def = this._performRpc(route, args);
+
+ var abort = def.abort || def.reject;
+ if (abort) {
+ abort = abort.bind(def);
+ } else {
+ abort = function () {
+ throw new Error("Can't abort this request");
+ };
+ }
+
+ def = def.then(function (result) {
+ var resultString = JSON.stringify(result || false);
+ if (debug) {
+ console.log('%c[rpc] response' + route, 'color: blue; font-weight: bold;', JSON.parse(resultString));
+ }
+ return JSON.parse(resultString);
+ }, function (result) {
+ var message = result && result.message;
+ var event = result && result.event;
+ var errorString = JSON.stringify(message || false);
+ console.warn('%c[rpc] response (error) ' + route, 'color: orange; font-weight: bold;', JSON.parse(errorString));
+ return Promise.reject({message: errorString, event: event || $.Event()});
+ });
+
+ def.abort = abort;
+ return def;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Apply the default values when creating an object in the local database.
+ *
+ * @private
+ * @param {Object} model a model object from the local database
+ * @param {Object} record
+ */
+ _applyDefaults: function (model, record) {
+ record.display_name = record.display_name || record.name;
+ for (var fieldName in model.fields) {
+ if (fieldName === 'id') {
+ continue;
+ }
+ if (!(fieldName in record)) {
+ if ('default' in model.fields[fieldName]) {
+ const def = model.fields[fieldName].default;
+ record[fieldName] = typeof def === 'function' ? def.call(this) : def;
+ } else if (_.contains(['one2many', 'many2many'], model.fields[fieldName].type)) {
+ record[fieldName] = [];
+ } else {
+ record[fieldName] = false;
+ }
+ }
+ }
+ },
+ /**
+ * Converts an Object representing a record to actual return Object of the
+ * python `onchange` method.
+ * Specifically, it applies `name_get` on many2one's and transforms raw id
+ * list in orm command lists for x2many's.
+ * For x2m fields that add or update records (ORM commands 0 and 1), it is
+ * recursive.
+ *
+ * @private
+ * @param {string} model: the model's name
+ * @param {Object} values: an object representing a record
+ * @returns {Object}
+ */
+ _convertToOnChange(model, values) {
+ Object.entries(values).forEach(([fname, val]) => {
+ const field = this.data[model].fields[fname];
+ if (field.type === 'many2one' && typeof val === 'number') {
+ // implicit name_get
+ const m2oRecord = this.data[field.relation].records.find(r => r.id === val);
+ values[fname] = [val, m2oRecord.display_name];
+ } else if (field.type === 'one2many' || field.type === 'many2many') {
+ // TESTS ONLY
+ // one2many_ids = [1,2,3] is a simpler way to express it than orm commands
+ const isCommandList = val.length && Array.isArray(val[0]);
+ if (!isCommandList) {
+ values[fname] = [[6, false, val]];
+ } else {
+ val.forEach(cmd => {
+ if (cmd[0] === 0 || cmd[0] === 1) {
+ cmd[2] = this._convertToOnChange(field.relation, cmd[2]);
+ }
+ });
+ }
+ }
+ });
+ return values;
+ },
+ /**
+ * helper to evaluate a domain for given field values.
+ * Currently, this is only a wrapper of the Domain.compute function in
+ * "web.Domain".
+ *
+ * @param {Array} domain
+ * @param {Object} fieldValues
+ * @returns {boolean}
+ */
+ _evaluateDomain: function (domain, fieldValues) {
+ return new Domain(domain).compute(fieldValues);
+ },
+ /**
+ * helper: read a string describing an arch, and returns a simulated
+ * 'fields_view_get' call to the server.
+ *
+ * @private
+ * @param {string} arch a string OR a parsed xml document
+ * @param {string} model a model name (that should be in this.data)
+ * @param {Object} fields
+ * @param {Object} context
+ * @returns {Object} an object with 2 keys: arch and fields (the fields
+ * appearing in the views)
+ */
+ _fieldsViewGet: function (arch, model, fields, context) {
+ var self = this;
+ var modifiersNames = ['invisible', 'readonly', 'required'];
+ var onchanges = this.data[model].onchanges || {};
+ var fieldNodes = {};
+ var groupbyNodes = {};
+
+ var doc;
+ if (typeof arch === 'string') {
+ doc = $.parseXML(arch).documentElement;
+ } else {
+ doc = arch;
+ }
+
+ var inTreeView = (doc.tagName === 'tree');
+
+ // mock _postprocess_access_rights
+ const isBaseModel = !context.base_model_name || (model === context.base_model_name);
+ var views = ['kanban', 'tree', 'form', 'gantt', 'activity'];
+ if (isBaseModel && views.indexOf(doc.tagName) !== -1) {
+ for (let action of ['create', 'delete', 'edit', 'write']) {
+ if (!doc.getAttribute(action) && action in context && !context[action]) {
+ doc.setAttribute(action, 'false');
+ }
+ }
+ }
+
+ this._traverse(doc, function (node) {
+ if (node.nodeType === Node.TEXT_NODE) {
+ return false;
+ }
+ var modifiers = {};
+
+ var isField = (node.tagName === 'field');
+ var isGroupby = (node.tagName === 'groupby');
+
+ if (isField) {
+ var fieldName = node.getAttribute('name');
+ fieldNodes[fieldName] = node;
+
+ // 'transfer_field_to_modifiers' simulation
+ var field = fields[fieldName];
+
+ if (!field) {
+ throw new Error("Field " + fieldName + " does not exist");
+ }
+ var defaultValues = {};
+ var stateExceptions = {};
+ _.each(modifiersNames, function (attr) {
+ stateExceptions[attr] = [];
+ defaultValues[attr] = !!field[attr];
+ });
+ _.each(field.states || {}, function (modifs, state) {
+ _.each(modifs, function (modif) {
+ if (defaultValues[modif[0]] !== modif[1]) {
+ stateExceptions[modif[0]].append(state);
+ }
+ });
+ });
+ _.each(defaultValues, function (defaultValue, attr) {
+ if (stateExceptions[attr].length) {
+ modifiers[attr] = [("state", defaultValue ? "not in" : "in", stateExceptions[attr])];
+ } else {
+ modifiers[attr] = defaultValue;
+ }
+ });
+ } else if (isGroupby && !node._isProcessed) {
+ var groupbyName = node.getAttribute('name');
+ fieldNodes[groupbyName] = node;
+ groupbyNodes[groupbyName] = node;
+ }
+
+ // 'transfer_node_to_modifiers' simulation
+ var attrs = node.getAttribute('attrs');
+ if (attrs) {
+ attrs = pyUtils.py_eval(attrs);
+ _.extend(modifiers, attrs);
+ }
+
+ var states = node.getAttribute('states');
+ if (states) {
+ if (!modifiers.invisible) {
+ modifiers.invisible = [];
+ }
+ modifiers.invisible.push(["state", "not in", states.split(",")]);
+ }
+
+ const inListHeader = inTreeView && node.closest('header');
+ _.each(modifiersNames, function (a) {
+ var mod = node.getAttribute(a);
+ if (mod) {
+ var pyevalContext = window.py.dict.fromJSON(context || {});
+ var v = pyUtils.py_eval(mod, {context: pyevalContext}) ? true: false;
+ if (inTreeView && !inListHeader && a === 'invisible') {
+ modifiers.column_invisible = v;
+ } else if (v || !(a in modifiers) || !_.isArray(modifiers[a])) {
+ modifiers[a] = v;
+ }
+ }
+ });
+
+ _.each(modifiersNames, function (a) {
+ if (a in modifiers && (!!modifiers[a] === false || (_.isArray(modifiers[a]) && !modifiers[a].length))) {
+ delete modifiers[a];
+ }
+ });
+
+ if (Object.keys(modifiers).length) {
+ node.setAttribute('modifiers', JSON.stringify(modifiers));
+ }
+
+ if (isGroupby && !node._isProcessed) {
+ return false;
+ }
+
+ return !isField;
+ });
+
+ var relModel, relFields;
+ _.each(fieldNodes, function (node, name) {
+ var field = fields[name];
+ if (field.type === "many2one" || field.type === "many2many") {
+ var canCreate = node.getAttribute('can_create');
+ node.setAttribute('can_create', canCreate || "true");
+ var canWrite = node.getAttribute('can_write');
+ node.setAttribute('can_write', canWrite || "true");
+ }
+ if (field.type === "one2many" || field.type === "many2many") {
+ field.views = {};
+ _.each(node.childNodes, function (children) {
+ if (children.tagName) { // skip text nodes
+ relModel = field.relation;
+ relFields = $.extend(true, {}, self.data[relModel].fields);
+ field.views[children.tagName] = self._fieldsViewGet(children, relModel,
+ relFields, _.extend({}, context, {base_model_name: model}));
+ }
+ });
+ }
+
+ // add onchanges
+ if (name in onchanges) {
+ node.setAttribute('on_change', "1");
+ }
+ });
+ _.each(groupbyNodes, function (node, name) {
+ var field = fields[name];
+ if (field.type !== 'many2one') {
+ throw new Error('groupby can only target many2one');
+ }
+ field.views = {};
+ relModel = field.relation;
+ relFields = $.extend(true, {}, self.data[relModel].fields);
+ node._isProcessed = true;
+ // postprocess simulation
+ field.views.groupby = self._fieldsViewGet(node, relModel, relFields, context);
+ while (node.firstChild) {
+ node.removeChild(node.firstChild);
+ }
+ });
+
+ var xmlSerializer = new XMLSerializer();
+ var processedArch = xmlSerializer.serializeToString(doc);
+ return {
+ arch: processedArch,
+ fields: _.pick(fields, _.keys(fieldNodes)),
+ model: model,
+ type: doc.tagName === 'tree' ? 'list' : doc.tagName,
+ };
+ },
+ /**
+ * Get all records from a model matching a domain. The only difficulty is
+ * that if we have an 'active' field, we implicitely add active = true in
+ * the domain.
+ *
+ * @private
+ * @param {string} model a model name
+ * @param {any[]} domain
+ * @param {Object} [params={}]
+ * @param {boolean} [params.active_test=true]
+ * @returns {Object[]} a list of records
+ */
+ _getRecords: function (model, domain, { active_test = true } = {}) {
+ if (!_.isArray(domain)) {
+ throw new Error("MockServer._getRecords: given domain has to be an array.");
+ }
+
+ var self = this;
+ var records = this.data[model].records;
+
+ if (active_test && 'active' in this.data[model].fields) {
+ // add ['active', '=', true] to the domain if 'active' is not yet present in domain
+ var activeInDomain = false;
+ _.each(domain, function (subdomain) {
+ activeInDomain = activeInDomain || subdomain[0] === 'active';
+ });
+ if (!activeInDomain) {
+ domain = [['active', '=', true]].concat(domain);
+ }
+ }
+
+ if (domain.length) {
+ // 'child_of' operator isn't supported by domain.js, so we replace
+ // in by the 'in' operator (with the ids of children)
+ domain = domain.map(function (criterion) {
+ if (criterion[1] === 'child_of') {
+ var oldLength = 0;
+ var childIDs = [criterion[2]];
+ while (childIDs.length > oldLength) {
+ oldLength = childIDs.length;
+ _.each(records, function (r) {
+ if (childIDs.indexOf(r.parent_id) >= 0) {
+ childIDs.push(r.id);
+ }
+ });
+ }
+ criterion = [criterion[0], 'in', childIDs];
+ }
+ return criterion;
+ });
+ records = _.filter(records, function (record) {
+ return self._evaluateDomain(domain, record);
+ });
+ }
+
+ return records;
+ },
+ /**
+ * Helper function, to find an available ID. The current algorithm is to
+ * return the currently highest id + 1.
+ *
+ * @private
+ * @param {string} modelName
+ * @returns {integer} a valid ID (> 0)
+ */
+ _getUnusedID: function (modelName) {
+ var model = this.data[modelName];
+ return model.records.reduce((max, record) => {
+ if (!Number.isInteger(record.id)) {
+ return max;
+ }
+ return Math.max(record.id, max);
+ }, 0) + 1;
+ },
+ /**
+ * Simulate a 'call_button' operation from a view.
+ *
+ * @private
+ * @param {Object} param0
+ * @param {Array<integer[]>} param0.args
+ * @param {Object} [param0.kargs]
+ * @param {string} param0.method
+ * @param {string} param0.model
+ * @returns {any}
+ * @throws {Error} in case the call button of provided model/method is not
+ * implemented.
+ */
+ _mockCallButton({ args, kwargs, method, model }) {
+ throw new Error(`Unimplemented mocked call button on "${model}"/"${method}"`);
+ },
+ /**
+ * Simulate a 'copy' operation, so we simply try to duplicate a record in
+ * memory
+ *
+ * @private
+ * @param {string} modelName
+ * @param {integer} id the ID of a valid record
+ * @returns {integer} the ID of the duplicated record
+ */
+ _mockCopy: function (modelName, id) {
+ var model = this.data[modelName];
+ var newID = this._getUnusedID(modelName);
+ var originalRecord = _.findWhere(model.records, {id: id});
+ var duplicateRecord = _.extend({}, originalRecord, {id: newID});
+ duplicateRecord.display_name = originalRecord.display_name + ' (copy)';
+ model.records.push(duplicateRecord);
+ return newID;
+ },
+ /**
+ * Simulate a 'create' operation. This is basically a 'write' with the
+ * added work of getting a valid ID and applying default values.
+ *
+ * @private
+ * @param {string} modelName
+ * @param {Object} values
+ * @returns {integer}
+ */
+ _mockCreate: function (modelName, values) {
+ if ('id' in values) {
+ throw new Error("Cannot create a record with a predefinite id");
+ }
+ var model = this.data[modelName];
+ var id = this._getUnusedID(modelName);
+ var record = {id: id};
+ model.records.push(record);
+ this._applyDefaults(model, values);
+ this._writeRecord(modelName, values, id);
+ return id;
+ },
+ /**
+ * Simulate a 'default_get' operation
+ *
+ * @private
+ * @param {string} modelName
+ * @param {array[]} args a list with a list of fields in the first position
+ * @param {Object} [kwargs={}]
+ * @param {Object} [kwargs.context] the context to eventually read default
+ * values
+ * @returns {Object}
+ */
+ _mockDefaultGet: function (modelName, args, kwargs = {}) {
+ const fields = args[0];
+ const model = this.data[modelName];
+ const result = {};
+ for (const fieldName of fields) {
+ const key = "default_" + fieldName;
+ if (kwargs.context && key in kwargs.context) {
+ result[fieldName] = kwargs.context[key];
+ continue;
+ }
+ const field = model.fields[fieldName];
+ if ('default' in field) {
+ result[fieldName] = field.default;
+ continue;
+ }
+ }
+ for (const fieldName in result) {
+ const field = model.fields[fieldName];
+ if (field.type === "many2one") {
+ const recordExists = this.data[field.relation].records.some(
+ (r) => r.id === result[fieldName]
+ );
+ if (!recordExists) {
+ delete result[fieldName];
+ }
+ }
+ }
+ return result;
+ },
+ /**
+ * Simulate a 'fields_get' operation
+ *
+ * @private
+ * @param {string} modelName
+ * @param {any} args
+ * @returns {Object}
+ */
+ _mockFieldsGet: function (modelName, args) {
+ var modelFields = this.data[modelName].fields;
+ // Get only the asked fields (args[0] could be the field names)
+ if (args[0] && args[0].length) {
+ modelFields = _.pick.apply(_, [modelFields].concat(args[0]));
+ }
+ // Get only the asked attributes (args[1] could be the attribute names)
+ if (args[1] && args[1].length) {
+ modelFields = _.mapObject(modelFields, function (field) {
+ return _.pick.apply(_, [field].concat(args[1]));
+ });
+ }
+ return modelFields;
+ },
+ /**
+ * Simulates a call to the server '_search_panel_field_image' method.
+ *
+ * @private
+ * @param {string} model
+ * @param {string} fieldName
+ * @param {Object} kwargs
+ * @see _mockSearchPanelDomainImage()
+ */
+ _mockSearchPanelFieldImage(model, fieldName, kwargs) {
+ const enableCounters = kwargs.enable_counters;
+ const onlyCounters = kwargs.only_counters;
+ const extraDomain = kwargs.extra_domain || [];
+ const normalizedExtra = Domain.prototype.normalizeArray(extraDomain);
+ const noExtra = JSON.stringify(normalizedExtra) === "[]";
+ const modelDomain = kwargs.model_domain || [];
+ const countDomain = Domain.prototype.normalizeArray([
+ ...modelDomain,
+ ...extraDomain,
+ ]);
+
+ const limit = kwargs.limit;
+ const setLimit = kwargs.set_limit;
+
+ if (onlyCounters) {
+ return this._mockSearchPanelDomainImage(model, fieldName, countDomain, true);
+ }
+
+ const modelDomainImage = this._mockSearchPanelDomainImage(
+ model,
+ fieldName,
+ modelDomain,
+ enableCounters && noExtra,
+ setLimit && limit
+ );
+ if (enableCounters && !noExtra) {
+ const countDomainImage = this._mockSearchPanelDomainImage(
+ model,
+ fieldName,
+ countDomain,
+ true
+ );
+ for (const [id, values] of modelDomainImage.entries()) {
+ const element = countDomainImage.get(id);
+ values.__count = element ? element.__count : 0;
+ }
+ }
+
+ return modelDomainImage;
+ },
+
+ /**
+ * Simulates a call to the server '_search_panel_domain_image' method.
+ *
+ * @private
+ * @param {string} model
+ * @param {Array[]} domain
+ * @param {string} fieldName
+ * @param {boolean} setCount
+ * @returns {Map}
+ */
+ _mockSearchPanelDomainImage: function (model, fieldName, domain, setCount=false, limit=false) {
+ const field = this.data[model].fields[fieldName];
+ let groupIdName;
+ if (field.type === 'many2one') {
+ groupIdName = value => value || [false, undefined];
+ // mockReadGroup does not take care of the condition [fieldName, '!=', false]
+ // in the domain defined below !!!
+ } else if (field.type === 'selection') {
+ const selection = {};
+ for (const [value, label] of this.data[model].fields[fieldName].selection) {
+ selection[value] = label;
+ }
+ groupIdName = value => [value, selection[value]];
+ }
+ domain = Domain.prototype.normalizeArray([
+ ...domain,
+ [fieldName, '!=', false],
+ ]);
+ const groups = this._mockReadGroup(model, {
+ domain,
+ fields: [fieldName],
+ groupby: [fieldName],
+ limit,
+ });
+ const domainImage = new Map();
+ for (const group of groups) {
+ const [id, display_name] = groupIdName(group[fieldName]);
+ const values = { id, display_name };
+ if (setCount) {
+ values.__count = group[fieldName + '_count'];
+ }
+ domainImage.set(id, values);
+ }
+ return domainImage;
+ },
+ /**
+ * Simulates a call to the server '_search_panel_global_counters' method.
+ *
+ * @private
+ * @param {Map} valuesRange
+ * @param {(string|boolean)} parentName 'parent_id' or false
+ */
+ _mockSearchPanelGlobalCounters: function (valuesRange, parentName) {
+ const localCounters = [...valuesRange.keys()].map(id => valuesRange.get(id).__count);
+ for (let [id, values] of valuesRange.entries()) {
+ const count = localCounters[id];
+ if (count) {
+ let parent_id = values[parentName];
+ while (parent_id) {
+ values = valuesRange.get(parent_id);
+ values.__count += count;
+ parent_id = values[parentName];
+ }
+ }
+ }
+ },
+ /**
+ * Simulates a call to the server '_search_panel_sanitized_parent_hierarchy' method.
+ *
+ * @private
+ * @param {Object[]} records
+ * @param {(string|boolean)} parentName 'parent_id' or false
+ * @param {number[]} ids
+ * @returns {Object[]}
+ */
+ _mockSearchPanelSanitizedParentHierarchy: function (records, parentName, ids) {
+ const getParentId = record => record[parentName] && record[parentName][0];
+ const allowedRecords = {};
+ for (const record of records) {
+ allowedRecords[record.id] = record;
+ }
+ const recordsToKeep = {};
+ for (const id of ids) {
+ const ancestorChain = {};
+ let recordId = id;
+ let chainIsFullyIncluded = true;
+ while (chainIsFullyIncluded && recordId) {
+ const knownStatus = recordsToKeep[recordId];
+ if (knownStatus !== undefined) {
+ chainIsFullyIncluded = knownStatus;
+ break;
+ }
+ const record = allowedRecords[recordId];
+ if (record) {
+ ancestorChain[recordId] = record;
+ recordId = getParentId(record);
+ } else {
+ chainIsFullyIncluded = false;
+ }
+ }
+ for (const id in ancestorChain) {
+ recordsToKeep[id] = chainIsFullyIncluded;
+ }
+ }
+ return records.filter(rec => recordsToKeep[rec.id]);
+ },
+ /**
+ * Simulates a call to the server 'search_panel_selection_range' method.
+ *
+ * @private
+ * @param {string} model
+ * @param {string} fieldName
+ * @param {Object} kwargs
+ * @returns {Object[]}
+ */
+ _mockSearchPanelSelectionRange: function (model, fieldName, kwargs) {
+ const enableCounters = kwargs.enable_counters;
+ const expand = kwargs.expand;
+ let domainImage;
+ if (enableCounters || !expand) {
+ const newKwargs = Object.assign({}, kwargs, {
+ only_counters: expand,
+ });
+ domainImage = this._mockSearchPanelFieldImage(model, fieldName, newKwargs);
+ }
+ if (!expand) {
+ return [...domainImage.values()];
+ }
+ const selection = this.data[model].fields[fieldName].selection;
+ const selectionRange = [];
+ for (const [value, label] of selection) {
+ const values = {
+ id: value,
+ display_name: label,
+ };
+ if (enableCounters) {
+ values.__count = domainImage.get(value) ? domainImage.get(value).__count : 0;
+ }
+ selectionRange.push(values);
+ }
+ return selectionRange;
+ },
+ /**
+ * Simulates a call to the server 'search_panel_select_range' method.
+ *
+ * @private
+ * @param {string} model
+ * @param {string[]} args
+ * @param {string} args[fieldName]
+ * @param {Object} [kwargs={}]
+ * @param {Array[]} [kwargs.category_domain] domain generated by categories
+ * (this parameter is used in _search_panel_range)
+ * @param {Array[]} [kwargs.comodel_domain] domain of field values (if relational)
+ * (this parameter is used in _search_panel_range)
+ * @param {boolean} [kwargs.enable_counters] whether to count records by value
+ * @param {Array[]} [kwargs.filter_domain] domain generated by filters
+ * @param {integer} [kwargs.limit] maximal number of values to fetch
+ * @param {Array[]} [kwargs.search_domain] base domain of search (this parameter
+ * is used in _search_panel_range)
+ * @returns {Object}
+ */
+ _mockSearchPanelSelectRange: function (model, [fieldName], kwargs) {
+ const field = this.data[model].fields[fieldName];
+ const supportedTypes = ['many2one', 'selection'];
+ if (!supportedTypes.includes(field.type)) {
+ throw new Error(`Only types ${supportedTypes} are supported for category (found type ${field.type})`);
+ }
+
+ const modelDomain = kwargs.search_domain || [];
+ const extraDomain = Domain.prototype.normalizeArray([
+ ...(kwargs.category_domain || []),
+ ...(kwargs.filter_domain || []),
+ ]);
+
+ if (field.type === 'selection') {
+ const newKwargs = Object.assign({}, kwargs, {
+ model_domain: modelDomain,
+ extra_domain: extraDomain,
+ });
+ kwargs.model_domain = modelDomain;
+ return {
+ parent_field: false,
+ values: this._mockSearchPanelSelectionRange(model, fieldName, newKwargs),
+ };
+ }
+
+ const fieldNames = ['display_name'];
+ let hierarchize = 'hierarchize' in kwargs ? kwargs.hierarchize : true;
+ let getParentId;
+ let parentName = false;
+ if (hierarchize && this.data[field.relation].fields.parent_id) {
+ parentName = 'parent_id'; // in tests, parent field is always 'parent_id'
+ fieldNames.push(parentName);
+ getParentId = record => record.parent_id && record.parent_id[0];
+ } else {
+ hierarchize = false;
+ }
+ let comodelDomain = kwargs.comodel_domain || [];
+ const enableCounters = kwargs.enable_counters;
+ const expand = kwargs.expand;
+ const limit = kwargs.limit;
+ let domainImage;
+ if (enableCounters || !expand) {
+ const newKwargs = Object.assign({}, kwargs, {
+ model_domain: modelDomain,
+ extra_domain: extraDomain,
+ only_counters: expand,
+ set_limit: limit && !(expand || hierarchize || comodelDomain),
+ });
+ domainImage = this._mockSearchPanelFieldImage(model, fieldName, newKwargs);
+ }
+ if (!expand && !hierarchize && !comodelDomain.length) {
+ if (limit && domainImage.size === limit) {
+ return { error_msg: "Too many items to display." };
+ }
+ return {
+ parent_field: parentName,
+ values: [...domainImage.values()],
+ };
+ }
+ let imageElementIds;
+ if (!expand) {
+ imageElementIds = [...domainImage.keys()].map(Number);
+ let condition;
+ if (hierarchize) {
+ const records = this.data[field.relation].records;
+ const ancestorIds = new Set();
+ for (const id of imageElementIds) {
+ let recordId = id;
+ let record;
+ while (recordId) {
+ ancestorIds.add(recordId);
+ record = records.find(rec => rec.id === recordId);
+ recordId = record[parentName];
+ }
+ }
+ condition = ['id', 'in', [...new Set(ancestorIds)]];
+ } else {
+ condition = ['id', 'in', imageElementIds];
+ }
+ comodelDomain = Domain.prototype.normalizeArray([
+ ...comodelDomain,
+ condition,
+ ]);
+ }
+ let comodelRecords = this._mockSearchRead(field.relation, [comodelDomain, fieldNames], { limit });
+
+ if (hierarchize) {
+ const ids = expand ? comodelRecords.map(rec => rec.id) : imageElementIds;
+ comodelRecords = this._mockSearchPanelSanitizedParentHierarchy(comodelRecords, parentName, ids);
+ }
+
+ if (limit && comodelRecords.length === limit) {
+ return { error_msg: "Too many items to display." };
+ }
+ // A map is used to keep the initial order.
+ const fieldRange = new Map();
+ for (const record of comodelRecords) {
+ const values = {
+ id: record.id,
+ display_name: record.display_name,
+ };
+ if (hierarchize) {
+ values[parentName] = getParentId(record);
+ }
+ if (enableCounters) {
+ values.__count = domainImage.get(record.id) ? domainImage.get(record.id).__count : 0;
+ }
+ fieldRange.set(record.id, values);
+ }
+
+ if (hierarchize && enableCounters) {
+ this._mockSearchPanelGlobalCounters(fieldRange, parentName);
+ }
+
+ return {
+ parent_field: parentName,
+ values: [...fieldRange.values()],
+ };
+ },
+ /**
+ * Simulates a call to the server 'search_panel_select_multi_range' method.
+ *
+ * @private
+ * @param {string} model
+ * @param {string[]} args
+ * @param {string} args[fieldName]
+ * @param {Object} [kwargs={}]
+ * @param {Array[]} [kwargs.category_domain] domain generated by categories
+ * @param {Array[]} [kwargs.comodel_domain] domain of field values (if relational)
+ * (this parameter is used in _search_panel_range)
+ * @param {boolean} [kwargs.enable_counters] whether to count records by value
+ * @param {Array[]} [kwargs.filter_domain] domain generated by filters
+ * @param {string} [kwargs.group_by] extra field to read on comodel, to group
+ * comodel records
+ * @param {Array[]} [kwargs.group_domain] dict, one domain for each activated
+ * group for the group_by (if any). Those domains are used to fech accurate
+ * counters for values in each group
+ * @param {integer} [kwargs.limit] maximal number of values to fetch
+ * @param {Array[]} [kwargs.search_domain] base domain of search
+ * @returns {Object}
+ */
+ _mockSearchPanelSelectMultiRange: function (model, [fieldName], kwargs) {
+ const field = this.data[model].fields[fieldName];
+ const supportedTypes = ['many2one', 'many2many', 'selection'];
+ if (!supportedTypes.includes(field.type)) {
+ throw new Error(`Only types ${supportedTypes} are supported for filter (found type ${field.type})`);
+ }
+ let modelDomain = kwargs.search_domain || [];
+ let extraDomain = Domain.prototype.normalizeArray([
+ ...(kwargs.category_domain || []),
+ ...(kwargs.filter_domain || []),
+ ]);
+ if (field.type === 'selection') {
+ const newKwargs = Object.assign({}, kwargs, {
+ model_domain: modelDomain,
+ extra_domain: extraDomain,
+ });
+ return {
+ values: this._mockSearchPanelSelectionRange(model, fieldName, newKwargs),
+ };
+ }
+ const fieldNames = ['display_name'];
+ const groupBy = kwargs.group_by;
+ let groupIdName;
+ if (groupBy) {
+ const groupByField = this.data[field.relation].fields[groupBy];
+ fieldNames.push(groupBy);
+ if (groupByField.type === 'many2one') {
+ groupIdName = value => value || [false, "Not set"];
+ } else if (groupByField.type === 'selection') {
+ const groupBySelection = Object.assign({}, this.data[field.relation].fields[groupBy].selection);
+ groupBySelection[false] = "Not Set";
+ groupIdName = value => [value, groupBySelection[value]];
+ } else {
+ groupIdName = value => value ? [value, value] : [false, "Not set"];
+ }
+ }
+ let comodelDomain = kwargs.comodel_domain || [];
+ const enableCounters = kwargs.enable_counters;
+ const expand = kwargs.expand;
+ const limit = kwargs.limit;
+ if (field.type === 'many2many') {
+ const comodelRecords = this._mockSearchRead(field.relation, [comodelDomain, fieldNames], { limit });
+ if (expand && limit && comodelRecords.length === limit) {
+ return { error_msg: "Too many items to display." };
+ }
+
+ const groupDomain = kwargs.group_domain;
+ const fieldRange = [];
+ for (const record of comodelRecords) {
+ const values= {
+ id: record.id,
+ display_name: record.display_name,
+ };
+ let groupId;
+ if (groupBy) {
+ const [gId, gName] = groupIdName(record[groupBy]);
+ values.group_id = groupId = gId;
+ values.group_name = gName;
+ }
+ let count;
+ let inImage;
+ if (enableCounters || !expand) {
+ const searchDomain = Domain.prototype.normalizeArray([
+ ...modelDomain,
+ [fieldName, "in", record.id]
+ ]);
+ let localExtraDomain = extraDomain;
+ if (groupBy && groupDomain) {
+ localExtraDomain = Domain.prototype.normalizeArray([
+ ...localExtraDomain,
+ ...(groupDomain[JSON.stringify(groupId)] || []),
+ ]);
+ }
+ const searchCountDomain = Domain.prototype.normalizeArray([
+ ...searchDomain,
+ ...localExtraDomain,
+ ]);
+ if (enableCounters) {
+ count = this._mockSearchCount(model, [searchCountDomain]);
+ }
+ if (!expand) {
+ if (
+ enableCounters &&
+ JSON.stringify(localExtraDomain) === "[]"
+ ) {
+ inImage = count;
+ } else {
+ inImage = (this._mockSearch(model, [searchDomain], { limit: 1 })).length;
+ }
+ }
+ }
+ if (expand || inImage) {
+ if (enableCounters) {
+ values.__count = count;
+ }
+ fieldRange.push(values);
+ }
+ }
+
+ if (!expand && limit && fieldRange.length === limit) {
+ return { error_msg: "Too many items to display." };
+ }
+
+ return { values: fieldRange };
+ }
+
+ if (field.type === 'many2one') {
+ let domainImage;
+ if (enableCounters || !expand) {
+ extraDomain = Domain.prototype.normalizeArray([
+ ...extraDomain,
+ ...(kwargs.group_domain || []),
+ ]);
+ modelDomain = Domain.prototype.normalizeArray([
+ ...modelDomain,
+ ...(kwargs.group_domain || []),
+ ]);
+ const newKwargs = Object.assign({}, kwargs, {
+ model_domain: modelDomain,
+ extra_domain: extraDomain,
+ only_counters: expand,
+ set_limit: limit && !(expand || groupBy || comodelDomain),
+ });
+ domainImage = this._mockSearchPanelFieldImage(model, fieldName, newKwargs);
+ }
+ if (!expand && !groupBy && !comodelDomain.length) {
+ if (limit && domainImage.size === limit) {
+ return { error_msg: "Too many items to display." };
+ }
+ return { values: [...domainImage.values()] };
+ }
+ if (!expand) {
+ const imageElementIds = [...domainImage.keys()].map(Number);
+ comodelDomain = Domain.prototype.normalizeArray([
+ ...comodelDomain,
+ ['id', 'in', imageElementIds],
+ ]);
+ }
+ const comodelRecords = this._mockSearchRead(field.relation, [comodelDomain, fieldNames], { limit });
+ if (limit && comodelRecords.length === limit) {
+ return { error_msg: "Too many items to display." };
+ }
+
+ const fieldRange = [];
+ for (const record of comodelRecords) {
+ const values= {
+ id: record.id,
+ display_name: record.display_name,
+ };
+ if (groupBy) {
+ const [groupId, groupName] = groupIdName(record[groupBy]);
+ values.group_id = groupId;
+ values.group_name = groupName;
+ }
+ if (enableCounters) {
+ values.__count = domainImage.get(record.id) ? domainImage.get(record.id).__count : 0;
+ }
+ fieldRange.push(values);
+ }
+ return { values: fieldRange };
+ }
+ },
+ /**
+ * Simulate a call to the '/web/action/load' route
+ *
+ * @private
+ * @param {Object} kwargs
+ * @param {integer} kwargs.action_id
+ * @returns {Object}
+ */
+ _mockLoadAction: function (kwargs) {
+ var action = _.findWhere(this.actions, {id: parseInt(kwargs.action_id)});
+ if (!action) {
+ // when the action doesn't exist, the real server doesn't crash, it
+ // simply returns false
+ console.warn("No action found for ID " + kwargs.action_id);
+ }
+ return action || false;
+ },
+ /**
+ * Simulate a 'load_views' operation
+ *
+ * @param {string} model
+ * @param {Array} args
+ * @param {Object} kwargs
+ * @param {Array} kwargs.views
+ * @param {Object} kwargs.options
+ * @param {Object} kwargs.context
+ * @returns {Object}
+ */
+ _mockLoadViews: function (model, kwargs) {
+ var self = this;
+ var views = {};
+ _.each(kwargs.views, function (view_descr) {
+ var viewID = view_descr[0] || false;
+ var viewType = view_descr[1];
+ if (!viewID) {
+ var contextKey = (viewType === 'list' ? 'tree' : viewType) + '_view_ref';
+ if (contextKey in kwargs.context) {
+ viewID = kwargs.context[contextKey];
+ }
+ }
+ var key = [model, viewID, viewType].join(',');
+ var arch = self.archs[key] || _.find(self.archs, function (_v, k) {
+ var ka = k.split(',');
+ viewID = parseInt(ka[1], 10);
+ return ka[0] === model && ka[2] === viewType;
+ });
+ if (!arch) {
+ throw new Error('No arch found for key ' + key);
+ }
+ views[viewType] = {
+ arch: arch,
+ view_id: viewID,
+ model: model,
+ viewOptions: {
+ context: kwargs.context,
+ },
+ };
+ });
+ return views;
+ },
+ /**
+ * Simulate a 'name_get' operation
+ *
+ * @private
+ * @param {string} model
+ * @param {Array} args
+ * @returns {Array[]} a list of [id, display_name]
+ */
+ _mockNameGet: function (model, args) {
+ var ids = args[0];
+ if (!args.length) {
+ throw new Error("name_get: expected one argument");
+ }
+ else if (!ids) {
+ return []
+ }
+ if (!_.isArray(ids)) {
+ ids = [ids];
+ }
+ var records = this.data[model].records;
+ var names = _.map(ids, function (id) {
+ return id ? [id, _.findWhere(records, {id: id}).display_name] : [null, "False"];
+ });
+ return names;
+ },
+ /**
+ * Simulate a 'name_create' operation
+ *
+ * @private
+ * @param {string} model
+ * @param {Array} args
+ * @returns {Array} a couple [id, name]
+ */
+ _mockNameCreate: function (model, args) {
+ var name = args[0];
+ var values = {
+ name: name,
+ display_name: name,
+ };
+ var id = this._mockCreate(model, values);
+ return [id, name];
+ },
+ /**
+ * Simulate a 'name_search' operation.
+ *
+ * not yet fully implemented (missing: limit, and evaluate operators)
+ * domain works but only to filter on ids
+ *
+ * @private
+ * @param {string} model
+ * @param {Array} args
+ * @param {string} args[0]
+ * @param {Array} args[1], search domain
+ * @param {Object} _kwargs
+ * @param {number} [_kwargs.limit=100] server-side default limit
+ * @returns {Array[]} a list of [id, display_name]
+ */
+ _mockNameSearch: function (model, args, _kwargs) {
+ var str = args && typeof args[0] === 'string' ? args[0] : _kwargs.name;
+ const limit = _kwargs.limit || 100;
+ var domain = (args && args[1]) || _kwargs.args || [];
+ var records = this._getRecords(model, domain);
+ if (str.length) {
+ records = _.filter(records, function (record) {
+ return record.display_name.indexOf(str) !== -1;
+ });
+ }
+ var result = _.map(records, function (record) {
+ return [record.id, record.display_name];
+ });
+ return result.slice(0, limit);
+ },
+ /**
+ * Simulate an 'onchange' rpc
+ *
+ * @private
+ * @param {string} model
+ * @param {Object} args
+ * @param {Object} args[1] the current record data
+ * @param {string|string[]} [args[2]] a list of field names, or just a field name
+ * @param {Object} args[3] the onchange spec
+ * @param {Object} [kwargs]
+ * @returns {Object}
+ */
+ _mockOnchange: function (model, args, kwargs) {
+ const currentData = args[1];
+ let fields = args[2];
+ const onChangeSpec = args[3];
+ var onchanges = this.data[model].onchanges || {};
+
+ if (fields && !(fields instanceof Array)) {
+ fields = [fields];
+ }
+ const firstOnChange = !fields || !fields.length;
+ const onchangeVals = {};
+ let defaultVals;
+ let nullValues;
+ if (firstOnChange) {
+ const fieldsFromView = Object.keys(onChangeSpec).reduce((acc, fname) => {
+ fname = fname.split('.', 1)[0];
+ if (!acc.includes(fname)) {
+ acc.push(fname);
+ }
+ return acc;
+ }, []);
+ const defaultingFields = fieldsFromView.filter(fname => !(fname in currentData));
+ defaultVals = this._mockDefaultGet(model, [defaultingFields], kwargs);
+ // It is the new semantics: no field in arguments means we are in
+ // a default_get + onchange situation
+ fields = fieldsFromView;
+ nullValues = {};
+ fields.filter(fName => !Object.keys(defaultVals).includes(fName)).forEach(fName => {
+ nullValues[fName] = false;
+ });
+ }
+ Object.assign(currentData, defaultVals);
+ fields.forEach(field => {
+ if (field in onchanges) {
+ const changes = Object.assign({}, nullValues, currentData);
+ onchanges[field](changes);
+ Object.entries(changes).forEach(([key, value]) => {
+ if (currentData[key] !== value) {
+ onchangeVals[key] = value;
+ }
+ });
+ }
+ });
+
+ return {
+ value: this._convertToOnChange(model, Object.assign({}, defaultVals, onchangeVals)),
+ };
+ },
+ /**
+ * Simulate a 'read' operation.
+ *
+ * @private
+ * @param {string} model
+ * @param {Array} args
+ * @param {Object} _kwargs ignored... is that correct?
+ * @returns {Object}
+ */
+ _mockRead: function (model, args, _kwargs) {
+ var self = this;
+ var ids = args[0];
+ if (!_.isArray(ids)) {
+ ids = [ids];
+ }
+ var fields = args[1] && args[1].length ? _.uniq(args[1].concat(['id'])) : Object.keys(this.data[model].fields);
+ var records = _.reduce(ids, function (records, id) {
+ if (!id) {
+ throw new Error("mock read: falsy value given as id, would result in an access error in actual server !");
+ }
+ var record = _.findWhere(self.data[model].records, {id: id});
+ return record ? records.concat(record) : records;
+ }, []);
+ var results = _.map(records, function (record) {
+ var result = {};
+ for (var i = 0; i < fields.length; i++) {
+ var field = self.data[model].fields[fields[i]];
+ if (!field) {
+ // the field doens't exist on the model, so skip it
+ continue;
+ }
+ if (field.type === 'float' ||
+ field.type === 'integer' ||
+ field.type === 'monetary') {
+ // read should return 0 for unset numeric fields
+ result[fields[i]] = record[fields[i]] || 0;
+ } else if (field.type === 'many2one') {
+ var relatedRecord = _.findWhere(self.data[field.relation].records, {
+ id: record[fields[i]]
+ });
+ if (relatedRecord) {
+ result[fields[i]] =
+ [record[fields[i]], relatedRecord.display_name];
+ } else {
+ result[fields[i]] = false;
+ }
+ } else if (field.type === 'one2many' || field.type === 'many2many') {
+ result[fields[i]] = record[fields[i]] || [];
+ } else {
+ result[fields[i]] = record[fields[i]] || false;
+ }
+ }
+ return result;
+ });
+ return results;
+ },
+ /**
+ * Simulate a 'read_group' call to the server.
+ *
+ * Note: most of the keys in kwargs are still ignored
+ *
+ * @private
+ * @param {string} model a string describing an existing model
+ * @param {Object} kwargs various options supported by read_group
+ * @param {string[]} kwargs.groupby fields that we are grouping
+ * @param {string[]} kwargs.fields fields that we are aggregating
+ * @param {Array} kwargs.domain the domain used for the read_group
+ * @param {boolean} kwargs.lazy still mostly ignored
+ * @param {integer} [kwargs.limit]
+ * @param {integer} [kwargs.offset]
+ * @returns {Object[]}
+ */
+ _mockReadGroup: function (model, kwargs) {
+ if (!('lazy' in kwargs)) {
+ kwargs.lazy = true;
+ }
+ var self = this;
+ var fields = this.data[model].fields;
+ var aggregatedFields = [];
+ _.each(kwargs.fields, function (field) {
+ var split = field.split(":");
+ var fieldName = split[0];
+ if (kwargs.groupby.indexOf(fieldName) > 0) {
+ // grouped fields are not aggregated
+ return;
+ }
+ if (fields[fieldName] && (fields[fieldName].type === 'many2one') && split[1] !== 'count_distinct') {
+ return;
+ }
+ aggregatedFields.push(fieldName);
+ });
+ var groupBy = [];
+ if (kwargs.groupby.length) {
+ groupBy = kwargs.lazy ? [kwargs.groupby[0]] : kwargs.groupby;
+ }
+ var records = this._getRecords(model, kwargs.domain);
+
+ // if no fields have been given, the server picks all stored fields
+ if (kwargs.fields.length === 0) {
+ aggregatedFields = _.keys(this.data[model].fields);
+ }
+
+ var groupByFieldNames = _.map(groupBy, function (groupByField) {
+ return groupByField.split(":")[0];
+ });
+
+ // filter out non existing fields
+ aggregatedFields = _.filter(aggregatedFields, function (name) {
+ return name in self.data[model].fields && !(_.contains(groupByFieldNames,name));
+ });
+
+ function aggregateFields(group, records) {
+ var type;
+ for (var i = 0; i < aggregatedFields.length; i++) {
+ type = fields[aggregatedFields[i]].type;
+ if (type === 'float' || type === 'integer') {
+ group[aggregatedFields[i]] = null;
+ for (var j = 0; j < records.length; j++) {
+ var value = group[aggregatedFields[i]] || 0;
+ group[aggregatedFields[i]] = value + records[j][aggregatedFields[i]];
+ }
+ }
+ if (type === 'many2one') {
+ var ids = _.pluck(records, aggregatedFields[i]);
+ group[aggregatedFields[i]] = _.uniq(ids).length || null;
+ }
+ }
+ }
+ function formatValue(groupByField, val) {
+ var fieldName = groupByField.split(':')[0];
+ var aggregateFunction = groupByField.split(':')[1] || 'month';
+ if (fields[fieldName].type === 'date') {
+ if (!val) {
+ return false;
+ } else if (aggregateFunction === 'day') {
+ return moment(val).format('YYYY-MM-DD');
+ } else if (aggregateFunction === 'week') {
+ return moment(val).format('ww YYYY');
+ } else if (aggregateFunction === 'quarter') {
+ return 'Q' + moment(val).format('Q YYYY');
+ } else if (aggregateFunction === 'year') {
+ return moment(val).format('Y');
+ } else {
+ return moment(val).format('MMMM YYYY');
+ }
+ } else {
+ return val instanceof Array ? val[0] : (val || false);
+ }
+ }
+ function groupByFunction(record) {
+ var value = '';
+ _.each(groupBy, function (groupByField) {
+ value = (value ? value + ',' : value) + groupByField + '#';
+ var fieldName = groupByField.split(':')[0];
+ if (fields[fieldName].type === 'date') {
+ value += formatValue(groupByField, record[fieldName]);
+ } else {
+ value += JSON.stringify(record[groupByField]);
+ }
+ });
+ return value;
+ }
+
+ if (!groupBy.length) {
+ var group = { __count: records.length };
+ aggregateFields(group, records);
+ return [group];
+ }
+
+ var groups = _.groupBy(records, groupByFunction);
+ var result = _.map(groups, function (group) {
+ var res = {
+ __domain: kwargs.domain || [],
+ };
+ _.each(groupBy, function (groupByField) {
+ var fieldName = groupByField.split(':')[0];
+ var val = formatValue(groupByField, group[0][fieldName]);
+ var field = self.data[model].fields[fieldName];
+ if (field.type === 'many2one' && !_.isArray(val)) {
+ var related_record = _.findWhere(self.data[field.relation].records, {
+ id: val
+ });
+ if (related_record) {
+ res[groupByField] = [val, related_record.display_name];
+ } else {
+ res[groupByField] = false;
+ }
+ } else {
+ res[groupByField] = val;
+ }
+
+ if (field.type === 'date' && val) {
+ var aggregateFunction = groupByField.split(':')[1];
+ var startDate, endDate;
+ if (aggregateFunction === 'day') {
+ startDate = moment(val, 'YYYY-MM-DD');
+ endDate = startDate.clone().add(1, 'days');
+ } else if (aggregateFunction === 'week') {
+ startDate = moment(val, 'ww YYYY');
+ endDate = startDate.clone().add(1, 'weeks');
+ } else if (aggregateFunction === 'year') {
+ startDate = moment(val, 'Y');
+ endDate = startDate.clone().add(1, 'years');
+ } else {
+ startDate = moment(val, 'MMMM YYYY');
+ endDate = startDate.clone().add(1, 'months');
+ }
+ res.__domain = [[fieldName, '>=', startDate.format('YYYY-MM-DD')], [fieldName, '<', endDate.format('YYYY-MM-DD')]].concat(res.__domain);
+ } else {
+ res.__domain = [[fieldName, '=', val]].concat(res.__domain);
+ }
+
+ });
+
+ // compute count key to match dumb server logic...
+ var countKey;
+ if (kwargs.lazy) {
+ countKey = groupBy[0].split(':')[0] + "_count";
+ } else {
+ countKey = "__count";
+ }
+ res[countKey] = group.length;
+ aggregateFields(res, group);
+
+ return res;
+ });
+
+ if (kwargs.orderby) {
+ // only consider first sorting level
+ kwargs.orderby = kwargs.orderby.split(',')[0];
+ var fieldName = kwargs.orderby.split(' ')[0];
+ var order = kwargs.orderby.split(' ')[1];
+ result = this._sortByField(result, model, fieldName, order);
+ }
+
+ if (kwargs.limit) {
+ var offset = kwargs.offset || 0;
+ result = result.slice(offset, kwargs.limit + offset);
+ }
+
+ return result;
+ },
+ /**
+ * Simulates a 'read_progress_bar' operation
+ *
+ * @private
+ * @param {string} model
+ * @param {Object} kwargs
+ * @returns {Object[][]}
+ */
+ _mockReadProgressBar: function (model, kwargs) {
+ var domain = kwargs.domain;
+ var groupBy = kwargs.group_by;
+ var progress_bar = kwargs.progress_bar;
+
+ var records = this._getRecords(model, domain || []);
+
+ var data = {};
+ _.each(records, function (record) {
+ var groupByValue = record[groupBy]; // always technical value here
+
+ if (!(groupByValue in data)) {
+ data[groupByValue] = {};
+ _.each(progress_bar.colors, function (val, key) {
+ data[groupByValue][key] = 0;
+ });
+ }
+
+ var fieldValue = record[progress_bar.field];
+ if (fieldValue in data[groupByValue]) {
+ data[groupByValue][fieldValue]++;
+ }
+ });
+
+ return data;
+ },
+ /**
+ * Simulates a 'resequence' operation
+ *
+ * @private
+ * @param {string} model
+ * @param {string} field
+ * @param {Array} ids
+ */
+ _mockResequence: function (args) {
+ var offset = args.offset ? Number(args.offset) : 0;
+ var field = args.field ? args.field : 'sequence';
+ var records = this.data[args.model].records;
+ if (!(field in this.data[args.model].fields)) {
+ return false;
+ }
+ for (var i in args.ids) {
+ var record = _.findWhere(records, {id: args.ids[i]});
+ record[field] = Number(i) + offset;
+ }
+ return true;
+ },
+ /**
+ * Simulate a 'search' operation
+ *
+ * @private
+ * @param {string} model
+ * @param {Array} args
+ * @param {Object} kwargs
+ * @param {integer} [kwargs.limit]
+ * @returns {integer[]}
+ */
+ _mockSearch: function (model, args, kwargs) {
+ const limit = kwargs.limit || Number.MAX_VALUE;
+ const { context } = kwargs;
+ const active_test =
+ context && "active_test" in context ? context.active_test : true;
+ return this._getRecords(model, args[0], { active_test }).map(r => r.id).slice(0, limit);
+ },
+ /**
+ * Simulate a 'search_count' operation
+ *
+ * @private
+ * @param {string} model
+ * @param {Array} args
+ * @returns {integer}
+ */
+ _mockSearchCount: function (model, args) {
+ return this._getRecords(model, args[0]).length;
+ },
+ /**
+ * Simulate a 'search_read' operation on a model
+ *
+ * @private
+ * @param {Object} args
+ * @param {Array} args.domain
+ * @param {string} args.model
+ * @param {Array} [args.fields] defaults to the list of all fields
+ * @param {integer} [args.limit]
+ * @param {integer} [args.offset=0]
+ * @param {string[]} [args.sort]
+ * @returns {Object}
+ */
+ _mockSearchRead: function (model, args, kwargs) {
+ var result = this._mockSearchReadController({
+ model: model,
+ domain: kwargs.domain || args[0],
+ fields: kwargs.fields || args[1],
+ offset: kwargs.offset || args[2],
+ limit: kwargs.limit || args[3],
+ sort: kwargs.order || args[4],
+ context: kwargs.context,
+ });
+ return result.records;
+ },
+ /**
+ * Simulate a 'search_read' operation, from the controller point of view
+ *
+ * @private
+ * @private
+ * @param {Object} args
+ * @param {Array} args.domain
+ * @param {string} args.model
+ * @param {Array} [args.fields] defaults to the list of all fields
+ * @param {integer} [args.limit]
+ * @param {integer} [args.offset=0]
+ * @param {string[]} [args.sort]
+ * @returns {Object}
+ */
+ _mockSearchReadController: function (args) {
+ var self = this;
+ const { context } = args;
+ const active_test =
+ context && "active_test" in context ? context.active_test : true;
+ var records = this._getRecords(args.model, args.domain || [], {
+ active_test,
+ });
+ var fields = args.fields && args.fields.length ? args.fields : _.keys(this.data[args.model].fields);
+ var nbRecords = records.length;
+ var offset = args.offset || 0;
+ records = records.slice(offset, args.limit ? (offset + args.limit) : nbRecords);
+ var processedRecords = _.map(records, function (r) {
+ var result = {};
+ _.each(_.uniq(fields.concat(['id'])), function (fieldName) {
+ var field = self.data[args.model].fields[fieldName];
+ if (field.type === 'many2one') {
+ var related_record = _.findWhere(self.data[field.relation].records, {
+ id: r[fieldName]
+ });
+ result[fieldName] =
+ related_record ? [r[fieldName], related_record.display_name] : false;
+ } else {
+ result[fieldName] = r[fieldName];
+ }
+ });
+ return result;
+ });
+ if (args.sort) {
+ // warning: only consider first level of sort
+ args.sort = args.sort.split(',')[0];
+ var fieldName = args.sort.split(' ')[0];
+ var order = args.sort.split(' ')[1];
+ processedRecords = this._sortByField(processedRecords, args.model, fieldName, order);
+ }
+ var result = {
+ length: nbRecords,
+ records: processedRecords,
+ };
+ return $.extend(true, {}, result);
+ },
+ /**
+ * Simulate a 'unlink' operation
+ *
+ * @private
+ * @param {string} model
+ * @param {Array} args
+ * @returns {boolean} currently, always returns true
+ */
+ _mockUnlink: function (model, args) {
+ var ids = args[0];
+ if (!_.isArray(ids)) {
+ ids = [ids];
+ }
+ this.data[model].records = _.reject(this.data[model].records, function (record) {
+ return _.contains(ids, record.id);
+ });
+
+ // update value of one2many fields pointing to the deleted records
+ _.each(this.data, function (d) {
+ var relatedFields = _.pick(d.fields, function (field) {
+ return field.type === 'one2many' && field.relation === model;
+ });
+ _.each(Object.keys(relatedFields), function (relatedField) {
+ _.each(d.records, function (record) {
+ record[relatedField] = _.difference(record[relatedField], ids);
+ });
+ });
+ });
+
+ return true;
+ },
+ /**
+ * Simulate a 'web_read_group' call to the server.
+ *
+ * Note: some keys in kwargs are still ignored
+ *
+ * @private
+ * @param {string} model a string describing an existing model
+ * @param {Object} kwargs various options supported by read_group
+ * @param {string[]} kwargs.groupby fields that we are grouping
+ * @param {string[]} kwargs.fields fields that we are aggregating
+ * @param {Array} kwargs.domain the domain used for the read_group
+ * @param {boolean} kwargs.lazy still mostly ignored
+ * @param {integer} [kwargs.limit]
+ * @param {integer} [kwargs.offset]
+ * @param {boolean} [kwargs.expand=false] if true, read records inside each
+ * group
+ * @param {integer} [kwargs.expand_limit]
+ * @param {integer} [kwargs.expand_orderby]
+ * @returns {Object[]}
+ */
+ _mockWebReadGroup: function (model, kwargs) {
+ var self = this;
+ var groups = this._mockReadGroup(model, kwargs);
+ if (kwargs.expand && kwargs.groupby.length === 1) {
+ groups.forEach(function (group) {
+ group.__data = self._mockSearchReadController({
+ domain: group.__domain,
+ model: model,
+ fields: kwargs.fields,
+ limit: kwargs.expand_limit,
+ order: kwargs.expand_orderby,
+ });
+ });
+ }
+ var allGroups = this._mockReadGroup(model, {
+ domain: kwargs.domain,
+ fields: ['display_name'],
+ groupby: kwargs.groupby,
+ lazy: kwargs.lazy,
+ });
+ return {
+ groups: groups,
+ length: allGroups.length,
+ };
+ },
+ /**
+ * Simulate a 'write' operation
+ *
+ * @private
+ * @param {string} model
+ * @param {Array} args
+ * @returns {boolean} currently, always return 'true'
+ */
+ _mockWrite: function (model, args) {
+ _.each(args[0], this._writeRecord.bind(this, model, args[1]));
+ return true;
+ },
+ /**
+ * Dispatches a fetch call to the correct helper function.
+ *
+ * @param {string} resource
+ * @param {Object} init
+ * @returns {any}
+ */
+ _performFetch(resource, init) {
+ throw new Error("Unimplemented resource: " + resource);
+ },
+ /**
+ * Dispatch a RPC call to the correct helper function
+ *
+ * @see performRpc
+ *
+ * @private
+ * @param {string} route
+ * @param {Object} args
+ * @returns {Promise<any>}
+ * Resolved with the result of the RPC. If the RPC should fail, the
+ * promise should either be rejected or the call should throw an
+ * exception (@see performRpc for error handling).
+ */
+ _performRpc: function (route, args) {
+ switch (route) {
+ case '/web/dataset/call_button':
+ return Promise.resolve(this._mockCallButton(args));
+ case '/web/action/load':
+ return Promise.resolve(this._mockLoadAction(args));
+
+ case '/web/dataset/search_read':
+ return Promise.resolve(this._mockSearchReadController(args));
+
+ case '/web/dataset/resequence':
+ return Promise.resolve(this._mockResequence(args));
+ }
+ if (route.indexOf('/web/image') >= 0 || _.contains(['.png', '.jpg'], route.substr(route.length - 4))) {
+ return Promise.resolve();
+ }
+ switch (args.method) {
+ case 'copy':
+ return Promise.resolve(this._mockCopy(args.model, args.args[0]));
+
+ case 'create':
+ return Promise.resolve(this._mockCreate(args.model, args.args[0]));
+
+ case 'fields_get':
+ return Promise.resolve(this._mockFieldsGet(args.model, args.args));
+
+ case 'search_panel_select_range':
+ return Promise.resolve(this._mockSearchPanelSelectRange(args.model, args.args, args.kwargs));
+
+ case 'search_panel_select_multi_range':
+ return Promise.resolve(this._mockSearchPanelSelectMultiRange(args.model, args.args, args.kwargs));
+
+ case 'load_views':
+ return Promise.resolve(this._mockLoadViews(args.model, args.kwargs));
+
+ case 'name_get':
+ return Promise.resolve(this._mockNameGet(args.model, args.args));
+
+ case 'name_create':
+ return Promise.resolve(this._mockNameCreate(args.model, args.args));
+
+ case 'name_search':
+ return Promise.resolve(this._mockNameSearch(args.model, args.args, args.kwargs));
+
+ case 'onchange':
+ return Promise.resolve(this._mockOnchange(args.model, args.args, args.kwargs));
+
+ case 'read':
+ return Promise.resolve(this._mockRead(args.model, args.args, args.kwargs));
+
+ case 'read_group':
+ return Promise.resolve(this._mockReadGroup(args.model, args.kwargs));
+
+ case 'web_read_group':
+ return Promise.resolve(this._mockWebReadGroup(args.model, args.kwargs));
+
+ case 'read_progress_bar':
+ return Promise.resolve(this._mockReadProgressBar(args.model, args.kwargs));
+
+ case 'search':
+ return Promise.resolve(this._mockSearch(args.model, args.args, args.kwargs));
+
+ case 'search_count':
+ return Promise.resolve(this._mockSearchCount(args.model, args.args));
+
+ case 'search_read':
+ return Promise.resolve(this._mockSearchRead(args.model, args.args, args.kwargs));
+
+ case 'unlink':
+ return Promise.resolve(this._mockUnlink(args.model, args.args));
+
+ case 'write':
+ return Promise.resolve(this._mockWrite(args.model, args.args));
+ }
+ var model = this.data[args.model];
+ if (model && typeof model[args.method] === 'function') {
+ return Promise.resolve(this.data[args.model][args.method](args.args, args.kwargs));
+ }
+
+ throw new Error("Unimplemented route: " + route);
+ },
+ /**
+ * @private
+ * @param {Object[]} records the records to sort
+ * @param {string} model the model of records
+ * @param {string} fieldName the field to sort on
+ * @param {string} [order="DESC"] "ASC" or "DESC"
+ * @returns {Object}
+ */
+ _sortByField: function (records, model, fieldName, order) {
+ const field = this.data[model].fields[fieldName];
+ records.sort((r1, r2) => {
+ let v1 = r1[fieldName];
+ let v2 = r2[fieldName];
+ if (field.type === 'many2one') {
+ const coRecords = this.data[field.relation].records;
+ if (this.data[field.relation].fields.sequence) {
+ // use sequence field of comodel to sort records
+ v1 = coRecords.find(r => r.id === v1[0]).sequence;
+ v2 = coRecords.find(r => r.id === v2[0]).sequence;
+ } else {
+ // sort by id
+ v1 = v1[0];
+ v2 = v2[0];
+ }
+ }
+ if (v1 < v2) {
+ return order === 'ASC' ? -1 : 1;
+ }
+ if (v1 > v2) {
+ return order === 'ASC' ? 1 : -1;
+ }
+ return 0;
+ });
+ return records;
+ },
+ /**
+ * helper function: traverse a tree and apply the function f to each of its
+ * nodes.
+ *
+ * Note: this should be abstracted somewhere in web.utils, or in
+ * web.tree_utils
+ *
+ * @param {Object} tree object with a 'children' key, which contains an
+ * array of trees.
+ * @param {function} f
+ */
+ _traverse: function (tree, f) {
+ var self = this;
+ if (f(tree)) {
+ _.each(tree.childNodes, function (c) { self._traverse(c, f); });
+ }
+ },
+ /**
+ * Write a record. The main difficulty is that we have to apply x2many
+ * commands
+ *
+ * @private
+ * @param {string} model
+ * @param {Object} values
+ * @param {integer} id
+ * @param {Object} [params={}]
+ * @param {boolean} [params.ensureIntegrity=true] writing non-existing id
+ * in many2one field will throw if this param is true
+ */
+ _writeRecord: function (model, values, id, { ensureIntegrity = true } = {}) {
+ var self = this;
+ var record = _.findWhere(this.data[model].records, {id: id});
+ for (var field_changed in values) {
+ var field = this.data[model].fields[field_changed];
+ var value = values[field_changed];
+ if (!field) {
+ throw Error(`Mock: Can't write value "${JSON.stringify(value)}" on field "${field_changed}" on record "${model},${id}" (field is undefined)`);
+ }
+ if (_.contains(['one2many', 'many2many'], field.type)) {
+ var ids = _.clone(record[field_changed]) || [];
+
+ // fallback to command 6 when given a simple list of ids
+ if (
+ Array.isArray(value) &&
+ value.reduce((hasOnlyInt, val) => hasOnlyInt && Number.isInteger(val), true)
+ ) {
+ value = [[6, 0, value]];
+ }
+ // convert commands
+ for (const command of value || []) {
+ if (command[0] === 0) { // CREATE
+ const newId = self._mockCreate(field.relation, command[2]);
+ ids.push(newId);
+ } else if (command[0] === 1) { // UPDATE
+ self._mockWrite(field.relation, [[command[1]], command[2]]);
+ } else if (command[0] === 2) { // DELETE
+ ids = _.without(ids, command[1]);
+ } else if (command[0] === 3) { // FORGET
+ ids = _.without(ids, command[1]);
+ } else if (command[0] === 4) { // LINK_TO
+ if (!_.contains(ids, command[1])) {
+ ids.push(command[1]);
+ }
+ } else if (command[0] === 5) { // DELETE ALL
+ ids = [];
+ } else if (command[0] === 6) { // REPLACE WITH
+ // copy array to avoid leak by reference (eg. of default data)
+ ids = [...command[2]];
+ } else {
+ throw Error(`Command "${JSON.stringify(value)}" not supported by the MockServer on field "${field_changed}" on record "${model},${id}"`);
+ }
+ }
+ record[field_changed] = ids;
+ } else if (field.type === 'many2one') {
+ if (value) {
+ var relatedRecord = _.findWhere(this.data[field.relation].records, {
+ id: value
+ });
+ if (!relatedRecord && ensureIntegrity) {
+ throw Error(`Wrong id "${JSON.stringify(value)}" for a many2one on field "${field_changed}" on record "${model},${id}"`);
+ }
+ record[field_changed] = value;
+ } else {
+ record[field_changed] = false;
+ }
+ } else {
+ record[field_changed] = value;
+ }
+ }
+ },
+});
+
+return MockServer;
+
+});
diff --git a/addons/web/static/tests/helpers/qunit_asserts.js b/addons/web/static/tests/helpers/qunit_asserts.js
new file mode 100644
index 00000000..69cf2807
--- /dev/null
+++ b/addons/web/static/tests/helpers/qunit_asserts.js
@@ -0,0 +1,244 @@
+odoo.define('web.qunit_asserts', function (require) {
+ "use strict";
+
+ /**
+ * In this file, we extend QUnit by adding some specialized assertions. The goal
+ * of these new assertions is twofold:
+ * - ease of use: they should allow us to simplify some common complex assertions
+ * - safer: these assertions will fail when some preconditions are not met.
+ *
+ * For example, the assert.isVisible assertion will also check that the target
+ * matches exactly one element.
+ */
+
+ const Widget = require('web.Widget');
+
+ /** @todo use testUtilsDom.getNode to extract the element from the 'w' argument */
+
+ //-------------------------------------------------------------------------
+ // Private functions
+ //-------------------------------------------------------------------------
+
+ /**
+ * Helper function, to check if a given element
+ * - is unique (if it is a jquery node set)
+ * - has (or has not) a css class
+ *
+ * @private
+ * @param {Widget|jQuery|HTMLElement|owl.Component} w
+ * @param {string} className
+ * @param {boolean} shouldHaveClass
+ * @param {string} [msg]
+ */
+ function _checkClass(w, className, shouldHaveClass, msg) {
+ if (w instanceof jQuery && w.length !== 1) {
+ const assertion = shouldHaveClass ? 'hasClass' : 'doesNotHaveClass';
+ QUnit.assert.ok(false, `Assertion '${assertion} ${className}' targets ${w.length} elements instead of 1`);
+ }
+
+ const el = w instanceof Widget || w instanceof owl.Component ? w.el :
+ w instanceof jQuery ? w[0] : w;
+
+ msg = msg || `target should ${shouldHaveClass ? 'have' : 'not have'} class ${className}`;
+ const isFalse = className.split(" ").some(cls => {
+ const hasClass = el.classList.contains(cls);
+ return shouldHaveClass ? !hasClass : hasClass;
+ });
+ QUnit.assert.ok(!isFalse, msg);
+ }
+
+ /**
+ * Helper function, to check if a given element
+ * - is unique (if it is a jquery node set)
+ * - is (or not) visible
+ *
+ * @private
+ * @param {Widget|jQuery|HTMLElement|owl.Component} w
+ * @param {boolean} shouldBeVisible
+ * @param {string} [msg]
+ */
+ function _checkVisible(w, shouldBeVisible, msg) {
+ if (w instanceof jQuery && w.length !== 1) {
+ const assertion = shouldBeVisible ? 'isVisible' : 'isNotVisible';
+ QUnit.assert.ok(false, `Assertion '${assertion}' targets ${w.length} elements instead of 1`);
+ }
+
+ const el = w instanceof Widget || w instanceof owl.Component ? w.el :
+ w instanceof jQuery ? w[0] : w;
+
+ msg = msg || `target should ${shouldBeVisible ? '' : 'not'} be visible`;
+ let isVisible = el &&
+ el.offsetWidth &&
+ el.offsetHeight;
+ if (isVisible) {
+ // This computation is a little more heavy and we only want to perform it
+ // if the above assertion has failed.
+ const rect = el.getBoundingClientRect();
+ isVisible = rect.width + rect.height;
+ }
+ const condition = shouldBeVisible ? isVisible : !isVisible;
+ QUnit.assert.ok(condition, msg);
+ }
+
+ //-------------------------------------------------------------------------
+ // Public functions
+ //-------------------------------------------------------------------------
+
+ /**
+ * Checks that the target element (described by widget/jquery or html element)
+ * contains exactly n matches for the selector.
+ *
+ * Example: assert.containsN(document.body, '.modal', 0)
+ *
+ * @param {Widget|jQuery|HTMLElement|owl.Component} w
+ * @param {string} selector
+ * @param {number} n
+ * @param {string} [msg]
+ */
+ function containsN(w, selector, n, msg) {
+ if (typeof n !== 'number') {
+ throw new Error("containsN assert should be called with a number as third argument");
+ }
+ let matches = [];
+ if (w instanceof owl.Component) {
+ if (!w.el) {
+ throw new Error(`containsN assert with selector '${selector}' called on an unmounted component`);
+ }
+ matches = w.el.querySelectorAll(selector);
+ } else {
+ const $el = w instanceof Widget ? w.$el :
+ w instanceof HTMLElement ? $(w) :
+ w; // jquery element
+ matches = $el.find(selector);
+ }
+ if (!msg) {
+ msg = `Selector '${selector}' should have exactly ${n} matches (inside the target)`;
+ }
+ QUnit.assert.strictEqual(matches.length, n, msg);
+ }
+
+ /**
+ * Checks that the target element (described by widget/jquery or html element)
+ * contains exactly 0 match for the selector.
+ *
+ * @param {Widget|jQuery|HTMLElement|owl.Component} w
+ * @param {string} selector
+ * @param {string} [msg]
+ */
+ function containsNone(w, selector, msg) {
+ containsN(w, selector, 0, msg);
+ }
+
+ /**
+ * Checks that the target element (described by widget/jquery or html element)
+ * contains exactly 1 match for the selector.
+ *
+ * @param {Widget|jQuery|HTMLElement|owl.Component} w
+ * @param {string} selector
+ * @param {string} [msg]
+ */
+ function containsOnce(w, selector, msg) {
+ containsN(w, selector, 1, msg);
+ }
+
+ /**
+ * Checks that the target element (described by widget/jquery or html element)
+ * - exists
+ * - is unique
+ * - has the given class (specified by className)
+ *
+ * Note that it uses the hasClass jQuery method, so it can be used to check the
+ * presence of more than one class ('some-class other-class'), but it is a
+ * little brittle, because it depends on the order of these classes:
+ *
+ * div.a.b.c: has class 'a b c', but does not have class 'a c b'
+ *
+ * @param {Widget|jQuery|HTMLElement|owl.Component} w
+ * @param {string} className
+ * @param {string} [msg]
+ */
+ function hasClass(w, className, msg) {
+ _checkClass(w, className, true, msg);
+ }
+
+ /**
+ * Checks that the target element (described by widget/jquery or html element)
+ * - exists
+ * - is unique
+ * - does not have the given class (specified by className)
+ *
+ * @param {Widget|jQuery|HTMLElement|owl.Component} w
+ * @param {string} className
+ * @param {string} [msg]
+ */
+ function doesNotHaveClass(w, className, msg) {
+ _checkClass(w, className, false, msg);
+ }
+
+ /**
+ * Checks that the target element (described by widget/jquery or html element)
+ * - exists
+ * - is unique
+ * - has the given attribute with the proper value
+ *
+ * @param {Widget|jQuery|HTMLElement|owl.Component} w
+ * @param {string} attr
+ * @param {string} value
+ * @param {string} [msg]
+ */
+ function hasAttrValue(w, attr, value, msg) {
+ const $el = w instanceof Widget ? w.$el :
+ w instanceof HTMLElement ? $(w) :
+ w; // jquery element
+
+ if ($el.length !== 1) {
+ const descr = `hasAttrValue (${attr}: ${value})`;
+ QUnit.assert.ok(false,
+ `Assertion '${descr}' targets ${$el.length} elements instead of 1`
+ );
+ } else {
+ msg = msg || `attribute '${attr}' of target should be '${value}'`;
+ QUnit.assert.strictEqual($el.attr(attr), value, msg);
+ }
+ }
+
+ /**
+ * Checks that the target element (described by widget/jquery or html element)
+ * - exists
+ * - is visible (as far as jQuery can tell: not in display none, ...)
+ *
+ * @param {Widget|jQuery|HTMLElement|owl.Component} w
+ * @param {string} [msg]
+ */
+ function isVisible(w, msg) {
+ _checkVisible(w, true, msg);
+ }
+
+ /**
+ * Checks that the target element (described by widget/jquery or html element)
+ * - exists
+ * - is not visible (as far as jQuery can tell: display none, ...)
+ *
+ * @param {Widget|jQuery|HTMLElement|owl.Component} w
+ * @param {string} [msg]
+ */
+ function isNotVisible(w, msg) {
+ _checkVisible(w, false, msg);
+ }
+
+ //-------------------------------------------------------------------------
+ // Exposed API
+ //-------------------------------------------------------------------------
+
+ QUnit.assert.containsN = containsN;
+ QUnit.assert.containsNone = containsNone;
+ QUnit.assert.containsOnce = containsOnce;
+
+ QUnit.assert.hasClass = hasClass;
+ QUnit.assert.doesNotHaveClass = doesNotHaveClass;
+
+ QUnit.assert.hasAttrValue = hasAttrValue;
+
+ QUnit.assert.isVisible = isVisible;
+ QUnit.assert.isNotVisible = isNotVisible;
+});
diff --git a/addons/web/static/tests/helpers/qunit_config.js b/addons/web/static/tests/helpers/qunit_config.js
new file mode 100644
index 00000000..575c8022
--- /dev/null
+++ b/addons/web/static/tests/helpers/qunit_config.js
@@ -0,0 +1,249 @@
+(function() {
+"use strict";
+
+/**
+ * QUnit Config
+ *
+ * The Odoo javascript test framework is based on QUnit (http://qunitjs.com/).
+ * This file is necessary to setup Qunit and to prepare its interactions with
+ * Odoo. It has to be loaded before any tests are defined.
+ *
+ * Note that it is not an Odoo module, because we want this code to be executed
+ * as soon as possible, not whenever the Odoo module system feels like it.
+ */
+
+
+/**
+ * This configuration variable is not strictly necessary, but it ensures more
+ * safety for asynchronous tests. With it, each test has to explicitely tell
+ * QUnit how many assertion it expects, otherwise the test will fail.
+ */
+QUnit.config.requireExpects = true;
+
+/**
+ * not important in normal mode, but in debug=assets, the files are loaded
+ * asynchroneously, which can lead to various issues with QUnit... Notice that
+ * this is done outside of odoo modules, otherwise the setting would not take
+ * effect on time.
+ */
+QUnit.config.autostart = false;
+
+/**
+ * A test timeout of 1 min, before an async test is considered failed.
+ */
+QUnit.config.testTimeout = 1 * 60 * 1000;
+
+/**
+ * Hide passed tests by default in the QUnit page
+ */
+QUnit.config.hidepassed = (window.location.href.match(/[?&]testId=/) === null);
+
+var sortButtonAppended = false;
+
+/**
+ * If we want to log several errors, we have to log all of them at once, as
+ * browser_js is closed as soon as an error is logged.
+ */
+const errorMessages = [];
+/**
+ * List of elements tolerated in the body after a test. The property "keep"
+ * prevents the element from being removed (typically: qunit suite elements).
+ */
+const validElements = [
+ // always in the body:
+ { tagName: 'DIV', attr: 'id', value: 'qunit', keep: true },
+ { tagName: 'DIV', attr: 'id', value: 'qunit-fixture', keep: true },
+ // shouldn't be in the body after a test but are tolerated:
+ { tagName: 'SCRIPT', attr: 'id', value: '' },
+ { tagName: 'DIV', attr: 'className', value: 'o_notification_manager' },
+ { tagName: 'DIV', attr: 'className', value: 'tooltip fade bs-tooltip-auto' },
+ { tagName: 'DIV', attr: 'className', value: 'tooltip fade bs-tooltip-auto show' },
+ { tagName: 'SPAN', attr: 'className', value: 'select2-hidden-accessible' },
+ // Due to a Document Kanban bug (already present in 12.0)
+ { tagName: 'DIV', attr: 'className', value: 'ui-helper-hidden-accessible' },
+ { tagName: 'UL', attr: 'className', value: 'ui-menu ui-widget ui-widget-content ui-autocomplete ui-front' },
+];
+
+/**
+ * Waits for the module system to end processing the JS modules, so that we can
+ * make the suite fail if some modules couldn't be loaded (e.g. because of a
+ * missing dependency).
+ *
+ * @returns {Promise<boolean>}
+ */
+async function checkModules() {
+ // do not mark the suite as successful already, as we still need to ensure
+ // that all modules have been correctly loaded
+ $('#qunit-banner').removeClass('qunit-pass');
+ const $modulesAlert = $('<div>')
+ .addClass('alert alert-info')
+ .text('Waiting for modules check...');
+ $modulesAlert.appendTo('#qunit');
+
+ // wait for the module system to end processing the JS modules
+ await odoo.__DEBUG__.didLogInfo;
+
+ const info = odoo.__DEBUG__.jsModules;
+ if (info.missing.length || info.failed.length) {
+ $('#qunit-banner').addClass('qunit-fail');
+ $modulesAlert.toggleClass('alert-info alert-danger');
+ const failingModules = info.missing.concat(info.failed);
+ const error = `Some modules couldn't be started: ${failingModules.join(', ')}.`;
+ $modulesAlert.text(error);
+ errorMessages.unshift(error);
+ return false;
+ } else {
+ $modulesAlert.toggleClass('alert-info alert-success');
+ $modulesAlert.text('All modules have been correctly loaded.');
+ $('#qunit-banner').addClass('qunit-pass');
+ return true;
+ }
+}
+
+/**
+ * This is the way the testing framework knows that tests passed or failed. It
+ * only look in the phantomJS console and check if there is a ok or an error.
+ *
+ * Someday, we should devise a safer strategy...
+ */
+QUnit.done(async function (result) {
+ const allModulesLoaded = await checkModules();
+ if (result.failed) {
+ errorMessages.push(`${result.failed} / ${result.total} tests failed.`);
+ }
+ if (!result.failed && allModulesLoaded) {
+ console.log('test successful');
+ } else {
+ console.error(errorMessages.join('\n'));
+ }
+
+ if (!sortButtonAppended) {
+ _addSortButton();
+ }
+});
+
+/**
+ * This logs various data in the console, which will be available in the log
+ * .txt file generated by the runbot.
+ */
+QUnit.log(function (result) {
+ if (!result.result) {
+ var info = '"QUnit test failed: "' + result.module + ' > ' + result.name + '"';
+ info += ' [message: "' + result.message + '"';
+ if (result.actual !== null) {
+ info += ', actual: "' + result.actual + '"';
+ }
+ if (result.expected !== null) {
+ info += ', expected: "' + result.expected + '"';
+ }
+ info += ']';
+ errorMessages.push(info);
+ }
+});
+
+/**
+ * This is done mostly for the .txt log file generated by the runbot.
+ */
+QUnit.moduleDone(function(result) {
+ if (!result.failed) {
+ console.log('"' + result.name + '"', "passed", result.total, "tests.");
+ } else {
+ console.log('"' + result.name + '"',
+ "failed", result.failed,
+ "tests out of", result.total, ".");
+ }
+
+});
+
+/**
+ * After each test, we check that there is no leftover in the DOM.
+ *
+ * Note: this event is not QUnit standard, we added it for this specific use case.
+ * As a payload, an object with keys 'moduleName' and 'testName' is provided. It
+ * is used to indicate the test that left elements in the DOM, when it happens.
+ */
+QUnit.on('OdooAfterTestHook', function (info) {
+ const toRemove = [];
+ // check for leftover elements in the body
+ for (const bodyChild of document.body.children) {
+ const tolerated = validElements.find((e) =>
+ e.tagName === bodyChild.tagName && bodyChild[e.attr] === e.value
+ );
+ if (!tolerated) {
+ console.error(`Test ${info.moduleName} > ${info.testName}`);
+ console.error('Body still contains undesirable elements:' +
+ '\nInvalid element:\n' + bodyChild.outerHTML);
+ QUnit.pushFailure(`Body still contains undesirable elements`);
+ }
+ if (!tolerated || !tolerated.keep) {
+ toRemove.push(bodyChild);
+ }
+ }
+
+ // check for leftovers in #qunit-fixture
+ const qunitFixture = document.getElementById('qunit-fixture');
+ if (qunitFixture.children.length) {
+ console.error(`Test ${info.moduleName} > ${info.testName}`);
+ console.error('#qunit-fixture still contains elements:' +
+ '\n#qunit-fixture HTML:\n' + qunitFixture.outerHTML);
+ QUnit.pushFailure(`#qunit-fixture still contains elements`);
+ toRemove.push(...qunitFixture.children);
+ }
+
+ // remove unwanted elements if not in debug
+ if (!document.body.classList.contains('debug')) {
+ for (const el of toRemove) {
+ el.remove();
+ }
+ }
+});
+
+/**
+ * Add a sort button on top of the QUnit result page, so we can see which tests
+ * take the most time.
+ */
+function _addSortButton() {
+ sortButtonAppended = true;
+ var $sort = $('<label> sort by time (desc)</label>').css({float: 'right'});
+ $('h2#qunit-userAgent').append($sort);
+ $sort.click(function() {
+ var $ol = $('ol#qunit-tests');
+ var $results = $ol.children('li').get();
+ $results.sort(function (a, b) {
+ var timeA = Number($(a).find('span.runtime').first().text().split(" ")[0]);
+ var timeB = Number($(b).find('span.runtime').first().text().split(" ")[0]);
+ if (timeA < timeB) {
+ return 1;
+ } else if (timeA > timeB) {
+ return -1;
+ } else {
+ return 0;
+ }
+ });
+ $.each($results, function(idx, $itm) { $ol.append($itm); });
+
+ });
+}
+
+/**
+ * We add here a 'fail fast' feature: we often want to stop the test suite after
+ * the first failed test. This is also useful for the runbot test suites.
+ */
+
+QUnit.config.urlConfig.push({
+ id: "failfast",
+ label: "Fail Fast",
+ tooltip: "Stop the test suite immediately after the first failed test."
+});
+
+QUnit.begin(function() {
+ if (QUnit.config.failfast) {
+ QUnit.testDone(function(details) {
+ if (details.failed > 0) {
+ QUnit.config.queue.length = 0;
+ }
+ });
+ }
+});
+
+})();
diff --git a/addons/web/static/tests/helpers/test_env.js b/addons/web/static/tests/helpers/test_env.js
new file mode 100644
index 00000000..344fb48d
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_env.js
@@ -0,0 +1,88 @@
+odoo.define('web.test_env', async function (require) {
+ "use strict";
+
+ const Bus = require('web.Bus');
+ const { buildQuery } = require('web.rpc');
+ const session = require('web.session');
+
+ let qweb;
+
+ /**
+ * Creates a test environment with the given environment object.
+ * Any access to a key that has not been explicitly defined in the given environment object
+ * will result in an error.
+ *
+ * @param {Object} [env={}]
+ * @param {Function} [providedRPC=null]
+ * @returns {Proxy}
+ */
+ function makeTestEnvironment(env = {}, providedRPC = null) {
+ if (!qweb) {
+ // avoid parsing templates at every test because it takes a lot of
+ // time and they never change
+ qweb = new owl.QWeb({ templates: session.owlTemplates });
+ }
+ const database = {
+ parameters: {
+ code: "en_US",
+ date_format: '%m/%d/%Y',
+ decimal_point: ".",
+ direction: 'ltr',
+ grouping: [],
+ thousands_sep: ",",
+ time_format: '%H:%M:%S',
+ },
+ };
+ const defaultEnv = {
+ _t: env._t || Object.assign((s => s), { database }),
+ browser: Object.assign({
+ setTimeout: window.setTimeout.bind(window),
+ clearTimeout: window.clearTimeout.bind(window),
+ setInterval: window.setInterval.bind(window),
+ clearInterval: window.clearInterval.bind(window),
+ requestAnimationFrame: window.requestAnimationFrame.bind(window),
+ Date: window.Date,
+ fetch: (window.fetch || (() => { })).bind(window),
+ }, env.browser),
+ bus: env.bus || new Bus(),
+ device: Object.assign({ isMobile: false }, env.device),
+ isDebug: env.isDebug || (() => false),
+ qweb,
+ services: Object.assign({
+ ajax: {
+ rpc() {
+ return env.session.rpc(...arguments); // Compatibility Legacy Widgets
+ }
+ },
+ getCookie() {},
+ httpRequest(/* route, params = {}, readMethod = 'json' */) {
+ return Promise.resolve('');
+ },
+ rpc(params, options) {
+ const query = buildQuery(params);
+ return env.session.rpc(query.route, query.params, options);
+ },
+ notification: { notify() { } },
+ }, env.services),
+ session: Object.assign({
+ rpc(route, params, options) {
+ if (providedRPC) {
+ return providedRPC(route, params, options);
+ }
+ throw new Error(`No method to perform RPC`);
+ },
+ url: session.url,
+ }, env.session),
+ };
+ return Object.assign(env, defaultEnv);
+ }
+
+ /**
+ * Before each test, we want owl.Component.env to be a fresh test environment.
+ */
+ QUnit.on('OdooBeforeTestHook', function () {
+ owl.Component.env = makeTestEnvironment();
+ });
+
+ return makeTestEnvironment;
+});
diff --git a/addons/web/static/tests/helpers/test_utils.js b/addons/web/static/tests/helpers/test_utils.js
new file mode 100644
index 00000000..33aa4327
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils.js
@@ -0,0 +1,282 @@
+odoo.define('web.test_utils', async function (require) {
+ "use strict";
+
+ /**
+ * Test Utils
+ *
+ * In this module, we define various utility functions to help simulate a mock
+ * environment as close as possible as a real environment. The main function is
+ * certainly createView, which takes a bunch of parameters and give you back an
+ * instance of a view, appended in the dom, ready to be tested.
+ */
+
+ const ajax = require('web.ajax');
+ const core = require('web.core');
+ const relationalFields = require('web.relational_fields');
+ const session = require('web.session');
+ const testUtilsCreate = require('web.test_utils_create');
+ const testUtilsControlPanel = require('web.test_utils_control_panel');
+ const testUtilsDom = require('web.test_utils_dom');
+ const testUtilsFields = require('web.test_utils_fields');
+ const testUtilsFile = require('web.test_utils_file');
+ const testUtilsForm = require('web.test_utils_form');
+ const testUtilsGraph = require('web.test_utils_graph');
+ const testUtilsKanban = require('web.test_utils_kanban');
+ const testUtilsMock = require('web.test_utils_mock');
+ const testUtilsModal = require('web.test_utils_modal');
+ const testUtilsPivot = require('web.test_utils_pivot');
+ const tools = require('web.tools');
+
+
+ function deprecated(fn, type) {
+ const msg = `Helper 'testUtils.${fn.name}' is deprecated. ` +
+ `Please use 'testUtils.${type}.${fn.name}' instead.`;
+ return tools.deprecated(fn, msg);
+ }
+
+ /**
+ * Helper function, make a promise with a public resolve function. Note that
+ * this is not standard and should not be used outside of tests...
+ *
+ * @returns {Promise + resolve and reject function}
+ */
+ function makeTestPromise() {
+ let resolve;
+ let reject;
+ const promise = new Promise(function (_resolve, _reject) {
+ resolve = _resolve;
+ reject = _reject;
+ });
+ promise.resolve = function () {
+ resolve.apply(null, arguments);
+ return promise;
+ };
+ promise.reject = function () {
+ reject.apply(null, arguments);
+ return promise;
+ };
+ return promise;
+ }
+
+ /**
+ * Make a promise with public resolve and reject functions (see
+ * @makeTestPromise). Perform an assert.step when the promise is
+ * resolved/rejected.
+ *
+ * @param {Object} assert instance object with the assertion methods
+ * @param {function} assert.step
+ * @param {string} str message to pass to assert.step
+ * @returns {Promise + resolve and reject function}
+ */
+ function makeTestPromiseWithAssert(assert, str) {
+ const prom = makeTestPromise();
+ prom.then(() => assert.step('ok ' + str)).catch(function () { });
+ prom.catch(() => assert.step('ko ' + str));
+ return prom;
+ }
+
+ /**
+ * Create a new promise that can be waited by the caller in order to execute
+ * code after the next microtask tick and before the next jobqueue tick.
+ *
+ * @return {Promise} an already fulfilled promise
+ */
+ async function nextMicrotaskTick() {
+ return Promise.resolve();
+ }
+
+ /**
+ * Returns a promise that will be resolved after the tick after the
+ * nextAnimationFrame
+ *
+ * This is usefull to guarantee that OWL has had the time to render
+ *
+ * @returns {Promise}
+ */
+ async function nextTick() {
+ return testUtilsDom.returnAfterNextAnimationFrame();
+ }
+
+ /**
+ * Calls nextTick. While we have a hybrid implemetation (Owl + legacy), we may
+ * have situations where waiting for a single nextTick isn't enough. For instance,
+ * having a layer of Owl components, above a layer of legacy widgets, above a
+ * layer of Owl components requires two nextTick for the whole hierarchy to be
+ * rendered into the DOM. In those situation, one should use this helper, which
+ * will be removed (alongside all its calls) in the future.
+ *
+ * @returns {Promise}
+ */
+ async function owlCompatibilityNextTick() {
+ return nextTick();
+ }
+
+ // Loading static files cannot be properly simulated when their real content is
+ // really needed. This is the case for static XML files so we load them here,
+ // before starting the qunit test suite.
+ // (session.js is in charge of loading the static xml bundle and we also have
+ // to load xml files that are normally lazy loaded by specific widgets).
+ await Promise.all([
+ session.is_bound,
+ ajax.loadXML('/web/static/src/xml/crash_manager.xml', core.qweb),
+ ajax.loadXML('/web/static/src/xml/debug.xml', core.qweb),
+ ajax.loadXML('/web/static/src/xml/dialog.xml', core.qweb),
+ ajax.loadXML('/web/static/src/xml/translation_dialog.xml', core.qweb),
+ ]);
+ setTimeout(function () {
+ // jquery autocomplete refines the search in a setTimeout() parameterized
+ // with a delay, so we force this delay to 0 s.t. the dropdown is filtered
+ // directly on the next tick
+ relationalFields.FieldMany2One.prototype.AUTOCOMPLETE_DELAY = 0;
+
+ // this is done with the hope that tests are
+ // only started all together...
+ QUnit.start();
+ }, 0);
+ return {
+ mock: {
+ addMockEnvironment: testUtilsMock.addMockEnvironment,
+ addMockEnvironmentOwl: testUtilsMock.addMockEnvironmentOwl,
+ intercept: testUtilsMock.intercept,
+ patch: testUtilsMock.patch,
+ patchDate: testUtilsMock.patchDate,
+ unpatch: testUtilsMock.unpatch,
+ fieldsViewGet: testUtilsMock.fieldsViewGet,
+ patchSetTimeout: testUtilsMock.patchSetTimeout,
+ },
+ controlPanel: {
+ // Generic interactions
+ toggleMenu: testUtilsControlPanel.toggleMenu,
+ toggleMenuItem: testUtilsControlPanel.toggleMenuItem,
+ toggleMenuItemOption: testUtilsControlPanel.toggleMenuItemOption,
+ isItemSelected: testUtilsControlPanel.isItemSelected,
+ isOptionSelected: testUtilsControlPanel.isOptionSelected,
+ getMenuItemTexts: testUtilsControlPanel.getMenuItemTexts,
+ // Button interactions
+ getButtons: testUtilsControlPanel.getButtons,
+ // FilterMenu interactions
+ toggleFilterMenu: testUtilsControlPanel.toggleFilterMenu,
+ toggleAddCustomFilter: testUtilsControlPanel.toggleAddCustomFilter,
+ applyFilter: testUtilsControlPanel.applyFilter,
+ // GroupByMenu interactions
+ toggleGroupByMenu: testUtilsControlPanel.toggleGroupByMenu,
+ toggleAddCustomGroup: testUtilsControlPanel.toggleAddCustomGroup,
+ selectGroup: testUtilsControlPanel.selectGroup,
+ applyGroup: testUtilsControlPanel.applyGroup,
+ // FavoriteMenu interactions
+ toggleFavoriteMenu: testUtilsControlPanel.toggleFavoriteMenu,
+ toggleSaveFavorite: testUtilsControlPanel.toggleSaveFavorite,
+ editFavoriteName: testUtilsControlPanel.editFavoriteName,
+ saveFavorite: testUtilsControlPanel.saveFavorite,
+ deleteFavorite: testUtilsControlPanel.deleteFavorite,
+ // ComparisonMenu interactions
+ toggleComparisonMenu: testUtilsControlPanel.toggleComparisonMenu,
+ // SearchBar interactions
+ getFacetTexts: testUtilsControlPanel.getFacetTexts,
+ removeFacet: testUtilsControlPanel.removeFacet,
+ editSearch: testUtilsControlPanel.editSearch,
+ validateSearch: testUtilsControlPanel.validateSearch,
+ // Action menus interactions
+ toggleActionMenu: testUtilsControlPanel.toggleActionMenu,
+ // Pager interactions
+ pagerPrevious: testUtilsControlPanel.pagerPrevious,
+ pagerNext: testUtilsControlPanel.pagerNext,
+ getPagerValue: testUtilsControlPanel.getPagerValue,
+ getPagerSize: testUtilsControlPanel.getPagerSize,
+ setPagerValue: testUtilsControlPanel.setPagerValue,
+ // View switcher
+ switchView: testUtilsControlPanel.switchView,
+ },
+ dom: {
+ triggerKeypressEvent: testUtilsDom.triggerKeypressEvent,
+ triggerMouseEvent: testUtilsDom.triggerMouseEvent,
+ triggerPositionalMouseEvent: testUtilsDom.triggerPositionalMouseEvent,
+ dragAndDrop: testUtilsDom.dragAndDrop,
+ find: testUtilsDom.findItem,
+ getNode: testUtilsDom.getNode,
+ openDatepicker: testUtilsDom.openDatepicker,
+ click: testUtilsDom.click,
+ clickFirst: testUtilsDom.clickFirst,
+ clickLast: testUtilsDom.clickLast,
+ triggerEvents: testUtilsDom.triggerEvents,
+ triggerEvent: testUtilsDom.triggerEvent,
+ },
+ form: {
+ clickEdit: testUtilsForm.clickEdit,
+ clickSave: testUtilsForm.clickSave,
+ clickCreate: testUtilsForm.clickCreate,
+ clickDiscard: testUtilsForm.clickDiscard,
+ reload: testUtilsForm.reload,
+ },
+ graph: {
+ reload: testUtilsGraph.reload,
+ },
+ kanban: {
+ reload: testUtilsKanban.reload,
+ clickCreate: testUtilsKanban.clickCreate,
+ quickCreate: testUtilsKanban.quickCreate,
+ toggleGroupSettings: testUtilsKanban.toggleGroupSettings,
+ toggleRecordDropdown: testUtilsKanban.toggleRecordDropdown,
+ },
+ modal: {
+ clickButton: testUtilsModal.clickButton,
+ },
+ pivot: {
+ clickMeasure: testUtilsPivot.clickMeasure,
+ toggleMeasuresDropdown: testUtilsPivot.toggleMeasuresDropdown,
+ reload: testUtilsPivot.reload,
+ },
+ fields: {
+ many2one: {
+ createAndEdit: testUtilsFields.clickM2OCreateAndEdit,
+ clickOpenDropdown: testUtilsFields.clickOpenM2ODropdown,
+ clickHighlightedItem: testUtilsFields.clickM2OHighlightedItem,
+ clickItem: testUtilsFields.clickM2OItem,
+ searchAndClickItem: testUtilsFields.searchAndClickM2OItem,
+ },
+ editInput: testUtilsFields.editInput,
+ editSelect: testUtilsFields.editSelect,
+ editAndTrigger: testUtilsFields.editAndTrigger,
+ triggerKey: testUtilsFields.triggerKey,
+ triggerKeydown: testUtilsFields.triggerKeydown,
+ triggerKeyup: testUtilsFields.triggerKeyup,
+ },
+ file: {
+ createFile: testUtilsFile.createFile,
+ dragoverFile: testUtilsFile.dragoverFile,
+ dropFile: testUtilsFile.dropFile,
+ dropFiles: testUtilsFile.dropFiles,
+ inputFiles: testUtilsFile.inputFiles,
+ },
+
+ createActionManager: testUtilsCreate.createActionManager,
+ createComponent: testUtilsCreate.createComponent,
+ createControlPanel: testUtilsCreate.createControlPanel,
+ createDebugManager: testUtilsCreate.createDebugManager,
+ createAsyncView: testUtilsCreate.createView,
+ createCalendarView: testUtilsCreate.createCalendarView,
+ createView: testUtilsCreate.createView,
+ createModel: testUtilsCreate.createModel,
+ createParent: testUtilsCreate.createParent,
+ makeTestPromise: makeTestPromise,
+ makeTestPromiseWithAssert: makeTestPromiseWithAssert,
+ nextMicrotaskTick: nextMicrotaskTick,
+ nextTick: nextTick,
+ owlCompatibilityNextTick: owlCompatibilityNextTick,
+ prepareTarget: testUtilsCreate.prepareTarget,
+ returnAfterNextAnimationFrame: testUtilsDom.returnAfterNextAnimationFrame,
+
+ // backward-compatibility
+ addMockEnvironment: deprecated(testUtilsMock.addMockEnvironment, 'mock'),
+ dragAndDrop: deprecated(testUtilsDom.dragAndDrop, 'dom'),
+ fieldsViewGet: deprecated(testUtilsMock.fieldsViewGet, 'mock'),
+ intercept: deprecated(testUtilsMock.intercept, 'mock'),
+ openDatepicker: deprecated(testUtilsDom.openDatepicker, 'dom'),
+ patch: deprecated(testUtilsMock.patch, 'mock'),
+ patchDate: deprecated(testUtilsMock.patchDate, 'mock'),
+ triggerKeypressEvent: deprecated(testUtilsDom.triggerKeypressEvent, 'dom'),
+ triggerMouseEvent: deprecated(testUtilsDom.triggerMouseEvent, 'dom'),
+ triggerPositionalMouseEvent: deprecated(testUtilsDom.triggerPositionalMouseEvent, 'dom'),
+ unpatch: deprecated(testUtilsMock.unpatch, 'mock'),
+ };
+});
diff --git a/addons/web/static/tests/helpers/test_utils_control_panel.js b/addons/web/static/tests/helpers/test_utils_control_panel.js
new file mode 100644
index 00000000..2dda1980
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_control_panel.js
@@ -0,0 +1,351 @@
+odoo.define('web.test_utils_control_panel', function (require) {
+ "use strict";
+
+ const { click, findItem, getNode, triggerEvent } = require('web.test_utils_dom');
+ const { editInput, editSelect, editAndTrigger } = require('web.test_utils_fields');
+
+ //-------------------------------------------------------------------------
+ // Exported functions
+ //-------------------------------------------------------------------------
+
+ /**
+ * @param {EventTarget} el
+ * @param {(number|string)} menuFinder
+ * @returns {Promise}
+ */
+ async function toggleMenu(el, menuFinder) {
+ const menu = findItem(el, `.o_dropdown > button`, menuFinder);
+ await click(menu);
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @param {(number|string)} itemFinder
+ * @returns {Promise}
+ */
+ async function toggleMenuItem(el, itemFinder) {
+ const item = findItem(el, `.o_menu_item > a`, itemFinder);
+ await click(item);
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @param {(number|string)} itemFinder
+ * @param {(number|string)} optionFinder
+ * @returns {Promise}
+ */
+ async function toggleMenuItemOption(el, itemFinder, optionFinder) {
+ const item = findItem(el, `.o_menu_item > a`, itemFinder);
+ const option = findItem(item.parentNode, '.o_item_option > a', optionFinder);
+ await click(option);
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @param {(number|string)} itemFinder
+ * @returns {boolean}
+ */
+ function isItemSelected(el, itemFinder) {
+ const item = findItem(el, `.o_menu_item > a`, itemFinder);
+ return item.classList.contains('selected');
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @param {(number|string)} itemuFinder
+ * @param {(number|string)} optionFinder
+ * @returns {boolean}
+ */
+ function isOptionSelected(el, itemFinder, optionFinder) {
+ const item = findItem(el, `.o_menu_item > a`, itemFinder);
+ const option = findItem(item.parentNode, '.o_item_option > a', optionFinder);
+ return option.classList.contains('selected');
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {string[]}
+ */
+ function getMenuItemTexts(el) {
+ return [...getNode(el).querySelectorAll(`.o_dropdown ul .o_menu_item`)].map(
+ e => e.innerText.trim()
+ );
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {HTMLButtonElement[]}
+ */
+ function getButtons(el) {
+ return [...getNode(el).querySelector((`div.o_cp_bottom div.o_cp_buttons`)).children];
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {Promise}
+ */
+ async function toggleFilterMenu(el) {
+ await click(getNode(el).querySelector(`.o_filter_menu button`));
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {Promise}
+ */
+ async function toggleAddCustomFilter(el) {
+ await click(getNode(el).querySelector(`button.o_add_custom_filter`));
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {Promise}
+ */
+ async function applyFilter(el) {
+ await click(getNode(el).querySelector(`div.o_add_filter_menu > button.o_apply_filter`));
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {Promise}
+ */
+ async function toggleGroupByMenu(el) {
+ await click(getNode(el).querySelector(`.o_group_by_menu button`));
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {Promise}
+ */
+ async function toggleAddCustomGroup(el) {
+ await click(getNode(el).querySelector(`button.o_add_custom_group_by`));
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @param {string} fieldName
+ * @returns {Promise}
+ */
+ async function selectGroup(el, fieldName) {
+ await editSelect(
+ getNode(el).querySelector(`select.o_group_by_selector`),
+ fieldName
+ );
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {Promise}
+ */
+ async function applyGroup(el) {
+ await click(getNode(el).querySelector(`div.o_add_group_by_menu > button.o_apply_group_by`));
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {Promise}
+ */
+ async function toggleFavoriteMenu(el) {
+ await click(getNode(el).querySelector(`.o_favorite_menu button`));
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {Promise}
+ */
+ async function toggleSaveFavorite(el) {
+ await click(getNode(el).querySelector(`.o_favorite_menu .o_add_favorite button`));
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @param {string} name
+ * @returns {Promise}
+ */
+ async function editFavoriteName(el, name) {
+ await editInput(getNode(el).querySelector(`.o_favorite_menu .o_add_favorite input[type="text"]`), name);
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {Promise}
+ */
+ async function saveFavorite(el) {
+ await click(getNode(el).querySelector(`.o_favorite_menu .o_add_favorite button.o_save_favorite`));
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @param {(string|number)} favoriteFinder
+ * @returns {Promise}
+ */
+ async function deleteFavorite(el, favoriteFinder) {
+ const favorite = findItem(el, `.o_favorite_menu .o_menu_item`, favoriteFinder);
+ await click(favorite.querySelector('i.fa-trash-o'));
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {Promise}
+ */
+ async function toggleComparisonMenu(el) {
+ await click(getNode(el).querySelector(`div.o_comparison_menu > button`));
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {Promise}
+ */
+ function getFacetTexts(el) {
+ return [...getNode(el).querySelectorAll(`.o_searchview .o_searchview_facet`)].map(
+ facet => facet.innerText.trim()
+ );
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @param {(string|number)} facetFinder
+ * @returns {Promise}
+ */
+ async function removeFacet(el, facetFinder = 0) {
+ const facet = findItem(el, `.o_searchview .o_searchview_facet`, facetFinder);
+ await click(facet.querySelector('.o_facet_remove'));
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @param {string} value
+ * @returns {Promise}
+ */
+ async function editSearch(el, value) {
+ await editInput(getNode(el).querySelector(`.o_searchview_input`), value);
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {Promise}
+ */
+ async function validateSearch(el) {
+ await triggerEvent(
+ getNode(el).querySelector(`.o_searchview_input`),
+ 'keydown', { key: 'Enter' }
+ );
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @param {string} [menuFinder="Action"]
+ * @returns {Promise}
+ */
+ async function toggleActionMenu(el, menuFinder = "Action") {
+ const dropdown = findItem(el, `.o_cp_action_menus button`, menuFinder);
+ await click(dropdown);
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {Promise}
+ */
+ async function pagerPrevious(el) {
+ await click(getNode(el).querySelector(`.o_pager button.o_pager_previous`));
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {Promise}
+ */
+ async function pagerNext(el) {
+ await click(getNode(el).querySelector(`.o_pager button.o_pager_next`));
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {string}
+ */
+ function getPagerValue(el) {
+ const pagerValue = getNode(el).querySelector(`.o_pager_counter .o_pager_value`);
+ switch (pagerValue.tagName) {
+ case 'INPUT':
+ return pagerValue.value;
+ case 'SPAN':
+ return pagerValue.innerText.trim();
+ }
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @returns {string}
+ */
+ function getPagerSize(el) {
+ return getNode(el).querySelector(`.o_pager_counter span.o_pager_limit`).innerText.trim();
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @param {string} value
+ * @returns {Promise}
+ */
+ async function setPagerValue(el, value) {
+ let pagerValue = getNode(el).querySelector(`.o_pager_counter .o_pager_value`);
+ if (pagerValue.tagName === 'SPAN') {
+ await click(pagerValue);
+ }
+ pagerValue = getNode(el).querySelector(`.o_pager_counter input.o_pager_value`);
+ if (!pagerValue) {
+ throw new Error("Pager value is being edited and cannot be changed.");
+ }
+ await editAndTrigger(pagerValue, value, ['change', 'blur']);
+ }
+
+ /**
+ * @param {EventTarget} el
+ * @param {string} viewType
+ * @returns {Promise}
+ */
+ async function switchView(el, viewType) {
+ await click(getNode(el).querySelector(`button.o_switch_view.o_${viewType}`));
+ }
+
+ return {
+ // Generic interactions
+ toggleMenu,
+ toggleMenuItem,
+ toggleMenuItemOption,
+ isItemSelected,
+ isOptionSelected,
+ getMenuItemTexts,
+ // Button interactions
+ getButtons,
+ // FilterMenu interactions
+ toggleFilterMenu,
+ toggleAddCustomFilter,
+ applyFilter,
+ // GroupByMenu interactions
+ toggleGroupByMenu,
+ toggleAddCustomGroup,
+ selectGroup,
+ applyGroup,
+ // FavoriteMenu interactions
+ toggleFavoriteMenu,
+ toggleSaveFavorite,
+ editFavoriteName,
+ saveFavorite,
+ deleteFavorite,
+ // ComparisonMenu interactions
+ toggleComparisonMenu,
+ // SearchBar interactions
+ getFacetTexts,
+ removeFacet,
+ editSearch,
+ validateSearch,
+ // Action menus interactions
+ toggleActionMenu,
+ // Pager interactions
+ pagerPrevious,
+ pagerNext,
+ getPagerValue,
+ getPagerSize,
+ setPagerValue,
+ // View switcher
+ switchView,
+ };
+});
diff --git a/addons/web/static/tests/helpers/test_utils_create.js b/addons/web/static/tests/helpers/test_utils_create.js
new file mode 100644
index 00000000..908ac97a
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_create.js
@@ -0,0 +1,512 @@
+odoo.define('web.test_utils_create', function (require) {
+ "use strict";
+
+ /**
+ * Create Test Utils
+ *
+ * This module defines various utility functions to help creating mock widgets
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+ const ActionManager = require('web.ActionManager');
+ const ActionMenus = require('web.ActionMenus');
+ const concurrency = require('web.concurrency');
+ const config = require('web.config');
+ const ControlPanel = require('web.ControlPanel');
+ const customHooks = require('web.custom_hooks');
+ const DebugManager = require('web.DebugManager.Backend');
+ const dom = require('web.dom');
+ const makeTestEnvironment = require('web.test_env');
+ const ActionModel = require('web/static/src/js/views/action_model.js');
+ const Registry = require('web.Registry');
+ const testUtilsMock = require('web.test_utils_mock');
+ const Widget = require('web.Widget');
+
+ const { Component } = owl;
+ const { useRef, useState } = owl.hooks;
+ const { xml } = owl.tags;
+
+ /**
+ * Create and return an instance of ActionManager with all rpcs going through a
+ * mock method using the data, actions and archs objects as sources.
+ *
+ * @param {Object} [params={}]
+ * @param {Object} [params.actions] the actions given to the mock server
+ * @param {Object} [params.archs] this archs given to the mock server
+ * @param {Object} [params.data] the business data given to the mock server
+ * @param {function} [params.mockRPC]
+ * @returns {Promise<ActionManager>}
+ */
+ async function createActionManager(params = {}) {
+ const target = prepareTarget(params.debug);
+
+ const widget = new Widget();
+ // when 'document' addon is installed, the sidebar does a 'search_read' on
+ // model 'ir_attachment' each time a record is open, so we monkey-patch
+ // 'mockRPC' to mute those RPCs, so that the tests can be written uniformly,
+ // whether or not 'document' is installed
+ const mockRPC = params.mockRPC;
+ Object.assign(params, {
+ async mockRPC(route, args) {
+ if (args.model === 'ir.attachment') {
+ return [];
+ }
+ if (mockRPC) {
+ return mockRPC.apply(this, arguments);
+ }
+ return this._super(...arguments);
+ },
+ });
+ const mockServer = await testUtilsMock.addMockEnvironment(widget, Object.assign({ debounce: false }, params));
+ await widget.prependTo(target);
+ widget.el.classList.add('o_web_client');
+ if (config.device.isMobile) {
+ widget.el.classList.add('o_touch_device');
+ }
+
+ params.server = mockServer;
+
+ const userContext = params.context && params.context.user_context || {};
+ const actionManager = new ActionManager(widget, userContext);
+
+ // Override the ActionMenus registry unless told otherwise.
+ let actionMenusRegistry = ActionMenus.registry;
+ if (params.actionMenusRegistry !== true) {
+ ActionMenus.registry = new Registry();
+ }
+
+ const originalDestroy = ActionManager.prototype.destroy;
+ actionManager.destroy = function () {
+ actionManager.destroy = originalDestroy;
+ widget.destroy();
+ if (params.actionMenusRegistry !== true) {
+ ActionMenus.registry = actionMenusRegistry;
+ }
+ };
+ const fragment = document.createDocumentFragment();
+ await actionManager.appendTo(fragment);
+ dom.append(widget.el, fragment, {
+ callbacks: [{ widget: actionManager }],
+ in_DOM: true,
+ });
+ return actionManager;
+ }
+
+ /**
+ * Similar as createView, but specific for calendar views. Some calendar
+ * tests need to trigger positional clicks on the DOM produced by fullcalendar.
+ * Those tests must use this helper with option positionalClicks set to true.
+ * This will move the rendered calendar to the body (required to do positional
+ * clicks), and wait for a setTimeout(0) before returning, because fullcalendar
+ * makes the calendar scroll to 6:00 in a setTimeout(0), which might have an
+ * impact according to where we want to trigger positional clicks.
+ *
+ * @param {Object} params @see createView
+ * @param {Object} [options]
+ * @param {boolean} [options.positionalClicks=false]
+ * @returns {Promise<CalendarController>}
+ */
+ async function createCalendarView(params, options) {
+ const calendar = await createView(params);
+ if (!options || !options.positionalClicks) {
+ return calendar;
+ }
+ const viewElements = [...document.getElementById('qunit-fixture').children];
+ // prepend reset the scrollTop to zero so we restore it manually
+ let fcScroller = document.querySelector('.fc-scroller');
+ const scrollPosition = fcScroller.scrollTop;
+ viewElements.forEach(el => document.body.prepend(el));
+ fcScroller = document.querySelector('.fc-scroller');
+ fcScroller.scrollTop = scrollPosition;
+
+ const destroy = calendar.destroy;
+ calendar.destroy = () => {
+ viewElements.forEach(el => el.remove());
+ destroy();
+ };
+ await concurrency.delay(0);
+ return calendar;
+ }
+
+ /**
+ * Create a simple component environment with a basic Parent component, an
+ * extensible env and a mocked server. The returned value is the instance of
+ * the given constructor.
+ * @param {class} constructor Component class to instantiate
+ * @param {Object} [params = {}]
+ * @param {boolean} [params.debug]
+ * @param {Object} [params.env]
+ * @param {Object} [params.intercepts] object in which the keys represent the
+ * intercepted event names and the values are their callbacks.
+ * @param {Object} [params.props]
+ * @returns {Promise<Component>} instance of `constructor`
+ */
+ async function createComponent(constructor, params = {}) {
+ if (!constructor) {
+ throw new Error(`Missing argument "constructor".`);
+ }
+ if (!(constructor.prototype instanceof Component)) {
+ throw new Error(`Argument "constructor" must be an Owl Component.`);
+ }
+ const cleanUp = await testUtilsMock.addMockEnvironmentOwl(Component, params);
+ class Parent extends Component {
+ constructor() {
+ super(...arguments);
+ this.Component = constructor;
+ this.state = useState(params.props || {});
+ this.component = useRef('component');
+ for (const eventName in params.intercepts || {}) {
+ customHooks.useListener(eventName, params.intercepts[eventName]);
+ }
+ }
+ }
+ Parent.template = xml`<t t-component="Component" t-props="state" t-ref="component"/>`;
+ const parent = new Parent();
+ await parent.mount(prepareTarget(params.debug), { position: 'first-child' });
+ const child = parent.component.comp;
+ const originalDestroy = child.destroy;
+ child.destroy = function () {
+ child.destroy = originalDestroy;
+ cleanUp();
+ parent.destroy();
+ };
+ return child;
+ }
+
+ /**
+ * Create a Control Panel instance, with an extensible environment and
+ * its related Control Panel Model. Event interception is done through
+ * params['get-controller-query-params'] and params.search, for the two
+ * available event handlers respectively.
+ * @param {Object} [params={}]
+ * @param {Object} [params.cpProps]
+ * @param {Object} [params.cpModelConfig]
+ * @param {boolean} [params.debug]
+ * @param {Object} [params.env]
+ * @returns {Object} useful control panel testing elements:
+ * - controlPanel: the control panel instance
+ * - el: the control panel HTML element
+ * - helpers: a suite of bound helpers (see above functions for all
+ * available helpers)
+ */
+ async function createControlPanel(params = {}) {
+ const debug = params.debug || false;
+ const env = makeTestEnvironment(params.env || {});
+ const props = Object.assign({
+ action: {},
+ fields: {},
+ }, params.cpProps);
+ const globalConfig = Object.assign({
+ context: {},
+ domain: [],
+ }, params.cpModelConfig);
+
+ if (globalConfig.arch && globalConfig.fields) {
+ const model = "__mockmodel__";
+ const serverParams = {
+ model,
+ data: { [model]: { fields: globalConfig.fields, records: [] } },
+ };
+ const mockServer = await testUtilsMock.addMockEnvironment(
+ new Widget(),
+ serverParams,
+ );
+ const { arch, fields } = testUtilsMock.fieldsViewGet(mockServer, {
+ arch: globalConfig.arch,
+ fields: globalConfig.fields,
+ model,
+ viewOptions: { context: globalConfig.context },
+ });
+ Object.assign(globalConfig, { arch, fields });
+ }
+
+ globalConfig.env = env;
+ const archs = (globalConfig.arch && { search: globalConfig.arch, }) || {};
+ const { ControlPanel: controlPanelInfo, } = ActionModel.extractArchInfo(archs);
+ const extensions = {
+ ControlPanel: { archNodes: controlPanelInfo.children, },
+ };
+
+ class Parent extends Component {
+ constructor() {
+ super();
+ this.searchModel = new ActionModel(extensions, globalConfig);
+ this.state = useState(props);
+ this.controlPanel = useRef("controlPanel");
+ }
+ async willStart() {
+ await this.searchModel.load();
+ }
+ mounted() {
+ if (params['get-controller-query-params']) {
+ this.searchModel.on('get-controller-query-params', this,
+ params['get-controller-query-params']);
+ }
+ if (params.search) {
+ this.searchModel.on('search', this, params.search);
+ }
+ }
+ }
+ Parent.components = { ControlPanel };
+ Parent.env = env;
+ Parent.template = xml`
+ <ControlPanel
+ t-ref="controlPanel"
+ t-props="state"
+ searchModel="searchModel"
+ />`;
+
+ const parent = new Parent();
+ await parent.mount(prepareTarget(debug), { position: 'first-child' });
+
+ const controlPanel = parent.controlPanel.comp;
+ const destroy = controlPanel.destroy;
+ controlPanel.destroy = function () {
+ controlPanel.destroy = destroy;
+ parent.destroy();
+ };
+ controlPanel.getQuery = () => parent.searchModel.get('query');
+
+ return controlPanel;
+ }
+
+ /**
+ * Create and return an instance of DebugManager with all rpcs going through a
+ * mock method, assuming that the user has access rights, and is an admin.
+ *
+ * @param {Object} [params={}]
+ * @returns {Promise<DebugManager>}
+ */
+ async function createDebugManager(params = {}) {
+ const mockRPC = params.mockRPC;
+ Object.assign(params, {
+ async mockRPC(route, args) {
+ if (args.method === 'check_access_rights') {
+ return true;
+ }
+ if (args.method === 'xmlid_to_res_id') {
+ return true;
+ }
+ if (mockRPC) {
+ return mockRPC.apply(this, arguments);
+ }
+ return this._super(...arguments);
+ },
+ session: {
+ async user_has_group(group) {
+ if (group === 'base.group_no_one') {
+ return true;
+ }
+ return this._super(...arguments);
+ },
+ },
+ });
+ const debugManager = new DebugManager();
+ await testUtilsMock.addMockEnvironment(debugManager, params);
+ return debugManager;
+ }
+
+ /**
+ * Create a model from given parameters.
+ *
+ * @param {Object} params This object will be given to addMockEnvironment, so
+ * any parameters from that method applies
+ * @param {Class} params.Model the model class to use
+ * @returns {Model}
+ */
+ async function createModel(params) {
+ const widget = new Widget();
+
+ const model = new params.Model(widget, params);
+
+ await testUtilsMock.addMockEnvironment(widget, params);
+
+ // override the model's 'destroy' so that it calls 'destroy' on the widget
+ // instead, as the widget is the parent of the model and the mockServer.
+ model.destroy = function () {
+ // remove the override to properly destroy the model when it will be
+ // called the second time (by its parent)
+ delete model.destroy;
+ widget.destroy();
+ };
+
+ return model;
+ }
+
+ /**
+ * Create a widget parent from given parameters.
+ *
+ * @param {Object} params This object will be given to addMockEnvironment, so
+ * any parameters from that method applies
+ * @returns {Promise<Widget>}
+ */
+ async function createParent(params) {
+ const widget = new Widget();
+ await testUtilsMock.addMockEnvironment(widget, params);
+ return widget;
+ }
+
+ /**
+ * Create a view from various parameters. Here, a view means a javascript
+ * instance of an AbstractView class, such as a form view, a list view or a
+ * kanban view.
+ *
+ * It returns the instance of the view, properly created, with all rpcs going
+ * through a mock method using the data object as source, and already loaded/
+ * started.
+ *
+ * @param {Object} params
+ * @param {string} params.arch the xml (arch) of the view to be instantiated
+ * @param {any[]} [params.domain] the initial domain for the view
+ * @param {Object} [params.context] the initial context for the view
+ * @param {string[]} [params.groupBy] the initial groupBy for the view
+ * @param {Object[]} [params.favoriteFilters] the favorite filters one would like to have at initialization
+ * @param {integer} [params.fieldDebounce=0] the debounce value to use for the
+ * duration of the test.
+ * @param {AbstractView} params.View the class that will be instantiated
+ * @param {string} params.model a model name, will be given to the view
+ * @param {Object} params.intercepts an object with event names as key, and
+ * callback as value. Each key,value will be used to intercept the event.
+ * Note that this is particularly useful if you want to intercept events going
+ * up in the init process of the view, because there are no other way to do it
+ * after this method returns
+ * @param {Boolean} [params.doNotDisableAHref=false] will not preventDefault on the A elements of the view if true.
+ * Default is false.
+ * @returns {Promise<AbstractController>} the instance of the view
+ */
+ async function createView(params) {
+ const target = prepareTarget(params.debug);
+ const widget = new Widget();
+ // reproduce the DOM environment of views
+ const webClient = Object.assign(document.createElement('div'), {
+ className: 'o_web_client',
+ });
+ const actionManager = Object.assign(document.createElement('div'), {
+ className: 'o_action_manager',
+ });
+ target.prepend(webClient);
+ webClient.append(actionManager);
+
+ // add mock environment: mock server, session, fieldviewget, ...
+ const mockServer = await testUtilsMock.addMockEnvironment(widget, params);
+ const viewInfo = testUtilsMock.fieldsViewGet(mockServer, params);
+
+ params.server = mockServer;
+
+ // create the view
+ const View = params.View;
+ const modelName = params.model || 'foo';
+ const defaultAction = {
+ res_model: modelName,
+ context: {},
+ };
+ const viewOptions = Object.assign({
+ action: Object.assign(defaultAction, params.action),
+ view: viewInfo,
+ modelName: modelName,
+ ids: 'res_id' in params ? [params.res_id] : undefined,
+ currentId: 'res_id' in params ? params.res_id : undefined,
+ domain: params.domain || [],
+ context: params.context || {},
+ hasActionMenus: false,
+ }, params.viewOptions);
+ // patch the View to handle the groupBy given in params, as we can't give it
+ // in init (unlike the domain and context which can be set in the action)
+ testUtilsMock.patch(View, {
+ _updateMVCParams() {
+ this._super(...arguments);
+ this.loadParams.groupedBy = params.groupBy || viewOptions.groupBy || [];
+ testUtilsMock.unpatch(View);
+ },
+ });
+ if ('hasSelectors' in params) {
+ viewOptions.hasSelectors = params.hasSelectors;
+ }
+
+ let view;
+ if (viewInfo.type === 'controlpanel' || viewInfo.type === 'search') {
+ // TODO: probably needs to create an helper just for that
+ view = new params.View({ viewInfo, modelName });
+ } else {
+ viewOptions.controlPanelFieldsView = Object.assign(testUtilsMock.fieldsViewGet(mockServer, {
+ arch: params.archs && params.archs[params.model + ',false,search'] || '<search/>',
+ fields: viewInfo.fields,
+ model: params.model,
+ }), { favoriteFilters: params.favoriteFilters });
+
+ view = new params.View(viewInfo, viewOptions);
+ }
+
+ if (params.interceptsPropagate) {
+ for (const name in params.interceptsPropagate) {
+ testUtilsMock.intercept(widget, name, params.interceptsPropagate[name], true);
+ }
+ }
+
+ // Override the ActionMenus registry unless told otherwise.
+ let actionMenusRegistry = ActionMenus.registry;
+ if (params.actionMenusRegistry !== true) {
+ ActionMenus.registry = new Registry();
+ }
+
+ const viewController = await view.getController(widget);
+ // override the view's 'destroy' so that it calls 'destroy' on the widget
+ // instead, as the widget is the parent of the view and the mockServer.
+ viewController.__destroy = viewController.destroy;
+ viewController.destroy = function () {
+ // remove the override to properly destroy the viewController and its children
+ // when it will be called the second time (by its parent)
+ delete viewController.destroy;
+ widget.destroy();
+ webClient.remove();
+ if (params.actionMenusRegistry !== true) {
+ ActionMenus.registry = actionMenusRegistry;
+ }
+ };
+
+ // render the viewController in a fragment as they must be able to render correctly
+ // without being in the DOM
+ const fragment = document.createDocumentFragment();
+ await viewController.appendTo(fragment);
+ dom.prepend(actionManager, fragment, {
+ callbacks: [{ widget: viewController }],
+ in_DOM: true,
+ });
+
+ if (!params.doNotDisableAHref) {
+ [...viewController.el.getElementsByTagName('A')].forEach(elem => {
+ elem.addEventListener('click', ev => {
+ ev.preventDefault();
+ });
+ });
+ }
+ return viewController;
+ }
+
+ /**
+ * Get the target (fixture or body) of the document and adds event listeners
+ * to intercept custom or DOM events.
+ *
+ * @param {boolean} [debug=false] if true, the widget will be appended in
+ * the DOM. Also, RPCs and uncaught OdooEvent will be logged
+ * @returns {HTMLElement}
+ */
+ function prepareTarget(debug = false) {
+ document.body.classList.toggle('debug', debug);
+ return debug ? document.body : document.getElementById('qunit-fixture');
+ }
+
+ return {
+ createActionManager,
+ createCalendarView,
+ createComponent,
+ createControlPanel,
+ createDebugManager,
+ createModel,
+ createParent,
+ createView,
+ prepareTarget,
+ };
+});
diff --git a/addons/web/static/tests/helpers/test_utils_dom.js b/addons/web/static/tests/helpers/test_utils_dom.js
new file mode 100644
index 00000000..eedbcbe5
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_dom.js
@@ -0,0 +1,551 @@
+odoo.define('web.test_utils_dom', function (require) {
+ "use strict";
+
+ const concurrency = require('web.concurrency');
+ const Widget = require('web.Widget');
+
+ /**
+ * DOM Test Utils
+ *
+ * This module defines various utility functions to help simulate DOM events.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+ //-------------------------------------------------------------------------
+ // Private functions
+ //-------------------------------------------------------------------------
+
+ // TriggerEvent helpers
+ const keyboardEventBubble = args => Object.assign({}, args, { bubbles: true, keyCode: args.which });
+ const mouseEventMapping = args => Object.assign({}, args, {
+ bubbles: true,
+ cancelable: true,
+ clientX: args ? args.pageX : undefined,
+ clientY: args ? args.pageY : undefined,
+ view: window,
+ });
+ const mouseEventNoBubble = args => Object.assign({}, args, {
+ bubbles: false,
+ cancelable: false,
+ clientX: args ? args.pageX : undefined,
+ clientY: args ? args.pageY : undefined,
+ view: window,
+ });
+ const noBubble = args => Object.assign({}, args, { bubbles: false });
+ const onlyBubble = args => Object.assign({}, args, { bubbles: true });
+ // TriggerEvent constructor/args processor mapping
+ const EVENT_TYPES = {
+ auxclick: { constructor: MouseEvent, processParameters: mouseEventMapping },
+ click: { constructor: MouseEvent, processParameters: mouseEventMapping },
+ contextmenu: { constructor: MouseEvent, processParameters: mouseEventMapping },
+ dblclick: { constructor: MouseEvent, processParameters: mouseEventMapping },
+ mousedown: { constructor: MouseEvent, processParameters: mouseEventMapping },
+ mouseup: { constructor: MouseEvent, processParameters: mouseEventMapping },
+
+ mousemove: { constructor: MouseEvent, processParameters: mouseEventMapping },
+ mouseenter: { constructor: MouseEvent, processParameters: mouseEventNoBubble },
+ mouseleave: { constructor: MouseEvent, processParameters: mouseEventNoBubble },
+ mouseover: { constructor: MouseEvent, processParameters: mouseEventMapping },
+ mouseout: { constructor: MouseEvent, processParameters: mouseEventMapping },
+
+ focus: { constructor: FocusEvent, processParameters: noBubble },
+ focusin: { constructor: FocusEvent, processParameters: onlyBubble },
+ blur: { constructor: FocusEvent, processParameters: noBubble },
+
+ cut: { constructor: ClipboardEvent, processParameters: onlyBubble },
+ copy: { constructor: ClipboardEvent, processParameters: onlyBubble },
+ paste: { constructor: ClipboardEvent, processParameters: onlyBubble },
+
+ keydown: { constructor: KeyboardEvent, processParameters: keyboardEventBubble },
+ keypress: { constructor: KeyboardEvent, processParameters: keyboardEventBubble },
+ keyup: { constructor: KeyboardEvent, processParameters: keyboardEventBubble },
+
+ drag: { constructor: DragEvent, processParameters: onlyBubble },
+ dragend: { constructor: DragEvent, processParameters: onlyBubble },
+ dragenter: { constructor: DragEvent, processParameters: onlyBubble },
+ dragstart: { constructor: DragEvent, processParameters: onlyBubble },
+ dragleave: { constructor: DragEvent, processParameters: onlyBubble },
+ dragover: { constructor: DragEvent, processParameters: onlyBubble },
+ drop: { constructor: DragEvent, processParameters: onlyBubble },
+
+ input: { constructor: InputEvent, processParameters: onlyBubble },
+
+ compositionstart: { constructor: CompositionEvent, processParameters: onlyBubble },
+ compositionend: { constructor: CompositionEvent, processParameters: onlyBubble },
+ };
+
+ /**
+ * Check if an object is an instance of EventTarget.
+ *
+ * @param {Object} node
+ * @returns {boolean}
+ */
+ function _isEventTarget(node) {
+ if (!node) {
+ throw new Error(`Provided node is ${node}.`);
+ }
+ if (node instanceof window.top.EventTarget) {
+ return true;
+ }
+ const contextWindow = node.defaultView || // document
+ (node.ownerDocument && node.ownerDocument.defaultView); // iframe node
+ return contextWindow && node instanceof contextWindow.EventTarget;
+ }
+
+ //-------------------------------------------------------------------------
+ // Public functions
+ //-------------------------------------------------------------------------
+
+ /**
+ * Click on a specified element. If the option first or last is not specified,
+ * this method also check the unicity and the visibility of the target.
+ *
+ * @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector)
+ * @param {Object} [options={}] click options
+ * @param {boolean} [options.allowInvisible=false] if true, clicks on the
+ * element event if it is invisible
+ * @param {boolean} [options.first=false] if true, clicks on the first element
+ * @param {boolean} [options.last=false] if true, clicks on the last element
+ * @returns {Promise}
+ */
+ async function click(el, options = {}) {
+ let matches, target;
+ let selectorMsg = "";
+ if (typeof el === 'string') {
+ el = $(el);
+ }
+ if (_isEventTarget(el)) {
+ // EventTarget
+ matches = [el];
+ } else {
+ // Any other iterable object containing EventTarget objects (jQuery, HTMLCollection, etc.)
+ matches = [...el];
+ }
+
+ const validMatches = options.allowInvisible ?
+ matches : matches.filter(t => $(t).is(':visible'));
+
+ if (options.first) {
+ if (validMatches.length === 1) {
+ throw new Error(`There should be more than one visible target ${selectorMsg}. If` +
+ ' you are sure that there is exactly one target, please use the ' +
+ 'click function instead of the clickFirst function');
+ }
+ target = validMatches[0];
+ } else if (options.last) {
+ if (validMatches.length === 1) {
+ throw new Error(`There should be more than one visible target ${selectorMsg}. If` +
+ ' you are sure that there is exactly one target, please use the ' +
+ 'click function instead of the clickLast function');
+ }
+ target = validMatches[validMatches.length - 1];
+ } else if (validMatches.length !== 1) {
+ throw new Error(`Found ${validMatches.length} elements to click on, instead of 1 ${selectorMsg}`);
+ } else {
+ target = validMatches[0];
+ }
+ if (validMatches.length === 0 && matches.length > 0) {
+ throw new Error(`Element to click on is not visible ${selectorMsg}`);
+ }
+
+ return triggerEvent(target, 'click');
+ }
+
+ /**
+ * Click on the first element of a list of elements. Note that if the list has
+ * only one visible element, we trigger an error. In that case, it is better to
+ * use the click helper instead.
+ *
+ * @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector)
+ * @param {boolean} [options={}] click options
+ * @param {boolean} [options.allowInvisible=false] if true, clicks on the
+ * element event if it is invisible
+ * @returns {Promise}
+ */
+ async function clickFirst(el, options) {
+ return click(el, Object.assign({}, options, { first: true }));
+ }
+
+ /**
+ * Click on the last element of a list of elements. Note that if the list has
+ * only one visible element, we trigger an error. In that case, it is better to
+ * use the click helper instead.
+ *
+ * @param {string|EventTarget|EventTarget[]} el (if string: it is a (jquery) selector)
+ * @param {boolean} [options={}] click options
+ * @param {boolean} [options.allowInvisible=false] if true, clicks on the
+ * element event if it is invisible
+ * @returns {Promise}
+ */
+ async function clickLast(el, options) {
+ return click(el, Object.assign({}, options, { last: true }));
+ }
+
+ /**
+ * Simulate a drag and drop operation between 2 jquery nodes: $el and $to.
+ * This is a crude simulation, with only the mousedown, mousemove and mouseup
+ * events, but it is enough to help test drag and drop operations with jqueryUI
+ * sortable.
+ *
+ * @todo: remove the withTrailingClick option when the jquery update branch is
+ * merged. This is not the default as of now, because handlers are triggered
+ * synchronously, which is not the same as the 'reality'.
+ *
+ * @param {jQuery|EventTarget} $el
+ * @param {jQuery|EventTarget} $to
+ * @param {Object} [options]
+ * @param {string|Object} [options.position='center'] target position:
+ * can either be one of {'top', 'bottom', 'left', 'right'} or
+ * an object with two attributes (top and left))
+ * @param {boolean} [options.disableDrop=false] whether to trigger the drop action
+ * @param {boolean} [options.continueMove=false] whether to trigger the
+ * mousedown action (will only work after another call of this function with
+ * without this option)
+ * @param {boolean} [options.withTrailingClick=false] if true, this utility
+ * function will also trigger a click on the target after the mouseup event
+ * (this is actually what happens when a drag and drop operation is done)
+ * @param {jQuery|EventTarget} [options.mouseenterTarget=undefined] target of the mouseenter event
+ * @param {jQuery|EventTarget} [options.mousedownTarget=undefined] target of the mousedown event
+ * @param {jQuery|EventTarget} [options.mousemoveTarget=undefined] target of the mousemove event
+ * @param {jQuery|EventTarget} [options.mouseupTarget=undefined] target of the mouseup event
+ * @param {jQuery|EventTarget} [options.ctrlKey=undefined] if the ctrl key should be considered pressed at the time of mouseup
+ * @returns {Promise}
+ */
+ async function dragAndDrop($el, $to, options) {
+ let el = null;
+ if (_isEventTarget($el)) {
+ el = $el;
+ $el = $(el);
+ }
+ if (_isEventTarget($to)) {
+ $to = $($to);
+ }
+ options = options || {};
+ const position = options.position || 'center';
+ const elementCenter = $el.offset();
+ const toOffset = $to.offset();
+
+ if (typeof position === 'object') {
+ toOffset.top += position.top + 1;
+ toOffset.left += position.left + 1;
+ } else {
+ toOffset.top += $to.outerHeight() / 2;
+ toOffset.left += $to.outerWidth() / 2;
+ const vertical_offset = (toOffset.top < elementCenter.top) ? -1 : 1;
+ if (position === 'top') {
+ toOffset.top -= $to.outerHeight() / 2 + vertical_offset;
+ } else if (position === 'bottom') {
+ toOffset.top += $to.outerHeight() / 2 - vertical_offset;
+ } else if (position === 'left') {
+ toOffset.left -= $to.outerWidth() / 2;
+ } else if (position === 'right') {
+ toOffset.left += $to.outerWidth() / 2;
+ }
+ }
+
+ if ($to[0].ownerDocument !== document) {
+ // we are in an iframe
+ const bound = $('iframe')[0].getBoundingClientRect();
+ toOffset.left += bound.left;
+ toOffset.top += bound.top;
+ }
+ await triggerEvent(options.mouseenterTarget || el || $el, 'mouseover', {}, true);
+ if (!(options.continueMove)) {
+ elementCenter.left += $el.outerWidth() / 2;
+ elementCenter.top += $el.outerHeight() / 2;
+
+ await triggerEvent(options.mousedownTarget || el || $el, 'mousedown', {
+ which: 1,
+ pageX: elementCenter.left,
+ pageY: elementCenter.top
+ }, true);
+ }
+ await triggerEvent(options.mousemoveTarget || el || $el, 'mousemove', {
+ which: 1,
+ pageX: toOffset.left,
+ pageY: toOffset.top
+ }, true);
+
+ if (!options.disableDrop) {
+ await triggerEvent(options.mouseupTarget || el || $el, 'mouseup', {
+ which: 1,
+ pageX: toOffset.left,
+ pageY: toOffset.top,
+ ctrlKey: options.ctrlKey,
+ }, true);
+ if (options.withTrailingClick) {
+ await triggerEvent(options.mouseupTarget || el || $el, 'click', {}, true);
+ }
+ } else {
+ // It's impossible to drag another element when one is already
+ // being dragged. So it's necessary to finish the drop when the test is
+ // over otherwise it's impossible for the next tests to drag and
+ // drop elements.
+ $el.on('remove', function () {
+ triggerEvent($el, 'mouseup', {}, true);
+ });
+ }
+ return returnAfterNextAnimationFrame();
+ }
+
+ /**
+ * Helper method to retrieve a distinct item from a collection of elements defined
+ * by the given "selector" string. It can either be the index of the item or its
+ * inner text.
+ * @param {Element} el
+ * @param {string} selector
+ * @param {number | string} [elFinder=0]
+ * @returns {Element | null}
+ */
+ function findItem(el, selector, elFinder = 0) {
+ const elements = [...getNode(el).querySelectorAll(selector)];
+ if (!elements.length) {
+ throw new Error(`No element found with selector "${selector}".`);
+ }
+ switch (typeof elFinder) {
+ case "number": {
+ const match = elements[elFinder];
+ if (!match) {
+ throw new Error(
+ `No element with selector "${selector}" at index ${elFinder}.`
+ );
+ }
+ return match;
+ }
+ case "string": {
+ const match = elements.find(
+ (el) => el.innerText.trim().toLowerCase() === elFinder.toLowerCase()
+ );
+ if (!match) {
+ throw new Error(
+ `No element with selector "${selector}" containing "${elFinder}".
+ `);
+ }
+ return match;
+ }
+ default: throw new Error(
+ `Invalid provided element finder: must be a number|string|function.`
+ );
+ }
+ }
+
+ /**
+ * Helper function used to extract an HTML EventTarget element from a given
+ * target. The extracted element will depend on the target type:
+ * - Component|Widget -> el
+ * - jQuery -> associated element (must have 1)
+ * - HTMLCollection (or similar) -> first element (must have 1)
+ * - string -> result of document.querySelector with string
+ * - else -> as is
+ * @private
+ * @param {(Component|Widget|jQuery|HTMLCollection|HTMLElement|string)} target
+ * @returns {EventTarget}
+ */
+ function getNode(target) {
+ let nodes;
+ if (target instanceof owl.Component || target instanceof Widget) {
+ nodes = [target.el];
+ } else if (typeof target === 'string') {
+ nodes = document.querySelectorAll(target);
+ } else if (target === jQuery) { // jQuery (or $)
+ nodes = [document.body];
+ } else if (target.length) { // jQuery instance, HTMLCollection or array
+ nodes = target;
+ } else {
+ nodes = [target];
+ }
+ if (nodes.length !== 1) {
+ throw new Error(`Found ${nodes.length} nodes instead of 1.`);
+ }
+ const node = nodes[0];
+ if (!node) {
+ throw new Error(`Expected a node and got ${node}.`);
+ }
+ if (!_isEventTarget(node)) {
+ throw new Error(`Expected node to be an instance of EventTarget and got ${node.constructor.name}.`);
+ }
+ return node;
+ }
+
+ /**
+ * Open the datepicker of a given element.
+ *
+ * @param {jQuery} $datepickerEl element to which a datepicker is attached
+ */
+ async function openDatepicker($datepickerEl) {
+ return click($datepickerEl.find('.o_datepicker_input'));
+ }
+
+ /**
+ * Returns a promise that will be resolved after the nextAnimationFrame after
+ * the next tick
+ *
+ * This is useful to guarantee that OWL has had the time to render
+ *
+ * @returns {Promise}
+ */
+ async function returnAfterNextAnimationFrame() {
+ await concurrency.delay(0);
+ await new Promise(resolve => {
+ window.requestAnimationFrame(resolve);
+ });
+ }
+
+ /**
+ * Trigger an event on the specified target.
+ * This function will dispatch a native event to an EventTarget or a
+ * jQuery event to a jQuery object. This behaviour can be overridden by the
+ * jquery option.
+ *
+ * @param {EventTarget|EventTarget[]} el
+ * @param {string} eventType event type
+ * @param {Object} [eventAttrs] event attributes
+ * on a jQuery element with the `$.fn.trigger` function
+ * @param {Boolean} [fast=false] true if the trigger event have to wait for a single tick instead of waiting for the next animation frame
+ * @returns {Promise}
+ */
+ async function triggerEvent(el, eventType, eventAttrs = {}, fast = false) {
+ let matches;
+ let selectorMsg = "";
+ if (_isEventTarget(el)) {
+ matches = [el];
+ } else {
+ matches = [...el];
+ }
+
+ if (matches.length !== 1) {
+ throw new Error(`Found ${matches.length} elements to trigger "${eventType}" on, instead of 1 ${selectorMsg}`);
+ }
+
+ const target = matches[0];
+ let event;
+
+ if (!EVENT_TYPES[eventType] && !EVENT_TYPES[eventType.type]) {
+ event = new Event(eventType, Object.assign({}, eventAttrs, { bubbles: true }));
+ } else {
+ if (typeof eventType === "object") {
+ const { constructor, processParameters } = EVENT_TYPES[eventType.type];
+ const eventParameters = processParameters(eventType);
+ event = new constructor(eventType.type, eventParameters);
+ } else {
+ const { constructor, processParameters } = EVENT_TYPES[eventType];
+ event = new constructor(eventType, processParameters(eventAttrs));
+ }
+ }
+ target.dispatchEvent(event);
+ return fast ? undefined : returnAfterNextAnimationFrame();
+ }
+
+ /**
+ * Trigger multiple events on the specified element.
+ *
+ * @param {EventTarget|EventTarget[]} el
+ * @param {string[]} events the events you want to trigger
+ * @returns {Promise}
+ */
+ async function triggerEvents(el, events) {
+ if (el instanceof jQuery) {
+ if (el.length !== 1) {
+ throw new Error(`target has length ${el.length} instead of 1`);
+ }
+ }
+ if (typeof events === 'string') {
+ events = [events];
+ }
+
+ for (let e = 0; e < events.length; e++) {
+ await triggerEvent(el, events[e]);
+ }
+ }
+
+ /**
+ * Simulate a keypress event for a given character
+ *
+ * @param {string} char the character, or 'ENTER'
+ * @returns {Promise}
+ */
+ async function triggerKeypressEvent(char) {
+ let keycode;
+ if (char === 'Enter') {
+ keycode = $.ui.keyCode.ENTER;
+ } else if (char === "Tab") {
+ keycode = $.ui.keyCode.TAB;
+ } else {
+ keycode = char.charCodeAt(0);
+ }
+ return triggerEvent(document.body, 'keypress', {
+ key: char,
+ keyCode: keycode,
+ which: keycode,
+ });
+ }
+
+ /**
+ * simulate a mouse event with a custom event who add the item position. This is
+ * sometimes necessary because the basic way to trigger an event (such as
+ * $el.trigger('mousemove')); ) is too crude for some uses.
+ *
+ * @param {jQuery|EventTarget} $el
+ * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove'
+ * @returns {Promise}
+ */
+ async function triggerMouseEvent($el, type) {
+ const el = $el instanceof jQuery ? $el[0] : $el;
+ if (!el) {
+ throw new Error(`no target found to trigger MouseEvent`);
+ }
+ const rect = el.getBoundingClientRect();
+ // little fix since it seems on chrome, it triggers 1px too on the left
+ const left = rect.x + 1;
+ const top = rect.y;
+ return triggerEvent($el, type, {
+ which: 1,
+ pageX: left, layerX: left, screenX: left,
+ pageY: top, layerY: top, screenY: top,
+ });
+ }
+
+ /**
+ * simulate a mouse event with a custom event on a position x and y. This is
+ * sometimes necessary because the basic way to trigger an event (such as
+ * $el.trigger('mousemove')); ) is too crude for some uses.
+ *
+ * @param {integer} x
+ * @param {integer} y
+ * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove'
+ * @returns {HTMLElement}
+ */
+ async function triggerPositionalMouseEvent(x, y, type) {
+ const ev = document.createEvent("MouseEvent");
+ const el = document.elementFromPoint(x, y);
+ ev.initMouseEvent(
+ type,
+ true /* bubble */,
+ true /* cancelable */,
+ window, null,
+ x, y, x, y, /* coordinates */
+ false, false, false, false, /* modifier keys */
+ 0 /*left button*/, null
+ );
+ el.dispatchEvent(ev);
+ return el;
+ }
+
+ return {
+ click,
+ clickFirst,
+ clickLast,
+ dragAndDrop,
+ findItem,
+ getNode,
+ openDatepicker,
+ returnAfterNextAnimationFrame,
+ triggerEvent,
+ triggerEvents,
+ triggerKeypressEvent,
+ triggerMouseEvent,
+ triggerPositionalMouseEvent,
+ };
+});
diff --git a/addons/web/static/tests/helpers/test_utils_fields.js b/addons/web/static/tests/helpers/test_utils_fields.js
new file mode 100644
index 00000000..3e35235c
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_fields.js
@@ -0,0 +1,250 @@
+odoo.define('web.test_utils_fields', function (require) {
+ "use strict";
+
+ /**
+ * Field Test Utils
+ *
+ * This module defines various utility functions to help testing field widgets.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+ const testUtilsDom = require('web.test_utils_dom');
+
+ const ARROW_KEYS_MAPPING = {
+ down: 'ArrowDown',
+ left: 'ArrowLeft',
+ right: 'ArrowRight',
+ up: 'ArrowUp',
+ };
+
+ //-------------------------------------------------------------------------
+ // Public functions
+ //-------------------------------------------------------------------------
+
+ /**
+ * Autofills the input of a many2one field and clicks on the "Create and Edit" option.
+ *
+ * @param {string} fieldName
+ * @param {string} text Used as default value for the record name
+ * @see clickM2OItem
+ */
+ async function clickM2OCreateAndEdit(fieldName, text = "ABC") {
+ await clickOpenM2ODropdown(fieldName);
+ const match = document.querySelector(`.o_field_many2one[name=${fieldName}] input`);
+ await editInput(match, text);
+ return clickM2OItem(fieldName, "Create and Edit");
+ }
+
+ /**
+ * Click on the active (highlighted) selection in a m2o dropdown.
+ *
+ * @param {string} fieldName
+ * @param {[string]} selector if set, this will restrict the search for the m2o
+ * input
+ * @returns {Promise}
+ */
+ async function clickM2OHighlightedItem(fieldName, selector) {
+ const m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`;
+ const $dropdown = $(m2oSelector).autocomplete('widget');
+ // clicking on an li (no matter which one), will select the focussed one
+ return testUtilsDom.click($dropdown[0].querySelector('li'));
+ }
+
+ /**
+ * Click on a menuitem in the m2o dropdown. This helper will target an element
+ * which contains some specific text. Note that it assumes that the dropdown
+ * is currently open.
+ *
+ * Example:
+ * testUtils.fields.many2one.clickM2OItem('partner_id', 'George');
+ *
+ * @param {string} fieldName
+ * @param {string} searchText
+ * @returns {Promise}
+ */
+ async function clickM2OItem(fieldName, searchText) {
+ const m2oSelector = `.o_field_many2one[name=${fieldName}] input`;
+ const $dropdown = $(m2oSelector).autocomplete('widget');
+ const $target = $dropdown.find(`li:contains(${searchText})`).first();
+ if ($target.length !== 1 || !$target.is(':visible')) {
+ throw new Error('Menu item should be visible');
+ }
+ $target.mouseenter(); // This is NOT a mouseenter event. See jquery.js:5516 for more headaches.
+ return testUtilsDom.click($target);
+ }
+
+ /**
+ * Click to open the dropdown on a many2one
+ *
+ * @param {string} fieldName
+ * @param {[string]} selector if set, this will restrict the search for the m2o
+ * input
+ * @returns {Promise<HTMLInputElement>} the main many2one input
+ */
+ async function clickOpenM2ODropdown(fieldName, selector) {
+ const m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`;
+ const matches = document.querySelectorAll(m2oSelector);
+ if (matches.length !== 1) {
+ throw new Error(`cannot open m2o: selector ${selector} has been found ${matches.length} instead of 1`);
+ }
+
+ await testUtilsDom.click(matches[0]);
+ return matches[0];
+ }
+
+ /**
+ * Sets the value of an element and then, trigger all specified events.
+ * Note that this helper also checks the unicity of the target.
+ *
+ * Example:
+ * testUtils.fields.editAndTrigger($('selector'), 'test', ['input', 'change']);
+ *
+ * @param {jQuery|EventTarget} el should target an input, textarea or select
+ * @param {string|number} value
+ * @param {string[]} events
+ * @returns {Promise}
+ */
+ async function editAndTrigger(el, value, events) {
+ if (el instanceof jQuery) {
+ if (el.length !== 1) {
+ throw new Error(`target ${el.selector} has length ${el.length} instead of 1`);
+ }
+ el.val(value);
+ } else {
+ el.value = value;
+ }
+ return testUtilsDom.triggerEvents(el, events);
+ }
+
+ /**
+ * Sets the value of an input.
+ *
+ * Note that this helper also checks the unicity of the target.
+ *
+ * Example:
+ * testUtils.fields.editInput($('selector'), 'somevalue');
+ *
+ * @param {jQuery|EventTarget} el should target an input, textarea or select
+ * @param {string|number} value
+ * @returns {Promise}
+ */
+ async function editInput(el, value) {
+ return editAndTrigger(el, value, ['input']);
+ }
+
+ /**
+ * Sets the value of a select.
+ *
+ * Note that this helper also checks the unicity of the target.
+ *
+ * Example:
+ * testUtils.fields.editSelect($('selector'), 'somevalue');
+ *
+ * @param {jQuery|EventTarget} el should target an input, textarea or select
+ * @param {string|number} value
+ * @returns {Promise}
+ */
+ function editSelect(el, value) {
+ return editAndTrigger(el, value, ['change']);
+ }
+
+ /**
+ * This helper is useful to test many2one fields. Here is what it does:
+ * - click to open the dropdown
+ * - enter a search string in the input
+ * - wait for the selection
+ * - click on the requested menuitem, or the active one by default
+ *
+ * Example:
+ * testUtils.fields.many2one.searchAndClickM2OItem('partner_id', {search: 'George'});
+ *
+ * @param {string} fieldName
+ * @param {[Object]} [options = {}]
+ * @param {[string]} [options.selector]
+ * @param {[string]} [options.search]
+ * @param {[string]} [options.item]
+ * @returns {Promise}
+ */
+ async function searchAndClickM2OItem(fieldName, options = {}) {
+ const input = await clickOpenM2ODropdown(fieldName, options.selector);
+ if (options.search) {
+ await editInput(input, options.search);
+ }
+ if (options.item) {
+ return clickM2OItem(fieldName, options.item);
+ } else {
+ return clickM2OHighlightedItem(fieldName, options.selector);
+ }
+ }
+
+ /**
+ * Helper to trigger a key event on an element.
+ *
+ * @param {string} type type of key event ('press', 'up' or 'down')
+ * @param {jQuery} $el
+ * @param {number|string} keyCode used as number, but if string, it'll check if
+ * the string corresponds to a key -otherwise it will keep only the first
+ * char to get a letter key- and convert it into a keyCode.
+ * @returns {Promise}
+ */
+ function triggerKey(type, $el, keyCode) {
+ type = 'key' + type;
+ const params = {};
+ if (typeof keyCode === 'string') {
+ // Key (new API)
+ if (keyCode in ARROW_KEYS_MAPPING) {
+ params.key = ARROW_KEYS_MAPPING[keyCode];
+ } else {
+ params.key = keyCode[0].toUpperCase() + keyCode.slice(1).toLowerCase();
+ }
+ // KeyCode/which (jQuery)
+ if (keyCode.length > 1) {
+ keyCode = keyCode.toUpperCase();
+ keyCode = $.ui.keyCode[keyCode];
+ } else {
+ keyCode = keyCode.charCodeAt(0);
+ }
+ }
+ params.keyCode = keyCode;
+ params.which = keyCode;
+ return testUtilsDom.triggerEvent($el, type, params);
+ }
+
+ /**
+ * Helper to trigger a keydown event on an element.
+ *
+ * @param {jQuery} $el
+ * @param {number|string} keyCode @see triggerKey
+ * @returns {Promise}
+ */
+ function triggerKeydown($el, keyCode) {
+ return triggerKey('down', $el, keyCode);
+ }
+
+ /**
+ * Helper to trigger a keyup event on an element.
+ *
+ * @param {jQuery} $el
+ * @param {number|string} keyCode @see triggerKey
+ * @returns {Promise}
+ */
+ function triggerKeyup($el, keyCode) {
+ return triggerKey('up', $el, keyCode);
+ }
+
+ return {
+ clickM2OCreateAndEdit,
+ clickM2OHighlightedItem,
+ clickM2OItem,
+ clickOpenM2ODropdown,
+ editAndTrigger,
+ editInput,
+ editSelect,
+ searchAndClickM2OItem,
+ triggerKey,
+ triggerKeydown,
+ triggerKeyup,
+ };
+});
diff --git a/addons/web/static/tests/helpers/test_utils_file.js b/addons/web/static/tests/helpers/test_utils_file.js
new file mode 100644
index 00000000..ecdd85e5
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_file.js
@@ -0,0 +1,158 @@
+odoo.define('web.test_utils_file', function () {
+"use strict";
+
+/**
+ * FILE Test Utils
+ *
+ * This module defines various utility functions to help simulate events with
+ * files, such as drag-and-drop.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+
+//------------------------------------------------------------------------------
+// Private functions
+//------------------------------------------------------------------------------
+
+/**
+ * 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,
+ getData: function () {
+ return files;
+ },
+ items: [],
+ types: ['Files'],
+ };
+}
+
+//------------------------------------------------------------------------------
+// Public functions
+//------------------------------------------------------------------------------
+
+/**
+ * Create a file object, which can be used for drag-and-drop.
+ *
+ * @param {Object} data
+ * @param {string} data.name
+ * @param {string} data.content
+ * @param {string} data.contentType
+ * @returns {Promise<Object>} resolved with file created
+ */
+function createFile(data) {
+ // Note: this is only supported by Chrome, and does not work in Incognito mode
+ return new Promise(function (resolve, reject) {
+ var requestFileSystem = window.requestFileSystem || window.webkitRequestFileSystem;
+ if (!requestFileSystem) {
+ throw new Error('FileSystem API is not supported');
+ }
+ requestFileSystem(window.TEMPORARY, 1024 * 1024, function (fileSystem) {
+ fileSystem.root.getFile(data.name, { create: true }, function (fileEntry) {
+ fileEntry.createWriter(function (fileWriter) {
+ fileWriter.onwriteend = function (e) {
+ fileSystem.root.getFile(data.name, {}, function (fileEntry) {
+ fileEntry.file(function (file) {
+ resolve(file);
+ });
+ });
+ };
+ fileWriter.write(new Blob([ data.content ], { type: data.contentType }));
+ });
+ });
+ });
+ });
+}
+
+/**
+ * Drag a file over a DOM element
+ *
+ * @param {$.Element} $el
+ * @param {Object} file must have been created beforehand (@see createFile)
+ */
+function dragoverFile($el, file) {
+ var ev = new Event('dragover', { bubbles: true });
+ Object.defineProperty(ev, 'dataTransfer', {
+ value: _createFakeDataTransfer(file),
+ });
+ $el[0].dispatchEvent(ev);
+}
+
+/**
+ * Drop a file on a DOM element.
+ *
+ * @param {$.Element} $el
+ * @param {Object} file must have been created beforehand (@see createFile)
+ */
+function dropFile($el, file) {
+ var ev = new Event('drop', { bubbles: true, });
+ Object.defineProperty(ev, 'dataTransfer', {
+ value: _createFakeDataTransfer([file]),
+ });
+ $el[0].dispatchEvent(ev);
+}
+
+/**
+ * Drop some files on a DOM element.
+ *
+ * @param {$.Element} $el
+ * @param {Object[]} files must have been created beforehand (@see createFile)
+ */
+function dropFiles($el, files) {
+ var ev = new Event('drop', { bubbles: true, });
+ Object.defineProperty(ev, 'dataTransfer', {
+ value: _createFakeDataTransfer(files),
+ });
+ $el[0].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) {
+ // could not use _createFakeDataTransfer as el.files assignation will only
+ // work with a real FileList object.
+ 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'));
+ }
+}
+
+//------------------------------------------------------------------------------
+// Exposed API
+//------------------------------------------------------------------------------
+
+return {
+ createFile: createFile,
+ dragoverFile: dragoverFile,
+ dropFile: dropFile,
+ dropFiles,
+ inputFiles,
+};
+
+});
diff --git a/addons/web/static/tests/helpers/test_utils_form.js b/addons/web/static/tests/helpers/test_utils_form.js
new file mode 100644
index 00000000..b4bff154
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_form.js
@@ -0,0 +1,74 @@
+odoo.define('web.test_utils_form', function (require) {
+"use strict";
+
+/**
+ * Form Test Utils
+ *
+ * This module defines various utility functions to help test form views.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+var testUtilsDom = require('web.test_utils_dom');
+
+/**
+ * Clicks on the Edit button in a form view, to set it to edit mode. Note that
+ * it checks that the button is visible, so calling this method in edit mode
+ * will fail.
+ *
+ * @param {FormController} form
+ */
+function clickEdit(form) {
+ return testUtilsDom.click(form.$buttons.find('.o_form_button_edit'));
+}
+
+/**
+ * Clicks on the Save button in a form view. Note that this method checks that
+ * the Save button is visible.
+ *
+ * @param {FormController} form
+ */
+function clickSave(form) {
+ return testUtilsDom.click(form.$buttons.find('.o_form_button_save'));
+}
+
+/**
+ * Clicks on the Create button in a form view. Note that this method checks that
+ * the Create button is visible.
+ *
+ * @param {FormController} form
+ */
+function clickCreate(form) {
+ return testUtilsDom.click(form.$buttons.find('.o_form_button_create'));
+}
+
+/**
+ * Clicks on the Discard button in a form view. Note that this method checks that
+ * the Discard button is visible.
+ *
+ * @param {FormController} form
+ */
+function clickDiscard(form) {
+ return testUtilsDom.click(form.$buttons.find('.o_form_button_cancel'));
+}
+
+/**
+ * Reloads a form view.
+ *
+ * @param {FormController} form
+ * @param {[Object]} params given to the controller reload method
+ */
+function reload(form, params) {
+ return form.reload(params);
+}
+
+return {
+ clickEdit: clickEdit,
+ clickSave: clickSave,
+ clickCreate: clickCreate,
+ clickDiscard: clickDiscard,
+ reload: reload,
+};
+
+});
diff --git a/addons/web/static/tests/helpers/test_utils_graph.js b/addons/web/static/tests/helpers/test_utils_graph.js
new file mode 100644
index 00000000..f6b50a1b
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_graph.js
@@ -0,0 +1,28 @@
+odoo.define('web.test_utils_graph', function () {
+"use strict";
+
+/**
+ * Graph Test Utils
+ *
+ * This module defines various utility functions to help test graph views.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+
+/**
+ * Reloads a graph view.
+ *
+ * @param {GraphController} graph
+ * @param {[Object]} params given to the controller reload method
+ */
+function reload(graph, params) {
+ return graph.reload(params);
+}
+
+return {
+ reload: reload,
+};
+
+});
diff --git a/addons/web/static/tests/helpers/test_utils_kanban.js b/addons/web/static/tests/helpers/test_utils_kanban.js
new file mode 100644
index 00000000..76af632b
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_kanban.js
@@ -0,0 +1,102 @@
+odoo.define('web.test_utils_kanban', function (require) {
+"use strict";
+
+/**
+ * Kanban Test Utils
+ *
+ * This module defines various utility functions to help testing kanban views.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+var testUtilsDom = require('web.test_utils_dom');
+var testUtilsFields = require('web.test_utils_fields');
+
+/**
+ * Clicks on the Create button in a kanban view. Note that this method checks that
+ * the Create button is visible.
+ *
+ * @param {KanbanController} kanban
+ * @returns {Promise}
+ */
+function clickCreate(kanban) {
+ return testUtilsDom.click(kanban.$buttons.find('.o-kanban-button-new'));
+}
+
+/**
+ * Open the settings menu for a column (in a grouped kanban view)
+ *
+ * @param {jQuery} $column
+ * @returns {Promise}
+ */
+function toggleGroupSettings($column) {
+ var $dropdownToggler = $column.find('.o_kanban_config > a.dropdown-toggle');
+ if (!$dropdownToggler.is(':visible')) {
+ $dropdownToggler.css('display', 'block');
+ }
+ return testUtilsDom.click($dropdownToggler);
+}
+
+/**
+ * Edit a value in a quickcreate form view (this method assumes that the quick
+ * create feature is active, and a sub form view is open)
+ *
+ * @param {kanbanController} kanban
+ * @param {string|number} value
+ * @param {[string]} fieldName
+ * @returns {Promise}
+ */
+function quickCreate(kanban, value, fieldName) {
+ var additionalSelector = fieldName ? ('[name=' + fieldName + ']'): '';
+ var enterEvent = $.Event(
+ 'keydown',
+ {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }
+ );
+ return testUtilsFields.editAndTrigger(
+ kanban.$('.o_kanban_quick_create input' + additionalSelector),
+ value,
+ ['input', enterEvent]
+ );
+}
+
+/**
+ * Reloads a kanban view.
+ *
+ * @param {KanbanController} kanban
+ * @param {[Object]} params given to the controller reload method
+ * @returns {Promise}
+ */
+function reload(kanban, params) {
+ return kanban.reload(params);
+}
+
+/**
+ * Open the setting dropdown of a kanban record. Note that the template of a
+ * kanban record is not standardized, so this method will fail if the template
+ * does not comply with the usual dom structure.
+ *
+ * @param {jQuery} $record
+ * @returns {Promise}
+ */
+function toggleRecordDropdown($record) {
+ var $dropdownToggler = $record.find('.o_dropdown_kanban > a.dropdown-toggle');
+ if (!$dropdownToggler.is(':visible')) {
+ $dropdownToggler.css('display', 'block');
+ }
+ return testUtilsDom.click($dropdownToggler);
+}
+
+
+return {
+ clickCreate: clickCreate,
+ quickCreate: quickCreate,
+ reload: reload,
+ toggleGroupSettings: toggleGroupSettings,
+ toggleRecordDropdown: toggleRecordDropdown,
+};
+
+});
diff --git a/addons/web/static/tests/helpers/test_utils_mock.js b/addons/web/static/tests/helpers/test_utils_mock.js
new file mode 100644
index 00000000..8474ed22
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_mock.js
@@ -0,0 +1,781 @@
+odoo.define('web.test_utils_mock', function (require) {
+"use strict";
+
+/**
+ * Mock Test Utils
+ *
+ * This module defines various utility functions to help mocking data.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+const AbstractStorageService = require('web.AbstractStorageService');
+const AjaxService = require('web.AjaxService');
+const basic_fields = require('web.basic_fields');
+const Bus = require('web.Bus');
+const config = require('web.config');
+const core = require('web.core');
+const dom = require('web.dom');
+const makeTestEnvironment = require('web.test_env');
+const MockServer = require('web.MockServer');
+const RamStorage = require('web.RamStorage');
+const session = require('web.session');
+
+const DebouncedField = basic_fields.DebouncedField;
+
+
+//------------------------------------------------------------------------------
+// Private functions
+//------------------------------------------------------------------------------
+
+/**
+ * Returns a mocked environment to be used by OWL components in tests, with
+ * requested services (+ ajax, local_storage and session_storage) deployed.
+ *
+ * @private
+ * @param {Object} params
+ * @param {Bus} [params.bus]
+ * @param {boolean} [params.debug]
+ * @param {Object} [params.env]
+ * @param {Bus} [params.env.bus]
+ * @param {Object} [params.env.dataManager]
+ * @param {Object} [params.env.services]
+ * @param {Object[]} [params.favoriteFilters]
+ * @param {Object} [params.services]
+ * @param {Object} [params.session]
+ * @param {MockServer} [mockServer]
+ * @returns {Promise<Object>} env
+ */
+async function _getMockedOwlEnv(params, mockServer) {
+ params.env = params.env || {};
+
+ // build the env
+ const favoriteFilters = params.favoriteFilters;
+ const debug = params.debug;
+ const services = {};
+ const env = Object.assign({}, params.env, {
+ browser: Object.assign({
+ fetch: (resource, init) => mockServer.performFetch(resource, init),
+ }, params.env.browser),
+ bus: params.bus || params.env.bus || new Bus(),
+ dataManager: Object.assign({
+ load_action: (actionID, context) => {
+ return mockServer.performRpc('/web/action/load', {
+ action_id: actionID,
+ additional_context: context,
+ });
+ },
+ load_views: (params, options) => {
+ return mockServer.performRpc('/web/dataset/call_kw/' + params.model, {
+ args: [],
+ kwargs: {
+ context: params.context,
+ options: options,
+ views: params.views_descr,
+ },
+ method: 'load_views',
+ model: params.model,
+ }).then(function (views) {
+ views = _.mapObject(views, viewParams => {
+ return fieldsViewGet(mockServer, viewParams);
+ });
+ if (favoriteFilters && 'search' in views) {
+ views.search.favoriteFilters = favoriteFilters;
+ }
+ return views;
+ });
+ },
+ load_filters: params => {
+ if (debug) {
+ console.log('[mock] load_filters', params);
+ }
+ return Promise.resolve([]);
+ },
+ }, params.env.dataManager),
+ services: Object.assign(services, params.env.services),
+ session: params.env.session || params.session || {},
+ });
+
+ // deploy services into the env
+ // determine services to instantiate (classes), and already register function services
+ const servicesToDeploy = {};
+ for (const name in params.services || {}) {
+ const Service = params.services[name];
+ if (Service.constructor.name === 'Class') {
+ servicesToDeploy[name] = Service;
+ } else {
+ services[name] = Service;
+ }
+ }
+ // always deploy ajax, local storage and session storage
+ if (!servicesToDeploy.ajax) {
+ const MockedAjaxService = AjaxService.extend({
+ rpc: mockServer.performRpc.bind(mockServer),
+ });
+ services.ajax = new MockedAjaxService(env);
+ }
+ const RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+ if (!servicesToDeploy.local_storage) {
+ services.local_storage = new RamStorageService(env);
+ }
+ if (!servicesToDeploy.session_storage) {
+ services.session_storage = new RamStorageService(env);
+ }
+ // deploy other requested services
+ let done = false;
+ while (!done) {
+ const serviceName = Object.keys(servicesToDeploy).find(serviceName => {
+ const Service = servicesToDeploy[serviceName];
+ return Service.prototype.dependencies.every(depName => {
+ return env.services[depName];
+ });
+ });
+ if (serviceName) {
+ const Service = servicesToDeploy[serviceName];
+ services[serviceName] = new Service(env);
+ delete servicesToDeploy[serviceName];
+ services[serviceName].start();
+ } else {
+ const serviceNames = _.keys(servicesToDeploy);
+ if (serviceNames.length) {
+ console.warn("Non loaded services:", serviceNames);
+ }
+ done = true;
+ }
+ }
+ // wait for asynchronous services to properly start
+ await new Promise(setTimeout);
+
+ return env;
+}
+/**
+ * This function is used to mock global objects (session, config...) in tests.
+ * It is necessary for legacy widgets. It returns a cleanUp function to call at
+ * the end of the test.
+ *
+ * The function could be removed as soon as we do not support legacy widgets
+ * anymore.
+ *
+ * @private
+ * @param {Object} params
+ * @param {Object} [params.config] if given, it is used to extend the global
+ * config,
+ * @param {Object} [params.session] if given, it is used to extend the current,
+ * real session.
+ * @param {Object} [params.translateParameters] if given, it will be used to
+ * extend the core._t.database.parameters object.
+ * @returns {function} a cleanUp function to restore everything, to call at the
+ * end of the test
+ */
+function _mockGlobalObjects(params) {
+ // store initial session state (for restoration)
+ const initialSession = Object.assign({}, session);
+ const sessionPatch = Object.assign({
+ getTZOffset() { return 0; },
+ async user_has_group() { return false; },
+ }, params.session);
+ // patch session
+ Object.assign(session, sessionPatch);
+
+ // patch config
+ let initialConfig;
+ if ('config' in params) {
+ initialConfig = Object.assign({}, config);
+ initialConfig.device = Object.assign({}, config.device);
+ if ('device' in params.config) {
+ Object.assign(config.device, params.config.device);
+ }
+ if ('debug' in params.config) {
+ odoo.debug = params.config.debug;
+ }
+ }
+
+ // patch translate params
+ let initialParameters;
+ if ('translateParameters' in params) {
+ initialParameters = Object.assign({}, core._t.database.parameters);
+ Object.assign(core._t.database.parameters, params.translateParameters);
+ }
+
+ // build the cleanUp function to restore everything at the end of the test
+ function cleanUp() {
+ let key;
+ for (key in sessionPatch) {
+ delete session[key];
+ }
+ Object.assign(session, initialSession);
+ if ('config' in params) {
+ for (key in config) {
+ delete config[key];
+ }
+ _.extend(config, initialConfig);
+ }
+ if ('translateParameters' in params) {
+ for (key in core._t.database.parameters) {
+ delete core._t.database.parameters[key];
+ }
+ _.extend(core._t.database.parameters, initialParameters);
+ }
+ }
+
+ return cleanUp;
+}
+/**
+ * logs all event going through the target widget.
+ *
+ * @param {Widget} widget
+ */
+function _observe(widget) {
+ var _trigger_up = widget._trigger_up.bind(widget);
+ widget._trigger_up = function (event) {
+ console.log('%c[event] ' + event.name, 'color: blue; font-weight: bold;', event);
+ _trigger_up(event);
+ };
+}
+
+//------------------------------------------------------------------------------
+// Public functions
+//------------------------------------------------------------------------------
+
+/**
+ * performs a fields_view_get, and mocks the postprocessing done by the
+ * data_manager to return an equivalent structure.
+ *
+ * @param {MockServer} server
+ * @param {Object} params
+ * @param {string} params.model
+ * @returns {Object} an object with 3 keys: arch, fields and viewFields
+ */
+function fieldsViewGet(server, params) {
+ var fieldsView = server.fieldsViewGet(params);
+ // mock the structure produced by the DataManager
+ fieldsView.viewFields = fieldsView.fields;
+ fieldsView.fields = server.fieldsGet(params.model);
+ return fieldsView;
+}
+
+/**
+ * intercepts an event bubbling up the widget hierarchy. The event intercepted
+ * must be a "custom event", i.e. an event generated by the method 'trigger_up'.
+ *
+ * Note that this method really intercepts the event if @propagate is not set.
+ * It will not be propagated further, and even the handlers on the target will
+ * not fire.
+ *
+ * @param {Widget} widget the target widget (any Odoo widget)
+ * @param {string} eventName description of the event
+ * @param {function} fn callback executed when the even is intercepted
+ * @param {boolean} [propagate=false]
+ */
+function intercept(widget, eventName, fn, propagate) {
+ var _trigger_up = widget._trigger_up.bind(widget);
+ widget._trigger_up = function (event) {
+ if (event.name === eventName) {
+ fn(event);
+ if (!propagate) { return; }
+ }
+ _trigger_up(event);
+ };
+}
+
+/**
+ * Removes the src attribute on images and iframes to prevent not found errors,
+ * and optionally triggers an rpc with the src url as route on a widget.
+ * This method is critical and must be fastest (=> no jQuery, no underscore)
+ *
+ * @param {HTMLElement} el
+ * @param {[function]} rpc
+ */
+function removeSrcAttribute(el, rpc) {
+ var nodes;
+ if (el.nodeName === "#comment") {
+ return;
+ }
+ el = el.nodeType === 8 ? el.nextSibling : el;
+ if (el.nodeName === 'IMG' || el.nodeName === 'IFRAME') {
+ nodes = [el];
+ } else {
+ nodes = Array.prototype.slice.call(el.getElementsByTagName('img'))
+ .concat(Array.prototype.slice.call(el.getElementsByTagName('iframe')));
+ }
+ var node;
+ while (node = nodes.pop()) {
+ var src = node.attributes.src && node.attributes.src.value;
+ if (src && src !== 'about:blank') {
+ node.setAttribute('data-src', src);
+ if (node.nodeName === 'IMG') {
+ node.attributes.removeNamedItem('src');
+ } else {
+ node.setAttribute('src', 'about:blank');
+ }
+ if (rpc) {
+ rpc(src, []);
+ }
+ $(node).trigger('load');
+ }
+ }
+}
+
+/**
+ * Add a mock environment to test Owl Components. This function generates a test
+ * env and sets it on the given Component. It also has several side effects,
+ * like patching the global session or config objects. It returns a cleanup
+ * function to call at the end of the test.
+ *
+ * @param {Component} Component
+ * @param {Object} [params]
+ * @param {Object} [params.actions]
+ * @param {Object} [params.archs]
+ * @param {string} [params.currentDate]
+ * @param {Object} [params.data]
+ * @param {boolean} [params.debug]
+ * @param {function} [params.mockFetch]
+ * @param {function} [params.mockRPC]
+ * @param {number} [params.fieldDebounce=0] the value of the DEBOUNCE attribute
+ * of fields
+ * @param {boolean} [params.debounce=true] if false, patch _.debounce to remove
+ * its behavior
+ * @param {boolean} [params.throttle=false] by default, _.throttle is patched to
+ * remove its behavior, except if this params is set to true
+ * @param {boolean} [params.mockSRC=false] if true, redirect src GET requests to
+ * the mockServer
+ * @param {MockServer} [mockServer]
+ * @returns {Promise<function>} the cleanup function
+ */
+async function addMockEnvironmentOwl(Component, params, mockServer) {
+ params = params || {};
+
+ // instantiate a mockServer if not provided
+ if (!mockServer) {
+ let Server = MockServer;
+ if (params.mockFetch) {
+ Server = MockServer.extend({ _performFetch: params.mockFetch });
+ }
+ if (params.mockRPC) {
+ Server = Server.extend({ _performRpc: params.mockRPC });
+ }
+ mockServer = new Server(params.data, {
+ actions: params.actions,
+ archs: params.archs,
+ currentDate: params.currentDate,
+ debug: params.debug,
+ });
+ }
+
+ // make sure the debounce value for input fields is set to 0
+ const initialDebounceValue = DebouncedField.prototype.DEBOUNCE;
+ DebouncedField.prototype.DEBOUNCE = params.fieldDebounce || 0;
+ const initialDOMDebounceValue = dom.DEBOUNCE;
+ dom.DEBOUNCE = 0;
+
+ // patch underscore debounce/throttle functions
+ const initialDebounce = _.debounce;
+ if (params.debounce === false) {
+ _.debounce = function (func) {
+ return func;
+ };
+ }
+ // fixme: throttle is inactive by default, should we make it explicit ?
+ const initialThrottle = _.throttle;
+ if (!('throttle' in params) || !params.throttle) {
+ _.throttle = function (func) {
+ return func;
+ };
+ }
+
+ // make sure images do not trigger a GET on the server
+ $('body').on('DOMNodeInserted.removeSRC', function (ev) {
+ let rpc;
+ if (params.mockSRC) {
+ rpc = mockServer.performRpc.bind(mockServer);
+ }
+ removeSrcAttribute(ev.target, rpc);
+ });
+
+ // mock global objects for legacy widgets (session, config...)
+ const restoreMockedGlobalObjects = _mockGlobalObjects(params);
+
+ // set the test env on owl Component
+ const env = await _getMockedOwlEnv(params, mockServer);
+ const originalEnv = Component.env;
+ Component.env = makeTestEnvironment(env, mockServer.performRpc.bind(mockServer));
+
+ // while we have a mix between Owl and legacy stuff, some of them triggering
+ // events on the env.bus (a new Bus instance especially created for the current
+ // test), the others using core.bus, we have to ensure that events triggered
+ // on env.bus are also triggered on core.bus (note that outside the testing
+ // environment, both are the exact same instance of Bus)
+ const envBusTrigger = env.bus.trigger;
+ env.bus.trigger = function () {
+ core.bus.trigger(...arguments);
+ envBusTrigger.call(env.bus, ...arguments);
+ };
+
+ // build the clean up function to call at the end of the test
+ function cleanUp() {
+ env.bus.destroy();
+ Object.keys(env.services).forEach(function (s) {
+ var service = env.services[s];
+ if (service.destroy && !service.isDestroyed()) {
+ service.destroy();
+ }
+ });
+
+ DebouncedField.prototype.DEBOUNCE = initialDebounceValue;
+ dom.DEBOUNCE = initialDOMDebounceValue;
+ _.debounce = initialDebounce;
+ _.throttle = initialThrottle;
+
+ // clear the caches (e.g. data_manager, ModelFieldSelector) at the end
+ // of each test to avoid collisions
+ core.bus.trigger('clear_cache');
+
+ $('body').off('DOMNodeInserted.removeSRC');
+ $('.blockUI').remove(); // fixme: move to qunit_config in OdooAfterTestHook?
+
+ restoreMockedGlobalObjects();
+
+ Component.env = originalEnv;
+ }
+
+ return cleanUp;
+}
+
+/**
+ * Add a mock environment to a widget. This helper function can simulate
+ * various kind of side effects, such as mocking RPCs, changing the session,
+ * or the translation settings.
+ *
+ * The simulated environment lasts for the lifecycle of the widget, meaning it
+ * disappears when the widget is destroyed. It is particularly relevant for the
+ * session mocks, because the previous session is restored during the destroy
+ * call. So, it means that you have to be careful and make sure that it is
+ * properly destroyed before another test is run, otherwise you risk having
+ * interferences between tests.
+ *
+ * @param {Widget} widget
+ * @param {Object} params
+ * @param {Object} [params.archs] a map of string [model,view_id,view_type] to
+ * a arch object. It is used to mock answers to 'load_views' custom events.
+ * This is useful when the widget instantiate a formview dialog that needs
+ * to load a particular arch.
+ * @param {string} [params.currentDate] a string representation of the current
+ * date. It is given to the mock server.
+ * @param {Object} params.data the data given to the created mock server. It is
+ * used to generate mock answers for every kind of routes supported by odoo
+ * @param {number} [params.debug] if set to true, logs RPCs and uncaught Odoo
+ * events.
+ * @param {Object} [params.bus] the instance of Bus that will be used (in the env)
+ * @param {function} [params.mockFetch] a function that will be used to override
+ * the _performFetch method from the mock server. It is really useful to add
+ * some custom fetch mocks, or to check some assertions.
+ * @param {function} [params.mockRPC] a function that will be used to override
+ * the _performRpc method from the mock server. It is really useful to add
+ * some custom rpc mocks, or to check some assertions.
+ * @param {Object} [params.session] if it is given, it will be used as answer
+ * for all calls to this.getSession() by the widget, of its children. Also,
+ * it will be used to extend the current, real session. This side effect is
+ * undone when the widget is destroyed.
+ * @param {Object} [params.translateParameters] if given, it will be used to
+ * extend the core._t.database.parameters object. After the widget
+ * destruction, the original parameters will be restored.
+ * @param {Object} [params.intercepts] an object with event names as key, and
+ * callback as value. Each key,value will be used to intercept the event.
+ * Note that this is particularly useful if you want to intercept events going
+ * up in the init process of the view, because there are no other way to do it
+ * after this method returns. Some events ('call_service', "load_views",
+ * "get_session", "load_filters") have a special treatment beforehand.
+ * @param {Object} [params.services={}] list of services to load in
+ * addition to the ajax service. For instance, if a test needs the local
+ * storage service in order to work, it can provide a mock version of it.
+ * @param {boolean} [debounce=true] set to false to completely remove the
+ * debouncing, forcing the handler to be called directly (not on the next
+ * execution stack, like it does with delay=0).
+ * @param {boolean} [throttle=false] set to true to keep the throttling, which
+ * is completely removed by default.
+ *
+ * @returns {Promise<MockServer>} the instance of the mock server, created by this
+ * function. It is necessary for createView so that method can call some
+ * other methods on it.
+ */
+async function addMockEnvironment(widget, params) {
+ // log events triggered up if debug flag is true
+ if (params.debug) {
+ _observe(widget);
+ var separator = window.location.href.indexOf('?') !== -1 ? "&" : "?";
+ var url = window.location.href + separator + 'testId=' + QUnit.config.current.testId;
+ console.log('%c[debug] debug mode activated', 'color: blue; font-weight: bold;', url);
+ }
+
+ // instantiate mock server
+ var Server = MockServer;
+ if (params.mockFetch) {
+ Server = MockServer.extend({ _performFetch: params.mockFetch });
+ }
+ if (params.mockRPC) {
+ Server = Server.extend({ _performRpc: params.mockRPC });
+ }
+ var mockServer = new Server(params.data, {
+ actions: params.actions,
+ archs: params.archs,
+ currentDate: params.currentDate,
+ debug: params.debug,
+ widget: widget,
+ });
+
+ // build and set the Owl env on Component
+ if (!('mockSRC' in params)) { // redirect src rpcs to the mock server
+ params.mockSRC = true;
+ }
+ const cleanUp = await addMockEnvironmentOwl(owl.Component, params, mockServer);
+ const env = owl.Component.env;
+
+ // ensure to clean up everything when the widget will be destroyed
+ const destroy = widget.destroy;
+ widget.destroy = function () {
+ cleanUp();
+ destroy.call(this, ...arguments);
+ };
+
+ // intercept service/data manager calls and redirect them to the env
+ intercept(widget, 'call_service', function (ev) {
+ if (env.services[ev.data.service]) {
+ var service = env.services[ev.data.service];
+ const result = service[ev.data.method].apply(service, ev.data.args || []);
+ ev.data.callback(result);
+ }
+ });
+ intercept(widget, 'load_action', async ev => {
+ const action = await env.dataManager.load_action(ev.data.actionID, ev.data.context);
+ ev.data.on_success(action);
+ });
+ intercept(widget, "load_views", async ev => {
+ const params = {
+ model: ev.data.modelName,
+ context: ev.data.context,
+ views_descr: ev.data.views,
+ };
+ const views = await env.dataManager.load_views(params, ev.data.options);
+ if ('search' in views && params.favoriteFilters) {
+ views.search.favoriteFilters = params.favoriteFilters;
+ }
+ ev.data.on_success(views);
+ });
+ intercept(widget, "get_session", ev => {
+ ev.data.callback(session);
+ });
+ intercept(widget, "load_filters", async ev => {
+ const filters = await env.dataManager.load_filters(ev.data);
+ ev.data.on_success(filters);
+ });
+
+ // make sure all other Odoo events bubbling up are intercepted
+ Object.keys(params.intercepts || {}).forEach(function (name) {
+ intercept(widget, name, params.intercepts[name]);
+ });
+
+ return mockServer;
+}
+
+/**
+ * Patch window.Date so that the time starts its flow from the provided Date.
+ *
+ * Usage:
+ *
+ * ```
+ * var unpatchDate = testUtils.mock.patchDate(2018, 0, 10, 17, 59, 30)
+ * new window.Date(); // "Wed Jan 10 2018 17:59:30 GMT+0100 (Central European Standard Time)"
+ * ... // 5 hours delay
+ * new window.Date(); // "Wed Jan 10 2018 22:59:30 GMT+0100 (Central European Standard Time)"
+ * ...
+ * unpatchDate();
+ * new window.Date(); // actual current date time
+ * ```
+ *
+ * @param {integer} year
+ * @param {integer} month index of the month, starting from zero.
+ * @param {integer} day the day of the month.
+ * @param {integer} hours the digits for hours (24h)
+ * @param {integer} minutes
+ * @param {integer} seconds
+ * @returns {Function} a callback to unpatch window.Date.
+ */
+function patchDate(year, month, day, hours, minutes, seconds) {
+ var RealDate = window.Date;
+ var actualDate = new RealDate();
+ var fakeDate = new RealDate(year, month, day, hours, minutes, seconds);
+ var timeInterval = actualDate.getTime() - (fakeDate.getTime());
+
+ Date = (function (NativeDate) {
+ function Date(Y, M, D, h, m, s, ms) {
+ var length = arguments.length;
+ if (arguments.length > 0) {
+ var date = length == 1 && String(Y) === Y ? // isString(Y)
+ // We explicitly pass it through parse:
+ new NativeDate(Date.parse(Y)) :
+ // We have to manually make calls depending on argument
+ // length here
+ length >= 7 ? new NativeDate(Y, M, D, h, m, s, ms) :
+ length >= 6 ? new NativeDate(Y, M, D, h, m, s) :
+ length >= 5 ? new NativeDate(Y, M, D, h, m) :
+ length >= 4 ? new NativeDate(Y, M, D, h) :
+ length >= 3 ? new NativeDate(Y, M, D) :
+ length >= 2 ? new NativeDate(Y, M) :
+ length >= 1 ? new NativeDate(Y) :
+ new NativeDate();
+ // Prevent mixups with unfixed Date object
+ date.constructor = Date;
+ return date;
+ } else {
+ var date = new NativeDate();
+ var time = date.getTime();
+ time -= timeInterval;
+ date.setTime(time);
+ return date;
+ }
+ }
+
+ // Copy any custom methods a 3rd party library may have added
+ for (var key in NativeDate) {
+ Date[key] = NativeDate[key];
+ }
+
+ // Copy "native" methods explicitly; they may be non-enumerable
+ // exception: 'now' uses fake date as reference
+ Date.now = function () {
+ var date = new NativeDate();
+ var time = date.getTime();
+ time -= timeInterval;
+ return time;
+ };
+ Date.UTC = NativeDate.UTC;
+ Date.prototype = NativeDate.prototype;
+ Date.prototype.constructor = Date;
+
+ // Upgrade Date.parse to handle simplified ISO 8601 strings
+ Date.parse = NativeDate.parse;
+ return Date;
+ })(Date);
+
+ return function () { window.Date = RealDate; };
+}
+
+var patches = {};
+/**
+ * Patches a given Class or Object with the given properties.
+ *
+ * @param {Class|Object} target
+ * @param {Object} props
+ */
+function patch(target, props) {
+ var patchID = _.uniqueId('patch_');
+ target.__patchID = patchID;
+ patches[patchID] = {
+ target: target,
+ otherPatchedProps: [],
+ ownPatchedProps: [],
+ };
+ if (target.prototype) {
+ _.each(props, function (value, key) {
+ if (target.prototype.hasOwnProperty(key)) {
+ patches[patchID].ownPatchedProps.push({
+ key: key,
+ initialValue: target.prototype[key],
+ });
+ } else {
+ patches[patchID].otherPatchedProps.push(key);
+ }
+ });
+ target.include(props);
+ } else {
+ _.each(props, function (value, key) {
+ if (key in target) {
+ var oldValue = target[key];
+ patches[patchID].ownPatchedProps.push({
+ key: key,
+ initialValue: oldValue,
+ });
+ if (typeof value === 'function') {
+ target[key] = function () {
+ var oldSuper = this._super;
+ this._super = oldValue;
+ var result = value.apply(this, arguments);
+ if (oldSuper === undefined) {
+ delete this._super;
+ } else {
+ this._super = oldSuper;
+ }
+ return result;
+ };
+ } else {
+ target[key] = value;
+ }
+ } else {
+ patches[patchID].otherPatchedProps.push(key);
+ target[key] = value;
+ }
+ });
+ }
+}
+
+/**
+ * Unpatches a given Class or Object.
+ *
+ * @param {Class|Object} target
+ */
+function unpatch(target) {
+ var patchID = target.__patchID;
+ var patch = patches[patchID];
+ if (target.prototype) {
+ _.each(patch.ownPatchedProps, function (p) {
+ target.prototype[p.key] = p.initialValue;
+ });
+ _.each(patch.otherPatchedProps, function (key) {
+ delete target.prototype[key];
+ });
+ } else {
+ _.each(patch.ownPatchedProps, function (p) {
+ target[p.key] = p.initialValue;
+ });
+ _.each(patch.otherPatchedProps, function (key) {
+ delete target[key];
+ });
+ }
+ delete patches[patchID];
+ delete target.__patchID;
+}
+
+window.originalSetTimeout = window.setTimeout;
+function patchSetTimeout() {
+ var original = window.setTimeout;
+ var self = this;
+ window.setTimeout = function (handler, delay) {
+ console.log("calling setTimeout on " + (handler.name || "some function") + "with delay of " + delay);
+ console.trace();
+ var handlerArguments = Array.prototype.slice.call(arguments, 1);
+ return original(function () {
+ handler.bind(self, handlerArguments)();
+ console.log('after doing the action of the setTimeout');
+ }, delay);
+ };
+
+ return function () {
+ window.setTimeout = original;
+ };
+}
+
+return {
+ addMockEnvironment: addMockEnvironment,
+ fieldsViewGet: fieldsViewGet,
+ addMockEnvironmentOwl: addMockEnvironmentOwl,
+ intercept: intercept,
+ patchDate: patchDate,
+ patch: patch,
+ unpatch: unpatch,
+ patchSetTimeout: patchSetTimeout,
+};
+
+});
diff --git a/addons/web/static/tests/helpers/test_utils_modal.js b/addons/web/static/tests/helpers/test_utils_modal.js
new file mode 100644
index 00000000..ca3eeef1
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_modal.js
@@ -0,0 +1,26 @@
+odoo.define('web.test_utils_modal', function (require) {
+ "use strict";
+
+ /**
+ * Modal Test Utils
+ *
+ * This module defines various utility functions to help test pivot views.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+ const { _t } = require('web.core');
+ const testUtilsDom = require('web.test_utils_dom');
+
+ /**
+ * Click on a button in the footer of a modal (which contains a given string).
+ *
+ * @param {string} text (in english: this method will perform the translation)
+ */
+ function clickButton(text) {
+ return testUtilsDom.click($(`.modal-footer button:contains(${_t(text)})`));
+ }
+
+ return { clickButton };
+});
diff --git a/addons/web/static/tests/helpers/test_utils_pivot.js b/addons/web/static/tests/helpers/test_utils_pivot.js
new file mode 100644
index 00000000..fe059b53
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_pivot.js
@@ -0,0 +1,57 @@
+odoo.define('web.test_utils_pivot', function (require) {
+"use strict";
+
+var testUtilsDom = require('web.test_utils_dom');
+
+/**
+ * Pivot Test Utils
+ *
+ * This module defines various utility functions to help test pivot views.
+ *
+ * Note that all methods defined in this module are exported in the main
+ * testUtils file.
+ */
+
+
+/**
+ * Select a measure by clicking on the corresponding dropdown item (in the
+ * control panel 'Measure' submenu).
+ *
+ * Note that this method assumes that the dropdown menu is open.
+ * @see toggleMeasuresDropdown
+ *
+ * @param {PivotController} pivot
+ * @param {string} measure
+ */
+function clickMeasure(pivot, measure) {
+ return testUtilsDom.click(pivot.$buttons.find(`.dropdown-item[data-field=${measure}]`));
+}
+
+/**
+ * Open the 'Measure' dropdown menu (in the control panel)
+ *
+ * @see clickMeasure
+ *
+ * @param {PivotController} pivot
+ */
+function toggleMeasuresDropdown(pivot) {
+ return testUtilsDom.click(pivot.$buttons.filter('.btn-group:first').find('> button'));
+}
+
+/**
+ * Reloads a graph view.
+ *
+ * @param {PivotController} pivot
+ * @param {[Object]} params given to the controller reload method
+ */
+function reload(pivot, params) {
+ return pivot.reload(params);
+}
+
+return {
+ clickMeasure: clickMeasure,
+ reload: reload,
+ toggleMeasuresDropdown: toggleMeasuresDropdown,
+};
+
+});
diff --git a/addons/web/static/tests/helpers/test_utils_tests.js b/addons/web/static/tests/helpers/test_utils_tests.js
new file mode 100644
index 00000000..d4f38662
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_tests.js
@@ -0,0 +1,36 @@
+odoo.define('web.testUtilsTests', function (require) {
+"use strict";
+
+var testUtils = require('web.test_utils');
+
+QUnit.module('web', {}, function () {
+QUnit.module('testUtils', {}, function () {
+
+QUnit.module('patch date');
+
+QUnit.test('new date', function (assert) {
+ assert.expect(5);
+ const unpatchDate = testUtils.mock.patchDate(2018, 9, 23, 14, 50, 0);
+
+ const date = new Date();
+
+ assert.strictEqual(date.getFullYear(), 2018);
+ assert.strictEqual(date.getMonth(), 9);
+ assert.strictEqual(date.getDate(), 23);
+ assert.strictEqual(date.getHours(), 14);
+ assert.strictEqual(date.getMinutes(), 50);
+ unpatchDate();
+});
+
+QUnit.test('new moment', function (assert) {
+ assert.expect(1);
+ const unpatchDate = testUtils.mock.patchDate(2018, 9, 23, 14, 50, 0);
+
+ const m = moment();
+ assert.strictEqual(m.format('YYYY-MM-DD HH:mm'), '2018-10-23 14:50');
+ unpatchDate();
+});
+
+});
+});
+}); \ No newline at end of file