diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/tests/helpers | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web/static/tests/helpers')
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 |
