summaryrefslogtreecommitdiff
path: root/addons/web/static/tests/helpers
diff options
context:
space:
mode:
authorGéry Debongnie <ged@odoo.com>2018-11-19 08:18:28 +0000
committerAaron Bohy <aab@odoo.com>2018-11-19 11:24:28 +0000
commitabf32b8b21394491040e5c11c1fea809c1d7c62c (patch)
tree6058f8a893815bf0ed2495649c125523f4def762 /addons/web/static/tests/helpers
parentbcbb7ee3fecaad3c7e801fa5dbc90a566faac27f (diff)
[REF] *: update js test suite to use helpers
This rev. introduces robust helpers to use in the JS tests suite to interact with DOM and components, and starts using them (almost) everywhere. All the helpers are exposed though testUtils.js. There are 2 kinds of helpers: 1. Assertions ------------- * assert.containsNone, containsN, containsOnce check that the DOM (or a specific part of the DOM) contains a `selector`. It generates a correct error message automatically. ex: assert.strictEqual(form.$('.o_form_editable'), 1, "msg"); -> assert.containsOnce(form, '.o_form_editable'); * assert.isVisible, isNotVisible check that the DOM has an element visible or not. They also check that the element is actually in the DOM (before most tests didn't verify this). * assert.hasClass, doesNotHaveClass, hasAttrVAlue, check specific properties of a DOM element, and also validate that it is applied on a single existing DOM element (before most tests didn't verify this). ex: assert.notOk(form.$('button').hasClass('btn-primary')); -> assert.doesNotHaveClass(form.$('button'), 'btn-primary'); 2. Utilities ------------ The goal of the utilities is to centralize the definition of many standard components and interactions, ensuring that when we refactor the JS framework, we do not need to change all the tests. Existing mock utilities (addMockEnvironment, intercept, path, patchDate, unpatch and fieldsViewGet) are moved to 'testUtils.mock.*'. Existing DOM utilities are moved to 'testUtils.dom.*'. New dom utilities are created for opendDatePicker, click, clickFirst and clickLast. Helper `click` verifies that there is exactly 1 element visible in the DOM you click on, `clickFirst` and `clickLast` verify that there are more than one element on the DOM. ex: form.$('button').click(); -> testUtils.dom.click(form.$('button')); New Form utilities: (testUtils.form.*) clickEdit, clickSave, clickCreate, clickDiscard, all clicks on the control panel buttons of the form. `reload` reloads the form data. New modal, graph, kanban and pivot utils (testUtils.pivot.*, testUtils.kanban.*, etc.). New fields utils: (testUtils.fields.*) * editInput, editSelect: allow to change the value of a field, using a selector to identify it. They validate that the input exists and trigger the change event automatically. * editAndTrigger: allow to modify a field and trigger specific events after the value change * many2one (testUtils.fields.many2one.*) clickOpenDropdown, clickHighlightedItem, clickItem, searchAndClickItem: use a field name instead of a selector and do all the complex mechanism to open, filter and highlight many2one fields. Joint work with aab, dam, ged, mge, svs and vsc.
Diffstat (limited to 'addons/web/static/tests/helpers')
-rw-r--r--addons/web/static/tests/helpers/qunit_asserts.js229
-rw-r--r--addons/web/static/tests/helpers/test_utils.js1008
-rw-r--r--addons/web/static/tests/helpers/test_utils_create.js288
-rw-r--r--addons/web/static/tests/helpers/test_utils_dom.js235
-rw-r--r--addons/web/static/tests/helpers/test_utils_fields.js172
-rw-r--r--addons/web/static/tests/helpers/test_utils_form.js74
-rw-r--r--addons/web/static/tests/helpers/test_utils_graph.js28
-rw-r--r--addons/web/static/tests/helpers/test_utils_kanban.js97
-rw-r--r--addons/web/static/tests/helpers/test_utils_mock.js545
-rw-r--r--addons/web/static/tests/helpers/test_utils_modal.js34
-rw-r--r--addons/web/static/tests/helpers/test_utils_pivot.js57
11 files changed, 1829 insertions, 938 deletions
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 00000000000..bce6ef2a777
--- /dev/null
+++ b/addons/web/static/tests/helpers/qunit_asserts.js
@@ -0,0 +1,229 @@
+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.
+ */
+
+var Widget = require('web.Widget');
+
+//------------------------------------------------------------------------------
+// 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} w
+ * @param {string} className
+ * @param {boolean} shouldHaveClass
+ * @param {string} [msg]
+ */
+function _checkClass(w, className, shouldHaveClass, msg) {
+ var $el = w instanceof Widget ? w.$el :
+ w instanceof HTMLElement ? $(w) :
+ w; // jquery element
+
+ if ($el.length !== 1) {
+ var descr = `${shouldHaveClass ? 'hasClass' : 'doesNotHaveClass'} ${className}`;
+ QUnit.assert.ok(false, `Assertion '${descr}' targets ${$el.length} elements instead of 1`);
+ } else {
+ msg = msg || `target ${$el.selector || ''} should ${shouldHaveClass ? '' : 'not'} have class ${className}`;
+ var hasClass = $el.hasClass(className);
+ var condition = shouldHaveClass ? hasClass : !hasClass;
+ QUnit.assert.ok(condition, 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} w
+ * @param {boolean} shouldBeVisible
+ * @param {string} [msg]
+ */
+function _checkVisible(w, shouldBeVisible, msg) {
+ var $el = w instanceof Widget ? w.$el :
+ w instanceof HTMLElement ? $(w) :
+ w; // jquery element
+
+ if ($el.length !== 1) {
+ var descr = `${shouldBeVisible ? 'isVisible' : 'isNotVisible'}`;
+ QUnit.assert.ok(false, `Assertion '${descr}' targets ${$el.length} elements instead of 1`);
+ } else {
+ msg = msg || `target ${$el.selector} should ${shouldBeVisible ? '' : 'not'} be visible`;
+ var isVisible = $el.is(':visible');
+ var 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} w
+ * @param {string} selector
+ * @param {number} n
+ * @param {string} [msg]
+ */
+function containsN(w, selector, n, msg) {
+ if (typeof n !== 'number') {
+ throw Error("containsN assert should be called with a number as third argument");
+ }
+ var $el = w instanceof Widget ? w.$el :
+ w instanceof HTMLElement ? $(w) :
+ w; // jquery element
+
+ var $matches = $el.find(selector);
+ if (!msg) {
+ msg = `Selector '${selector}' should have exactly ${n} matches`;
+ if ($el.selector) {
+ msg += ` (inside '${$el.selector}')`;
+ }
+ }
+ QUnit.assert.strictEqual($matches.length, n, msg);
+}
+
+/**
+ * Checks that the target element (described by widget/jquery or html element)
+ * contains exactly 1 match for the selector.
+ *
+ * @param {Widget|jQuery|HTMLElement} 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)
+ * contains exactly 0 match for the selector.
+ *
+ * @param {Widget|jQuery|HTMLElement} 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)
+ * - 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} 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} 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} w
+ * @param {string} attr
+ * @param {string} value
+ * @param {string} [msg]
+ */
+function hasAttrValue(w, attr, value, msg) {
+ var $el = w instanceof Widget ? w.$el :
+ w instanceof HTMLElement ? $(w) :
+ w; // jquery element
+
+ if ($el.length !== 1) {
+ var descr = `hasAttrValue (${attr}: ${value})`;
+ QUnit.assert.ok(false, `Assertion '${descr}' targets ${$el.length} elements instead of 1`);
+ } else {
+ msg = msg || `attribute '${attr}' of target ${$el.selector || ''} 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} 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} w
+ * @param {string} [msg]
+ */
+function isNotVisible(w, msg) {
+ _checkVisible(w, false, msg);
+}
+
+//------------------------------------------------------------------------------
+// Exposed API
+//------------------------------------------------------------------------------
+
+QUnit.assert.containsOnce = containsOnce;
+QUnit.assert.containsNone = containsNone;
+QUnit.assert.containsN = containsN;
+
+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/test_utils.js b/addons/web/static/tests/helpers/test_utils.js
index ab46c05cfa8..2ecc30b3872 100644
--- a/addons/web/static/tests/helpers/test_utils.js
+++ b/addons/web/static/tests/helpers/test_utils.js
@@ -10,928 +10,18 @@ odoo.define('web.test_utils', function (require) {
* instance of a view, appended in the dom, ready to be tested.
*/
-var ActionManager = require('web.ActionManager');
var ajax = require('web.ajax');
-var basic_fields = require('web.basic_fields');
-var config = require('web.config');
-var ControlPanel = require('web.ControlPanel');
var core = require('web.core');
-var DebugManager = require('web.DebugManager');
-var dom = require('web.dom');
var session = require('web.session');
-var MockServer = require('web.MockServer');
-var Widget = require('web.Widget');
-
-var DebouncedField = basic_fields.DebouncedField;
-
-/**
- * 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);
- };
-}
-
-/**
- * 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);
- };
-}
-
-/**
- * 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 {boolean} [params.debug]
- * @param {function} [params.mockRPC]
- * @returns {ActionManager}
- */
-var createActionManager = function (params) {
- params = params || {};
- var $target = $('#qunit-fixture');
- if (params.debug) {
- $target = $('body');
- $target.addClass('debug');
- }
-
- var 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
- var mockRPC = params.mockRPC;
- _.extend(params, {
- mockRPC: function (route, args) {
- if (args.model === 'ir.attachment') {
- return $.when([]);
- }
- if (mockRPC) {
- return mockRPC.apply(this, arguments);
- }
- return this._super.apply(this, arguments);
- },
- });
- addMockEnvironment(widget, _.defaults(params, { debounce: false }));
- widget.prependTo($target);
- widget.$el.addClass('o_web_client');
- if (config.device.isMobile) {
- widget.$el.addClass('o_touch_device');
- }
-
- var userContext = params.context && params.context.user_context || {};
- var actionManager = new ActionManager(widget, userContext);
-
- var originalDestroy = ActionManager.prototype.destroy;
- actionManager.destroy = function () {
- actionManager.destroy = originalDestroy;
- widget.destroy();
- };
- actionManager.appendTo(widget.$el);
-
- return actionManager;
-};
-/**
- * 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={}]
- */
-var createDebugManager = function (params) {
- params = params || {};
- var mockRPC = params.mockRPC;
- _.extend(params, {
- mockRPC: function (route, args) {
- if (args.method === 'check_access_rights') {
- return $.when(true);
- }
- if (args.method === 'xmlid_to_res_id') {
- return $.when(true);
- }
- if (mockRPC) {
- return mockRPC.apply(this, arguments);
- }
- return this._super.apply(this, arguments);
- },
- session: {
- user_has_group: function (group) {
- if (group === 'base.group_no_one') {
- return $.when(true);
- }
- return this._super.apply(this, arguments);
- },
- },
- });
- var debugManager = new DebugManager();
- addMockEnvironment(debugManager, params);
- return debugManager;
-};
-
-/**
- * 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;
-}
-
-/**
- * create a view synchronously. This method uses the createAsyncView method.
- * Most views are synchronous, so the deferred can be resolved immediately and
- * this method will work.
- *
- * Be careful, if for some reason a view is async, this method will crash.
- * @see createAsyncView
- *
- * @param {Object} params will be given to createAsyncView
- * @returns {AbstractView}
- */
-function createView(params) {
- var view;
- createAsyncView(params).then(function (result) {
- view = result;
- });
- if (!view) {
- throw "The view that you are trying to create is async. Please use createAsyncView instead";
- }
- return view;
-}
-
-/**
- * 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.
- *
- * Most views can be tested synchronously (@see createView), but some view have
- * external dependencies (like lazy loaded libraries). In that case, it is
- * necessary to use this method.
- *
- * @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 {Object} [params.debug=false] if true, the widget will be appended in
- * the DOM. Also, RPCs and uncaught OdooEvent will be logged
- * @param {string[]} [params.groupBy] the initial groupBy for the view
- * @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
- * @returns {Deferred<AbstractView>} resolves with the instance of the view
- */
-function createAsyncView(params) {
- var $target = $('#qunit-fixture');
- var widget = new Widget();
- if (params.debug) {
- $target = $('body');
- $target.addClass('debug');
- }
-
- // add mock environment: mock server, session, fieldviewget, ...
- var mockServer = addMockEnvironment(widget, params);
- var viewInfo = fieldsViewGet(mockServer, params);
- // create the view
- var viewOptions = {
- modelName: params.model || 'foo',
- ids: 'res_id' in params ? [params.res_id] : undefined,
- currentId: 'res_id' in params ? params.res_id : undefined,
- domain: params.domain || [],
- context: params.context || {},
- groupBy: params.groupBy || [],
- };
- if (params.hasSelectors) {
- viewOptions.hasSelectors = params.hasSelectors;
- }
-
- _.extend(viewOptions, params.viewOptions);
-
- var view = new params.View(viewInfo, viewOptions);
-
- // reproduce the DOM environment of views
- var $web_client = $('<div>').addClass('o_web_client').prependTo($target);
- var controlPanel = new ControlPanel(widget);
- controlPanel.appendTo($web_client);
- var $content = $('<div>').addClass('o_content').appendTo($web_client);
-
- if (params.interceptsPropagate) {
- _.each(params.interceptsPropagate, function (cb, name) {
- intercept(widget, name, cb, true);
- });
- }
-
- return view.getController(widget).then(function (view) {
- // 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.
- view.__destroy = view.destroy;
- view.destroy = function () {
- // remove the override to properly destroy the view and its children
- // when it will be called the second time (by its parent)
- delete view.destroy;
- widget.destroy();
- };
-
- // link the view to the control panel
- view.set_cp_bus(controlPanel.get_bus());
-
- // render the view in a fragment as they must be able to render correctly
- // without being in the DOM
- var fragment = document.createDocumentFragment();
- return view.appendTo(fragment).then(function () {
- dom.append($content, fragment, {
- callbacks: [{ widget: view }],
- in_DOM: true,
- });
- view.$el.on('click', 'a', function (ev) {
- ev.preventDefault();
- });
-
- return view;
- });
- });
-}
-
-/**
- * Patch window.Date so that the time starts its flow from the provided Date.
- *
- * Usage:
- *
- * ```
- * var unpatchDate = testUtils.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
- Date.now = NativeDate.now;
- 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; };
-}
-
-/**
- * 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 {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 {MockServer} the instance of the mock server, created by this
- * function. It is necessary for createAsyncView so that method can call some
- * other methods on it.
- */
-function addMockEnvironment(widget, params) {
- var Server = MockServer;
- params.services = params.services || {};
- if (params.mockRPC) {
- Server = MockServer.extend({ _performRpc: params.mockRPC });
- }
- 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);
- }
-
- var mockServer = new Server(params.data, {
- actions: params.actions,
- archs: params.archs,
- currentDate: params.currentDate,
- debug: params.debug,
- widget: widget,
- });
-
- // make sure images do not trigger a GET on the server
- $('body').on('DOMNodeInserted.removeSRC', function (event) {
- removeSrcAttribute(event.target, widget);
- });
-
- // make sure the debounce value for input fields is set to 0
- var initialDebounceValue = DebouncedField.prototype.DEBOUNCE;
- DebouncedField.prototype.DEBOUNCE = params.fieldDebounce || 0;
- var initialSession, initialConfig, initialParameters, initialDebounce, initialThrottle;
- initialSession = _.extend({}, session);
- session.getTZOffset = function () {
- return 0; // by default, but may be overriden in specific tests
- };
- if ('session' in params) {
- _.extend(session, params.session);
- }
- if ('config' in params) {
- initialConfig = _.clone(config);
- initialConfig.device = _.clone(config.device);
- if ('device' in params.config) {
- _.extend(config.device, params.config.device);
- }
- if ('debug' in params.config) {
- config.debug = params.config.debug;
- }
- }
- if ('translateParameters' in params) {
- initialParameters = _.extend({}, core._t.database.parameters);
- _.extend(core._t.database.parameters, params.translateParameters);
- }
- if (params.debounce === false) {
- initialDebounce = _.debounce;
- _.debounce = function (func) {
- return func;
- };
- }
- if (!('throttle' in params) || !params.throttle) {
- initialThrottle = _.throttle;
- _.throttle = function (func) {
- return func;
- };
- }
-
- var widgetDestroy = widget.destroy;
- widget.destroy = function () {
- // clear the caches (e.g. data_manager, ModelFieldSelector) when the
- // widget is destroyed, at the end of each test to avoid collisions
- core.bus.trigger('clear_cache');
-
- DebouncedField.prototype.DEBOUNCE = initialDebounceValue;
- if (params.debounce === false) {
- _.debounce = initialDebounce;
- }
- if (!('throttle' in params) || !params.throttle) {
- _.throttle = initialThrottle;
- }
-
- var key;
- if ('session' in params) {
- for (key in session) {
- delete session[key];
- }
- }
- _.extend(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);
- }
-
- $('body').off('DOMNodeInserted.removeSRC');
- $('.blockUI').remove();
-
- widgetDestroy.call(this);
- };
-
- // Dispatch service calls
- // Note: some services could call other services at init,
- // Which is why we have to init services after that
- var services = {};
- intercept(widget, 'call_service', function (ev) {
- var args, result;
- if (services[ev.data.service]) {
- var service = services[ev.data.service];
- args = (ev.data.args || []);
- result = service[ev.data.method].apply(service, args);
- } else if (ev.data.service === 'ajax') {
- // use ajax service that is mocked by the server
- var route = ev.data.args[0];
- args = ev.data.args[1];
- result = mockServer.performRpc(route, args);
- }
- ev.data.callback(result);
- });
-
- intercept(widget, 'load_action', function (event) {
- mockServer.performRpc('/web/action/load', {
- kwargs: {
- action_id: event.data.actionID,
- additional_context: event.data.context,
- },
- }).then(function (action) {
- event.data.on_success(action);
- });
- });
-
- intercept(widget, "load_views", function (event) {
- mockServer.performRpc('/web/dataset/call_kw/' + event.data.modelName, {
- args: [],
- kwargs: {
- context: event.data.context,
- options: event.data.options,
- views: event.data.views,
- },
- method: 'load_views',
- model: event.data.modelName,
- }).then(function (views) {
- views = _.mapObject(views, function (viewParams) {
- return fieldsViewGet(mockServer, viewParams);
- });
- event.data.on_success(views);
- });
- });
-
- intercept(widget, "get_session", function (event) {
- event.data.callback(session);
- });
-
- intercept(widget, "load_filters", function (event) {
- if (params.debug) {
- console.log('[mock] load_filters', event.data);
- }
- event.data.on_success([]);
- });
-
- // make sure all Odoo events bubbling up are intercepted
- if ('intercepts' in params) {
- _.each(params.intercepts, function (cb, name) {
- intercept(widget, name, cb);
- });
- }
-
- // Deploy services
- var done = false;
- var servicesToDeploy = _.clone(params.services);
- if (!servicesToDeploy.ajax) {
- services.ajax = null; // use mocked ajax from mocked server
- }
- while (!done) {
- var serviceName = _.findKey(servicesToDeploy, function (Service) {
- return !_.some(Service.prototype.dependencies, function (depName) {
- return !_.has(services, depName);
- });
- });
- if (serviceName) {
- var Service = servicesToDeploy[serviceName];
- var service = services[serviceName] = new Service(widget);
- delete servicesToDeploy[serviceName];
-
- intercept(service, "get_session", function (event) {
- event.data.callback(session);
- });
-
- service.start();
- } else {
- var serviceNames = _.keys(servicesToDeploy);
- if (serviceNames.length) {
- console.warn("Non loaded services:", serviceNames);
- }
- done = true;
- }
- }
-
- return mockServer;
-}
-
-/**
- * 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}
- */
-function createModel(params) {
- var widget = new Widget();
-
- var model = new params.Model(widget);
-
- 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 {Widget}
- */
-function createParent(params) {
- var widget = new Widget();
- addMockEnvironment(widget, params);
- return widget;
-}
-
-/**
- * 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.
- *
- * @param {jqueryElement} $el
- * @param {jqueryElement} $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)
- */
-function dragAndDrop($el, $to, options) {
- var position = (options && options.position) || 'center';
- var elementCenter = $el.offset();
- var toOffset = $to.offset();
-
- if (_.isObject(position)) {
- toOffset.top += position.top;
- toOffset.left += position.left;
- } else {
- toOffset.top += $to.outerHeight() / 2;
- toOffset.left += $to.outerWidth() / 2;
- var 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
- var bound = $('iframe')[0].getBoundingClientRect();
- toOffset.left += bound.left;
- toOffset.top += bound.top;
- }
- $el.trigger($.Event("mouseenter"));
- if (!(options && options.continueMove)) {
- elementCenter.left += $el.outerWidth() / 2;
- elementCenter.top += $el.outerHeight() / 2;
-
- $el.trigger($.Event("mousedown", {
- which: 1,
- pageX: elementCenter.left,
- pageY: elementCenter.top
- }));
- }
-
- $el.trigger($.Event("mousemove", {
- which: 1,
- pageX: toOffset.left,
- pageY: toOffset.top
- }));
-
- if (!(options && options.disableDrop)) {
- $el.trigger($.Event("mouseup", {
- which: 1,
- pageX: toOffset.left,
- pageY: toOffset.top
- }));
- } 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 () {
- $el.trigger($.Event("mouseup"));
- });
- }
-}
-
-/**
- * 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 {jqueryElement} $el
- * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove'
- */
-function triggerMouseEvent($el, type) {
- var pos = $el.offset();
- var e = new $.Event(type);
- e.pageX = e.layerX = e.screenX = pos.left;
- e.pageY = e.layerY = e.screenY = pos.top;
- e.which = 1;
- $el.trigger(e);
-}
-
-/**
- * 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'
- */
-function triggerPositionalMouseEvent(x, y, type) {
- var ev = document.createEvent("MouseEvent");
- var 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;
-}
-
-/**
- * simulate a keypress event for a given character
- * @param {string} the character
- */
-function triggerKeypressEvent(char) {
- var keycode;
- if (char === "Enter") {
- keycode = $.ui.keyCode.ENTER;
- } else {
- keycode = char.charCodeAt(0);
- }
- return $('body').trigger($.Event('keypress', { which: keycode, keyCode: keycode }));
-}
-
-/**
- * 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 {DOM Node} el
- * @param {[Widget]} widget the widget on which the rpc should be performed
- */
-function removeSrcAttribute(el, widget) {
- var nodes;
- 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') {
- var $el = $(node);
- node.setAttribute('data-src', src);
- if (node.nodeName === 'IMG') {
- node.attributes.removeNamedItem('src');
- } else {
- node.setAttribute('src', 'about:blank');
- }
- if (widget) {
- widget._rpc({ route: src });
- }
- }
- }
-}
-
-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;
-}
-
-/**
- * Opens the datepicker of a given element.
- *
- * @param {jQuery} $datepickerEl element to which a datepicker is attached
- */
-function openDatepicker($datepickerEl) {
- $datepickerEl.find('.o_datepicker_input').trigger('focus.datetimepicker');
-}
+var testUtilsCreate = require('web.test_utils_create');
+var testUtilsDom = require('web.test_utils_dom');
+var testUtilsFields = require('web.test_utils_fields');
+var testUtilsForm = require('web.test_utils_form');
+var testUtilsGraph = require('web.test_utils_graph');
+var testUtilsKanban = require('web.test_utils_kanban');
+var testUtilsMock = require('web.test_utils_mock');
+var testUtilsModal = require('web.test_utils_modal');
+var testUtilsPivot = require('web.test_utils_pivot');
// 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,
@@ -948,25 +38,67 @@ return $.when(
QUnit.start();
}, 0);
return {
- addMockEnvironment: addMockEnvironment,
- createActionManager: createActionManager,
- createDebugManager: createDebugManager,
- createAsyncView: createAsyncView,
- createModel: createModel,
- createParent: createParent,
- createView: createView,
- dragAndDrop: dragAndDrop,
- fieldsViewGet: fieldsViewGet,
- intercept: intercept,
- observe: observe,
- openDatepicker: openDatepicker,
- patch: patch,
- patchDate: patchDate,
- removeSrcAttribute: removeSrcAttribute,
- triggerKeypressEvent: triggerKeypressEvent,
- triggerMouseEvent: triggerMouseEvent,
- triggerPositionalMouseEvent: triggerPositionalMouseEvent,
- unpatch: unpatch,
+ mock: {
+ addMockEnvironment: testUtilsMock.addMockEnvironment,
+ intercept: testUtilsMock.intercept,
+ patch: testUtilsMock.patch,
+ patchDate: testUtilsMock.patchDate,
+ unpatch: testUtilsMock.unpatch,
+ fieldsViewGet: testUtilsMock.fieldsViewGet,
+ },
+ dom: {
+ triggerKeypressEvent: testUtilsDom.triggerKeypressEvent,
+ triggerMouseEvent: testUtilsDom.triggerMouseEvent,
+ triggerPositionalMouseEvent: testUtilsDom.triggerPositionalMouseEvent,
+ dragAndDrop: testUtilsDom.dragAndDrop,
+ openDatepicker: testUtilsDom.openDatepicker,
+ click: testUtilsDom.click,
+ clickFirst: testUtilsDom.clickFirst,
+ clickLast: testUtilsDom.clickLast,
+ },
+ 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: {
+ clickOpenDropdown: testUtilsFields.clickOpenM2ODropdown,
+ clickHighlightedItem: testUtilsFields.clickM2OHighlightedItem,
+ clickItem: testUtilsFields.clickM2OItem,
+ searchAndClickItem: testUtilsFields.searchAndClickM2OItem,
+ },
+ editInput: testUtilsFields.editInput,
+ editSelect: testUtilsFields.editSelect,
+ editAndTrigger: testUtilsFields.editAndTrigger,
+ },
+
+ createActionManager: testUtilsCreate.createActionManager,
+ createDebugManager: testUtilsCreate.createDebugManager,
+ createAsyncView: testUtilsCreate.createAsyncView,
+ createView: testUtilsCreate.createView,
+ createModel: testUtilsCreate.createModel,
+ createParent: testUtilsCreate.createParent,
};
});
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 00000000000..d4d4e2a6b55
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_create.js
@@ -0,0 +1,288 @@
+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.
+ */
+
+var ActionManager = require('web.ActionManager');
+var config = require('web.config');
+var ControlPanel = require('web.ControlPanel');
+var dom = require('web.dom');
+var DebugManager = require('web.DebugManager');
+var testUtilsMock = require('web.test_utils_mock');
+var Widget = require('web.Widget');
+
+/**
+ * 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 {boolean} [params.debug]
+ * @param {function} [params.mockRPC]
+ * @returns {ActionManager}
+ */
+function createActionManager (params) {
+ params = params || {};
+ var $target = $('#qunit-fixture');
+ if (params.debug) {
+ $target = $('body');
+ $target.addClass('debug');
+ }
+
+ var 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
+ var mockRPC = params.mockRPC;
+ _.extend(params, {
+ mockRPC: function (route, args) {
+ if (args.model === 'ir.attachment') {
+ return $.when([]);
+ }
+ if (mockRPC) {
+ return mockRPC.apply(this, arguments);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+ testUtilsMock.addMockEnvironment(widget, _.defaults(params, { debounce: false }));
+ widget.prependTo($target);
+ widget.$el.addClass('o_web_client');
+ if (config.device.isMobile) {
+ widget.$el.addClass('o_touch_device');
+ }
+
+ var userContext = params.context && params.context.user_context || {};
+ var actionManager = new ActionManager(widget, userContext);
+
+ var originalDestroy = ActionManager.prototype.destroy;
+ actionManager.destroy = function () {
+ actionManager.destroy = originalDestroy;
+ widget.destroy();
+ };
+ actionManager.appendTo(widget.$el);
+
+ return actionManager;
+}
+
+/**
+ * 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.
+ *
+ * Most views can be tested synchronously (@see createView), but some view have
+ * external dependencies (like lazy loaded libraries). In that case, it is
+ * necessary to use this method.
+ *
+ * @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 {Object} [params.debug=false] if true, the widget will be appended in
+ * the DOM. Also, RPCs and uncaught OdooEvent will be logged
+ * @param {string[]} [params.groupBy] the initial groupBy for the view
+ * @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
+ * @returns {Deferred<AbstractView>} resolves with the instance of the view
+ */
+function createAsyncView(params) {
+ var $target = $('#qunit-fixture');
+ var widget = new Widget();
+ if (params.debug) {
+ $target = $('body');
+ $target.addClass('debug');
+ }
+
+ // add mock environment: mock server, session, fieldviewget, ...
+ var mockServer = testUtilsMock.addMockEnvironment(widget, params);
+ var viewInfo = testUtilsMock.fieldsViewGet(mockServer, params);
+ // create the view
+ var viewOptions = {
+ modelName: params.model || 'foo',
+ ids: 'res_id' in params ? [params.res_id] : undefined,
+ currentId: 'res_id' in params ? params.res_id : undefined,
+ domain: params.domain || [],
+ context: params.context || {},
+ groupBy: params.groupBy || [],
+ };
+ if (params.hasSelectors) {
+ viewOptions.hasSelectors = params.hasSelectors;
+ }
+
+ _.extend(viewOptions, params.viewOptions);
+
+ var view = new params.View(viewInfo, viewOptions);
+
+ // reproduce the DOM environment of views
+ var $web_client = $('<div>').addClass('o_web_client').prependTo($target);
+ var controlPanel = new ControlPanel(widget);
+ controlPanel.appendTo($web_client);
+ var $content = $('<div>').addClass('o_content').appendTo($web_client);
+
+ if (params.interceptsPropagate) {
+ _.each(params.interceptsPropagate, function (cb, name) {
+ testUtilsMock.intercept(widget, name, cb, true);
+ });
+ }
+
+ return view.getController(widget).then(function (view) {
+ // 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.
+ view.__destroy = view.destroy;
+ view.destroy = function () {
+ // remove the override to properly destroy the view and its children
+ // when it will be called the second time (by its parent)
+ delete view.destroy;
+ widget.destroy();
+ };
+
+ // link the view to the control panel
+ view.set_cp_bus(controlPanel.get_bus());
+
+ // render the view in a fragment as they must be able to render correctly
+ // without being in the DOM
+ var fragment = document.createDocumentFragment();
+ return view.appendTo(fragment).then(function () {
+ dom.append($content, fragment, {
+ callbacks: [{ widget: view }],
+ in_DOM: true,
+ });
+ view.$el.on('click', 'a', function (ev) {
+ ev.preventDefault();
+ });
+
+ return view;
+ });
+ });
+}
+
+/**
+ * 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={}]
+ */
+function createDebugManager (params) {
+ params = params || {};
+ var mockRPC = params.mockRPC;
+ _.extend(params, {
+ mockRPC: function (route, args) {
+ if (args.method === 'check_access_rights') {
+ return $.when(true);
+ }
+ if (args.method === 'xmlid_to_res_id') {
+ return $.when(true);
+ }
+ if (mockRPC) {
+ return mockRPC.apply(this, arguments);
+ }
+ return this._super.apply(this, arguments);
+ },
+ session: {
+ user_has_group: function (group) {
+ if (group === 'base.group_no_one') {
+ return $.when(true);
+ }
+ return this._super.apply(this, arguments);
+ },
+ },
+ });
+ var debugManager = new DebugManager();
+ 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}
+ */
+function createModel(params) {
+ var widget = new Widget();
+
+ var model = new params.Model(widget);
+
+ 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 {Widget}
+ */
+function createParent(params) {
+ var widget = new Widget();
+ testUtilsMock.addMockEnvironment(widget, params);
+ return widget;
+}
+
+/**
+ * create a view synchronously. This method uses the createAsyncView method.
+ * Most views are synchronous, so the deferred can be resolved immediately and
+ * this method will work.
+ *
+ * Be careful, if for some reason a view is async, this method will crash.
+ * @see createAsyncView
+ *
+ * @param {Object} params will be given to createAsyncView
+ * @returns {AbstractView}
+ */
+function createView(params) {
+ var view;
+ createAsyncView(params).then(function (result) {
+ view = result;
+ });
+ if (!view) {
+ throw "The view that you are trying to create is async. Please use createAsyncView instead";
+ }
+ return view;
+}
+
+
+return {
+ createActionManager: createActionManager,
+ createAsyncView: createAsyncView,
+ createDebugManager: createDebugManager,
+ createModel: createModel,
+ createParent: createParent,
+ createView: createView,
+};
+
+});
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 00000000000..b89f5a8eb5f
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_dom.js
@@ -0,0 +1,235 @@
+odoo.define('web.test_utils_dom', function () {
+"use strict";
+
+/**
+ * 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.
+ */
+
+
+/**
+ * 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.
+ *
+ * @param {jqueryElement} $el
+ * @param {jqueryElement} $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)
+ */
+function dragAndDrop($el, $to, options) {
+ var position = (options && options.position) || 'center';
+ var elementCenter = $el.offset();
+ var toOffset = $to.offset();
+
+ if (_.isObject(position)) {
+ toOffset.top += position.top;
+ toOffset.left += position.left;
+ } else {
+ toOffset.top += $to.outerHeight() / 2;
+ toOffset.left += $to.outerWidth() / 2;
+ var 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
+ var bound = $('iframe')[0].getBoundingClientRect();
+ toOffset.left += bound.left;
+ toOffset.top += bound.top;
+ }
+ $el.trigger($.Event("mouseenter"));
+ if (!(options && options.continueMove)) {
+ elementCenter.left += $el.outerWidth() / 2;
+ elementCenter.top += $el.outerHeight() / 2;
+
+ $el.trigger($.Event("mousedown", {
+ which: 1,
+ pageX: elementCenter.left,
+ pageY: elementCenter.top
+ }));
+ }
+
+ $el.trigger($.Event("mousemove", {
+ which: 1,
+ pageX: toOffset.left,
+ pageY: toOffset.top
+ }));
+
+ if (!(options && options.disableDrop)) {
+ $el.trigger($.Event("mouseup", {
+ which: 1,
+ pageX: toOffset.left,
+ pageY: toOffset.top
+ }));
+ } 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 () {
+ $el.trigger($.Event("mouseup"));
+ });
+ }
+}
+
+/**
+ * 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 {jqueryElement} $el
+ * @param {string} type a mouse event type, such as 'mousedown' or 'mousemove'
+ */
+function triggerMouseEvent($el, type) {
+ var pos = $el.offset();
+ var e = new $.Event(type);
+ e.pageX = e.layerX = e.screenX = pos.left;
+ e.pageY = e.layerY = e.screenY = pos.top;
+ e.which = 1;
+ $el.trigger(e);
+}
+
+/**
+ * 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'
+ */
+function triggerPositionalMouseEvent(x, y, type) {
+ var ev = document.createEvent("MouseEvent");
+ var 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;
+}
+
+/**
+ * simulate a keypress event for a given character
+ *
+ * @param {string} char the character, or 'ENTER'
+ */
+function triggerKeypressEvent(char) {
+ var keycode;
+ if (char === "Enter") {
+ keycode = $.ui.keyCode.ENTER;
+ } else {
+ keycode = char.charCodeAt(0);
+ }
+ return $('body').trigger($.Event('keypress', { which: keycode, keyCode: keycode }));
+}
+
+/**
+ * Opens the datepicker of a given element.
+ *
+ * @param {jQuery} $datepickerEl element to which a datepicker is attached
+ */
+function openDatepicker($datepickerEl) {
+ $datepickerEl.find('.o_datepicker_input').trigger('focus.datetimepicker');
+}
+
+/**
+ * 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|NodeList|jQuery} el (if string: it is a (jquery) selector)
+ * @param {Object} [options]
+ * @param {boolean} [options.first=false] if true, clicks on the first element
+ * @param {boolean} [options.last=false] if true, clicks on the last element
+ */
+function click(el, options) {
+ options = options || {};
+ var matches = typeof el === 'string' ? $(el) : el;
+ var selectorMsg = el.selector ? `(selector: ${el.selector})` : '';
+ if (!matches.filter) { // it might be an array of dom elements
+ matches = $(matches);
+ }
+
+ var visibleMatches = matches.filter(':visible');
+ if (options.first) {
+ if (visibleMatches.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');
+ }
+ visibleMatches = visibleMatches.first();
+ } else if (options.last) {
+ if (visibleMatches.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');
+ }
+ visibleMatches = visibleMatches.last();
+ }
+ if (visibleMatches.length === 0 && matches.length > 0) {
+ throw new Error(`Element to click on is not visible ${selectorMsg}`);
+ } else if (visibleMatches.length !== 1) {
+ throw new Error(`Found ${visibleMatches.length} elements to click on, instead of 1 ${selectorMsg}`);
+ }
+ visibleMatches.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|NodeList|jQuery} el (if string: it is a (jquery) selector)
+ */
+function clickFirst(el) {
+ click(el, {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|NodeList|jQuery} el
+ */
+function clickLast(el) {
+ click(el, {last: true});
+}
+
+return {
+ triggerKeypressEvent: triggerKeypressEvent,
+ triggerMouseEvent: triggerMouseEvent,
+ triggerPositionalMouseEvent: triggerPositionalMouseEvent,
+ dragAndDrop: dragAndDrop,
+ openDatepicker: openDatepicker,
+ click: click,
+ clickFirst: clickFirst,
+ clickLast: clickLast,
+};
+
+});
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 00000000000..f7d8829ce82
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_fields.js
@@ -0,0 +1,172 @@
+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.
+ */
+
+var concurrency = require('web.concurrency');
+
+//------------------------------------------------------------------------------
+// Public functions
+//------------------------------------------------------------------------------
+
+/**
+ * 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} $el should target an input, textarea or select
+ * @param {string|number} value
+ * @param {string[]} events
+ */
+function editAndTrigger($el, value, events) {
+ if ($el.length !== 1) {
+ throw new Error(`target ${$el.selector} has length ${$el.length} instead of 1`);
+ }
+ $el.val(value);
+ events.forEach(function (event) {
+ $el.trigger(event);
+ });
+}
+
+/**
+ * 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} $el
+ * @param {string|number} value
+ */
+function editInput($el, value) {
+ 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} $el
+ * @param {string|number} value
+ */
+function editSelect($el, value) {
+ editAndTrigger($el, value, ['change']);
+}
+
+/**
+ * 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 {HTMLInputElement} the main many2one input
+ */
+function clickOpenM2ODropdown(fieldName, selector) {
+ var m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`;
+ var matches = document.querySelectorAll(m2oSelector);
+ if (matches.length !== 1) {
+ throw new Error(`cannot open m2o: selector ${selector} has been found ${matches.length} instead of 1`);
+ }
+ matches[0].click();
+ return matches[0];
+}
+
+/**
+ * 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
+ */
+function clickM2OHighlightedItem(fieldName, selector) {
+ var m2oSelector = `${selector || ''} .o_field_many2one[name=${fieldName}] input`;
+ var $dropdown = $(m2oSelector).autocomplete('widget');
+ // clicking on an li (no matter which one), will select the focussed one
+ $dropdown.find('li:first()').click();
+}
+
+/**
+ * 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
+ */
+function clickM2OItem(fieldName, searchText) {
+ var m2oSelector = `.o_field_many2one[name=${fieldName}] input`;
+ var $dropdown = $(m2oSelector).autocomplete('widget');
+ var $target = $dropdown.find(`li:contains(${searchText})`).first();
+ if ($target.length !== 1 || !$target.is(':visible')) {
+ throw new Error('Menu item should be unique and visible');
+ }
+ $target.mouseenter().click();
+}
+
+/**
+ * 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 active menuitem
+ *
+ * Example:
+ * testUtils.fields.many2one.searchAndClickM2OItem('partner_id', 'George');
+ *
+ * @param {string} fieldName
+ * @param {[Object]} options
+ * @param {[string]} options.selector
+ * @param {[string]} options.search
+ * @returns {Promise}
+ */
+function searchAndClickM2OItem(fieldName, options) {
+ options = options || {};
+
+ var input = clickOpenM2ODropdown(fieldName, options.selector);
+
+ var def;
+ if (options.search) {
+ // 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
+ var $input = $(input);
+ var delay = $input.autocomplete('option', 'delay');
+ $input.autocomplete('option', 'delay', 0);
+ input.value = options.search;
+ input.dispatchEvent(new Event('input'));
+ $input.autocomplete('option', 'delay', delay);
+ def = concurrency.delay(0);
+ }
+
+ return $.when(def).then(function () {
+ clickM2OHighlightedItem(fieldName, options.selector);
+ });
+}
+return {
+ clickOpenM2ODropdown: clickOpenM2ODropdown,
+ clickM2OHighlightedItem: clickM2OHighlightedItem,
+ clickM2OItem: clickM2OItem,
+ editAndTrigger: editAndTrigger,
+ editInput: editInput,
+ editSelect: editSelect,
+ searchAndClickM2OItem: searchAndClickM2OItem,
+};
+
+});
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 00000000000..379350e1ca6
--- /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) {
+ 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) {
+ 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) {
+ 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) {
+ 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) {
+ 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 00000000000..8867f82c349
--- /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) {
+ 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 00000000000..166ae2a2572
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_kanban.js
@@ -0,0 +1,97 @@
+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
+ */
+function clickCreate(kanban) {
+ testUtilsDom.click(kanban.$buttons.find('.o-kanban-button-new'));
+}
+
+/**
+ * Open the settings menu for a column (in a grouped kanban view)
+ *
+ * @param {jQuery} $column
+ */
+function toggleGroupSettings($column) {
+ var $dropdownToggler = $column.find('.o_kanban_config > a.dropdown-toggle');
+ if (!$dropdownToggler.is(':visible')) {
+ $dropdownToggler.css('display', 'block');
+ }
+ 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
+ */
+function quickCreate(kanban, value, fieldName) {
+ var additionalSelector = fieldName ? ('[name=' + fieldName + ']'): '';
+ var enterEvent = $.Event(
+ 'keydown',
+ {
+ which: $.ui.keyCode.ENTER,
+ keyCode: $.ui.keyCode.ENTER,
+ }
+ );
+ 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
+ */
+function reload(kanban, params) {
+ 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
+ */
+function toggleRecordDropdown($record) {
+ var $dropdownToggler = $record.find('.o_dropdown_kanban > a.dropdown-toggle');
+ if (!$dropdownToggler.is(':visible')) {
+ $dropdownToggler.css('display', 'block');
+ }
+ 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 00000000000..769c179472a
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_mock.js
@@ -0,0 +1,545 @@
+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.
+ */
+
+var basic_fields = require('web.basic_fields');
+var config = require('web.config');
+var core = require('web.core');
+var MockServer = require('web.MockServer');
+var session = require('web.session');
+
+var DebouncedField = basic_fields.DebouncedField;
+
+//------------------------------------------------------------------------------
+// Private functions
+//------------------------------------------------------------------------------
+
+/**
+ * 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
+//------------------------------------------------------------------------------
+
+/**
+ * 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 {DOM Node} el
+ * @param {[Widget]} widget the widget on which the rpc should be performed
+ */
+function removeSrcAttribute(el, widget) {
+ var nodes;
+ 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 (widget) {
+ widget._rpc({ route: src });
+ }
+ }
+ }
+}
+
+/**
+ * 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 {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 {MockServer} the instance of the mock server, created by this
+ * function. It is necessary for createAsyncView so that method can call some
+ * other methods on it.
+ */
+function addMockEnvironment(widget, params) {
+ var Server = MockServer;
+ params.services = params.services || {};
+ if (params.mockRPC) {
+ Server = MockServer.extend({ _performRpc: params.mockRPC });
+ }
+ 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);
+ }
+
+ var mockServer = new Server(params.data, {
+ actions: params.actions,
+ archs: params.archs,
+ currentDate: params.currentDate,
+ debug: params.debug,
+ widget: widget,
+ });
+
+ // make sure images do not trigger a GET on the server
+ $('body').on('DOMNodeInserted.removeSRC', function (event) {
+ removeSrcAttribute(event.target, widget);
+ });
+
+ // make sure the debounce value for input fields is set to 0
+ var initialDebounceValue = DebouncedField.prototype.DEBOUNCE;
+ DebouncedField.prototype.DEBOUNCE = params.fieldDebounce || 0;
+ var initialSession, initialConfig, initialParameters, initialDebounce, initialThrottle;
+ initialSession = _.extend({}, session);
+ session.getTZOffset = function () {
+ return 0; // by default, but may be overriden in specific tests
+ };
+ if ('session' in params) {
+ _.extend(session, params.session);
+ }
+ if ('config' in params) {
+ initialConfig = _.clone(config);
+ initialConfig.device = _.clone(config.device);
+ if ('device' in params.config) {
+ _.extend(config.device, params.config.device);
+ }
+ if ('debug' in params.config) {
+ config.debug = params.config.debug;
+ }
+ }
+ if ('translateParameters' in params) {
+ initialParameters = _.extend({}, core._t.database.parameters);
+ _.extend(core._t.database.parameters, params.translateParameters);
+ }
+ if (params.debounce === false) {
+ initialDebounce = _.debounce;
+ _.debounce = function (func) {
+ return func;
+ };
+ }
+ if (!('throttle' in params) || !params.throttle) {
+ initialThrottle = _.throttle;
+ _.throttle = function (func) {
+ return func;
+ };
+ }
+
+ var widgetDestroy = widget.destroy;
+ widget.destroy = function () {
+ // clear the caches (e.g. data_manager, ModelFieldSelector) when the
+ // widget is destroyed, at the end of each test to avoid collisions
+ core.bus.trigger('clear_cache');
+
+ DebouncedField.prototype.DEBOUNCE = initialDebounceValue;
+ if (params.debounce === false) {
+ _.debounce = initialDebounce;
+ }
+ if (!('throttle' in params) || !params.throttle) {
+ _.throttle = initialThrottle;
+ }
+
+ var key;
+ if ('session' in params) {
+ for (key in session) {
+ delete session[key];
+ }
+ }
+ _.extend(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);
+ }
+
+ $('body').off('DOMNodeInserted.removeSRC');
+ $('.blockUI').remove();
+
+ widgetDestroy.call(this);
+ };
+
+ // Dispatch service calls
+ // Note: some services could call other services at init,
+ // Which is why we have to init services after that
+ var services = {};
+ intercept(widget, 'call_service', function (ev) {
+ var args, result;
+ if (services[ev.data.service]) {
+ var service = services[ev.data.service];
+ args = (ev.data.args || []);
+ result = service[ev.data.method].apply(service, args);
+ } else if (ev.data.service === 'ajax') {
+ // use ajax service that is mocked by the server
+ var route = ev.data.args[0];
+ args = ev.data.args[1];
+ result = mockServer.performRpc(route, args);
+ }
+ ev.data.callback(result);
+ });
+
+ intercept(widget, 'load_action', function (event) {
+ mockServer.performRpc('/web/action/load', {
+ kwargs: {
+ action_id: event.data.actionID,
+ additional_context: event.data.context,
+ },
+ }).then(function (action) {
+ event.data.on_success(action);
+ });
+ });
+
+ intercept(widget, "load_views", function (event) {
+ mockServer.performRpc('/web/dataset/call_kw/' + event.data.modelName, {
+ args: [],
+ kwargs: {
+ context: event.data.context,
+ options: event.data.options,
+ views: event.data.views,
+ },
+ method: 'load_views',
+ model: event.data.modelName,
+ }).then(function (views) {
+ views = _.mapObject(views, function (viewParams) {
+ return fieldsViewGet(mockServer, viewParams);
+ });
+ event.data.on_success(views);
+ });
+ });
+
+ intercept(widget, "get_session", function (event) {
+ event.data.callback(session);
+ });
+
+ intercept(widget, "load_filters", function (event) {
+ if (params.debug) {
+ console.log('[mock] load_filters', event.data);
+ }
+ event.data.on_success([]);
+ });
+
+ // make sure all Odoo events bubbling up are intercepted
+ if ('intercepts' in params) {
+ _.each(params.intercepts, function (cb, name) {
+ intercept(widget, name, cb);
+ });
+ }
+
+ // Deploy services
+ var done = false;
+ var servicesToDeploy = _.clone(params.services);
+ if (!servicesToDeploy.ajax) {
+ services.ajax = null; // use mocked ajax from mocked server
+ }
+ while (!done) {
+ var serviceName = _.findKey(servicesToDeploy, function (Service) {
+ return !_.some(Service.prototype.dependencies, function (depName) {
+ return !_.has(services, depName);
+ });
+ });
+ if (serviceName) {
+ var Service = servicesToDeploy[serviceName];
+ var service = services[serviceName] = new Service(widget);
+ delete servicesToDeploy[serviceName];
+
+ intercept(service, "get_session", function (event) {
+ event.data.callback(session);
+ });
+
+ service.start();
+ } else {
+ var serviceNames = _.keys(servicesToDeploy);
+ if (serviceNames.length) {
+ console.warn("Non loaded services:", serviceNames);
+ }
+ done = true;
+ }
+ }
+
+ return mockServer;
+}
+
+/**
+ * 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);
+ };
+}
+
+/**
+ * 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
+ Date.now = NativeDate.now;
+ 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;
+}
+
+
+return {
+ addMockEnvironment: addMockEnvironment,
+ fieldsViewGet: fieldsViewGet,
+ intercept: intercept,
+ patchDate: patchDate,
+ patch: patch,
+ unpatch: unpatch,
+};
+
+});
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 00000000000..d2162aebfa0
--- /dev/null
+++ b/addons/web/static/tests/helpers/test_utils_modal.js
@@ -0,0 +1,34 @@
+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.
+ */
+
+var core = require('web.core');
+
+/**
+ * Click on a button in the footer of a modal (which contains a given string).
+ * Note that this method checks the unicity of the button.
+ *
+ * @param {string} text (in english: this method will perform the translation)
+ */
+function clickButton(text) {
+ var selector = `.modal-footer button:contains(${core._t(text)})`;
+ var $button = $(selector);
+ if ($button.length !== 1) {
+ throw new Error(`Found ${$button.length} button(s) containing '${text}'`);
+ }
+ $button.click();
+}
+
+return {
+ clickButton: 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 00000000000..18a5a7307f3
--- /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) {
+ 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) {
+ 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) {
+ pivot.reload(params);
+}
+
+return {
+ clickMeasure: clickMeasure,
+ reload: reload,
+ toggleMeasuresDropdown: toggleMeasuresDropdown,
+};
+
+});